caracms 0.3.4 → 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.
- package/README.md +73 -3
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +103 -12
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +6 -2
- package/src/content.ts +188 -27
- package/src/document.ts +13 -2
- package/src/editor/README.md +3 -0
- package/src/editor/addresses.ts +6 -1
- package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
- package/src/editor/client/arrange/model.ts +659 -0
- package/src/editor/client/arrange/store.ts +335 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +75 -19
- package/src/editor/client/documents/dialogs.tsx +137 -18
- package/src/editor/client/editor.ts +78 -6
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/Pane.tsx +3 -0
- package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/client/shell/Stage.tsx +8 -4
- package/src/editor/routes/admin.astro +1 -1
- package/src/editor/routes/arrange.ts +17 -0
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +24 -4
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +55 -10
- package/src/editor/server/move.ts +300 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/arrange.css +152 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +83 -7
- package/src/held.ts +116 -0
- package/src/index.ts +6 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +170 -77
- package/src/live/editor.ts +26 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/page-order.ts +253 -0
- package/src/redirects.ts +64 -1
- package/src/relink.ts +40 -9
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +18 -1
- package/src/slug.ts +15 -4
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrange pages (notes/2026-10-25-arrange-pages.md): the screen's state and actions, one
|
|
3
|
+
* per editor (`arrangeFor(editor)`), read by the stage (ArrangeStage.tsx) and the pane's
|
|
4
|
+
* level (pane/levels/ArrangeLevel.tsx). Nothing is saved until Apply (Decision 15): the
|
|
5
|
+
* state starts again from the listing each time the screen opens, and a reload does too.
|
|
6
|
+
*
|
|
7
|
+
* arrange.state { mode, orig, tree, drag, naming, renaming, collapsed, … }
|
|
8
|
+
* arrange.plan() what it means (model.ts → makePlan), worked out once per tree
|
|
9
|
+
* arrange.drop(id, t) / newGroup() / rename… / remove(id)
|
|
10
|
+
* arrange.review() / back() / apply()
|
|
11
|
+
* arrange.leave(go?) asks first when there are changes (the leave dialog), then goes
|
|
12
|
+
*
|
|
13
|
+
* Apply (Decision 10): this tab's unsent edits to what moves go first (as Change address…),
|
|
14
|
+
* then one POST of the plan's request. A refusal re-reads the listing, re-plans the same
|
|
15
|
+
* arrangement on it and shows the review again, naming what changed; a lock refusal names
|
|
16
|
+
* the person and the document.
|
|
17
|
+
*/
|
|
18
|
+
import { isOrderedCollection, listingStamp } from '../../../page-order.ts';
|
|
19
|
+
import { SETTINGS_PATH } from '../../../reserved.ts';
|
|
20
|
+
import type { Editor } from '../editor.ts';
|
|
21
|
+
import { closeDialog, openDialog } from '../documents/dialogs.tsx';
|
|
22
|
+
import { createStore, type Store } from '../state/store.ts';
|
|
23
|
+
import { docId, ENDPOINTS, TAB_HEADER, type ArrangeRequest, type DocRef, type LockRefusal, type Person } from '../../types.ts';
|
|
24
|
+
import { addGroup, buildTree, dropNode, indexTree, locate, makePlan, nameCheck, rebase, removeNode, renameGroup, type ArrangeNode, type ArrangeSite, type DropTarget, type Plan } from './model.ts';
|
|
25
|
+
|
|
26
|
+
export type ArrangeNote =
|
|
27
|
+
| { kind: 'lock'; person: Person | null; error: string }
|
|
28
|
+
| { kind: 'underneath'; lines: string[]; clashes: string[] }
|
|
29
|
+
| { kind: 'refused'; error: string }
|
|
30
|
+
| { kind: 'failed'; error: string };
|
|
31
|
+
|
|
32
|
+
export interface ArrangeState {
|
|
33
|
+
/** Arranging, the review, or the review while Apply runs. */
|
|
34
|
+
mode: 'arrange' | 'review' | 'applying';
|
|
35
|
+
/** The tree as the listing had it when the screen opened (or at the last re-plan), and as arranged. */
|
|
36
|
+
orig: ArrangeNode[];
|
|
37
|
+
tree: ArrangeNode[];
|
|
38
|
+
/** Mid-drag: the row and where it would land (null: nowhere). */
|
|
39
|
+
drag: { id: string; target: DropTarget | null } | null;
|
|
40
|
+
/** New group, being named; Rename… on a group, inline. */
|
|
41
|
+
naming: { value: string } | null;
|
|
42
|
+
renaming: { id: string; value: string } | null;
|
|
43
|
+
/** Rows whose rows under them are hidden (the screen's twisties). */
|
|
44
|
+
collapsed: string[];
|
|
45
|
+
/** Holders whose documents the review shows one by one. */
|
|
46
|
+
openHeld: string[];
|
|
47
|
+
/** "Leave redirects from the old addresses" (Decision 3). */
|
|
48
|
+
tick: boolean;
|
|
49
|
+
note: ArrangeNote | null;
|
|
50
|
+
/** After a re-plan: rows and held documents added since the screen opened. */
|
|
51
|
+
added: string[];
|
|
52
|
+
addedDocs: string[];
|
|
53
|
+
/** New groups made so far (their ids). */
|
|
54
|
+
made: number;
|
|
55
|
+
/** The row an entry of the review points at while hovered (washed on the screen). */
|
|
56
|
+
linked: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Arrange {
|
|
60
|
+
state: Store<ArrangeState>;
|
|
61
|
+
site: ArrangeSite;
|
|
62
|
+
plan(): Plan;
|
|
63
|
+
/** Why Review… is off, as text and bold parts in turn; null when it's on. */
|
|
64
|
+
blocked(): string[] | null;
|
|
65
|
+
reset(): void;
|
|
66
|
+
setDrag(drag: ArrangeState['drag']): void;
|
|
67
|
+
drop(id: string, target: DropTarget): void;
|
|
68
|
+
newGroup(): void;
|
|
69
|
+
setNaming(value: string | null): void;
|
|
70
|
+
confirmNaming(): void;
|
|
71
|
+
startRename(id: string): void;
|
|
72
|
+
setRenaming(value: string | null): void;
|
|
73
|
+
confirmRename(): void;
|
|
74
|
+
remove(id: string): void;
|
|
75
|
+
toggleCollapsed(id: string): void;
|
|
76
|
+
toggleHeld(id: string): void;
|
|
77
|
+
setLinked(id: string | null): void;
|
|
78
|
+
setTick(tick: boolean): void;
|
|
79
|
+
review(): void;
|
|
80
|
+
back(): void;
|
|
81
|
+
apply(): Promise<void>;
|
|
82
|
+
/** Leave the screen (Cancel, Back, the trail): asks first when anything is arranged. */
|
|
83
|
+
leave(go?: () => void): void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const all = new WeakMap<Editor, Arrange>();
|
|
87
|
+
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x)) as T;
|
|
88
|
+
|
|
89
|
+
/** The collection the screen arranges (`pages`): the one the saved order is for, by the server's rule (page-order.ts). */
|
|
90
|
+
export function arrangeSite(editor: Pick<Editor, 'site'>): ArrangeSite {
|
|
91
|
+
const pages = editor.site.collections.find(isOrderedCollection);
|
|
92
|
+
return {
|
|
93
|
+
collection: pages?.name ?? 'pages',
|
|
94
|
+
base: pages?.urlBase ?? '/',
|
|
95
|
+
home: editor.site.home.collection === pages?.name ? editor.site.home.path : 'index',
|
|
96
|
+
collections: editor.site.collections,
|
|
97
|
+
codeRoutes: editor.site.codeRoutes,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function arrangeFor(editor: Editor): Arrange {
|
|
102
|
+
const had = all.get(editor);
|
|
103
|
+
if (had) return had;
|
|
104
|
+
const site = arrangeSite(editor);
|
|
105
|
+
const start = (): ArrangeState => {
|
|
106
|
+
const orig = buildTree(site, editor.documents.get());
|
|
107
|
+
return { mode: 'arrange', orig, tree: clone(orig), drag: null, naming: null, renaming: null, collapsed: [], openHeld: [], tick: true, note: null, added: [], addedDocs: [], made: 0, linked: null };
|
|
108
|
+
};
|
|
109
|
+
const state = createStore<ArrangeState>(start());
|
|
110
|
+
let cached: { orig: ArrangeNode[]; tree: ArrangeNode[]; waiting: unknown; plan: Plan } | null = null;
|
|
111
|
+
const plan = () => {
|
|
112
|
+
const { orig, tree } = state.get();
|
|
113
|
+
const waiting = editor.liveState.get().status?.waiting;
|
|
114
|
+
if (cached && cached.orig === orig && cached.tree === tree && cached.waiting === waiting) return cached.plan;
|
|
115
|
+
// Made since the last Publish, or moved since and already redirected: no live address there to leave a redirect from.
|
|
116
|
+
const pending = (collection: string, path: string) => (waiting ?? []).some((w) => w.collection === collection && w.path === path && (w.change === 'new' || !!w.movedFrom));
|
|
117
|
+
cached = { orig, tree, waiting, plan: makePlan(site, orig, tree, { pending }) };
|
|
118
|
+
return cached.plan;
|
|
119
|
+
};
|
|
120
|
+
/** A change to the tree, on a copy (the plan is worked out again from it). */
|
|
121
|
+
const change = (edit: (tree: ArrangeNode[]) => void, patch: Partial<ArrangeState> = {}) => {
|
|
122
|
+
const tree = clone(state.get().tree);
|
|
123
|
+
edit(tree);
|
|
124
|
+
state.update({ tree, ...patch });
|
|
125
|
+
};
|
|
126
|
+
const parentOf = (id: string) => locate(state.get().tree, id)?.parentId ?? null;
|
|
127
|
+
const goToDocuments = () => {
|
|
128
|
+
void editor.openDocument(editor.doc.get().ref, { history: 'push' }).then(() => editor.goTo('documents'));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const arrange: Arrange = {
|
|
132
|
+
state,
|
|
133
|
+
site,
|
|
134
|
+
plan,
|
|
135
|
+
blocked() {
|
|
136
|
+
const s = state.get();
|
|
137
|
+
if (s.naming) return ['Finish naming the new group first.'];
|
|
138
|
+
if (s.renaming) return [`Finish renaming ${locate(s.tree, s.renaming.id)?.node.label ?? 'the group'} first.`];
|
|
139
|
+
const p = plan();
|
|
140
|
+
if (p.emptyFresh.length) return ['', p.emptyFresh[0]!.label, ' is empty: add a page to it or remove it.'];
|
|
141
|
+
if (!p.any) return ['Nothing to review yet.'];
|
|
142
|
+
return null;
|
|
143
|
+
},
|
|
144
|
+
reset() {
|
|
145
|
+
cached = null;
|
|
146
|
+
state.set(start());
|
|
147
|
+
},
|
|
148
|
+
setDrag(drag) {
|
|
149
|
+
state.update({ drag });
|
|
150
|
+
},
|
|
151
|
+
drop(id, target) {
|
|
152
|
+
if (target.kind === 'refused') return state.update({ drag: null });
|
|
153
|
+
change((tree) => dropNode(tree, id, target), { drag: null });
|
|
154
|
+
},
|
|
155
|
+
newGroup() {
|
|
156
|
+
state.update({ naming: { value: '' }, renaming: null });
|
|
157
|
+
},
|
|
158
|
+
setNaming(value) {
|
|
159
|
+
state.update({ naming: value === null ? null : { value } });
|
|
160
|
+
},
|
|
161
|
+
confirmNaming() {
|
|
162
|
+
const s = state.get();
|
|
163
|
+
if (!s.naming) return;
|
|
164
|
+
const c = nameCheck(site, s.tree, s.naming.value, null, null);
|
|
165
|
+
if (c.bad) return;
|
|
166
|
+
// A group named like a page at the top level is that page (Decision 8): nothing to add.
|
|
167
|
+
if (c.merge) return state.update({ naming: null });
|
|
168
|
+
change((tree) => addGroup(tree, s.naming!.value, s.made + 1), { naming: null, made: s.made + 1 });
|
|
169
|
+
},
|
|
170
|
+
startRename(id) {
|
|
171
|
+
const node = locate(state.get().tree, id)?.node;
|
|
172
|
+
if (node) state.update({ renaming: { id, value: node.label }, naming: null });
|
|
173
|
+
},
|
|
174
|
+
setRenaming(value) {
|
|
175
|
+
const r = state.get().renaming;
|
|
176
|
+
state.update({ renaming: value === null || !r ? null : { id: r.id, value } });
|
|
177
|
+
},
|
|
178
|
+
confirmRename() {
|
|
179
|
+
const s = state.get();
|
|
180
|
+
if (!s.renaming) return;
|
|
181
|
+
const { id, value } = s.renaming;
|
|
182
|
+
const c = nameCheck(site, s.tree, value, parentOf(id), id);
|
|
183
|
+
if (c.bad) return;
|
|
184
|
+
change((tree) => renameGroup(tree, id, value, c.merge ?? null), { renaming: null });
|
|
185
|
+
},
|
|
186
|
+
remove(id) {
|
|
187
|
+
change((tree) => removeNode(tree, id));
|
|
188
|
+
},
|
|
189
|
+
toggleCollapsed(id) {
|
|
190
|
+
const c = state.get().collapsed;
|
|
191
|
+
state.update({ collapsed: c.includes(id) ? c.filter((x) => x !== id) : [...c, id] });
|
|
192
|
+
},
|
|
193
|
+
toggleHeld(id) {
|
|
194
|
+
const o = state.get().openHeld;
|
|
195
|
+
state.update({ openHeld: o.includes(id) ? o.filter((x) => x !== id) : [...o, id] });
|
|
196
|
+
},
|
|
197
|
+
setLinked(linked) {
|
|
198
|
+
state.update({ linked });
|
|
199
|
+
},
|
|
200
|
+
setTick(tick) {
|
|
201
|
+
state.update({ tick });
|
|
202
|
+
},
|
|
203
|
+
review() {
|
|
204
|
+
if (arrange.blocked()) return;
|
|
205
|
+
state.update({ mode: 'review', note: null, drag: null });
|
|
206
|
+
},
|
|
207
|
+
back() {
|
|
208
|
+
if (state.get().mode === 'applying') return;
|
|
209
|
+
state.update({ mode: 'arrange', note: null, added: [], addedDocs: [] });
|
|
210
|
+
},
|
|
211
|
+
async apply() {
|
|
212
|
+
const s = state.get();
|
|
213
|
+
const p = plan();
|
|
214
|
+
if (s.mode !== 'review' || (s.note?.kind === 'underneath' && s.note.clashes.length)) return;
|
|
215
|
+
state.update({ mode: 'applying', note: null });
|
|
216
|
+
const refused = (note: ArrangeNote) => state.update({ mode: 'review', note });
|
|
217
|
+
// This tab's unsent edits to what moves (and to Site Settings, which the order rides) go first.
|
|
218
|
+
const refs: DocRef[] = [...movingRefs(site, p), { collection: 'settings', path: SETTINGS_PATH }];
|
|
219
|
+
for (const r of refs) {
|
|
220
|
+
const id = docId(r);
|
|
221
|
+
let flushed = true;
|
|
222
|
+
await editor.keeper?.flush(id).catch(() => (flushed = false));
|
|
223
|
+
if (!flushed || editor.keeper?.dirty(id)) {
|
|
224
|
+
const what = editor.documents.get().find((d) => d.collection === r.collection && d.path === r.path)?.label ?? r.path;
|
|
225
|
+
return refused({ kind: 'refused', error: `${what} has changes that haven't been saved yet, so nothing can move. Sort those out first (Use mine or Discard mine, or sign in again), then apply again.` });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const body: ArrangeRequest = { moves: p.moves, order: p.order, expect: expected(site, p), ...(p.live && !s.tick ? { redirect: false } : {}) };
|
|
229
|
+
const answer = await post(editor, body);
|
|
230
|
+
if (!answer.ok) {
|
|
231
|
+
if (answer.code === 'locked') return refused({ kind: 'lock', person: answer.lock?.person ?? null, error: answer.error });
|
|
232
|
+
if (answer.status === 0 || answer.status >= 500 || answer.status === 401) return refused({ kind: 'failed', error: answer.error });
|
|
233
|
+
// Something changed underneath (Decision 10): the same arrangement on the listing as it is now.
|
|
234
|
+
await editor.refreshDocuments().catch(() => undefined);
|
|
235
|
+
const now = state.get();
|
|
236
|
+
const r = rebase(site, now.orig, now.tree, editor.documents.get());
|
|
237
|
+
const holders = [...indexTree(r.tree).values()].filter((x) => x.node.holds?.docs.some((d) => r.addedDocs.includes(d.path))).map((x) => x.node.id);
|
|
238
|
+
state.update({ orig: r.orig, tree: r.tree, added: r.added, addedDocs: r.addedDocs, openHeld: [...new Set([...now.openHeld, ...holders])] });
|
|
239
|
+
return refused(r.notes.length || r.clashes.length ? { kind: 'underneath', lines: r.notes, clashes: r.clashes } : { kind: 'refused', error: answer.error });
|
|
240
|
+
}
|
|
241
|
+
for (const r of refs) editor.keeper?.discard(docId(r));
|
|
242
|
+
await editor.refreshDocuments().catch(() => undefined);
|
|
243
|
+
// The document under the screen goes with its page when it moved.
|
|
244
|
+
const open = editor.doc.get().ref;
|
|
245
|
+
const to = open.collection === site.collection ? newPathOf(p, open.path) : null;
|
|
246
|
+
const total = p.total;
|
|
247
|
+
const later = editor.mode === 'live' ? (total === 1 ? ' It goes live at the next Publish.' : ' They go live at the next Publish.') : '';
|
|
248
|
+
const text = total ? `Applied: ${p.lead} ${total === 1 ? 'has a new address' : 'have new addresses'}.${later}` : 'Applied: the Pages tree’s new order is saved.';
|
|
249
|
+
arrange.reset();
|
|
250
|
+
await editor.openDocument(to ? { collection: open.collection, path: to } : open, { history: 'push' });
|
|
251
|
+
editor.goTo('documents');
|
|
252
|
+
editor.toast(text, 'check');
|
|
253
|
+
editor.announce(text);
|
|
254
|
+
},
|
|
255
|
+
leave(go = goToDocuments) {
|
|
256
|
+
if (state.get().mode === 'applying') return;
|
|
257
|
+
const proceed = () => {
|
|
258
|
+
arrange.reset();
|
|
259
|
+
go();
|
|
260
|
+
};
|
|
261
|
+
if (!plan().any) return proceed();
|
|
262
|
+
openDialog(editor, {
|
|
263
|
+
kind: 'arrange-leave',
|
|
264
|
+
go: () => {
|
|
265
|
+
closeDialog(editor);
|
|
266
|
+
proceed();
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
editor.arrangeGuard = {
|
|
272
|
+
ask(go) {
|
|
273
|
+
if (editor.view.get().level !== 'arrange' || !plan().any) {
|
|
274
|
+
if (editor.view.get().level === 'arrange') arrange.reset();
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
arrange.leave(go);
|
|
278
|
+
return true;
|
|
279
|
+
},
|
|
280
|
+
up() {
|
|
281
|
+
const s = state.get();
|
|
282
|
+
if (s.naming || s.renaming) return;
|
|
283
|
+
if (s.mode === 'review') arrange.back();
|
|
284
|
+
else if (s.mode === 'arrange') arrange.leave();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
// Each time the screen opens, it starts from the listing as it is (Decision 15).
|
|
288
|
+
editor.view.subscribe((next, previous) => {
|
|
289
|
+
if (next.level === 'arrange' && previous.level !== 'arrange') arrange.reset();
|
|
290
|
+
});
|
|
291
|
+
all.set(editor, arrange);
|
|
292
|
+
return arrange;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Every document whose address the plan changes, by its path now: the pages, and the held documents that go with them. */
|
|
296
|
+
function movingRefs(site: ArrangeSite, p: Plan): DocRef[] {
|
|
297
|
+
const out: DocRef[] = [];
|
|
298
|
+
for (const id of p.moved) out.push({ collection: site.collection, path: p.orig.get(id)!.path });
|
|
299
|
+
for (const h of p.heldMoved) for (const d of h.docs) out.push({ collection: h.collection, path: d.path });
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Decision 10: the listing the review was planned on, as the server checks it at Apply
|
|
305
|
+
* (`ArrangeRequest.expect`): every page's path, and each moving holder's documents'.
|
|
306
|
+
*/
|
|
307
|
+
function expected(site: ArrangeSite, p: Plan): Record<string, string> {
|
|
308
|
+
const pages = [...p.orig.values()].filter((x) => x.node.kind === 'page').map((x) => x.path);
|
|
309
|
+
const out: Record<string, string> = { [site.collection]: listingStamp(pages) };
|
|
310
|
+
for (const h of p.heldMoved) out[h.collection] = listingStamp(h.docs.map((d) => d.path));
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Where a page of the arranged collection is after the plan (its path now, if it moves). */
|
|
315
|
+
function newPathOf(p: Plan, path: string): string | null {
|
|
316
|
+
for (const [id, y] of p.orig) if (y.path === path && p.now.has(id) && p.now.get(id)!.path !== path) return p.now.get(id)!.path;
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
type Answer = { ok: true } | { ok: false; status: number; error: string; code: string | null; lock: LockRefusal['lock'] | null };
|
|
321
|
+
|
|
322
|
+
async function post(editor: Editor, body: ArrangeRequest): Promise<Answer> {
|
|
323
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
324
|
+
if (editor.liveSession) headers[TAB_HEADER] = editor.liveSession.tab;
|
|
325
|
+
let response: Response;
|
|
326
|
+
try {
|
|
327
|
+
response = await fetch(ENDPOINTS.arrange, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
328
|
+
} catch {
|
|
329
|
+
return { ok: false, status: 0, error: "The server can't be reached. Nothing was applied; try again in a moment.", code: 'offline', lock: null };
|
|
330
|
+
}
|
|
331
|
+
const said = (await response.json().catch(() => ({}))) as Partial<LockRefusal> & { error?: string; code?: string };
|
|
332
|
+
if (response.status === 401 && editor.liveSession) editor.liveState.update({ signedOut: said as never });
|
|
333
|
+
if (!response.ok) return { ok: false, status: response.status, error: said.error ?? `The server said no (${response.status}).`, code: said.code ?? null, lock: said.lock ?? null };
|
|
334
|
+
return { ok: true };
|
|
335
|
+
}
|
|
@@ -28,7 +28,7 @@ import { useEditor, useStore } from '../context.tsx';
|
|
|
28
28
|
import type { Editor } from '../editor.ts';
|
|
29
29
|
import { createStore, type Store } from '../state/store.ts';
|
|
30
30
|
import { Icon } from '../shell/Icons.tsx';
|
|
31
|
-
import { docIcon } from '../nav/DocRow.tsx';
|
|
31
|
+
import { docIcon, hiddenTitle } from '../nav/DocRow.tsx';
|
|
32
32
|
import { DocMarksView } from '../live/marks.tsx';
|
|
33
33
|
import { when } from '../live/format.ts';
|
|
34
34
|
import { openDialog } from '../documents/dialogs.tsx';
|
|
@@ -378,6 +378,14 @@ function Row(props: { doc: ListedDocument; columns: readonly TableColumn[]; curr
|
|
|
378
378
|
<span className="sr">, can't be opened: {doc.warning.message}</span>
|
|
379
379
|
</span>
|
|
380
380
|
) : null}
|
|
381
|
+
{/* M9 Decision 7, collections under pages (Decision 11): hidden, or hidden with the page that holds it, named. */}
|
|
382
|
+
{/* The sidebar's mark: the word, small and grey; the page that hides it on hover and to a screen reader. */}
|
|
383
|
+
{doc.hidden ? (
|
|
384
|
+
<span className="hmark" data-mark="hidden" title={hiddenTitle(doc, editor.documents.get())}>
|
|
385
|
+
Hidden
|
|
386
|
+
{doc.hiddenBy ? <span className="sr">: {hiddenTitle(doc, editor.documents.get())}</span> : null}
|
|
387
|
+
</span>
|
|
388
|
+
) : null}
|
|
381
389
|
{editor.mode === 'live' ? <DocMarksView file={doc.file} /> : <span data-slot="presence" hidden />}
|
|
382
390
|
</span>
|
|
383
391
|
{props.extra ? <span className="ct-extra">{props.extra}</span> : null}
|
|
@@ -70,31 +70,76 @@ export async function deleteDocument(editor: Editor, ref: DocRef, title: string)
|
|
|
70
70
|
/**
|
|
71
71
|
* Change a document's address (pre-M7 item 8). Its unsent edits go first (the move
|
|
72
72
|
* carries them), then the document opens at its new address in place of the old one,
|
|
73
|
-
* and the list is read again (other documents' links changed too).
|
|
73
|
+
* and the list is read again (other documents' links changed too). `redirect` false: no
|
|
74
|
+
* redirect from the old address.
|
|
74
75
|
*/
|
|
75
|
-
export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string): Promise<{ ok: true; body: MoveResponse } | Refused> {
|
|
76
|
-
|
|
77
|
-
const
|
|
76
|
+
export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string, redirect = true): Promise<{ ok: true; body: MoveResponse } | Refused> {
|
|
77
|
+
// Everything under it moves with it (collections under pages, Decision 9): each of those documents' unsent edits too.
|
|
78
|
+
const moving = addressesMoving(editor.site?.collections ?? [], ref, to, editor.documents?.get() ?? []).map((m) => ({ collection: m.doc.collection, path: m.doc.path }));
|
|
79
|
+
const refs = moving.some((m) => sameDoc(m, ref)) ? moving : [ref, ...moving];
|
|
78
80
|
// Nothing moves while the keeper still holds edits the server hasn't got (a held conflict, a
|
|
79
81
|
// signed-out session, a refused write): the move would discard them.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
flushed =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
for (const r of refs) {
|
|
83
|
+
const id = docId(r);
|
|
84
|
+
let flushed = true;
|
|
85
|
+
await editor.keeper?.flush(id).catch(() => {
|
|
86
|
+
flushed = false;
|
|
87
|
+
});
|
|
88
|
+
if (!flushed || editor.keeper?.dirty(id)) {
|
|
89
|
+
const what = sameDoc(r, ref) ? title : (editor.documents?.get().find((d) => sameDoc(d, r))?.label ?? r.path);
|
|
90
|
+
return { ok: false, status: 0, error: `${what} has changes that haven't been saved yet, so ${sameDoc(r, ref) ? 'its' : `${title}'s`} address can't change. Sort those out first (Use mine or Discard mine, or sign in again), then try again.`, code: 'unsent', usedOn: null };
|
|
91
|
+
}
|
|
86
92
|
}
|
|
87
|
-
const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to });
|
|
93
|
+
const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to, ...(redirect ? {} : { redirect: false }) });
|
|
88
94
|
if (!answer.ok) return answer;
|
|
89
|
-
editor.keeper?.discard(id);
|
|
90
|
-
await editor.refreshDocuments().catch(() => undefined);
|
|
91
|
-
if (open) await editor.openDocument({ collection: answer.body.collection, path: answer.body.path }, { history: 'replace' });
|
|
92
95
|
const { url, from } = answer.body;
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
const moved = answer.body.moved ?? [];
|
|
97
|
+
for (const r of refs) editor.keeper?.discard(docId(r));
|
|
98
|
+
await editor.refreshDocuments().catch(() => undefined);
|
|
99
|
+
// The open document goes with it when it's under it.
|
|
100
|
+
const openRef = editor.doc.get().ref;
|
|
101
|
+
const opened = sameDoc(openRef, ref) ? { collection: answer.body.collection, from: ref.path, to: answer.body.path } : moved.find((m) => m.collection === openRef.collection && m.from === openRef.path);
|
|
102
|
+
if (opened && opened.from !== opened.to) await editor.openDocument({ collection: opened.collection, path: opened.to }, { history: 'replace' });
|
|
103
|
+
else if (opened) await editor.reloadOpen();
|
|
104
|
+
const more = Math.max(moved.length - 1, 0);
|
|
105
|
+
editor.toast(more ? `Moved ${title} to ${url}, with ${more} more under it.` : `Moved ${title} to ${url}.`, 'check');
|
|
106
|
+
editor.announce(more ? `Moved ${title} from ${from.url} to ${url}, and ${more} more addresses under it.` : `Moved ${title} from ${from.url} to ${url}.`);
|
|
95
107
|
return answer;
|
|
96
108
|
}
|
|
97
109
|
|
|
110
|
+
/** One address Change address… changes (collections under pages, Decision 9). */
|
|
111
|
+
export interface AddressChange {
|
|
112
|
+
doc: ListedDocument;
|
|
113
|
+
/** Its new address; null while the new path isn't one yet. */
|
|
114
|
+
toUrl: string | null;
|
|
115
|
+
/** A document of a collection a page in the move holds (its file stays; only its address changes). */
|
|
116
|
+
held: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every address Change address… on `ref` changes once it's at `to` (Decision 9): the
|
|
121
|
+
* document, the pages under it at any depth, and the documents of each collection held by
|
|
122
|
+
* it or a page under it. move.ts → `movesUnder` and `planMoves` work out the same, so the
|
|
123
|
+
* dialog lists them before asking.
|
|
124
|
+
*/
|
|
125
|
+
export function addressesMoving(collections: readonly EditorAnyCollection[], ref: DocRef, to: string, documents: readonly ListedDocument[]): AddressChange[] {
|
|
126
|
+
const collection = collections.find((c) => c.name === ref.collection);
|
|
127
|
+
const root = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
|
|
128
|
+
if (!collection || !root) return [];
|
|
129
|
+
const newPath = (path: string) => (path === ref.path ? to : `${to}${path.slice(ref.path.length)}`);
|
|
130
|
+
const pages = [root, ...(collection.nested ? pagesUnder(collection.name, ref.path, documents) : [])];
|
|
131
|
+
const out: AddressChange[] = pages.map((d) => ({ doc: d, toUrl: addressFor(collection, newPath(d.path)), held: false }));
|
|
132
|
+
for (const held of collections) {
|
|
133
|
+
const holder = held.holder;
|
|
134
|
+
if (!held.heldBy || !holder || holder.collection !== ref.collection) continue;
|
|
135
|
+
if (holder.path !== ref.path && !holder.path.startsWith(`${ref.path}/`)) continue;
|
|
136
|
+
const holderUrl = addressFor(collection, newPath(holder.path));
|
|
137
|
+
const base = holderUrl === null ? null : `${holderUrl.replace(/\/+$/, '')}/`;
|
|
138
|
+
for (const d of documents) if (d.collection === held.name && d.url !== null && !d.warning) out.push({ doc: d, toUrl: base === null ? null : base + d.path, held: true });
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
/**
|
|
99
144
|
* M6c G4 (Decision 9): Replace… on a file. Every unsent edit in this tab goes first, and
|
|
100
145
|
* nothing is replaced while any is still held (a conflict, a refusal, a signed-out
|
|
@@ -167,8 +212,8 @@ export interface InsideOption {
|
|
|
167
212
|
* New page's "Inside" (Decision 11): the top level, then every group and every page, at
|
|
168
213
|
* any depth, in tree order. A page with nothing under it yet is offered too, so a page can
|
|
169
214
|
* go under it (Urban Return, 2026-10-24); Home isn't, since no address goes under `/index/`.
|
|
170
|
-
* At each level,
|
|
171
|
-
*
|
|
215
|
+
* At each level, the Pages tree's order (arrange pages, Decision 19): `documents` is the
|
|
216
|
+
* listing, already in that order (Home, the saved order, then what holds pages, then A–Z).
|
|
172
217
|
*/
|
|
173
218
|
export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'urlBase'>, documents: readonly ListedDocument[]): InsideOption[] {
|
|
174
219
|
const base = collection.urlBase ?? '/';
|
|
@@ -182,11 +227,22 @@ export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'ur
|
|
|
182
227
|
const labelOf = (path: string) => docs.find((d) => d.path === path)?.label ?? titleCase(path.split('/').pop()!);
|
|
183
228
|
const holds = (path: string) => docs.some((d) => d.path.startsWith(`${path}/`));
|
|
184
229
|
const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
|
|
230
|
+
// Arrange pages (Decision 19): the tree's order, as the listing gives it (content.ts → pageTree,
|
|
231
|
+
// page-order.ts's rule): a folder sits where its page, or its first page down, is listed.
|
|
232
|
+
const first = new Map<string, number>();
|
|
233
|
+
docs.forEach((d, i) => {
|
|
234
|
+
const parts = d.path.split('/');
|
|
235
|
+
for (let n = 1; n <= parts.length; n++) {
|
|
236
|
+
const at = parts.slice(0, n).join('/');
|
|
237
|
+
if (!first.has(at)) first.set(at, i);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const rank = (path: string) => first.get(path) ?? Number.MAX_SAFE_INTEGER;
|
|
185
241
|
const children = new Map<string, string[]>();
|
|
186
242
|
for (const f of folders) children.set(parentOf(f), [...(children.get(parentOf(f)) ?? []), f]);
|
|
187
243
|
const out: InsideOption[] = [{ path: '', label: 'Top level', name: 'Top level', depth: 0, page: false, holds: true, base }];
|
|
188
244
|
const walk = (parent: string) => {
|
|
189
|
-
const kids = (children.get(parent) ?? []).sort((a, b) =>
|
|
245
|
+
const kids = (children.get(parent) ?? []).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
190
246
|
for (const path of kids) {
|
|
191
247
|
const parts = path.split('/');
|
|
192
248
|
out.push({
|