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,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autosaves in Redis (M4 Decision 4), and the two small records beside them.
|
|
3
|
+
*
|
|
4
|
+
* - **`caracms:autosave:<file>`**: the whole document as the tab last sent it, with the
|
|
5
|
+
* base it was edited from, its text (`applyEdits(base, document)`), its editor and
|
|
6
|
+
* tab, and two times for the sweep: the first write since the last commit (the
|
|
7
|
+
* five-minute cap) and the newest write (the idle time). Expires after a week, as a
|
|
8
|
+
* backstop (`autosaveTtlMs`).
|
|
9
|
+
* - **`caracms:committed:<file>`**: what the sweep last committed from a tab's editing
|
|
10
|
+
* session (its tab, its base, and the text it committed). The next commit from the
|
|
11
|
+
* same session merges against that text, not the session's first base, so the
|
|
12
|
+
* session's own earlier commit isn't mistaken for a change made elsewhere (no false
|
|
13
|
+
* "also changed outside the editor" notice).
|
|
14
|
+
* - **`caracms:changed:<file>`**: who last changed the document and the version their
|
|
15
|
+
* autosave left (Decision 11). Set by every autosave; a merge from `main` or the sweep
|
|
16
|
+
* committing that person's own autosave leaves it alone. **It never expires** (one
|
|
17
|
+
* small key per file), so it outlives Publish; only a flush loses it.
|
|
18
|
+
*
|
|
19
|
+
* **Creating and deleting** (M5a Decisions 11 and 12) go through the same autosave:
|
|
20
|
+
*
|
|
21
|
+
* - **A new document** is an autosave with `created: { over }`: `over` is what `drafts`
|
|
22
|
+
* had at that path when it was created (null; or the file a pending deletion was
|
|
23
|
+
* removing, when a document deleted since the last Publish is made again). Until the
|
|
24
|
+
* sweep commits it, `over` is the merge's ancestor, not `base` (which is the new
|
|
25
|
+
* document's first text, the base the creator's tab edits against). Later writes
|
|
26
|
+
* carry `created` on until the commit.
|
|
27
|
+
* - **A deletion** is a "gone" autosave: `text` and `document` are null, and
|
|
28
|
+
* `gone: { title }` keeps the document's title from before the removal (the waiting
|
|
29
|
+
* list's and the commit message's name for it when `main` can't say). `base` is the
|
|
30
|
+
* text the deleter's tab was on, so the merge's ancestor is worked out as for any
|
|
31
|
+
* autosave. The sweep commits it as a removed file.
|
|
32
|
+
* - **`caracms:changed:`** records a deletion too (`gone: true`), so a viewer's tab
|
|
33
|
+
* learns who deleted the document after the sweep has committed it.
|
|
34
|
+
*
|
|
35
|
+
* **Moves** (pre-M7 item 8): **`caracms:moves`**, a hash from a moved document's old
|
|
36
|
+
* file to its new file, kept until the move is published (`movesKey`). A move records
|
|
37
|
+
* itself here rather than being read off Site Settings' redirects, so it stays one
|
|
38
|
+
* "Moved" entry (and one headline item) whether or not the editor keeps the redirect.
|
|
39
|
+
* A move of a document moved since the last Publish extends its record (old → newest);
|
|
40
|
+
* moving it back to where it was drops the record, as does deleting it. A record only
|
|
41
|
+
* counts while its old file is gone and its new file is there (`movesIn`, git.ts).
|
|
42
|
+
*
|
|
43
|
+
* Paths are site-relative (`content/pages/index.json`). Values are JSON text.
|
|
44
|
+
*/
|
|
45
|
+
import type { AnyDocument } from '../document.ts';
|
|
46
|
+
import type { Person } from './messages.ts';
|
|
47
|
+
import type { RedisClient } from './redis.ts';
|
|
48
|
+
import type { LiveTimings } from './timings.ts';
|
|
49
|
+
|
|
50
|
+
export interface Autosave {
|
|
51
|
+
file: string;
|
|
52
|
+
/** The text the tab's edits were made against (the write body's `base`). */
|
|
53
|
+
base: string;
|
|
54
|
+
/**
|
|
55
|
+
* What `drafts` had under this autosave, when `base` isn't it: a write made against
|
|
56
|
+
* the text of an autosave the sweep hadn't committed yet (opened over it: a move's
|
|
57
|
+
* Site Settings, say) keeps the replaced autosave's ancestor here. The merge's
|
|
58
|
+
* ancestor when no session record applies (`ancestorFor`); absent, it's `base`.
|
|
59
|
+
*/
|
|
60
|
+
ancestor?: string;
|
|
61
|
+
/** Any kind of document (M5a). Null: the document is deleted (`gone`). */
|
|
62
|
+
document: AnyDocument | null;
|
|
63
|
+
/** `applyEdits(base, document)`: what the document reads as now. Null: deleted (`gone`). */
|
|
64
|
+
text: string | null;
|
|
65
|
+
editor: Person;
|
|
66
|
+
tab: string;
|
|
67
|
+
/** ms since the epoch: the first write since the last commit. */
|
|
68
|
+
firstAt: number;
|
|
69
|
+
/** ms since the epoch: the newest write. */
|
|
70
|
+
updatedAt: number;
|
|
71
|
+
/** The tab's own write sequence (`X-Caracms-Seq`), when it sent one: a lower one from the same tab is older. */
|
|
72
|
+
seq?: number | null;
|
|
73
|
+
/**
|
|
74
|
+
* Ready for the sweep at once, with no idle wait (M5a Decision 10): Site Settings'
|
|
75
|
+
* autosave, so `editors` and `brand` apply on the sweep's next tick. The sweep
|
|
76
|
+
* commits it on that tick whatever its age (sweep.ts → `ready`).
|
|
77
|
+
*/
|
|
78
|
+
sweepAtOnce?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* M5a: this autosave creates the document (Decision 11). `over` is the file `drafts`
|
|
81
|
+
* had at that path then (usually null): the merge's ancestor until it's committed.
|
|
82
|
+
*/
|
|
83
|
+
created?: { over: string | null };
|
|
84
|
+
/** M5a: the document is deleted (Decision 12): `text` and `document` are null. `title` is its title before the removal. */
|
|
85
|
+
gone?: { title: string };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface Committed {
|
|
89
|
+
tab: string;
|
|
90
|
+
base: string;
|
|
91
|
+
/** The session's own text as committed (before any merge); null for a deletion. */
|
|
92
|
+
text: string | null;
|
|
93
|
+
at: number;
|
|
94
|
+
/** The editor's address, lower-cased (absent on records written before it was added). */
|
|
95
|
+
editor?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Where an autosave's merge ancestor came from (sweep.ts logs anything but `session`/`base`). */
|
|
99
|
+
export type AncestorFrom = 'session' | 'own-commit' | 'base' | 'created' | 'base, record from another tab' | 'base, record from someone else';
|
|
100
|
+
|
|
101
|
+
export interface ChangeRecord {
|
|
102
|
+
/** Null: only merges from `main` have changed it (no editor has, since the record began). */
|
|
103
|
+
by: Person | null;
|
|
104
|
+
version: string;
|
|
105
|
+
/** ISO. */
|
|
106
|
+
at: string;
|
|
107
|
+
/** M5a: this change deleted the document (`by` deleted it). */
|
|
108
|
+
gone?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A value with the raw text it was read as, for compare-and-swap. */
|
|
112
|
+
export interface Read<T> {
|
|
113
|
+
raw: string;
|
|
114
|
+
value: T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The Redis hash of moves waiting to publish (old file → new file). */
|
|
118
|
+
export const movesKey = (prefix: string) => `${prefix}moves`;
|
|
119
|
+
|
|
120
|
+
const parse = <T>(raw: string | null): T | null => {
|
|
121
|
+
if (raw === null) return null;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(raw) as T;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export class Autosaves {
|
|
130
|
+
readonly redis: RedisClient;
|
|
131
|
+
readonly timings: LiveTimings;
|
|
132
|
+
readonly now: () => number;
|
|
133
|
+
readonly prefix: string;
|
|
134
|
+
|
|
135
|
+
constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:') {
|
|
136
|
+
this.redis = redis;
|
|
137
|
+
this.timings = timings;
|
|
138
|
+
this.now = now;
|
|
139
|
+
this.prefix = prefix;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
key = (file: string) => `${this.prefix}autosave:${file}`;
|
|
143
|
+
private committedKey = (file: string) => `${this.prefix}committed:${file}`;
|
|
144
|
+
private changedKey = (file: string) => `${this.prefix}changed:${file}`;
|
|
145
|
+
|
|
146
|
+
async get(file: string): Promise<Read<Autosave> | null> {
|
|
147
|
+
const raw = await this.redis.get(this.key(file));
|
|
148
|
+
const value = parse<Autosave>(raw);
|
|
149
|
+
return raw !== null && value ? { raw, value } : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Every autosave: one SCAN and one MGET. */
|
|
153
|
+
async all(): Promise<Read<Autosave>[]> {
|
|
154
|
+
const keys = await this.redis.scan(`${this.prefix}autosave:*`);
|
|
155
|
+
if (keys.length === 0) return [];
|
|
156
|
+
const raws = await this.redis.mget(keys);
|
|
157
|
+
const out: Read<Autosave>[] = [];
|
|
158
|
+
raws.forEach((raw) => {
|
|
159
|
+
const value = parse<Autosave>(raw);
|
|
160
|
+
if (raw !== null && value) out.push({ raw, value });
|
|
161
|
+
});
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Store it only if the autosave is still what was read (`null`: only if there's none), so a write that read an older one never replaces a newer. */
|
|
166
|
+
putIf(read: Read<Autosave> | null, autosave: Autosave): Promise<boolean> {
|
|
167
|
+
return this.redis.compareAndSet(this.key(autosave.file), read?.raw ?? null, JSON.stringify(autosave), { px: this.timings.autosaveTtlMs });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Delete it only if it's still what was read (a newer write wins). */
|
|
171
|
+
removeIf(read: Read<Autosave>): Promise<boolean> {
|
|
172
|
+
return this.redis.compareAndDelete(this.key(read.value.file), read.raw);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Replace it only if it's still what was read. */
|
|
176
|
+
replaceIf(read: Read<Autosave>, next: Autosave): Promise<boolean> {
|
|
177
|
+
return this.redis.compareAndSet(this.key(next.file), read.raw, JSON.stringify(next), { px: this.timings.autosaveTtlMs });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async committed(file: string): Promise<Committed | null> {
|
|
181
|
+
return parse<Committed>(await this.redis.get(this.committedKey(file)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async setCommitted(file: string, record: Committed): Promise<void> {
|
|
185
|
+
await this.redis.set(this.committedKey(file), JSON.stringify(record), { px: this.timings.autosaveTtlMs });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async changed(file: string): Promise<ChangeRecord | null> {
|
|
189
|
+
return parse<ChangeRecord>(await this.redis.get(this.changedKey(file)));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Several files' records in one MGET (the documents list's "Last edited"), in the order asked; none: null. */
|
|
193
|
+
async changedMany(files: readonly string[]): Promise<(ChangeRecord | null)[]> {
|
|
194
|
+
if (files.length === 0) return [];
|
|
195
|
+
return (await this.redis.mget(files.map(this.changedKey))).map((raw) => parse<ChangeRecord>(raw));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** No expiry: one small key per file, overwritten on each change, so "who changed it" is never forgotten while Redis keeps its data. */
|
|
199
|
+
async setChanged(file: string, record: ChangeRecord): Promise<void> {
|
|
200
|
+
await this.redis.set(this.changedKey(file), JSON.stringify(record));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Record a change only if the file has no record yet (SET NX): a merge or fast-forward
|
|
205
|
+
* from `main` leaves `{ by: null }` ("only merges"), and never overwrites an editor's.
|
|
206
|
+
*/
|
|
207
|
+
async setChangedIfAbsent(file: string, record: Omit<ChangeRecord, 'by'> & { by: Person | null }): Promise<void> {
|
|
208
|
+
await this.redis.set(this.changedKey(file), JSON.stringify(record), { nx: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Pre-M7 item 8: the moves waiting to publish, old file → new file. */
|
|
212
|
+
async moves(): Promise<Map<string, string>> {
|
|
213
|
+
return new Map(Object.entries(await this.redis.hgetall(movesKey(this.prefix))));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A document moved from `from` to `to`. Moved since the last Publish already (a record
|
|
218
|
+
* ends at `from`): that record now ends at `to`, or goes when `to` is where it started
|
|
219
|
+
* (moved back). Otherwise a new record, only when `from` is live (`wasLive`): a page
|
|
220
|
+
* made since the last Publish is just a new page at its new address.
|
|
221
|
+
*/
|
|
222
|
+
async recordMove(from: string, to: string, wasLive: boolean): Promise<void> {
|
|
223
|
+
const moves = await this.moves();
|
|
224
|
+
const earlier = [...moves].find(([, t]) => t === from)?.[0];
|
|
225
|
+
if (earlier !== undefined) {
|
|
226
|
+
if (earlier === to) await this.redis.hdel(movesKey(this.prefix), earlier);
|
|
227
|
+
else await this.redis.hset(movesKey(this.prefix), { [earlier]: to });
|
|
228
|
+
} else if (wasLive) await this.redis.hset(movesKey(this.prefix), { [from]: to });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A deleted document's move, if it was moved, is no move any more: it's a deletion. */
|
|
232
|
+
async forgetMoveTo(file: string): Promise<void> {
|
|
233
|
+
const moves = await this.moves();
|
|
234
|
+
const olds = [...moves].filter(([, t]) => t === file).map(([o]) => o);
|
|
235
|
+
if (olds.length) await this.redis.hdel(movesKey(this.prefix), ...olds);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The merge's ancestor for an autosave (Decision 6). See `ancestorFor`. */
|
|
239
|
+
async ancestorOf(autosave: Autosave): Promise<string | null> {
|
|
240
|
+
return (await this.ancestorFor(autosave)).text;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The merge's ancestor for an autosave (Decision 6), so the editor's own earlier
|
|
245
|
+
* commit from the same editing session is never read as a change made elsewhere:
|
|
246
|
+
*
|
|
247
|
+
* - **The same tab, the same base** (the session's last commit): that commit's text.
|
|
248
|
+
* - **The same person and base, another tab id** (held edits re-sent from a reloaded
|
|
249
|
+
* or new tab, Use mine): per top-level key, the person's own committed value for
|
|
250
|
+
* each key this draft changed, and the base's value for every key it didn't. So
|
|
251
|
+
* their own commit isn't a conflict, and a key their other tab changed and this
|
|
252
|
+
* draft never touched is still taken from `drafts`, not undone.
|
|
253
|
+
* - Otherwise (no record, another base, someone else): what `drafts` had under it (its
|
|
254
|
+
* `ancestor`, else its own base).
|
|
255
|
+
* - **A new document not committed yet** (M5a, `created`): what `drafts` had at its
|
|
256
|
+
* path when it was made (`created.over`, usually null: the file didn't exist).
|
|
257
|
+
*/
|
|
258
|
+
async ancestorFor(autosave: Autosave): Promise<{ text: string | null; from: AncestorFrom }> {
|
|
259
|
+
if (autosave.created) return { text: autosave.created.over, from: 'created' };
|
|
260
|
+
const c = await this.committed(autosave.file);
|
|
261
|
+
const start = autosave.ancestor ?? autosave.base;
|
|
262
|
+
if (!c || c.base !== autosave.base) return { text: start, from: 'base' };
|
|
263
|
+
if (c.tab === autosave.tab) return { text: c.text, from: 'session' };
|
|
264
|
+
if (c.editor !== autosave.editor.email.toLowerCase()) return { text: start, from: 'base, record from someone else' };
|
|
265
|
+
if (autosave.text === null || c.text === null) return { text: start, from: 'base, record from another tab' };
|
|
266
|
+
const own = ownAncestor(autosave.base, autosave.text, c.text);
|
|
267
|
+
return own === null ? { text: start, from: 'base, record from another tab' } : { text: own, from: 'own-commit' };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const equal = (x: unknown, y: unknown): boolean => {
|
|
272
|
+
if (x === y) return true;
|
|
273
|
+
if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null || Array.isArray(x) !== Array.isArray(y)) return false;
|
|
274
|
+
const xk = Object.keys(x);
|
|
275
|
+
const yk = Object.keys(y);
|
|
276
|
+
return xk.length === yk.length && xk.every((k) => Object.hasOwn(y, k) && equal((x as Record<string, unknown>)[k], (y as Record<string, unknown>)[k]));
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const object = (text: string): Record<string, unknown> | null => {
|
|
280
|
+
try {
|
|
281
|
+
const v = JSON.parse(text) as unknown;
|
|
282
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The per-key ancestor for a draft whose person already committed from the same base
|
|
290
|
+
* in another tab: the committed value where the draft changed a key, the base's where
|
|
291
|
+
* it didn't. Only values matter (merge.ts reads the ancestor's values, never its text),
|
|
292
|
+
* so it's plain JSON. Null when any side isn't a JSON object.
|
|
293
|
+
*/
|
|
294
|
+
function ownAncestor(base: string, draft: string, committed: string): string | null {
|
|
295
|
+
const b = object(base);
|
|
296
|
+
const d = object(draft);
|
|
297
|
+
const c = object(committed);
|
|
298
|
+
if (!b || !d || !c) return null;
|
|
299
|
+
const out: Record<string, unknown> = {};
|
|
300
|
+
for (const key of new Set([...Object.keys(b), ...Object.keys(c), ...Object.keys(d)])) {
|
|
301
|
+
const changed = Object.hasOwn(d, key) !== Object.hasOwn(b, key) || !equal(d[key], b[key]);
|
|
302
|
+
const from = changed ? c : b;
|
|
303
|
+
if (Object.hasOwn(from, key)) out[key] = from[key];
|
|
304
|
+
}
|
|
305
|
+
return JSON.stringify(out, null, 2);
|
|
306
|
+
}
|