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
79 lines
2.3 KiB
TypeScript
79 lines
2.3 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|