| 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 [...String(title ?? "").replace(/\s+/g, " ").trim()].slice(0, max).join(""); |
| 38 | } |
| 39 | |
| 40 | /** One emoji (or a few characters), or null. */ |
| 41 | export function cleanIcon(icon: unknown): string | null { |
| 42 | const s = String(icon ?? "").trim(); |
| 43 | return s ? [...s].slice(0, 4).join("") : null; |
| 44 | } |
| 45 | |
| 46 | export 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 | |
| 54 | export 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. */ |
| 60 | export 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 | |
| 68 | export 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 | |
| 84 | export 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. */ |
| 91 | export type Cursor = { k: string; id: string }; |
| 92 | |
| 93 | export function encodeCursor(cursor: Cursor): string { |
| 94 | return btoa(JSON.stringify([cursor.k, cursor.id])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 95 | } |
| 96 | |
| 97 | export 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. */ |
| 109 | export function slugOf(title: string, id: string): string { |
| 110 | return pageSlug(title, id); |
| 111 | } |
| 112 | |
| 113 | type 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 | */ |
| 119 | export 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 | */ |
| 141 | export 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. */ |
| 147 | export 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. */ |
| 154 | export 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 | } |