| 1 | /** |
| 2 | * Artifacts mode's pure helpers (code says "folio", people see |
| 3 | * "artifact"; docs/ARTIFACTS_MODE.md): sidebar trees, the home list's |
| 4 | * days, cursor colours, covers, roles, how search snippets mark their |
| 5 | * matches, and projects' docs folders. No Workers or DOM imports, so it is |
| 6 | * tested under Node. |
| 7 | */ |
| 8 | import type { DocRole, FolioKind, FolioListQuery, FolioTreeNode } from "@g1t/contracts"; |
| 9 | |
| 10 | const KINDS: readonly string[] = ["doc", "slides", "design", "dashboard"]; |
| 11 | |
| 12 | /** |
| 13 | * The home list's query, from an address's search |
| 14 | * (`?tab=&kind=&space=&owner=&project=&q=&cursor=`). `owner` is passed |
| 15 | * through as written: Home turns a username into a member key. |
| 16 | */ |
| 17 | export function listQuery(q: URLSearchParams): FolioListQuery { |
| 18 | const tab = q.get("tab"); |
| 19 | const kind = q.get("kind") ?? ""; |
| 20 | return { |
| 21 | tab: tab === "yours" || tab === "shared" ? tab : "all", |
| 22 | kinds: KINDS.includes(kind) ? [kind as FolioKind] : null, |
| 23 | space_id: q.get("space") || null, |
| 24 | owner: q.get("owner") || null, |
| 25 | project: q.get("project") || null, |
| 26 | q: q.get("q")?.trim() || null, |
| 27 | cursor: q.get("cursor") || null, |
| 28 | limit: 30, |
| 29 | }; |
| 30 | } |
| 31 | |
| 32 | export type TreeItem = FolioTreeNode & { children: TreeItem[]; depth: number }; |
| 33 | |
| 34 | type TreeNode = Pick<FolioTreeNode, "id" | "parent_id" | "position">; |
| 35 | |
| 36 | /** A flat list of folios as a tree, each level in position order. */ |
| 37 | export function buildTree(nodes: FolioTreeNode[]): TreeItem[] { |
| 38 | const byParent = new Map<string | null, FolioTreeNode[]>(); |
| 39 | const ids = new Set(nodes.map((n) => n.id)); |
| 40 | for (const node of nodes) { |
| 41 | // One whose parent isn't in the list (in the trash, or not readable) shows at the top. |
| 42 | const parent = node.parent_id && ids.has(node.parent_id) ? node.parent_id : null; |
| 43 | const list = byParent.get(parent) ?? []; |
| 44 | list.push(node); |
| 45 | byParent.set(parent, list); |
| 46 | } |
| 47 | const seen = new Set<string>(); |
| 48 | const build = (parent: string | null, depth: number): TreeItem[] => |
| 49 | (byParent.get(parent) ?? []) |
| 50 | .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)) |
| 51 | .filter((n) => !seen.has(n.id) && seen.add(n.id)) |
| 52 | .map((n) => ({ ...n, depth, children: build(n.id, depth + 1) })); |
| 53 | return build(null, 0); |
| 54 | } |
| 55 | |
| 56 | /** Every folio in a tree, depth first, as a move dialog lists them. */ |
| 57 | export function flatten(tree: TreeItem[]): TreeItem[] { |
| 58 | const out: TreeItem[] = []; |
| 59 | const walk = (items: TreeItem[]) => { |
| 60 | for (const item of items) { |
| 61 | out.push(item); |
| 62 | walk(item.children); |
| 63 | } |
| 64 | }; |
| 65 | walk(tree); |
| 66 | return out; |
| 67 | } |
| 68 | |
| 69 | /** The ids on the way to `id` (its ancestors), so the sidebar opens them. */ |
| 70 | export function pathTo(nodes: TreeNode[], id: string | null): string[] { |
| 71 | if (!id) return []; |
| 72 | const byId = new Map(nodes.map((n) => [n.id, n])); |
| 73 | const out: string[] = []; |
| 74 | let at = byId.get(id)?.parent_id ?? null; |
| 75 | while (at && !out.includes(at)) { |
| 76 | out.unshift(at); |
| 77 | at = byId.get(at)?.parent_id ?? null; |
| 78 | } |
| 79 | return out; |
| 80 | } |
| 81 | |
| 82 | /** A day of the home list: Today, Yesterday, then `Oct 7`, with the year when it isn't this year. */ |
| 83 | export type DayGroup<T> = { key: string; label: string; items: T[] }; |
| 84 | |
| 85 | /** The calendar day of `at` in `zone`, as `YYYY-MM-DD`. */ |
| 86 | export function dayKey(at: string | number | Date, zone: string): string { |
| 87 | const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date(at)); |
| 88 | const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""; |
| 89 | return `${get("year")}-${get("month")}-${get("day")}`; |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Items grouped by the day `at` gives each, in `zone` (the viewer's), in |
| 94 | * the order they come: lists arrive newest first, so the days do too. |
| 95 | */ |
| 96 | export function dayGroups<T>(items: readonly T[], at: (item: T) => string, zone: string, now: Date = new Date()): DayGroup<T>[] { |
| 97 | const today = dayKey(now, zone); |
| 98 | const yesterday = dayKey(now.getTime() - 86_400_000, zone); |
| 99 | const year = today.slice(0, 4); |
| 100 | const out: DayGroup<T>[] = []; |
| 101 | for (const item of items) { |
| 102 | const key = dayKey(at(item), zone); |
| 103 | let group = out.find((g) => g.key === key); |
| 104 | if (!group) { |
| 105 | const [y, m, d] = key.split("-").map(Number) as [number, number, number]; |
| 106 | // Noon UTC names the same calendar day in any zone's words. |
| 107 | const date = new Date(Date.UTC(y, m - 1, d, 12)); |
| 108 | const label = |
| 109 | key === today |
| 110 | ? "Today" |
| 111 | : key === yesterday |
| 112 | ? "Yesterday" |
| 113 | : new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", day: "numeric", ...(key.slice(0, 4) === year ? {} : { year: "numeric" }) }).format(date); |
| 114 | group = { key, label, items: [] }; |
| 115 | out.push(group); |
| 116 | } |
| 117 | group.items.push(item); |
| 118 | } |
| 119 | return out; |
| 120 | } |
| 121 | |
| 122 | /** A search snippet's parts: `[[word]]` marks a match. */ |
| 123 | export function snippetParts(snippet: string): { text: string; match: boolean }[] { |
| 124 | const out: { text: string; match: boolean }[] = []; |
| 125 | const re = /\[\[([\s\S]*?)\]\]/g; |
| 126 | let at = 0; |
| 127 | for (let m = re.exec(snippet); m; m = re.exec(snippet)) { |
| 128 | if (m.index > at) out.push({ text: snippet.slice(at, m.index), match: false }); |
| 129 | out.push({ text: m[1]!, match: true }); |
| 130 | at = m.index + m[0].length; |
| 131 | } |
| 132 | if (at < snippet.length) out.push({ text: snippet.slice(at), match: false }); |
| 133 | return out; |
| 134 | } |
| 135 | |
| 136 | /** Cursor colours: distinct, readable on the dark page. */ |
| 137 | export const CURSOR_COLOURS = ["#b8a6ff", "#7dd3fc", "#86efac", "#fcd34d", "#fca5a5", "#f9a8d4", "#a5b4fc", "#5eead4", "#fdba74", "#c4b5fd"]; |
| 138 | |
| 139 | /** A person's cursor colour, stable for their name. */ |
| 140 | export function cursorColour(name: string): string { |
| 141 | let hash = 0; |
| 142 | for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0; |
| 143 | return CURSOR_COLOURS[Math.abs(hash) % CURSOR_COLOURS.length]!; |
| 144 | } |
| 145 | |
| 146 | /** The covers a doc can take without an upload. */ |
| 147 | export const COVER_GRADIENTS = [ |
| 148 | "linear-gradient(120deg, #2a2340 0%, #4b3d7a 50%, #8f7ee0 100%)", |
| 149 | "linear-gradient(120deg, #11222c 0%, #1f4b5c 55%, #4fb3c8 100%)", |
| 150 | "linear-gradient(120deg, #1d1f17 0%, #3d4a24 55%, #a3c45a 100%)", |
| 151 | "linear-gradient(120deg, #2b1a1a 0%, #5c2e2e 55%, #d9776a 100%)", |
| 152 | "linear-gradient(120deg, #241a2b 0%, #5a2d5f 55%, #d27bd8 100%)", |
| 153 | "linear-gradient(120deg, #1a1d2b 0%, #2c3466 55%, #7a8cf0 100%)", |
| 154 | ]; |
| 155 | |
| 156 | /** A cover's CSS background: one of the gradients, or an image. */ |
| 157 | export function coverStyle(cover: string | null | undefined): string | null { |
| 158 | if (!cover) return null; |
| 159 | const gradient = /^gradient:(\d{1,2})$/.exec(cover); |
| 160 | if (gradient) return COVER_GRADIENTS[Number(gradient[1]) % COVER_GRADIENTS.length]!; |
| 161 | if (/^https:\/\//.test(cover)) return `center / cover no-repeat url("${cover.replace(/["\\]/g, "")}")`; |
| 162 | return null; |
| 163 | } |
| 164 | |
| 165 | const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 }; |
| 166 | |
| 167 | export function canDo(role: DocRole | null | undefined, need: DocRole): boolean { |
| 168 | return !!role && RANK[role] >= RANK[need]; |
| 169 | } |
| 170 | |
| 171 | /** How many words a doc has, and the minutes it takes to read. */ |
| 172 | export function readingTime(markdown: string): { words: number; minutes: number } { |
| 173 | const words = markdown.replace(/```[\s\S]*?```/g, " ").split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length; |
| 174 | return { words, minutes: Math.max(1, Math.round(words / 230)) }; |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Where a citation links: the file or folder in Code at the commit it was |
| 179 | * cited at, or the default branch (`HEAD`). A glob links to the folder it |
| 180 | * starts from. Mirrors `citationHref` in services/docs src/citations.ts. |
| 181 | */ |
| 182 | export function citationHref(c: { repo: string; path: string; ref: string | null }): string { |
| 183 | const parts = c.path.split("/").filter(Boolean); |
| 184 | const globAt = parts.findIndex((p) => /[*?]/.test(p)); |
| 185 | const glob = globAt >= 0; |
| 186 | const shown = (glob ? parts.slice(0, globAt) : parts).map(encodeURIComponent).join("/"); |
| 187 | const kind = glob || !/\.[A-Za-z0-9]{1,10}$/.test(c.path) ? "tree" : "blob"; |
| 188 | return `/${c.repo}/${kind}/${encodeURIComponent(c.ref || "HEAD")}${shown ? `/${shown}` : ""}`; |
| 189 | } |
| 190 | |
| 191 | /** A project's docs file's address in Artifacts. */ |
| 192 | export function repoFilePath(workspace: string, repo: string, path: string): string { |
| 193 | return `/${workspace}/-/artifacts/repo/${repo}/${path.split("/").map(encodeURIComponent).join("/")}`; |
| 194 | } |
| 195 | |
| 196 | /** A space's address in Artifacts. */ |
| 197 | export function spacePath(workspace: string, space: string): string { |
| 198 | return `/${workspace}/-/artifacts/spaces/${space}`; |
| 199 | } |
| 200 | |
| 201 | /** A folder of a project's docs, as the sidebar shows it: files, then folders, each by name. */ |
| 202 | export type RepoFolder = { name: string; path: string; files: { path: string; title: string }[]; folders: RepoFolder[] }; |
| 203 | |
| 204 | /** A project's docs files as folders: README and `docs/` at the top, `docs/a/b.md` under `a`. */ |
| 205 | export function repoFolders(files: { path: string; title: string }[]): RepoFolder { |
| 206 | const root: RepoFolder = { name: "", path: "", files: [], folders: [] }; |
| 207 | for (const file of files) { |
| 208 | // `docs/` is the space itself: its files sit at the top beside the README. |
| 209 | const parts = file.path.replace(/^docs\//i, "").split("/"); |
| 210 | parts.pop(); |
| 211 | let at = root; |
| 212 | for (const part of parts) { |
| 213 | let next = at.folders.find((f) => f.name === part); |
| 214 | if (!next) { |
| 215 | next = { name: part, path: at.path ? `${at.path}/${part}` : part, files: [], folders: [] }; |
| 216 | at.folders.push(next); |
| 217 | } |
| 218 | at = next; |
| 219 | } |
| 220 | at.files.push(file); |
| 221 | } |
| 222 | const sort = (f: RepoFolder) => { |
| 223 | f.folders.sort((a, b) => a.name.localeCompare(b.name)); |
| 224 | f.folders.forEach(sort); |
| 225 | }; |
| 226 | sort(root); |
| 227 | return root; |
| 228 | } |
| 229 | |
| 230 | /** The artifact ids a chat message links to in this workspace, in order, once each. */ |
| 231 | export function linkedArtifacts(body: string, workspace: string): string[] { |
| 232 | const out: string[] = []; |
| 233 | const re = /\/([A-Za-z0-9_.-]+)\/-\/artifacts\/(?:[a-z0-9-]*-)?(fol_[0-9a-hjkmnp-tv-z]{26})(?![0-9a-z])/g; |
| 234 | for (let m = re.exec(body); m; m = re.exec(body)) { |
| 235 | if (m[1]!.toLowerCase() === workspace.toLowerCase() && !out.includes(m[2]!)) out.push(m[2]!); |
| 236 | } |
| 237 | return out; |
| 238 | } |