| 1 | /** |
| 2 | * The doc kind: BlockNote on `XmlFragment("document-store")`, comments in |
| 3 | * the `threads` map, Markdown as its text rendition. The same document, |
| 4 | * code and behaviour as Docs' pages (src/blocks.ts, src/markdown.ts, |
| 5 | * src/edits.ts, src/citations.ts, src/threads.ts), which PageRoom still |
| 6 | * uses until Phase 7 retires it; those modules move here then. Pure. |
| 7 | */ |
| 8 | import type { DocEditTarget } from "@g1t/contracts"; |
| 9 | import * as Y from "yjs"; |
| 10 | |
| 11 | import { seed as seedBlocks } from "../../blocks.ts"; |
| 12 | import { chunkMarkdown } from "../../chunks.ts"; |
| 13 | import { bodyCitations } from "../../citations.ts"; |
| 14 | import { applyEdit, findTarget, rangeIds, rangeMarkdown, restoreFrom } from "../../edits.ts"; |
| 15 | import { citationNodes, documentMarkdown, mentionedIds, outline, searchText } from "../../markdown.ts"; |
| 16 | import type { FolioOrigin, KindModel, Rendition } from "../types.ts"; |
| 17 | |
| 18 | /** Every folio id a text links to, for backlinks (as `linkedFolioIds` in @g1t/contracts; kept here so this module stays pure for Node's tests). */ |
| 19 | export function linkedFolioIds(text: string): string[] { |
| 20 | return [...new Set(text.match(/fol_[0-9a-hjkmnp-tv-z]{26}/g) ?? [])]; |
| 21 | } |
| 22 | |
| 23 | /** Where BlockNote keeps the document. */ |
| 24 | export const DOC_FRAGMENT = "document-store"; |
| 25 | /** The most a doc's Markdown may be. */ |
| 26 | export const DOC_MAX_TEXT = 512 * 1024; |
| 27 | /** Lines a card shows. */ |
| 28 | const PREVIEW_LINES = 4; |
| 29 | |
| 30 | export function docFragment(doc: Y.Doc): Y.XmlFragment { |
| 31 | return doc.getXmlFragment(DOC_FRAGMENT); |
| 32 | } |
| 33 | |
| 34 | /** The first lines of a doc, as plain text, for its card. */ |
| 35 | export function previewLines(markdown: string, max = PREVIEW_LINES): string[] { |
| 36 | return searchText(markdown) |
| 37 | .split("\n") |
| 38 | .map((l) => l.replace(/\s+/g, " ").trim()) |
| 39 | .filter(Boolean) |
| 40 | .slice(0, max) |
| 41 | .map((l) => (l.length > 120 ? `${l.slice(0, 119)}…` : l)); |
| 42 | } |
| 43 | |
| 44 | /** A target's current Markdown and blocks, or null when it is gone. */ |
| 45 | export function docTarget(doc: Y.Doc, target: DocEditTarget): { markdown: string; block_ids: string[] } | null { |
| 46 | const fragment = docFragment(doc); |
| 47 | const range = findTarget(fragment, target); |
| 48 | if (!range) return null; |
| 49 | return { markdown: rangeMarkdown(fragment, range), block_ids: rangeIds(fragment, range) }; |
| 50 | } |
| 51 | |
| 52 | /** Where each target is now, for marking open suggestions in the editor (null: gone). */ |
| 53 | export function docTargets(doc: Y.Doc, targets: DocEditTarget[]): (string[] | null)[] { |
| 54 | const fragment = docFragment(doc); |
| 55 | return targets.map((t) => { |
| 56 | const range = findTarget(fragment, t); |
| 57 | return range ? rangeIds(fragment, range) : null; |
| 58 | }); |
| 59 | } |
| 60 | |
| 61 | function describe(target: DocEditTarget): string { |
| 62 | switch (target.kind) { |
| 63 | case "append": |
| 64 | return "Added to the end"; |
| 65 | case "document": |
| 66 | return "Rewrote the doc"; |
| 67 | case "section": |
| 68 | return `Changed the section "${target.heading}"`; |
| 69 | case "blocks": |
| 70 | return "Changed some blocks"; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | export const doc: KindModel = { |
| 75 | kind: "doc", |
| 76 | isEmpty: (d) => docFragment(d).length === 0, |
| 77 | seed(d, init) { |
| 78 | seedBlocks(d, docFragment(d), String(init.text ?? "").slice(0, DOC_MAX_TEXT)); |
| 79 | }, |
| 80 | render(d): Rendition { |
| 81 | const fragment = docFragment(d); |
| 82 | const text = documentMarkdown(fragment); |
| 83 | return { |
| 84 | text, |
| 85 | mentions: mentionedIds(fragment).users, |
| 86 | links: linkedFolioIds(text), |
| 87 | citations: bodyCitations(citationNodes(fragment), text), |
| 88 | preview: { kind: "doc", lines: previewLines(text) }, |
| 89 | }; |
| 90 | }, |
| 91 | chunks: (text, title) => chunkMarkdown(text, title), |
| 92 | read(d) { |
| 93 | const fragment = docFragment(d); |
| 94 | return { content: documentMarkdown(fragment), blocks: outline(fragment) }; |
| 95 | }, |
| 96 | applyAgentEdit(d, edit, origin: FolioOrigin) { |
| 97 | if (edit.kind !== "doc") return { applied: false, summary: "That edit is for another kind of artifact." }; |
| 98 | const applied = applyEdit(d, docFragment(d), edit.target, String(edit.markdown ?? "").slice(0, DOC_MAX_TEXT), origin); |
| 99 | return { applied, summary: applied ? describe(edit.target) : "That part of the doc isn't there." }; |
| 100 | }, |
| 101 | restore(d, old, origin) { |
| 102 | restoreFrom(d, docFragment(d), docFragment(old), origin); |
| 103 | }, |
| 104 | restoreText(d, text, origin) { |
| 105 | applyEdit(d, docFragment(d), { kind: "document" }, text, origin); |
| 106 | }, |
| 107 | validate(d) { |
| 108 | return documentMarkdown(docFragment(d)).length > DOC_MAX_TEXT * 2 ? "This doc is too long." : null; |
| 109 | }, |
| 110 | }; |