@panphora/clayjs 1.24.0 → 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.
Files changed (209) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +16610 -14400
  11. package/dist/m/attrs/{refetch-on-save-DPGJJMGN.js → refetch-on-save-5CBFUJUF.js} +1 -1
  12. package/dist/m/attrs/save-freeze-MH3AOD56.js +1 -0
  13. package/dist/m/c-277QP5ET.js +1 -0
  14. package/dist/m/{c-SXGEWPFC.js → c-3E2IK5HU.js} +1 -1
  15. package/dist/m/{c-A4L7APIU.js → c-4AE2A2LK.js} +1 -1
  16. package/dist/m/{c-DZPO22O3.js → c-4Q6QIODW.js} +1 -1
  17. package/dist/m/{c-RO73MKKR.js → c-52OC3OH3.js} +1 -1
  18. package/dist/m/{c-VNAYHV2J.js → c-5PZYDWTW.js} +1 -1
  19. package/dist/m/c-5Q5XBN56.js +1 -0
  20. package/dist/m/c-5SZ6KFR5.js +1 -0
  21. package/dist/m/{c-ZRGEN6QQ.js → c-AI3H6ETU.js} +2 -2
  22. package/dist/m/{c-7V6MGMX2.js → c-BCWYEG6P.js} +1 -1
  23. package/dist/m/{c-A7N5TNSL.js → c-BJ2RRQBQ.js} +1 -1
  24. package/dist/m/{c-CTGRDT6E.js → c-C6YZNU6E.js} +1 -1
  25. package/dist/m/{c-DLBX4JCJ.js → c-C7SUMNMI.js} +1 -1
  26. package/dist/m/c-CITLTVQ4.js +4 -0
  27. package/dist/m/{c-TWOXQQKV.js → c-CL5LSATX.js} +1 -1
  28. package/dist/m/c-CU33V6RW.js +4 -0
  29. package/dist/m/{c-2R4OBWIP.js → c-D62HRSZK.js} +1 -1
  30. package/dist/m/{c-DD26VXAP.js → c-DT7E76Z2.js} +1 -1
  31. package/dist/m/{c-54ZZZYUN.js → c-EAV7A6BT.js} +1 -1
  32. package/dist/m/{c-RBPZNU2D.js → c-EQ5NBOGA.js} +1 -1
  33. package/dist/m/{c-KQKKIVLW.js → c-FBOO2UY3.js} +1 -1
  34. package/dist/m/{c-RTSRGGTP.js → c-FJDTDGDA.js} +1 -1
  35. package/dist/m/{c-MQKSKQHG.js → c-FKTALXNV.js} +1 -1
  36. package/dist/m/{c-GNIVHLEJ.js → c-GDNZEOOZ.js} +1 -1
  37. package/dist/m/{c-SUXHUL7U.js → c-GEAG5XAK.js} +1 -1
  38. package/dist/m/{c-UM7VZDTW.js → c-GY4BREDT.js} +1 -1
  39. package/dist/m/{c-4PO3YGDC.js → c-HGG7M2S3.js} +1 -1
  40. package/dist/m/c-HKSLOVU4.js +1 -0
  41. package/dist/m/{c-KJHVB2KG.js → c-HXGNH6GY.js} +1 -1
  42. package/dist/m/{c-FLEIU6CG.js → c-IESDKFO6.js} +1 -1
  43. package/dist/m/{c-SNVDWMM3.js → c-IJHF4IO6.js} +1 -1
  44. package/dist/m/{c-SGGGCIVD.js → c-IL5IVTOG.js} +1 -1
  45. package/dist/m/c-JB5YU57S.js +1 -0
  46. package/dist/m/{c-MP6S4AZU.js → c-JD2VFAYG.js} +2 -2
  47. package/dist/m/{c-CV4T65JE.js → c-JWW6BHWD.js} +3 -3
  48. package/dist/m/{c-GM7OA6EJ.js → c-K4M76VPM.js} +1 -1
  49. package/dist/m/{c-RXTPKCMC.js → c-KU3PX7LL.js} +1 -1
  50. package/dist/m/{c-2PVGJFI5.js → c-KYTVZT4D.js} +1 -1
  51. package/dist/m/{c-RJKKRWHW.js → c-M7ITYKMF.js} +1 -1
  52. package/dist/m/c-MFB74XVN.js +3 -0
  53. package/dist/m/c-MOP5KBWZ.js +1 -0
  54. package/dist/m/{c-YB3CDTDZ.js → c-MUM53J5J.js} +1 -1
  55. package/dist/m/{c-YK2L436K.js → c-MV7TEUTD.js} +1 -1
  56. package/dist/m/{c-LA2BW7UY.js → c-MZCOGNZL.js} +1 -1
  57. package/dist/m/{c-YGGO2G2L.js → c-N4GEJMXW.js} +1 -1
  58. package/dist/m/{c-NQAPWM7O.js → c-NADLYVAH.js} +1 -1
  59. package/dist/m/{c-RS77ODLY.js → c-NL4I4KNG.js} +1 -1
  60. package/dist/m/{c-6UPEKW6O.js → c-O2QZDVZ2.js} +1 -1
  61. package/dist/m/{c-LTSPHVAK.js → c-OBMZASYQ.js} +1 -1
  62. package/dist/m/{c-LUXAB2XI.js → c-ORLPQFNU.js} +1 -1
  63. package/dist/m/c-P73XPAOA.js +1 -0
  64. package/dist/m/{c-PWJSJEJU.js → c-PNZIJFG7.js} +1 -1
  65. package/dist/m/{c-ZKKAF4UT.js → c-PRZNJUYZ.js} +2 -2
  66. package/dist/m/{c-Q6VNQDJE.js → c-Q2CB57UG.js} +1 -1
  67. package/dist/m/{c-TWCXBWS3.js → c-QCDVDZIR.js} +1 -1
  68. package/dist/m/{c-P5NKFRL4.js → c-QDVG4NAZ.js} +1 -1
  69. package/dist/m/c-QWB53KRW.js +1 -0
  70. package/dist/m/{c-GNI7WHKF.js → c-RGAALCDU.js} +1 -1
  71. package/dist/m/c-RHPHMTF2.js +2 -0
  72. package/dist/m/{c-BULK5J4M.js → c-RWJ3OSCP.js} +1 -1
  73. package/dist/m/{c-5FXEKU4Y.js → c-SEVOS4UE.js} +1 -1
  74. package/dist/m/{c-PMRRMT72.js → c-SFYTKRU7.js} +1 -1
  75. package/dist/m/{c-47IBJGPQ.js → c-SFZRY6RT.js} +1 -1
  76. package/dist/m/{c-QENKN7QI.js → c-SGG6MUXW.js} +1 -1
  77. package/dist/m/c-SMLAJASY.js +1 -0
  78. package/dist/m/{c-VSEUFJAO.js → c-SUAC5RSG.js} +1 -1
  79. package/dist/m/{c-6RGEQNVV.js → c-SXZYVXD6.js} +1 -1
  80. package/dist/m/{c-ZFO5RW6G.js → c-TCFVG3I4.js} +1 -1
  81. package/dist/m/{c-PZRWZZRO.js → c-TLQINES7.js} +2 -2
  82. package/dist/m/c-U4TWEIKZ.js +1 -0
  83. package/dist/m/{c-FZRZPIFS.js → c-U65PREMC.js} +1 -1
  84. package/dist/m/{c-7YETSAVY.js → c-U7F66I64.js} +1 -1
  85. package/dist/m/{c-WOELSNWS.js → c-UD4VDBGJ.js} +1 -1
  86. package/dist/m/{c-VI3BJHCM.js → c-V2FPBRO6.js} +1 -1
  87. package/dist/m/{c-2OIKXOY6.js → c-V4B3RUWB.js} +1 -1
  88. package/dist/m/{c-O5ARALMJ.js → c-V5L6HWVL.js} +1 -1
  89. package/dist/m/c-VMXCX52Q.js +1 -0
  90. package/dist/m/{c-PVJMJL3P.js → c-VO6RSFCO.js} +1 -1
  91. package/dist/m/c-WDFQ7PBU.js +1 -0
  92. package/dist/m/{c-OG6GFSUX.js → c-WGQVSKKC.js} +1 -1
  93. package/dist/m/c-WSTUCBHV.js +1 -0
  94. package/dist/m/{c-EY5KDUZF.js → c-WVCT3ITX.js} +1 -1
  95. package/dist/m/{c-SE3SIY7Y.js → c-X3V4RWMA.js} +1 -1
  96. package/dist/m/{c-HIX5H5L7.js → c-XHZIKRCV.js} +1 -1
  97. package/dist/m/{c-2BLVA6IN.js → c-XQTERKQA.js} +1 -1
  98. package/dist/m/{c-A5ZBFICF.js → c-YFO6ZJLQ.js} +1 -1
  99. package/dist/m/{c-PD5AKHY3.js → c-YX2MC7YL.js} +1 -1
  100. package/dist/m/{c-OYHI67B6.js → c-YYGUHSZR.js} +1 -1
  101. package/dist/m/{c-YYMZWQLR.js → c-Z2OZFX3N.js} +1 -1
  102. package/dist/m/{c-AT7GLQGV.js → c-ZEA3DCG3.js} +1 -1
  103. package/dist/m/{c-75N3KG35.js → c-ZJG6C2WL.js} +1 -1
  104. package/dist/m/{c-FVPNX2GO.js → c-ZORVIXUR.js} +1 -1
  105. package/dist/m/{c-FV46ZKGX.js → c-ZPI7M5U5.js} +1 -1
  106. package/dist/m/core/{admin-attrs-5UGMYCKW.js → admin-attrs-ZI6K6UGM.js} +1 -1
  107. package/dist/m/core/{autosave-4P66VXX7.js → autosave-BSM2ABJL.js} +1 -1
  108. package/dist/m/core/{conflict-notice-BAR23OWH.js → conflict-notice-7EQLDYPA.js} +1 -1
  109. package/dist/m/core/draft-mode-M5XNYBMY.js +1 -0
  110. package/dist/m/core/{host-permission-3HE4CFD6.js → host-permission-5BYP24GC.js} +1 -1
  111. package/dist/m/core/{is-edit-mode-GK7IFFMT.js → is-edit-mode-73OXKK5H.js} +1 -1
  112. package/dist/m/core/{persist-AIJGTKKI.js → persist-2SZO3PCE.js} +1 -1
  113. package/dist/m/core/{recovery-LPB6X72P.js → recovery-VZF7RJZF.js} +1 -1
  114. package/dist/m/core/{save-FV4ZCKBI.js → save-K6Q47J2X.js} +1 -1
  115. package/dist/m/core/{save-core-K7XFAWQ4.js → save-core-U3Z44XBO.js} +1 -1
  116. package/dist/m/core/{site-edits-TEQORBJU.js → site-edits-CFMF6GHD.js} +1 -1
  117. package/dist/m/core/{snapshot-FJP5IJL3.js → snapshot-PWCHYFL6.js} +1 -1
  118. package/dist/m/core/unsaved-warning-2WRZLJH5.js +1 -0
  119. package/dist/m/events/{index-B6TGDYMG.js → index-YKQIBZE5.js} +1 -1
  120. package/dist/m/internals/index-FSLC4M7J.js +1 -0
  121. package/dist/m/lib/{cache-bust-NH7VBWHO.js → cache-bust-BL4KUQAJ.js} +1 -1
  122. package/dist/m/loader-RC54UV7H.js +1 -0
  123. package/dist/m/manifest-3B84C829.js +1 -0
  124. package/dist/m/manifest-edit-1CB79AD4.js +1 -0
  125. package/dist/m/manifest.json +1215 -1129
  126. package/dist/m/options/{options-55LQHSJ4.js → options-LKIZ7YTG.js} +1 -1
  127. package/dist/m/page/{editor-KAZDJNND.js → editor-PKSPJ2XJ.js} +2 -2
  128. package/dist/m/plugins/{ai-edit-CUKQQWJX.js → ai-edit-3LG6MGS3.js} +1 -1
  129. package/dist/m/plugins/{cms-JANMGPHM.js → cms-T2HLI7JG.js} +16 -16
  130. package/dist/m/plugins/{demo-JIR7DXJA.js → demo-LMCMBILC.js} +1 -1
  131. package/dist/m/plugins/{derive-QVI3LI3A.js → derive-7DR5TUDH.js} +1 -1
  132. package/dist/m/plugins/{indicator-DAKZGAWI.js → indicator-CTKX4EY7.js} +1 -1
  133. package/dist/m/plugins/{live-UAJGWRNP.js → live-IFLL6YN5.js} +1 -1
  134. package/dist/m/plugins/{menu-BFJ644YX.js → menu-YJZ3JWBC.js} +1 -1
  135. package/dist/m/plugins/page-GTDPYGE4.js +2 -0
  136. package/dist/m/plugins/{page-view-OYN7Z5TS.js → page-view-NMCCU6MF.js} +1 -1
  137. package/dist/m/plugins/{people-VBDR5Y22.js → people-6WIWCZEG.js} +1 -1
  138. package/dist/m/plugins/{richclay-CBLZRFB4.js → richclay-L3BZMX4I.js} +3 -3
  139. package/dist/m/plugins/sortable-365SRRL3.js +1 -0
  140. package/dist/m/plugins/{source-ZXDD7F6X.js → source-7LSGOV46.js} +1 -1
  141. package/dist/m/plugins/{undo-5JI5G7CQ.js → undo-UES4KS7B.js} +1 -1
  142. package/dist/m/plugins/{upgrade-6B6QS3NR.js → upgrade-MMPZUCDM.js} +1 -1
  143. package/dist/m/plugins/{upload-DNJNW6H3.js → upload-NZET3DNQ.js} +1 -1
  144. package/dist/m/plugins/visitor-drafts-V5YB7XDH.js +20 -0
  145. package/dist/m/plugins/{visitor-drafts-owner-VIAZC3CF.js → visitor-drafts-owner-TLECTYWS.js} +1 -1
  146. package/dist/m/plugins/{visitor-menu-ZHKWIKKY.js → visitor-menu-QDV2MYJN.js} +1 -1
  147. package/dist/m/plugins/wire-3YGUO3GE.js +1 -0
  148. package/dist/m/{reader-core-F6RY3V64.js → reader-core-VR7EL4WV.js} +2 -2
  149. package/dist/m/sync/live-sync-XMXXZS3G.js +1 -0
  150. package/dist/m/vendor/sap-core.vendor-JANUQULT.js +7 -0
  151. package/dist/offline-manifest.json +724 -664
  152. package/docs/guide.md +1 -0
  153. package/entries/clay-data.js +1 -1
  154. package/entries/sap.js +6 -5
  155. package/package.json +2 -2
  156. package/src/core/draft-mode.js +11 -0
  157. package/src/core/save-core.js +16 -17
  158. package/src/core/save.js +1 -1
  159. package/src/core/unsaved-warning.js +2 -2
  160. package/src/lib/dirty-gate.js +19 -0
  161. package/src/lib/idb-error-code.js +20 -0
  162. package/src/loader.js +2 -0
  163. package/src/plugins/menu/visitor.js +33 -1
  164. package/src/plugins/page.js +19 -5
  165. package/src/plugins/sortable.js +7 -0
  166. package/src/plugins/visitor-drafts/commit.js +350 -0
  167. package/src/plugins/visitor-drafts/idb.js +227 -0
  168. package/src/plugins/visitor-drafts/prompt.js +23 -3
  169. package/src/plugins/visitor-drafts/recovery-menu.js +118 -0
  170. package/src/plugins/visitor-drafts/recovery.js +64 -0
  171. package/src/plugins/visitor-drafts/session.js +393 -0
  172. package/src/plugins/visitor-drafts/store.js +247 -27
  173. package/src/plugins/visitor-drafts.js +177 -28
  174. package/src/sync/live-sync.js +1 -1
  175. package/src/sync/wire-profile.js +9 -4
  176. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  177. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  178. package/src/vendor/hypercms.vendor.js +16 -16
  179. package/src/vendor/richclay.vendor.js +1 -1
  180. package/src/vendor/sap-core.vendor.js +6 -5
  181. package/src/vendor/sap-ownership.vendor.js +181 -11
  182. package/tool-generation/system.md +1 -1
  183. package/dist/m/attrs/save-freeze-JZHKRQLR.js +0 -1
  184. package/dist/m/c-234RSEIW.js +0 -1
  185. package/dist/m/c-2YIJ6ZA6.js +0 -1
  186. package/dist/m/c-H2H7TI6O.js +0 -1
  187. package/dist/m/c-HSNGLIFB.js +0 -3
  188. package/dist/m/c-JMDKPBMC.js +0 -4
  189. package/dist/m/c-LO4C4J77.js +0 -1
  190. package/dist/m/c-N7JPUPGC.js +0 -1
  191. package/dist/m/c-NEXFVJIT.js +0 -1
  192. package/dist/m/c-R23UHMXG.js +0 -1
  193. package/dist/m/c-T527QHOB.js +0 -1
  194. package/dist/m/c-V2U3F4QX.js +0 -1
  195. package/dist/m/c-VG5XIN4T.js +0 -1
  196. package/dist/m/c-YINHPF4V.js +0 -1
  197. package/dist/m/core/draft-mode-WSQAT67S.js +0 -1
  198. package/dist/m/core/unsaved-warning-FF63QGKI.js +0 -1
  199. package/dist/m/internals/index-AX4CG7B2.js +0 -1
  200. package/dist/m/loader-5IA5OJ3J.js +0 -1
  201. package/dist/m/manifest-98258645.js +0 -1
  202. package/dist/m/manifest-edit-ABF56000.js +0 -1
  203. package/dist/m/plugins/page-ZBIYHGK4.js +0 -2
  204. package/dist/m/plugins/sortable-HDOQ6GAC.js +0 -1
  205. package/dist/m/plugins/visitor-drafts-A5FDQ2G7.js +0 -20
  206. package/dist/m/plugins/wire-XOT7XKJL.js +0 -1
  207. package/dist/m/sync/live-sync-M2PP2IWY.js +0 -1
  208. package/dist/m/vendor/sap-core.vendor-OA5SKL7Q.js +0 -6
  209. /package/dist/m/plugins/{richclay-CBLZRFB4.js.LEGAL.txt → richclay-L3BZMX4I.js.LEGAL.txt} +0 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The IndexedDB half of the visitor draft store.
3
+ *
4
+ * One database per origin (`clay-visitor-drafts`), two stores:
5
+ *
6
+ * records key: the draft's text key (`clay:visitor-drafts:<slot>@<path>`) and its
7
+ * siblings `<key>:<name>`; value: JSON text
8
+ * parts key: `[recordKey, regionKey, partId]`; value: `{ rev, text }`
9
+ *
10
+ * Every change to a draft goes through `change()`, one readwrite transaction that reads,
11
+ * merges (the caller's `build`) and writes; see `commit.js`. The only other write is
12
+ * `putRow()`, a tab's own chain left blind at a hide. A transaction resolves only once it
13
+ * completes. Every failure rejects with an Error whose `code` says what happened
14
+ * (`unavailable`, `blocked`, `quota`, `aborted`, `unknown-schema`, `closed`, `bad-op`,
15
+ * `error`).
16
+ */
17
+
18
+ import { codeFor } from "../../lib/idb-error-code.js";
19
+
20
+ export const DB_NAME = "clay-visitor-drafts";
21
+ export const DB_VERSION = 1;
22
+ export const RECORDS = "records";
23
+ export const PARTS = "parts";
24
+ const OPEN_TIMEOUT_MS = 2000;
25
+
26
+ function failure(code, cause) {
27
+ const error = new Error(`visitor-drafts: the draft database failed (${code})`);
28
+ error.code = code;
29
+ if (cause !== undefined) error.cause = cause;
30
+ return error;
31
+ }
32
+
33
+ function cursorEntries(source, range) {
34
+ return new Promise((resolve, reject) => {
35
+ const out = [];
36
+ const req = source.openCursor(range);
37
+ req.onerror = () => reject(req.error);
38
+ req.onsuccess = () => {
39
+ const cursor = req.result;
40
+ if (!cursor) return resolve(out);
41
+ out.push([cursor.key, cursor.value]);
42
+ cursor.continue();
43
+ };
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Open (and, the first time, create) the draft database.
49
+ *
50
+ * A database that does not open within `timeout` milliseconds (another tab holds an
51
+ * upgrade, or the browser never answers) rejects with `blocked`, so the caller can
52
+ * refuse to write over a draft it could not read rather than wait forever.
53
+ *
54
+ * @param {Object} [options]
55
+ * @param {?IDBFactory} [options.factory]
56
+ * @param {?typeof IDBKeyRange} [options.keyRange]
57
+ * @returns {Promise<{readAll: Function, readParts: Function, change: Function, putRow: Function, close: Function}>}
58
+ */
59
+ export function openDrafts({ factory = globalThis.indexedDB, keyRange = globalThis.IDBKeyRange, name = DB_NAME, timeout = OPEN_TIMEOUT_MS } = {}) {
60
+ return new Promise((resolve, reject) => {
61
+ if (!factory || typeof factory.open !== "function" || !keyRange) return reject(failure("unavailable"));
62
+ let req;
63
+ try {
64
+ req = factory.open(name, DB_VERSION);
65
+ } catch (error) {
66
+ return reject(failure(codeFor(error), error));
67
+ }
68
+ let settled = false;
69
+ const finish = (fn, value) => {
70
+ if (settled) return false;
71
+ settled = true;
72
+ clearTimeout(timer);
73
+ fn(value);
74
+ return true;
75
+ };
76
+ const timer = setTimeout(() => finish(reject, failure("blocked")), timeout);
77
+ req.onupgradeneeded = () => {
78
+ const db = req.result;
79
+ if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS);
80
+ if (!db.objectStoreNames.contains(PARTS)) db.createObjectStore(PARTS);
81
+ };
82
+ req.onsuccess = () => {
83
+ const db = req.result;
84
+ // An open that answered after the timeout is closed again, or it would hold the
85
+ // database against another tab's upgrade for as long as this page stays open.
86
+ if (!finish(resolve, wrap(db, keyRange))) db.close();
87
+ };
88
+ req.onerror = () => finish(reject, failure(codeFor(req.error), req.error));
89
+ req.onblocked = () => finish(reject, failure("blocked"));
90
+ });
91
+ }
92
+
93
+ function wrap(db, keyRange) {
94
+ let closed = false;
95
+ // Another tab opening a newer version needs this connection to stand aside.
96
+ db.onversionchange = () => {
97
+ closed = true;
98
+ try { db.close(); } catch {}
99
+ };
100
+
101
+ function transaction(stores, mode) {
102
+ if (closed) throw failure("closed");
103
+ try {
104
+ return db.transaction(stores, mode);
105
+ } catch (error) {
106
+ throw failure(codeFor(error), error);
107
+ }
108
+ }
109
+
110
+ // The record named `recordKey` and its siblings (`<recordKey>:<anything>`), as a Map
111
+ // from key to stored text. A key that only starts with `recordKey`, such as another
112
+ // document whose path extends this one's, is not a sibling.
113
+ async function readAll(recordKey) {
114
+ const tx = transaction(RECORDS, "readonly");
115
+ const range = keyRange.bound(recordKey, recordKey + ":￿");
116
+ try {
117
+ const entries = await cursorEntries(tx.objectStore(RECORDS), range);
118
+ return new Map(entries.filter(([key]) => key === recordKey || key.startsWith(recordKey + ":")));
119
+ } catch (error) {
120
+ throw failure(codeFor(error), error);
121
+ }
122
+ }
123
+
124
+ // The parts of one region of one record, as a Map from part id to `{ rev, text }`.
125
+ // An array sorts after every string, so `[recordKey, regionKey, []]` bounds them all.
126
+ async function readParts(recordKey, regionKey) {
127
+ const tx = transaction(PARTS, "readonly");
128
+ const range = keyRange.bound([recordKey, regionKey], [recordKey, regionKey, []]);
129
+ try {
130
+ const entries = await cursorEntries(tx.objectStore(PARTS), range);
131
+ return new Map(entries.map(([key, value]) => [key[2], value]));
132
+ } catch (error) {
133
+ throw failure(codeFor(error), error);
134
+ }
135
+ }
136
+
137
+ // The one read-merge-write. Reads the record, `:meta`, `:v1base` and every `:op:` row of
138
+ // `key`, hands them to `build` inside the transaction, and writes the `puts` and
139
+ // `deletes` it returns before the transaction can complete. A `build` or a write that
140
+ // throws aborts it, so nothing lands. Resolves with `build`'s `result` on `complete`.
141
+ function change(key, build, { timeout = 0, recovery = false } = {}) {
142
+ return new Promise((resolve, reject) => {
143
+ let tx;
144
+ try {
145
+ tx = transaction([RECORDS, PARTS], "readwrite");
146
+ } catch (error) {
147
+ return reject(error);
148
+ }
149
+ let thrown = null;
150
+ let result;
151
+ let settled = false;
152
+ let timer;
153
+ const finish = (fn, value) => {
154
+ if (settled) return;
155
+ settled = true;
156
+ clearTimeout(timer);
157
+ fn(value);
158
+ };
159
+ if (timeout > 0) timer = setTimeout(() => {
160
+ thrown = failure("blocked");
161
+ try { tx.abort(); } catch {}
162
+ finish(reject, thrown);
163
+ }, timeout);
164
+ tx.oncomplete = () => finish(resolve, result);
165
+ tx.onabort = () => finish(reject, thrown || failure(tx.error ? codeFor(tx.error) : "aborted", tx.error));
166
+ try {
167
+ const store = tx.objectStore(RECORDS);
168
+ const rows = keyRange.bound(key + ":op:", key + ":op:\uffff");
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
+ }
175
+ // Requests in one transaction answer in the order they were made: the last one in
176
+ // means every read is.
177
+ (reads.copyValues ?? reads.values).onsuccess = () => {
178
+ if (settled) return;
179
+ try {
180
+ const raw = {
181
+ record: reads.record.result ?? null,
182
+ meta: reads.meta.result ?? null,
183
+ v1base: reads.v1base.result ?? null,
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]]) }),
186
+ };
187
+ const planned = build(raw);
188
+ for (const [name, value] of planned.puts) store.put(value, name);
189
+ for (const name of planned.deletes) store.delete(name);
190
+ result = planned.result;
191
+ } catch (error) {
192
+ thrown = failure(codeFor(error), error);
193
+ try { tx.abort(); } catch {}
194
+ }
195
+ };
196
+ } catch (error) {
197
+ thrown = failure(codeFor(error), error);
198
+ try { tx.abort(); } catch {}
199
+ }
200
+ });
201
+ }
202
+
203
+ // A tab's own unconfirmed chain, written blind (no read) at a hide, with an explicit
204
+ // commit so it needs no callback in a page about to go away. `change()` applies it later.
205
+ function putRow(name, value) {
206
+ return new Promise((resolve, reject) => {
207
+ if (!/:op:[^:]+$/.test(name)) return reject(failure("bad-op"));
208
+ let tx;
209
+ try {
210
+ tx = transaction(RECORDS, "readwrite");
211
+ tx.objectStore(RECORDS).put(value, name);
212
+ tx.commit?.();
213
+ } catch (error) {
214
+ return reject(error instanceof Error && error.code ? error : failure(codeFor(error), error));
215
+ }
216
+ tx.oncomplete = () => resolve();
217
+ tx.onabort = () => reject(failure(tx.error ? codeFor(tx.error) : "aborted", tx.error));
218
+ });
219
+ }
220
+
221
+ function close() {
222
+ closed = true;
223
+ try { db.close(); } catch {}
224
+ }
225
+
226
+ return { readAll, readParts, change, putRow, close };
227
+ }
@@ -21,7 +21,9 @@ const ASIDE = `<aside class="hcp" id="hcp" hidden aria-label="Download this app"
21
21
  </div>
22
22
  </aside>`;
23
23
 
24
- export async function mountPrompt({ memory, appName, link }) {
24
+ // `settle` returns a promise while the page's last changes are not yet where the download
25
+ // page reads them, and null once they are. False requests a download from this page.
26
+ export async function mountPrompt({ memory, appName, link, settle, canDownloadCurrent = () => true }) {
25
27
  const { PROMPT_CSS } = await import("./prompt-style.js");
26
28
  const existing = document.querySelector('clay-editor-ui[data-clay-ui="download-prompt"]');
27
29
  let root = existing?.shadowRoot.getElementById("hcp");
@@ -118,6 +120,7 @@ export async function mountPrompt({ memory, appName, link }) {
118
120
  }
119
121
  }
120
122
 
123
+ let rescueRequested = false;
121
124
  root.addEventListener("click", async (e) => {
122
125
  const t = e.target.closest("[data-hcp]");
123
126
  if (t) {
@@ -131,9 +134,23 @@ export async function mountPrompt({ memory, appName, link }) {
131
134
  return;
132
135
  }
133
136
  if (e.target.closest("[data-hcp-link]")) {
134
- if (root.getAttribute("data-hcp-reason") === "error") {
137
+ if (!canDownloadCurrent()) return;
138
+ let rescue = rescueRequested || root.getAttribute("data-hcp-reason") === "error";
139
+ if (!rescue) {
140
+ const waiting = settle?.();
141
+ if (waiting) {
142
+ e.preventDefault();
143
+ if (await waiting !== false) {
144
+ linkEl.click();
145
+ return;
146
+ }
147
+ rescue = true;
148
+ }
149
+ }
150
+ if (rescue) {
135
151
  e.preventDefault();
136
152
  if (!(await downloadCurrent())) {
153
+ showError();
137
154
  root.querySelector(".hcp-body [data-hcp-for='error']").textContent = "The download could not be made. Keep this page open and try Download free again.";
138
155
  return;
139
156
  }
@@ -152,7 +169,10 @@ export async function mountPrompt({ memory, appName, link }) {
152
169
  function clearError() {
153
170
  if (root.getAttribute("data-hcp-reason") === "error") root.setAttribute("data-hcp-reason", "use");
154
171
  }
155
- const api = { set, root, showError, clearError, download: () => linkEl.click() };
172
+ const api = { set, root, showError, clearError, download: (rescue = false) => {
173
+ rescueRequested = rescue;
174
+ try { linkEl.click(); } finally { rescueRequested = false; }
175
+ } };
156
176
 
157
177
  document.addEventListener("input", onUse, true);
158
178
  document.addEventListener("change", onUse, true);
@@ -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
+ }