Add version history UI: list, view, diff, restore (#42)
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Successful in 2m41s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Successful in 2m41s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
Users can see who changed what and restore old states (ADR 0013). - shared: dependency-free word-level Markdown diff (diffMarkdown) with a unit test; PageVersionContentView; PAGE_RESTORE_CHANNEL + PageRestoreRequest. - api: GET /pages/:id/versions (list), GET .../:versionId (read-only HTML + Markdown for diffing), POST .../:versionId/restore. Every route requires write access — viewing history is gated like editing (permissions.md). Restore checks permission, then emits the page_restore NOTIFY; history is append-only (the api never deletes a version). - collab: a page_restore listener applies the restore on the live document via openDirectConnection — it snapshots the current state as a PRE_RESTORE version, then replaces the content in one transaction, so every connected client converges and the change persists like a normal edit. - web: HistoryPanel (version list with time/trigger/label/contributors, a read-only render of a selected version, a Markdown diff against the current page, and a restore action), toggled from the page menu. de+en strings. Tests: shared diff (added/removed/round-trip/edges); collab restore DB test (a connected client converges on the restored content; a pre-restore snapshot is appended alongside the original — append-only); api list/get/restore (newest-first, rendered content, write-permission gate, restore returns the target without mutating history). This completes M3 (real-time collaboration & history, #33–#42). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
parent
6fb6f6fce7
commit
1bda137ca4
@ -1,16 +1,40 @@
|
||||
import { Body, Controller, Param, Post, Req } from '@nestjs/common';
|
||||
import { CreateVersionInput, PageVersionView, createVersionInputSchema } from '@dorfteich/shared';
|
||||
import { Body, Controller, Get, Param, Post, Req } from '@nestjs/common';
|
||||
import {
|
||||
CreateVersionInput,
|
||||
PageVersionContentView,
|
||||
PageVersionView,
|
||||
createVersionInputSchema,
|
||||
} from '@dorfteich/shared';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { VersionsService } from './versions.service';
|
||||
|
||||
/** Page version history (issue #41, ADR 0013). List/get/restore arrive with #42. */
|
||||
/**
|
||||
* Page version history (issue #41/#42, ADR 0013). Every route requires write
|
||||
* access to the page — viewing history is gated like editing (permissions.md).
|
||||
*/
|
||||
@Controller()
|
||||
export class VersionsController {
|
||||
constructor(private readonly versions: VersionsService) {}
|
||||
|
||||
/** Create a named version of the page. Requires write access. */
|
||||
/** List the page's versions, newest first. */
|
||||
@Get('pages/:id/versions')
|
||||
async list(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageVersionView[]> {
|
||||
return this.versions.list(request.user!, id);
|
||||
}
|
||||
|
||||
/** A single version rendered read-only, with Markdown for diffing. */
|
||||
@Get('pages/:id/versions/:versionId')
|
||||
async getContent(
|
||||
@Param('id') id: string,
|
||||
@Param('versionId') versionId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PageVersionContentView> {
|
||||
return this.versions.getContent(request.user!, id, versionId);
|
||||
}
|
||||
|
||||
/** Create a named version of the page. */
|
||||
@Post('pages/:id/versions')
|
||||
async createNamed(
|
||||
@Param('id') id: string,
|
||||
@ -19,4 +43,14 @@ export class VersionsController {
|
||||
): Promise<PageVersionView> {
|
||||
return this.versions.createNamed(request.user!, id, input);
|
||||
}
|
||||
|
||||
/** Restore the page to an earlier version (creates a pre-restore snapshot). */
|
||||
@Post('pages/:id/versions/:versionId/restore')
|
||||
async restore(
|
||||
@Param('id') id: string,
|
||||
@Param('versionId') versionId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PageVersionView> {
|
||||
return this.versions.restore(request.user!, id, versionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,6 +107,49 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
|
||||
expect(await prisma.pagePendingContributor.count({ where: { pageId } })).toBe(0);
|
||||
});
|
||||
|
||||
it('lists versions newest first and renders a version read-only', async () => {
|
||||
const pageId = await createPage();
|
||||
const first = await versions.createNamed(owner, pageId, { label: 'first' });
|
||||
const second = await versions.createNamed(owner, pageId, { label: 'second' });
|
||||
|
||||
const list = await versions.list(owner, pageId);
|
||||
expect(list.map((v) => v.id)).toEqual([second.id, first.id]); // newest first
|
||||
|
||||
const content = await versions.getContent(owner, pageId, first.id);
|
||||
expect(content.id).toBe(first.id);
|
||||
expect(typeof content.html).toBe('string');
|
||||
expect(typeof content.markdown).toBe('string');
|
||||
});
|
||||
|
||||
it('gates list, content, and restore on write access', async () => {
|
||||
const pageId = await createPage();
|
||||
const version = await versions.createNamed(owner, pageId, { label: 'v' });
|
||||
|
||||
await expect(versions.list(outsider, pageId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(versions.getContent(outsider, pageId, version.id)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(versions.restore(outsider, pageId, version.id)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('restore returns the target version and emits without mutating history', async () => {
|
||||
const pageId = await createPage();
|
||||
const version = await versions.createNamed(owner, pageId, { label: 'target' });
|
||||
const before = await prisma.pageVersion.count({ where: { pageId } });
|
||||
|
||||
// The api side only checks permission and signals collab; it must not delete
|
||||
// or alter any version (history is append-only — collab appends pre-restore).
|
||||
const restored = await versions.restore(owner, pageId, version.id);
|
||||
expect(restored.id).toBe(version.id);
|
||||
expect(await prisma.pageVersion.count({ where: { pageId } })).toBe(before);
|
||||
|
||||
await expect(versions.restore(owner, pageId, randomUUID())).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a named version for a user without write access', async () => {
|
||||
const pageId = await createPage();
|
||||
await expect(versions.createNamed(outsider, pageId, { label: 'nope' })).rejects.toBeInstanceOf(
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateVersionInput, PageVersionTrigger, PageVersionView } from '@dorfteich/shared';
|
||||
import { PageVersion, PageVersionTrigger as PrismaTrigger, User } from '@prisma/client';
|
||||
import {
|
||||
CreateVersionInput,
|
||||
PAGE_RESTORE_CHANNEL,
|
||||
PageRestoreRequest,
|
||||
PageVersionContentView,
|
||||
PageVersionTrigger,
|
||||
PageVersionView,
|
||||
} from '@dorfteich/shared';
|
||||
import { Page, PageVersion, PageVersionTrigger as PrismaTrigger, Pond, User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { deriveContent } from '../pages/yjs-content';
|
||||
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@ -49,6 +57,70 @@ export class VersionsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page and assert the user may edit it. Viewing history requires the
|
||||
* same permission as editing (permissions.md §UI obligations / ADR 0013), so
|
||||
* every history operation gates on `assertCanModify`.
|
||||
*/
|
||||
private async findModifiablePage(user: User, pageId: string): Promise<Page & { pond: Pond }> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: { pond: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
this.access.assertCanModify(user, page.pond);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** The page's versions, newest first (no snapshot bytes). Write access only. */
|
||||
async list(user: User, pageId: string): Promise<PageVersionView[]> {
|
||||
await this.findModifiablePage(user, pageId);
|
||||
const versions = await this.prisma.pageVersion.findMany({
|
||||
where: { pageId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
// Exclude the (potentially large) snapshot bytes from the list.
|
||||
omit: { ydocSnapshot: true },
|
||||
});
|
||||
return versions.map((version) => this.viewOf(version));
|
||||
}
|
||||
|
||||
/** A single version rendered read-only (HTML) with its Markdown for diffing. */
|
||||
async getContent(user: User, pageId: string, versionId: string): Promise<PageVersionContentView> {
|
||||
await this.findModifiablePage(user, pageId);
|
||||
const version = await this.prisma.pageVersion.findFirst({
|
||||
where: { id: versionId, pageId },
|
||||
});
|
||||
if (!version) throw new NotFoundException();
|
||||
const derived = deriveContent(new Uint8Array(version.ydocSnapshot));
|
||||
return { ...this.viewOf(version), html: derived.html, markdown: derived.markdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the page to `versionId` (requires write access, ADR 0013). The
|
||||
* permission check happens here; the collab server, which owns the live
|
||||
* document, does the actual work: it snapshots the current state as a
|
||||
* `PRE_RESTORE` version and applies the restored content as a normal edit so
|
||||
* every open client converges. History is append-only — nothing is deleted.
|
||||
* Returns the version being restored.
|
||||
*/
|
||||
async restore(user: User, pageId: string, versionId: string): Promise<PageVersionView> {
|
||||
await this.findModifiablePage(user, pageId);
|
||||
const version = await this.prisma.pageVersion.findFirst({
|
||||
where: { id: versionId, pageId },
|
||||
omit: { ydocSnapshot: true },
|
||||
});
|
||||
if (!version) throw new NotFoundException();
|
||||
|
||||
const payload: PageRestoreRequest = { pageId, versionId, userId: user.id };
|
||||
await this.prisma
|
||||
.$executeRaw`SELECT pg_notify(${PAGE_RESTORE_CHANNEL}, ${JSON.stringify(payload)})`;
|
||||
this.logger.info(
|
||||
{ event: 'audit: version restore requested', pageId, versionId, userId: user.id },
|
||||
'version restore requested',
|
||||
);
|
||||
return this.viewOf(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a named version (requires write access, ADR 0013 / permissions.md).
|
||||
* The snapshot is the page's current persisted state (base state plus the
|
||||
|
||||
@ -5,6 +5,7 @@ import { createAccessListener } from './access-listener.js';
|
||||
import { createPool, pingDatabase } from './db.js';
|
||||
import { createLogger } from './logger.js';
|
||||
import { PostgresPagePersistence } from './persistence.js';
|
||||
import { createRestoreListener } from './restore-listener.js';
|
||||
import { createCollabServer } from './server.js';
|
||||
import { PostgresSessionRegistry } from './session-registry.js';
|
||||
import { PostgresVersionStore } from './version-store.js';
|
||||
@ -47,17 +48,30 @@ async function bootstrap(): Promise<void> {
|
||||
logger,
|
||||
});
|
||||
|
||||
// Applies api-requested restores to the live document (issue #42).
|
||||
const restoreListener = createRestoreListener({
|
||||
createClient: () => new Client({ connectionString: env.DATABASE_URL }),
|
||||
pool,
|
||||
openDirectConnection: (documentName) =>
|
||||
server.hocuspocus.openDirectConnection(documentName, { userId: 'restore', mode: 'rw' }),
|
||||
logger,
|
||||
});
|
||||
|
||||
await server.listen(env.PORT);
|
||||
await accessListener.start();
|
||||
await restoreListener.start();
|
||||
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
|
||||
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
|
||||
|
||||
const shutdown = (signal: NodeJS.Signals): void => {
|
||||
logger.info({ event: 'shutdown', signal }, 'shutting down');
|
||||
sessionRegistry.stop();
|
||||
void Promise.allSettled([accessListener.stop(), server.destroy(), pool.end()]).then(() =>
|
||||
process.exit(0),
|
||||
);
|
||||
void Promise.allSettled([
|
||||
accessListener.stop(),
|
||||
restoreListener.stop(),
|
||||
server.destroy(),
|
||||
pool.end(),
|
||||
]).then(() => process.exit(0));
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
156
apps/collab/src/restore-listener.db.test.ts
Normal file
156
apps/collab/src/restore-listener.db.test.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { PAGE_RESTORE_CHANNEL, docToPlainText, editorSchema } from '@dorfteich/shared';
|
||||
import { signCollabToken } from '@dorfteich/shared/token-crypto';
|
||||
import { Client, Pool } from 'pg';
|
||||
import { pino } from 'pino';
|
||||
import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { createCollabServer } from './server.js';
|
||||
import { createRestoreListener } from './restore-listener.js';
|
||||
import { freePort } from './testing/free-port.js';
|
||||
import { InMemoryPagePersistence } from './testing/fake-persistence.js';
|
||||
import { collabTestDatabaseUrlOrUndefined } from './testing/test-db.js';
|
||||
|
||||
const url = collabTestDatabaseUrlOrUndefined;
|
||||
const secret = 'restore-listener-test-secret-32c!!';
|
||||
const logger = pino({ enabled: false });
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error('timed out waiting for condition');
|
||||
}
|
||||
|
||||
/** Encode a single-paragraph document state for the "default" fragment. */
|
||||
function stateWithText(text: string): Buffer {
|
||||
const doc = new Y.Doc();
|
||||
const pm = editorSchema.node('doc', null, [
|
||||
editorSchema.node('paragraph', null, [editorSchema.text(text)]),
|
||||
]);
|
||||
prosemirrorJSONToYXmlFragment(editorSchema, pm.toJSON(), doc.getXmlFragment('default'));
|
||||
const state = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
doc.destroy();
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Read a live Yjs doc's "default" fragment as plain text. */
|
||||
function textOf(doc: Y.Doc): string {
|
||||
const node = yXmlFragmentToProseMirrorRootNode(doc.getXmlFragment('default'), editorSchema);
|
||||
return docToPlainText(node);
|
||||
}
|
||||
|
||||
describe.skipIf(!url)('restore listener (DB-backed, issue #42)', () => {
|
||||
let pool: Pool;
|
||||
let server: ReturnType<typeof createCollabServer>;
|
||||
let restoreListener: ReturnType<typeof createRestoreListener>;
|
||||
let wsUrl: string;
|
||||
const userId = randomUUID();
|
||||
const pondId = randomUUID();
|
||||
const pageId = randomUUID();
|
||||
let versionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = new Pool({ connectionString: url });
|
||||
await pool.query(
|
||||
'INSERT INTO users (id, username, email, display_name) VALUES ($1, $2, $3, $4)',
|
||||
[userId, `rl-${userId.slice(0, 8)}`, `${userId}@example.test`, 'Restore Tester'],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO ponds (id, slug, name, type, owner_id, updated_at)
|
||||
VALUES ($1, $2, 'Restore Pond', 'PERSONAL', $3, now())`,
|
||||
[pondId, `rl-pond-${pondId.slice(0, 8)}`, userId],
|
||||
);
|
||||
// The page's current content is "current text".
|
||||
await pool.query(
|
||||
`INSERT INTO pages (id, pond_id, title, slug, ydoc_state, sort_key, created_by, updated_at)
|
||||
VALUES ($1, $2, 'Test', $3, $4, 'a0', $5, now())`,
|
||||
[pageId, pondId, `p-${pageId.slice(0, 8)}`, stateWithText('current text'), userId],
|
||||
);
|
||||
// A stored version whose content is "restored text".
|
||||
versionId = randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO page_versions
|
||||
(id, page_id, ydoc_snapshot, trigger, contributor_ids, created_at)
|
||||
VALUES ($1, $2, $3, 'MANUAL', '{}'::text[], now())`,
|
||||
[versionId, pageId, stateWithText('restored text')],
|
||||
);
|
||||
|
||||
server = createCollabServer({
|
||||
version: 'test',
|
||||
logger,
|
||||
tokenSecret: secret,
|
||||
pingDatabase: async () => ({ ok: true }),
|
||||
persistence: new InMemoryPagePersistence(),
|
||||
});
|
||||
restoreListener = createRestoreListener({
|
||||
createClient: () => new Client({ connectionString: url }),
|
||||
pool,
|
||||
openDirectConnection: (name) =>
|
||||
server.hocuspocus.openDirectConnection(name, { userId: 'restore', mode: 'rw' }),
|
||||
logger,
|
||||
reconnectDelayMs: 100,
|
||||
});
|
||||
const port = await freePort();
|
||||
await server.listen(port);
|
||||
await restoreListener.start();
|
||||
wsUrl = `ws://127.0.0.1:${port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await restoreListener.stop();
|
||||
await server.destroy();
|
||||
await pool.query('DELETE FROM pages WHERE id = $1', [pageId]);
|
||||
await pool.query('DELETE FROM ponds WHERE id = $1', [pondId]);
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('restores a version live for a connected client and records a pre-restore snapshot', async () => {
|
||||
// The InMemoryPagePersistence starts documents empty; seed the live doc with
|
||||
// the "current" content by writing it as the connected client.
|
||||
const doc = new Y.Doc();
|
||||
const provider = new HocuspocusProvider({
|
||||
url: wsUrl,
|
||||
name: pageId,
|
||||
document: doc,
|
||||
token: signCollabToken({ userId, pageId, mode: 'rw' }, secret, 60),
|
||||
});
|
||||
await waitFor(() => provider.isSynced);
|
||||
|
||||
const pm = editorSchema.node('doc', null, [
|
||||
editorSchema.node('paragraph', null, [editorSchema.text('current text')]),
|
||||
]);
|
||||
prosemirrorJSONToYXmlFragment(editorSchema, pm.toJSON(), doc.getXmlFragment('default'));
|
||||
await waitFor(() => textOf(doc).includes('current text'));
|
||||
|
||||
// The api's restore signal.
|
||||
await pool.query(`SELECT pg_notify('${PAGE_RESTORE_CHANNEL}', $1)`, [
|
||||
JSON.stringify({ pageId, versionId, userId }),
|
||||
]);
|
||||
|
||||
// The connected client converges on the restored content.
|
||||
await waitFor(() => textOf(doc).includes('restored text'));
|
||||
expect(textOf(doc)).toContain('restored text');
|
||||
expect(textOf(doc)).not.toContain('current text');
|
||||
|
||||
// A pre-restore snapshot was appended; history is append-only (the original
|
||||
// manual version is still present too).
|
||||
const versions = await pool.query<{ trigger: string }>(
|
||||
'SELECT trigger FROM page_versions WHERE page_id = $1',
|
||||
[pageId],
|
||||
);
|
||||
const triggers = versions.rows.map((r) => r.trigger);
|
||||
expect(triggers).toContain('PRE_RESTORE');
|
||||
expect(triggers).toContain('MANUAL');
|
||||
|
||||
provider.destroy();
|
||||
doc.destroy();
|
||||
});
|
||||
});
|
||||
177
apps/collab/src/restore-listener.ts
Normal file
177
apps/collab/src/restore-listener.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { PAGE_RESTORE_CHANNEL, PageRestoreRequest, editorSchema } from '@dorfteich/shared';
|
||||
import type { Client, Pool } from 'pg';
|
||||
import type { Logger } from 'pino';
|
||||
import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
/** Mutates a page's live document; provided by the Hocuspocus server. */
|
||||
export interface DirectDocumentConnection {
|
||||
document: Y.Doc | null;
|
||||
transact(fn: (doc: Y.Doc) => void): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RestoreListenerDeps {
|
||||
/** Dedicated `LISTEN` connection factory (connection-bound, not pooled). */
|
||||
createClient: () => Client;
|
||||
/** Pool for reading the target snapshot and writing the pre-restore version. */
|
||||
pool: Pool;
|
||||
/** Opens a server-side connection to a document so edits broadcast + persist. */
|
||||
openDirectConnection: (documentName: string) => Promise<DirectDocumentConnection>;
|
||||
logger: Logger;
|
||||
reconnectDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface RestoreListener {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY_MS = 1000;
|
||||
const FRAGMENT_NAME = 'default';
|
||||
|
||||
/**
|
||||
* Applies page restores requested by the api (issue #42, ADR 0013). The api
|
||||
* checks permission and emits a {@link PAGE_RESTORE_CHANNEL} notification; this
|
||||
* listener owns the live document, so it can restore in a way that converges
|
||||
* every open client:
|
||||
*
|
||||
* 1. Open a server-side direct connection to the page's document.
|
||||
* 2. Snapshot the current state as a `PRE_RESTORE` version (history is
|
||||
* append-only — nothing is deleted).
|
||||
* 3. In one transaction, replace the document content with the target version's
|
||||
* content. This is a normal edit, so Hocuspocus broadcasts it to all clients
|
||||
* and persists it on disconnect.
|
||||
*/
|
||||
export function createRestoreListener(deps: RestoreListenerDeps): RestoreListener {
|
||||
const reconnectDelayMs = deps.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
|
||||
let client: Client | null = null;
|
||||
let stopped = false;
|
||||
let reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
async function restore(request: PageRestoreRequest): Promise<void> {
|
||||
const { pageId, versionId, userId } = request;
|
||||
const target = await deps.pool.query<{ ydoc_snapshot: Buffer }>(
|
||||
'SELECT ydoc_snapshot FROM page_versions WHERE id = $1 AND page_id = $2',
|
||||
[versionId, pageId],
|
||||
);
|
||||
const snapshot = target.rows[0];
|
||||
if (!snapshot) {
|
||||
deps.logger.warn(
|
||||
{ event: 'restore.target_missing', pageId, versionId },
|
||||
'restore target version not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode the target snapshot into ProseMirror JSON to rebuild the fragment.
|
||||
const targetDoc = new Y.Doc();
|
||||
let targetJson: unknown;
|
||||
try {
|
||||
Y.applyUpdate(targetDoc, new Uint8Array(snapshot.ydoc_snapshot));
|
||||
targetJson = yXmlFragmentToProseMirrorRootNode(
|
||||
targetDoc.getXmlFragment(FRAGMENT_NAME),
|
||||
editorSchema,
|
||||
).toJSON();
|
||||
} finally {
|
||||
targetDoc.destroy();
|
||||
}
|
||||
|
||||
const connection = await deps.openDirectConnection(pageId);
|
||||
try {
|
||||
let preRestore: Buffer | null = null;
|
||||
await connection.transact((doc) => {
|
||||
// Capture the current state for the pre-restore snapshot before mutating.
|
||||
preRestore = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
const fragment = doc.getXmlFragment(FRAGMENT_NAME);
|
||||
fragment.delete(0, fragment.length);
|
||||
prosemirrorJSONToYXmlFragment(editorSchema, targetJson, fragment);
|
||||
});
|
||||
if (preRestore) {
|
||||
await deps.pool.query(
|
||||
`INSERT INTO page_versions
|
||||
(id, page_id, ydoc_snapshot, trigger, label, created_by, contributor_ids, created_at)
|
||||
VALUES (gen_random_uuid(), $1, $2, 'PRE_RESTORE', NULL, $3, '{}'::text[], now())`,
|
||||
[pageId, preRestore, userId],
|
||||
);
|
||||
}
|
||||
deps.logger.info(
|
||||
{ event: 'restore.applied', pageId, versionId, userId },
|
||||
'restored page to an earlier version',
|
||||
);
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (stopped || reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
}, reconnectDelayMs);
|
||||
reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (stopped) return;
|
||||
const next = deps.createClient();
|
||||
next.on('error', (error) => {
|
||||
deps.logger.warn(
|
||||
{ event: 'restore.listen.error', err: error.message },
|
||||
'restore listener connection error; will reconnect',
|
||||
);
|
||||
if (client === next) client = null;
|
||||
scheduleReconnect();
|
||||
});
|
||||
next.on('notification', (message) => {
|
||||
if (message.channel !== PAGE_RESTORE_CHANNEL || !message.payload) return;
|
||||
let request: PageRestoreRequest;
|
||||
try {
|
||||
request = JSON.parse(message.payload) as PageRestoreRequest;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void restore(request).catch((error: unknown) => {
|
||||
deps.logger.error(
|
||||
{ event: 'restore.failed', err: (error as Error).message },
|
||||
'failed to apply page restore',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await next.connect();
|
||||
await next.query(`LISTEN ${PAGE_RESTORE_CHANNEL}`);
|
||||
client = next;
|
||||
deps.logger.info(
|
||||
{ event: 'restore.listen.ready', channel: PAGE_RESTORE_CHANNEL },
|
||||
'listening for page restore requests',
|
||||
);
|
||||
} catch (error) {
|
||||
deps.logger.warn(
|
||||
{ event: 'restore.listen.connect_failed', err: (error as Error).message },
|
||||
'could not start restore listener; will retry',
|
||||
);
|
||||
await next.end().catch(() => undefined);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
stopped = false;
|
||||
await connect();
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
stopped = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
const current = client;
|
||||
client = null;
|
||||
if (current) await current.end().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
134
apps/web/src/editor/HistoryPanel.tsx
Normal file
134
apps/web/src/editor/HistoryPanel.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import type { PageVersionContentView, PageVersionView } from '@dorfteich/shared';
|
||||
import { diffMarkdown } from '@dorfteich/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { apiGet, apiGetText, apiPost } from '../lib/api';
|
||||
|
||||
/**
|
||||
* Version history panel (issue #42, ADR 0013): lists the page's versions and,
|
||||
* for a selected one, shows a read-only render and a Markdown diff against the
|
||||
* current page, with a restore action. History access requires write
|
||||
* permission — the api enforces it, so a lack of access surfaces as an error.
|
||||
*/
|
||||
export function HistoryPanel({
|
||||
pageId,
|
||||
onClose,
|
||||
}: {
|
||||
pageId: string;
|
||||
onClose: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('editor');
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const versions = useQuery({
|
||||
queryKey: ['versions', pageId],
|
||||
queryFn: () => apiGet<PageVersionView[]>(`/pages/${pageId}/versions`),
|
||||
});
|
||||
|
||||
const selected = useQuery({
|
||||
queryKey: ['version', pageId, selectedId],
|
||||
queryFn: () => apiGet<PageVersionContentView>(`/pages/${pageId}/versions/${selectedId!}`),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
|
||||
// The current page Markdown to diff a selected version against.
|
||||
const current = useQuery({
|
||||
queryKey: ['page-markdown', pageId],
|
||||
queryFn: () => apiGetText(`/pages/${pageId}/export/markdown`),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: (versionId: string) =>
|
||||
apiPost<PageVersionView>(`/pages/${pageId}/versions/${versionId}/restore`),
|
||||
onSuccess: () => {
|
||||
// The live document converges via the collab server; refresh the list so
|
||||
// the new pre-restore snapshot shows up.
|
||||
void queryClient.invalidateQueries({ queryKey: ['versions', pageId] });
|
||||
},
|
||||
});
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
const diff =
|
||||
selected.data && current.data !== undefined
|
||||
? diffMarkdown(selected.data.markdown, current.data)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<aside className="history-panel" aria-label={t('history.title')}>
|
||||
<header className="history-panel__header">
|
||||
<h2>{t('history.title')}</h2>
|
||||
<button type="button" className="button" onClick={onClose}>
|
||||
{t('history.close')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{versions.isError && <p className="history-panel__error">{t('history.error')}</p>}
|
||||
{versions.data?.length === 0 && <p>{t('history.empty')}</p>}
|
||||
|
||||
<ol className="history-panel__list">
|
||||
{versions.data?.map((version) => (
|
||||
<li key={version.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="history-panel__item"
|
||||
aria-current={version.id === selectedId}
|
||||
onClick={() => setSelectedId(version.id)}
|
||||
>
|
||||
<span className="history-panel__when">
|
||||
{dateFormat.format(new Date(version.createdAt))}
|
||||
</span>
|
||||
<span className="history-panel__meta">
|
||||
{version.label ?? t(`history.trigger.${version.trigger}`)}
|
||||
{version.contributorIds.length > 0 &&
|
||||
` · ${t('history.contributors', { count: version.contributorIds.length })}`}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{selected.data && (
|
||||
<section className="history-panel__detail">
|
||||
<div className="history-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={restore.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('history.restoreConfirm'))) restore.mutate(selected.data!.id);
|
||||
}}
|
||||
>
|
||||
{restore.isSuccess ? t('history.restored') : t('history.restore')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3>{t('history.diffTitle')}</h3>
|
||||
{diff && (
|
||||
<pre className="history-panel__diff">
|
||||
{diff.map((segment, index) => (
|
||||
<span key={index} className={`diff diff--${segment.type}`}>
|
||||
{segment.value}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<h3>{t('history.viewTitle')}</h3>
|
||||
{/* Trusted server-rendered HTML from the same editor schema (#24). */}
|
||||
<div
|
||||
className="history-panel__preview"
|
||||
dangerouslySetInnerHTML={{ __html: selected.data.html }}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,7 @@ import * as Y from 'yjs';
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { FormError } from '../components/forms';
|
||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||
import { HistoryPanel } from '../editor/HistoryPanel';
|
||||
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
||||
import { documentExtensions } from '../editor/document-extensions';
|
||||
import { ImageUpload } from '../editor/image-upload';
|
||||
@ -140,10 +141,12 @@ function PageMenu({
|
||||
pageId,
|
||||
slug,
|
||||
pondSlug,
|
||||
onToggleHistory,
|
||||
}: {
|
||||
pageId: string;
|
||||
slug: string;
|
||||
pondSlug: string;
|
||||
onToggleHistory: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('editor');
|
||||
const navigate = useNavigate();
|
||||
@ -180,6 +183,9 @@ function PageMenu({
|
||||
>
|
||||
{t('page.downloadMarkdown')}
|
||||
</a>
|
||||
<button type="button" className="button" onClick={onToggleHistory}>
|
||||
{t('history.open')}
|
||||
</button>
|
||||
<button type="button" className="button" onClick={() => void deletePage()}>
|
||||
{t('page.delete')}
|
||||
</button>
|
||||
@ -192,6 +198,7 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
|
||||
const [mode, setMode] = useState<Mode>('view');
|
||||
const [title, setTitle] = useState('');
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
useForceSidebarHidden(mode === 'edit');
|
||||
|
||||
@ -278,9 +285,17 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
>
|
||||
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
||||
</button>
|
||||
<PageMenu pageId={resolved.id} slug={resolved.slug} pondSlug={pondSlug} />
|
||||
<PageMenu
|
||||
pageId={resolved.id}
|
||||
slug={resolved.slug}
|
||||
pondSlug={pondSlug}
|
||||
onToggleHistory={() => setShowHistory((open) => !open)}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-page__body">
|
||||
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} />
|
||||
{showHistory && <HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />}
|
||||
</div>
|
||||
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -642,6 +642,106 @@ button {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.editor-page__body {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.editor-page__body > .editor-shell {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
flex: 0 0 22rem;
|
||||
max-width: 22rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
padding: var(--space-3);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.history-panel__header h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.history-panel__list {
|
||||
list-style: none;
|
||||
margin: var(--space-2) 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.history-panel__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
gap: 2px;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.history-panel__item:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.history-panel__item[aria-current='true'] {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.history-panel__when {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.history-panel__meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.history-panel__error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.history-panel__diff {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-bg-subtle);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.diff--added {
|
||||
background: color-mix(in srgb, green 22%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.diff--removed {
|
||||
background: color-mix(in srgb, red 22%, transparent);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.history-panel__preview {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.access-revoked__title {
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-1);
|
||||
|
||||
@ -89,6 +89,25 @@
|
||||
"full": "Voll"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Versionsverlauf",
|
||||
"open": "Verlauf",
|
||||
"close": "Schließen",
|
||||
"empty": "Noch keine Versionen.",
|
||||
"error": "Du kannst den Verlauf dieser Seite nicht ansehen.",
|
||||
"restore": "Diese Version wiederherstellen",
|
||||
"restored": "Wiederhergestellt",
|
||||
"restoreConfirm": "Die Seite auf diese Version zurücksetzen? Der aktuelle Stand wird vorher als Version gesichert, sodass du das rückgängig machen kannst.",
|
||||
"diffTitle": "Änderungen von dieser Version bis heute",
|
||||
"viewTitle": "Vorschau",
|
||||
"contributors_one": "{{count}} Beitragende:r",
|
||||
"contributors_other": "{{count}} Beitragende",
|
||||
"trigger": {
|
||||
"auto": "Automatischer Schnappschuss",
|
||||
"manual": "Benannte Version",
|
||||
"pre_restore": "Vor einer Wiederherstellung"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"copyMarkdown": "Als Markdown kopieren",
|
||||
"markdownCopied": "Kopiert!",
|
||||
|
||||
@ -89,6 +89,25 @@
|
||||
"full": "Full"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Version history",
|
||||
"open": "History",
|
||||
"close": "Close",
|
||||
"empty": "No versions yet.",
|
||||
"error": "You can't view this page's history.",
|
||||
"restore": "Restore this version",
|
||||
"restored": "Restored",
|
||||
"restoreConfirm": "Restore the page to this version? The current state is saved as a version first, so you can undo this.",
|
||||
"diffTitle": "Changes from this version to now",
|
||||
"viewTitle": "Preview",
|
||||
"contributors_one": "{{count}} contributor",
|
||||
"contributors_other": "{{count}} contributors",
|
||||
"trigger": {
|
||||
"auto": "Automatic snapshot",
|
||||
"manual": "Named version",
|
||||
"pre_restore": "Before a restore"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"copyMarkdown": "Copy as Markdown",
|
||||
"markdownCopied": "Copied!",
|
||||
|
||||
@ -22,6 +22,24 @@ export type CollabTokenMode = z.infer<typeof collabTokenModeSchema>;
|
||||
*/
|
||||
export const POND_ACCESS_CHANGED_CHANNEL = 'pond_access_changed';
|
||||
|
||||
/**
|
||||
* PostgreSQL `NOTIFY` channel over which the api asks the collab server to
|
||||
* restore a page to an earlier version (issue #42, ADR 0013). The api does the
|
||||
* permission check, then emits this; the collab server owns the live document,
|
||||
* so it snapshots the current state (a `PRE_RESTORE` version) and applies the
|
||||
* restored content as a normal edit through the document, converging every open
|
||||
* client. Payload is a JSON {@link PageRestoreRequest}.
|
||||
*/
|
||||
export const PAGE_RESTORE_CHANNEL = 'page_restore';
|
||||
|
||||
/** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
|
||||
export interface PageRestoreRequest {
|
||||
pageId: string;
|
||||
versionId: string;
|
||||
/** The user who triggered the restore (recorded on the pre-restore snapshot). */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** The application claims carried by a collaboration token. */
|
||||
export const collabTokenClaimsSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
|
||||
@ -9,3 +9,4 @@ export * from './i18n-tools';
|
||||
export * from './pages';
|
||||
export * from './ponds';
|
||||
export * from './quotas';
|
||||
export * from './text-diff';
|
||||
|
||||
@ -78,3 +78,11 @@ export interface PageVersionView {
|
||||
contributorIds: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A single version with its rendered content, for viewing/diffing (issue #42). */
|
||||
export interface PageVersionContentView extends PageVersionView {
|
||||
/** Read-only HTML render of the snapshot. */
|
||||
html: string;
|
||||
/** Markdown of the snapshot, for the diff against the current page. */
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
58
packages/shared/src/text-diff.test.ts
Normal file
58
packages/shared/src/text-diff.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { diffMarkdown } from './text-diff';
|
||||
|
||||
/** Concatenate the segments of one type back into a string. */
|
||||
function only(
|
||||
segments: ReturnType<typeof diffMarkdown>,
|
||||
type: 'added' | 'removed' | 'equal',
|
||||
): string {
|
||||
return segments
|
||||
.filter((s) => s.type === type)
|
||||
.map((s) => s.value)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('diffMarkdown (issue #42)', () => {
|
||||
it('marks added and removed words against a fixture pair', () => {
|
||||
const before = 'The quick brown fox';
|
||||
const after = 'The slow brown fox jumps';
|
||||
const segments = diffMarkdown(before, after);
|
||||
|
||||
expect(only(segments, 'removed')).toContain('quick');
|
||||
expect(only(segments, 'added')).toContain('slow');
|
||||
expect(only(segments, 'added')).toContain('jumps');
|
||||
// Unchanged words stay in the equal channel.
|
||||
expect(only(segments, 'equal')).toContain('brown');
|
||||
expect(only(segments, 'equal')).toContain('fox');
|
||||
});
|
||||
|
||||
it('round-trips: equal+removed reconstructs before, equal+added reconstructs after', () => {
|
||||
const before = '# Title\n\nHello world, this is old.';
|
||||
const after = '# Title\n\nHello brave world, this is new.';
|
||||
const segments = diffMarkdown(before, after);
|
||||
|
||||
const reconstructedBefore = segments
|
||||
.filter((s) => s.type !== 'added')
|
||||
.map((s) => s.value)
|
||||
.join('');
|
||||
const reconstructedAfter = segments
|
||||
.filter((s) => s.type !== 'removed')
|
||||
.map((s) => s.value)
|
||||
.join('');
|
||||
expect(reconstructedBefore).toBe(before);
|
||||
expect(reconstructedAfter).toBe(after);
|
||||
});
|
||||
|
||||
it('returns only equal segments for identical text', () => {
|
||||
const text = 'nothing changed here';
|
||||
const segments = diffMarkdown(text, text);
|
||||
expect(segments.every((s) => s.type === 'equal')).toBe(true);
|
||||
expect(only(segments, 'equal')).toBe(text);
|
||||
});
|
||||
|
||||
it('handles empty before (pure insert) and empty after (pure delete)', () => {
|
||||
expect(diffMarkdown('', 'brand new')).toEqual([{ type: 'added', value: 'brand new' }]);
|
||||
expect(diffMarkdown('all gone', '')).toEqual([{ type: 'removed', value: 'all gone' }]);
|
||||
});
|
||||
});
|
||||
78
packages/shared/src/text-diff.ts
Normal file
78
packages/shared/src/text-diff.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* A minimal word-level text diff for the version-history view (issue #42,
|
||||
* ADR 0013 documents that a diff on the derived Markdown is sufficient — no
|
||||
* structural diff UI in v1). Kept dependency-free and in `packages/shared` so
|
||||
* the web app renders it and it can be unit-tested in isolation.
|
||||
*/
|
||||
|
||||
export type DiffSegmentType = 'equal' | 'added' | 'removed';
|
||||
|
||||
export interface DiffSegment {
|
||||
type: DiffSegmentType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Split into alternating word and whitespace tokens so the text round-trips. */
|
||||
function tokenize(text: string): string[] {
|
||||
return text.match(/\s+|\S+/g) ?? [];
|
||||
}
|
||||
|
||||
function pushSegment(segments: DiffSegment[], type: DiffSegmentType, value: string): void {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.type === type) {
|
||||
last.value += value;
|
||||
} else {
|
||||
segments.push({ type, value });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff `before` (an older version) against `after` (the current text),
|
||||
* returning ordered segments. `removed` marks text only in `before`, `added`
|
||||
* marks text only in `after`, `equal` is shared — consecutive tokens of the
|
||||
* same kind are merged so the UI renders whole runs.
|
||||
*
|
||||
* Uses a standard longest-common-subsequence DP over tokens; page Markdown is
|
||||
* small enough that the O(n·m) table is not a concern.
|
||||
*/
|
||||
export function diffMarkdown(before: string, after: string): DiffSegment[] {
|
||||
const a = tokenize(before);
|
||||
const b = tokenize(after);
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
|
||||
// lcs[i][j] = length of the LCS of a[i..] and b[j..].
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
lcs[i]![j] =
|
||||
a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
||||
}
|
||||
}
|
||||
|
||||
const segments: DiffSegment[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
pushSegment(segments, 'equal', a[i]!);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
|
||||
pushSegment(segments, 'removed', a[i]!);
|
||||
i += 1;
|
||||
} else {
|
||||
pushSegment(segments, 'added', b[j]!);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
pushSegment(segments, 'removed', a[i]!);
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
pushSegment(segments, 'added', b[j]!);
|
||||
j += 1;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user