@crossworks/content-core 0.232.245 → 0.232.246

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@crossworks/content-core",
3
- "version": "0.232.245",
3
+ "version": "0.232.246",
4
4
  "description": "Browser-safe content logic — markdown, blocks, pages, tables, formulas — shared by the server and any client. Zero server deps by design: nothing here may reach @mantle/db, node-only APIs, or the network (the jackdaw-repo-split P0 boundary).",
5
5
  "exports": {
6
+ "./app-nav": "./src/app-nav.ts",
6
7
  "./block-diff": "./src/block-diff.ts",
7
8
  "./block-ids": "./src/block-ids.ts",
8
9
  "./block-list": "./src/block-list.ts",
@@ -29,7 +30,7 @@
29
30
  "./thinking-tiers": "./src/thinking-tiers.ts"
30
31
  },
31
32
  "dependencies": {
32
- "@mantle/client-types": "npm:@crossworks/client-types@0.232.245",
33
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.246",
33
34
  "marked": "^18.0.11",
34
35
  "mathjs": "15.2.0"
35
36
  },
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { AppNavEntry } from '@mantle/client-types/app-nav';
3
+ import {
4
+ appNavAppIds,
5
+ appNavIssue,
6
+ canMoveAppNavEntry,
7
+ dissolveAppNavFolder,
8
+ flattenAppNav,
9
+ moveAppNavEntry,
10
+ placeAppNavApp,
11
+ projectAppIcon,
12
+ projectAppNav,
13
+ pruneAppNav,
14
+ updateAppNavFolder,
15
+ } from './app-nav';
16
+
17
+ // Readable UUIDs: the tree only ever stores UUIDs.
18
+ const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
19
+ const app = (n: number): AppNavEntry => ({ kind: 'app', id: id(n) });
20
+ const folder = (n: number, children: AppNavEntry[], name = `F${n}`): AppNavEntry => ({
21
+ kind: 'folder',
22
+ id: id(n),
23
+ name,
24
+ children,
25
+ });
26
+
27
+ // F100 › F101 › F102 (three levels, the maximum), plus a root app.
28
+ const deep = (): AppNavEntry[] => [
29
+ folder(100, [folder(101, [folder(102, [app(1)]), app(2)]), app(3)]),
30
+ app(4),
31
+ ];
32
+
33
+ describe('projectAppIcon', () => {
34
+ it('accepts emoji and well-formed lucide names', () => {
35
+ expect(projectAppIcon('🌤️')).toBe('🌤️');
36
+ expect(projectAppIcon('lucide:chart-bar')).toBe('lucide:chart-bar');
37
+ });
38
+ it('rejects text posing as an emoji and malformed lucide names', () => {
39
+ expect(projectAppIcon('chart-bar')).toBeUndefined();
40
+ expect(projectAppIcon('lucide:Chart Bar')).toBeUndefined();
41
+ expect(projectAppIcon('lucide:')).toBeUndefined();
42
+ expect(projectAppIcon('')).toBeUndefined();
43
+ expect(projectAppIcon(42)).toBeUndefined();
44
+ });
45
+ });
46
+
47
+ describe('appNavIssue (strict write check)', () => {
48
+ it('accepts a tree three folders deep', () => {
49
+ expect(appNavIssue(deep())).toBeNull();
50
+ });
51
+ it('rejects a fourth folder level', () => {
52
+ const tooDeep = [folder(100, [folder(101, [folder(102, [folder(103, [])])])])];
53
+ expect(appNavIssue(tooDeep)).toMatch(/3 levels/);
54
+ });
55
+ it('rejects duplicates, bad ids, unnamed folders and unknown colours', () => {
56
+ expect(appNavIssue([app(1), folder(100, [app(1)])])).toMatch(/more than once/);
57
+ expect(appNavIssue([{ kind: 'app', id: 'nope' }])).toMatch(/UUID/);
58
+ expect(appNavIssue([folder(100, [], ' ')])).toMatch(/needs a name/);
59
+ expect(appNavIssue([{ ...folder(100, []), color: 'chartreuse' }])).toMatch(/colour/);
60
+ expect(appNavIssue('x')).toMatch(/array/);
61
+ });
62
+ });
63
+
64
+ describe('projectAppNav (tolerant read)', () => {
65
+ it('round-trips a valid layout', () => {
66
+ expect(projectAppNav({ rev: 7, entries: deep() })).toEqual({ rev: 7, entries: deep() });
67
+ });
68
+ it('dissolves a too-deep folder, lifting its apps rather than losing them', () => {
69
+ const stored = {
70
+ rev: 1,
71
+ entries: [folder(100, [folder(101, [folder(102, [folder(103, [app(9)])])])])],
72
+ };
73
+ const nav = projectAppNav(stored)!;
74
+ expect(appNavAppIds(nav.entries)).toEqual([id(9)]);
75
+ expect(nav.entries).toEqual([folder(100, [folder(101, [folder(102, [app(9)])])])]);
76
+ });
77
+ it('keeps the first of a repeated id and drops junk', () => {
78
+ const nav = projectAppNav({ entries: [app(1), app(1), null, { kind: 'x', id: id(2) }] })!;
79
+ expect(nav).toEqual({ rev: 0, entries: [app(1)] });
80
+ });
81
+ it('is undefined for garbage', () => {
82
+ expect(projectAppNav(null)).toBeUndefined();
83
+ expect(projectAppNav({ entries: 'no' })).toBeUndefined();
84
+ });
85
+ });
86
+
87
+ describe('pruneAppNav', () => {
88
+ it('drops deleted apps but keeps the folders that held them', () => {
89
+ const out = pruneAppNav(deep(), (appId) => appId !== id(1));
90
+ expect(appNavAppIds(out)).toEqual([id(2), id(3), id(4)]);
91
+ expect(out[0]).toEqual(folder(100, [folder(101, [folder(102, []), app(2)]), app(3)]));
92
+ });
93
+ });
94
+
95
+ describe('moving entries', () => {
96
+ it('reorders within the root', () => {
97
+ const out = moveAppNavEntry([app(1), app(2), app(3)], id(3), null, 0);
98
+ expect(out).toEqual([app(3), app(1), app(2)]);
99
+ });
100
+ it('moves an app into a folder at an index', () => {
101
+ const out = moveAppNavEntry([folder(100, [app(1)]), app(2)], id(2), id(100), 0)!;
102
+ expect(out).toEqual([folder(100, [app(2), app(1)])]);
103
+ });
104
+ it('refuses a folder into itself or its own descendant', () => {
105
+ expect(canMoveAppNavEntry(deep(), id(100), id(100))).toBe(false);
106
+ expect(canMoveAppNavEntry(deep(), id(100), id(102))).toBe(false);
107
+ });
108
+ it('refuses a move that would exceed three levels', () => {
109
+ // F101 is two folder levels tall (F101 › F102). F201 already sits one
110
+ // level down, so F101 inside it would make four.
111
+ const tree = [folder(100, [folder(101, [folder(102, [])])]), folder(200, [folder(201, [])])];
112
+ expect(canMoveAppNavEntry(tree, id(101), id(201))).toBe(false);
113
+ expect(moveAppNavEntry(tree, id(101), id(201), 0)).toBeNull();
114
+ // ...but a flat folder fits there.
115
+ expect(canMoveAppNavEntry(tree, id(102), id(201))).toBe(true);
116
+ });
117
+ it('never mutates its input', () => {
118
+ const tree = deep();
119
+ const snapshot = JSON.stringify(tree);
120
+ moveAppNavEntry(tree, id(4), id(102), 0);
121
+ expect(JSON.stringify(tree)).toBe(snapshot);
122
+ });
123
+ });
124
+
125
+ describe('placing, dissolving and editing folders', () => {
126
+ it('places an unsorted app once, and only into a real folder', () => {
127
+ expect(placeAppNavApp([folder(100, [])], id(5), id(100), 0)).toEqual([folder(100, [app(5)])]);
128
+ expect(placeAppNavApp([app(5)], id(5), null, 0)).toBeNull();
129
+ expect(placeAppNavApp([], id(5), id(999), 0)).toBeNull();
130
+ });
131
+ it('dissolves a folder into its parent, keeping order', () => {
132
+ const out = dissolveAppNavFolder([app(1), folder(100, [app(2), app(3)]), app(4)], id(100));
133
+ expect(out).toEqual([app(1), app(2), app(3), app(4)]);
134
+ });
135
+ it('renames and recolours, clearing with null', () => {
136
+ const tree = [{ ...folder(100, []), color: 'teal' as const }];
137
+ const out = updateAppNavFolder(tree, id(100), { name: ' Risk ', color: null })!;
138
+ expect(out[0]).toEqual(folder(100, [], 'Risk'));
139
+ });
140
+ });
141
+
142
+ describe('flattenAppNav', () => {
143
+ it('descends only into open folders and marks last children', () => {
144
+ const rows = flattenAppNav(deep(), (f) => f !== id(101));
145
+ expect(rows.map((r) => [r.entry.id, r.depth, r.isLast])).toEqual([
146
+ [id(100), 0, false],
147
+ [id(101), 1, false],
148
+ [id(3), 1, true],
149
+ [id(4), 0, true],
150
+ ]);
151
+ });
152
+ it('carries whether each ancestor was last, for the guide lines', () => {
153
+ const rows = flattenAppNav(deep(), () => true);
154
+ const app1 = rows.find((r) => r.entry.id === id(1))!;
155
+ // Ancestors: F100 (not last at root), F101 (not last in F100), F102 (not last in F101).
156
+ expect(app1.guides).toEqual([false, false, false]);
157
+ expect(app1.depth).toBe(3);
158
+ });
159
+ });
package/src/app-nav.ts ADDED
@@ -0,0 +1,427 @@
1
+ /**
2
+ * @mantle/content-core · app nav
3
+ *
4
+ * Pure logic for the shared app-navigation tree (types and limits in
5
+ * @mantle/client-types/app-nav): the read projection, the strict write check,
6
+ * pruning, and the tree operations a client needs to render and reorganise it.
7
+ *
8
+ * The server and every client use the same functions, so a move the sidebar
9
+ * allows is a move the server accepts, and a depth the server rejects is one
10
+ * the sidebar never offers.
11
+ *
12
+ * Zero runtime dependencies (the content-core rule): client-types is imported
13
+ * for its constants, which are themselves dependency-free.
14
+ */
15
+
16
+ import {
17
+ APP_ICON_EMOJI_MAX,
18
+ APP_ICON_MAX,
19
+ APP_NAV_FOLDER_NAME_MAX,
20
+ APP_NAV_MAX_DEPTH,
21
+ APP_NAV_MAX_FOLDERS,
22
+ LUCIDE_ICON_PREFIX,
23
+ isAppTint,
24
+ type AppNav,
25
+ type AppNavEntry,
26
+ type AppNavFolder,
27
+ type AppTint,
28
+ } from '@mantle/client-types/app-nav';
29
+
30
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
31
+ const LUCIDE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
32
+
33
+ function asId(raw: unknown): string | undefined {
34
+ if (typeof raw !== 'string') return undefined;
35
+ const id = raw.trim().toLowerCase();
36
+ return UUID_RE.test(id) ? id : undefined;
37
+ }
38
+
39
+ /** An app or folder icon: an emoji, or `lucide:<kebab-name>`. Anything else
40
+ * (including an empty string) projects to undefined, i.e. the default tile. */
41
+ export function projectAppIcon(raw: unknown): string | undefined {
42
+ if (typeof raw !== 'string') return undefined;
43
+ const v = raw.trim();
44
+ if (!v || v.length > APP_ICON_MAX) return undefined;
45
+ if (v.startsWith(LUCIDE_ICON_PREFIX)) {
46
+ return LUCIDE_NAME_RE.test(v.slice(LUCIDE_ICON_PREFIX.length)) ? v : undefined;
47
+ }
48
+ // Emoji path: short, and no ASCII letters/digits, so a mistyped lucide name
49
+ // ("chart-bar") can't masquerade as an emoji and render as literal text.
50
+ if (v.length > APP_ICON_EMOJI_MAX || /[A-Za-z0-9]/.test(v)) return undefined;
51
+ return v;
52
+ }
53
+
54
+ export function projectAppTint(raw: unknown): AppTint | undefined {
55
+ return isAppTint(raw) ? raw : undefined;
56
+ }
57
+
58
+ function folderName(raw: unknown): string | undefined {
59
+ if (typeof raw !== 'string') return undefined;
60
+ const name = raw.trim().replace(/\s+/g, ' ').slice(0, APP_NAV_FOLDER_NAME_MAX);
61
+ return name || undefined;
62
+ }
63
+
64
+ // ── Read projection (tolerant) ───────────────────────────────────────────────
65
+
66
+ /**
67
+ * Project a stored `appNav` value. Tolerant by design, because jsonb can hold
68
+ * anything an older build or a hand edit put there: bad entries are dropped,
69
+ * a repeated id keeps its first position, and a folder deeper than
70
+ * APP_NAV_MAX_DEPTH is dissolved with its contents lifted into its parent, so
71
+ * no app is lost to a bad folder. Undefined for unset or garbage.
72
+ */
73
+ export function projectAppNav(raw: unknown): AppNav | undefined {
74
+ if (typeof raw !== 'object' || raw === null) return undefined;
75
+ const r = raw as { rev?: unknown; entries?: unknown };
76
+ if (!Array.isArray(r.entries)) return undefined;
77
+ const rev = typeof r.rev === 'number' && Number.isInteger(r.rev) && r.rev >= 0 ? r.rev : 0;
78
+ const seen = new Set<string>();
79
+ let folders = 0;
80
+
81
+ const walk = (list: unknown[], depth: number): AppNavEntry[] => {
82
+ const out: AppNavEntry[] = [];
83
+ for (const item of list) {
84
+ if (typeof item !== 'object' || item === null) continue;
85
+ const e = item as Record<string, unknown>;
86
+ const id = asId(e.id);
87
+ if (!id || seen.has(id)) continue;
88
+ if (e.kind === 'app') {
89
+ seen.add(id);
90
+ out.push({ kind: 'app', id });
91
+ continue;
92
+ }
93
+ if (e.kind !== 'folder') continue;
94
+ const kids = Array.isArray(e.children) ? e.children : [];
95
+ const name = folderName(e.name);
96
+ if (!name || depth >= APP_NAV_MAX_DEPTH || folders >= APP_NAV_MAX_FOLDERS) {
97
+ // Dissolve: keep what it held, one level up.
98
+ out.push(...walk(kids, depth));
99
+ continue;
100
+ }
101
+ seen.add(id);
102
+ folders++;
103
+ const folder: AppNavFolder = { kind: 'folder', id, name, children: [] };
104
+ const icon = projectAppIcon(e.icon);
105
+ if (icon) folder.icon = icon;
106
+ const color = projectAppTint(e.color);
107
+ if (color) folder.color = color;
108
+ folder.children = walk(kids, depth + 1);
109
+ out.push(folder);
110
+ }
111
+ return out;
112
+ };
113
+
114
+ return { rev, entries: walk(r.entries, 0) };
115
+ }
116
+
117
+ // ── Write check (strict) ─────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Why a proposed `entries` tree can't be saved, or null when it can. Strict,
121
+ * unlike the projection: a client sending a bad tree has a bug, and silently
122
+ * reshaping its save would hide it. Returns the first problem found.
123
+ */
124
+ export function appNavIssue(entries: unknown): string | null {
125
+ if (!Array.isArray(entries)) return 'entries must be an array';
126
+ const seen = new Set<string>();
127
+ let folders = 0;
128
+
129
+ const check = (list: unknown[], depth: number): string | null => {
130
+ for (const item of list) {
131
+ if (typeof item !== 'object' || item === null) return 'every entry must be an object';
132
+ const e = item as Record<string, unknown>;
133
+ const id = asId(e.id);
134
+ if (!id) return `'${String(e.id)}' is not a valid id (expected a UUID)`;
135
+ if (seen.has(id)) return `'${id}' appears more than once`;
136
+ seen.add(id);
137
+ if (e.kind === 'app') continue;
138
+ if (e.kind !== 'folder') return `entry '${id}' has an unknown kind`;
139
+ if (depth >= APP_NAV_MAX_DEPTH) {
140
+ return `folders nest at most ${APP_NAV_MAX_DEPTH} levels deep`;
141
+ }
142
+ if (++folders > APP_NAV_MAX_FOLDERS) return `at most ${APP_NAV_MAX_FOLDERS} folders`;
143
+ if (!folderName(e.name)) return `folder '${id}' needs a name`;
144
+ if (e.icon != null && e.icon !== '' && !projectAppIcon(e.icon)) {
145
+ return `folder '${id}' has an invalid icon`;
146
+ }
147
+ if (e.color != null && !isAppTint(e.color)) return `folder '${id}' has an unknown colour`;
148
+ if (!Array.isArray(e.children)) return `folder '${id}' needs a children array`;
149
+ const inner = check(e.children, depth + 1);
150
+ if (inner) return inner;
151
+ }
152
+ return null;
153
+ };
154
+
155
+ return check(entries, 0);
156
+ }
157
+
158
+ // ── Tree operations ──────────────────────────────────────────────────────────
159
+
160
+ /** Every app id placed in the tree, in display order. */
161
+ export function appNavAppIds(entries: readonly AppNavEntry[]): string[] {
162
+ const out: string[] = [];
163
+ const walk = (list: readonly AppNavEntry[]) => {
164
+ for (const e of list) {
165
+ if (e.kind === 'app') out.push(e.id);
166
+ else walk(e.children);
167
+ }
168
+ };
169
+ walk(entries);
170
+ return out;
171
+ }
172
+
173
+ /** Drop app entries whose id fails `keep` (deleted apps). Folders stay, even
174
+ * when emptied: an empty folder is something the owner made on purpose. */
175
+ export function pruneAppNav(
176
+ entries: readonly AppNavEntry[],
177
+ keep: (appId: string) => boolean,
178
+ ): AppNavEntry[] {
179
+ const out: AppNavEntry[] = [];
180
+ for (const e of entries) {
181
+ if (e.kind === 'app') {
182
+ if (keep(e.id)) out.push(e);
183
+ } else {
184
+ out.push({ ...e, children: pruneAppNav(e.children, keep) });
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+
190
+ export type AppNavLocation = {
191
+ entry: AppNavEntry;
192
+ /** Containing folder id, null at the root. */
193
+ parentId: string | null;
194
+ index: number;
195
+ /** 0 at the root. A folder at depth d is nested d+1 levels deep. */
196
+ depth: number;
197
+ };
198
+
199
+ export function findAppNavEntry(
200
+ entries: readonly AppNavEntry[],
201
+ id: string,
202
+ ): AppNavLocation | null {
203
+ const walk = (
204
+ list: readonly AppNavEntry[],
205
+ parentId: string | null,
206
+ depth: number,
207
+ ): AppNavLocation | null => {
208
+ for (let i = 0; i < list.length; i++) {
209
+ const e = list[i]!;
210
+ if (e.id === id) return { entry: e, parentId, index: i, depth };
211
+ if (e.kind === 'folder') {
212
+ const hit = walk(e.children, e.id, depth + 1);
213
+ if (hit) return hit;
214
+ }
215
+ }
216
+ return null;
217
+ };
218
+ return walk(entries, null, 0);
219
+ }
220
+
221
+ /** How many folder levels an entry occupies: 0 for an app, 1 for a folder of
222
+ * apps, 2 for a folder holding a folder, and so on. */
223
+ export function appNavFolderHeight(entry: AppNavEntry): number {
224
+ if (entry.kind === 'app') return 0;
225
+ let inner = 0;
226
+ for (const c of entry.children) inner = Math.max(inner, appNavFolderHeight(c));
227
+ return 1 + inner;
228
+ }
229
+
230
+ /**
231
+ * Whether `id` may move into folder `toParentId` (null = the root): the target
232
+ * must exist, must not be the entry itself or inside it, and the entry's folder
233
+ * height must still fit under APP_NAV_MAX_DEPTH once it lands there.
234
+ */
235
+ export function canMoveAppNavEntry(
236
+ entries: readonly AppNavEntry[],
237
+ id: string,
238
+ toParentId: string | null,
239
+ ): boolean {
240
+ const from = findAppNavEntry(entries, id);
241
+ if (!from) return false;
242
+ let targetDepth = 0; // depth the entry will sit at
243
+ if (toParentId !== null) {
244
+ const target = findAppNavEntry(entries, toParentId);
245
+ if (!target || target.entry.kind !== 'folder') return false;
246
+ if (toParentId === id) return false;
247
+ if (from.entry.kind === 'folder' && findAppNavEntry(from.entry.children, toParentId)) {
248
+ return false;
249
+ }
250
+ targetDepth = target.depth + 1;
251
+ }
252
+ return targetDepth + appNavFolderHeight(from.entry) <= APP_NAV_MAX_DEPTH;
253
+ }
254
+
255
+ function removeAt(
256
+ list: readonly AppNavEntry[],
257
+ id: string,
258
+ ): { list: AppNavEntry[]; removed: AppNavEntry | null } {
259
+ let removed: AppNavEntry | null = null;
260
+ const out: AppNavEntry[] = [];
261
+ for (const e of list) {
262
+ if (e.id === id) {
263
+ removed = e;
264
+ continue;
265
+ }
266
+ if (e.kind === 'folder' && !removed) {
267
+ const inner = removeAt(e.children, id);
268
+ if (inner.removed) {
269
+ removed = inner.removed;
270
+ out.push({ ...e, children: inner.list });
271
+ continue;
272
+ }
273
+ }
274
+ out.push(e);
275
+ }
276
+ return { list: out, removed };
277
+ }
278
+
279
+ function insertAt(
280
+ list: readonly AppNavEntry[],
281
+ parentId: string | null,
282
+ index: number,
283
+ entry: AppNavEntry,
284
+ ): AppNavEntry[] {
285
+ if (parentId === null) {
286
+ const out = [...list];
287
+ out.splice(Math.max(0, Math.min(index, out.length)), 0, entry);
288
+ return out;
289
+ }
290
+ return list.map((e) =>
291
+ e.kind === 'folder'
292
+ ? e.id === parentId
293
+ ? { ...e, children: insertAt(e.children, null, index, entry) }
294
+ : { ...e, children: insertAt(e.children, parentId, index, entry) }
295
+ : e,
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Move an entry (app or folder) to `index` within `toParentId` (null = root),
301
+ * where `index` counts positions in the target list AFTER the entry has been
302
+ * taken out of its old place. Returns the new tree, or null when the move is
303
+ * illegal (see canMoveAppNavEntry). The input is never mutated.
304
+ */
305
+ export function moveAppNavEntry(
306
+ entries: readonly AppNavEntry[],
307
+ id: string,
308
+ toParentId: string | null,
309
+ index: number,
310
+ ): AppNavEntry[] | null {
311
+ if (!canMoveAppNavEntry(entries, id, toParentId)) return null;
312
+ const { list, removed } = removeAt(entries, id);
313
+ if (!removed) return null;
314
+ return insertAt(list, toParentId, index, removed);
315
+ }
316
+
317
+ /**
318
+ * Place an app that isn't in the tree yet (an unsorted app). Returns null when
319
+ * it is already placed or the target folder doesn't exist.
320
+ */
321
+ export function placeAppNavApp(
322
+ entries: readonly AppNavEntry[],
323
+ appId: string,
324
+ toParentId: string | null,
325
+ index: number,
326
+ ): AppNavEntry[] | null {
327
+ if (findAppNavEntry(entries, appId)) return null;
328
+ if (toParentId !== null) {
329
+ const target = findAppNavEntry(entries, toParentId);
330
+ if (!target || target.entry.kind !== 'folder') return null;
331
+ }
332
+ return insertAt(entries, toParentId, index, { kind: 'app', id: appId });
333
+ }
334
+
335
+ /**
336
+ * Delete a folder without deleting what it holds: its children take its place
337
+ * in the parent, in order. Lifting children one level up can never break the
338
+ * depth limit. Null when `folderId` isn't a folder.
339
+ */
340
+ export function dissolveAppNavFolder(
341
+ entries: readonly AppNavEntry[],
342
+ folderId: string,
343
+ ): AppNavEntry[] | null {
344
+ const hit = findAppNavEntry(entries, folderId);
345
+ if (!hit || hit.entry.kind !== 'folder') return null;
346
+ const kids = hit.entry.children;
347
+ const splice = (list: readonly AppNavEntry[]): AppNavEntry[] => {
348
+ const out: AppNavEntry[] = [];
349
+ for (const e of list) {
350
+ if (e.id === folderId) out.push(...kids);
351
+ else if (e.kind === 'folder') out.push({ ...e, children: splice(e.children) });
352
+ else out.push(e);
353
+ }
354
+ return out;
355
+ };
356
+ return splice(entries);
357
+ }
358
+
359
+ /** Shallow-update one folder's name/icon/colour. Null when not a folder. */
360
+ export function updateAppNavFolder(
361
+ entries: readonly AppNavEntry[],
362
+ folderId: string,
363
+ patch: { name?: string; icon?: string | null; color?: AppTint | null },
364
+ ): AppNavEntry[] | null {
365
+ if (findAppNavEntry(entries, folderId)?.entry.kind !== 'folder') return null;
366
+ const apply = (list: readonly AppNavEntry[]): AppNavEntry[] =>
367
+ list.map((e) => {
368
+ if (e.kind !== 'folder') return e;
369
+ if (e.id !== folderId) return { ...e, children: apply(e.children) };
370
+ const next: AppNavFolder = { ...e };
371
+ if (patch.name !== undefined) next.name = folderName(patch.name) ?? e.name;
372
+ if (patch.icon !== undefined) {
373
+ const icon = projectAppIcon(patch.icon);
374
+ if (icon) next.icon = icon;
375
+ else delete next.icon;
376
+ }
377
+ if (patch.color !== undefined) {
378
+ if (patch.color && isAppTint(patch.color)) next.color = patch.color;
379
+ else delete next.color;
380
+ }
381
+ return next;
382
+ });
383
+ return apply(entries);
384
+ }
385
+
386
+ /** One visible row of the rendered tree. */
387
+ export type AppNavRow = {
388
+ entry: AppNavEntry;
389
+ depth: number;
390
+ parentId: string | null;
391
+ /** Last child of its parent: draws └ instead of ├. */
392
+ isLast: boolean;
393
+ /**
394
+ * For each ancestor level 0..depth-1, whether that ancestor was the last
395
+ * child of ITS parent. A level whose ancestor was last draws no continuing
396
+ * vertical guide (the └ already closed it); any other level draws │.
397
+ */
398
+ guides: boolean[];
399
+ };
400
+
401
+ /**
402
+ * Flatten the tree into display rows, descending only into folders `isOpen`
403
+ * reports as expanded. Carries what the dotted guide lines need, so the
404
+ * renderer doesn't have to walk the tree itself.
405
+ */
406
+ export function flattenAppNav(
407
+ entries: readonly AppNavEntry[],
408
+ isOpen: (folderId: string) => boolean,
409
+ ): AppNavRow[] {
410
+ const rows: AppNavRow[] = [];
411
+ const walk = (
412
+ list: readonly AppNavEntry[],
413
+ depth: number,
414
+ parentId: string | null,
415
+ guides: boolean[],
416
+ ) => {
417
+ list.forEach((entry, i) => {
418
+ const isLast = i === list.length - 1;
419
+ rows.push({ entry, depth, parentId, isLast, guides });
420
+ if (entry.kind === 'folder' && isOpen(entry.id)) {
421
+ walk(entry.children, depth + 1, entry.id, [...guides, isLast]);
422
+ }
423
+ });
424
+ };
425
+ walk(entries, 0, null, []);
426
+ return rows;
427
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { projectAppOpens, projectAppPins, projectNavFavorites } from './profile-projections';
3
+
4
+ const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
5
+
6
+ describe('projectAppPins', () => {
7
+ it('keeps UUIDs in order, lowercased and deduped', () => {
8
+ expect(projectAppPins([id(2), id(1).toUpperCase(), id(2), 'x'])).toEqual([id(2), id(1)]);
9
+ });
10
+ it('is undefined when nothing survives', () => {
11
+ expect(projectAppPins(['x'])).toBeUndefined();
12
+ expect(projectAppPins('x')).toBeUndefined();
13
+ });
14
+ });
15
+
16
+ describe('projectNavFavorites', () => {
17
+ it('keeps in-app hrefs only', () => {
18
+ expect(
19
+ projectNavFavorites(['/notes', '//evil.example', 'https://x.test', '/notes', ' /tasks ']),
20
+ ).toEqual(['/notes', '/tasks']);
21
+ });
22
+ });
23
+
24
+ describe('projectAppOpens', () => {
25
+ it('drops malformed counters and orders by most recent', () => {
26
+ const out = projectAppOpens({
27
+ [id(1)]: { n: 3, at: '2026-09-01T00:00:00.000Z' },
28
+ [id(2)]: { n: 1, at: '2026-09-20T00:00:00.000Z' },
29
+ [id(3)]: { n: 0, at: '2026-09-20T00:00:00.000Z' },
30
+ bad: { n: 5, at: '2026-09-20T00:00:00.000Z' },
31
+ })!;
32
+ expect(Object.keys(out)).toEqual([id(2), id(1)]);
33
+ });
34
+ });
@@ -25,6 +25,15 @@ import type {
25
25
  } from '@mantle/client-types';
26
26
 
27
27
  import { thinkingEffortForBudget, type ThinkingEffort } from './thinking-tiers';
28
+ import {
29
+ APP_OPENS_MAX,
30
+ APP_PINS_MAX,
31
+ NAV_FAVORITE_HREF_MAX,
32
+ NAV_FAVORITES_MAX,
33
+ type AppOpenStat,
34
+ } from '@mantle/client-types/app-nav';
35
+
36
+ export { projectAppNav } from './app-nav';
28
37
 
29
38
  export type { OnboardingModelChoices, ProfilePreferences, ReminderChannel, ThoughtTrailMode };
30
39
 
@@ -388,6 +397,56 @@ export function projectTeamHubTags(raw: unknown): string[] | undefined {
388
397
  return out.length > 0 ? out : undefined;
389
398
  }
390
399
 
400
+ /** Project stored `appPins`: lowercased, deduped UUIDs in order, capped at
401
+ * APP_PINS_MAX; undefined for unset/empty/garbage. Read and write share it. */
402
+ export function projectAppPins(raw: unknown): string[] | undefined {
403
+ if (!Array.isArray(raw)) return undefined;
404
+ const out: string[] = [];
405
+ for (const v of raw) {
406
+ if (typeof v !== 'string') continue;
407
+ const id = v.trim().toLowerCase();
408
+ if (!UUID_RE.test(id) || out.includes(id)) continue;
409
+ out.push(id);
410
+ if (out.length >= APP_PINS_MAX) break;
411
+ }
412
+ return out.length > 0 ? out : undefined;
413
+ }
414
+
415
+ /** Project stored `navFavorites`: in-app hrefs (a single leading '/', never
416
+ * '//' which would be protocol-relative), deduped, capped. */
417
+ export function projectNavFavorites(raw: unknown): string[] | undefined {
418
+ if (!Array.isArray(raw)) return undefined;
419
+ const out: string[] = [];
420
+ for (const v of raw) {
421
+ if (typeof v !== 'string') continue;
422
+ const href = v.trim();
423
+ if (!href.startsWith('/') || href.startsWith('//') || href.length > NAV_FAVORITE_HREF_MAX) {
424
+ continue;
425
+ }
426
+ if (out.includes(href)) continue;
427
+ out.push(href);
428
+ if (out.length >= NAV_FAVORITES_MAX) break;
429
+ }
430
+ return out.length > 0 ? out : undefined;
431
+ }
432
+
433
+ /** Project stored `appOpens`: well-formed counters only, keeping the
434
+ * APP_OPENS_MAX most recently opened. */
435
+ export function projectAppOpens(raw: unknown): Record<string, AppOpenStat> | undefined {
436
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
437
+ const rows: [string, AppOpenStat][] = [];
438
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
439
+ if (!UUID_RE.test(k) || typeof v !== 'object' || v === null) continue;
440
+ const { n, at } = v as { n?: unknown; at?: unknown };
441
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 1) continue;
442
+ if (typeof at !== 'string' || Number.isNaN(Date.parse(at))) continue;
443
+ rows.push([k.toLowerCase(), { n: Math.floor(n), at }]);
444
+ }
445
+ if (rows.length === 0) return undefined;
446
+ rows.sort((a, b) => Date.parse(b[1].at) - Date.parse(a[1].at));
447
+ return Object.fromEntries(rows.slice(0, APP_OPENS_MAX));
448
+ }
449
+
391
450
  export const DEFAULT_PREFERENCES: ProfilePreferences = {
392
451
  timezone: 'UTC',
393
452
  locale: 'en-GB',