caracms 0.4.0 → 0.5.0

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.
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Arrange pages (notes/2026-10-25-arrange-pages.md): the screen's model. Pure and
3
+ * browser-safe, so the screen, its review and the unit tests agree.
4
+ *
5
+ * - **The tree** (`buildTree`): the Pages listing as nodes, in the listing's order (the
6
+ * server's order rule, Decision 7), a folder with no page of its own as a group just
7
+ * before its first child (as nav/tree.ts does), each held collection on its page.
8
+ * A node's `id` is its path as listed (a page and the folder of its pages are one node),
9
+ * or `new:<n>` for a group made on the screen. The id never changes while arranging, so
10
+ * the tree as it was (`orig`) and as it is now (`tree`) line up by id.
11
+ * - **The plan** (`makePlan`): what the arrangement means, worked out from the two trees:
12
+ * the Apply's request (roots only, and the full child list of every level that changed:
13
+ * the spec's Contracts → "Apply request"), the review's old → new list (Decisions 3, 12,
14
+ * 14, 21), and the pane's list of changes.
15
+ * - **The checks** (`checkDrop`, `nameCheck`): a drop or a name refused on the screen, at
16
+ * the drop (Decision 8), Home taking nothing (Decision 13), a held collection's row
17
+ * taking nothing (Decision 12), a group's name by the new-page rules (Decision 17).
18
+ * - **Re-planning** (`rebase`, Decision 10): the same arrangement on a fresh listing, after
19
+ * the server refused an Apply, with what changed underneath named.
20
+ */
21
+ import { titleCase } from '../../../fields.ts';
22
+ import { documentsNoun } from '../../../held.ts';
23
+ import { INDEX_MESSAGE, NO_ADDRESS_MESSAGE, RESERVED_SLUG, addressProblem, slugify, type AddressCollection } from '../../../slug.ts';
24
+ import type { CodeRoute } from '../../../code-routes.ts';
25
+ import type { ListedDocument } from '../../types.ts';
26
+ import { andList, singular } from '../documents/actions.ts';
27
+
28
+ /** A collection a page holds, as its row on the screen shows it (Decision 12). */
29
+ export interface HeldRow {
30
+ collection: string;
31
+ label: string;
32
+ icon: string;
33
+ /** Its word for one document and for several: "case study", "case studies". */
34
+ one: string;
35
+ many: string;
36
+ /** Every document in it, hidden ones included (as the sidebar counts them). */
37
+ docs: { path: string; url: string | null }[];
38
+ }
39
+
40
+ export interface ArrangeNode {
41
+ /** Its path as listed, or `new:<n>` for a group made here. Constant while arranging. */
42
+ id: string;
43
+ kind: 'page' | 'group';
44
+ slug: string;
45
+ label: string;
46
+ /** A page's own "hidden" (M9), not one it has from a page above it. */
47
+ hidden: boolean;
48
+ holds: HeldRow | null;
49
+ kids: ArrangeNode[];
50
+ /** A group made on this screen (Decision 17). */
51
+ fresh?: boolean;
52
+ /** Groups this page took in (Decision 8): dropped beside one with its slug, or one renamed to its slug. */
53
+ joined?: { label: string; how: 'drop' | 'name' }[];
54
+ }
55
+
56
+ /** What the model needs of the site. */
57
+ export interface ArrangeSite {
58
+ /** The nested collection being arranged (`pages`). */
59
+ collection: string;
60
+ /** Its `urlBase` (`/`). */
61
+ base: string;
62
+ /** Home's path (`index`). */
63
+ home: string;
64
+ collections: readonly (AddressCollection & { kind?: string; heldBy?: string | null; holder?: { collection: string; path: string } | null; nested?: boolean })[];
65
+ codeRoutes?: readonly CodeRoute[];
66
+ }
67
+
68
+ export interface Placed {
69
+ node: ArrangeNode;
70
+ /** The parent's id; null at the top level. */
71
+ parent: string | null;
72
+ /** Its path in this tree. */
73
+ path: string;
74
+ /** Hidden on the site: its own flag, or a page above it hidden. */
75
+ hidden: boolean;
76
+ depth: number;
77
+ }
78
+
79
+ const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
80
+ export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
81
+ const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x)) as T;
82
+
83
+ /** The tree from the listing, in its order (the server's order rule). */
84
+ export function buildTree(site: ArrangeSite, documents: readonly ListedDocument[]): ArrangeNode[] {
85
+ const docs = documents.filter((d) => d.collection === site.collection);
86
+ const byPath = new Map<string, ArrangeNode>();
87
+ const top: ArrangeNode[] = [];
88
+ const place = (path: string, node: ArrangeNode) => {
89
+ byPath.set(path, node);
90
+ const parent = parentOf(path);
91
+ (parent ? ensure(parent).kids : top).push(node);
92
+ };
93
+ const ensure = (path: string): ArrangeNode => {
94
+ const had = byPath.get(path);
95
+ if (had) return had;
96
+ const slug = path.slice(path.lastIndexOf('/') + 1);
97
+ const group: ArrangeNode = { id: path, kind: 'group', slug, label: titleCase(slug), hidden: false, holds: null, kids: [] };
98
+ place(path, group);
99
+ return group;
100
+ };
101
+ for (const d of docs) {
102
+ const slug = d.path.slice(d.path.lastIndexOf('/') + 1);
103
+ const had = byPath.get(d.path);
104
+ const own = { kind: 'page' as const, label: d.label, hidden: !!d.hiddenSelf };
105
+ if (had) Object.assign(had, own);
106
+ else place(d.path, { id: d.path, slug, holds: null, kids: [], ...own });
107
+ }
108
+ for (const c of site.collections) {
109
+ if (c.heldBy !== site.collection || c.holder?.collection !== site.collection) continue;
110
+ const holder = byPath.get(c.holder.path);
111
+ if (!holder || holder.kind !== 'page') continue;
112
+ holder.holds = {
113
+ collection: c.name,
114
+ label: c.label,
115
+ icon: c.kind === 'fields' ? 'case' : 'journal',
116
+ one: singular({ kind: 'sections', nested: false, label: c.label }),
117
+ many: documentsNoun(c.label),
118
+ docs: documents.filter((d) => d.collection === c.name).map((d) => ({ path: d.path, url: d.url })),
119
+ };
120
+ }
121
+ return top;
122
+ }
123
+
124
+ export function indexTree(tree: readonly ArrangeNode[]): Map<string, Placed> {
125
+ const out = new Map<string, Placed>();
126
+ const walk = (nodes: readonly ArrangeNode[], parent: string | null, pp: string, hidden: boolean, depth: number) => {
127
+ for (const node of nodes) {
128
+ const path = pp ? `${pp}/${node.slug}` : node.slug;
129
+ const h = hidden || (node.kind === 'page' && node.hidden);
130
+ out.set(node.id, { node, parent, path, hidden: h, depth });
131
+ walk(node.kids, node.id, path, h, depth + 1);
132
+ }
133
+ };
134
+ walk(tree, null, '', false, 0);
135
+ return out;
136
+ }
137
+
138
+ /** A row's public address: `/about/team`, Home `/`, a group `/services/`. */
139
+ export function addressAt(site: Pick<ArrangeSite, 'base' | 'home'>, kind: ArrangeNode['kind'], path: string): string {
140
+ if (kind === 'group') return `${site.base}${path}/`;
141
+ return path === site.home ? site.base : `${site.base}${path}`;
142
+ }
143
+
144
+ export function locate(tree: ArrangeNode[], id: string, parentId: string | null = null): { list: ArrangeNode[]; i: number; node: ArrangeNode; parentId: string | null } | null {
145
+ for (let i = 0; i < tree.length; i++) {
146
+ const node = tree[i]!;
147
+ if (node.id === id) return { list: tree, i, node, parentId };
148
+ const found = locate(node.kids, id, node.id);
149
+ if (found) return found;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ export const countPages = (nodes: readonly ArrangeNode[]): number => nodes.reduce((k, n) => k + (n.kind === 'page' ? 1 : 0) + countPages(n.kids), 0);
155
+ /** A group with no page anywhere under it (only groups that go, or nothing): it goes too, or, new, it's empty. */
156
+ export const pageless = (node: ArrangeNode): boolean => node.kind === 'group' && countPages(node.kids) === 0;
157
+ export const countGroups = (nodes: readonly ArrangeNode[]): number => nodes.reduce((k, n) => k + (n.kind === 'group' ? 1 : 0) + countGroups(n.kids), 0);
158
+
159
+ // ── Changing the tree (each on a clone the store then keeps) ────────────────────
160
+
161
+ /** Move `id` into `parentId` (null: the top level), before `beforeId` (null: at the end). */
162
+ export function moveNode(tree: ArrangeNode[], id: string, parentId: string | null, beforeId: string | null): void {
163
+ const s = locate(tree, id);
164
+ if (!s) return;
165
+ s.list.splice(s.i, 1);
166
+ const list = parentId ? locate(tree, parentId)?.node.kids : tree;
167
+ if (!list) return void s.list.splice(s.i, 0, s.node);
168
+ let at = beforeId ? list.findIndex((x) => x.id === beforeId) : -1;
169
+ if (at < 0) at = list.length;
170
+ list.splice(at, 0, s.node);
171
+ }
172
+
173
+ /** A drop that landed: a move, or (Decision 8) a page taking the place of a group with its slug, the group's pages under it. */
174
+ export function dropNode(tree: ArrangeNode[], id: string, target: Exclude<DropTarget, { kind: 'refused' }>): void {
175
+ if (!target.merge) return moveNode(tree, id, target.parentId, target.beforeId);
176
+ const s = locate(tree, id);
177
+ const g = locate(tree, target.merge);
178
+ if (!s || !g) return;
179
+ s.list.splice(s.i, 1);
180
+ const at = g.list.indexOf(g.node);
181
+ s.node.kids = [...s.node.kids, ...g.node.kids];
182
+ s.node.joined = [...(s.node.joined ?? []), ...(g.node.joined ?? []), { label: g.node.label, how: 'drop' }];
183
+ g.list.splice(at, 1, s.node);
184
+ }
185
+
186
+ /** New group (Decision 17): at the end of the top level. Returns its id. */
187
+ export function addGroup(tree: ArrangeNode[], label: string, n: number): string {
188
+ const id = `new:${n}`;
189
+ tree.push({ id, kind: 'group', slug: slugify(label), label: label.trim(), hidden: false, holds: null, kids: [], fresh: true });
190
+ return id;
191
+ }
192
+
193
+ /** Rename… on a group; a name that's a sibling page's slug puts the group's pages under that page (Decision 8). */
194
+ export function renameGroup(tree: ArrangeNode[], id: string, label: string, merge: string | null): void {
195
+ const g = locate(tree, id);
196
+ if (!g) return;
197
+ if (merge) {
198
+ const page = locate(tree, merge)?.node;
199
+ if (!page) return;
200
+ g.list.splice(g.i, 1);
201
+ page.kids = [...page.kids, ...g.node.kids];
202
+ page.joined = [...(page.joined ?? []), { label: g.node.label, how: 'name' }];
203
+ return;
204
+ }
205
+ g.node.label = label.trim();
206
+ g.node.slug = slugify(label);
207
+ }
208
+
209
+ export function removeNode(tree: ArrangeNode[], id: string): void {
210
+ const s = locate(tree, id);
211
+ if (s) s.list.splice(s.i, 1);
212
+ }
213
+
214
+ // ── Checks ─────────────────────────────────────────────────────────────────────
215
+
216
+ export type DropTarget =
217
+ | {
218
+ kind: 'beside' | 'inside';
219
+ /** Where it lands: the parent's id (null: the top level), and the sibling it goes before (null: last). */
220
+ parentId: string | null;
221
+ beforeId: string | null;
222
+ /** The row the marker is drawn against, and which edge (beside). */
223
+ rowId: string;
224
+ edge?: 'top' | 'bottom';
225
+ depth: number;
226
+ /** A page dropped beside a group with its slug: the group it becomes the page of (Decision 8). */
227
+ merge?: string;
228
+ /** Only the order changes (same parent, no merge). */
229
+ same?: boolean;
230
+ /** The level it lands in, for the marker's tag: "Top level", "In About", "Inside Company". */
231
+ tag?: string;
232
+ }
233
+ | { kind: 'refused'; rowId: string; reason: string };
234
+
235
+ /**
236
+ * Whether a drop lands, given where the pointer is (`target` without `merge`, `same` or
237
+ * `tag`), or why not: a page beside a page with its slug, a group beside anything with its
238
+ * slug, a page into its own pages, an address a page can't have (reserved, a code route,
239
+ * under another collection, a held document's).
240
+ */
241
+ export function checkDrop(site: ArrangeSite, tree: ArrangeNode[], documents: readonly ListedDocument[], srcId: string, target: DropTarget): DropTarget {
242
+ if (target.kind === 'refused') return target;
243
+ const src = locate(tree, srcId);
244
+ if (!src) return { kind: 'refused', rowId: target.rowId, reason: 'That row has gone' };
245
+ const index = indexTree(tree);
246
+ const t = { ...target };
247
+ const refuse = (reason: string): DropTarget => ({ kind: 'refused', rowId: t.rowId, reason });
248
+ if (t.parentId) {
249
+ const p = index.get(t.parentId);
250
+ if (!p) return refuse('That row has gone');
251
+ if (p.path === site.home) return refuse('Home takes no pages');
252
+ if (t.parentId === srcId || p.path.startsWith(`${index.get(srcId)!.path}/`)) return refuse(`${src.node.label} can’t go inside itself`);
253
+ }
254
+ const list = t.parentId ? index.get(t.parentId)!.node.kids : tree;
255
+ const same = list.find((k) => k.id !== srcId && k.slug === src.node.slug);
256
+ if (same) {
257
+ if (same.kind === 'page' || src.node.kind === 'group') return refuse(`There’s already a ${same.kind} called ${same.label} here`);
258
+ const clash = src.node.kids.find((k) => same.kids.some((g) => g.slug === k.slug));
259
+ if (clash) return refuse(`There’s already a page called ${clash.label} in ${same.label}`);
260
+ t.merge = same.id;
261
+ }
262
+ t.same = src.parentId === t.parentId && !t.merge;
263
+ const label = (id: string) => index.get(id)?.node.label ?? '';
264
+ t.tag = t.kind === 'inside' ? `Inside ${label(t.parentId!)}` : t.parentId ? `In ${label(t.parentId)}` : 'Top level';
265
+ if (!t.same && !t.merge && src.node.kind === 'page') {
266
+ const pp = t.parentId ? `${index.get(t.parentId)!.path}/` : '';
267
+ const url = addressAt(site, 'page', `${pp}${src.node.slug}`);
268
+ const moving = new Set<string>();
269
+ for (const [, x] of indexTree([src.node])) if (x.node.kind === 'page') moving.add(x.node.id);
270
+ const others = documents.filter((d) => !(d.collection === site.collection && moving.has(d.path)));
271
+ const problem = addressProblem(url, site.collection, site.collections, others, site.codeRoutes);
272
+ if (problem) return refuse(problem.message.replace(/\.$/, ''));
273
+ }
274
+ return t;
275
+ }
276
+
277
+ export interface NameCheck {
278
+ slug: string;
279
+ /** Can't be added (empty, or refused). */
280
+ bad: boolean;
281
+ err?: string;
282
+ /** A sibling page with this slug: the group's pages go under it (Decision 8). */
283
+ merge?: string;
284
+ info?: string;
285
+ }
286
+
287
+ /** A group's name (New group, Rename…) by the new-page rules (Decision 17), among `list`, its siblings. */
288
+ export function nameCheck(site: ArrangeSite, tree: ArrangeNode[], value: string, parentId: string | null, selfId: string | null): NameCheck {
289
+ const slug = slugify(value || '');
290
+ if (!value.trim()) return { slug, bad: true };
291
+ if (!slug) return { slug, bad: true, err: NO_ADDRESS_MESSAGE };
292
+ if (slug === RESERVED_SLUG) return { slug, bad: true, err: INDEX_MESSAGE };
293
+ const index = indexTree(tree);
294
+ const list = parentId ? (index.get(parentId)?.node.kids ?? []) : tree;
295
+ const same = list.find((k) => k.id !== selfId && k.slug === slug);
296
+ if (same?.kind === 'group') return { slug, bad: true, err: `There’s already a group called ${same.label} here.` };
297
+ if (same?.kind === 'page') return { slug, bad: false, merge: same.id, info: `There’s a page called ${same.label} here, so this joins it: what you put in goes under ${same.label}.` };
298
+ const pp = parentId ? `${index.get(parentId)!.path}/` : '';
299
+ const problem = addressProblem(addressAt(site, 'page', `${pp}${slug}`), site.collection, site.collections, [], site.codeRoutes);
300
+ if (problem) return { slug, bad: true, err: problem.message };
301
+ return { slug, bad: false };
302
+ }
303
+
304
+ // ── The plan ───────────────────────────────────────────────────────────────────
305
+
306
+ export type Entry =
307
+ | { t: 'move' | 'carried'; id: string; label: string; from: string; to: string; ind: number; vis: 'hidden' | 'shown' | null }
308
+ | { t: 'held'; id: string; holder: string; held: HeldRow; from: string; to: string; base: string; ind: number }
309
+ | { t: 'newgroup'; id: string; label: string; empty: boolean; ind: number }
310
+ | { t: 'rename' | 'groupmove'; id: string; label: string; from: string; to: string; dest: string | null; fromA: string; toA: string; ind: number }
311
+ | { t: 'gone'; id: string; label: string; ind: number }
312
+ | { t: 'joined'; id: string; label: string; into: string; how: 'drop' | 'name'; ind: number };
313
+
314
+ /** One line in the pane's list of changes: an icon and its words (with the names to set in bold). */
315
+ export interface Change {
316
+ icon: string;
317
+ /** Text and bold parts in turn: `['', 'About', ' moves into ', 'Company', '']`, even indexes plain. */
318
+ parts: string[];
319
+ muted?: boolean;
320
+ }
321
+
322
+ export interface ApplyMove {
323
+ collection: string;
324
+ fromPath: string;
325
+ toPath: string;
326
+ }
327
+
328
+ export interface Plan {
329
+ orig: Map<string, Placed>;
330
+ now: Map<string, Placed>;
331
+ /** The Apply's request (Contracts): roots only, and the full child list of every level that changed. */
332
+ moves: ApplyMove[];
333
+ order: Record<string, string[]>;
334
+ entries: Entry[];
335
+ changes: Change[];
336
+ /** The levels whose order changed (a parent's id; null for the top level). */
337
+ reorders: (string | null)[];
338
+ /** Pages whose address changes, and the held collections that go with them. */
339
+ moved: string[];
340
+ heldMoved: HeldRow[];
341
+ emptyFresh: ArrangeNode[];
342
+ gone: string[];
343
+ /** Anything to apply. */
344
+ any: boolean;
345
+ /** No address changes: only the order (Decision 21). */
346
+ orderOnly: boolean;
347
+ /** At least one changed address was live (the tick shows, Decision 3). */
348
+ live: boolean;
349
+ /** "3 pages and 24 journal entries" and its verb. */
350
+ lead: string;
351
+ verb: 'changes' | 'change';
352
+ total: number;
353
+ }
354
+
355
+ export interface PlanOptions {
356
+ /** Made since the last Publish, or moved since and already redirected: no live address to leave a redirect from. */
357
+ pending?: (collection: string, path: string) => boolean;
358
+ }
359
+
360
+ export function makePlan(site: ArrangeSite, origTree: readonly ArrangeNode[], tree: readonly ArrangeNode[], options: PlanOptions = {}): Plan {
361
+ const o = indexTree(origTree);
362
+ const n = indexTree(tree);
363
+ const addr = (x: Placed) => addressAt(site, x.node.kind, x.path);
364
+ const moved: string[] = [];
365
+ const heldMoved: HeldRow[] = [];
366
+ for (const [id, x] of n) {
367
+ const y = o.get(id);
368
+ if (!y || x.node.kind !== 'page' || y.node.kind !== 'page') continue;
369
+ if (y.path !== x.path) {
370
+ moved.push(id);
371
+ if (x.node.holds) heldMoved.push(x.node.holds);
372
+ }
373
+ }
374
+ const fresh = [...n.values()].filter((x) => x.node.fresh);
375
+ const emptyFresh = fresh.filter((x) => pageless(x.node)).map((x) => x.node);
376
+ const gone = [...o.values()].filter((y) => y.node.kind === 'group' && n.has(y.node.id) && pageless(n.get(y.node.id)!.node)).map((y) => y.node.id);
377
+ const groupsMoved = [...n.values()].filter((x) => x.node.kind === 'group' && o.has(x.node.id) && o.get(x.node.id)!.path !== x.path);
378
+ const joined = [...n.values()].filter((x) => x.node.joined?.length);
379
+
380
+ // The levels whose order changed among the rows that were and still are there.
381
+ const reorders: (string | null)[] = [];
382
+ for (const p of [null, ...n.keys()]) {
383
+ if (p && !o.has(p)) continue;
384
+ const oldKids = (p ? o.get(p)!.node.kids : origTree).map((k) => k.id).filter((id) => n.get(id)?.parent === p);
385
+ const newKids = (p ? n.get(p)!.node.kids : tree).map((k) => k.id).filter((id) => oldKids.includes(id));
386
+ if (oldKids.join('\n') !== newKids.join('\n')) reorders.push(p);
387
+ }
388
+
389
+ // The request's roots: each page whose path changes and isn't carried by a root above it.
390
+ const moves: ApplyMove[] = [];
391
+ for (const [id, x] of n) {
392
+ const y = o.get(id);
393
+ if (!y || x.node.kind !== 'page' || y.node.kind !== 'page' || y.path === x.path) continue;
394
+ const carried = moves.some((m) => y.path.startsWith(`${m.fromPath}/`) && x.path === `${m.toPath}${y.path.slice(m.fromPath.length)}`);
395
+ if (!carried) moves.push({ collection: site.collection, fromPath: y.path, toPath: x.path });
396
+ }
397
+
398
+ // The request's order: the full child list of every level whose children or order changed, keyed by its path after
399
+ // the moves. Also a level where a row gained or lost what's under it, since the rule (Decision 7) would move it.
400
+ // Home is always first, so it's never listed; a group that goes has no key.
401
+ const order: Record<string, string[]> = {};
402
+ // A group with no page left under it goes (its folder can't exist), so it isn't listed: one holding only
403
+ // groups that go too.
404
+ const slugs = (kids: readonly ArrangeNode[]) => kids.filter((k) => !pageless(k)).map((k) => k.slug).filter((s) => s !== site.home);
405
+ const holdsSome = (k: ArrangeNode) => countPages(k.kids) > 0 || !!k.holds;
406
+ const levelChanged = (before: readonly ArrangeNode[] | null, after: readonly ArrangeNode[]) => {
407
+ if (!before) return after.length > 0;
408
+ if (slugs(before).join('\n') !== slugs(after).join('\n')) return true;
409
+ return after.some((k) => {
410
+ const was = o.get(k.id)?.node;
411
+ return !!was && holdsSome(was) !== holdsSome(k);
412
+ });
413
+ };
414
+ if (levelChanged(origTree, tree)) order[''] = slugs(tree);
415
+ for (const [id, x] of n) {
416
+ if (x.path === site.home) continue;
417
+ if (pageless(x.node)) continue;
418
+ const before = o.get(id)?.node;
419
+ if (levelChanged(before ? before.kids : null, x.node.kids)) order[x.path] = slugs(x.node.kids);
420
+ }
421
+
422
+ // The review's list, top to bottom in the new tree: each root, then what it carries.
423
+ const entries: Entry[] = [];
424
+ const isRoot = (id: string) => o.get(id)!.parent !== n.get(id)!.parent;
425
+ const visOf = (x: Placed, y: Placed) => (y.hidden !== x.hidden ? (x.hidden ? 'hidden' : 'shown') : null);
426
+ const pushPage = (node: ArrangeNode, ind: number) => {
427
+ const x = n.get(node.id)!;
428
+ const y = o.get(node.id)!;
429
+ entries.push({ t: isRoot(node.id) ? 'move' : 'carried', id: node.id, label: node.label, from: addr(y), to: addr(x), ind, vis: visOf(x, y) });
430
+ if (node.holds) entries.push({ t: 'held', id: node.id, holder: node.label, held: node.holds, from: `${addr(y).replace(/\/$/, '')}/…`, to: `${addr(x).replace(/\/$/, '')}/…`, base: addr(x).replace(/\/$/, ''), ind: ind + 1 });
431
+ };
432
+ const pushJoined = (node: ArrangeNode, ind: number) => {
433
+ for (const j of node.joined ?? []) entries.push({ t: 'joined', id: node.id, label: j.label, into: node.label, how: j.how, ind });
434
+ };
435
+ const carried = (nodes: readonly ArrangeNode[], ind: number) => {
436
+ for (const k of nodes) {
437
+ const y = o.get(k.id);
438
+ if (k.kind === 'page') {
439
+ if (y?.node.kind === 'page' && y.path !== n.get(k.id)!.path) pushPage(k, ind);
440
+ carried(k.kids, ind + 1);
441
+ } else carried(k.kids, ind);
442
+ }
443
+ };
444
+ const walk = (nodes: readonly ArrangeNode[], ind: number) => {
445
+ for (const node of nodes) {
446
+ const x = n.get(node.id)!;
447
+ const y = o.get(node.id);
448
+ if (node.kind === 'group') {
449
+ if (!y || node.fresh) {
450
+ entries.push({ t: 'newgroup', id: node.id, label: node.label, empty: pageless(node), ind });
451
+ walk(node.kids, ind + 1);
452
+ continue;
453
+ }
454
+ if (y.path !== x.path) {
455
+ const renamed = y.node.slug !== node.slug;
456
+ entries.push({ t: renamed ? 'rename' : 'groupmove', id: node.id, from: y.node.label, to: node.label, label: node.label, dest: x.parent ? n.get(x.parent)!.node.label : null, fromA: addr(y), toA: addr(x), ind });
457
+ carried(node.kids, ind + 1);
458
+ continue;
459
+ }
460
+ walk(node.kids, ind);
461
+ continue;
462
+ }
463
+ if (y && y.node.kind === 'page' && y.path !== x.path) {
464
+ pushPage(node, ind);
465
+ pushJoined(node, ind + 1);
466
+ carried(node.kids, ind + 1);
467
+ continue;
468
+ }
469
+ pushJoined(node, ind);
470
+ walk(node.kids, ind);
471
+ }
472
+ };
473
+ walk(tree, 0);
474
+ for (const g of gone) {
475
+ const gp = addr(o.get(g)!);
476
+ let last = -1;
477
+ entries.forEach((e, i) => {
478
+ if ('from' in e && typeof e.from === 'string' && e.from.startsWith(gp)) last = i;
479
+ });
480
+ const at = last < 0 ? entries.length : last + 1;
481
+ entries.splice(at, 0, { t: 'gone', id: g, label: o.get(g)!.node.label, ind: last < 0 ? 0 : entries[last]!.ind });
482
+ }
483
+
484
+ const parts: string[] = [];
485
+ if (moved.length) parts.push(plural(moved.length, 'page', 'pages'));
486
+ for (const h of heldMoved) parts.push(plural(h.docs.length, h.one, h.many));
487
+ const total = moved.length + heldMoved.reduce((k, h) => k + h.docs.length, 0);
488
+ const pending = options.pending ?? (() => false);
489
+ const live =
490
+ moved.some((id) => !pending(site.collection, o.get(id)!.path)) ||
491
+ heldMoved.some((h) => h.docs.some((d) => d.url !== null && !pending(h.collection, d.path)));
492
+ const any = moved.length > 0 || fresh.length > 0 || gone.length > 0 || groupsMoved.length > 0 || reorders.length > 0 || joined.length > 0 || Object.keys(order).length > 0;
493
+ const plan: Plan = {
494
+ orig: o,
495
+ now: n,
496
+ moves,
497
+ order,
498
+ entries,
499
+ changes: [],
500
+ reorders,
501
+ moved,
502
+ heldMoved,
503
+ emptyFresh,
504
+ gone,
505
+ any,
506
+ orderOnly: !moved.length && !entries.length,
507
+ live,
508
+ lead: andList(parts),
509
+ verb: total === 1 ? 'changes' : 'change',
510
+ total,
511
+ };
512
+ plan.changes = changesOf(plan);
513
+ return plan;
514
+ }
515
+
516
+ /** The pane's list (the mockup's changeItems): what's been arranged so far, in words. */
517
+ function changesOf(plan: Plan): Change[] {
518
+ const n = plan.now;
519
+ const nameOf = (id: string) => n.get(id)?.node.label ?? '';
520
+ const withWhat = (node: ArrangeNode) => {
521
+ const bits: string[] = [];
522
+ if (node.holds) bits.push(`its ${plural(node.holds.docs.length, node.holds.one, node.holds.many)}`);
523
+ const k = countPages(node.kids);
524
+ if (k === 1) bits.push(node.kids.find((c) => c.kind === 'page')?.label ?? 'its page');
525
+ else if (k > 1) bits.push(`its ${k} pages`);
526
+ return bits.length ? `, with ${andList(bits)}` : '';
527
+ };
528
+ const out: Change[] = [];
529
+ for (const e of plan.entries) {
530
+ if (e.t === 'newgroup') out.push({ icon: 'folder', parts: ['', e.label, ` is a new group${e.empty ? ', still empty' : ''}`] });
531
+ else if (e.t === 'rename') out.push({ icon: 'pencil', parts: ['', e.from, ' is renamed ', e.to, ''] });
532
+ else if (e.t === 'groupmove') out.push({ icon: 'arrow-r', parts: e.dest ? ['', e.label, ' moves into ', e.dest, ''] : ['', e.label, ' moves to the top level'] });
533
+ else if (e.t === 'move') {
534
+ const x = n.get(e.id)!;
535
+ const tail = `${withWhat(x.node)}${e.vis === 'hidden' ? ', and will be hidden' : e.vis === 'shown' ? ', and will be shown' : ''}`;
536
+ out.push({ icon: 'arrow-r', parts: x.parent ? ['', e.label, ' moves into ', nameOf(x.parent), tail] : ['', e.label, ` moves to the top level${tail}`] });
537
+ } else if (e.t === 'gone') out.push({ icon: 'folder', muted: true, parts: ['', e.label, ' goes: no pages left in it'] });
538
+ else if (e.t === 'joined') out.push({ icon: 'folder', parts: e.how === 'drop' ? ['', e.into, ' becomes ', e.label, '’s own page'] : ['', e.label, ' joins ', e.into, ': its pages go under it'] });
539
+ }
540
+ for (const p of plan.reorders) out.push({ icon: 'list', parts: p ? ['The order changes in ', nameOf(p), ''] : ['The order changes at the top level'] });
541
+ return out;
542
+ }
543
+
544
+ /** "at the top level and in About": where the order changes. */
545
+ export function whereOrder(plan: Plan): string {
546
+ return andList(plan.reorders.map((p) => (p ? `in ${plan.now.get(p)?.node.label ?? ''}` : 'at the top level')));
547
+ }
548
+
549
+ // ── Re-planning on a fresh listing (Decision 10) ───────────────────────────────
550
+
551
+ export interface Rebased {
552
+ orig: ArrangeNode[];
553
+ tree: ArrangeNode[];
554
+ /** What changed underneath, one sentence each: "Kiln & Co. was added under Services since you opened this." */
555
+ notes: string[];
556
+ /** Rows added since, marked in the review. */
557
+ added: string[];
558
+ /** Held documents added since (their paths), marked in the open disclosure. */
559
+ addedDocs: string[];
560
+ /** Two rows that would land at one address: the arrangement can't be applied as it is. */
561
+ clashes: string[];
562
+ }
563
+
564
+ /**
565
+ * The same arrangement on a fresh listing: rows that went are taken out (what the person
566
+ * moved under them stays, in their place), rows added since go where the listing has them
567
+ * (under the same row as on the site, which may since have moved or been renamed on the
568
+ * screen), names, hidden marks and held documents are the listing's.
569
+ */
570
+ export function rebase(site: ArrangeSite, oldOrig: readonly ArrangeNode[], tree: readonly ArrangeNode[], documents: readonly ListedDocument[]): Rebased {
571
+ const orig = buildTree(site, documents);
572
+ const was = indexTree(oldOrig);
573
+ const fresh = indexTree(orig);
574
+ const next = clone(tree) as ArrangeNode[];
575
+ const notes: string[] = [];
576
+ const added: string[] = [];
577
+ const addedDocs: string[] = [];
578
+ const addr = (kind: ArrangeNode['kind'], path: string) => addressAt(site, kind, path);
579
+
580
+ // Gone since: taken out, keeping in their place whatever the person put under them.
581
+ for (const [id, y] of was) {
582
+ if (fresh.has(id)) continue;
583
+ const at = locate(next, id);
584
+ if (!at) continue;
585
+ if (y.node.kind === 'page') notes.push(`${y.node.label} was deleted since you opened this.`);
586
+ const keep = at.node.kids.filter((k) => !(was.has(k.id) && !fresh.has(k.id)));
587
+ at.list.splice(at.i, 1, ...keep);
588
+ }
589
+ // Still there: the listing's name, hidden mark, kind and held documents.
590
+ for (const [id, f] of fresh) {
591
+ const y = was.get(id);
592
+ const at = locate(next, id);
593
+ if (!y || !at) continue;
594
+ const node = at.node;
595
+ if (y.node.kind !== f.node.kind) {
596
+ if (f.node.kind === 'page') {
597
+ notes.push(`${f.node.label} was made at ${addr('page', f.path)} since you opened this.`);
598
+ added.push(id);
599
+ } else notes.push(`${y.node.label} was deleted since you opened this; its pages stay, as a group.`);
600
+ }
601
+ node.kind = f.node.kind;
602
+ if (f.node.kind === 'page' || !node.label) node.label = f.node.label;
603
+ node.hidden = f.node.hidden;
604
+ const before = y.node.holds?.docs ?? [];
605
+ const after = f.node.holds?.docs ?? [];
606
+ node.holds = f.node.holds ? clone(f.node.holds) : null;
607
+ if (f.node.holds && y.node.holds) {
608
+ const newOnes = after.filter((d) => !before.some((b) => b.path === d.path));
609
+ const lost = before.filter((b) => !after.some((d) => d.path === b.path)).length;
610
+ const h = f.node.holds;
611
+ if (newOnes.length) {
612
+ addedDocs.push(...newOnes.map((d) => d.path));
613
+ notes.push(`${f.node.label} has ${newOnes.length === 1 ? `a new ${h.one}` : `${newOnes.length} new ${h.many}`} since you opened this, so ${plural(after.length, h.one, h.many)} move with it.`);
614
+ }
615
+ if (lost) notes.push(`${plural(lost, h.one, h.many)} under ${f.node.label} ${lost === 1 ? 'was' : 'were'} deleted since you opened this.`);
616
+ }
617
+ }
618
+ // Added since: under the same row as on the site, at the end.
619
+ for (const [id, f] of fresh) {
620
+ if (was.has(id) || locate(next, id)) continue;
621
+ const pp = parentOf(f.path);
622
+ const nowIndex = indexTree(next);
623
+ let list: ArrangeNode[] = next;
624
+ let under = '';
625
+ if (pp) {
626
+ const holder = locate(next, pp)?.node ?? [...nowIndex.values()].find((x) => x.path === pp)?.node;
627
+ if (holder) {
628
+ list = holder.kids;
629
+ under = holder.label;
630
+ }
631
+ }
632
+ if (f.node.kind === 'group') {
633
+ // A folder made by a page added in it: one already on the screen at that address takes its pages.
634
+ const there = [...nowIndex.values()].find((x) => x.path === f.path);
635
+ if (there) continue;
636
+ list.push({ ...clone(f.node), kids: [] });
637
+ continue;
638
+ }
639
+ list.push({ ...clone(f.node), kids: [] });
640
+ added.push(id);
641
+ notes.push(`${f.node.label} was added ${under ? `under ${under}` : 'at the top level'} since you opened this.`);
642
+ }
643
+ // Two rows at one address: the person has to arrange it again.
644
+ const clashes: string[] = [];
645
+ const scan = (nodes: readonly ArrangeNode[], pp: string) => {
646
+ const seen = new Map<string, ArrangeNode>();
647
+ for (const k of nodes) {
648
+ const other = seen.get(k.slug);
649
+ if (other) {
650
+ const [a, b] = added.includes(k.id) ? [k, other] : [other, k];
651
+ const path = pp ? `${pp}/${k.slug}` : k.slug;
652
+ clashes.push(`${a.label} ${added.includes(a.id) ? 'was added at' : 'is at'} ${addr(a.kind, path)}, where ${b.label} would go. Go back and move one of them.`);
653
+ } else seen.set(k.slug, k);
654
+ scan(k.kids, pp ? `${pp}/${k.slug}` : k.slug);
655
+ }
656
+ };
657
+ scan(next, '');
658
+ return { orig, tree: next, notes, added, addedDocs, clashes };
659
+ }