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

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:
Claude Opus 4.8 2026-07-09 08:53:42 +02:00
parent 6fb6f6fce7
commit 1bda137ca4
16 changed files with 957 additions and 11 deletions

View File

@ -1,16 +1,40 @@
import { Body, Controller, Param, Post, Req } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Req } from '@nestjs/common';
import { CreateVersionInput, PageVersionView, createVersionInputSchema } from '@dorfteich/shared'; import {
CreateVersionInput,
PageVersionContentView,
PageVersionView,
createVersionInputSchema,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { VersionsService } from './versions.service'; 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() @Controller()
export class VersionsController { export class VersionsController {
constructor(private readonly versions: VersionsService) {} 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') @Post('pages/:id/versions')
async createNamed( async createNamed(
@Param('id') id: string, @Param('id') id: string,
@ -19,4 +43,14 @@ export class VersionsController {
): Promise<PageVersionView> { ): Promise<PageVersionView> {
return this.versions.createNamed(request.user!, id, input); 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);
}
} }

View File

@ -107,6 +107,49 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
expect(await prisma.pagePendingContributor.count({ where: { pageId } })).toBe(0); 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 () => { it('refuses a named version for a user without write access', async () => {
const pageId = await createPage(); const pageId = await createPage();
await expect(versions.createNamed(outsider, pageId, { label: 'nope' })).rejects.toBeInstanceOf( await expect(versions.createNamed(outsider, pageId, { label: 'nope' })).rejects.toBeInstanceOf(

View File

@ -1,9 +1,17 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateVersionInput, PageVersionTrigger, PageVersionView } from '@dorfteich/shared'; import {
import { PageVersion, PageVersionTrigger as PrismaTrigger, User } from '@prisma/client'; 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 { PinoLogger } from 'nestjs-pino';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { deriveContent } from '../pages/yjs-content';
import { InterimAccessService } from '../ponds/interim-access.service'; import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.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). * Create a named version (requires write access, ADR 0013 / permissions.md).
* The snapshot is the page's current persisted state (base state plus the * The snapshot is the page's current persisted state (base state plus the

View File

@ -5,6 +5,7 @@ import { createAccessListener } from './access-listener.js';
import { createPool, pingDatabase } from './db.js'; import { createPool, pingDatabase } from './db.js';
import { createLogger } from './logger.js'; import { createLogger } from './logger.js';
import { PostgresPagePersistence } from './persistence.js'; import { PostgresPagePersistence } from './persistence.js';
import { createRestoreListener } from './restore-listener.js';
import { createCollabServer } from './server.js'; import { createCollabServer } from './server.js';
import { PostgresSessionRegistry } from './session-registry.js'; import { PostgresSessionRegistry } from './session-registry.js';
import { PostgresVersionStore } from './version-store.js'; import { PostgresVersionStore } from './version-store.js';
@ -47,17 +48,30 @@ async function bootstrap(): Promise<void> {
logger, 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 server.listen(env.PORT);
await accessListener.start(); await accessListener.start();
await restoreListener.start();
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]); sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening'); logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
const shutdown = (signal: NodeJS.Signals): void => { const shutdown = (signal: NodeJS.Signals): void => {
logger.info({ event: 'shutdown', signal }, 'shutting down'); logger.info({ event: 'shutdown', signal }, 'shutting down');
sessionRegistry.stop(); sessionRegistry.stop();
void Promise.allSettled([accessListener.stop(), server.destroy(), pool.end()]).then(() => void Promise.allSettled([
process.exit(0), accessListener.stop(),
); restoreListener.stop(),
server.destroy(),
pool.end(),
]).then(() => process.exit(0));
}; };
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGINT', () => shutdown('SIGINT'));

View 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();
});
});

View 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);
},
};
}

View 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>
);
}

View File

@ -10,6 +10,7 @@ import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
import { HistoryPanel } from '../editor/HistoryPanel';
import { collaborationCaretFor } from '../editor/collaboration-caret'; import { collaborationCaretFor } from '../editor/collaboration-caret';
import { documentExtensions } from '../editor/document-extensions'; import { documentExtensions } from '../editor/document-extensions';
import { ImageUpload } from '../editor/image-upload'; import { ImageUpload } from '../editor/image-upload';
@ -140,10 +141,12 @@ function PageMenu({
pageId, pageId,
slug, slug,
pondSlug, pondSlug,
onToggleHistory,
}: { }: {
pageId: string; pageId: string;
slug: string; slug: string;
pondSlug: string; pondSlug: string;
onToggleHistory: () => void;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const navigate = useNavigate(); const navigate = useNavigate();
@ -180,6 +183,9 @@ function PageMenu({
> >
{t('page.downloadMarkdown')} {t('page.downloadMarkdown')}
</a> </a>
<button type="button" className="button" onClick={onToggleHistory}>
{t('history.open')}
</button>
<button type="button" className="button" onClick={() => void deletePage()}> <button type="button" className="button" onClick={() => void deletePage()}>
{t('page.delete')} {t('page.delete')}
</button> </button>
@ -192,6 +198,7 @@ export function PageEditorPage(): React.JSX.Element {
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>(); const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
const [mode, setMode] = useState<Mode>('view'); const [mode, setMode] = useState<Mode>('view');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [showHistory, setShowHistory] = useState(false);
useForceSidebarHidden(mode === 'edit'); useForceSidebarHidden(mode === 'edit');
@ -278,9 +285,17 @@ export function PageEditorPage(): React.JSX.Element {
> >
{mode === 'edit' ? t('mode.view') : t('mode.edit')} {mode === 'edit' ? t('mode.view') : t('mode.edit')}
</button> </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> </div>
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} />
</div> </div>
); );
} }

View File

@ -642,6 +642,106 @@ button {
color: var(--color-danger); 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 { .access-revoked__title {
font-weight: 600; font-weight: 600;
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);

View File

@ -89,6 +89,25 @@
"full": "Voll" "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": { "page": {
"copyMarkdown": "Als Markdown kopieren", "copyMarkdown": "Als Markdown kopieren",
"markdownCopied": "Kopiert!", "markdownCopied": "Kopiert!",

View File

@ -89,6 +89,25 @@
"full": "Full" "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": { "page": {
"copyMarkdown": "Copy as Markdown", "copyMarkdown": "Copy as Markdown",
"markdownCopied": "Copied!", "markdownCopied": "Copied!",

View File

@ -22,6 +22,24 @@ export type CollabTokenMode = z.infer<typeof collabTokenModeSchema>;
*/ */
export const POND_ACCESS_CHANGED_CHANNEL = 'pond_access_changed'; 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. */ /** The application claims carried by a collaboration token. */
export const collabTokenClaimsSchema = z.object({ export const collabTokenClaimsSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),

View File

@ -9,3 +9,4 @@ export * from './i18n-tools';
export * from './pages'; export * from './pages';
export * from './ponds'; export * from './ponds';
export * from './quotas'; export * from './quotas';
export * from './text-diff';

View File

@ -78,3 +78,11 @@ export interface PageVersionView {
contributorIds: string[]; contributorIds: string[];
createdAt: 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;
}

View 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' }]);
});
});

View 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;
}