diff --git a/apps/api/src/versions/versions.controller.ts b/apps/api/src/versions/versions.controller.ts index 480304e..659998e 100644 --- a/apps/api/src/versions/versions.controller.ts +++ b/apps/api/src/versions/versions.controller.ts @@ -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 { + 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 { + 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 { 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 { + return this.versions.restore(request.user!, id, versionId); + } } diff --git a/apps/api/src/versions/versions.service.db.test.ts b/apps/api/src/versions/versions.service.db.test.ts index fb00f79..74315bf 100644 --- a/apps/api/src/versions/versions.service.db.test.ts +++ b/apps/api/src/versions/versions.service.db.test.ts @@ -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( diff --git a/apps/api/src/versions/versions.service.ts b/apps/api/src/versions/versions.service.ts index f603686..75d59ca 100644 --- a/apps/api/src/versions/versions.service.ts +++ b/apps/api/src/versions/versions.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 diff --git a/apps/collab/src/index.ts b/apps/collab/src/index.ts index f6938a8..87cf436 100644 --- a/apps/collab/src/index.ts +++ b/apps/collab/src/index.ts @@ -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 { 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')); diff --git a/apps/collab/src/restore-listener.db.test.ts b/apps/collab/src/restore-listener.db.test.ts new file mode 100644 index 0000000..d53c3f5 --- /dev/null +++ b/apps/collab/src/restore-listener.db.test.ts @@ -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 { + 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; + let restoreListener: ReturnType; + 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(); + }); +}); diff --git a/apps/collab/src/restore-listener.ts b/apps/collab/src/restore-listener.ts new file mode 100644 index 0000000..ea12b48 --- /dev/null +++ b/apps/collab/src/restore-listener.ts @@ -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; + disconnect(): Promise; +} + +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; + logger: Logger; + reconnectDelayMs?: number; +} + +export interface RestoreListener { + start(): Promise; + stop(): Promise; +} + +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 { + 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 { + 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 { + stopped = false; + await connect(); + }, + async stop(): Promise { + stopped = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + const current = client; + client = null; + if (current) await current.end().catch(() => undefined); + }, + }; +} diff --git a/apps/web/src/editor/HistoryPanel.tsx b/apps/web/src/editor/HistoryPanel.tsx new file mode 100644 index 0000000..3def2f9 --- /dev/null +++ b/apps/web/src/editor/HistoryPanel.tsx @@ -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(null); + + const versions = useQuery({ + queryKey: ['versions', pageId], + queryFn: () => apiGet(`/pages/${pageId}/versions`), + }); + + const selected = useQuery({ + queryKey: ['version', pageId, selectedId], + queryFn: () => apiGet(`/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(`/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 ( + + ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index f1a98b6..9f80e84 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -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')} + @@ -192,6 +198,7 @@ export function PageEditorPage(): React.JSX.Element { const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>(); const [mode, setMode] = useState('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')} - + setShowHistory((open) => !open)} + /> + +
+ + {showHistory && setShowHistory(false)} />}
- ); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index cad4db5..5f0f9b8 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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); diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index bf14193..63c192c 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -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!", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 3119a35..a0f5598 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -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!", diff --git a/packages/shared/src/collab-token.ts b/packages/shared/src/collab-token.ts index 056330f..5adfae8 100644 --- a/packages/shared/src/collab-token.ts +++ b/packages/shared/src/collab-token.ts @@ -22,6 +22,24 @@ export type CollabTokenMode = z.infer; */ 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), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8e91c43..efb1ddc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,3 +9,4 @@ export * from './i18n-tools'; export * from './pages'; export * from './ponds'; export * from './quotas'; +export * from './text-diff'; diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index 1f305aa..8ec3af0 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -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; +} diff --git a/packages/shared/src/text-diff.test.ts b/packages/shared/src/text-diff.test.ts new file mode 100644 index 0000000..8374fa5 --- /dev/null +++ b/packages/shared/src/text-diff.test.ts @@ -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, + 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' }]); + }); +}); diff --git a/packages/shared/src/text-diff.ts b/packages/shared/src/text-diff.ts new file mode 100644 index 0000000..7d8ae6d --- /dev/null +++ b/packages/shared/src/text-diff.ts @@ -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(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; +}