All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m16s
CI / Auth e2e pack (push) Successful in 2m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
Maintain a server-side `page_links` index on every content change so
backlinks and missing-target ("phantom") links can be queried.
- prisma: `PageLink` (from_page_id, nullable to_page_id, target_slug;
unique per (from, slug); cascade on source purge, set-null on target
purge); migration.
- shared: `extractWikilinkSlugs(doc)` (distinct target slugs) and the
`BacklinkView` / `PhantomLinkView` read shapes.
- collab: the persistence hook (#35) now rewrites the source page's outgoing
links in the same transaction as the content cache — one row per distinct
wikilink slug, resolved to a page in the same pond (null = phantom).
- api: `GET /pages/:id/backlinks` (permission-filtered — wikilinks resolve
within a pond, so seeing the pond is the read right) and
`GET /ponds/:id/phantom-links` (missing targets grouped with their
referrers). Creating or renaming a page to a slug that pages already link
to resolves those phantom rows; because links store the target's id,
backlinks survive a later rename of the target's slug.
- tests: shared extraction unit test; collab persistence db test (store
writes resolved + phantom rows and rewrites the index); api LinksService
db test (backlinks, permission filter, phantom aggregation, create/rename
resolution, id-based backlinks survive target rename).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
75 lines
2.4 KiB
TypeScript
75 lines
2.4 KiB
TypeScript
import {
|
|
docToHtml,
|
|
docToMarkdown,
|
|
docToPlainText,
|
|
editorSchema,
|
|
extractOutline,
|
|
extractWikilinkSlugs,
|
|
type OutlineEntry,
|
|
} from '@dorfteich/shared';
|
|
import { Node } from 'prosemirror-model';
|
|
import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
|
|
import * as Y from 'yjs';
|
|
|
|
/**
|
|
* The Yjs XmlFragment name the editor binds to — TipTap's collaboration
|
|
* extension defaults to "default" (#25). api (`apps/api/src/pages/yjs-content.ts`),
|
|
* web, and collab must all agree on this or Yjs states become unreadable across
|
|
* them. This file deliberately mirrors the api's derivation (the shared
|
|
* functions come from `@dorfteich/shared`, #24); the two were kept separate on
|
|
* purpose rather than abstracted prematurely (see the M3 handoff).
|
|
*/
|
|
const FRAGMENT_NAME = 'default';
|
|
|
|
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
|
|
export class InvalidPageStateError extends Error {}
|
|
|
|
function docFromDoc(ydoc: Y.Doc): Node {
|
|
try {
|
|
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
|
|
} catch (error) {
|
|
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
|
|
}
|
|
}
|
|
|
|
export interface DerivedPageContent {
|
|
plainText: string;
|
|
markdown: string;
|
|
html: string;
|
|
outline: OutlineEntry[];
|
|
/** fileIds of every `image` node embedded in the document — keeps
|
|
* `Attachment.pageId` pointed at the page that embeds the file (issue #31),
|
|
* mirroring the api's REST save path. */
|
|
imageFileIds: string[];
|
|
/** Distinct target slugs of every `[[wikilink]]`, for the `page_links`
|
|
* index (issue #47). */
|
|
wikilinkSlugs: string[];
|
|
}
|
|
|
|
function imageFileIdsOf(doc: Node): string[] {
|
|
const ids: string[] = [];
|
|
doc.descendants((node) => {
|
|
if (node.type.name === 'image' && typeof node.attrs.fileId === 'string') {
|
|
ids.push(node.attrs.fileId);
|
|
}
|
|
});
|
|
return ids;
|
|
}
|
|
|
|
/**
|
|
* Decode a live Yjs document into the derived representations stored in
|
|
* `page_content_cache` (issue #23/#35), using the shared editor schema (#24)
|
|
* so the cache matches exactly what the api derives from the same state.
|
|
*/
|
|
export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent {
|
|
const doc = docFromDoc(ydoc);
|
|
return {
|
|
plainText: docToPlainText(doc),
|
|
markdown: docToMarkdown(doc),
|
|
html: docToHtml(doc),
|
|
outline: extractOutline(doc),
|
|
imageFileIds: imageFileIdsOf(doc),
|
|
wikilinkSlugs: extractWikilinkSlugs(doc),
|
|
};
|
|
}
|