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,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping: getting the editor's copy of each document to the server (M3 Decisions 5
|
|
3
|
+
* and 6; M4 Decisions 4, 9 and 11).
|
|
4
|
+
*
|
|
5
|
+
* - **About a second after the last change**, the whole document is POSTed to the
|
|
6
|
+
* write route with the base it was made against: `{ file, base, document }`
|
|
7
|
+
* (server/write.ts). One write per document in flight; a change made meanwhile
|
|
8
|
+
* goes in the next one. In live mode every write carries the tab's id
|
|
9
|
+
* (`X-Caracms-Tab`) and its place in the tab's order (`X-Caracms-Seq`, rising, from
|
|
10
|
+
* the clock so a reload keeps rising), and **Kept means the server has it** (Redis's
|
|
11
|
+
* autosave; an older write that lands late never replaces a newer one).
|
|
12
|
+
* - **The bar's keep state** (`status`): `kept` when the server has every change,
|
|
13
|
+
* `keeping` while a write is pending or in flight, `offline` ("can't reach the
|
|
14
|
+
* server") once a write fails on the network or with a 5xx (503 `unavailable`
|
|
15
|
+
* included), **until a write succeeds**. A 4xx is the server answering, not an
|
|
16
|
+
* outage: `refused`, with the server's own message in `error`. Live mode adds two:
|
|
17
|
+
* a 409 `locked` (someone else's lock, or your own in another tab) is `held`, and a
|
|
18
|
+
* 401 is `signedout`. Whatever the state, **the edits stay held**.
|
|
19
|
+
* - **Retrying.** In local mode (M3) there's no timer: the next change, or a reload,
|
|
20
|
+
* retries. In live mode (`retryDelays`) **a retry timer runs only while offline**,
|
|
21
|
+
* backing off, with its time in `status.retryAt` for the countdown, and `tryNow()`
|
|
22
|
+
* for Try now. `unsent` counts the changes the server doesn't have yet.
|
|
23
|
+
* - **Unsent edits are held in the browser's storage**, per document, from the
|
|
24
|
+
* change until the server has them: `{ file, base, document, fileVersion }`. The
|
|
25
|
+
* base travels with them, because the blocks' `source` indexes point into it
|
|
26
|
+
* (serialize.ts; M3 → Watch-outs). They're flushed on `pagehide` too (a keepalive
|
|
27
|
+
* write; in live mode with `X-Caracms-Release: 1`, and a keepalive release of any
|
|
28
|
+
* other lock the tab holds with nothing unsent), since closing the tab inside the
|
|
29
|
+
* second would lose the last change.
|
|
30
|
+
*
|
|
31
|
+
* **The re-send rule (`verdict`): a stored copy is dropped only when the server's text
|
|
32
|
+
* already holds those edits** (never lose work silently: Spec → Non-functional, M2
|
|
33
|
+
* Decision 11). Stored edits remember the version of the file they were made against
|
|
34
|
+
* (a hash of its text, server/write.ts → fileVersion).
|
|
35
|
+
* 1. The file is still at that version: nothing has written it since. Re-sent.
|
|
36
|
+
* 2. It has moved on: the keeper runs the serializer here in the browser,
|
|
37
|
+
* `applyEdits(base, document)` exactly as the write route does, and compares
|
|
38
|
+
* with the server's text (the file in local mode; the autosave or `drafts` in
|
|
39
|
+
* live mode). **Equal, the copy is dropped** (the keepalive write never updates
|
|
40
|
+
* the stored version and may have been refused, and an earlier write can land
|
|
41
|
+
* after a later one, so a moved version alone proves nothing).
|
|
42
|
+
* 3. Otherwise, in local mode, re-sent (M3: the last write wins). In live mode **the
|
|
43
|
+
* server decides (M4 Decision 11)** with `lastChange`: no editor known (`by:
|
|
44
|
+
* null`: only merges), you, or an editor change that left exactly the version
|
|
45
|
+
* these edits were made against: re-sent (the server merges, Decision 6).
|
|
46
|
+
* **Another editor changed it since, or the server can't tell who did (`known:
|
|
47
|
+
* false`): not re-sent**, held as a `conflict` until Use mine (`useMine`) or
|
|
48
|
+
* Discard mine (`discard`).
|
|
49
|
+
* Versions are compared for equality, never as times, so no clock is involved.
|
|
50
|
+
*/
|
|
51
|
+
import { documentKind, normaliseAnyDocument, type AnyDocument } from '../../../document.ts';
|
|
52
|
+
import { applyDocumentEdits } from '../../../serialize.ts';
|
|
53
|
+
import type { Fields } from '../../../fields.ts';
|
|
54
|
+
import { ENDPOINTS, RELEASE_HEADER, SEQ_HEADER, TAB_HEADER, type EditorSite, type LastChange, type LockRefusal, type Person, type SignedOutError } from '../../types.ts';
|
|
55
|
+
import { createStore, type Store } from './store.ts';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `refused`: the server answered a write with a 4xx (its message is the status's
|
|
59
|
+
* `error`). `held` (live): a 409, or edits another editor's change overtook (Decision
|
|
60
|
+
* 11). `signedout` (live): a 401.
|
|
61
|
+
*/
|
|
62
|
+
export type KeepState = 'kept' | 'keeping' | 'offline' | 'refused' | 'held' | 'signedout';
|
|
63
|
+
|
|
64
|
+
/** A document's edits that are held, not sent, until the person decides (Decision 11). */
|
|
65
|
+
export interface HeldView {
|
|
66
|
+
id: string;
|
|
67
|
+
file: string;
|
|
68
|
+
/** `locked`: a write got 409 (the lock's another tab's). `conflict`: someone else changed it since. */
|
|
69
|
+
reason: 'locked' | 'conflict';
|
|
70
|
+
/** Who changed it, or holds it. */
|
|
71
|
+
by: Person | null;
|
|
72
|
+
/** It's your own lock in another tab (Use mine is offered at once). */
|
|
73
|
+
elsewhere: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface KeepStatus {
|
|
77
|
+
state: KeepState;
|
|
78
|
+
/**
|
|
79
|
+
* Offline: the last failed write's error, for the console and a tooltip. Refused:
|
|
80
|
+
* the server's own message, which the bar shows. Null otherwise.
|
|
81
|
+
*/
|
|
82
|
+
error: string | null;
|
|
83
|
+
/** Changes the server doesn't have yet (the offline note's "your last 3 changes"). */
|
|
84
|
+
unsent?: number;
|
|
85
|
+
/** Live mode, offline: when the retry timer fires next (ms since the epoch). Null otherwise. */
|
|
86
|
+
retryAt?: number | null;
|
|
87
|
+
/** Documents whose edits are held until the person decides. */
|
|
88
|
+
held?: readonly HeldView[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A file as the server has it now: its version and its text, and in live mode who last changed it. */
|
|
92
|
+
export interface FileNow {
|
|
93
|
+
version: string;
|
|
94
|
+
text: string;
|
|
95
|
+
lastChange?: LastChange;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What's held in the browser's storage for a document until the file has it. */
|
|
99
|
+
export interface StoredUnsent {
|
|
100
|
+
v: 1;
|
|
101
|
+
file: string;
|
|
102
|
+
base: string;
|
|
103
|
+
document: AnyDocument;
|
|
104
|
+
/** The version of the file these edits were made against (see above). */
|
|
105
|
+
fileVersion: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The parts of Storage the keeper uses (localStorage, or a fake in tests). */
|
|
109
|
+
export type KeeperStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem' | 'key' | 'length'>;
|
|
110
|
+
|
|
111
|
+
export interface KeeperOptions {
|
|
112
|
+
fetch: typeof fetch;
|
|
113
|
+
/** The block schemas, for running the serializer on load (the re-send rule above). */
|
|
114
|
+
site: KeeperSite;
|
|
115
|
+
/** null: no storage available (a private window that refuses it). */
|
|
116
|
+
storage: KeeperStorage | null;
|
|
117
|
+
/** Storage keys are `caracms:unsent:<storageKey>:<docId>`, so two sites on one dev port don't mix. */
|
|
118
|
+
storageKey: string;
|
|
119
|
+
/** ms after the last change. Default 1000. */
|
|
120
|
+
delay?: number;
|
|
121
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
122
|
+
clearTimer?: (timer: unknown) => void;
|
|
123
|
+
/** Errors go here too (default console.warn). */
|
|
124
|
+
log?: (message: string) => void;
|
|
125
|
+
/**
|
|
126
|
+
* Told whether any unsent edits are held, whenever that changes: the editor puts it
|
|
127
|
+
* in a cookie, so after a reload the server renders the bar as Keeping from the
|
|
128
|
+
* first paint instead of a Kept that isn't true yet (Decision 20's reason).
|
|
129
|
+
*/
|
|
130
|
+
onHeld?: (held: boolean) => void;
|
|
131
|
+
/** The state the bar starts in (the server rendered it from that cookie). Default 'kept'. */
|
|
132
|
+
initial?: KeepState;
|
|
133
|
+
|
|
134
|
+
// ── Live mode (M4) ───────────────────────────────────────────────────────────
|
|
135
|
+
/** The tab's id, sent as `X-Caracms-Tab` on every write. Absent: local mode. */
|
|
136
|
+
tab?: string | null;
|
|
137
|
+
/** The retry timer's delays while offline, in ms (the last repeats). Absent: no timer (M3). */
|
|
138
|
+
retryDelays?: readonly number[];
|
|
139
|
+
now?: () => number;
|
|
140
|
+
/** Files this tab holds the lock on (from the last poll): pagehide releases them. */
|
|
141
|
+
lockedFiles?: () => readonly string[];
|
|
142
|
+
/** A write got 409 `locked`: the editor shows the lock at once. */
|
|
143
|
+
onLocked?: (file: string, refusal: LockRefusal) => void;
|
|
144
|
+
/** A write got 401: the editor shows it's signed out. */
|
|
145
|
+
onSignedOut?: (body: SignedOutError) => void;
|
|
146
|
+
/** Who is signed in, for the re-send rule ("your own commits"). */
|
|
147
|
+
you?: () => Person | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A held record the editor may need to look at again: once the lock lapses, or the session is back. */
|
|
151
|
+
export interface Blocked {
|
|
152
|
+
id: string;
|
|
153
|
+
file: string;
|
|
154
|
+
failure: 'locked' | 'conflict' | 'signedout';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface Keeper {
|
|
158
|
+
readonly status: Store<KeepStatus>;
|
|
159
|
+
/** A document was opened: remember its file and the version it's at. */
|
|
160
|
+
opened(id: string, file: string, fileVersion: string): void;
|
|
161
|
+
/** The document changed: store it, and write it a second from now. */
|
|
162
|
+
change(id: string, edit: { file: string; base: string; document: AnyDocument }): void;
|
|
163
|
+
/** Write now whatever is waiting (one document, or all). */
|
|
164
|
+
flush(id?: string): Promise<void>;
|
|
165
|
+
/** Stored unsent edits for a document, if any. */
|
|
166
|
+
stored(id: string): StoredUnsent | null;
|
|
167
|
+
/**
|
|
168
|
+
* The re-send rule for a document being opened, with its file as it is now: its
|
|
169
|
+
* stored edits unless the file already holds them, else null (and that copy is
|
|
170
|
+
* dropped).
|
|
171
|
+
*/
|
|
172
|
+
recoverable(id: string, file: FileNow): StoredUnsent | null;
|
|
173
|
+
/** The same, saying whether they're to be sent (`send`) or held for the person (`by` changed it since). */
|
|
174
|
+
recover(id: string, file: FileNow): { stored: StoredUnsent; send: boolean; by: Person | null } | null;
|
|
175
|
+
/** Adopt stored edits and send them now (the bar shows Keeping until they land). */
|
|
176
|
+
resend(id: string, stored: StoredUnsent): void;
|
|
177
|
+
/** Adopt stored edits and hold them, unsent (another editor changed the document since). */
|
|
178
|
+
hold(id: string, stored: StoredUnsent, by: Person | null): void;
|
|
179
|
+
/**
|
|
180
|
+
* On load: every other document with stored edits, by the same rule. `fileOf` reads
|
|
181
|
+
* its file as it is now (null: it's gone, so its edits are kept but not sent; a
|
|
182
|
+
* rejection: the server couldn't be asked, so they wait for the next load).
|
|
183
|
+
*/
|
|
184
|
+
recoverOthers(exceptId: string, fileOf: (id: string) => Promise<FileNow | null>): Promise<void>;
|
|
185
|
+
/** pagehide: send whatever is waiting with keepalive requests. */
|
|
186
|
+
pagehide(): void;
|
|
187
|
+
/** Recovery on load is done: show the real state (Kept when nothing was waiting after all). */
|
|
188
|
+
settle(): void;
|
|
189
|
+
|
|
190
|
+
// ── Live mode (M4) ───────────────────────────────────────────────────────────
|
|
191
|
+
/** Try now: retry every write that couldn't reach the server, at once. */
|
|
192
|
+
tryNow(): void;
|
|
193
|
+
/** Use mine: send a document's held edits (the server merges them, Decision 6). */
|
|
194
|
+
useMine(id: string): void;
|
|
195
|
+
/** Discard mine: drop a document's held edits, from the tab and the browser's storage. */
|
|
196
|
+
discard(id: string): void;
|
|
197
|
+
/** Held records (locked, conflict, signed out) to look at again. */
|
|
198
|
+
blocked(): Blocked[];
|
|
199
|
+
/** Apply the re-send rule to a held record against the file as it is now (the lock lapsed; signed in again). */
|
|
200
|
+
recheck(id: string, file: FileNow): 'send' | 'drop' | 'conflict' | null;
|
|
201
|
+
/** Whether a document has changes the server doesn't have. */
|
|
202
|
+
dirty(id: string): boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type Failure = 'offline' | 'refused' | 'locked' | 'conflict' | 'signedout';
|
|
206
|
+
|
|
207
|
+
interface Record {
|
|
208
|
+
id: string;
|
|
209
|
+
file: string;
|
|
210
|
+
base: string;
|
|
211
|
+
document: AnyDocument | null;
|
|
212
|
+
fileVersion: string;
|
|
213
|
+
/** Bumped by every change. */
|
|
214
|
+
revision: number;
|
|
215
|
+
/** The revision the file is known to have. */
|
|
216
|
+
written: number;
|
|
217
|
+
inFlight: boolean;
|
|
218
|
+
/** How the last write failed, until one succeeds. */
|
|
219
|
+
failure: Failure | null;
|
|
220
|
+
/** The server's message when it refused the last write. */
|
|
221
|
+
refusal: string | null;
|
|
222
|
+
/** Held: who changed it or holds it, and whether it's your own other tab. */
|
|
223
|
+
by: Person | null;
|
|
224
|
+
elsewhere: boolean;
|
|
225
|
+
timer: unknown;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const PREFIX = 'caracms:unsent:';
|
|
229
|
+
|
|
230
|
+
/** Failures the next change or the retry timer don't retry: the person (or the session) decides. */
|
|
231
|
+
const WAITS: ReadonlySet<Failure> = new Set(['locked', 'conflict', 'signedout']);
|
|
232
|
+
|
|
233
|
+
/** A write the server answered with a 4xx: it was reached, and said no. */
|
|
234
|
+
class Refused extends Error {
|
|
235
|
+
readonly status: number;
|
|
236
|
+
readonly body: Record_<string, unknown>;
|
|
237
|
+
constructor(message: string, status: number, body: Record_<string, unknown>) {
|
|
238
|
+
super(message);
|
|
239
|
+
this.status = status;
|
|
240
|
+
this.body = body;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
type Record_<K extends string, V> = { [key in K]: V };
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What the keeper needs of the site: the block schemas, and (M5a) the collections, so a
|
|
247
|
+
* fields document's or Site Settings' edits are run through their own writer.
|
|
248
|
+
*/
|
|
249
|
+
export type KeeperSite = Pick<EditorSite, 'blocks'> & {
|
|
250
|
+
/** The editor's list, or a resolved site's record (tests). */
|
|
251
|
+
collections?: readonly KeeperCollection[] | { readonly [name: string]: KeeperCollection };
|
|
252
|
+
};
|
|
253
|
+
type KeeperCollection = { kind: 'sections' | 'fields' | 'shared' | 'settings'; fields: Fields | null };
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The collection a held document is written to, by its kind: a shared block's writer
|
|
257
|
+
* needs only the block schemas; a fields document's, Site Settings' and (M6c G2) a
|
|
258
|
+
* section builder's with page fields need their fields (the first collection of that
|
|
259
|
+
* kind whose fields match the document's `data` keys, else the first of that kind). A
|
|
260
|
+
* section builder's document without `data` has no page fields to write.
|
|
261
|
+
*/
|
|
262
|
+
function collectionFor(document: AnyDocument, site: KeeperSite): { kind: 'sections' | 'fields' | 'shared' | 'settings'; fields: Fields | null } {
|
|
263
|
+
const kind = documentKind(document);
|
|
264
|
+
const data = (document as { data?: { [key: string]: unknown } }).data;
|
|
265
|
+
if (kind === 'shared' || (kind === 'sections' && data === undefined)) return { kind, fields: null };
|
|
266
|
+
const candidates = Object.values(site.collections ?? {}).filter((c: KeeperCollection) => c.kind === kind && (kind !== 'sections' || c.fields));
|
|
267
|
+
const own = (keys: string[]) => keys.filter((k) => kind !== 'settings' || (k !== 'editors' && k !== 'brand')).sort().join('\n');
|
|
268
|
+
const keys = own(Object.keys(data ?? {}));
|
|
269
|
+
const match = candidates.find((c) => own(Object.keys(c.fields ?? {})) === keys) ?? candidates[0];
|
|
270
|
+
return { kind, fields: match?.fields ?? (kind === 'sections' ? null : {}) };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sameEmail = (a: Person | null | undefined, b: Person | null | undefined) => !!a && !!b && a.email.toLowerCase() === b.email.toLowerCase();
|
|
274
|
+
|
|
275
|
+
/** Whether the server's text is already what writing these edits would make it. */
|
|
276
|
+
export function holdsEdits(text: string, stored: Pick<StoredUnsent, 'base' | 'document'>, site: KeeperSite): boolean {
|
|
277
|
+
try {
|
|
278
|
+
const collection = collectionFor(stored.document, site);
|
|
279
|
+
return applyDocumentEdits(stored.base, normaliseAnyDocument(stored.document, site, collection), site, collection) === text;
|
|
280
|
+
} catch {
|
|
281
|
+
return false; // the edits don't fit their base: re-sent, and the server's refusal shows
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The re-send rule (see the header): `send`, `drop` (the server's text holds them), or
|
|
287
|
+
* `{ conflict: by }` (another editor changed the document since, Decision 11).
|
|
288
|
+
*/
|
|
289
|
+
export function verdict(
|
|
290
|
+
stored: Pick<StoredUnsent, 'base' | 'document' | 'fileVersion'>,
|
|
291
|
+
file: FileNow,
|
|
292
|
+
site: KeeperSite,
|
|
293
|
+
you: Person | null,
|
|
294
|
+
): 'send' | 'drop' | { conflict: Person | null } {
|
|
295
|
+
if (stored.fileVersion === file.version) return 'send'; // nothing has written it since
|
|
296
|
+
if (holdsEdits(file.text, stored, site)) return 'drop';
|
|
297
|
+
const last = file.lastChange;
|
|
298
|
+
if (!last) return 'send'; // local mode (M3): the last write wins
|
|
299
|
+
if (last.known === false) return { conflict: null }; // the server can't tell who changed it: never re-sent over unknown work
|
|
300
|
+
if (!last.by) return 'send'; // only merges from main are known
|
|
301
|
+
if (sameEmail(last.by, you)) return 'send'; // your own autosave or commit
|
|
302
|
+
if (last.version !== null && last.version === stored.fileVersion) return 'send'; // that change is what these edits were made on
|
|
303
|
+
return { conflict: last.by };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function createKeeper(options: KeeperOptions): Keeper {
|
|
307
|
+
const delay = options.delay ?? 1000;
|
|
308
|
+
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
309
|
+
const clearTimer = options.clearTimer ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
|
|
310
|
+
const log = options.log ?? ((message) => console.warn(`[caracms] ${message}`));
|
|
311
|
+
const now = options.now ?? Date.now;
|
|
312
|
+
const prefix = `${PREFIX}${options.storageKey}:`;
|
|
313
|
+
const records = new Map<string, Record>();
|
|
314
|
+
let offline = false;
|
|
315
|
+
let offlineError: string | null = null;
|
|
316
|
+
const status = createStore<KeepStatus>({ state: options.initial ?? 'kept', error: null, unsent: 0, retryAt: null, held: [] });
|
|
317
|
+
let held: boolean | null = null;
|
|
318
|
+
// The retry timer (live mode only, and only while offline).
|
|
319
|
+
let retryTimer: unknown = null;
|
|
320
|
+
let retryAt: number | null = null;
|
|
321
|
+
let retryStep = 0;
|
|
322
|
+
|
|
323
|
+
const storage = {
|
|
324
|
+
/** Tell onHeld when whether anything is held changes. */
|
|
325
|
+
check() {
|
|
326
|
+
const now = storage.ids().length > 0;
|
|
327
|
+
if (now !== held) {
|
|
328
|
+
held = now;
|
|
329
|
+
options.onHeld?.(now);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
get(id: string): StoredUnsent | null {
|
|
333
|
+
try {
|
|
334
|
+
const raw = options.storage?.getItem(prefix + id);
|
|
335
|
+
if (!raw) return null;
|
|
336
|
+
const value = JSON.parse(raw) as StoredUnsent;
|
|
337
|
+
return value && value.v === 1 && typeof value.base === 'string' && typeof value.fileVersion === 'string' && value.document ? value : null;
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
set(id: string, value: StoredUnsent) {
|
|
343
|
+
try {
|
|
344
|
+
options.storage?.setItem(prefix + id, JSON.stringify(value));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
log(`couldn't hold unsent edits in this browser's storage (${String(error)})`);
|
|
347
|
+
}
|
|
348
|
+
storage.check();
|
|
349
|
+
},
|
|
350
|
+
remove(id: string) {
|
|
351
|
+
try {
|
|
352
|
+
options.storage?.removeItem(prefix + id);
|
|
353
|
+
} catch {
|
|
354
|
+
/* nothing to do */
|
|
355
|
+
}
|
|
356
|
+
storage.check();
|
|
357
|
+
},
|
|
358
|
+
ids(): string[] {
|
|
359
|
+
const out: string[] = [];
|
|
360
|
+
try {
|
|
361
|
+
const s = options.storage;
|
|
362
|
+
if (!s) return out;
|
|
363
|
+
for (let i = 0; i < s.length; i++) {
|
|
364
|
+
const key = s.key(i);
|
|
365
|
+
if (key?.startsWith(prefix)) out.push(key.slice(prefix.length));
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
/* none */
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const dirty = (r: Record) => r.revision !== r.written;
|
|
375
|
+
let heldViews: readonly HeldView[] = [];
|
|
376
|
+
const refresh = () => {
|
|
377
|
+
const all = [...records.values()];
|
|
378
|
+
const busy = all.some((r) => dirty(r) || r.inFlight);
|
|
379
|
+
const refused = all.find((r) => r.failure === 'refused' && dirty(r));
|
|
380
|
+
const signedOut = all.some((r) => r.failure === 'signedout' && dirty(r));
|
|
381
|
+
const nextHeld = all
|
|
382
|
+
.filter((r) => dirty(r) && (r.failure === 'locked' || r.failure === 'conflict'))
|
|
383
|
+
.map((r): HeldView => ({ id: r.id, file: r.file, reason: r.failure as 'locked' | 'conflict', by: r.by, elsewhere: r.elsewhere }));
|
|
384
|
+
if (JSON.stringify(nextHeld) !== JSON.stringify(heldViews)) heldViews = nextHeld;
|
|
385
|
+
// Live mode's extras (the count shows only while offline or signed out); local mode's status is M3's.
|
|
386
|
+
const unsent = offline || signedOut ? all.reduce((n, r) => n + (dirty(r) ? r.revision - r.written : 0), 0) : 0;
|
|
387
|
+
const common = options.tab ? { unsent, retryAt, held: heldViews } : {};
|
|
388
|
+
if (offline) status.update({ state: 'offline', error: offlineError, ...common });
|
|
389
|
+
else if (signedOut) status.update({ state: 'signedout', error: null, ...common });
|
|
390
|
+
else if (refused) status.update({ state: 'refused', error: refused.refusal, ...common });
|
|
391
|
+
else if (heldViews.length) status.update({ state: 'held', error: null, ...common });
|
|
392
|
+
else status.update({ state: busy ? 'keeping' : 'kept', error: null, ...common });
|
|
393
|
+
};
|
|
394
|
+
const record = (id: string, file: string, fileVersion: string): Record => {
|
|
395
|
+
let r = records.get(id);
|
|
396
|
+
if (!r) {
|
|
397
|
+
r = { id, file, base: '', document: null, fileVersion, revision: 0, written: 0, inFlight: false, failure: null, refusal: null, by: null, elsewhere: false, timer: null };
|
|
398
|
+
records.set(id, r);
|
|
399
|
+
}
|
|
400
|
+
return r;
|
|
401
|
+
};
|
|
402
|
+
const stash = (r: Record) => {
|
|
403
|
+
if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion });
|
|
404
|
+
};
|
|
405
|
+
const schedule = (r: Record, ms = delay) => {
|
|
406
|
+
if (r.timer !== null) clearTimer(r.timer);
|
|
407
|
+
r.timer = setTimer(() => {
|
|
408
|
+
r.timer = null;
|
|
409
|
+
void send(r);
|
|
410
|
+
}, ms);
|
|
411
|
+
};
|
|
412
|
+
/** Live mode: each write's place in this tab's order (from the clock, so a reload keeps rising). */
|
|
413
|
+
let writeSeq = 0;
|
|
414
|
+
const headers = (release = false, write = false): Record_<string, string> => {
|
|
415
|
+
const h: Record_<string, string> = { 'Content-Type': 'application/json' };
|
|
416
|
+
if (options.tab) h[TAB_HEADER] = options.tab;
|
|
417
|
+
if (options.tab && release) h[RELEASE_HEADER] = '1';
|
|
418
|
+
if (options.tab && write) h[SEQ_HEADER] = String((writeSeq = Math.max(writeSeq + 1, Math.floor(Date.now()))));
|
|
419
|
+
return h;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const stopRetry = () => {
|
|
423
|
+
if (retryTimer !== null) clearTimer(retryTimer);
|
|
424
|
+
retryTimer = null;
|
|
425
|
+
retryAt = null;
|
|
426
|
+
retryStep = 0;
|
|
427
|
+
};
|
|
428
|
+
const retryAll = () => {
|
|
429
|
+
for (const r of records.values()) if (r.failure === 'offline' && dirty(r)) void send(r);
|
|
430
|
+
};
|
|
431
|
+
/** Live mode: while offline, try again later, backing off. */
|
|
432
|
+
const scheduleRetry = () => {
|
|
433
|
+
const delays = options.retryDelays;
|
|
434
|
+
if (!delays?.length || retryTimer !== null) return;
|
|
435
|
+
const ms = delays[Math.min(retryStep, delays.length - 1)]!;
|
|
436
|
+
retryStep += 1;
|
|
437
|
+
retryAt = now() + ms;
|
|
438
|
+
retryTimer = setTimer(() => {
|
|
439
|
+
retryTimer = null;
|
|
440
|
+
retryAt = null;
|
|
441
|
+
retryAll();
|
|
442
|
+
refresh();
|
|
443
|
+
}, ms);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
async function send(r: Record): Promise<void> {
|
|
447
|
+
if (!dirty(r) || !r.document) return;
|
|
448
|
+
if (r.inFlight) return; // the change waits; it's sent when this write returns
|
|
449
|
+
const revision = r.revision;
|
|
450
|
+
r.inFlight = true;
|
|
451
|
+
refresh();
|
|
452
|
+
let ok = false;
|
|
453
|
+
try {
|
|
454
|
+
const response = await options.fetch(ENDPOINTS.write, {
|
|
455
|
+
method: 'POST',
|
|
456
|
+
headers: headers(false, true),
|
|
457
|
+
body: JSON.stringify({ file: r.file, base: r.base, document: r.document }),
|
|
458
|
+
});
|
|
459
|
+
const body = (await response.json().catch(() => ({}))) as { error?: string; version?: string };
|
|
460
|
+
if (response.status >= 400 && response.status < 500) {
|
|
461
|
+
throw new Refused(typeof body.error === 'string' && body.error ? body.error : `The server refused the write (${response.status}).`, response.status, body);
|
|
462
|
+
}
|
|
463
|
+
if (!response.ok) throw new Error(body.error ?? `the write route answered ${response.status}`);
|
|
464
|
+
ok = true;
|
|
465
|
+
offline = false;
|
|
466
|
+
offlineError = null;
|
|
467
|
+
r.failure = null;
|
|
468
|
+
r.refusal = null;
|
|
469
|
+
r.by = null;
|
|
470
|
+
r.elsewhere = false;
|
|
471
|
+
if (typeof body.version === 'string') r.fileVersion = body.version;
|
|
472
|
+
r.written = Math.max(r.written, revision);
|
|
473
|
+
if (dirty(r)) stash(r); // newer edits wait: they're now against the version just written
|
|
474
|
+
else storage.remove(r.id);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
477
|
+
if (error instanceof Refused) {
|
|
478
|
+
// The server answered, so it isn't unreachable; the edits stay held.
|
|
479
|
+
offline = false;
|
|
480
|
+
offlineError = null;
|
|
481
|
+
const code = (error.body as { code?: unknown }).code;
|
|
482
|
+
if (options.tab && error.status === 401) {
|
|
483
|
+
r.failure = 'signedout';
|
|
484
|
+
r.refusal = null;
|
|
485
|
+
log(`signed out: ${r.file} is held in this tab`);
|
|
486
|
+
options.onSignedOut?.(error.body as unknown as SignedOutError);
|
|
487
|
+
} else if (options.tab && error.status === 409 && code === 'locked') {
|
|
488
|
+
const refusal = error.body as unknown as LockRefusal;
|
|
489
|
+
r.failure = 'locked';
|
|
490
|
+
r.refusal = message;
|
|
491
|
+
r.elsewhere = refusal.state === 'yours-elsewhere';
|
|
492
|
+
r.by = refusal.lastChange?.by ?? refusal.lock?.person ?? null;
|
|
493
|
+
log(`${r.file} is locked: ${message}`);
|
|
494
|
+
options.onLocked?.(r.file, refusal);
|
|
495
|
+
} else {
|
|
496
|
+
r.failure = 'refused';
|
|
497
|
+
r.refusal = message;
|
|
498
|
+
log(`the server refused to keep ${r.file}: ${message}`);
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
offline = true;
|
|
502
|
+
offlineError = message;
|
|
503
|
+
r.failure = 'offline';
|
|
504
|
+
log(`couldn't keep ${r.file}: ${message}`);
|
|
505
|
+
}
|
|
506
|
+
} finally {
|
|
507
|
+
r.inFlight = false;
|
|
508
|
+
if (ok && !offline) {
|
|
509
|
+
stopRetry();
|
|
510
|
+
// Live mode: the server is back, so whatever else couldn't reach it goes now.
|
|
511
|
+
if (options.retryDelays) for (const other of records.values()) if (other !== r && other.failure === 'offline' && dirty(other)) schedule(other, 0);
|
|
512
|
+
}
|
|
513
|
+
if (offline) scheduleRetry();
|
|
514
|
+
refresh();
|
|
515
|
+
}
|
|
516
|
+
// A change that came in during the write goes out now, unless its own timer is still running.
|
|
517
|
+
if (ok && dirty(r) && r.timer === null && !r.failure) void send(r);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function adopt(id: string, stored: StoredUnsent): Record {
|
|
521
|
+
const r = record(id, stored.file, stored.fileVersion);
|
|
522
|
+
r.file = stored.file;
|
|
523
|
+
r.base = stored.base;
|
|
524
|
+
r.document = stored.document;
|
|
525
|
+
r.fileVersion = stored.fileVersion;
|
|
526
|
+
r.revision += 1;
|
|
527
|
+
return r;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function resend(id: string, stored: StoredUnsent) {
|
|
531
|
+
const r = adopt(id, stored);
|
|
532
|
+
r.failure = null;
|
|
533
|
+
refresh();
|
|
534
|
+
schedule(r, 0);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function recover(id: string, file: FileNow) {
|
|
538
|
+
const stored = storage.get(id);
|
|
539
|
+
if (!stored) return null;
|
|
540
|
+
const v = verdict(stored, file, options.site, options.you?.() ?? null);
|
|
541
|
+
if (v === 'drop') {
|
|
542
|
+
storage.remove(id); // the server's text already holds them
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
return v === 'send' ? { stored, send: true, by: null } : { stored, send: false, by: v.conflict };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
status,
|
|
550
|
+
opened(id, file, fileVersion) {
|
|
551
|
+
const r = record(id, file, fileVersion);
|
|
552
|
+
if (!dirty(r) && !r.inFlight) {
|
|
553
|
+
r.file = file;
|
|
554
|
+
r.fileVersion = fileVersion;
|
|
555
|
+
r.document = null;
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
change(id, edit) {
|
|
559
|
+
const r = record(id, edit.file, '');
|
|
560
|
+
r.file = edit.file;
|
|
561
|
+
r.base = edit.base;
|
|
562
|
+
r.document = edit.document;
|
|
563
|
+
r.revision += 1;
|
|
564
|
+
stash(r);
|
|
565
|
+
if (!r.failure || !WAITS.has(r.failure)) schedule(r);
|
|
566
|
+
// After a failed write (unreachable or refused), the next change retries everything waiting.
|
|
567
|
+
for (const other of records.values()) if (other !== r && other.failure && !WAITS.has(other.failure) && dirty(other)) schedule(other, 0);
|
|
568
|
+
refresh();
|
|
569
|
+
},
|
|
570
|
+
async flush(id) {
|
|
571
|
+
const targets = id ? [records.get(id)].filter((r): r is Record => !!r) : [...records.values()];
|
|
572
|
+
await Promise.all(
|
|
573
|
+
targets.map((r) => {
|
|
574
|
+
if (r.failure && WAITS.has(r.failure)) return undefined;
|
|
575
|
+
if (r.timer !== null) {
|
|
576
|
+
clearTimer(r.timer);
|
|
577
|
+
r.timer = null;
|
|
578
|
+
}
|
|
579
|
+
return send(r);
|
|
580
|
+
}),
|
|
581
|
+
);
|
|
582
|
+
},
|
|
583
|
+
stored: (id) => storage.get(id),
|
|
584
|
+
recoverable: (id, file) => recover(id, file)?.stored ?? null,
|
|
585
|
+
recover,
|
|
586
|
+
resend,
|
|
587
|
+
hold(id, stored, by) {
|
|
588
|
+
const r = adopt(id, stored);
|
|
589
|
+
r.failure = 'conflict';
|
|
590
|
+
r.by = by;
|
|
591
|
+
r.elsewhere = false;
|
|
592
|
+
refresh();
|
|
593
|
+
},
|
|
594
|
+
async recoverOthers(exceptId, fileOf) {
|
|
595
|
+
for (const id of storage.ids()) {
|
|
596
|
+
if (id === exceptId || records.has(id)) continue;
|
|
597
|
+
const stored = storage.get(id);
|
|
598
|
+
if (!stored) continue;
|
|
599
|
+
const file = await fileOf(id).catch(() => undefined);
|
|
600
|
+
if (file === undefined) continue; // couldn't ask (the server is away): leave them for the next load
|
|
601
|
+
if (file === null) {
|
|
602
|
+
log(`unsent edits for ${stored.file} are kept in this browser, but the document is gone, so they weren't sent`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const v = verdict(stored, file, options.site, options.you?.() ?? null);
|
|
606
|
+
if (v === 'send') resend(id, stored);
|
|
607
|
+
else if (v === 'drop') storage.remove(id); // the file already holds them
|
|
608
|
+
// A conflict stays in storage, unsent: opening that document shows it (Decision 11).
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
settle() {
|
|
612
|
+
storage.check();
|
|
613
|
+
refresh();
|
|
614
|
+
},
|
|
615
|
+
pagehide() {
|
|
616
|
+
const written = new Set<string>();
|
|
617
|
+
for (const r of records.values()) {
|
|
618
|
+
if (!dirty(r) || !r.document) continue;
|
|
619
|
+
stash(r);
|
|
620
|
+
if (r.failure && WAITS.has(r.failure)) continue; // it would be refused; it's re-sent from storage later
|
|
621
|
+
written.add(r.file);
|
|
622
|
+
try {
|
|
623
|
+
void options
|
|
624
|
+
.fetch(ENDPOINTS.write, {
|
|
625
|
+
method: 'POST',
|
|
626
|
+
keepalive: true,
|
|
627
|
+
// Live mode: the last write releases the lock once its autosave is committed (Decision 9).
|
|
628
|
+
headers: headers(true, true),
|
|
629
|
+
body: JSON.stringify({ file: r.file, base: r.base, document: r.document }),
|
|
630
|
+
})
|
|
631
|
+
.catch(() => {});
|
|
632
|
+
} catch {
|
|
633
|
+
/* the stored copy is re-sent on the next load */
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// Live mode: a lock with nothing unsent is released on its own.
|
|
637
|
+
if (!options.tab) return;
|
|
638
|
+
for (const file of options.lockedFiles?.() ?? []) {
|
|
639
|
+
if (written.has(file)) continue;
|
|
640
|
+
try {
|
|
641
|
+
void options
|
|
642
|
+
.fetch(ENDPOINTS.lock, { method: 'POST', keepalive: true, headers: headers(), body: JSON.stringify({ file, action: 'release' }) })
|
|
643
|
+
.catch(() => {});
|
|
644
|
+
} catch {
|
|
645
|
+
/* it lapses on its own */
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
tryNow() {
|
|
650
|
+
stopRetry();
|
|
651
|
+
retryAll();
|
|
652
|
+
refresh();
|
|
653
|
+
},
|
|
654
|
+
useMine(id) {
|
|
655
|
+
const r = records.get(id);
|
|
656
|
+
if (!r || !dirty(r)) return;
|
|
657
|
+
r.failure = null;
|
|
658
|
+
r.by = null;
|
|
659
|
+
r.elsewhere = false;
|
|
660
|
+
refresh();
|
|
661
|
+
schedule(r, 0);
|
|
662
|
+
},
|
|
663
|
+
discard(id) {
|
|
664
|
+
const r = records.get(id);
|
|
665
|
+
if (r) {
|
|
666
|
+
if (r.timer !== null) clearTimer(r.timer);
|
|
667
|
+
r.timer = null;
|
|
668
|
+
r.written = r.revision;
|
|
669
|
+
r.document = null;
|
|
670
|
+
r.failure = null;
|
|
671
|
+
r.by = null;
|
|
672
|
+
r.elsewhere = false;
|
|
673
|
+
}
|
|
674
|
+
storage.remove(id);
|
|
675
|
+
refresh();
|
|
676
|
+
},
|
|
677
|
+
blocked() {
|
|
678
|
+
return [...records.values()]
|
|
679
|
+
.filter((r) => dirty(r) && r.failure && WAITS.has(r.failure))
|
|
680
|
+
.map((r) => ({ id: r.id, file: r.file, failure: r.failure as Blocked['failure'] }));
|
|
681
|
+
},
|
|
682
|
+
recheck(id, file) {
|
|
683
|
+
const r = records.get(id);
|
|
684
|
+
if (!r || !dirty(r) || !r.document) return null;
|
|
685
|
+
const v = verdict({ base: r.base, document: r.document, fileVersion: r.fileVersion }, file, options.site, options.you?.() ?? null);
|
|
686
|
+
if (v === 'drop') {
|
|
687
|
+
r.written = r.revision;
|
|
688
|
+
r.failure = null;
|
|
689
|
+
r.by = null;
|
|
690
|
+
storage.remove(id);
|
|
691
|
+
refresh();
|
|
692
|
+
return 'drop';
|
|
693
|
+
}
|
|
694
|
+
if (v === 'send') {
|
|
695
|
+
r.failure = null;
|
|
696
|
+
r.by = null;
|
|
697
|
+
r.elsewhere = false;
|
|
698
|
+
refresh();
|
|
699
|
+
schedule(r, 0);
|
|
700
|
+
return 'send';
|
|
701
|
+
}
|
|
702
|
+
r.failure = 'conflict';
|
|
703
|
+
r.by = v.conflict;
|
|
704
|
+
r.elsewhere = false;
|
|
705
|
+
refresh();
|
|
706
|
+
return 'conflict';
|
|
707
|
+
},
|
|
708
|
+
dirty: (id) => {
|
|
709
|
+
const r = records.get(id);
|
|
710
|
+
return !!r && (dirty(r) || r.inFlight);
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|