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 { 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'));
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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!",
|
||||||
|
|||||||
@ -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!",
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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