Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 1 | /** |
| 2 | * Folio lists' small rules, apart from where rows come from: cleaning | |
| 3 | * input, the paging cursor, sidebar trees and the "Shared" section's | |
| 4 | * tops, and a tree's depth. Pure. | |
| 5 | */ | |
| 6 | import type { DocEditTarget, FolioTreeNode } from "@g1t/contracts"; | |
| 7 | ||
| 8 | import { pageSlug } from "../slugs.ts"; | |
| 9 | ||
| 10 | /** The longest title, in characters (FOLIO_MAX_TITLE). */ | |
| 11 | export const MAX_TITLE = 200; | |
| 12 | /** The deepest a folio tree goes (FOLIO_MAX_DEPTH). */ | |
| 13 | export const MAX_DEPTH = 10; | |
| 14 | /** A page of a list, unless asked for fewer (at most FOLIO_LIST_MAX). */ | |
| 15 | export const DEFAULT_LIMIT = 30; | |
| 16 | export const MAX_LIMIT = 100; | |
| 17 | /** A note on an edit, a template's description: at most this long. */ | |
| 18 | export const MAX_NOTE = 500; | |
| 19 | ||
| 20 | export function cleanTitle(title: unknown, max = MAX_TITLE): string { | |
| 21 | return [...String(title ?? "").replace(/\s+/g, " ").trim()].slice(0, max).join(""); | |
| 22 | } | |
| 23 | ||
| 24 | /** One emoji (or a few characters), or null. */ | |
| 25 | export function cleanIcon(icon: unknown): string | null { | |
| 26 | const s = String(icon ?? "").trim(); | |
| 27 | return s ? [...s].slice(0, 4).join("") : null; | |
| 28 | } | |
| 29 | ||
| 30 | export function cleanCover(cover: unknown): string | null { | |
| 31 | const s = String(cover ?? "").trim(); | |
| 32 | if (!s) return null; | |
| 33 | if (/^gradient:\d{1,2}$/.test(s)) return s; | |
| 34 | if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s; | |
| 35 | return null; | |
| 36 | } | |
| 37 | ||
| 38 | export function cleanNote(note: unknown): string | null { | |
| 39 | const s = String(note ?? "").trim(); | |
| 40 | return s ? s.slice(0, MAX_NOTE) : null; | |
| 41 | } | |
| 42 | ||
| 43 | /** Where it was written up from: a link on this site only. */ | |
| 44 | export function cleanSource(source: unknown): { title: string; href: string } | null { | |
| 45 | const s = source as { title?: unknown; href?: unknown } | null; | |
| 46 | if (!s || typeof s !== "object" || typeof s.href !== "string") return null; | |
| 47 | const href = s.href.trim(); | |
| 48 | if (!href.startsWith("/") || href.startsWith("//") || href.length > 500 || /[\s"'<>]/.test(href)) return null; | |
| 49 | return { title: cleanTitle(s.title || "A conversation", 120) || "A conversation", href }; | |
| 50 | } | |
| 51 | ||
| 52 | export function cleanTarget(target: unknown): DocEditTarget | null { | |
| 53 | const t = target as DocEditTarget | null; | |
| 54 | if (!t || typeof t !== "object") return null; | |
| 55 | switch (t.kind) { | |
| 56 | case "append": | |
| 57 | case "document": | |
| 58 | return { kind: t.kind }; | |
| 59 | case "section": | |
| 60 | return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null; | |
| 61 | case "blocks": | |
| 62 | return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null; | |
| 63 | default: | |
| 64 | return null; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | export function listLimit(limit: unknown): number { | |
| 69 | const n = Math.floor(Number(limit)); | |
| 70 | if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT; | |
| 71 | return Math.min(n, MAX_LIMIT); | |
| 72 | } | |
| 73 | ||
| 74 | /** Where the next page of a list starts: after this sort key and id. */ | |
| 75 | export type Cursor = { k: string; id: string }; | |
| 76 | ||
| 77 | export function encodeCursor(cursor: Cursor): string { | |
| 78 | return btoa(JSON.stringify([cursor.k, cursor.id])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 79 | } | |
| 80 | ||
| 81 | export function decodeCursor(value: unknown): Cursor | null { | |
| 82 | if (typeof value !== "string" || !value || value.length > 400) return null; | |
| 83 | try { | |
| 84 | const parsed = JSON.parse(atob(value.replace(/-/g, "+").replace(/_/g, "/"))) as unknown; | |
| 85 | if (Array.isArray(parsed) && typeof parsed[0] === "string" && typeof parsed[1] === "string") return { k: parsed[0], id: parsed[1] }; | |
| 86 | } catch { | |
| 87 | // Not one of ours. | |
| 88 | } | |
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | /** A folio's last address segment (`<title-slug>-<id>`), as `folioSlug` in contracts. */ | |
| 93 | export function slugOf(title: string, id: string): string { | |
| 94 | return pageSlug(title, id); | |
| 95 | } | |
| 96 | ||
| 97 | type TreeRow = { id: string; kind: FolioTreeNode["kind"]; parent_id: string | null; position: number; title: string; icon: string | null; inherit: number | boolean }; | |
| 98 | ||
| 99 | /** | |
| 100 | * A sidebar tree: the rows a reader may see, each under its parent when | |
| 101 | * they may see the parent too, otherwise at the top. Ordered by position. | |
| 102 | */ | |
| 103 | export function treeNodes(rows: readonly TreeRow[], stale: ReadonlySet<string> = new Set()): FolioTreeNode[] { | |
| 104 | const shown = new Set(rows.map((r) => r.id)); | |
| 105 | return [...rows] | |
| 106 | .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)) | |
| 107 | .map((r) => ({ | |
| 108 | id: r.id, | |
| 109 | kind: r.kind, | |
| 110 | parent_id: r.parent_id && shown.has(r.parent_id) ? r.parent_id : null, | |
| 111 | position: r.position, | |
| 112 | title: r.title, | |
| 113 | icon: r.icon, | |
| 114 | slug: slugOf(r.title, r.id), | |
| 115 | restricted: !r.inherit && !!r.parent_id, | |
| 116 | ...(stale.has(r.id) ? { stale: true } : {}), | |
| 117 | })); | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * The tops of what is shared with someone: of the folios they can read | |
| 122 | * that aren't theirs, those whose parent they can't read (or that have | |
| 123 | * none), leaving out anything already in their other sections. | |
| 124 | */ | |
| 125 | export function sharedTops<T extends { id: string; parent_id: string | null }>(readable: readonly T[], elsewhere: ReadonlySet<string>): T[] { | |
| 126 | const ids = new Set(readable.map((r) => r.id)); | |
| 127 | return readable.filter((r) => !elsewhere.has(r.id) && (!r.parent_id || (!ids.has(r.parent_id) && !elsewhere.has(r.parent_id)))); | |
| 128 | } | |
| 129 | ||
| 130 | /** How deep a folio is: 1 at the top. */ | |
| 131 | export function depthOf(path: string): number { | |
| 132 | return String(path ?? "") | |
| 133 | .split("/") | |
| 134 | .filter(Boolean).length; | |
| 135 | } | |
| 136 | ||
| 137 | /** How many levels a subtree spans below its top (0 for a leaf), from its rows' paths. */ | |
| 138 | export function subtreeHeight(top: { path: string }, rows: readonly { path: string }[]): number { | |
| 139 | const base = depthOf(top.path); | |
| 140 | return rows.reduce((h, r) => Math.max(h, depthOf(r.path) - base), 0); | |
| 141 | } |