diff --git a/packages/pages/src/blocks/blocks.test.ts b/packages/pages/src/blocks/blocks.test.ts index fe93d27..03a83e5 100644 --- a/packages/pages/src/blocks/blocks.test.ts +++ b/packages/pages/src/blocks/blocks.test.ts @@ -7,16 +7,11 @@ */ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - BLOCK_HANDLES, - indented, - NAMESPACE, - outdented, - predict, - reduce, -} from "./blocks.ts"; +import { BLOCK_HANDLES, indented, outdented, predict, reduce } from "./blocks.ts"; +import { NAMESPACE } from "../events/events.ts"; import { getPage, getReferences, searchPages } from "../pages.ts"; -import type { Block, PageEvent, Pages } from "./blocks.ts"; +import type { Pages } from "../pages.ts"; +import type { Block, PageEvent } from "./blocks.ts"; const TODAY = "2026-09-21"; @@ -279,3 +274,28 @@ test("a prediction files a block under any spelling of the page it is drawn on", assert.equal(next.title, "Mira"); assert.deepEqual(next.blocks.map((it) => it.id), ["n"]); }); + +/** + * A block event from a shared folder may be anything. One that cannot fold is + * skipped whole; a non-string text used to fold in and crash every query + * that read it. The two leniencies keep a block that can still be shown. + */ +test("a malformed block event is skipped whole, and nothing that reads the pages throws", () => { + const created = (payload: unknown) => event(`${NAMESPACE}block.created`, 1, payload); + const state = [ + created({ page: "x", id: "a", text: 5 }), + created({ page: "x", text: "no id" }), + created({ page: "x", id: "", text: "empty id" }), + created({ page: "x", id: "b", text: "kept" }), + created({ page: "x", id: "c", text: "after a non-string", after: 5 }), + created({ id: "d", text: "no page" }), + event(`${NAMESPACE}block.edited`, 2, { id: "b", text: ["not", "text"] }), + ].reduce(reduce, empty()); + + assert.deepEqual(getPage(state, "x").blocks.map((it) => [it.id, it.text]), [ + ["b", "kept"], + ["c", "after a non-string"], + ]); + assert.deepEqual(getPage(state, "undefined").blocks.map((it) => it.id), ["d"]); + assert.doesNotThrow(() => searchPages(state, "x")); +}); diff --git a/packages/pages/src/blocks/blocks.ts b/packages/pages/src/blocks/blocks.ts index cceee2f..848dc31 100644 --- a/packages/pages/src/blocks/blocks.ts +++ b/packages/pages/src/blocks/blocks.ts @@ -1,28 +1,24 @@ /** - * The block is the primitive, and this is everything that knows what one is. + * The block is the primitive, and this is everything that knows what one is: + * the event schema, the tree operations a fold is made of, and the fold. * * A page is not a record anywhere: it is a title that blocks carry, so pages * come into being by being named and go away by having nothing left on them. - * That is why only a creation says anything about a page — it is where the - * user wrote the block — and why every event after it names the block alone. + * That is why only a creation says anything about a page, and why every event + * after it names the block alone. * - * Nothing here touches a log, a file, a device or a window. It is the domain: - * an event schema, the tree operations a fold is made of, and the fold itself. - * `pages.ts` is what a caller points a log at. The split is the reason this - * file is readable — and the reason it runs under plain `node --test` with - * nothing around it. - * - * Both sides of the app fold with it, and so does anyone else reading a - * folder. The app's main process folds the log, and its fold is the truth. - * The renderer folds only the writes it has sent and not yet heard back - * about, over what main last said, so that a key shows its result without - * waiting for the disk — see `predict`. Two folds that have to agree are one - * fold, or they drift: that is why this is a package and not a copy. + * Nothing here touches a log, a file, a device or a window. Both sides of the + * app fold with it — main's fold is the truth, the renderer's `predict` is a + * guess over it — and so does anyone else reading a folder. Two folds that + * have to agree are one fold, or they drift. */ import { canonicalize } from "../canonicalize/canonicalize.ts"; +import { field, NAMESPACE, unnamed } from "../events/events.ts"; +import type { Named } from "../events/events.ts"; import { read } from "../reading/reading.ts"; -import type { Properties } from "../properties/properties.ts"; +import { spell } from "../titles/titles.ts"; +import type { Titles } from "../titles/titles.ts"; export type Block = { id: string; @@ -34,9 +30,8 @@ export type Block = { export type Page = { /** * What names the page: a local `YYYY-MM-DD` for a journal day, otherwise - * the first spelling the fold saw for it. Case does not matter — `#Mira` - * and `#mira` are one page, see `canonicalize` — but the log keeps each title as - * it was typed, so the page shows a spelling someone actually wrote. + * the first spelling the fold saw for it. Case does not matter (see + * `canonicalize`), but the log keeps each title as it was typed. */ title: string; /** In the order they belong on the page, which is the order they fold. */ @@ -44,13 +39,24 @@ export type Page = { }; /** - * Where a block sits: the array it is in, its index in that array, and the - * block it hangs under. Undefined `parent` means the top level of the page. + * What the block fold reads and writes. `Pages` is one; any state with these + * two fields folds through it. * - * Exported because a caller needs the same walk the fold does — the app's - * main to find the sibling an indent moves a block under, its renderer to - * work out where a caret goes — and two copies of one tree walk that have to - * agree drift. + * `Map`, not a plain object: a title is whatever was typed, and `__proto__` + * is a key every object inherits (#62). Every key in `blocks` is in `titles`, + * but not the other way round: a link names a page before anyone writes on it. + */ +export type BlockState = { + readonly blocks: Map; + readonly titles: Titles; +}; + +// ── The tree ──────────────────────────────────────────────────────────────── + +/** + * Where a block sits: the array it is in, its index there, and the block it + * hangs under (undefined at the top level). Exported because the app walks + * the tree the same way the fold does, and two walks that must agree drift. */ export function locate( blocks: Block[], @@ -66,10 +72,7 @@ export function locate( return undefined; } -/** - * The deepest last descendant — the block that renders directly above - * whatever comes after this one's subtree. Itself, for a leaf. - */ +/** The deepest last descendant: what renders directly above the next block. */ export function lastLeaf(block: Block): Block { let last = block; while (last.children.length > 0) { @@ -79,180 +82,8 @@ export function lastLeaf(block: Block): Block { } /** - * Every page, by `canonicalize`: a journal day or a page a link named. - * - * `titles` holds the first spelling the fold saw for each key, as the `page` - * of a creation or a link in the text of a creation or an edit, and is never - * overwritten: editing the link away later leaves it as it was. A page can - * have a spelling and no blocks yet — a link names it before anyone writes on - * it — so every key in `blocks` is in `titles` but not the other way round. - * - * `properties` sits beside the blocks, not inside them, so nothing that reads - * blocks changes (`properties.ts`, #84). A property event spells its page in - * `titles` too, so every key in `properties` is in `titles` as well. - * - * `Map` throughout, like `properties`: a title is whatever was typed, and as - * a key of a plain object `toString` or `__proto__` is something every object - * inherits. A fold that throws on an event the log keeps forever never opens - * again (#62). A `Map` survives the structured clone to the renderer as well - * as an object does (#146). - */ -export type Pages = { - blocks: Map; - titles: Map; - properties: Properties; -}; - -/** The blocks on a page, empty for one nothing has been written to. */ -export function blocksOn(pages: Pages, title: string): Block[] { - return pages.blocks.get(canonicalize(title)) ?? []; -} - -/** - * How a page is shown: the first spelling the fold saw for it, or `title` - * itself for a page nothing has named yet. - */ -export function titleOf(pages: Pages, title: string): string { - return pages.titles.get(canonicalize(title)) ?? title; -} - -/** - * `titles` with a spelling for each of `names` whose key has none yet, in - * order, so the first of two spellings in one event is the one kept. The - * same object back when every name already has one. - * - * Exported for the property fold, which spells a page the same way. - */ -export function spell( - titles: Pages["titles"], - names: string[], -): Pages["titles"] { - let next = titles; - for (const name of names) { - const key = canonicalize(name); - if (next.has(key)) continue; - // Copied once, on the first new name: what was handed in is a checkpoint. - if (next === titles) next = new Map(titles); - next.set(key, name); - } - return next; -} - -/** - * The pages a block's text links to. Nothing for text that is not a string: - * a payload from a shared folder may be anything, and the parser would throw. - */ -function linksIn(text: unknown): string[] { - return typeof text === "string" ? read(text).links : []; -} - -/** - * Where the user wrote a block. The only event that carries a page, because - * it is the only one that says anything about one: a block's page is settled - * when it is created and no event moves it to another. - */ -export type BlockCreated = { - page: string; - id: string; - text: string; - /** - * The sibling it was written directly beneath, at whatever depth that - * sibling sits. Absent means the end of the page. - * - * A position, never an index: an index stored on a record has to be - * rewritten on every neighbour the moment anything lands between them, - * whereas which block the user wrote beneath is a fact that stays true - * forever. The array is derived from it, the same way on replay as it was - * live. - */ - after?: string; -}; - -/** The later of two facts about one block's text. The earlier one stays. */ -export type BlockEdited = { id: string; text: string }; - -/** That the user was done with it. Nothing leaves the disk. */ -export type BlockDeleted = { id: string }; - -/** The block it became the last child of, resolved when the user indented. */ -export type BlockIndented = { id: string; parent: string }; - -/** The block it used to hang under, and now sits directly beneath. */ -export type BlockOutdented = { id: string; after: string }; - -/** - * Every event this fold understands, and the shape each one's payload has. - * - * The four that follow a creation were once written with a `page` too. It is - * not declared here because it is not read: an extra field on disk is - * invisible to a fold that never asks for it, which is what made dropping it - * an upcast-free change in this direction. `blocks.test.ts` pins that a `v1` - * payload still folds, including one whose page is outright wrong. - * - * Named under the namespace, which is the only name an event has. The first - * logs were written with bare names (`block.created`); those were rewritten - * once (#39) and are not read (#40). - */ -export type PageEvent = - | { type: Named<"block.created">; payload: BlockCreated } - | { type: Named<"block.edited">; payload: BlockEdited } - | { type: Named<"block.deleted">; payload: BlockDeleted } - | { type: Named<"block.indented">; payload: BlockIndented } - | { type: Named<"block.outdented">; payload: BlockOutdented }; - -/** - * What every pbnotes event type starts with: - * `github.com/shovon/pbnotes#block.created`. See #37. - * - * An identifier and not a link. Once a log holds it, it is on disk forever, - * and it stays these bytes wherever the repository goes. - * - * Public contract, with `HANDLES`: programs other than the app read these - * names. A name and a `v`, once written, never mean anything else — a new - * meaning is a new `v` or a new name. See the README. - * - * ponytail: here because this is the only fold there is. A second feature's - * fold takes this out to a package of its own. - */ -export const NAMESPACE = "github.com/shovon/pbnotes#"; - -/** An event type as it is written: under the namespace. */ -type Named = `${typeof NAMESPACE}${T}`; - -/** - * Event type → the payload `v` this build folds, which is also the `v` it - * writes. One number for both on purpose: a build that wrote a version its own - * gate refuses would skip its own events. - * - * Keyed on `PageEvent['type']`, so the table cannot drift from the schema — a - * new event type without a version here, or a version here for an event type - * that does not exist, stops the build. - * - * The block events' half of `HANDLES` in `pages.ts`, which is the table the - * gate reads. The gate itself is `pagesFold` there. Its job is the case a - * single-writer log could never produce: a newer build on another machine - * writing something this one does not understand. Those events are skipped. - */ -export const BLOCK_HANDLES: Record = { - [`${NAMESPACE}block.created`]: 1, - // 2 since the page came off the payload. Nothing needed an upcast in this - // direction — the field is simply unread — but an older build handed one of - // these would look up `state[undefined]`, miss, and drop the user's text - // without a word. At `v: 2` its own gate refuses the event and says so. - [`${NAMESPACE}block.edited`]: 2, - [`${NAMESPACE}block.deleted`]: 2, - [`${NAMESPACE}block.indented`]: 2, - [`${NAMESPACE}block.outdented`]: 2, -}; - -/** - * `block` spliced in directly after `after`, wherever in the tree that is. - * Undefined when `after` is not in this subtree, so the caller can tell - * "not here, keep looking" from "here, and this is the result". - * - * A sibling of `after`, which is what makes `after` alone enough to place a - * block at any depth: Enter inside a nested block writes the next one beside - * it, not back at the top level. + * `block` spliced in directly after `after`, as its sibling, at whatever depth + * `after` sits. Undefined when `after` is not in this subtree. */ export function beside( blocks: Block[], @@ -304,6 +135,13 @@ function mapChildren( ); } +// ── Pages of blocks ───────────────────────────────────────────────────────── + +/** The blocks on a page, empty for one nothing has been written to. */ +export function blocksOn(holds: Pick, title: string): Block[] { + return holds.blocks.get(canonicalize(title)) ?? []; +} + /** Where a block sits, and which page it sits on, by the title it shows. */ export type Found = NonNullable> & { page: string; @@ -312,65 +150,61 @@ export type Found = NonNullable> & { /** * The block, wherever in the project it is. A block id is unique across every - * page, so it is enough on its own — which is why nothing after - * `block.created` carries a page. + * page, which is why nothing after `block.created` carries a page. * * ponytail: scans every page per event, so a replay is O(blocks × events). * Keep a block id → page index in the fold state if a long log ever shows up - * as a slow launch; a delete would have to drop that block's whole subtree - * from it, the way `without` does. + * as a slow launch. */ -export function find(pages: Pages, id: string): Found | undefined { - for (const [key, blocks] of pages.blocks) { +export function find(state: BlockState, id: string): Found | undefined { + for (const [key, blocks] of state.blocks) { const where = locate(blocks, id); - if (where) return { page: pages.titles.get(key) ?? key, blocks, ...where }; + if (where) return { page: state.titles.get(key) ?? key, blocks, ...where }; } return undefined; } -/** - * Applies `change` to the page the block turned out to be on. A block that is - * not in the project, or an `undefined` from `change`, leaves the view exactly - * as it was. - * - * The page comes from `find` and never from a payload. A page on an edit would - * be a copy of where the block sat when the edit was typed — true on the - * machine that wrote it, and a miss that drops the user's text the moment - * anything else is true. This is the one place that could reintroduce that, so - * it is the only place allowed to choose which page a fold writes to. - */ -function onBlock( - state: Pages, - id: string, - change: (found: Found) => Block[] | undefined, -): Pages { - const found = find(state, id); - if (!found) return state; - const blocks = change(found); - return blocks - ? { - ...state, - blocks: new Map(state.blocks).set(canonicalize(found.page), blocks), - } - : state; -} +// ── The events ────────────────────────────────────────────────────────────── + +/** Where the user wrote a block. The only event that carries a page. */ +export type BlockCreated = { + page: string; + id: string; + text: string; + /** + * The sibling it was written directly beneath, at whatever depth. Absent + * means the end of the page. A position, never an index: which block the + * user wrote beneath stays true forever, an index does not. + */ + after?: string; +}; + +/** The later of two facts about one block's text. The earlier one stays. */ +export type BlockEdited = { id: string; text: string }; + +/** That the user was done with it. Nothing leaves the disk. */ +export type BlockDeleted = { id: string }; + +/** The block it became the last child of, resolved when the user indented. */ +export type BlockIndented = { id: string; parent: string }; + +/** The block it used to hang under, and now sits directly beneath. */ +export type BlockOutdented = { id: string; after: string }; /** - * The default branch of the fold. `event` is `never` to the compiler, so - * adding a type to `PageEvent` without a case for it stops the build here — - * which matters because the alternative is a page that silently folds without - * part of itself, the exact silence this log is built against. - * - * At run time it is reachable and ordinary: an event type some other build - * wrote, already refused by the gate in `pagesFold`. The view is left as it - * was either way. + * Every event this fold understands, and its payload. The four after a + * creation were once written with a `page` too; it is not read, so a `v1` + * payload still folds (`blocks.test.ts` pins that). */ -function foreign(_event: never, state: Pages): Pages { - return state; -} +export type PageEvent = + | { type: Named<"block.created">; payload: BlockCreated } + | { type: Named<"block.edited">; payload: BlockEdited } + | { type: Named<"block.deleted">; payload: BlockDeleted } + | { type: Named<"block.indented">; payload: BlockIndented } + | { type: Named<"block.outdented">; payload: BlockOutdented }; -/** A `PageEvent` with the namespace off its type, which is what `reduce` switches on. */ -type Bare = E extends { +/** A `PageEvent` with the namespace off its type. */ +export type Bare = E extends { type: `${typeof NAMESPACE}${infer T}`; payload: infer P; } @@ -378,52 +212,117 @@ type Bare = E extends { : never; /** - * Pure, and over state that survives a structured clone: the view crosses to - * the renderer as data and the fold may one day run off-thread. + * Event type → the payload `v` this build folds, which is also the `v` it + * writes. Keyed on `PageEvent['type']`, so it cannot drift from the schema. + * The block half of `HANDLES`; the gate that reads it is `pagesFold`. + */ +export const BLOCK_HANDLES: Record = { + [`${NAMESPACE}block.created`]: 1, + // 2 since the page came off the payload: an older build handed one would + // look up a page of `undefined` and drop the text without a word. At `v: 2` + // its own gate refuses the event instead. + [`${NAMESPACE}block.edited`]: 2, + [`${NAMESPACE}block.deleted`]: 2, + [`${NAMESPACE}block.indented`]: 2, + [`${NAMESPACE}block.outdented`]: 2, +}; + +// ── The fold: parse, then apply ───────────────────────────────────────────── + +/** A string field of the payload, or undefined for anything else. */ +function string(payload: object, name: string): string | undefined { + const value = field(payload, name); + return typeof value === "string" ? value : undefined; +} + +/** + * A block event as this build reads it, or undefined for anything else: not + * one of ours, or malformed. A malformed event is skipped whole, never folded + * in part. The types say what *this build writes*; what arrives in a shared + * folder was written by something else, and may be anything. * - * Each branch pulls in the fields its own event has and no others. A payload - * shape that changes gets a new `v` and a branch of its own here; the file on - * disk is never rewritten, because an edit is a second fact appended after the - * first rather than a correction of it. + * Two leniencies, both so that a block is never dropped that could be shown: + * a page that is not a string is filed under its `String` (it always was), + * and an `after` that is not a string means the end of the page. + */ +function parse(event: { type: string; payload: unknown }): Bare | undefined { + const name = unnamed(event.type); + const payload = event.payload; + if (!name || typeof payload !== "object" || payload === null) return undefined; + const id = string(payload, "id"); + if (!id) return undefined; + + switch (name) { + case "block.created": { + const text = string(payload, "text"); + if (text === undefined) return undefined; + const page = String(field(payload, "page")); + const after = string(payload, "after"); + return { type: name, payload: { page, id, text, ...(after ? { after } : {}) } }; + } + case "block.edited": { + const text = string(payload, "text"); + return text === undefined ? undefined : { type: name, payload: { id, text } }; + } + case "block.deleted": + return { type: name, payload: { id } }; + case "block.indented": { + const parent = string(payload, "parent"); + return parent ? { type: name, payload: { id, parent } } : undefined; + } + case "block.outdented": { + const after = string(payload, "after"); + return after ? { type: name, payload: { id, after } } : undefined; + } + default: + return undefined; + } +} + +/** + * Applies `change` to the page the block turned out to be on. A block that is + * not in the project, or an `undefined` from `change`, leaves the state as it + * was. * - * Guards stay on fields the schema calls required. The types describe what - * *this build writes*; what arrives in a shared folder was written by - * something else, and may be anything. + * The page comes from `find` and never from a payload: a page on an edit + * would be where the block sat on the machine that wrote it, and a miss drops + * the user's text. This is the only place that chooses which page a fold + * writes to. */ -export function reduce( - state: Pages, - event: { type: string; payload: unknown }, -): Pages { - // Ours are under the namespace and nothing else is: a bare `block.created` - // is not this event (#40), and the gate in `pagesFold` refuses one too. - if (!event.type.startsWith(NAMESPACE)) return state; - // The one cast in the fold: "if this is one of ours, this is its shape." - // `foreign` below is where that turns out not to be true. - const it = { - ...event, - type: event.type.slice(NAMESPACE.length), - } as unknown as Bare; +function onBlock( + state: S, + id: string, + change: (found: Found) => Block[] | undefined, +): S { + const found = find(state, id); + if (!found) return state; + const blocks = change(found); + return blocks + ? { ...state, blocks: new Map(state.blocks).set(canonicalize(found.page), blocks) } + : state; +} + +/** Adding a type to `PageEvent` without a case for it stops the build here. */ +function foreign(_event: never, state: unknown): never { + return state as never; +} +/** One parsed event applied. Pure, and never changes what it is handed. */ +function apply(state: S, it: Bare): S { switch (it.type) { case "block.created": { - const { id, text, after } = it.payload; - // A string for whatever was there: a missing page used to file under - // `"undefined"` as a key, and `canonicalize` would throw on it. - const page = String(it.payload.page); + const { page, id, text, after } = it.payload; const blocks = blocksOn(state, page); const block: Block = { id, text, children: [] }; - // `addBlock` refuses an `after` the page does not have, so missing it - // here means a log written by something else. Append rather than drop: a - // block in the wrong place can be moved, one the fold discarded is gone. - const next = (after && beside(blocks, after, block)) || [ - ...blocks, - block, - ]; + // `addBlock` refuses an `after` the page lacks, so a miss here is a log + // written by something else. Append rather than drop: a block in the + // wrong place can be moved, one the fold discarded is gone. + const next = (after && beside(blocks, after, block)) || [...blocks, block]; return { ...state, blocks: new Map(state.blocks).set(canonicalize(page), next), // The page before its links: it is where the user was writing. - titles: spell(state.titles, [page, ...linksIn(text)]), + titles: spell(state.titles, [page, ...read(text).links]), }; } @@ -432,36 +331,26 @@ export function reduce( const next = onBlock(state, id, ({ blocks }) => retext(blocks, id, text)); // An edit to a block the ledger lacks names nothing either. if (next === state) return state; - return { ...next, titles: spell(next.titles, linksIn(text)) }; + return { ...next, titles: spell(next.titles, read(text).links) }; } case "block.deleted": { + // A plain filter, no tombstone, and the subtree goes with it. Every + // event that built them is still on disk. const { id } = it.payload; - // A plain filter, no tombstone. Nothing later in the log needs to know - // this block was here: `after` only ever names a block that was present - // when it was written, and a stray edit arriving afterwards already - // folds to nothing through `onBlock`. - // - // The subtree goes with it. The events that built those children are all - // still on disk — what the fold stops showing is a block the user said - // they were done with, and everything they had filed underneath it. return onBlock(state, id, ({ blocks }) => without(blocks, id)); } case "block.indented": { const { id, parent } = it.payload; return onBlock(state, id, (found) => { - // A parent on another page is not a move across pages; there is no - // such event yet, so it folds to nothing. - if (!parent || !locate(found.blocks, parent)) return undefined; - // Read before the detach: the block carries its own children across, - // so what lands under `parent` is the whole subtree, not a stripped - // root. + // A parent on another page would be a move across pages, and there + // is no such event yet. + if (!locate(found.blocks, parent)) return undefined; + // Read before the detach, so the whole subtree moves. const moving = found.siblings[found.at]; - // Under itself or its own subtree: the detach would take `parent` - // with it, and both would leave the page. Two machines each indenting - // one block under the other before they sync is how this gets - // written (#62). + // Under itself or its own subtree: two machines each indenting one + // block under the other before they sync writes this (#62). if (parent === id || locate(moving.children, parent)) return undefined; return mapChildren(without(found.blocks, id), parent, (children) => [ ...children, @@ -473,24 +362,15 @@ export function reduce( case "block.outdented": { const { id, after } = it.payload; return onBlock(state, id, (found) => { - if (!after || !found.parent) return undefined; - - // The siblings below it come along as its own children. Leaving them - // behind would strand them inside the old parent, which renders - // *above* where this block is going — so the page would come back - // reading in a different order than the user left it, off one - // keystroke that only asked about depth. + if (!found.parent) return undefined; + // The siblings below it come along as its own children, or they would + // be stranded above it and the page would read in a different order. const moving = found.siblings[found.at]; const trailing = found.siblings.slice(found.at + 1); - const outdented = { - ...moving, - children: [...moving.children, ...trailing], - }; + const outdented = { ...moving, children: [...moving.children, ...trailing] }; const trimmed = mapChildren(found.blocks, found.parent.id, (children) => children.slice(0, found.at), ); - // `after` is the block it used to hang under, so this lands it - // directly beneath, at that block's own level. return beside(trimmed, after, outdented) ?? [...trimmed, outdented]; }); } @@ -501,12 +381,24 @@ export function reduce( } /** - * What indenting a block is, on the page as it stands: filed under the - * sibling above it. Undefined for a block that is first among its siblings — - * there is nothing above it to move under — or one the page does not have. - * - * Here, and not in the store, because whoever predicts a move has to resolve - * it exactly as whoever records it does. + * The block fold: one event in, the state out. Hands back `state` itself for + * any event that is not a well-formed block event, so it composes with the + * property fold. + */ +export function reduce( + state: S, + event: { type: string; payload: unknown }, +): S { + const parsed = parse(event); + return parsed ? apply(state, parsed) : state; +} + +// ── Moves, and predicting them ────────────────────────────────────────────── + +/** + * What indenting a block is: filed under the sibling above it. Undefined for + * a block first among its siblings, or one the page lacks. Here, because + * whoever predicts a move has to resolve it exactly as whoever records it. */ export function indented( blocks: Block[], @@ -518,10 +410,7 @@ export function indented( : undefined; } -/** - * What outdenting a block is: directly beneath the block it hangs under. - * Undefined at the top level, where there is nothing to come out of. - */ +/** What outdenting is: directly beneath the block it hangs under. */ export function outdented( blocks: Block[], id: string, @@ -532,42 +421,34 @@ export function outdented( /** * Whether the page already shows what `event` did. Asked only of the events - * that are not safe to fold twice: a creation would put a second block on the - * page, and an outdent would take the siblings now below the block as its - * children all over again. + * that are not safe to fold twice. */ -function landed(state: Pages, event: PageEvent): boolean { - if (event.type === `${NAMESPACE}block.created`) - return !!find(state, event.payload.id); +function landed(state: BlockState, event: PageEvent): boolean { const found = find(state, event.payload.id); - if (event.type === `${NAMESPACE}block.indented`) { - return found?.parent?.id === event.payload.parent; - } - if (event.type === `${NAMESPACE}block.outdented`) { - return found?.siblings[found.at - 1]?.id === event.payload.after; + switch (event.type) { + case `${NAMESPACE}block.created`: + return !!found; + case `${NAMESPACE}block.indented`: + return found?.parent?.id === event.payload.parent; + case `${NAMESPACE}block.outdented`: + return found?.siblings[found.at - 1]?.id === event.payload.after; + default: + return false; } - return false; } /** - * The page as it will stand once `events` have been recorded: writes the - * renderer has sent and not yet heard back about, folded over the page main - * last sent. - * - * A guess, and thrown away on purpose. The renderer drops an event when its - * write settles, and a write settles only once the state it led to is in — - * so there is a moment when the page already holds an event that is still in - * `events`. `landed` is what makes that moment draw the same page rather - * than the event twice. A write main refuses is dropped the same way, and the - * page goes back to what main has. + * The page as it will stand once `events` are recorded: the renderer's unsent + * writes, folded over the page main last sent. A guess, thrown away when each + * write settles. `landed` keeps an event the page already holds from being + * drawn twice in the moment before it is dropped. */ export function predict(page: Page, events: PageEvent[]): Page { if (events.length === 0) return page; const key = canonicalize(page.title); - let state: Pages = { + let state: BlockState = { blocks: new Map([[key, page.blocks]]), titles: new Map([[key, page.title]]), - properties: new Map(), }; for (const event of events) { if (!landed(state, event)) state = reduce(state, event); diff --git a/packages/pages/src/blocks/commands.ts b/packages/pages/src/blocks/commands.ts new file mode 100644 index 0000000..8952990 --- /dev/null +++ b/packages/pages/src/blocks/commands.ts @@ -0,0 +1,148 @@ +/** + * Writing blocks: a pure builder per event, and a command per keystroke that + * checks the page before it builds and dispatches. + */ +import { NAMESPACE } from "../events/events.ts"; +import type { Named } from "../events/events.ts"; +import { BLOCK_HANDLES, blocksOn, find, indented, locate, outdented } from "./blocks.ts"; +import type { Bare, BlockIndented, BlockOutdented, BlockState, Found, Page, PageEvent } from "./blocks.ts"; +import { getPage } from "./queries.ts"; + +/** + * What the commands need: the state as it stands, and a way to append one of + * this fold's events. `PagesView` is one. + */ +export type BlocksView = { + readonly state: BlockState; + /** Resolves once the event is durable and folded into `state`. */ + dispatch(v: number, type: PageEvent["type"], payload: unknown): Promise; +}; + +/** One event as it is written: schema type and payload, plus the `v` the gate reads. */ +type Built = Extract }> & { v: number }; + +/** + * The one place an event is put together. The payload is typed against the + * schema and `v` comes from `BLOCK_HANDLES`, because a payload that drifts + * folds to nothing and a hand-typed `v` the gate refuses skips this build's + * own writes. Reads no clock and no randomness. + */ +function build( + name: T, + payload: Extract["payload"], +): Built { + const type = `${NAMESPACE}${name}` as Named; + return { v: BLOCK_HANDLES[type], type, payload } as Built; +} + +/** + * The `block.created` event: the same bytes `addBlock` writes. A writer with + * no fold (the CLI) can write a valid one if it trusts its own `after` and + * `id`. An absent `after` is left off, so an appended block writes the bytes + * it always did. + */ +export function blockCreated( + page: string, + text: string, + after: string | undefined, + id: string, +): Built<"block.created"> { + return build("block.created", { page, id, text, ...(after ? { after } : {}) }); +} + +/** The `block.edited` event: `id`'s text is now `text`. */ +export function blockEdited(id: string, text: string): Built<"block.edited"> { + return build("block.edited", { id, text }); +} + +/** The `block.deleted` event: the user is done with `id`. */ +export function blockDeleted(id: string): Built<"block.deleted"> { + return build("block.deleted", { id }); +} + +/** The `block.indented` event for a move `indented` resolved. */ +export function blockIndented(payload: BlockIndented): Built<"block.indented"> { + return build("block.indented", payload); +} + +/** The `block.outdented` event for a move `outdented` resolved. */ +export function blockOutdented(payload: BlockOutdented): Built<"block.outdented"> { + return build("block.outdented", payload); +} + +function append(view: BlocksView, event: Built): Promise { + return view.dispatch(event.v, event.type, event.payload); +} + +/** + * The shape of every command on an existing block: refuse one the ledger + * lacks, write the event `eventFor` makes (none when the keystroke changes + * nothing, so the log does not collect events that mean nothing), and hand + * back the page the block is on. + */ +async function onExisting( + view: BlocksView, + blockId: string, + eventFor: (found: Found) => Built | undefined, +): Promise { + const found = find(view.state, blockId); + if (!found) throw new Error("No such block"); + const event = eventFor(found); + if (event) await append(view, event); + return getPage(view.state, found.page); +} + +/** + * Writes a new block at the end of the page or directly beneath `after`. + * Refuses an `after` not on this page, and an id the ledger already has. The + * id is always the caller's: the block on disk has to be the one on screen. + */ +export async function addBlock( + view: BlocksView, + title: string, + text: string, + after: string | undefined, + id: string, +): Promise { + if (after && !locate(blocksOn(view.state, title), after)) { + throw new Error("No such block"); + } + if (find(view.state, id)) throw new Error("That block already exists"); + // dispatch appends before it folds, so the page handed back can never show + // something a crash takes. + await append(view, blockCreated(title, text, after, id)); + return getPage(view.state, title); +} + +/** Rewrites a block's text. The block is found by id, on whatever page it is. */ +export function editBlock(view: BlocksView, blockId: string, text: string): Promise { + return onExisting(view, blockId, () => blockEdited(blockId, text)); +} + +/** Removes a block and its subtree from view. Nothing is taken off disk. */ +export function deleteBlock(view: BlocksView, blockId: string): Promise { + return onExisting(view, blockId, () => blockDeleted(blockId)); +} + +/** + * Moves a block under the sibling above it, children and all. The event + * records the resolved parent, not "Tab was pressed". A block first among its + * siblings writes nothing. + */ +export function indentBlock(view: BlocksView, blockId: string): Promise { + return onExisting(view, blockId, (found) => { + const payload = indented(found.blocks, blockId); + return payload && blockIndented(payload); + }); +} + +/** + * Brings a block out a level, directly beneath what it hung under, taking the + * siblings below it as its children. A top-level block writes nothing. + */ +export function outdentBlock(view: BlocksView, blockId: string): Promise { + return onExisting(view, blockId, (found) => { + const payload = outdented(found.blocks, blockId); + return payload && blockOutdented(payload); + }); +} diff --git a/packages/pages/src/blocks/queries.ts b/packages/pages/src/blocks/queries.ts new file mode 100644 index 0000000..fa00d53 --- /dev/null +++ b/packages/pages/src/blocks/queries.ts @@ -0,0 +1,110 @@ +/** + * Reads over the folded blocks. Each takes the state alone, since none of + * them writes. + */ +import { canonicalize } from "../canonicalize/canonicalize.ts"; +import { DATE_PATTERN } from "../journal/journal.ts"; +import { read } from "../reading/reading.ts"; +import { titleOf } from "../titles/titles.ts"; +import { blocksOn, find } from "./blocks.ts"; +import type { Block, BlockState, Page } from "./blocks.ts"; + +/** A page as it stands, under the spelling it shows, empty if nothing is on it. */ +export function getPage(state: BlockState, title: string): Page { + return { title: titleOf(state, title), blocks: blocksOn(state, title) }; +} + +/** Every block in a tree, depth first. */ +function* every(blocks: Block[]): Generator { + for (const block of blocks) { + yield block; + yield* every(block.children); + } +} + +/** + * The blocks that link to `title`, in whatever case the link was written, + * each with its subtree. A block that links is taken whole and not searched + * inside: its children are already on show under it. + */ +export function linking(blocks: Block[], title: string): Block[] { + const key = canonicalize(title); + return blocks.flatMap((block) => + read(block.text).links.some((link) => canonicalize(link) === key) + ? [block] + : linking(block.children, title), + ); +} + +/** + * What links to a page, grouped by the page it was written on and cut down to + * the blocks that do the linking. A page is not a reference to itself. + * + * Journal days first, newest first, then named pages A to Z. Days are told + * apart before anything is compared, so a page called `1984` does not land + * among them. + * + * ponytail: one call per page shown. Fold this into a single reverse index if + * a ledger ever has enough days for that to show. + */ +export function getReferences(state: BlockState, title: string): Page[] { + const key = canonicalize(title); + return [...state.blocks] + .filter(([page]) => page !== key) + .map(([page, on]) => ({ title: state.titles.get(page) ?? page, blocks: linking(on, title) })) + .filter((page) => page.blocks.length > 0) + .sort((a, b) => { + const days = [a, b].map((page) => DATE_PATTERN.test(page.title)); + if (days[0] !== days[1]) return days[0] ? -1 : 1; + return days[0] ? b.title.localeCompare(a.title) : a.title.localeCompare(b.title); + }); +} + +/** + * Every block the pages refer to with `((id))`, and what a ref to it shows: + * the target's first paragraph, or null for an id the ledger lacks. + * + * Pairs, not an object: an id is whatever was typed, and `((__proto__))` is a + * key an object does not hold. + */ +export function getPreviews(state: BlockState): [string, string | null][] { + const ids = new Set(); + for (const blocks of state.blocks.values()) { + for (const block of every(blocks)) read(block.text).refs.forEach((id) => ids.add(id)); + } + return [...ids].map((id) => { + const found = find(state, id); + return [id, found ? read(found.siblings[found.at].text).preview : null]; + }); +} + +/** + * The page titles that contain `query`, ignoring case: prefix matches first, + * then the rest, each A to Z, at most `limit`. A page is any title with blocks + * on it and any title a link names. Walks the blocks rather than `titles`, so + * a typo linked once and fixed is not offered forever. + * + * ponytail: walks every block per call, with `read` memoised. Keep a title set + * in the fold if a ledger ever gets big enough for that to show. + */ +export function searchPages(state: BlockState, query: string, limit = 10): string[] { + const titles = new Set(); + const add = (title: string): void => void titles.add(titleOf(state, title)); + for (const [key, blocks] of state.blocks) { + if (blocks.length > 0) add(key); + for (const block of every(blocks)) read(block.text).links.forEach(add); + } + const q = canonicalize(query); + return [...titles] + .map((title) => ({ title, at: canonicalize(title).indexOf(q) })) + .filter(({ at }) => at !== -1) + .sort((a, b) => Number(a.at !== 0) - Number(b.at !== 0) || a.title.localeCompare(b.title)) + .slice(0, limit) + .map(({ title }) => title); +} + +/** The page a block is on, or undefined for a block the ledger lacks. */ +export function locateBlock(state: BlockState, blockId: string): { page: string } | undefined { + const found = find(state, blockId); + return found && { page: found.page }; +} diff --git a/packages/pages/src/events/events.ts b/packages/pages/src/events/events.ts new file mode 100644 index 0000000..e652312 --- /dev/null +++ b/packages/pages/src/events/events.ts @@ -0,0 +1,33 @@ +/** + * What every pbnotes event looks like on the way in: a name under the + * namespace, and a payload from a shared folder that may be anything. Both + * folds read events through this, and neither owns it. + */ + +/** + * What every pbnotes event type starts with: + * `github.com/shovon/pbnotes#block.created`. See #37. + * + * An identifier and not a link. Once a log holds it, it is on disk forever, + * and it stays these bytes wherever the repository goes. Public contract, with + * `HANDLES`: see the README. + */ +export const NAMESPACE = "github.com/shovon/pbnotes#"; + +/** An event type as it is written: under the namespace. */ +export type Named = `${typeof NAMESPACE}${T}`; + +/** + * The name under the namespace, or undefined for a type outside it. A bare + * `block.created` is not one of ours (#40). + */ +export function unnamed(type: string): string | undefined { + return type.startsWith(NAMESPACE) ? type.slice(NAMESPACE.length) : undefined; +} + +/** A field the payload holds itself, never one every object inherits (#62). */ +export function field(payload: object, name: string): unknown { + return Object.hasOwn(payload, name) + ? (payload as Record)[name] + : undefined; +} diff --git a/packages/pages/src/journal/journal.ts b/packages/pages/src/journal/journal.ts new file mode 100644 index 0000000..7e69c2f --- /dev/null +++ b/packages/pages/src/journal/journal.ts @@ -0,0 +1,20 @@ +/** + * What makes a title a journal day. Whoever writes picks the day, so the fold + * never has to hold an opinion about the user's timezone; this only tells the + * journal apart from the pages that links name. + */ +export const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Today as `YYYY-MM-DD`, in the user's local time. Not `toISOString()`, which + * is UTC and hands the wrong page to anyone whose evening is already tomorrow + * in Greenwich. + * + * Shared because everyone who writes the journal needs the same day: the + * app's main, its renderer and the CLI. + */ +export function today(now: Date): string { + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${now.getFullYear()}-${month}-${day}`; +} diff --git a/packages/pages/src/pages.ts b/packages/pages/src/pages.ts index b2fc7e9..77125b1 100644 --- a/packages/pages/src/pages.ts +++ b/packages/pages/src/pages.ts @@ -1,43 +1,27 @@ /** - * Pages: what pbnotes events mean, read and written through whichever view of - * the pages fold a caller hands in. Nothing here knows what that is a view - * of: not a ledger, not its id, not where it is kept. A program that gets its - * events some other way can fold them with this all the same. - * - * No plumbing, and no import of any — not `@pbnotes/ledger`, and no - * `node:` module, because the app's renderer bundles this too. What a block - * *is* — the event schema and the fold over it — is `blocks.ts`. What is - * left here is the fold as it is handed to whoever runs it, the commands that - * author events and the queries over what they fold to. - * - * What several machines writing at once does to a page, and what the block - * fold still gets wrong then, is `app/docs/block-sync.md`. - */ -import { - BLOCK_HANDLES, - blocksOn, - find, - indented, - locate, - NAMESPACE, - outdented, - reduce, - titleOf, -} from "./blocks/blocks.ts"; -import type { - Block, - BlockIndented, - BlockOutdented, - Page, - PageEvent, - Pages, -} from "./blocks/blocks.ts"; -import { canonicalize } from "./canonicalize/canonicalize.ts"; -import { linking, read } from "./reading/reading.ts"; + * Pages: what pbnotes events mean. This is where the slices meet — the block + * fold and the property fold composed behind one gate, the state they share, + * and the one read that needs both. Everything else is re-exported from the + * slice that owns it. + * + * No plumbing, and no import of any: not `@pbnotes/ledger`, and no `node:` + * module, because the app's renderer bundles this too. A caller hands the + * commands a view whose `dispatch` writes to its own log. + * + * What several machines writing at once does to a page is + * `app/docs/block-sync.md`. + */ +import { BLOCK_HANDLES, reduce } from "./blocks/blocks.ts"; +import type { Block, BlockState, PageEvent, Page } from "./blocks/blocks.ts"; +import type { BlocksView } from "./blocks/commands.ts"; +import { DATE_PATTERN } from "./journal/journal.ts"; import { PROPERTY_HANDLES, reduceProperties } from "./properties/properties.ts"; -import type { PropertyEvent } from "./properties/properties.ts"; +import type { Properties, PropertyEvent } from "./properties/properties.ts"; export { canonicalize } from "./canonicalize/canonicalize.ts"; +export { NAMESPACE } from "./events/events.ts"; +export { DATE_PATTERN, today } from "./journal/journal.ts"; +export { titleOf } from "./titles/titles.ts"; export { beside, blocksOn, @@ -45,11 +29,9 @@ export { indented, lastLeaf, locate, - NAMESPACE, outdented, predict, reduce, - titleOf, without, } from "./blocks/blocks.ts"; export type { @@ -62,9 +44,28 @@ export type { Found, Page, PageEvent, - Pages, } from "./blocks/blocks.ts"; -export { inCode, linking, read } from "./reading/reading.ts"; +export { + getPage, + getPreviews, + getReferences, + linking, + locateBlock, + searchPages, +} from "./blocks/queries.ts"; +export { + addBlock, + blockCreated, + blockDeleted, + blockEdited, + blockIndented, + blockOutdented, + deleteBlock, + editBlock, + indentBlock, + outdentBlock, +} from "./blocks/commands.ts"; +export { inCode, read } from "./reading/reading.ts"; export { children, get, @@ -90,31 +91,20 @@ export type { export { remarkPlugins, remarkWikilink } from "./wikilink/wikilink.ts"; /** - * What makes a title a journal day. Whoever writes picks the day, so the fold - * never has to hold an opinion about the user's timezone; this only tells the - * journal apart from the pages that links name. - */ -export const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; - -/** - * Today as `YYYY-MM-DD`, in the user's local time. Not `toISOString()`, which - * is UTC and hands the wrong page to anyone whose evening is already - * tomorrow in Greenwich — or whose morning is still yesterday. + * Every page, by `canonicalize`: the blocks on it, the first spelling the + * fold saw for it, and its properties. Properties sit beside the blocks, not + * inside them, so nothing that reads blocks changes (#84). Every key in + * `blocks` or `properties` is in `titles`. * - * Here because everyone who writes the journal needs the same day: the app's - * main puts today at the top of the journal, its renderer writes a dropped - * image there, and the CLI appends to it. + * `Map` throughout, so a title of `__proto__` is an ordinary key (#62), and + * it survives the structured clone to the renderer (#146). */ -export function today(now: Date): string { - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${now.getFullYear()}-${month}-${day}`; -} +export type Pages = BlockState & { readonly properties: Properties }; /** - * Event type → the payload `v` this build folds, which is also the `v` it - * writes: the block events and the property events in one table, because - * the gate reads one. Public contract with `NAMESPACE`; see the README. + * Event type → the payload `v` this build folds and writes, for both folds in + * one table, because the gate reads one. Public contract with `NAMESPACE`; + * see the README. */ export const HANDLES: Record = { ...BLOCK_HANDLES, @@ -125,15 +115,12 @@ export const HANDLES: Record * The pages fold, for whoever runs it over a ledger: `ledger.onEvent( * pagesFold.initial, pagesFold.reduce)`. * - * The ledger hands it every event, so the gate is here: an event type this - * build does not have — a bare name (#40) among them — or a `v` newer than it - * reads leaves the pages as they were. A build that wrote a `v` its own gate - * refuses would skip its own events, which is why the builders (`blockCreated` - * and the rest) take `v` from the same table. + * The gate is here: a type this build does not have, or a `v` newer than it + * reads, leaves the pages as they were. Behind it, the block fold then the + * property fold; each hands back the state it was given for an event that is + * not its own, so composing them is all it takes. */ export const pagesFold = { - // The block fold, then the property fold. Each hands back the state it was - // given for an event that is not its own, so composing them is all it takes. reduce: (state: Pages, event: { type: string; v: number; payload: unknown }): Pages => Object.hasOwn(HANDLES, event.type) && event.v <= HANDLES[event.type as keyof typeof HANDLES] @@ -142,45 +129,17 @@ export const pagesFold = { initial: { blocks: new Map(), titles: new Map(), properties: new Map() } as Pages, }; -/** - * What the commands are handed: the pages as they stand, and the way to - * append one of this fold's events. A writer wires `dispatch` to its own log. - * The queries take the pages alone, since none of them writes. - */ -export type PagesView = { - readonly state: Pages; - /** Resolves once the event is durable and folded. */ - dispatch(v: number, type: PageEvent["type"], payload: unknown): Promise; -}; - -/** - * The page as it stands right now, empty if nothing has been written to it, - * under the spelling it shows whichever spelling it was asked for. - */ -export function getPage(pages: Pages, title: string): Page { - return { - title: titleOf(pages, title), - blocks: blocksOn(pages, title), - }; -} +/** What the commands are handed: the pages as they stand, and a `dispatch`. */ +export type PagesView = BlocksView & { readonly state: Pages }; /** - * The journal: every day that has something on it, newest first, out of the - * same fold that holds every other page. Only the days — a page a link named - * shares the fold and the events but is not a day, and stacking it among them - * would sort `Mira` somewhere between two years. - * - * A day that folds to nothing is left out. A date whose only block was - * deleted is not a page the user wrote on any more, and a heading with - * nothing under it reads like something went missing. A day that has - * properties and no blocks is still a day (#84). - * - * Today is not added here. Which day that is depends on the clock, and this - * is a reading of the log — a caller that shows a journal puts today at the - * top itself, whether or not it is in this list. + * The journal: every day that has something on it, newest first. A day whose + * only block was deleted is left out; a day with properties and no blocks is + * still a day (#84). Today is not added: which day that is depends on the + * clock, and a caller that shows a journal puts it at the top itself. * - * Sorted on the date string, which for `YYYY-MM-DD` is the same order as the - * dates themselves. That is the reason for the format. + * Here, not in a slice, because it is the one read that needs blocks and + * properties both. */ export function getPages(pages: Pages): Page[] { const { blocks, titles, properties } = pages; @@ -194,321 +153,3 @@ export function getPages(pages: Pages): Page[] { .sort((a, b) => b.localeCompare(a)) .map((key) => ({ title: titles.get(key) ?? key, blocks: on(key) })); } - -/** - * What links to a page, grouped by the page it was written on and cut down - * to the blocks that do the linking. A page is not a reference to itself. - * - * Journal days first, newest first, then the named pages A to Z. Days are - * told apart before anything is compared: a page called `2026` or `1984` - * would otherwise land among them, and a comparator that turns dates around - * but leaves everything else forward is not an order at all once a name - * sorts between two dates. - * - * ponytail: one call per page shown, so the journal makes one per day. Fold - * this into a single reverse index per call if a ledger ever has enough - * days for that to show. - */ -export function getReferences(pages: Pages, title: string): Page[] { - const { blocks, titles } = pages; - const key = canonicalize(title); - return [...blocks] - .filter(([page]) => page !== key) - .map(([page, on]) => ({ title: titles.get(page) ?? page, blocks: linking(on, title) })) - .filter((page) => page.blocks.length > 0) - .sort((a, b) => { - const days = [a, b].map((page) => DATE_PATTERN.test(page.title)); - if (days[0] !== days[1]) return days[0] ? -1 : 1; - return days[0] - ? b.title.localeCompare(a.title) - : a.title.localeCompare(b.title); - }); -} - -/** - * Every block the pages refer to with `((id))`, and what a ref to it - * shows: the target's first paragraph, or null for an id the ledger does - * not have — deleted, or never there. - * - * For the whole ledger rather than per page, and resolved here rather than - * in a view: a ref points anywhere, a view holds only the pages on - * screen, and the fold has all of them. The answer is as big as the number of - * blocks anyone has referred to, which is small. - * - * Pairs, not an object: an id is whatever was typed between the parentheses, - * and `((__proto__))` is a key an object does not hold. - */ -export function getPreviews(pages: Pages): [string, string | null][] { - const ids = new Set(); - const collect = (blocks: Block[]): void => { - for (const block of blocks) { - for (const id of read(block.text).refs) ids.add(id); - collect(block.children); - } - }; - for (const blocks of pages.blocks.values()) collect(blocks); - return [...ids].map((id) => { - const found = find(pages, id); - return [id, found ? read(found.siblings[found.at].text).preview : null]; - }); -} - -/** - * The page titles that contain `query`, ignoring case: the ones that start - * with it first, then the rest, each A to Z, at most `limit` of them. A page - * is any title with blocks on it and any title a link names, since naming a - * page is all it takes for one to exist. One title per page, in the spelling - * it shows. - * - * Walks the blocks rather than listing `titles`, which remembers every page - * ever named: a typo linked once and fixed would be offered forever. - * - * Titles only for now. #28 searches block text through the same call. - * - * ponytail: walks every block on each call, with `read` memoised. Keep a - * title set in the fold if a ledger ever gets big enough for that to show. - */ -export function searchPages( - pages: Pages, - query: string, - limit = 10, -): string[] { - const titles = new Set(); - const add = (title: string): void => void titles.add(titleOf(pages, title)); - const collect = (blocks: Block[]): void => { - for (const block of blocks) { - read(block.text).links.forEach(add); - collect(block.children); - } - }; - for (const [key, blocks] of pages.blocks) { - if (blocks.length > 0) add(key); - collect(blocks); - } - const q = canonicalize(query); - return [...titles] - .map((title) => ({ title, at: canonicalize(title).indexOf(q) })) - .filter(({ at }) => at !== -1) - .sort( - (a, b) => - Number(a.at !== 0) - Number(b.at !== 0) || - a.title.localeCompare(b.title), - ) - .slice(0, limit) - .map(({ title }) => title); -} - -/** The page a block is on, or undefined for a block the ledger lacks. */ -export function locateBlock( - pages: Pages, - blockId: string, -): { page: string } | undefined { - const found = find(pages, blockId); - return found && { page: found.page }; -} - -/** - * One of this fold's events as it is written: the schema's own type and - * payload, and the `v` the gate reads. A `PageEvent` with one more field, so - * whatever reads a `PageEvent` (`predict`, `reduce`) takes it as it is. - */ -type Built = Extract< - PageEvent, - { type: `${typeof NAMESPACE}${T}` } -> & { v: number }; - -/** - * Appends an event one of the builders below made. - * - * The builders are what stand between a keystroke and one hazard with two - * halves. A payload literal that drifts from the schema folds to nothing, and - * a `v` typed out by hand next to a `HANDLES` that says something else is a - * build whose own gate refuses its own writes — either way the user's - * keystroke lands on disk and never comes back. So every event is built in - * one place, the payload typed against the schema and `v` taken from - * `HANDLES`, and the renderer predicts with the same builders it will be - * recorded by. - * - * Each builder is built from its arguments alone and checks nothing against - * any page: that is the command's job, before it builds. They read no clock - * and no randomness. - */ -function append(view: PagesView, event: Built): Promise { - return view.dispatch(event.v, event.type, event.payload); -} - -/** - * The `block.created` event for these arguments, the same bytes `addBlock` - * writes. A writer that holds no fold (the CLI) can still write a valid one, - * as long as it trusts its own `after` and `id`, which a fresh id and no - * `after` always are. The id is the caller's to make. - */ -export function blockCreated( - page: string, - text: string, - after: string | undefined, - id: string, -): Built<"block.created"> { - const type = `${NAMESPACE}block.created` as const; - return { - v: HANDLES[type], - type, - // Left off entirely when absent, so an appended block writes the same - // bytes it always did. - payload: { page, id, text, ...(after ? { after } : {}) }, - }; -} - -/** The `block.edited` event: `id`'s text is now `text`. */ -export function blockEdited(id: string, text: string): Built<"block.edited"> { - const type = `${NAMESPACE}block.edited` as const; - return { v: HANDLES[type], type, payload: { id, text } }; -} - -/** The `block.deleted` event: the user is done with `id`. */ -export function blockDeleted(id: string): Built<"block.deleted"> { - const type = `${NAMESPACE}block.deleted` as const; - return { v: HANDLES[type], type, payload: { id } }; -} - -/** - * The `block.indented` event for a move `indented` resolved. Takes the - * resolved move rather than a block id: which parent Tab means is read off a - * page, and this reads none. - */ -export function blockIndented(payload: BlockIndented): Built<"block.indented"> { - const type = `${NAMESPACE}block.indented` as const; - return { v: HANDLES[type], type, payload }; -} - -/** The `block.outdented` event for a move `outdented` resolved. */ -export function blockOutdented( - payload: BlockOutdented, -): Built<"block.outdented"> { - const type = `${NAMESPACE}block.outdented` as const; - return { v: HANDLES[type], type, payload }; -} - -/** - * Writes a new block, at the end of the page or directly beneath `after`. - * - * Refuses an `after` that is not on this page, for the same reason `editBlock` - * refuses an unknown block: the fold would have to guess, and a log that keeps - * everything forever should not be collecting events that mean nothing. - * - * The id is always the caller's: the renderer draws a block before it is - * written, and the block on disk has to be the one on screen. Whoever has no - * id yet makes one where it wires things up, with whatever randomness it - * trusts; this reads none of its own. One the - * ledger already has is refused — two blocks answering to one id is a page - * that every later event about either of them folds wrongly. - */ -export async function addBlock( - view: PagesView, - title: string, - text: string, - after: string | undefined, - id: string, -): Promise { - const blocks = blocksOn(view.state, title); - if (after && !locate(blocks, after)) { - throw new Error("No such block"); - } - if (find(view.state, id)) throw new Error("That block already exists"); - - // dispatch appends before it folds, so this resolves only once the event is - // durable — the page handed back can never show something a crash takes. - await append(view, blockCreated(title, text, after, id)); - return getPage(view.state, title); -} - -/** - * Rewrites a block's text by appending the fact that it changed. Refuses a - * block the ledger does not have: the fold would ignore the event, and a log - * that keeps everything forever should not be collecting events that mean - * nothing. - * - * No page is asked for, and none is written. The block is looked up by id and - * the page it turned out to be on is what comes back — so a caller showing - * blocks from several pages at once, which is what a references cut already - * is, does not have to carry the right title beside each one to be allowed to - * write. - */ -export async function editBlock( - view: PagesView, - blockId: string, - text: string, -): Promise { - const found = find(view.state, blockId); - if (!found) throw new Error("No such block"); - - await append(view, blockEdited(blockId, text)); - return getPage(view.state, found.page); -} - -/** - * Removes a block by appending the fact that the user deleted it. Refuses one - * the page does not have, like `editBlock`. - * - * Nothing is taken off disk. Every word the block ever held is still in the - * log, in the events that put it there — what the fold stops showing is a - * block the user said they were done with, which is a different claim from - * "this was never written". - */ -export async function deleteBlock( - view: PagesView, - blockId: string, -): Promise { - const found = find(view.state, blockId); - if (!found) throw new Error("No such block"); - - await append(view, blockDeleted(blockId)); - return getPage(view.state, found.page); -} - -/** - * Moves a block under the sibling above it, children and all. - * - * The event records the parent it resolved to, not just "the user pressed - * Tab". Both would replay identically — the fold is deterministic, so the - * sibling above is the same one on replay as it was live — but the resolved - * form is the one that still reads correctly if what Tab *means* ever - * changes, and it is what `after` already does for a new block. - * - * A block that is first among its siblings has nothing above it to move - * under. Nothing is appended and the page comes back as it was: the outline - * did not change, and a log that keeps everything forever has no use for the - * record of a keystroke that did nothing. - */ -export async function indentBlock( - view: PagesView, - blockId: string, -): Promise { - const found = find(view.state, blockId); - if (!found) throw new Error("No such block"); - const payload = indented(found.blocks, blockId); - if (payload) await append(view, blockIndented(payload)); - return getPage(view.state, found.page); -} - -/** - * Brings a block out a level, to sit directly beneath what it used to hang - * under, taking the siblings that were below it along as its children. - * - * The event names the block it lands beneath rather than the level it landed - * at, which is the same thing `after` means everywhere else in this log. - * - * A block already at the top level has nothing to come out of. Nothing is - * appended and the page comes back as it was, like an indent with no sibling - * above it. - */ -export async function outdentBlock( - view: PagesView, - blockId: string, -): Promise { - const found = find(view.state, blockId); - if (!found) throw new Error("No such block"); - const payload = outdented(found.blocks, blockId); - if (payload) await append(view, blockOutdented(payload)); - return getPage(view.state, found.page); -} diff --git a/packages/pages/src/properties/properties.ts b/packages/pages/src/properties/properties.ts index 8be081d..98097fc 100644 --- a/packages/pages/src/properties/properties.ts +++ b/packages/pages/src/properties/properties.ts @@ -18,8 +18,11 @@ * Like `blocks.ts`, nothing here touches a log, a clock or a window. */ -import { NAMESPACE, spell } from "../blocks/blocks.ts"; import { canonicalize } from "../canonicalize/canonicalize.ts"; +import { field, NAMESPACE, unnamed } from "../events/events.ts"; +import type { Named } from "../events/events.ts"; +import { spell } from "../titles/titles.ts"; +import type { Titles } from "../titles/titles.ts"; /** * A value or an item. The four types stay distinct: `1`, `"1"`, `true` and @@ -65,9 +68,6 @@ export type Seen = { path: string[]; value?: Scalar; items: Scalar[] }; */ export type PropertyRemoved = { page: string; seen: Seen[] }; -/** An event type as it is written: under the namespace. */ -type Named = `${typeof NAMESPACE}${T}`; - /** * Every property event and its payload. No timestamp in any of them: the * event's own `at` is when it was written, and this fold keeps no time. @@ -152,13 +152,6 @@ const removeItem = return { ...entry, items }; }; -/** A field the payload holds itself, never one every object inherits. */ -function field(payload: object, name: string): unknown { - return Object.hasOwn(payload, name) - ? (payload as Record)[name] - : undefined; -} - /** * A scalar as the fold keeps it, boxed so that `null` is not a refusal: * strings in NFC, `-0` as `0`. Undefined for anything that is not a scalar, @@ -234,12 +227,12 @@ function parse( type: string, payload: unknown, ): { page: string; edits: Edit[] } | undefined { - if (!type.startsWith(NAMESPACE)) return undefined; + const named = unnamed(type); + if (!named) return undefined; if (typeof payload !== "object" || payload === null) return undefined; const page = field(payload, "page"); if (typeof page !== "string" || canonicalize(page) === "") return undefined; - const named = type.slice(NAMESPACE.length); if (named === "page.property.removed") { const seen = field(payload, "seen"); if (!Array.isArray(seen) || seen.length === 0) return undefined; @@ -316,7 +309,7 @@ function apply(properties: Properties, page: string, edits: Edit[]): Properties * `titles` and `properties` folds through it. */ export function reduceProperties< - S extends { titles: Map; properties: Properties }, + S extends { titles: Titles; properties: Properties }, >(state: S, event: { type: string; payload: unknown }): S { const parsed = parse(event.type, event.payload); if (!parsed) return state; diff --git a/packages/pages/src/reading/reading.ts b/packages/pages/src/reading/reading.ts index 9107d5b..3259bed 100644 --- a/packages/pages/src/reading/reading.ts +++ b/packages/pages/src/reading/reading.ts @@ -2,13 +2,10 @@ * What a block's text says once it is read as markdown: the pages it links * to, the blocks it refers to, and what a ref to it shows. * - * Text in, data out. Nothing here knows a project, a ledger or a view — - * `pages.ts` fetches the fold and asks these about the blocks in it. + * Text in, data out. Nothing here knows a block, a page, a ledger or a view. */ import { unified } from "unified"; import remarkParse from "remark-parse"; -import { canonicalize } from "../canonicalize/canonicalize.ts"; -import type { Block } from "../blocks/blocks.ts"; import { remarkPlugins } from "../wikilink/wikilink.ts"; /** @@ -109,19 +106,3 @@ export function inCode(text: string, offset: number): boolean { : (node.children ?? []).some(inside); return inside(md.parse(text) as Node); } - -/** - * The blocks that link to `title`, in whatever case the link was written, - * each with its subtree intact. A block that links is taken whole and not - * searched inside: its children are already on show under it, and listing - * one of them again for a link of its own would put the same words on the - * page twice. - */ -export function linking(blocks: Block[], title: string): Block[] { - const key = canonicalize(title); - return blocks.flatMap((block) => - read(block.text).links.some((link) => canonicalize(link) === key) - ? [block] - : linking(block.children, title), - ); -} diff --git a/packages/pages/src/titles/titles.ts b/packages/pages/src/titles/titles.ts new file mode 100644 index 0000000..f2e1edf --- /dev/null +++ b/packages/pages/src/titles/titles.ts @@ -0,0 +1,30 @@ +/** + * How a page is spelled: `canonicalize` key → the first spelling the fold saw. + * Blocks and properties both name pages, so both spell them here. + */ +import { canonicalize } from "../canonicalize/canonicalize.ts"; + +/** Never overwritten: editing a link away later leaves its spelling as it was. */ +export type Titles = Map; + +/** + * `titles` with a spelling for each of `names` whose key has none yet, in + * order, so the first of two spellings in one event is the one kept. The same + * map back when every name already has one. + */ +export function spell(titles: Titles, names: string[]): Titles { + let next = titles; + for (const name of names) { + const key = canonicalize(name); + if (next.has(key)) continue; + // Copied once, on the first new name: what was handed in is a checkpoint. + if (next === titles) next = new Map(titles); + next.set(key, name); + } + return next; +} + +/** How a page is shown: its first spelling, or `title` for one nothing has named. */ +export function titleOf(holds: { readonly titles: Titles }, title: string): string { + return holds.titles.get(canonicalize(title)) ?? title; +}