| 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 | export 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. */ |
| 25 | export function cleanIcon(icon: unknown): string | null { |
| 26 | const s = String(icon ?? "").trim(); |
| 27 | return s ? [...s].slice(0, 4).join("") : null; |
| 28 | } |
| 29 | |
| 30 | export 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 | |
| 38 | export 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. */ |
| 44 | export 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 | |
| 52 | export 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 | |
| 68 | export 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. */ |
| 75 | export type Cursor = { k: string; id: string }; |
| 76 | |
| 77 | export function encodeCursor(cursor: Cursor): string { |
| 78 | return btoa(JSON.stringify([cursor.k, cursor.id])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 79 | } |
| 80 | |
| 81 | export 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. */ |
| 93 | export function slugOf(title: string, id: string): string { |
| 94 | return pageSlug(title, id); |
| 95 | } |
| 96 | |
| 97 | type 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 | */ |
| 103 | export 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 | */ |
| 125 | export 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. */ |
| 131 | export 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. */ |
| 138 | export 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 | } |