domma-cms 1.8.32 → 1.8.34

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 (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,318 @@
1
+ # Docs - AI Assistant Guide
2
+
3
+ A document editor in the admin (2.0): Home and a per-document editor, sharing with people, public
4
+ links, import/export, send to page/blog/email, an edit lock, trash, stars, version history, templates.
5
+ Free (2.14.0; paid and closed source until then). Plan and phases (with Docs Pro): `dcms-marketplace/docs/docs-plan.md`.
6
+
7
+ ## Files
8
+
9
+ | File | What |
10
+ |---|---|
11
+ | `plugin.js` | the admin API (`/api/plugins/docs`) |
12
+ | `plugin.public.js` | the public link page `GET /docs/s/:token` (`?download=md|html|txt`), via `renderBlogPage` + `templates/public-doc.html` |
13
+ | `lib/store.js` | storage, `accessOf`, user state (stars/recent), the edit lock table, link index, `sanitizeDoc`, `selectRows`, `purgeTrash`. Shared by both halves: its module state (`runtime.config`, locks, caches) is one copy. |
14
+ | `lib/importer.js` | `.md` (CMS `marked`, GFM task lists -> checklists), `.html`, `.txt` -> sanitised HTML |
15
+ | `lib/pdf.js`, `lib/pdf-worker.js` | 2.13: `inspectPdf` (the checks a PDF passes before it is kept), `extractPdfText` (pdfjs-dist in a worker thread with a time budget), `extractToHtml`, `contentDisposition` |
16
+ | `admin/lib/pdf-view.js` | 2.13: the PDF screen's parts - the viewer frame, facts, download, replace, the PDF download menu |
17
+ | `lib/extensions.js` | the registry `globalThis[Symbol.for('domma.docs')]` for Docs Pro |
18
+ | `admin/lib/convert.js` | pure, DOM-free: `parseHtml`, `htmlToMarkdown`, `htmlToText`, `countWords`, `preview`, `outline`, `standaloneHtml`, `startsWithTitle`. Used by server AND browser. |
19
+ | `admin/lib/shortcuts.js` | pure: Markdown typing shortcuts |
20
+ | `admin/lib/editor.js` | `DocEditor` - contenteditable + execCommand, toolbar, shortcuts as DOM work, checklists, tables, image paste/upload |
21
+ | `admin/lib/api.js` | `docsApi` (through `H`), `loadKit`, `loadExtensions`, `sanitizeHtml`, download/print helpers |
22
+ | `admin/lib/share.js`, `send.js`, `settings.js`, `versions.js`, `folders.js`, `templates.js`, `find-replace.js` | panels and helpers |
23
+ | `admin/views/docs.js` | Home (`#/plugins/docs`) |
24
+ | `admin/views/editor.js` | the editor (`#/plugins/docs/d/:id`) |
25
+
26
+ ## Storage - files only
27
+
28
+ | Path | Holds |
29
+ |---|---|
30
+ | `data/documents/<id>.json` | `id, title, content (HTML), format:'html', folderId, tags, userId (owner), shares:[{userId, role:'viewer'|'editor', addedAt, addedBy}], publicLink:{token, createdAt, createdBy}|null, trashedAt, trashedBy, wordCount, createdAt, updatedAt, updatedBy` |
31
+ | `data/versions/<id>/<n>.json` | `num, title, content, createdAt, savedBy, replacedBy` |
32
+ | `data/user-state.json` | `{[userId]: {starred:[id], recent:[{id, at}]}}` |
33
+ | `data/folders.json`, `data/templates.json` | through `_lib/dataStore` (per user) |
34
+ | `data/.backup-markdown/` | originals of documents converted from the old Markdown editor |
35
+ | `data/files/<id>.pdf` | 2.13: a PDF document's file; `data/versions/<id>/<n>.pdf` is the file an earlier version was |
36
+
37
+ Document writes are atomic (tmp + rename) and serialised per document. **Every route that changes an
38
+ existing document uses `mutateDoc(id, fn)`**, which re-reads inside the per-document write chain (a
39
+ share saved while an autosave lands must not write back a stale body). Single process only. There is
40
+ no MongoDB mode. Tests set `DOCS_DATA_DIR` (tests/_env.js, imported first) to a temp dir.
41
+
42
+ ## Rules that matter
43
+
44
+ - **HTML is the only stored format.** Every body goes through `sanitizeDoc` (sanitize-html allowlist:
45
+ document tags, a few inline styles, `data-type` on ul, `data-checked` on li; http/https/mailto/tel
46
+ links, data: only for img) on create, update, import, restore and template save; `full()` and the
47
+ public page sanitise again on the way out. `editorMode` was removed in 2.0. `migrateLegacy` (onReady)
48
+ upgrades every document/template without `format` once, per document: no HTML tags = Markdown ->
49
+ `marked`, otherwise sanitised (1.x stored bodies raw). Versions are upgraded on restore. Backups in
50
+ `data/.backup-markdown/`.
51
+ - **The admin sanitises with DOMPurify directly** (`api.js sanitizeHtml`): `ALLOW_DATA_ATTR:false` plus
52
+ only `data-type`/`data-checked` (`allowDocAttrs` for add-ons). Domma.sanitize drops all `data-*`
53
+ (checklists became bullets); allowing all would let a body's `data-on-click`/`data-help` reach the
54
+ admin's bindings. The editor also sets the body only AFTER applyBindings/helpPopovers/icons.scan.
55
+ No DOMPurify -> text only.
56
+ - **Lost updates:** the editor sends `baseUpdatedAt`; a text save from a stale copy -> 409 `{conflict}`
57
+ and the editor keeps its text as a copy. The heartbeat compares the lock's `updatedAt` too (a lock
58
+ that lapsed while a laptop slept).
59
+ - **Versions:** one person's saves within 10 minutes of their last save add none (`shouldSnapshot`);
60
+ restores always snapshot.
61
+ - **Trash** belongs to the owner: sharees get 404, send/shares/link -> 409, Trash view and Empty Trash
62
+ are the user's own docs only (even an admin with `scope:'all'`).
63
+ - **Email:** `allowSend` site setting; 50 addresses per person per day (in memory) on top of 10 per send
64
+ and 10 sends / 10 min.
65
+ - **Known gap:** the public page renders through the site template, so site analytics / injected
66
+ scripts on that page can see the token in `location.href`.
67
+ - **Access** (`accessOf`): owner > shares (editor/viewer) > `scope:'all'` (everyone reads, admins are
68
+ owners). No access -> 404; a viewer asking to edit -> 403. Owner only: move, trash/restore/delete,
69
+ removing editors (an editor may add people, never remove an editor). Duplicates are always the
70
+ copier's own, unshared, without a link.
71
+ - **Edit lock** (in memory, per browser tab `session`, TTL 90s, heartbeat 30s): `POST /lock` answers
72
+ 200 `{ok:false, lock, canTakeOver}` when held elsewhere (never 423 - the admin's `H` hides error
73
+ bodies). `PUT` of title/content from another session -> 423. `force` only for the owner or the same
74
+ user. The editor that loses the lock saves its unsaved content as "<title> (your unsaved changes)"
75
+ via `POST /duplicate {title, content}`.
76
+ - **Protected links fail closed (2.3):** `doc.linkGuarded` (set by Docs Pro through `setLinkGuard`) means the public page
77
+ answers 404 unless an extension with a `publicGate` is registered (`registry.hasGate()`) - a lapsed Docs Pro never
78
+ opens a password-protected or expired link.
79
+ - **Public links:** a random 18-byte token; `docByToken` returns null when the site setting
80
+ `publicLinks` is false or the document is trashed. Rate-limited 60/min, `noindex`, `no-store`,
81
+ `no-referrer`. Turning a link off and on makes a new token.
82
+ - **Send to page/blog** is done in the browser through the core `/api/pages` and Blog `/api/plugins/blog/posts`
83
+ with the user's own token (Markdown body, drafts) - Docs grants nothing.
84
+
85
+ ## Routes (`/api/plugins/docs`)
86
+
87
+ - `GET /count` (2.7) - sidebar badge: documents you own (Trash left out), `inventory: true`; details rows Yours / Shared with you / Starred / In Trash. Cached a minute per user (a Map; entries older than 60 s are dropped), keyed to a write generation bumped in `onResponse` for every non-GET request - a count stores its answer only if no write finished while it read
88
+ - `GET/PUT /settings` (`autoSaveInterval, maxVersions, publicLinks, allowSend, trashDays`; `scope` read-only; `extensions`)
89
+ - `GET /people` - share picker (id, name; email for admins), filtered to roles holding `docs.read`
90
+ - `GET /documents?view=mine|recent|starred|shared|all|trash&folder=&q=` - rows with `excerpt`, no body
91
+ - `POST /documents`, `POST /documents/import {name, text, folderId}` (5 MB)
92
+ - `GET/PUT/DELETE /documents/:id` (`GET ?poll=1` = a reader's refresh, not recorded as opened; `PUT
93
+ baseUpdatedAt`) (DELETE = Trash; `?permanent=1` from Trash only), `POST .../restore`, `POST /trash/empty`
94
+ - `POST .../duplicate {title?, content?}`, `POST .../star {on}`
95
+ - `POST/DELETE .../lock` (`session`), `PUT .../shares {shares, notify, message}`, `POST/DELETE .../link`
96
+ - `POST .../send {to, message, mode:'inline'|'link'}` - max 10, 10 per 10 min
97
+ - `GET .../versions`, `GET .../versions/:num`, `POST .../versions/:num/restore`
98
+ - `GET/POST /folders`, `PUT/DELETE /folders/:id`, `GET/POST /templates`, `DELETE /templates/:id`
99
+
100
+ ## Extension registry (for Docs Pro)
101
+
102
+ `docsRegistry()` - `register(owner, {label, admin, markup, publicGate, linkInfo, on})`, `services`
103
+ (readDoc, writeDoc, listDocs, accessOf, sanitizeDoc, allowMarkup, siteBase, people, config, htmlToText,
104
+ and since 2.1 `listFolders`, `isAdmin` - Docs Pro's Spaces are folders; since 2.2 `convert` {standaloneHtml,
105
+ htmlToMarkdown, htmlToText, fileName, startsWithTitle, parseHtml (2.4)} - Docs Pro's cloud copies and Word files; since 2.3 `setLinkGuard(docId, on)`;
106
+ since 2.4 `createDoc({title, content, folderId}, user)` - sanitised, folder checked with `usableFolder`, into Recent),
107
+ `gate()`, `linkInfo()`, `emit('saved'|'trashed'|'deleted'|'linkRemoved')`. Register from `onReady`.
108
+ The admin screens import each `admin` module and call `install(host)`; the host offers
109
+ `addDocMenu(fn)`, `addShareSection(fn)`, Home `addPlace({key,label,icon,render})`, editor
110
+ `openSide(title)`/`closeSide()`, `doc()`, `reload()`, `save()`, `onDispose`, and since 2.1 `addChip()` (an
111
+ element beside the lock/Trash chips) and `watchDoc(fn)` (called with the document now and on every change).
112
+ Home hides search, sort and grid/list while an add-on place is open (2.1).
113
+ Since 2.4: Home `addImporter({ext: ['.docx'], accept, label, import(file, {folderId}) => doc})` (New > Import and drop
114
+ use it for matching files; extends the file input's `accept`), both screens `allowAttrs([...])` (more attributes kept by the
115
+ admin's DOMPurify - Pro's `data-comment`; the SERVER side is the registry's `markup`), and the editor's DocEditor
116
+ `saveRange()`, `restoreRange(r)`, `selectedText(r)`, `insertHtml(html, r)`, `applyNamed(name, r)` (2.8 - a named
117
+ toolbar action at a saved selection: bold/italic/underline/strike/highlight/inlineCode/link/clearFormat/bulletList/
118
+ numberedList/checklist/quote; refused read-only and while suggesting), `wrapSelection({data-*}, r)` (a span; across
119
+ blocks keeps the first block's part), `unwrap(selector)`. "Use a template" sets `sessionStorage['docs:fill-next']='1'` when
120
+ the template holds `{{...}}` - Pro's merge fields open their fill-in panel.
121
+
122
+ ## Admin traps met here
123
+
124
+ - `data-each` templates are set in JS; whole class lists are one `{{cls}}` (whitespace next to
125
+ `{{…}}` is dropped); a select's value is set after render.
126
+ - Markdown shortcuts are DOM work: `execCommand('insertUnorderedList')` in a freshly emptied `<p>`
127
+ nests the list inside the `<p>`.
128
+ - Headless tests: one browser context per user (a shared context shares localStorage and so the
129
+ sign-in); Domma keeps a hidden `.dm-dialog-confirm` - click the visible one.
130
+
131
+ ## Tests
132
+
133
+ `tests/plugin.test.js` (API: settings, versions, permissions, sanitising, sharing, notify, lock, trash,
134
+ star/recent, public links + page, import, send, people - mail and users are injected via
135
+ `options.mailer` / `options.listUsers`), `tests/convert.test.js`, `tests/shortcuts.test.js`. Run from
136
+ the CMS: `cd cms && node --test --test-concurrency=1 ../dcms-marketplace/plugins/docs/tests/*.test.js`.
137
+
138
+ ## Suggesters (2.5)
139
+
140
+ Share role `suggester` - `accessOf` answers it; `canEdit` is false. While an extension registers `suggest: {check}` (Docs Pro,
141
+ `registry.hasSuggest()`), a Suggester may take the edit lock and PUT the body: title/tags ignored, folder refused, the lock
142
+ REQUIRED (423 without it), and the body stored is `registry.checkSuggestion(stored, sent, user)` (null -> 403
143
+ `suggestOnly`). Without Pro a Suggester is a Viewer everywhere (no lock, no save) and the share role is kept as it was
144
+ (new ones cannot be given). `/settings` answers `suggestions`. Admin: share panel offers "Suggester" when
145
+ `site.suggestions` (or someone already has it); the editor treats a Suggester as able to edit (then Pro forces
146
+ suggesting mode), title read-only, no version restore. DocEditor `setSuggesting(on)` switches off the toolbar, Markdown
147
+ shortcuts, Tab, Enter-in-checklist, checklist ticks, the table menu, Docs' paste/drop handling and find-and-replace's
148
+ Replace - so an add-on can track every change from `beforeinput`.
149
+
150
+ **What leaves Docs** goes through add-ons' filters: server `registry.publicHtml(html)` (public page and its downloads,
151
+ email "inline") and admin `outgoing(html)` (`setContentFilter(key, fn)` on both screens' host; downloads, print, Send
152
+ to Page / Blog). Docs Pro uses them to strip comment marks and show pending suggestions as not made.
153
+
154
+ ## Ready-made templates (2.6)
155
+
156
+ An extension's `templates()` (registry) lists read-only templates: `GET /templates` answers the user's own first, then
157
+ `{id: 'x-<owner>-<id>', name, description, category, content (sanitised), builtIn: true, source: <label>}`. Home shows
158
+ "<category> · <source>" and no Delete. (admin/lib/templates.js `showPicker` is not wired to anything - its built-in
159
+ branch only matters if it is revived.) DELETE on one is a 404
160
+ (they are not in templates.json).
161
+
162
+ ## 2.8.1 (2026-10-13): trusted email links; history for editors
163
+
164
+ - `siteBase()` is the Site URL (or old `site.url`) only; `linkBase(req)` (async, in the registry's services) is what
165
+ emails and the share panel's link use: the Site URL, else core's `trustedOrigin` (an address someone has really
166
+ signed in on, as password resets do). Never the request's Host header - an insider could send colleagues a link to a
167
+ lookalike host. With no trusted address the share email names the path and the log warns.
168
+ - `GET .../versions` and `.../versions/:num` answer 403 to anyone who cannot edit (viewers, suggesters, `scope:'all'`
169
+ readers): a version may hold text the owner removed before sharing. The editor menu shows Version history to
170
+ editors and owners only.
171
+
172
+ ## The Tool Upgrade (2.9.0, 2026-10-14)
173
+
174
+ - **Home banner:** New (+, the New menu is bound to that button) and Import beside the cog. The rail's own New button is
175
+ hidden when the banner exists (kept for a core without the chrome). A help mark beside the list title explains the places.
176
+ - **Shift+F10** on a focused document card, template card or folder opens its menu; every Home menu has a header.
177
+ - **Sidebar badge:** `recount()` (kit.sidebarCount - added to loadKit) after create, import, copy, trash, restore,
178
+ delete for good and Empty Trash.
179
+ - **Unsaved work:** the editor's existing `beforeunload` (draft + browser prompt when dirty) now also asks while a save is
180
+ on its way. Share, Email and Settings ask before closing with a change (`admin/lib/panel-guard.js` guardClose, applied
181
+ AFTER kit.onceClosed - otherwise onceClosed's wrapper tears the panel down whatever the question answered).
182
+ - **The editor keeps its own top bar** for Outline, Find, History, Focus, Share and More - Focus mode hides the admin
183
+ banner (`body.docs-focus .dm-plugin-banner`), so document actions in the banner would vanish exactly when writing.
184
+ - Prompts with a starting value use `promptWith(message, options, value)` (api.js), never E.prompt's `inputValue`: Domma
185
+ sanitises that into `value="…"` and turns `&quot;` back into a bare quote, so a title with `"` opened cut short (escaping
186
+ it does not help). The value is set as a property on the dialog's input, which exists as soon as E.prompt returns.
187
+
188
+ ## 2.10.0 (2026-10-15 Tool Upgrade, second pass)
189
+
190
+ - Drafts: `admin/lib/drafts.js` (pure, tested), stored as `docs.draft.<userId>.<docId>` = `{owner, title, content, at,
191
+ was}`. Another person's draft is never read back; 2.9's drafts with no owner are dropped. Restore question: OK throws
192
+ away, Cancel/Escape put back, or make a new document "<title> (your unsaved changes)" if the original is gone. Enter
193
+ is ignored and the page locked while it asks; a draft goes only after an answer.
194
+ - `panel-guard.js`: `claimPanel` (one of a kind), `tieToScreen`, `guardClose(..., busy)`. Share and Settings keep values
195
+ typed during a slow save; Email ignores close while sending.
196
+ - `/count` answers `places` {starred, shared, mine (top level), trash} for the rail. Its cache generation is
197
+ `runtime.writeGen` in lib/store.js, bumped by EVERY store write (Docs Pro writes through services, not Docs' routes).
198
+ - Menus on Share people and Version history rows.
199
+
200
+ ## 2.11.0 (2026-10-15): the Wiki's hooks
201
+
202
+ - Registry `access: (doc, user) => 'editor' | 'viewer' | null` (synchronous) - `extraAccess()` gives the strongest any
203
+ extension grants. `store.accessOf` takes the stronger of the share/site-wide role and the extension's (owner stays
204
+ owner). The Wiki makes `wiki.update` holders editors of every wiki page.
205
+ - `onlyThroughExtension(doc, user)`: a document reached ONLY through an extension (not owned, not shared, not
206
+ site-wide) is in no Home place but Starred (when starred) and Recent (when opened) - it was never shared with them.
207
+ - Services: `mutateDoc(id, fn)`, `publicHtml(html)` (= `registry.publicHtml(withoutSuggestions(sanitizeDoc(html)))`,
208
+ what the public sees), `lockOf(id)` (the lock as `publicLock` shows it to nobody's session, or null).
209
+ - Access ONLY through an extension is for editing and history: `outwardRefusal(doc, user)` - PUT shares, POST/DELETE
210
+ link and send answer 403; the editor hides Share and Send to, Home hides Share/Email (`row.outward`).
211
+ - Registry `restrict: (doc) => null | {reason}` (`restrictionOf`, a throwing one restricts): for EVERYONE, owner
212
+ included - POST link and send refused (403, the reason in `outwardRefusal`), the public page `/docs/s/:token` 404s
213
+ (an existing link stops working while restricted, and works again after), the share panel shows the reason instead
214
+ of the link switch, Send to is hidden. PUT shares refuses (403) any change that ADDS someone, owner included -
215
+ existing shares keep working and removing people is allowed; the share panel hides Add people and says why (`restricted` on the document). The Wiki restricts its pages
216
+ while it is members-only. (Send to page/blog is browser-side through core APIs: hidden, not enforced.)
217
+ - Events `restored(doc, user, num)` after a version restore, `untrashed(doc, user)` after a restore from Trash.
218
+ - Tested through the Wiki's API tests (`plugins/wiki/tests/api.test.js`) with real Docs underneath.
219
+
220
+ ## 2.12.0 (2026-10-15): freeze - protection that reaches Docs' editor
221
+
222
+ - Registry `freeze: (doc, user) => null | {reason}` (synchronous; `frozenFor(doc, user)` - the first reason, a throwing
223
+ one freezes) and `store.frozenFor()`. For EVERYONE, owner included: PUT of title/content -> 403 `{error: reason,
224
+ frozen: true}` (folder, tags still allowed), `POST /lock` -> `{ok: false, readOnly: true, frozen}`, version restore
225
+ and DELETE (Trash or permanent) -> 403. Reading, history, duplicating and the rest are untouched. `full()` answers
226
+ `frozen` (the reason, '' when not); the editor treats a frozen document as not editable (`mayEdit`) and shows a
227
+ "Protected" chip with the reason as its title.
228
+ - The Wiki (0.2) registers it from its add-ons' `canEdit` (Wiki Pro's protection, locks and a wiki's editor roles).
229
+ `typeof registry.frozenFor === 'function'` is how a consumer detects 2.12 (the Wiki's `/status.protectable`).
230
+ - A frozen document also refuses `POST .../restore` (out of Trash), and `POST /trash/empty` leaves it in Trash.
231
+ - Service `outwardRefusal(doc, user)` - why this person may not share, link, send or publish it ('' = they may);
232
+ Docs Pro 0.9.1 refuses to publish such a document to a Space.
233
+ - Credited saves (for Wiki Pro's approved suggestions): service `snapshotVersion(doc, by)` keeps what a change replaces
234
+ as a version, as a save does. A document's `creditName` + `creditAt` (equal to its `updatedAt`) names who that save
235
+ is credited to ("Sam (visitor)"): the version made of it carries `savedByName`, and `shouldSnapshot` never folds a
236
+ credited save into the next one. Both apply only while `creditAt === updatedAt` - the next ordinary save ends it.
237
+
238
+ ## 2.13.0 (2026-10-20): PDF documents
239
+
240
+ A document can be a file. `format: 'pdf'` + `file: {name, size, pages, sha256, encrypted, uploadedAt, uploadedBy}`
241
+ (`fileRecord()`, whitelisted); the bytes at `data/files/<id>.pdf`; `content` is the TEXT READ OUT OF THE FILE as
242
+ `<p>` HTML (`extractToHtml`, sanitised), so search, the card excerpt and `wordCount` work unchanged. `kindOf(doc)` -
243
+ `'pdf' | 'html'` - is the one question; everything that renders, exports or edits a body asks it. Docs never rewrites
244
+ a PDF: it is kept whole or refused.
245
+
246
+ - **Checks (`lib/pdf.js inspectPdf`)**: 20 MB cap (`MAX_PDF_BYTES`), `%PDF-` in the first KB, then every name in
247
+ `REFUSED_NAMES` (JavaScript, JS, Launch, EmbeddedFile(s), RichMedia, XFA, GoToE, SubmitForm, ImportData) searched in
248
+ the raw bytes AND every FlateDecode stream inflated (per-stream and total caps - a bomb is skipped, not followed), with
249
+ `#xx` name escapes undone first. Refused with the plain reason, never stripped. `/OpenAction` is NOT refused (a plain
250
+ "open at page 1" is in every LaTeX export; a script behind it is caught by /JS). Encrypted files are kept (no extract).
251
+ - **Text extract**: `extractPdfText` runs pdfjs-dist (`pdfjs-dist/legacy/build/pdf.mjs`, an OPTIONAL dependency of the
252
+ CMS - `extractCapability()` resolves it without loading) in a `worker_threads` Worker with scripting off, no fonts,
253
+ 300 pages / 200k chars, and a 20 s budget - over time it is terminated and the document stored without an extract.
254
+ `GET /settings` answers `pdf: {maxBytes, search, reason}`. Tests skip the extract test where pdfjs is absent.
255
+ - **Routes**: `POST /documents/upload {name, data: base64, folderId, title?}` (the same road as Docs Pro's Word import -
256
+ no multipart dependency), `GET /documents/:id/file` (inline; `?download=1` saves; anyone who may read),
257
+ `PUT /documents/:id/file {name, data}` (editors; ALWAYS a version - a new upload is a real change; 409 in Trash, 423
258
+ while another session holds the lock, 403 frozen), `GET /documents/:id/versions/:num/file` (editors, as history is),
259
+ `GET /documents/:id/view?t=<ticket>` (below). PUT of `content` on a PDF -> 400; title/folder/tags still save and keep
260
+ `format`. Duplicate copies the file BEFORE the record is written. Restore of a PDF version copies `<n>.pdf` back.
261
+ `removeDoc` removes the file; the version trim removes `<n>.pdf` with `<n>.json`. Send "inline" attaches the PDF.
262
+ - **The file goes out** (`sendPdf`) with `application/pdf`, `Content-Disposition` from the TITLE (never the uploaded
263
+ name), `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store`, `Content-Security-Policy: default-src
264
+ 'none'; sandbox`, `X-Robots-Tag: noindex`. The public page frames `/docs/s/<token>?file=1` and offers `?download=pdf`;
265
+ `?download=md|html|txt` on a PDF answers the PDF (nothing else is derived from it). `public-doc.html` takes its action
266
+ links as `{{actions}}` now.
267
+ - **View tickets - why the admin frame is not a blob**. The admin's CSP has `frame-src 'self' …`: a `blob:` frame is
268
+ REFUSED (the first build failed exactly there). An `<iframe>` carries no bearer header either. So `full()` answers
269
+ `fileTicket` for a PDF - HMAC-SHA256 of `{d: docId, u: userId, a: admin, e: expiry}` under a key made at boot
270
+ (`viewKey`), 20 minutes - and the frame's address is `/view?t=…` (no `authenticate`; the ticket names the person
271
+ and the route checks `accessOf` for them again). The editor polls every 30 s, so `current.fileTicket` is always
272
+ fresh; a restart ends every ticket and the next poll hands out new ones.
273
+ - **No `sandbox` ATTRIBUTE on the frame**: Chrome's PDF viewer will not open in a sandboxed iframe (`chrome-error://`
274
+ instead - measured). The CSP `sandbox` header on the response is fine (the viewer loads, measured too).
275
+ - **Registry `kinds`** (`register(owner, {kinds: ['html', 'pdf']})`, default `['html']`): an add-on written before PDF
276
+ documents sees none of them - `emit()` skips it for a PDF (first arg with `format`), `adminModules()` carries `kinds`,
277
+ Home tags each add-on's `addDocMenu`/`addShareSection` function with the installing add-on's kinds (`forKind`) and the
278
+ editor installs only add-ons declaring `'pdf'` in a PDF's screen. `registry.handles(ext, doc)`. SERVICES ARE NOT
279
+ FILTERED: Docs Pro < 0.10 that lists documents itself (Spaces' folder publish, cloud "every save" runs through
280
+ `on.saved` - skipped; but `listDocs` in a Space) sees the text extract as the body. Docs Pro 0.10 branches on `kindOf`.
281
+ Services added: `kindOf`, `readFile(docId)`, `writeFile(docId, buf, user, {name})` (= PUT .../file), `pdfCapability()`.
282
+ - **Admin**: Home routes `.pdf` files (`isPdfFile`) to `uploadPdf` (`readBase64`), cards say `PDF · n pages · size`
283
+ (`pdfFacts`) with a PDF chip, Download offers the PDF only (`pdfExportItems`). The editor for a PDF: the DocEditor
284
+ runs on a DETACHED element (its save/lock/outline paths stay inert) and `mountPdfViewer` has the real `.docs-page`;
285
+ `mayEdit()` is false (mode `view`, no lock, no drafts), the title is renamed from the menu (saved on its own),
286
+ `pdfMenu()` replaces `moreMenu()` (Rename, copy, move, Download PDF, Replace the PDF…, Send to > Email, history,
287
+ add-ons, Trash). `showContent()` for a PDF reloads the viewer when `file.sha256`/`uploadedAt` changed
288
+ (`fresh._before` carries the record as it was). Version history rows/preview show facts + Download this version.
289
+ `.docs-ed.is-pdf` hides the toolbar, outline, Outline and Find buttons (CSS).
290
+ - Tests: `tests/pdf.test.js` (lib), four `2.13:` tests in `tests/plugin.test.js`; `tests/_pdf.js` makes real PDFs
291
+ (xref correct, optional FlateDecode, extra objects for the refusals). Browser-checked headlessly: upload -> editor ->
292
+ viewer loads (Chrome's PDF viewer frame present) -> replace -> history -> Home card -> public page.
293
+
294
+ ## 2.13.1 (2026-10-20): `services.createPdfDoc`
295
+
296
+ `createPdfDoc({title, buf, name, folderId}, user)` - a PDF document from bytes, as the upload route makes one (checked
297
+ by `inspectPdf`, text read, filed where the person may file, into their Recent). Throws `{status, message}`. Docs Pro
298
+ 0.11's "take pages out" is the caller.
299
+
300
+ ## 2.13.2 (2026-10-20): review fixes
301
+
302
+ - **The name scan inflates every stream** (`lib/pdf.js searchableText`): no dictionary is parsed to decide - the first build
303
+ read the innermost `<<` before `stream`, so any stream whose dictionary held a nested one (every object stream with
304
+ /DecodeParms) was never inflated and a /JavaScript or /EmbeddedFile inside it passed. Each body that starts with a zlib
305
+ header is inflated, again while the output still looks deflated (double /FlateDecode, 3 deep), and a stream past the cap
306
+ (32 MB a stream, 160 MB in all) makes the file UNCHECKABLE and refused ("too large to check"), not skipped.
307
+ - **Restoring the oldest PDF version at the history cap** read its file after the restore's own snapshot had trimmed it:
308
+ the version's bytes are read BEFORE the snapshot now, and written with `writeFileBytes`.
309
+ - `POST /documents` passes only title/content/folderId/tags to `newDoc` - a record saying `format: 'pdf'` with no file
310
+ behind it can no longer be made there.
311
+ - `GET /documents/:id/view` re-checks that the ticket's person still exists, is active and holds a role with `docs.read`
312
+ (or level 0) - a ticket no longer outlives the permission for its 20 minutes.
313
+
314
+ ## 2.14.0 (2026-10-21): free
315
+
316
+ `tier: free`, no `closedSource` - no licence is needed or checked any more (the engine only checks a
317
+ `closedSource` plugin). Included in every hosted plan and free to self-host; not `bundle: false`, so the next
318
+ `make sync-free-tier` ships it in the engine. Docs Pro and Wiki Pro stay paid and still `requires: ["docs"]`.