@panphora/clayjs 1.24.1 → 1.25.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/dist/all.js +1 -1
- package/dist/clay-dom.js +1 -1
- package/dist/clay-events.js +1 -1
- package/dist/clay-internals.js +1 -1
- package/dist/clay-options.js +1 -1
- package/dist/clay-router.js +1 -1
- package/dist/clay-ui.js +1 -1
- package/dist/clay-utils.js +1 -1
- package/dist/clay.js +1 -1
- package/dist/clay.standalone.js +2044 -1247
- package/dist/m/attrs/{refetch-on-save-DPGJJMGN.js → refetch-on-save-5CBFUJUF.js} +1 -1
- package/dist/m/attrs/save-freeze-MH3AOD56.js +1 -0
- package/dist/m/{c-SXGEWPFC.js → c-3E2IK5HU.js} +1 -1
- package/dist/m/{c-A4L7APIU.js → c-4AE2A2LK.js} +1 -1
- package/dist/m/{c-YDW4VCHP.js → c-4Q6QIODW.js} +1 -1
- package/dist/m/{c-RO73MKKR.js → c-52OC3OH3.js} +1 -1
- package/dist/m/{c-ZGCO4J3H.js → c-5PZYDWTW.js} +1 -1
- package/dist/m/c-5Q5XBN56.js +1 -0
- package/dist/m/c-5SZ6KFR5.js +1 -0
- package/dist/m/{c-ZRGEN6QQ.js → c-AI3H6ETU.js} +2 -2
- package/dist/m/{c-7V6MGMX2.js → c-BCWYEG6P.js} +1 -1
- package/dist/m/{c-C5AKJVRH.js → c-BJ2RRQBQ.js} +1 -1
- package/dist/m/{c-CTGRDT6E.js → c-C6YZNU6E.js} +1 -1
- package/dist/m/{c-EDE45NM7.js → c-C7SUMNMI.js} +1 -1
- package/dist/m/c-CITLTVQ4.js +4 -0
- package/dist/m/{c-STRI73GQ.js → c-CL5LSATX.js} +1 -1
- package/dist/m/{c-Z4G2UK6T.js → c-CU33V6RW.js} +1 -1
- package/dist/m/{c-6LEYVQJK.js → c-D62HRSZK.js} +1 -1
- package/dist/m/{c-DD26VXAP.js → c-DT7E76Z2.js} +1 -1
- package/dist/m/{c-P2HKMJAW.js → c-EAV7A6BT.js} +1 -1
- package/dist/m/{c-QQFHEODI.js → c-EQ5NBOGA.js} +1 -1
- package/dist/m/{c-KQKKIVLW.js → c-FBOO2UY3.js} +1 -1
- package/dist/m/{c-JQF2EUCG.js → c-FJDTDGDA.js} +1 -1
- package/dist/m/{c-MQKSKQHG.js → c-FKTALXNV.js} +1 -1
- package/dist/m/{c-HL6BLJNA.js → c-GDNZEOOZ.js} +1 -1
- package/dist/m/{c-SUXHUL7U.js → c-GEAG5XAK.js} +1 -1
- package/dist/m/{c-LSVN3OLG.js → c-GY4BREDT.js} +1 -1
- package/dist/m/{c-4PO3YGDC.js → c-HGG7M2S3.js} +1 -1
- package/dist/m/{c-DDS23EY6.js → c-HXGNH6GY.js} +1 -1
- package/dist/m/{c-FLEIU6CG.js → c-IESDKFO6.js} +1 -1
- package/dist/m/{c-CJ7QYEWI.js → c-IJHF4IO6.js} +1 -1
- package/dist/m/{c-SGGGCIVD.js → c-IL5IVTOG.js} +1 -1
- package/dist/m/c-JB5YU57S.js +1 -0
- package/dist/m/{c-MP6S4AZU.js → c-JD2VFAYG.js} +2 -2
- package/dist/m/{c-CV4T65JE.js → c-JWW6BHWD.js} +3 -3
- package/dist/m/{c-42ZNV7LI.js → c-K4M76VPM.js} +1 -1
- package/dist/m/{c-6IJEVE6B.js → c-KU3PX7LL.js} +1 -1
- package/dist/m/{c-2PVGJFI5.js → c-KYTVZT4D.js} +1 -1
- package/dist/m/{c-BGN7RCTJ.js → c-M7ITYKMF.js} +1 -1
- package/dist/m/c-MFB74XVN.js +3 -0
- package/dist/m/c-MOP5KBWZ.js +1 -0
- package/dist/m/{c-YLHTD3KX.js → c-MUM53J5J.js} +1 -1
- package/dist/m/{c-YK2L436K.js → c-MV7TEUTD.js} +1 -1
- package/dist/m/{c-LA2BW7UY.js → c-MZCOGNZL.js} +1 -1
- package/dist/m/{c-XW5TIYYB.js → c-N4GEJMXW.js} +1 -1
- package/dist/m/{c-NQAPWM7O.js → c-NADLYVAH.js} +1 -1
- package/dist/m/{c-RS77ODLY.js → c-NL4I4KNG.js} +1 -1
- package/dist/m/{c-X75G43SZ.js → c-O2QZDVZ2.js} +1 -1
- package/dist/m/{c-LTSPHVAK.js → c-OBMZASYQ.js} +1 -1
- package/dist/m/{c-LUXAB2XI.js → c-ORLPQFNU.js} +1 -1
- package/dist/m/c-P73XPAOA.js +1 -0
- package/dist/m/{c-PWJSJEJU.js → c-PNZIJFG7.js} +1 -1
- package/dist/m/{c-ZKKAF4UT.js → c-PRZNJUYZ.js} +2 -2
- package/dist/m/{c-B7GOFGQR.js → c-Q2CB57UG.js} +1 -1
- package/dist/m/{c-TWCXBWS3.js → c-QCDVDZIR.js} +1 -1
- package/dist/m/{c-UOOIF2AL.js → c-QDVG4NAZ.js} +1 -1
- package/dist/m/c-QWB53KRW.js +1 -0
- package/dist/m/{c-GNI7WHKF.js → c-RGAALCDU.js} +1 -1
- package/dist/m/c-RHPHMTF2.js +2 -0
- package/dist/m/{c-3UW5EJ77.js → c-RWJ3OSCP.js} +1 -1
- package/dist/m/{c-5FXEKU4Y.js → c-SEVOS4UE.js} +1 -1
- package/dist/m/{c-HDVH3MVC.js → c-SFYTKRU7.js} +1 -1
- package/dist/m/{c-47IBJGPQ.js → c-SFZRY6RT.js} +1 -1
- package/dist/m/{c-QENKN7QI.js → c-SGG6MUXW.js} +1 -1
- package/dist/m/c-SMLAJASY.js +1 -0
- package/dist/m/{c-DFKBGSN2.js → c-SUAC5RSG.js} +1 -1
- package/dist/m/{c-6RGEQNVV.js → c-SXZYVXD6.js} +1 -1
- package/dist/m/{c-ZFO5RW6G.js → c-TCFVG3I4.js} +1 -1
- package/dist/m/{c-OCYG4NBQ.js → c-TLQINES7.js} +2 -2
- package/dist/m/c-U4TWEIKZ.js +1 -0
- package/dist/m/{c-MQYEDF3Q.js → c-U65PREMC.js} +1 -1
- package/dist/m/{c-PDBNCJCT.js → c-U7F66I64.js} +1 -1
- package/dist/m/{c-LATNQ6Q5.js → c-UD4VDBGJ.js} +1 -1
- package/dist/m/{c-PCNJETUA.js → c-V2FPBRO6.js} +1 -1
- package/dist/m/{c-ND3Q2G2I.js → c-V4B3RUWB.js} +1 -1
- package/dist/m/{c-JWDCQL6J.js → c-V5L6HWVL.js} +1 -1
- package/dist/m/c-VMXCX52Q.js +1 -0
- package/dist/m/{c-CZHTXGJS.js → c-VO6RSFCO.js} +1 -1
- package/dist/m/c-WDFQ7PBU.js +1 -0
- package/dist/m/{c-OG6GFSUX.js → c-WGQVSKKC.js} +1 -1
- package/dist/m/c-WSTUCBHV.js +1 -0
- package/dist/m/{c-FEC6LGFF.js → c-WVCT3ITX.js} +1 -1
- package/dist/m/{c-SE3SIY7Y.js → c-X3V4RWMA.js} +1 -1
- package/dist/m/{c-HIX5H5L7.js → c-XHZIKRCV.js} +1 -1
- package/dist/m/{c-2BLVA6IN.js → c-XQTERKQA.js} +1 -1
- package/dist/m/{c-PYG2NGWU.js → c-YFO6ZJLQ.js} +1 -1
- package/dist/m/{c-7CM4GMTS.js → c-YX2MC7YL.js} +1 -1
- package/dist/m/{c-OYHI67B6.js → c-YYGUHSZR.js} +1 -1
- package/dist/m/{c-YYMZWQLR.js → c-Z2OZFX3N.js} +1 -1
- package/dist/m/{c-AT7GLQGV.js → c-ZEA3DCG3.js} +1 -1
- package/dist/m/{c-T3P3AXEW.js → c-ZJG6C2WL.js} +1 -1
- package/dist/m/{c-FVPNX2GO.js → c-ZORVIXUR.js} +1 -1
- package/dist/m/{c-SKMIR5HQ.js → c-ZPI7M5U5.js} +1 -1
- package/dist/m/core/{admin-attrs-ONRWVXPK.js → admin-attrs-ZI6K6UGM.js} +1 -1
- package/dist/m/core/{autosave-TBKAAAFB.js → autosave-BSM2ABJL.js} +1 -1
- package/dist/m/core/{conflict-notice-FOEE6I7E.js → conflict-notice-7EQLDYPA.js} +1 -1
- package/dist/m/core/draft-mode-M5XNYBMY.js +1 -0
- package/dist/m/core/{host-permission-X4BENZEU.js → host-permission-5BYP24GC.js} +1 -1
- package/dist/m/core/{is-edit-mode-FFHCQ5DR.js → is-edit-mode-73OXKK5H.js} +1 -1
- package/dist/m/core/{persist-6ILUR34S.js → persist-2SZO3PCE.js} +1 -1
- package/dist/m/core/{recovery-APWADU4K.js → recovery-VZF7RJZF.js} +1 -1
- package/dist/m/core/{save-6HT2LABE.js → save-K6Q47J2X.js} +1 -1
- package/dist/m/core/{save-core-EVMNZQ4V.js → save-core-U3Z44XBO.js} +1 -1
- package/dist/m/core/{site-edits-G4BOZMV4.js → site-edits-CFMF6GHD.js} +1 -1
- package/dist/m/core/{snapshot-FJP5IJL3.js → snapshot-PWCHYFL6.js} +1 -1
- package/dist/m/core/unsaved-warning-2WRZLJH5.js +1 -0
- package/dist/m/events/{index-B6TGDYMG.js → index-YKQIBZE5.js} +1 -1
- package/dist/m/internals/index-FSLC4M7J.js +1 -0
- package/dist/m/lib/{cache-bust-NH7VBWHO.js → cache-bust-BL4KUQAJ.js} +1 -1
- package/dist/m/{loader-GLLVDUZA.js → loader-RC54UV7H.js} +1 -1
- package/dist/m/manifest-3B84C829.js +1 -0
- package/dist/m/manifest-edit-1CB79AD4.js +1 -0
- package/dist/m/manifest.json +1165 -1115
- package/dist/m/options/{options-55LQHSJ4.js → options-LKIZ7YTG.js} +1 -1
- package/dist/m/page/{editor-KAZDJNND.js → editor-PKSPJ2XJ.js} +2 -2
- package/dist/m/plugins/{ai-edit-LABECCSN.js → ai-edit-3LG6MGS3.js} +1 -1
- package/dist/m/plugins/{demo-T7B4RIKL.js → demo-LMCMBILC.js} +1 -1
- package/dist/m/plugins/{derive-6LPJOCAV.js → derive-7DR5TUDH.js} +1 -1
- package/dist/m/plugins/{indicator-J4ABFARX.js → indicator-CTKX4EY7.js} +1 -1
- package/dist/m/plugins/{live-PGBIEMGB.js → live-IFLL6YN5.js} +1 -1
- package/dist/m/plugins/{menu-YZL3X2AJ.js → menu-YJZ3JWBC.js} +1 -1
- package/dist/m/plugins/{page-WOCNX2IN.js → page-GTDPYGE4.js} +2 -2
- package/dist/m/plugins/{page-view-SJODOOGE.js → page-view-NMCCU6MF.js} +1 -1
- package/dist/m/plugins/{people-C4TCWFOQ.js → people-6WIWCZEG.js} +1 -1
- package/dist/m/plugins/{richclay-Y2NV44NA.js → richclay-L3BZMX4I.js} +2 -2
- package/dist/m/plugins/{sortable-XCHQG65Z.js → sortable-365SRRL3.js} +1 -1
- package/dist/m/plugins/{source-YK7MO23W.js → source-7LSGOV46.js} +1 -1
- package/dist/m/plugins/{undo-VLRBQNPN.js → undo-UES4KS7B.js} +1 -1
- package/dist/m/plugins/{upgrade-NNTJBSK2.js → upgrade-MMPZUCDM.js} +1 -1
- package/dist/m/plugins/{upload-ZKKJV76X.js → upload-NZET3DNQ.js} +1 -1
- package/dist/m/plugins/visitor-drafts-V5YB7XDH.js +20 -0
- package/dist/m/plugins/{visitor-drafts-owner-LHUHFU3L.js → visitor-drafts-owner-TLECTYWS.js} +1 -1
- package/dist/m/plugins/{visitor-menu-FY4KM2SW.js → visitor-menu-QDV2MYJN.js} +1 -1
- package/dist/m/plugins/wire-3YGUO3GE.js +1 -0
- package/dist/m/{reader-core-T2S3JC4E.js → reader-core-VR7EL4WV.js} +2 -2
- package/dist/m/sync/live-sync-XMXXZS3G.js +1 -0
- package/dist/m/vendor/sap-core.vendor-JANUQULT.js +7 -0
- package/dist/offline-manifest.json +665 -635
- package/docs/guide.md +1 -0
- package/entries/sap.js +6 -5
- package/package.json +1 -1
- package/src/core/draft-mode.js +5 -0
- package/src/core/save-core.js +10 -10
- package/src/core/save.js +1 -1
- package/src/lib/dirty-gate.js +19 -0
- package/src/plugins/menu/visitor.js +33 -1
- package/src/plugins/visitor-drafts/commit.js +50 -3
- package/src/plugins/visitor-drafts/idb.js +8 -2
- package/src/plugins/visitor-drafts/recovery-menu.js +118 -0
- package/src/plugins/visitor-drafts/recovery.js +64 -0
- package/src/plugins/visitor-drafts/session.js +61 -13
- package/src/plugins/visitor-drafts/store.js +44 -8
- package/src/plugins/visitor-drafts.js +34 -5
- package/src/sync/live-sync.js +1 -1
- package/src/sync/wire-profile.js +9 -4
- package/src/vendor/sap-core.vendor.js +6 -5
- package/src/vendor/sap-ownership.vendor.js +181 -11
- package/tool-generation/system.md +1 -1
- package/dist/m/attrs/save-freeze-JZHKRQLR.js +0 -1
- package/dist/m/c-6Q6RGFBF.js +0 -1
- package/dist/m/c-APGHGDZM.js +0 -1
- package/dist/m/c-EILLN2H2.js +0 -1
- package/dist/m/c-HTLYXCUH.js +0 -1
- package/dist/m/c-MFOSMAVM.js +0 -3
- package/dist/m/c-R23UHMXG.js +0 -1
- package/dist/m/c-SGH3UTF3.js +0 -1
- package/dist/m/c-U4NTUVDZ.js +0 -1
- package/dist/m/c-V2U3F4QX.js +0 -1
- package/dist/m/c-VG5XIN4T.js +0 -1
- package/dist/m/c-VHSMPITY.js +0 -1
- package/dist/m/core/draft-mode-4MCL3LYI.js +0 -1
- package/dist/m/core/unsaved-warning-ITZDIZ2I.js +0 -1
- package/dist/m/internals/index-HS3CQDFW.js +0 -1
- package/dist/m/manifest-50FF1C45.js +0 -1
- package/dist/m/manifest-edit-BE1A621D.js +0 -1
- package/dist/m/plugins/visitor-drafts-RTZNONHT.js +0 -20
- package/dist/m/plugins/wire-M2NR354P.js +0 -1
- package/dist/m/sync/live-sync-Z64YREWQ.js +0 -1
- package/dist/m/vendor/sap-core.vendor-QAEPMETX.js +0 -6
- /package/dist/m/plugins/{richclay-Y2NV44NA.js.LEGAL.txt → richclay-L3BZMX4I.js.LEGAL.txt} +0 -0
package/src/lib/dirty-gate.js
CHANGED
|
@@ -29,6 +29,7 @@ import Mutation from './mutation.js';
|
|
|
29
29
|
import { isEditMode } from '../core/is-edit-mode.js';
|
|
30
30
|
import { STRIP_FROM_DIRTY_CHECK, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
|
|
31
31
|
import { PERSIST } from './attr-aliases.js';
|
|
32
|
+
import { setDraftGate } from '../core/draft-mode.js';
|
|
32
33
|
|
|
33
34
|
let changes = 0;
|
|
34
35
|
let clearedAt = 0;
|
|
@@ -121,6 +122,7 @@ export function startDirtyGate() {
|
|
|
121
122
|
);
|
|
122
123
|
document.addEventListener('input', onUserInput, true);
|
|
123
124
|
document.addEventListener('change', onUserInput, true);
|
|
125
|
+
setDraftGate({ token: gateTokenNow, unchangedSince: gateUnchangedSince });
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
/** The morph-apply window: nothing that happens inside it is a user edit. */
|
|
@@ -210,6 +212,23 @@ export function gateCaptureToken() {
|
|
|
210
212
|
return { gen: changes, probe };
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
/** A token for the page as it is now: what the browser has queued is counted first. */
|
|
216
|
+
export function gateTokenNow() {
|
|
217
|
+
Mutation._deliverBeforePause();
|
|
218
|
+
return gateCaptureToken();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* True when nothing was recorded since the token was taken and every [persist] control
|
|
223
|
+
* still holds what it held then. Records the browser has queued are delivered first, so
|
|
224
|
+
* the answer holds in the task that made a change too.
|
|
225
|
+
*/
|
|
226
|
+
export function gateUnchangedSince(token) {
|
|
227
|
+
if (!token) return false;
|
|
228
|
+
Mutation._deliverBeforePause();
|
|
229
|
+
return changes === token.gen && token.probe.every(([el, sig]) => controlSignature(el) === sig);
|
|
230
|
+
}
|
|
231
|
+
|
|
213
232
|
/** A change nothing in the DOM recorded: the page holds text a save has not written. */
|
|
214
233
|
export function gateMarkDirty() {
|
|
215
234
|
changes++;
|
|
@@ -72,17 +72,49 @@ function copyUrl(loc = window.location) {
|
|
|
72
72
|
return `/_/copy${path === "/" ? "" : path}`;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Hyperclay's trial bar, once the visitor closes it, leaves its Host action and a way back to the
|
|
76
|
+
// page (hyperclay server-lib/drop/trial-bar.js): `window.hyperclayBar` holds its state, set before
|
|
77
|
+
// this menu loads, and `hyperclay:bar` reports each change. Download copy is already in the menu.
|
|
78
|
+
function attachBar({ addItem, removeItem }) {
|
|
79
|
+
const draw = (bar) => {
|
|
80
|
+
if (bar?.kind !== "trial" || !bar.dismissed) {
|
|
81
|
+
removeItem("clay.bar.host");
|
|
82
|
+
removeItem("clay.bar.show");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const host = Array.isArray(bar.actions) ? bar.actions.find((action) => action?.id === "host") : null;
|
|
86
|
+
if (typeof host?.href === "string") addItem({ id: "clay.bar.host", label: "Host and share online", href: host.href });
|
|
87
|
+
if (typeof bar.show === "function") addItem({ id: "clay.bar.show", label: "Show draft bar", run: () => bar.show() });
|
|
88
|
+
};
|
|
89
|
+
document.addEventListener("hyperclay:bar", (event) => draw(event.detail));
|
|
90
|
+
draw(window.hyperclayBar);
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
// A visitor's menu holds exactly Undo, Redo, Download copy and Make your own copy (David, Nov 11):
|
|
76
|
-
// no theme row, no page items, no Start over.
|
|
94
|
+
// no theme row, no page items, no Start over. While Hyperclay's trial bar is closed it also holds
|
|
95
|
+
// Host and share online and Show draft bar (David, Nov 11, sheet hosts review), and Earlier
|
|
96
|
+
// drafts when this page has recovery copies (David, Nov 13).
|
|
77
97
|
export function start() {
|
|
78
98
|
if (document.querySelector('clay-editor-ui[data-clay-ui="menu"]')) return;
|
|
79
99
|
const shell = createShell({ sections: [["clay.undo"], null], icon: GEAR, label: "Settings" });
|
|
80
100
|
const { embedded, skin, addItem, removeItem, open, close, note, goo, beforeOpen } = shell;
|
|
81
101
|
shell.mount(embedded ? [MENU_SAFETY, SHELL_BASE, skin ?? MENU_SKIN, MENU_TOUCH] : [MENU_SAFETY, SHELL_BASE, SHELL_FLOATING, MENU_SKIN, MENU_TOUCH]);
|
|
102
|
+
let recoveryMenu;
|
|
103
|
+
beforeOpen.push(() => {
|
|
104
|
+
if (typeof globalThis.indexedDB?.open !== "function" || !globalThis.IDBKeyRange) return;
|
|
105
|
+
recoveryMenu ??= import("../visitor-drafts/recovery-menu.js").then(async ({ attachRecoveryMenu }) => {
|
|
106
|
+
const { recoveryDrafts } = await import("../visitor-drafts.js");
|
|
107
|
+
attachRecoveryMenu(shell, recoveryDrafts);
|
|
108
|
+
}).catch((error) => {
|
|
109
|
+
recoveryMenu = null;
|
|
110
|
+
console.warn("visitor-menu: earlier drafts did not load", error);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
82
113
|
publishLooks();
|
|
83
114
|
attachUndo({ addItem, beforeOpen });
|
|
84
115
|
addItem({ id: "clay.download", label: "Download copy", run: () => window.clay.visitorDrafts.download() });
|
|
85
116
|
addItem({ id: "clay.copy", label: "Make your own copy", href: copyUrl() });
|
|
117
|
+
attachBar({ addItem, removeItem });
|
|
86
118
|
window.clay.menu = { addItem, removeItem, open, close, note, embedded, button: goo };
|
|
87
119
|
document.dispatchEvent(new CustomEvent("clay:menu-ready"));
|
|
88
120
|
}
|
|
@@ -76,6 +76,22 @@ export function applyKeys(data, keys, merge3) {
|
|
|
76
76
|
export const isChain = (value) => isPlain(value) && typeof value.tab === "string" && typeof value.reset === "string" && Array.isArray(value.ops);
|
|
77
77
|
export const recoveryMark = (copy) => isChain(copy) ? `op:${copy.tab}:${copy.ops.at(-1)?.seq ?? copy.acked ?? 0}` : foreignMark(copy);
|
|
78
78
|
|
|
79
|
+
// Ended journals carry only the fields they changed; other fields use the served page.
|
|
80
|
+
export function recoveryRecord(copy, seed) {
|
|
81
|
+
if (isPlain(copy) && copy.v === 1 && isPlain(copy.data)) return clone(copy);
|
|
82
|
+
if (!isChain(copy) || !copy.ops.length) return null;
|
|
83
|
+
const data = clone(seed);
|
|
84
|
+
let unit = null;
|
|
85
|
+
for (const op of copy.ops) {
|
|
86
|
+
for (const [name, pair] of Object.entries(op.keys ?? {})) {
|
|
87
|
+
if ("m" in pair) Object.defineProperty(data, name, { value: clone(pair.m), enumerable: true, writable: true, configurable: true });
|
|
88
|
+
else delete data[name];
|
|
89
|
+
}
|
|
90
|
+
if (op.unit !== undefined) unit = op.unit.to;
|
|
91
|
+
}
|
|
92
|
+
return { v: 1, savedAt: copy.ops.at(-1).savedAt, data, ...(unit && { live: clone(unit.live), blockeditor: clone(unit.blockeditor) }) };
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
// Cleanup needs proof for this exact chain, even when its generation has ended.
|
|
80
96
|
export function settled(chain, meta) {
|
|
81
97
|
if (isPlain(chain) && meta.recovery?.includes(recoveryMark(chain))) return true;
|
|
@@ -123,7 +139,7 @@ function foreignBase(line, { record, seen, direct, v1base, meta }) {
|
|
|
123
139
|
* @param {?Object} [input.seen] - the latest cache copy the caller saw at the draft's key
|
|
124
140
|
* @param {boolean} [input.direct] - a storage event says `local` replaced exactly that copy
|
|
125
141
|
*/
|
|
126
|
-
export function commit(stored, { seed, newId, now, merge3, unitMark, marker = null, resets = [], chains = [], local = null, seen = null, direct = false }) {
|
|
142
|
+
export function commit(stored, { seed, newId, now, merge3, same, unitMark, marker = null, resets = [], chains = [], local = null, seen = null, direct = false, recovery = null, copies = [] }) {
|
|
127
143
|
const meta = stored.meta ? clone(stored.meta) : emptyMeta(marker);
|
|
128
144
|
// A database the browser cleared counts on from this ClayJS's cache, so the cache never
|
|
129
145
|
// reads as newer than the record.
|
|
@@ -260,6 +276,36 @@ export function commit(stored, { seed, newId, now, merge3, unitMark, marker = nu
|
|
|
260
276
|
out.applied[chain.tab] = at;
|
|
261
277
|
}
|
|
262
278
|
|
|
279
|
+
if (recovery) {
|
|
280
|
+
const found = copies.find(([mark]) => mark === recovery.mark);
|
|
281
|
+
if (!found) throw new Error("That draft is no longer available. Open the list again.");
|
|
282
|
+
if (recovery.type === "remove") {
|
|
283
|
+
meta.recovery = (meta.recovery ?? []).filter((mark) => mark !== recovery.mark);
|
|
284
|
+
out.dropped.push(recovery.mark);
|
|
285
|
+
out.removedMark = recovery.mark;
|
|
286
|
+
out.changed = true;
|
|
287
|
+
} else if (recovery.type === "restore" && recovery.reset === meta.reset) {
|
|
288
|
+
const chosen = recoveryRecord(found[1], seed);
|
|
289
|
+
if (!chosen) throw new Error("This draft cannot be restored by this version.");
|
|
290
|
+
if (meta.recovery?.includes(recovery.mark)) meta.recovery = [recovery.mark, ...meta.recovery.filter((mark) => mark !== recovery.mark)];
|
|
291
|
+
const alreadyKept = record && copies.some(([mark, copy]) => {
|
|
292
|
+
if (!meta.recovery?.includes(mark)) return false;
|
|
293
|
+
const kept = recoveryRecord(copy, seed);
|
|
294
|
+
return kept && same(record.data, kept.data) && same(unitOf(record), unitOf(kept));
|
|
295
|
+
});
|
|
296
|
+
if (record && !alreadyKept) recover(record);
|
|
297
|
+
record = {
|
|
298
|
+
v: 1, savedAt: new Date(now).toISOString(), id: newId(),
|
|
299
|
+
ancestry: lineage([], record, meta), data: chosen.data,
|
|
300
|
+
...(chosen.blockeditor && { live: chosen.live, blockeditor: chosen.blockeditor }),
|
|
301
|
+
};
|
|
302
|
+
meta.unit = unitMark(unitOf(record));
|
|
303
|
+
meta.rev++;
|
|
304
|
+
out.changed = true;
|
|
305
|
+
out.restoredCopy = true;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
263
309
|
for (const [tab, entry] of Object.entries(meta.tabs)) {
|
|
264
310
|
if (now - (entry.at ?? 0) > TAB_TTL_MS) {
|
|
265
311
|
delete meta.tabs[tab];
|
|
@@ -285,7 +331,8 @@ export function transact(drafts, key, input, options) {
|
|
|
285
331
|
const stored = { meta: parse(raw.meta), record: parse(raw.record), v1base: parse(raw.v1base) };
|
|
286
332
|
const rows = raw.rows.map(([name, text]) => [name, parse(text)]);
|
|
287
333
|
const given = input(stored);
|
|
288
|
-
const
|
|
334
|
+
const copies = (raw.recovery ?? []).map(([name, text]) => [name.slice((key + ":recovery:").length), parse(text)]);
|
|
335
|
+
const result = commit(stored, { ...given, copies, chains: [...rows.map(([, chain]) => chain), ...(given.chains ?? [])] });
|
|
289
336
|
const puts = [];
|
|
290
337
|
const deletes = [];
|
|
291
338
|
if (result.changed) {
|
|
@@ -297,7 +344,7 @@ export function transact(drafts, key, input, options) {
|
|
|
297
344
|
if (result.conflicts.length) puts.push([conflictKey(key), JSON.stringify(result.conflicts[result.conflicts.length - 1])]);
|
|
298
345
|
for (const copy of result.recovered) puts.push([recoveryKey(key, recoveryMark(copy)), JSON.stringify(copy)]);
|
|
299
346
|
for (const mark of result.dropped) deletes.push(recoveryKey(key, mark));
|
|
300
|
-
for (const [name, chain] of rows) if (settled(chain, result.meta)) deletes.push(name);
|
|
347
|
+
for (const [name, chain] of rows) if (settled(chain, result.meta) || (result.removedMark && isChain(chain) && recoveryMark(chain) === result.removedMark)) deletes.push(name);
|
|
301
348
|
return { puts, deletes, result };
|
|
302
349
|
}, options);
|
|
303
350
|
}
|
|
@@ -138,7 +138,7 @@ function wrap(db, keyRange) {
|
|
|
138
138
|
// `key`, hands them to `build` inside the transaction, and writes the `puts` and
|
|
139
139
|
// `deletes` it returns before the transaction can complete. A `build` or a write that
|
|
140
140
|
// throws aborts it, so nothing lands. Resolves with `build`'s `result` on `complete`.
|
|
141
|
-
function change(key, build, { timeout = 0 } = {}) {
|
|
141
|
+
function change(key, build, { timeout = 0, recovery = false } = {}) {
|
|
142
142
|
return new Promise((resolve, reject) => {
|
|
143
143
|
let tx;
|
|
144
144
|
try {
|
|
@@ -167,9 +167,14 @@ function wrap(db, keyRange) {
|
|
|
167
167
|
const store = tx.objectStore(RECORDS);
|
|
168
168
|
const rows = keyRange.bound(key + ":op:", key + ":op:\uffff");
|
|
169
169
|
const reads = { record: store.get(key), meta: store.get(key + ":meta"), v1base: store.get(key + ":v1base"), names: store.getAllKeys(rows), values: store.getAll(rows) };
|
|
170
|
+
const copies = recovery ? keyRange.bound(key + ":recovery:", key + ":recovery:\uffff") : null;
|
|
171
|
+
if (copies) {
|
|
172
|
+
reads.copyNames = store.getAllKeys(copies);
|
|
173
|
+
reads.copyValues = store.getAll(copies);
|
|
174
|
+
}
|
|
170
175
|
// Requests in one transaction answer in the order they were made: the last one in
|
|
171
176
|
// means every read is.
|
|
172
|
-
reads.values.onsuccess = () => {
|
|
177
|
+
(reads.copyValues ?? reads.values).onsuccess = () => {
|
|
173
178
|
if (settled) return;
|
|
174
179
|
try {
|
|
175
180
|
const raw = {
|
|
@@ -177,6 +182,7 @@ function wrap(db, keyRange) {
|
|
|
177
182
|
meta: reads.meta.result ?? null,
|
|
178
183
|
v1base: reads.v1base.result ?? null,
|
|
179
184
|
rows: reads.names.result.map((name, i) => [name, reads.values.result[i]]),
|
|
185
|
+
...(copies && { recovery: reads.copyNames.result.map((name, i) => [name, reads.copyValues.result[i]]) }),
|
|
180
186
|
};
|
|
181
187
|
const planned = build(raw);
|
|
182
188
|
for (const [name, value] of planned.puts) store.put(value, name);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { COPY } from "./recovery.js";
|
|
2
|
+
import { bevelBox, bevelButton, bevelText, setShown } from "../../ui/bevel-controls.js";
|
|
3
|
+
import { FONT_MONO } from "../../ui/bevel.js";
|
|
4
|
+
import { resolveTheme } from "../../ui/theme.js";
|
|
5
|
+
import modal, { SURFACE_THEME } from "../../ui/modal.js";
|
|
6
|
+
|
|
7
|
+
export function attachRecoveryMenu(shell, load) {
|
|
8
|
+
let request = 0;
|
|
9
|
+
const refresh = async () => {
|
|
10
|
+
const id = ++request;
|
|
11
|
+
try {
|
|
12
|
+
const recovery = await load();
|
|
13
|
+
const rows = recovery ? await recovery.list() : [];
|
|
14
|
+
if (id !== request) return;
|
|
15
|
+
if (!rows.length) return shell.removeItem("clay.earlier-drafts");
|
|
16
|
+
shell.addItem({ id: "clay.earlier-drafts", label: COPY.label, run: () => openRecovery(recovery, { refresh, anchor: shell.goo }).catch(() => shell.note("The drafts could not be opened. Try again.")) });
|
|
17
|
+
} catch {
|
|
18
|
+
if (id === request) shell.removeItem("clay.earlier-drafts");
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
shell.beforeOpen.push(refresh);
|
|
22
|
+
refresh();
|
|
23
|
+
return refresh;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function openRecovery(recovery, { refresh = () => {}, anchor = document.activeElement } = {}) {
|
|
27
|
+
const theme = resolveTheme();
|
|
28
|
+
const box = bevelBox("div", ["display:flex", "flex-direction:column", "gap:16px"], { theme, parts: ["dialog.content"] });
|
|
29
|
+
box.dataset.draftCopies = "";
|
|
30
|
+
box.append(bevelText("p", ["display:block", "margin:0"], COPY.intro, { theme }));
|
|
31
|
+
const status = bevelText("p", ["display:block", "margin:0"], "", { theme });
|
|
32
|
+
status.setAttribute("role", "status");
|
|
33
|
+
const list = bevelBox("ol", ["display:flex", "flex-direction:column", "gap:16px", "padding:0", "margin:0", "list-style:none"], { theme });
|
|
34
|
+
box.append(status, list);
|
|
35
|
+
let busy = false;
|
|
36
|
+
|
|
37
|
+
async function draw() {
|
|
38
|
+
const rows = await recovery.list();
|
|
39
|
+
list.replaceChildren();
|
|
40
|
+
if (!rows.length) status.textContent = "No earlier drafts are kept.";
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
const item = bevelBox("li", ["display:flex", "flex-direction:column", "gap:8px"], { theme });
|
|
43
|
+
item.dataset.draftMark = row.mark;
|
|
44
|
+
const date = new Date(row.savedAt);
|
|
45
|
+
const when = Number.isNaN(date.getTime()) ? "Time unavailable" : date.toLocaleString();
|
|
46
|
+
const time = bevelText("time", ["display:block", `font-family:${FONT_MONO}`], when, { theme });
|
|
47
|
+
if (row.savedAt) time.dateTime = row.savedAt;
|
|
48
|
+
item.append(time, bevelText("p", ["display:block", "margin:0"], `${row.fields} saved ${row.fields === 1 ? "field" : "fields"}`, { theme, role: "mutedText" }));
|
|
49
|
+
const actions = bevelBox("div", ["display:flex", "flex-wrap:wrap", "gap:8px"], { theme });
|
|
50
|
+
const preview = bevelBox("div", ["display:block"], { theme });
|
|
51
|
+
setShown(preview, false);
|
|
52
|
+
const confirm = bevelBox("div", ["display:flex", "flex-wrap:wrap", "gap:8px", "align-items:center"], { theme });
|
|
53
|
+
setShown(confirm, false);
|
|
54
|
+
const action = (label, fn, options = {}) => {
|
|
55
|
+
const button = bevelButton(label, { small: true, theme, ...options, onClick: async () => {
|
|
56
|
+
if (busy) return;
|
|
57
|
+
busy = true;
|
|
58
|
+
button.setDisabled(true);
|
|
59
|
+
status.textContent = "";
|
|
60
|
+
try { await fn(button); }
|
|
61
|
+
catch (error) { status.textContent = error.message || "That did not work. Try again."; }
|
|
62
|
+
finally { busy = false; button.setDisabled(false); }
|
|
63
|
+
} });
|
|
64
|
+
button.setAttribute("aria-label", `${label}: ${when}`);
|
|
65
|
+
return button;
|
|
66
|
+
};
|
|
67
|
+
const open = action("Preview", () => {
|
|
68
|
+
if (!preview.hidden) { setShown(preview, false); open.setAttribute("aria-expanded", "false"); return; }
|
|
69
|
+
const doc = new DOMParser().parseFromString(recovery.html(row.copy), "text/html");
|
|
70
|
+
for (const el of doc.querySelectorAll('script, meta[http-equiv="refresh" i]')) el.remove();
|
|
71
|
+
for (const el of doc.querySelectorAll("input, textarea, select, button")) el.disabled = true;
|
|
72
|
+
for (const el of doc.querySelectorAll("[contenteditable]")) el.removeAttribute("contenteditable");
|
|
73
|
+
for (const el of doc.querySelectorAll("a, area")) {
|
|
74
|
+
el.removeAttribute("href");
|
|
75
|
+
el.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
|
|
76
|
+
el.removeAttribute("xlink:href");
|
|
77
|
+
}
|
|
78
|
+
const policy = doc.createElement("meta");
|
|
79
|
+
policy.httpEquiv = "Content-Security-Policy";
|
|
80
|
+
policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; form-action 'none'";
|
|
81
|
+
doc.head.prepend(policy);
|
|
82
|
+
const frame = bevelBox("iframe", ["display:block", "width:100%", "height:45vh", "border:0"], { theme });
|
|
83
|
+
frame.title = `Draft preview: ${when}`;
|
|
84
|
+
frame.setAttribute("sandbox", "");
|
|
85
|
+
frame.srcdoc = "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
|
86
|
+
preview.replaceChildren(frame);
|
|
87
|
+
setShown(preview, true);
|
|
88
|
+
open.setAttribute("aria-expanded", "true");
|
|
89
|
+
});
|
|
90
|
+
open.setAttribute("aria-expanded", "false");
|
|
91
|
+
const restore = action("Restore", async () => { await recovery.restore(row.mark); status.textContent = "Draft restored. Reloading the page."; await refresh(); });
|
|
92
|
+
const download = action("Download", () => recovery.download(row.copy));
|
|
93
|
+
for (const button of [open, restore, download]) button.setDisabled(!row.available);
|
|
94
|
+
const remove = action("Remove", () => { setShown(confirm, true, "flex"); cancel.focus(); });
|
|
95
|
+
const cancel = bevelButton("Keep draft", { small: true, theme, onClick: () => { setShown(confirm, false); remove.focus(); } });
|
|
96
|
+
confirm.append(bevelText("p", ["margin:0"], "Remove this saved draft?", { theme }), cancel, action("Remove draft", async () => {
|
|
97
|
+
await recovery.remove(row.mark);
|
|
98
|
+
await draw();
|
|
99
|
+
await refresh();
|
|
100
|
+
list.querySelector("button")?.focus();
|
|
101
|
+
}, { variant: "danger" }));
|
|
102
|
+
actions.append(open, restore, download, remove);
|
|
103
|
+
item.append(actions, confirm, preview);
|
|
104
|
+
list.append(item);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await draw();
|
|
109
|
+
modal.title = COPY.label;
|
|
110
|
+
modal.html = box;
|
|
111
|
+
modal.width = "720px";
|
|
112
|
+
modal[SURFACE_THEME] = theme;
|
|
113
|
+
modal.closeHtml = "x";
|
|
114
|
+
modal.no = "Close";
|
|
115
|
+
modal.onNo(() => anchor?.focus());
|
|
116
|
+
modal.open();
|
|
117
|
+
return box;
|
|
118
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { applyData, extractData } from "../../vendor/hyper-html-api-data.vendor.js";
|
|
2
|
+
import { finalizeControlForSave } from "../../vendor/control-serialize.vendor.js";
|
|
3
|
+
import { downloadHtml, documentName } from "../../core/download.js";
|
|
4
|
+
|
|
5
|
+
export const COPY = {
|
|
6
|
+
label: "Earlier drafts",
|
|
7
|
+
intro: "This page kept your earlier drafts aside, listed by time.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createRecovery({ store, session, getHTML = () => window.clay.getHTML(), download = downloadHtml }) {
|
|
11
|
+
const recordOf = (copy) => session.recoveryRecord(copy);
|
|
12
|
+
const savedAt = (copy) => copy?.savedAt ?? copy?.ops?.at(-1)?.savedAt ?? (copy?.at ? new Date(copy.at).toISOString() : null);
|
|
13
|
+
|
|
14
|
+
async function list() {
|
|
15
|
+
const rows = await session.listRecovery();
|
|
16
|
+
return rows.map(({ mark, copy }) => {
|
|
17
|
+
const record = recordOf(copy);
|
|
18
|
+
const fields = copy?.ops ? new Set(copy.ops.flatMap((op) => Object.keys(op.keys ?? {}))).size : Object.keys(record?.data ?? {}).length;
|
|
19
|
+
return { mark, copy, savedAt: savedAt(copy), fields, available: Boolean(record) };
|
|
20
|
+
}).sort((a, b) => (Date.parse(b.savedAt) || 0) - (Date.parse(a.savedAt) || 0));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function html(copy) {
|
|
24
|
+
const record = recordOf(copy);
|
|
25
|
+
if (!record) throw new Error("This draft cannot be opened by this version.");
|
|
26
|
+
const doc = new DOMParser().parseFromString(getHTML(), "text/html");
|
|
27
|
+
applyData(doc, store.seed(), store.rules);
|
|
28
|
+
applyData(doc, record.data, store.rules);
|
|
29
|
+
if (!store.fitsInto(record.data, extractData(doc, store.rules))) throw new Error("This draft no longer fits the page. Its saved copy is still kept.");
|
|
30
|
+
const unit = record.blockeditor ? record : store.servedUnit();
|
|
31
|
+
const article = doc.querySelector("article[data-blockeditor]");
|
|
32
|
+
if (unit) {
|
|
33
|
+
if (!article) throw new Error("This draft's page editor is no longer available.");
|
|
34
|
+
for (const tag of doc.querySelectorAll('script[type="application/json"][merge][clay~="live"]')) tag.remove();
|
|
35
|
+
for (const [name, value] of Object.entries(unit.live)) {
|
|
36
|
+
const tag = doc.createElement("script");
|
|
37
|
+
tag.type = "application/json";
|
|
38
|
+
tag.setAttribute("merge", name);
|
|
39
|
+
tag.setAttribute("clay", "live");
|
|
40
|
+
tag.textContent = JSON.stringify(value).replace(/</g, "\\u003c");
|
|
41
|
+
article.before(tag);
|
|
42
|
+
}
|
|
43
|
+
article.setAttribute("data-rev", unit.blockeditor.rev);
|
|
44
|
+
article.innerHTML = unit.blockeditor.html;
|
|
45
|
+
}
|
|
46
|
+
for (const control of doc.querySelectorAll('input:not([type="password"]):not([type="file"]), textarea, select')) finalizeControlForSave(control);
|
|
47
|
+
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function downloadCopy(copy) {
|
|
51
|
+
const ok = await download({ html: html(copy), name: documentName() + ".htmlclay" });
|
|
52
|
+
if (!ok) throw new Error("The download could not be made. Try again.");
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function restore(mark) {
|
|
57
|
+
const row = (await list()).find((row) => row.mark === mark);
|
|
58
|
+
if (!row) throw new Error("That draft is no longer available. Open the list again.");
|
|
59
|
+
html(row.copy);
|
|
60
|
+
return session.restoreRecovery(mark);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { list, html, download: downloadCopy, restore, remove: (mark) => session.removeRecovery(mark) };
|
|
64
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { openDrafts } from "./idb.js";
|
|
23
|
-
import { emptyMeta, foreignMark, isForeign, markerFor, markerGen, recoveryKey, recoveryMark, rowKey, settled, transact } from "./commit.js";
|
|
23
|
+
import { emptyMeta, foreignMark, isChain, isForeign, markerFor, markerGen, recoveryKey, recoveryMark, recoveryRecord, rowKey, settled, transact } from "./commit.js";
|
|
24
24
|
import { setDraftStoreFailed } from "../../core/draft-mode.js";
|
|
25
25
|
|
|
26
26
|
export const FULL_COPY_LIMIT = 256 * 1024;
|
|
@@ -91,6 +91,7 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
91
91
|
let sent = null;
|
|
92
92
|
let carried = 0;
|
|
93
93
|
let retiring = false;
|
|
94
|
+
let restoringCopy = false;
|
|
94
95
|
let shownId = store.held()?.id;
|
|
95
96
|
// The latest full cache copy this tab saw at the key, and the text of another writer's
|
|
96
97
|
// record a storage event says replaced it: such a record merges from that copy.
|
|
@@ -127,6 +128,7 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
127
128
|
seed: store.seed(),
|
|
128
129
|
newId: store.newId,
|
|
129
130
|
merge3: store.merge3,
|
|
131
|
+
same: store.same,
|
|
130
132
|
unitMark: store.unitMark,
|
|
131
133
|
now: now(),
|
|
132
134
|
marker: held,
|
|
@@ -139,12 +141,12 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
// Another tab's slot is removed once nothing more can come of it and its tab has gone quiet.
|
|
142
|
-
function sweep(meta) {
|
|
144
|
+
function sweep(meta, removedMark) {
|
|
143
145
|
const at = now();
|
|
144
146
|
for (const name of store.journalSlots()) {
|
|
145
147
|
if (name === store.slotKey) continue;
|
|
146
148
|
const chain = parse(read(name));
|
|
147
|
-
if (settled(chain, meta) && at - (chain?.at ?? 0) > SLOT_AGE_MS) remove(name);
|
|
149
|
+
if ((removedMark && isChain(chain) && recoveryMark(chain) === removedMark) || (settled(chain, meta) && at - (chain?.at ?? 0) > SLOT_AGE_MS)) remove(name);
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
|
|
@@ -169,20 +171,26 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
169
171
|
try { storage.setItem(key, headOf(record, w)); } catch {}
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
async function stopWithPending() {
|
|
175
|
+
store.stop({ keepPending: true });
|
|
176
|
+
if (store.unsafe()) {
|
|
177
|
+
try { await drafts.putRow(rowKey(key, store.tab), JSON.stringify(store.chain())); }
|
|
178
|
+
catch (error) { throw store.failed(error); }
|
|
179
|
+
store.acknowledge(store.chain().ops.at(-1)?.seq ?? 0);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
// Operations captured after the transaction read stay journaled for the next load.
|
|
173
184
|
async function ended(result) {
|
|
185
|
+
try { store.captureLate(); }
|
|
186
|
+
catch (error) { throw store.failed(error); }
|
|
174
187
|
retiring = true;
|
|
175
188
|
const chain = store.chain();
|
|
176
189
|
const mark = recoveryMark(chain);
|
|
177
190
|
const kept = chain.ops.some((op) => op.seq > carried) || result.meta.recovery?.includes(mark)
|
|
178
191
|
? mark : result.meta.recovery?.includes(shownId) ? shownId : null;
|
|
179
192
|
store.acknowledge(carried);
|
|
180
|
-
|
|
181
|
-
if (store.unsafe()) {
|
|
182
|
-
try { await drafts.putRow(rowKey(key, store.tab), JSON.stringify(store.chain())); }
|
|
183
|
-
catch (error) { throw store.failed(error); }
|
|
184
|
-
store.acknowledge(store.chain().ops.at(-1)?.seq ?? 0);
|
|
185
|
-
}
|
|
193
|
+
await stopWithPending();
|
|
186
194
|
cache(result);
|
|
187
195
|
if (kept) rememberRecovery(kept);
|
|
188
196
|
const held = store.held();
|
|
@@ -277,13 +285,14 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
277
285
|
gen = meta.gen;
|
|
278
286
|
rememberGeneration(meta.reset);
|
|
279
287
|
const saved = store.acknowledge(meta.tabs[store.tab]?.seq ?? 0);
|
|
280
|
-
sweep(meta);
|
|
288
|
+
sweep(meta, result.removedMark);
|
|
281
289
|
cache(result);
|
|
282
290
|
if (store.pending()) return result;
|
|
283
291
|
if (saved) {
|
|
284
292
|
setDraftStoreFailed(false);
|
|
285
293
|
doc.dispatchEvent(new CustomEvent("clay:drafts-saved", { detail: { data: record.data } }));
|
|
286
294
|
}
|
|
295
|
+
if (restoringCopy) return result;
|
|
287
296
|
if (!store.shown()) {
|
|
288
297
|
rev = meta.rev;
|
|
289
298
|
return result;
|
|
@@ -303,8 +312,15 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
303
312
|
// so it carries every operation made before then.
|
|
304
313
|
let active = null;
|
|
305
314
|
let waiting = null;
|
|
306
|
-
function run(options) {
|
|
307
|
-
|
|
315
|
+
function run(options, recovery = null) {
|
|
316
|
+
const given = () => {
|
|
317
|
+
if (recovery?.type === "restore") {
|
|
318
|
+
if (store.stopped()) throw new Error("This page has started over. Reload it before restoring a draft.");
|
|
319
|
+
store.captureNow();
|
|
320
|
+
}
|
|
321
|
+
return { ...input(), recovery };
|
|
322
|
+
};
|
|
323
|
+
active = transact(drafts, key, given, options).then(took, (error) => {
|
|
308
324
|
throw store.pending() ? store.failed(error) : error;
|
|
309
325
|
}).finally(() => { active = null; });
|
|
310
326
|
return active;
|
|
@@ -318,6 +334,38 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
318
334
|
});
|
|
319
335
|
return waiting;
|
|
320
336
|
}
|
|
337
|
+
async function changeRecovery(recovery) {
|
|
338
|
+
while (active || waiting) await (waiting ?? active).catch(() => {});
|
|
339
|
+
return run({ recovery: true }, recovery);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function restoreRecovery(mark) {
|
|
343
|
+
if (restoringCopy) throw new Error("A draft is already being restored.");
|
|
344
|
+
restoringCopy = true;
|
|
345
|
+
try {
|
|
346
|
+
if (store.stopped()) throw new Error("This page has started over. Reload it before restoring a draft.");
|
|
347
|
+
store.captureNow();
|
|
348
|
+
let result;
|
|
349
|
+
do {
|
|
350
|
+
result = await changeRecovery({ type: "restore", mark, reset: store.generation() });
|
|
351
|
+
if (!result.restoredCopy || store.stopped()) throw new Error("This page has started over. Reload it before restoring a draft.");
|
|
352
|
+
} while (store.pending());
|
|
353
|
+
// This tab's pre-restore page is retired without ending the other tabs' generation.
|
|
354
|
+
try { store.captureLate("restored:" + store.tab); }
|
|
355
|
+
catch (error) { throw store.failed(error); }
|
|
356
|
+
await stopWithPending();
|
|
357
|
+
reload();
|
|
358
|
+
return result;
|
|
359
|
+
} finally { restoringCopy = false; }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function listRecovery() {
|
|
363
|
+
await first.catch(() => {});
|
|
364
|
+
const prefix = key + ":recovery:";
|
|
365
|
+
const rows = await drafts.readAll(key);
|
|
366
|
+
return [...rows].filter(([name]) => name.startsWith(prefix)).map(([name, raw]) => ({ mark: name.slice(prefix.length), copy: parse(raw) }));
|
|
367
|
+
}
|
|
368
|
+
|
|
321
369
|
const quietly = () => { sync().catch(() => {}); };
|
|
322
370
|
|
|
323
371
|
// At a hide, an operation too large for the journal slot goes into the database blind, with
|
|
@@ -341,5 +389,5 @@ export async function openSession({ store, storage, doc = document, now = () =>
|
|
|
341
389
|
|
|
342
390
|
const first = run({ timeout: FIRST_TRANSACTION_TIMEOUT_MS });
|
|
343
391
|
first.catch(() => {});
|
|
344
|
-
return { first, sync, hide, startOver };
|
|
392
|
+
return { first, sync, hide, startOver, listRecovery, restoreRecovery, recoveryRecord: (copy) => recoveryRecord(copy, store.seed()), removeRecovery: (mark) => changeRecovery({ type: "remove", mark }) };
|
|
345
393
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { extractData, applyData, engine } from "../../vendor/hyper-html-api-data.vendor.js";
|
|
2
|
-
import { draftSlot, setDraftStoreFailed } from "../../core/draft-mode.js";
|
|
2
|
+
import { draftGate, draftSlot, setDraftStoreFailed } from "../../core/draft-mode.js";
|
|
3
3
|
|
|
4
4
|
export const KEY_PREFIX = "clay:visitor-drafts:";
|
|
5
5
|
export const RECORD_VERSION = 1;
|
|
@@ -73,6 +73,7 @@ export function same(a, b) {
|
|
|
73
73
|
|
|
74
74
|
const STORE_SEL = 'script[type="application/json"][merge][clay~="live"]';
|
|
75
75
|
const ARTICLE_SEL = "article[data-blockeditor]";
|
|
76
|
+
const HANDLER_SEL = "[onbeforesave], [onbeforesnapshot]";
|
|
76
77
|
const isPagePart = (name) => name === "blockeditor" || /^blockeditor\.\d+$/.test(name);
|
|
77
78
|
const canonical = (live) => JSON.stringify(Object.keys(live).sort().map((name) => [name, live[name]]));
|
|
78
79
|
|
|
@@ -269,7 +270,7 @@ function withoutSiteKey(value) {
|
|
|
269
270
|
return rest;
|
|
270
271
|
}
|
|
271
272
|
|
|
272
|
-
export function createStore({ doc = document, storage = safeStorage(), now = () => new Date(), path = draftPath(), fallback = null, slotLimit = SLOT_LIMIT, pendingLimit = PENDING_LIMIT } = {}) {
|
|
273
|
+
export function createStore({ doc = document, storage = safeStorage(), gate = draftGate, now = () => new Date(), path = draftPath(), fallback = null, slotLimit = SLOT_LIMIT, pendingLimit = PENDING_LIMIT } = {}) {
|
|
273
274
|
const slot = draftSlot(doc);
|
|
274
275
|
const key = draftKey(slot, path);
|
|
275
276
|
const rules = withoutSiteKey(engine.findRulesIn(doc, "api").rules);
|
|
@@ -610,16 +611,46 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
610
611
|
// to `localStorage` after it made some.
|
|
611
612
|
let before = null;
|
|
612
613
|
|
|
613
|
-
|
|
614
|
+
// The dirty gate's token for the snapshot `base` was last compared with: taken before
|
|
615
|
+
// that snapshot, in its task. Null while that is not known.
|
|
616
|
+
let capturedAt = null;
|
|
617
|
+
|
|
618
|
+
const pageOf = (html) => (html === undefined ? doc : new DOMParser().parseFromString(html, "text/html"));
|
|
619
|
+
|
|
620
|
+
// `at` is the gate token of the snapshot `html` was serialized from.
|
|
621
|
+
function capture(html, { reset = tab.reset, at = null } = {}) {
|
|
622
|
+
if (stopped) return null;
|
|
623
|
+
const parsed = pageOf(html);
|
|
624
|
+
const op = captureData(extractData(parsed, rules), recordable(readUnit(parsed)), reset);
|
|
625
|
+
capturedAt = at;
|
|
626
|
+
return op;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// The page as it is now.
|
|
630
|
+
function captureNow(reset = tab.reset) {
|
|
631
|
+
if (stopped) return null;
|
|
632
|
+
const at = gate()?.token() ?? null;
|
|
633
|
+
return capture(window.clay.getHTML(), { reset, at });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// The last capture of a generation that has ended. A capture runs the page's save
|
|
637
|
+
// handlers, and a handler that writes a changing value makes a field differ with no
|
|
638
|
+
// edit behind it. So on a page with a save handler that has recorded no edit since
|
|
639
|
+
// `base` was captured, the fields are what `base` holds, and only the page editor's
|
|
640
|
+
// part, which no handler writes, is compared.
|
|
641
|
+
function captureLate(reset = tab.reset) {
|
|
614
642
|
if (stopped) return null;
|
|
643
|
+
const settled = capturedAt !== null && doc.querySelector(HANDLER_SEL) !== null && gate()?.unchangedSince(capturedAt) === true;
|
|
644
|
+
const parsed = pageOf(window.clay?.getHTML?.());
|
|
645
|
+
return captureData(settled ? base : extractData(parsed, rules), recordable(readUnit(parsed)), reset);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function captureData(mine, mineUnit, reset = tab.reset) {
|
|
615
649
|
if (unparked != null && park(unparked, { keepSlot: true }) === null) {
|
|
616
650
|
throw storageFailure(doc, new Error("an earlier saved copy could not be kept aside"));
|
|
617
651
|
}
|
|
618
652
|
// Too much is waiting: nothing more is taken until it drains, and the page keeps it.
|
|
619
653
|
if (tab.sizes.reduce((sum, size) => sum + size, 0) >= pendingLimit) throw storageFailure(doc, new Error("too many changes are waiting to be saved"));
|
|
620
|
-
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
621
|
-
const mine = extractData(parsed, rules);
|
|
622
|
-
const mineUnit = recordable(readUnit(parsed));
|
|
623
654
|
const unitChanged = !sameUnit(mineUnit, editorUnit);
|
|
624
655
|
const keys = {};
|
|
625
656
|
for (const name of Object.keys(mine)) {
|
|
@@ -644,6 +675,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
644
675
|
}
|
|
645
676
|
if (unitChanged) editorUnit = seenUnit = mineUnit;
|
|
646
677
|
fresh = false;
|
|
678
|
+
tab.reset = reset;
|
|
647
679
|
tab.ops.push(op);
|
|
648
680
|
tab.sizes.push(JSON.stringify(op).length);
|
|
649
681
|
journal();
|
|
@@ -655,7 +687,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
655
687
|
function keep() {
|
|
656
688
|
if (stopped) return true;
|
|
657
689
|
try {
|
|
658
|
-
if (changed())
|
|
690
|
+
if (changed()) captureNow();
|
|
659
691
|
} catch { return false; }
|
|
660
692
|
return tab.journaled === tab.seq;
|
|
661
693
|
}
|
|
@@ -680,6 +712,7 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
680
712
|
// unsaved changes, for `write` to save.
|
|
681
713
|
function release() {
|
|
682
714
|
if (tab.ops.length) ({ base, editorUnit, seenUnit } = before);
|
|
715
|
+
capturedAt = null;
|
|
683
716
|
drop();
|
|
684
717
|
}
|
|
685
718
|
|
|
@@ -815,8 +848,11 @@ export function createStore({ doc = document, storage = safeStorage(), now = ()
|
|
|
815
848
|
|
|
816
849
|
return {
|
|
817
850
|
slot, key, rules, read, restore, write, writeLive, flush, changed, adoptExternal, externalUnit, adoptedUnit, park, clear, snapshot,
|
|
818
|
-
tab: tab.id, slotKey, chain, capture, keep, acknowledge, release, take, stop, journalSlots,
|
|
851
|
+
tab: tab.id, slotKey, chain, capture, captureNow, captureLate, keep, acknowledge, release, take, stop, journalSlots,
|
|
819
852
|
seed: seedData,
|
|
853
|
+
servedUnit: () => { readServed(); return served; },
|
|
854
|
+
fitsInto,
|
|
855
|
+
same,
|
|
820
856
|
// For the session, which is fetched after the boot and imports nothing from here.
|
|
821
857
|
merge3,
|
|
822
858
|
unitMark,
|