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