Skip to content
157 linesCodeBlameRaw
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/**
20 * The largest saved document a page carries to the browser (base64, in
21 * the page's data). Past it the editor waits for the room, as it did for
22 * every doc before the state was kept.
23 */
24export const MAX_PAGE_STATE = 512 * 1024;
25
26/** A folio's saved Yjs state as the page carries it: base64, or null when none is kept or it is too large. */
27export function pageState(state: ArrayBuffer | ArrayLike<number> | null | undefined): string | null {
28 if (!state) return null;
29 const bytes = new Uint8Array(state as ArrayBuffer);
30 if (!bytes.byteLength || bytes.byteLength > MAX_PAGE_STATE) return null;
31 let binary = "";
32 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
33 return btoa(binary);
34}
35
36export function cleanTitle(title: unknown, max = MAX_TITLE): string {
37 return [...String(title ?? "").replace(/\s+/g, " ").trim()].slice(0, max).join("");
38}
39
40/** One emoji (or a few characters), or null. */
41export function cleanIcon(icon: unknown): string | null {
42 const s = String(icon ?? "").trim();
43 return s ? [...s].slice(0, 4).join("") : null;
44}
45
46export function cleanCover(cover: unknown): string | null {
47 const s = String(cover ?? "").trim();
48 if (!s) return null;
49 if (/^gradient:\d{1,2}$/.test(s)) return s;
50 if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s;
51 return null;
52}
53
54export function cleanNote(note: unknown): string | null {
55 const s = String(note ?? "").trim();
56 return s ? s.slice(0, MAX_NOTE) : null;
57}
58
59/** Where it was written up from: a link on this site only. */
60export function cleanSource(source: unknown): { title: string; href: string } | null {
61 const s = source as { title?: unknown; href?: unknown } | null;
62 if (!s || typeof s !== "object" || typeof s.href !== "string") return null;
63 const href = s.href.trim();
64 if (!href.startsWith("/") || href.startsWith("//") || href.length > 500 || /[\s"'<>]/.test(href)) return null;
65 return { title: cleanTitle(s.title || "A conversation", 120) || "A conversation", href };
66}
67
68export function cleanTarget(target: unknown): DocEditTarget | null {
69 const t = target as DocEditTarget | null;
70 if (!t || typeof t !== "object") return null;
71 switch (t.kind) {
72 case "append":
73 case "document":
74 return { kind: t.kind };
75 case "section":
76 return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
77 case "blocks":
78 return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
79 default:
80 return null;
81 }
82}
83
84export function listLimit(limit: unknown): number {
85 const n = Math.floor(Number(limit));
86 if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
87 return Math.min(n, MAX_LIMIT);
88}
89
90/** Where the next page of a list starts: after this sort key and id. */
91export type Cursor = { k: string; id: string };
92
93export function encodeCursor(cursor: Cursor): string {
94 return btoa(JSON.stringify([cursor.k, cursor.id])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
95}
96
97export function decodeCursor(value: unknown): Cursor | null {
98 if (typeof value !== "string" || !value || value.length > 400) return null;
99 try {
100 const parsed = JSON.parse(atob(value.replace(/-/g, "+").replace(/_/g, "/"))) as unknown;
101 if (Array.isArray(parsed) && typeof parsed[0] === "string" && typeof parsed[1] === "string") return { k: parsed[0], id: parsed[1] };
102 } catch {
103 // Not one of ours.
104 }
105 return null;
106}
107
108/** A folio's last address segment (`<title-slug>-<id>`), as `folioSlug` in contracts. */
109export function slugOf(title: string, id: string): string {
110 return pageSlug(title, id);
111}
112
113type TreeRow = { id: string; kind: FolioTreeNode["kind"]; parent_id: string | null; position: number; title: string; icon: string | null; inherit: number | boolean };
114
115/**
116 * A sidebar tree: the rows a reader may see, each under its parent when
117 * they may see the parent too, otherwise at the top. Ordered by position.
118 */
119export function treeNodes(rows: readonly TreeRow[], stale: ReadonlySet<string> = new Set()): FolioTreeNode[] {
120 const shown = new Set(rows.map((r) => r.id));
121 return [...rows]
122 .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id))
123 .map((r) => ({
124 id: r.id,
125 kind: r.kind,
126 parent_id: r.parent_id && shown.has(r.parent_id) ? r.parent_id : null,
127 position: r.position,
128 title: r.title,
129 icon: r.icon,
130 slug: slugOf(r.title, r.id),
131 restricted: !r.inherit && !!r.parent_id,
132 ...(stale.has(r.id) ? { stale: true } : {}),
133 }));
134}
135
136/**
137 * The tops of what is shared with someone: of the folios they can read
138 * that aren't theirs, those whose parent they can't read (or that have
139 * none), leaving out anything already in their other sections.
140 */
141export function sharedTops<T extends { id: string; parent_id: string | null }>(readable: readonly T[], elsewhere: ReadonlySet<string>): T[] {
142 const ids = new Set(readable.map((r) => r.id));
143 return readable.filter((r) => !elsewhere.has(r.id) && (!r.parent_id || (!ids.has(r.parent_id) && !elsewhere.has(r.parent_id))));
144}
145
146/** How deep a folio is: 1 at the top. */
147export function depthOf(path: string): number {
148 return String(path ?? "")
149 .split("/")
150 .filter(Boolean).length;
151}
152
153/** How many levels a subtree spans below its top (0 for a leaf), from its rows' paths. */
154export function subtreeHeight(top: { path: string }, rows: readonly { path: string }[]): number {
155 const base = depthOf(top.path);
156 return rows.reduce((h, r) => Math.max(h, depthOf(r.path) - base), 0);
157}