caracms 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live drafts store (M4 Decision 4), behind M3's `DraftsStore` seam
|
|
3
|
+
* (editor/server/drafts-store.ts).
|
|
4
|
+
*
|
|
5
|
+
* - **`list` and `read`** come from the in-memory `drafts` copy with each document's
|
|
6
|
+
* Redis autosave on top, through content.ts's reader, so a document reads the same
|
|
7
|
+
* in both modes. `read` also says who last changed the document and the version
|
|
8
|
+
* that change left (Decision 11).
|
|
9
|
+
* - **`write`** keeps M3's path rules (write.ts → `confinePath`), with existence checked
|
|
10
|
+
* against the copy, not the disk (which holds `main` from the build). It takes the
|
|
11
|
+
* lock (Decision 9: the first write takes it, atomically; anyone else's is a 409),
|
|
12
|
+
* then stores the whole document with its base as the autosave, with its editor
|
|
13
|
+
* and tab. **That's when the answer says Kept.** The sweep commits it later.
|
|
14
|
+
* - Redis out past its 30-second budget is a 503 (`unavailable`): the keeper holds the
|
|
15
|
+
* edits and shows "can't reach the server".
|
|
16
|
+
* - **Every kind of document** (M5a): the reserved `shared` and `settings` collections
|
|
17
|
+
* go through here like any other (Decision 8). **Site Settings' autosave is ready for
|
|
18
|
+
* the sweep at once** (`Autosave.sweepAtOnce`, Decision 10): no idle wait, so its
|
|
19
|
+
* `editors` and `brand` apply on the sweep's next tick, read from the copy.
|
|
20
|
+
* - `source()`: the copy with every autosave on top, what a preview's render reads
|
|
21
|
+
* other documents from (Decision 9).
|
|
22
|
+
* - **Creating and deleting** (M5a Decisions 11 and 12) go through the same pipeline as
|
|
23
|
+
* typing, with editor/server/create.ts's rules over this store's own listing.
|
|
24
|
+
* **`create`**: the creator's tab takes the lock, and the new document is its
|
|
25
|
+
* autosave (`created: { over }`, no base in `drafts`); from then on the listing, the
|
|
26
|
+
* waiting count and Publish see it. **`delete`**: the lock (someone else's refuses:
|
|
27
|
+
* "Aoife is editing Contact"), then a "gone" autosave (autosaves.ts), and the lock is
|
|
28
|
+
* marked releasing so it drops once the sweep has committed the removal. The copy
|
|
29
|
+
* with autosaves on top leaves a gone document out, so it leaves the listing and
|
|
30
|
+
* can't be opened or written. A document deleted since the last Publish can be made
|
|
31
|
+
* again: the new autosave replaces the pending deletion. `caracms:changed:` records
|
|
32
|
+
* both, a deletion with `gone`, so a viewer's poll can say who deleted it.
|
|
33
|
+
*/
|
|
34
|
+
import { join, relative, sep } from 'node:path';
|
|
35
|
+
import { SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
|
|
36
|
+
import { filesSource, listDocuments, readDocument, resolveDocumentFile } from '../content.ts';
|
|
37
|
+
import { planCreate, planDelete, refused } from '../editor/server/create.ts';
|
|
38
|
+
import { moveChanges, moveResponse, planMove, settingsWithoutRedirectFrom } from '../editor/server/move.ts';
|
|
39
|
+
import { replaceChanges, replaceResponse } from '../editor/server/replace.ts';
|
|
40
|
+
import { parseAnyDocument } from '../document.ts';
|
|
41
|
+
import type { DraftsStore } from '../editor/server/drafts-store.ts';
|
|
42
|
+
import { confinePath, fileVersion, WriteRefused, writeText, type WriteContext, type WriteRequest, type WriteResponse } from '../editor/server/write.ts';
|
|
43
|
+
import type { CreateResponse, DeleteResponse, LastChange, LockRefusal, MoveResponse, ReplaceFileResponse } from '../editor/types.ts';
|
|
44
|
+
import type { Autosave, Autosaves, Read } from './autosaves.ts';
|
|
45
|
+
import { titleOf, type LiveGit } from './git.ts';
|
|
46
|
+
import type { LockRecord, Locks } from './locks.ts';
|
|
47
|
+
import { displayName, type Person } from './messages.ts';
|
|
48
|
+
import { RedisUnavailableError } from './redis.ts';
|
|
49
|
+
import type { Sweep } from './sweep.ts';
|
|
50
|
+
|
|
51
|
+
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
|
|
52
|
+
|
|
53
|
+
/** One autosave a write stored, and the one it replaced (none: there was none), so a move that fails part way can put it back. */
|
|
54
|
+
interface Written {
|
|
55
|
+
prior: Read<Autosave> | null;
|
|
56
|
+
wrote: Read<Autosave>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LiveStoreParts {
|
|
60
|
+
site: Site;
|
|
61
|
+
git: Pick<LiveGit, 'files' | 'read' | 'waiting' | 'changeOf'>;
|
|
62
|
+
autosaves: Autosaves;
|
|
63
|
+
locks: Locks;
|
|
64
|
+
sweep: Pick<Sweep, 'poke' | 'commitFiles'>;
|
|
65
|
+
now: () => number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const posix = (p: string) => p.split(sep).join('/');
|
|
69
|
+
|
|
70
|
+
/** A Redis outage as the write route's answer: 503, so the keeper says "can't reach the server" and holds the edits. */
|
|
71
|
+
export function unavailable(error: unknown): WriteRefused {
|
|
72
|
+
const what = error instanceof Error ? error.message : String(error);
|
|
73
|
+
return new WriteRefused(503, `The server can't reach its store right now, so your changes are held in this browser (${what}).`, { code: 'unavailable' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Who last changed a file (Decision 11): Redis's record, else the newest editor git names
|
|
78
|
+
* (Redis flushed while it waits). **Neither: unknown** (`known: false`). The records never
|
|
79
|
+
* expire, so a file with none was never changed in the editor or Redis lost its data, and
|
|
80
|
+
* the server can't tell which: the keeper holds edits on unknown rather than re-send them.
|
|
81
|
+
*/
|
|
82
|
+
export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string): Promise<LastChange> {
|
|
83
|
+
const record = await parts.autosaves.changed(file);
|
|
84
|
+
if (record) return { by: record.by, version: record.version, at: record.at };
|
|
85
|
+
const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
|
|
86
|
+
return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
90
|
+
const { site, git, autosaves, locks, sweep, now } = parts;
|
|
91
|
+
|
|
92
|
+
/** The copy with these autosaves laid over it. */
|
|
93
|
+
const overlay = (saves: Autosave[]) => {
|
|
94
|
+
const files = new Map(git.files());
|
|
95
|
+
for (const a of saves) {
|
|
96
|
+
if (a.text === null) files.delete(a.file); // deleted (M5a): gone from the listing, can't be opened
|
|
97
|
+
else files.set(a.file, a.text);
|
|
98
|
+
}
|
|
99
|
+
return filesSource(site.root, () => files);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const guard = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
103
|
+
try {
|
|
104
|
+
return await fn();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof RedisUnavailableError) throw unavailable(error);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The copy with every autosave on top, listed (create, delete and move check against it). */
|
|
112
|
+
const listed = async () => listDocuments(site, undefined, overlay((await autosaves.all()).map((r) => r.value)));
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The documents list, each with when it was last edited (`mtimeMs`, the table's "Last
|
|
116
|
+
* edited"): its autosave's time while one waits, else its change record's (Decision 11:
|
|
117
|
+
* every write, create, delete and move sets one, and a change taken in from `main` sets
|
|
118
|
+
* one if there's none), all in one MGET with the autosaves' SCAN + MGET, so the time
|
|
119
|
+
* comes with the list. None of either (never changed in the editor, or Redis lost its
|
|
120
|
+
* data): null, shown as a dash.
|
|
121
|
+
*/
|
|
122
|
+
const list = async () => {
|
|
123
|
+
const saves = (await autosaves.all()).map((r) => r.value);
|
|
124
|
+
const entries = await listDocuments(site, undefined, overlay(saves));
|
|
125
|
+
const pending = new Map(saves.map((a) => [a.file, a.updatedAt]));
|
|
126
|
+
const records = await autosaves.changedMany(entries.map((e) => e.file));
|
|
127
|
+
return entries.map((entry, i) => {
|
|
128
|
+
const recorded = records[i]?.at ? Date.parse(records[i]!.at) : NaN;
|
|
129
|
+
return { ...entry, mtimeMs: pending.get(entry.file) ?? (Number.isFinite(recorded) ? recorded : null) };
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** A create's or delete's person and tab (defence in depth: the guard is the real check). */
|
|
134
|
+
const who = (context: WriteContext | undefined) => {
|
|
135
|
+
const person = context?.person ?? null;
|
|
136
|
+
if (!person) throw new WriteRefused(401, 'signed-out', { code: 'signed-out' });
|
|
137
|
+
const tab = context?.tab ?? null;
|
|
138
|
+
if (!tab) throw new WriteRefused(400, 'Every request names its tab (X-Caracms-Tab).', { code: 'no-tab' });
|
|
139
|
+
return { person, tab };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 409 `locked` with the lock's state (a LockRefusal). A delete: "Aoife is editing Contact."
|
|
144
|
+
* A create (someone's deletion of that document is still being committed): "Aoife is
|
|
145
|
+
* working on /contact right now, so it can't be created yet."
|
|
146
|
+
*/
|
|
147
|
+
const lockRefusal = async (file: string, held: LockRecord, tab: string, person: Person, what: { title: string } | { address: string }): Promise<never> => {
|
|
148
|
+
const lock = locks.view(file, held, tab, person);
|
|
149
|
+
const body: Omit<LockRefusal, 'error'> = {
|
|
150
|
+
code: 'locked',
|
|
151
|
+
lock,
|
|
152
|
+
state: lock.mine === 'other-tab' ? 'yours-elsewhere' : 'theirs',
|
|
153
|
+
lastChange: await lastChangeOf(parts, file),
|
|
154
|
+
};
|
|
155
|
+
const message =
|
|
156
|
+
'title' in what
|
|
157
|
+
? lock.mine
|
|
158
|
+
? `You're editing ${what.title} in another tab.`
|
|
159
|
+
: `${displayName(lock.person)} is editing ${what.title}.`
|
|
160
|
+
: `${lock.mine ? 'You are' : `${displayName(lock.person)} is`} working on ${what.address} right now, so it can't be created yet.`;
|
|
161
|
+
throw new WriteRefused(409, message, body);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One file's autosave, as a write stores it (pre-M7 item 8's move, and a create's
|
|
166
|
+
* redirect): this tab's own autosave's base kept, another tab's committed first.
|
|
167
|
+
* The caller holds the file's lock.
|
|
168
|
+
*/
|
|
169
|
+
const stash = async (file: string, tab: string, make: (prior: Autosave | null, current: string | null) => Autosave): Promise<Written> => {
|
|
170
|
+
let committed: string | null = null;
|
|
171
|
+
for (let attempt = 1; ; attempt++) {
|
|
172
|
+
if (attempt > 6) throw new Error('caracms: the autosave kept changing under the move.');
|
|
173
|
+
const existing = await autosaves.get(file);
|
|
174
|
+
if (existing && existing.value.tab !== tab && !existing.value.gone && existing.raw !== committed) {
|
|
175
|
+
committed = existing.raw;
|
|
176
|
+
await sweep.commitFiles([file]);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const next = make(existing && existing.value.tab === tab ? existing.value : null, git.read(file));
|
|
180
|
+
if (await autosaves.putIf(existing, next)) return { prior: existing, wrote: { raw: JSON.stringify(next), value: next } };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
/** A same-tab autosave's `ancestor`, kept with its `base` (autosaves.ts). */
|
|
184
|
+
const carried = (prior: Autosave | null): Pick<Autosave, 'ancestor'> => (prior?.ancestor !== undefined ? { ancestor: prior.ancestor } : {});
|
|
185
|
+
/** A whole new text for a file, as its autosave (for `stash`). */
|
|
186
|
+
const saved =
|
|
187
|
+
(file: string, text: string, collection: string, person: Person, tab: string, t: number) =>
|
|
188
|
+
(prior: Autosave | null, current: string | null): Autosave => ({
|
|
189
|
+
file,
|
|
190
|
+
base: prior ? prior.base : (current ?? text),
|
|
191
|
+
...carried(prior),
|
|
192
|
+
document: parseAnyDocument(JSON.parse(text), site, site.collections[collection]!),
|
|
193
|
+
text,
|
|
194
|
+
editor: person,
|
|
195
|
+
tab,
|
|
196
|
+
firstAt: prior?.firstAt ?? t,
|
|
197
|
+
updatedAt: t,
|
|
198
|
+
seq: null,
|
|
199
|
+
...(file === SITE_SETTINGS_FILE ? { sweepAtOnce: true } : {}),
|
|
200
|
+
// Made since the last Publish (or Site Settings made by a move): still new until it's committed.
|
|
201
|
+
...(prior?.created ? { created: prior.created } : current === null ? { created: { over: null } } : {}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A failed move's rollback: each autosave it stored goes back to the one it replaced
|
|
206
|
+
* (newest first), only while it's still the move's (a newer write wins), and every lock
|
|
207
|
+
* it took is released. Best effort: one step failing (Redis down) doesn't stop the rest,
|
|
208
|
+
* and the move's own error is what the caller sees.
|
|
209
|
+
*/
|
|
210
|
+
const undo = async (written: Written[], took: string[], tab: string, person: Person) => {
|
|
211
|
+
for (const w of [...written].reverse()) {
|
|
212
|
+
try {
|
|
213
|
+
if (w.prior) await autosaves.replaceIf(w.wrote, w.prior.value);
|
|
214
|
+
else await autosaves.removeIf(w.wrote);
|
|
215
|
+
} catch {
|
|
216
|
+
// Left as the move wrote it: nothing more can be done without the store.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const f of took) {
|
|
220
|
+
try {
|
|
221
|
+
await locks.release(f, tab, person);
|
|
222
|
+
} catch {
|
|
223
|
+
// The lock's own expiry drops it.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Every file a move (or a Replace…) writes, this tab's before anything is written:
|
|
230
|
+
* someone else's lock on one refuses the whole change with `why`'s sentence (nothing is
|
|
231
|
+
* changed); your own lock in another tab moves here, as Edit here moves it. Each lock
|
|
232
|
+
* this took is pushed onto `took` as it's taken, so a refusal part way can release them.
|
|
233
|
+
*/
|
|
234
|
+
const takeAll = async (files: readonly string[], tab: string, person: Person, took: string[], why: (file: string, who: string, mine: boolean) => string) => {
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
const before = await locks.read(file);
|
|
237
|
+
let taken = await locks.acquire(file, tab, person);
|
|
238
|
+
// Your own lock in another tab (one left open, or just closed) moves here, as Edit here moves it.
|
|
239
|
+
if (!taken.ok && taken.lock.person.email.toLowerCase() === person.email.toLowerCase()) taken = await locks.take(file, tab, person);
|
|
240
|
+
if (!taken.ok) {
|
|
241
|
+
const lock = locks.view(file, taken.lock, tab, person);
|
|
242
|
+
const body: Omit<LockRefusal, 'error'> = { code: 'locked', lock, state: lock.mine === 'other-tab' ? 'yours-elsewhere' : 'theirs', lastChange: await lastChangeOf(parts, file) };
|
|
243
|
+
throw new WriteRefused(409, why(file, lock.mine ? 'You are' : `${displayName(lock.person)} is`, !!lock.mine), body);
|
|
244
|
+
}
|
|
245
|
+
if (!(before && before.lock.tab === tab)) took.push(file);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Pre-M7 item 8: a new page at an address that redirected takes the redirect out of
|
|
251
|
+
* Site Settings, as an autosave of this tab's. Someone else editing Site Settings
|
|
252
|
+
* leaves it in: the page wins over it anyway (the server only redirects a 404).
|
|
253
|
+
*/
|
|
254
|
+
const dropRedirectFrom = async (url: string | null, person: Person, tab: string, t: number) => {
|
|
255
|
+
const current = (await autosaves.get(SITE_SETTINGS_FILE))?.value;
|
|
256
|
+
const text = current ? current.text : git.read(SITE_SETTINGS_FILE);
|
|
257
|
+
if (settingsWithoutRedirectFrom(site, text, url) === null) return;
|
|
258
|
+
const before = await locks.read(SITE_SETTINGS_FILE);
|
|
259
|
+
if (!(await locks.acquire(SITE_SETTINGS_FILE, tab, person)).ok) return;
|
|
260
|
+
let after: string | null = null;
|
|
261
|
+
await stash(SITE_SETTINGS_FILE, tab, (prior, drafts) => {
|
|
262
|
+
const now = prior ? prior.text : drafts;
|
|
263
|
+
after = settingsWithoutRedirectFrom(site, now, url) ?? now;
|
|
264
|
+
return saved(SITE_SETTINGS_FILE, after!, 'settings', person, tab, t)(prior, drafts);
|
|
265
|
+
});
|
|
266
|
+
if (after !== null) await autosaves.setChanged(SITE_SETTINGS_FILE, { by: person, version: fileVersion(after), at: new Date(t).toISOString() });
|
|
267
|
+
if (!(before && before.lock.tab === tab)) await locks.release(SITE_SETTINGS_FILE, tab, person);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
source: () =>
|
|
272
|
+
guard(async () => overlay((await autosaves.all()).map((r) => r.value))),
|
|
273
|
+
|
|
274
|
+
list: () => guard(list),
|
|
275
|
+
|
|
276
|
+
read: (collection, path) =>
|
|
277
|
+
guard(async () => {
|
|
278
|
+
const file = posix(relative(site.root, resolveDocumentFile(site, collection, path)));
|
|
279
|
+
const save = (await autosaves.get(file))?.value ?? null;
|
|
280
|
+
const read = await readDocument(site, collection, path, overlay(save ? [save] : []));
|
|
281
|
+
return { ...read, mtimeMs: save?.updatedAt ?? 0, version: fileVersion(read.text), lastChange: await lastChangeOf(parts, file) };
|
|
282
|
+
}),
|
|
283
|
+
|
|
284
|
+
write: (request: WriteRequest, context?: WriteContext): Promise<WriteResponse> =>
|
|
285
|
+
guard(async () => {
|
|
286
|
+
const person = context?.person ?? null;
|
|
287
|
+
if (!person) throw new WriteRefused(401, 'signed-out', { code: 'signed-out' });
|
|
288
|
+
const tab = context?.tab ?? null;
|
|
289
|
+
if (!tab) throw new WriteRefused(400, 'Every write names its tab (X-Caracms-Tab).', { code: 'no-tab' });
|
|
290
|
+
|
|
291
|
+
const target = confinePath(site, request.file);
|
|
292
|
+
const current = git.read(request.file);
|
|
293
|
+
// It exists: in the copy, or as a new document's autosave (M5a); a "gone" autosave is a deletion.
|
|
294
|
+
const missing = () => new WriteRefused(404, `${site.collections[target.collection]!.label}: no document "${target.path}".`);
|
|
295
|
+
const saved = await autosaves.get(request.file);
|
|
296
|
+
if (saved?.value.gone || (current === null && !saved)) throw missing();
|
|
297
|
+
|
|
298
|
+
const text = writeText(site, target.collection, request);
|
|
299
|
+
|
|
300
|
+
const refuse = async (held: LockRecord): Promise<never> => {
|
|
301
|
+
const lock = locks.view(request.file, held, tab, person);
|
|
302
|
+
const body: Omit<LockRefusal, 'error'> = {
|
|
303
|
+
code: 'locked',
|
|
304
|
+
lock,
|
|
305
|
+
state: lock.mine === 'other-tab' ? 'yours-elsewhere' : 'theirs',
|
|
306
|
+
lastChange: await lastChangeOf(parts, request.file),
|
|
307
|
+
};
|
|
308
|
+
const who = lock.mine ? 'you, in another tab' : displayName(lock.person);
|
|
309
|
+
throw new WriteRefused(409, `This document is being edited by ${who}, so your changes weren't kept.`, body);
|
|
310
|
+
};
|
|
311
|
+
const taken = await locks.acquire(request.file, tab, person);
|
|
312
|
+
if (!taken.ok) await refuse(taken.lock);
|
|
313
|
+
|
|
314
|
+
// Compare-and-set against the autosave as read, so a write that lands late never
|
|
315
|
+
// replaces a newer one: from this tab, a lower `seq` is older (its pagehide write
|
|
316
|
+
// and a normal write racing); from another tab, it's stored only while this tab
|
|
317
|
+
// still holds the lock (an old tab's write that got past `acquire` before Edit
|
|
318
|
+
// here moved the lock is refused, not answered Kept).
|
|
319
|
+
const seq = context?.seq ?? null;
|
|
320
|
+
let existing: Read<Autosave> | null = null;
|
|
321
|
+
let committed: string | null = null;
|
|
322
|
+
let t = now();
|
|
323
|
+
for (let attempt = 1; ; attempt++) {
|
|
324
|
+
if (attempt > 6) throw new Error('caracms: the autosave kept changing under the write.');
|
|
325
|
+
existing = await autosaves.get(request.file);
|
|
326
|
+
if (existing?.value.gone || (current === null && !existing)) throw missing(); // deleted meanwhile
|
|
327
|
+
if (existing && existing.value.tab !== tab) {
|
|
328
|
+
const held = await locks.read(request.file);
|
|
329
|
+
if (!held) {
|
|
330
|
+
// The lock went meanwhile: take it again (or be refused), then look again.
|
|
331
|
+
const again = await locks.acquire(request.file, tab, person);
|
|
332
|
+
if (!again.ok) await refuse(again.lock);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (held.lock.tab !== tab || held.lock.person.email.toLowerCase() !== person.email.toLowerCase()) await refuse(held.lock);
|
|
336
|
+
// The lock moved (Edit here) over an autosave from another tab that this tab's document may not hold: commit it first.
|
|
337
|
+
if (existing.value.text !== request.base && existing.raw !== committed) {
|
|
338
|
+
committed = existing.raw;
|
|
339
|
+
await sweep.commitFiles([request.file]);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const prior = existing?.value;
|
|
344
|
+
if (prior && prior.tab === tab && seq !== null && typeof prior.seq === 'number' && prior.seq > seq) {
|
|
345
|
+
// This tab already stored a newer write: this one is older and changes nothing. The tab's own newer document is what's kept.
|
|
346
|
+
if (context?.release) await locks.release(request.file, tab, person);
|
|
347
|
+
return { ok: true, file: request.file, changed: false, mtimeMs: prior.updatedAt, version: fileVersion(prior.text ?? '') };
|
|
348
|
+
}
|
|
349
|
+
t = now();
|
|
350
|
+
// The merge's ancestor stays what `drafts` had under the autosave this write replaces, when
|
|
351
|
+
// this write's base is something else: it can be that autosave's own text, opened before the
|
|
352
|
+
// sweep committed it (a move's redirect in Site Settings, then edits on top), and as the
|
|
353
|
+
// ancestor that text would read the uncommitted change as one `drafts` made, and undo it.
|
|
354
|
+
const under = prior && (prior.tab === tab || prior.text === request.base) ? (prior.ancestor ?? prior.base) : request.base;
|
|
355
|
+
const next: Autosave = {
|
|
356
|
+
file: request.file,
|
|
357
|
+
base: request.base,
|
|
358
|
+
...(under !== request.base ? { ancestor: under } : {}),
|
|
359
|
+
document: request.document,
|
|
360
|
+
text,
|
|
361
|
+
editor: person,
|
|
362
|
+
tab,
|
|
363
|
+
firstAt: prior?.firstAt ?? t, // the first write since the last commit (the cap)
|
|
364
|
+
updatedAt: t,
|
|
365
|
+
seq,
|
|
366
|
+
// Decision 10: Site Settings' editors and brand apply on the sweep's next tick, never after an idle wait.
|
|
367
|
+
...(request.file === SITE_SETTINGS_FILE ? { sweepAtOnce: true } : {}),
|
|
368
|
+
// M5a: a new document stays one (its ancestor `drafts`' file then) until the sweep commits it.
|
|
369
|
+
...(prior?.created ? { created: prior.created } : {}),
|
|
370
|
+
};
|
|
371
|
+
if (await autosaves.putIf(existing, next)) break;
|
|
372
|
+
}
|
|
373
|
+
const version = fileVersion(text);
|
|
374
|
+
await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString() });
|
|
375
|
+
if (context?.release) await locks.release(request.file, tab, person);
|
|
376
|
+
sweep.poke();
|
|
377
|
+
return { ok: true, file: request.file, changed: text !== (existing?.value.text ?? current), mtimeMs: t, version };
|
|
378
|
+
}),
|
|
379
|
+
|
|
380
|
+
create: (request, context): Promise<CreateResponse> =>
|
|
381
|
+
guard(async () => {
|
|
382
|
+
const { person, tab } = who(context);
|
|
383
|
+
const plan = planCreate(site, request, await listed());
|
|
384
|
+
const taken = await locks.acquire(plan.file, tab, person);
|
|
385
|
+
if (!taken.ok) await lockRefusal(plan.file, taken.lock, tab, person, { address: plan.url ?? plan.path });
|
|
386
|
+
const t = now();
|
|
387
|
+
for (let attempt = 1; ; attempt++) {
|
|
388
|
+
if (attempt > 6) throw new Error('caracms: the autosave kept changing under the create.');
|
|
389
|
+
const existing = await autosaves.get(plan.file);
|
|
390
|
+
const over = git.read(plan.file);
|
|
391
|
+
// Made since the listing was read (or there all along): it exists. A pending deletion is replaced.
|
|
392
|
+
if ((existing && !existing.value.gone) || (!existing && over !== null)) {
|
|
393
|
+
throw refused(409, 'exists', plan.url ? `There's already a page at ${plan.url}.` : `There's already a document called "${plan.path}".`);
|
|
394
|
+
}
|
|
395
|
+
const next: Autosave = {
|
|
396
|
+
file: plan.file,
|
|
397
|
+
base: plan.text,
|
|
398
|
+
document: plan.document,
|
|
399
|
+
text: plan.text,
|
|
400
|
+
editor: person,
|
|
401
|
+
tab,
|
|
402
|
+
firstAt: t,
|
|
403
|
+
updatedAt: t,
|
|
404
|
+
seq: null,
|
|
405
|
+
created: { over },
|
|
406
|
+
};
|
|
407
|
+
if (await autosaves.putIf(existing, next)) break;
|
|
408
|
+
}
|
|
409
|
+
const version = fileVersion(plan.text);
|
|
410
|
+
await autosaves.setChanged(plan.file, { by: person, version, at: new Date(t).toISOString() });
|
|
411
|
+
await dropRedirectFrom(plan.url, person, tab, t);
|
|
412
|
+
sweep.poke();
|
|
413
|
+
return { ok: true, collection: plan.collection, path: plan.path, file: plan.file, url: plan.url, version };
|
|
414
|
+
}),
|
|
415
|
+
|
|
416
|
+
move: (request, context): Promise<MoveResponse> =>
|
|
417
|
+
guard(async () => {
|
|
418
|
+
const { person, tab } = who(context);
|
|
419
|
+
const documents = await listed();
|
|
420
|
+
const plan = planMove(site, request, documents);
|
|
421
|
+
const source = overlay((await autosaves.all()).map((r) => r.value));
|
|
422
|
+
// A document made since the last Publish was never live, so nobody has its old address: no redirect.
|
|
423
|
+
const wasLive = git.changeOf(plan.from.file, '') === 'changed';
|
|
424
|
+
const changes = await moveChanges(site, plan, documents, (file) => source.readText(join(site.root, file)), wasLive);
|
|
425
|
+
const relinked = changes.relinked.map((r) => r.file);
|
|
426
|
+
const files = [plan.from.file, plan.to.file, ...relinked, ...(changes.settings ? [SITE_SETTINGS_FILE] : [])];
|
|
427
|
+
|
|
428
|
+
// Every file the move writes is this tab's before anything is written. Someone else's
|
|
429
|
+
// lock on one refuses the whole move (nothing is changed), naming them and the document;
|
|
430
|
+
// the mover's own lock in another tab moves to this one (that tab goes read-only, as after Edit here).
|
|
431
|
+
//
|
|
432
|
+
// **A move lands whole or leaves nothing behind.** Each autosave it stores is noted with the
|
|
433
|
+
// one it replaced; any failure after the first (Redis gone, an autosave that kept changing)
|
|
434
|
+
// puts them back newest first, compare-and-set against what the move wrote, and releases
|
|
435
|
+
// every lock the move took, so a retry finds the document where it was. Site Settings (the
|
|
436
|
+
// one autosave the sweep commits at once) is stored last, so a sweep tick can't commit a
|
|
437
|
+
// redirect for a move that then fails.
|
|
438
|
+
const took: string[] = [];
|
|
439
|
+
const written: Written[] = [];
|
|
440
|
+
try {
|
|
441
|
+
await takeAll(files, tab, person, took, (file, who, mine) => {
|
|
442
|
+
const name = file === plan.from.file ? plan.title : file === SITE_SETTINGS_FILE ? 'Site Settings' : (changes.relinked.find((r) => r.file === file)?.title ?? plan.to.url);
|
|
443
|
+
return file === plan.from.file
|
|
444
|
+
? `${who} editing ${name}${mine ? ' in another tab' : ''}, so its address can't change yet.`
|
|
445
|
+
: file === plan.to.file
|
|
446
|
+
? `${who} working on ${plan.to.url} right now, so ${plan.title} can't move there yet.`
|
|
447
|
+
: `${who} editing ${name}${mine ? ' in another tab' : ''}, which ${file === SITE_SETTINGS_FILE ? 'holds the redirects' : `links to ${plan.from.url}`}, so the address can't change until ${mine ? 'that tab is closed' : 'they\'re done'}.`;
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const t = now();
|
|
451
|
+
const at = new Date(t).toISOString();
|
|
452
|
+
// The new document first, as a create makes one: its autosave, with no base in drafts.
|
|
453
|
+
// Made there since the listing was read: it exists. A pending deletion there is replaced.
|
|
454
|
+
for (let attempt = 1; ; attempt++) {
|
|
455
|
+
if (attempt > 6) throw new Error('caracms: the autosave kept changing under the move.');
|
|
456
|
+
const existing = await autosaves.get(plan.to.file);
|
|
457
|
+
const over = git.read(plan.to.file);
|
|
458
|
+
if ((existing && !existing.value.gone) || (!existing && over !== null)) throw refused(409, 'address-taken', `There's already a page at ${plan.to.url}.`);
|
|
459
|
+
const next: Autosave = { ...saved(plan.to.file, changes.moved, plan.to.collection, person, tab, t)(null, null), base: changes.moved, created: { over } };
|
|
460
|
+
if (await autosaves.putIf(existing, next)) {
|
|
461
|
+
written.push({ prior: existing, wrote: { raw: JSON.stringify(next), value: next } });
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (const r of changes.relinked) written.push(await stash(r.file, tab, saved(r.file, r.text, r.collection, person, tab, t)));
|
|
466
|
+
// Then the old one goes, as a delete removes one.
|
|
467
|
+
const oldTitle = titleOf(plan.from.file, git.read(plan.from.file) ?? (await source.readText(join(site.root, plan.from.file))));
|
|
468
|
+
written.push(
|
|
469
|
+
await stash(plan.from.file, tab, (prior, current) => ({
|
|
470
|
+
file: plan.from.file,
|
|
471
|
+
base: prior ? prior.base : (current ?? ''),
|
|
472
|
+
...carried(prior),
|
|
473
|
+
document: null,
|
|
474
|
+
text: null,
|
|
475
|
+
editor: person,
|
|
476
|
+
tab,
|
|
477
|
+
firstAt: prior?.firstAt ?? t,
|
|
478
|
+
updatedAt: t,
|
|
479
|
+
seq: null,
|
|
480
|
+
...(prior?.created ? { created: prior.created } : {}),
|
|
481
|
+
gone: { title: oldTitle },
|
|
482
|
+
})),
|
|
483
|
+
);
|
|
484
|
+
// The redirect last (see above).
|
|
485
|
+
if (changes.settings) written.push(await stash(SITE_SETTINGS_FILE, tab, saved(SITE_SETTINGS_FILE, changes.settings.after, 'settings', person, tab, t)));
|
|
486
|
+
|
|
487
|
+
for (const [file, text] of [[plan.to.file, changes.moved], ...changes.relinked.map((r) => [r.file, r.text]), ...(changes.settings ? [[SITE_SETTINGS_FILE, changes.settings.after]] : [])] as [string, string][]) {
|
|
488
|
+
await autosaves.setChanged(file, { by: person, version: fileVersion(text), at });
|
|
489
|
+
}
|
|
490
|
+
await autosaves.setChanged(plan.from.file, { by: person, version: '', at, gone: true });
|
|
491
|
+
// The move itself, kept until it's published: the waiting list and Publish read it, not the redirect.
|
|
492
|
+
await autosaves.recordMove(plan.from.file, plan.to.file, wasLive);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
await undo(written, took, tab, person);
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
// The tab moves on to the new address, keeping its lock there; the others go once committed.
|
|
498
|
+
await locks.release(plan.from.file, tab, person, { deleted: true });
|
|
499
|
+
for (const f of took) if (f !== plan.to.file && f !== plan.from.file) await locks.release(f, tab, person);
|
|
500
|
+
sweep.poke();
|
|
501
|
+
return moveResponse(site, plan, changes);
|
|
502
|
+
}),
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* M6c G4 (Decision 9): Replace… on a file. Every document with a link to the old file
|
|
506
|
+
* (replace.ts, read with the autosaves on top) is this tab's before anything is written,
|
|
507
|
+
* as a move's are (takeAll); then each rewritten text is its autosave (stash), noted with
|
|
508
|
+
* the one it replaced, so a failure part way puts them all back (undo) and releases the
|
|
509
|
+
* locks: **it lands whole or leaves every document as it was**. The rewritten documents
|
|
510
|
+
* are waiting changes, going live with the next Publish.
|
|
511
|
+
*/
|
|
512
|
+
replaceFile: (request, context): Promise<ReplaceFileResponse> =>
|
|
513
|
+
guard(async () => {
|
|
514
|
+
const { person, tab } = who(context);
|
|
515
|
+
const documents = await listed();
|
|
516
|
+
const source = overlay((await autosaves.all()).map((r) => r.value));
|
|
517
|
+
const changes = await replaceChanges(site, request, documents, (file) => source.readText(join(site.root, file)));
|
|
518
|
+
const took: string[] = [];
|
|
519
|
+
const written: Written[] = [];
|
|
520
|
+
try {
|
|
521
|
+
await takeAll(
|
|
522
|
+
changes.map((c) => c.file),
|
|
523
|
+
tab,
|
|
524
|
+
person,
|
|
525
|
+
took,
|
|
526
|
+
(file, who, mine) => `${who} editing ${changes.find((c) => c.file === file)?.title ?? file}${mine ? ' in another tab' : ''}, which links here. Try once ${mine ? 'that tab is closed' : "they're done"}.`,
|
|
527
|
+
);
|
|
528
|
+
const t = now();
|
|
529
|
+
const at = new Date(t).toISOString();
|
|
530
|
+
// Site Settings (the one autosave the sweep commits at once) last, as a move stores its redirect last.
|
|
531
|
+
const ordered = [...changes.filter((c) => c.file !== SITE_SETTINGS_FILE), ...changes.filter((c) => c.file === SITE_SETTINGS_FILE)];
|
|
532
|
+
for (const c of ordered) written.push(await stash(c.file, tab, saved(c.file, c.text, c.collection, person, tab, t)));
|
|
533
|
+
for (const c of ordered) await autosaves.setChanged(c.file, { by: person, version: fileVersion(c.text), at });
|
|
534
|
+
} catch (error) {
|
|
535
|
+
await undo(written, took, tab, person);
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
for (const f of took) await locks.release(f, tab, person);
|
|
539
|
+
sweep.poke();
|
|
540
|
+
return replaceResponse(request, changes);
|
|
541
|
+
}),
|
|
542
|
+
|
|
543
|
+
delete: (request, context): Promise<DeleteResponse> =>
|
|
544
|
+
guard(async () => {
|
|
545
|
+
const { person, tab } = who(context);
|
|
546
|
+
const plan = planDelete(site, request, await listed());
|
|
547
|
+
const title = plan.entry.label;
|
|
548
|
+
const taken = await locks.acquire(plan.file, tab, person);
|
|
549
|
+
if (!taken.ok) await lockRefusal(plan.file, taken.lock, tab, person, { title });
|
|
550
|
+
let committed: string | null = null;
|
|
551
|
+
const t = now();
|
|
552
|
+
for (let attempt = 1; ; attempt++) {
|
|
553
|
+
if (attempt > 6) throw new Error('caracms: the autosave kept changing under the delete.');
|
|
554
|
+
const existing = await autosaves.get(plan.file);
|
|
555
|
+
const current = git.read(plan.file);
|
|
556
|
+
if (existing?.value.gone || (!existing && current === null)) throw refused(404, 'not-found', `${site.collections[plan.collection]!.label}: no document "${plan.path}".`);
|
|
557
|
+
if (existing && existing.value.tab !== tab && existing.raw !== committed) {
|
|
558
|
+
// Another tab's autosave under a lock this tab now holds: commit it first, under its editor's name.
|
|
559
|
+
committed = existing.raw;
|
|
560
|
+
await sweep.commitFiles([plan.file]);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const prior = existing && existing.value.tab === tab ? existing.value : null;
|
|
564
|
+
const next: Autosave = {
|
|
565
|
+
file: plan.file,
|
|
566
|
+
// The text this tab's session was on (its own autosave's base), else drafts' copy:
|
|
567
|
+
// the sweep works out the merge's ancestor from it as for any autosave.
|
|
568
|
+
base: prior ? prior.base : (current ?? ''),
|
|
569
|
+
...carried(prior),
|
|
570
|
+
document: null,
|
|
571
|
+
text: null,
|
|
572
|
+
editor: person,
|
|
573
|
+
tab,
|
|
574
|
+
firstAt: prior?.firstAt ?? t,
|
|
575
|
+
updatedAt: t,
|
|
576
|
+
seq: null,
|
|
577
|
+
...(prior?.created ? { created: prior.created } : {}),
|
|
578
|
+
// The title before the removal: drafts' copy, else the new document's own.
|
|
579
|
+
gone: { title: titleOf(plan.file, current ?? prior?.text ?? null) },
|
|
580
|
+
};
|
|
581
|
+
if (await autosaves.putIf(existing, next)) break;
|
|
582
|
+
}
|
|
583
|
+
await autosaves.setChanged(plan.file, { by: person, version: '', at: new Date(t).toISOString(), gone: true });
|
|
584
|
+
await autosaves.forgetMoveTo(plan.file);
|
|
585
|
+
// The deleter's tab moves on: the lock drops once the sweep has committed the removal.
|
|
586
|
+
await locks.release(plan.file, tab, person, { deleted: true });
|
|
587
|
+
sweep.poke();
|
|
588
|
+
return { ok: true, collection: plan.collection, path: plan.path, file: plan.file };
|
|
589
|
+
}),
|
|
590
|
+
};
|
|
591
|
+
}
|