Skip to content
141 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
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}