Skip to content
141 linesCodeBlameRaw

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 */
6import type { DocEditTarget, FolioTreeNode } from "@g1t/contracts";
7
8import { pageSlug } from "../slugs.ts";
9
10/** The longest title, in characters (FOLIO_MAX_TITLE). */
11export const MAX_TITLE = 200;
12/** The deepest a folio tree goes (FOLIO_MAX_DEPTH). */
13export const MAX_DEPTH = 10;
14/** A page of a list, unless asked for fewer (at most FOLIO_LIST_MAX). */
15export const DEFAULT_LIMIT = 30;
16export const MAX_LIMIT = 100;
17/** A note on an edit, a template's description: at most this long. */
18export const MAX_NOTE = 500;
19
20export 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. */
25export function cleanIcon(icon: unknown): string | null {
26 const s = String(icon ?? "").trim();
27 return s ? [...s].slice(0, 4).join("") : null;
28}
29
30export 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
38export 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. */
44export 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
52export 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
68export 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. */
75export type Cursor = { k: string; id: string };
76
77export function encodeCursor(cursor: Cursor): string {
78 return btoa(JSON.stringify([cursor.k, cursor.id])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
79}
80
81export 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. */
93export function slugOf(title: string, id: string): string {
94 return pageSlug(title, id);
95}
96
97type 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 */
103export 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 */
125export 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. */
131export 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. */
138export 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}