Skip to content
238 linesCodeBlameRaw
1/**
2 * Artifacts mode's pure helpers (code says "folio", people see
3 * "artifact"): 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 */
8import type { DocRole, FolioKind, FolioListQuery, FolioTreeNode } from "@g1t/contracts";
9
10const 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 */
17export 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
32export type TreeItem = FolioTreeNode & { children: TreeItem[]; depth: number };
33
34type TreeNode = Pick<FolioTreeNode, "id" | "parent_id" | "position">;
35
36/** A flat list of folios as a tree, each level in position order. */
37export 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. */
57export 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. */
70export 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. */
83export type DayGroup<T> = { key: string; label: string; items: T[] };
84
85/** The calendar day of `at` in `zone`, as `YYYY-MM-DD`. */
86export 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 */
96export 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. */
123export 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. */
137export const CURSOR_COLOURS = ["#b8a6ff", "#7dd3fc", "#86efac", "#fcd34d", "#fca5a5", "#f9a8d4", "#a5b4fc", "#5eead4", "#fdba74", "#c4b5fd"];
138
139/** A person's cursor colour, stable for their name. */
140export 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. */
147export 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. */
157export 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
165const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
166
167export 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. */
172export 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/artifacts src/citations.ts.
181 */
182export 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. */
192export 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. */
197export 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. */
202export 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`. */
205export 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. */
231export 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}