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.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Docs API, and what the Docs screens share: the admin's helpers (loaded
|
|
3
|
+
* at run time - a plugin never imports admin/ statically), escaping, small
|
|
4
|
+
* DOM fragments and the add-on loader.
|
|
5
|
+
*
|
|
6
|
+
* Calls go through Domma's `H`, which the admin wraps to attach the access
|
|
7
|
+
* token and refresh it on a 401. It throws with the server's `error` text.
|
|
8
|
+
*
|
|
9
|
+
* @module docs/admin/lib/api
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const BASE = '/api/plugins/docs';
|
|
13
|
+
export const HOME = '#/plugins/docs';
|
|
14
|
+
export const docHref = (id) => `#/plugins/docs/d/${encodeURIComponent(id)}`;
|
|
15
|
+
|
|
16
|
+
const seg = (id) => encodeURIComponent(String(id));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Call a Docs endpoint - the old single-function form, kept for the libs
|
|
20
|
+
* (folders, templates, versions) that use it.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} path
|
|
23
|
+
* @param {string} [method='GET']
|
|
24
|
+
* @param {*} [body]
|
|
25
|
+
* @param {object} [headers]
|
|
26
|
+
* @returns {Promise<*>}
|
|
27
|
+
*/
|
|
28
|
+
export function api(path, method = 'GET', body, headers) {
|
|
29
|
+
return H.request(method, BASE + path, body ?? null, {headers: headers || {}, silent: true});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One browser tab's editing session - what the edit lock is held by. */
|
|
33
|
+
export function newSession() {
|
|
34
|
+
return (crypto.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`).replace(/[^A-Za-z0-9-]/g, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sessionHeader = (session) => (session ? {'X-Docs-Session': session} : {});
|
|
38
|
+
|
|
39
|
+
export const docsApi = {
|
|
40
|
+
settings: {
|
|
41
|
+
get: () => api('/settings'),
|
|
42
|
+
save: (d) => api('/settings', 'PUT', d)
|
|
43
|
+
},
|
|
44
|
+
people: () => api('/people'),
|
|
45
|
+
list: (params = {}) => {
|
|
46
|
+
const q = new URLSearchParams();
|
|
47
|
+
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') q.set(k, v);
|
|
48
|
+
return api(`/documents${q.toString() ? `?${q}` : ''}`);
|
|
49
|
+
},
|
|
50
|
+
get: (id, session, {poll = false} = {}) => api(`/documents/${seg(id)}${poll ? '?poll=1' : ''}`, 'GET', null, sessionHeader(session)),
|
|
51
|
+
create: (d) => api('/documents', 'POST', d),
|
|
52
|
+
update: (id, d, session) => api(`/documents/${seg(id)}`, 'PUT', d, sessionHeader(session)),
|
|
53
|
+
trash: (id) => api(`/documents/${seg(id)}`, 'DELETE'),
|
|
54
|
+
purge: (id) => api(`/documents/${seg(id)}?permanent=1`, 'DELETE'),
|
|
55
|
+
restore: (id) => api(`/documents/${seg(id)}/restore`, 'POST', {}),
|
|
56
|
+
emptyTrash: () => api('/trash/empty', 'POST', {}),
|
|
57
|
+
duplicate: (id, d = {}) => api(`/documents/${seg(id)}/duplicate`, 'POST', d),
|
|
58
|
+
star: (id, on) => api(`/documents/${seg(id)}/star`, 'POST', {on}),
|
|
59
|
+
importFile: (d) => api('/documents/import', 'POST', d),
|
|
60
|
+
// 2.13: PDF documents - the file goes up as base64 (the same road Docs Pro's Word import takes).
|
|
61
|
+
uploadPdf: (d) => api('/documents/upload', 'POST', d),
|
|
62
|
+
replaceFile: (id, d, session) => api(`/documents/${seg(id)}/file`, 'PUT', d, sessionHeader(session)),
|
|
63
|
+
fileUrl: (id, {download = false} = {}) => `${BASE}/documents/${seg(id)}/file${download ? '?download=1' : ''}`,
|
|
64
|
+
// The frame's address: the file by ticket (GET /documents/:id answers `fileTicket`), since a frame carries no bearer header.
|
|
65
|
+
viewUrl: (id, ticket) => `${BASE}/documents/${seg(id)}/view?t=${encodeURIComponent(ticket)}`,
|
|
66
|
+
versionFileUrl: (id, n, {download = false} = {}) => `${BASE}/documents/${seg(id)}/versions/${seg(n)}/file${download ? '?download=1' : ''}`,
|
|
67
|
+
shares: (id, d) => api(`/documents/${seg(id)}/shares`, 'PUT', d),
|
|
68
|
+
linkOn: (id) => api(`/documents/${seg(id)}/link`, 'POST', {}),
|
|
69
|
+
linkOff: (id) => api(`/documents/${seg(id)}/link`, 'DELETE'),
|
|
70
|
+
send: (id, d) => api(`/documents/${seg(id)}/send`, 'POST', d),
|
|
71
|
+
lock: (id, session, force = false) => api(`/documents/${seg(id)}/lock`, 'POST', {session, force}),
|
|
72
|
+
unlock: (id, session) => api(`/documents/${seg(id)}/lock?session=${seg(session)}`, 'DELETE'),
|
|
73
|
+
versions: (id) => api(`/documents/${seg(id)}/versions`),
|
|
74
|
+
version: (id, n) => api(`/documents/${seg(id)}/versions/${seg(n)}`),
|
|
75
|
+
restoreVersion: (id, n, session) => api(`/documents/${seg(id)}/versions/${seg(n)}/restore`, 'POST', {}, sessionHeader(session))
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Let go of the edit lock while the page itself is going away (a closed
|
|
80
|
+
* tab). A normal call may be cut off mid-flight; `keepalive` is not.
|
|
81
|
+
*/
|
|
82
|
+
export function unlockOnUnload(id, session) {
|
|
83
|
+
try {
|
|
84
|
+
const raw = S.get('auth_token');
|
|
85
|
+
const token = typeof raw === 'string' ? raw : '';
|
|
86
|
+
fetch(`${BASE}/documents/${seg(id)}/lock?session=${seg(session)}`, {
|
|
87
|
+
method: 'DELETE', keepalive: true, headers: token ? {Authorization: `Bearer ${token}`} : {}
|
|
88
|
+
}).catch(() => {});
|
|
89
|
+
} catch { /* best effort */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sanitise document HTML for the admin. DOMPurify directly, because the
|
|
94
|
+
* admin's Domma.sanitize drops data-* attributes - and checklists live in
|
|
95
|
+
* data-type / data-checked. Without DOMPurify the HTML goes in as the server
|
|
96
|
+
* stored it (the server sanitised it on the way in).
|
|
97
|
+
*
|
|
98
|
+
* @param {string} html
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
// Suggestion marks (2.5) are always kept, so they stay suggestions even while Docs Pro is off.
|
|
102
|
+
const DOC_ATTRS = ['target', 'data-type', 'data-checked', 'data-sug', 'data-by', 'data-name', 'data-at'];
|
|
103
|
+
|
|
104
|
+
/** Let an add-on keep more attributes in documents (Docs Pro comment anchors). */
|
|
105
|
+
export function allowDocAttrs(list) {
|
|
106
|
+
for (const a of list || []) if (/^[a-z][a-z0-9-]*$/.test(a) && !/^on/.test(a) && !DOC_ATTRS.includes(a)) DOC_ATTRS.push(a);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What a document says to the world (2.5): add-ons' filters over the body
|
|
111
|
+
* before it leaves Docs - a download, print, a new Page or Blog post. Docs
|
|
112
|
+
* Pro takes out comment marks and shows pending suggestions as not made.
|
|
113
|
+
* Keyed, so an add-on installed again replaces its own filter.
|
|
114
|
+
*/
|
|
115
|
+
const contentFilters = new Map();
|
|
116
|
+
|
|
117
|
+
/** Pending suggestions as not made - Docs' own, whether or not Docs Pro is on (the server's withoutSuggestions). */
|
|
118
|
+
export function withoutSuggestions(html) {
|
|
119
|
+
if (!html.includes('data-sug')) return html;
|
|
120
|
+
const t = document.createElement('template');
|
|
121
|
+
t.innerHTML = html;
|
|
122
|
+
t.content.querySelectorAll('ins[data-sug]').forEach(el => el.remove());
|
|
123
|
+
t.content.querySelectorAll('del[data-sug]').forEach(el => el.replaceWith(...el.childNodes));
|
|
124
|
+
return t.innerHTML;
|
|
125
|
+
}
|
|
126
|
+
export function setContentFilter(key, fn) {
|
|
127
|
+
if (typeof fn === 'function') contentFilters.set(String(key), fn); else contentFilters.delete(String(key));
|
|
128
|
+
}
|
|
129
|
+
export function outgoing(html) {
|
|
130
|
+
let out = withoutSuggestions(String(html ?? ''));
|
|
131
|
+
for (const fn of contentFilters.values()) { try { out = String(fn(out) ?? out); } catch { /* an add-on's problem */ } }
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sanitizeHtml(html) {
|
|
136
|
+
const P = window.DOMPurify;
|
|
137
|
+
if (P?.sanitize) {
|
|
138
|
+
// Only the data-* attributes documents use: a data-on-click or a help marker
|
|
139
|
+
// in someone's body must never reach the admin's own bindings.
|
|
140
|
+
return P.sanitize(String(html ?? ''), {
|
|
141
|
+
ALLOW_DATA_ATTR: false,
|
|
142
|
+
ADD_ATTR: DOC_ATTRS,
|
|
143
|
+
FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select', 'iframe', 'object', 'embed'],
|
|
144
|
+
FORBID_ATTR: ['id', 'name']
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// No sanitiser loaded: show the text, never raw markup.
|
|
148
|
+
const t = document.createElement('div');
|
|
149
|
+
t.textContent = String(html ?? '').replace(/<[^>]+>/g, ' ');
|
|
150
|
+
return t.innerHTML;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** E.confirm, titles and toasts render HTML: user text goes in escaped. */
|
|
154
|
+
export const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
155
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
156
|
+
|
|
157
|
+
/** Text as text, whatever it arrived as. */
|
|
158
|
+
export function asText(value) {
|
|
159
|
+
return value === null || value === undefined ? '' : String(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A small DOM tree from an HTML string that holds NO unescaped user data. */
|
|
163
|
+
export function fragment(html) {
|
|
164
|
+
const t = document.createElement('template');
|
|
165
|
+
t.innerHTML = html.trim();
|
|
166
|
+
return t.content.firstElementChild;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** An icon as an SVG string, for templates built in JS (empty when the name is unknown). */
|
|
170
|
+
export function icon(name, size = 16) {
|
|
171
|
+
try { return window.I?.html?.(name, {size}) || ''; } catch { return ''; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let kit = null;
|
|
175
|
+
|
|
176
|
+
/** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
|
|
177
|
+
export function loadKit() {
|
|
178
|
+
if (!kit) {
|
|
179
|
+
kit = Promise.all([
|
|
180
|
+
import('/admin/js/lib/tool-kit.js').catch(() => ({})),
|
|
181
|
+
import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
|
|
182
|
+
import('/admin/js/lib/help-popover.js').catch(() => ({}))
|
|
183
|
+
]).then(([tk, chrome, help]) => ({
|
|
184
|
+
onceClosed: tk.onceClosed || ((so, fn) => {
|
|
185
|
+
const c = so.close.bind(so);
|
|
186
|
+
let done = false;
|
|
187
|
+
so.close = (...a) => {
|
|
188
|
+
const r = c(...a);
|
|
189
|
+
if (!done) { done = true; fn?.(); setTimeout(() => { try { so.destroy?.(); } catch { /* gone */ } so.element?.remove(); }, 400); }
|
|
190
|
+
return r;
|
|
191
|
+
};
|
|
192
|
+
}),
|
|
193
|
+
openMenuAt: tk.openMenuAt || ((el) => {
|
|
194
|
+
const r = el.getBoundingClientRect();
|
|
195
|
+
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
196
|
+
}),
|
|
197
|
+
settingsStore: tk.settingsStore || ((key, normalise) => ({
|
|
198
|
+
read() { try { return normalise(S.get(key)); } catch { return normalise(null); } },
|
|
199
|
+
write(v) { try { S.set(key, v); } catch { /* convenience only */ } }
|
|
200
|
+
})),
|
|
201
|
+
viewLifetime: tk.viewLifetime || ((pattern) => {
|
|
202
|
+
let fns = [];
|
|
203
|
+
const run = () => { const l = fns; fns = []; l.forEach(fn => { try { fn?.(); } catch { /* gone */ } }); };
|
|
204
|
+
const onHash = () => { if (!pattern.test(location.hash)) run(); };
|
|
205
|
+
return {
|
|
206
|
+
add(fn) { fns.push(fn); return () => { const at = fns.indexOf(fn); if (at !== -1) fns.splice(at, 1); }; },
|
|
207
|
+
reset() { run(); window.addEventListener('hashchange', onHash); fns.push(() => window.removeEventListener('hashchange', onHash)); }
|
|
208
|
+
};
|
|
209
|
+
}),
|
|
210
|
+
addBannerAction: chrome.addBannerAction || (() => null),
|
|
211
|
+
sidebarCount: tk.sidebarCount || (() => {}),
|
|
212
|
+
helpPopovers: help.helpPopovers || (() => () => {})
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
return kit;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Add-ons (Docs Pro) the server says are registered: their admin modules,
|
|
220
|
+
* imported once. Each exports `install(host)`; a module that fails to load
|
|
221
|
+
* is skipped - Docs works without it.
|
|
222
|
+
*
|
|
223
|
+
* @param {Array<{module:string}>} list from GET /settings `extensions`
|
|
224
|
+
* @returns {Promise<Array<object>>}
|
|
225
|
+
*/
|
|
226
|
+
const loaded = new Map();
|
|
227
|
+
export async function loadExtensions(list) {
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const ext of Array.isArray(list) ? list : []) {
|
|
230
|
+
if (!ext?.module || !String(ext.module).startsWith('/plugins/')) continue;
|
|
231
|
+
if (!loaded.has(ext.module)) loaded.set(ext.module, import(ext.module).catch((err) => { console.warn('[docs] add-on failed to load', ext.module, err); return null; }));
|
|
232
|
+
const mod = await loaded.get(ext.module);
|
|
233
|
+
if (mod) out.push({...ext, mod});
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A file (an upload) as base64, for the JSON body of an upload. */
|
|
239
|
+
export function readBase64(file) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const r = new FileReader();
|
|
242
|
+
r.onload = () => resolve(String(r.result || '').replace(/^data:[^,]*,/, ''));
|
|
243
|
+
r.onerror = () => reject(new Error(`Could not read ${file.name}`));
|
|
244
|
+
r.readAsDataURL(file);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Is this a PDF, by name or type? (2.13) */
|
|
249
|
+
export const isPdfFile = (file) => Boolean(file) && (/\.pdf$/i.test(file.name || '') || file.type === 'application/pdf');
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A Docs route that answers bytes (a PDF), fetched with the signed-in token
|
|
253
|
+
* - an <iframe> cannot carry a bearer header, so the file comes here as a
|
|
254
|
+
* Blob and the frame shows the blob's address (2.13).
|
|
255
|
+
*
|
|
256
|
+
* @param {string} url from docsApi.fileUrl / versionFileUrl
|
|
257
|
+
* @returns {Promise<Blob>}
|
|
258
|
+
*/
|
|
259
|
+
export async function fetchBlob(url) {
|
|
260
|
+
const raw = S.get('auth_token');
|
|
261
|
+
const token = typeof raw === 'string' ? raw : '';
|
|
262
|
+
const res = await fetch(url, {headers: token ? {Authorization: `Bearer ${token}`} : {}, cache: 'no-store'});
|
|
263
|
+
if (!res.ok) {
|
|
264
|
+
let msg = '';
|
|
265
|
+
try { msg = (await res.json())?.error || ''; } catch { /* no body */ }
|
|
266
|
+
throw new Error(msg || (res.status === 404 ? 'The file could not be found.' : `The file could not be loaded (${res.status}).`));
|
|
267
|
+
}
|
|
268
|
+
return res.blob();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Save a Blob as a file. */
|
|
272
|
+
export function downloadBlob(name, blob) {
|
|
273
|
+
const url = URL.createObjectURL(blob);
|
|
274
|
+
const a = document.createElement('a');
|
|
275
|
+
a.href = url;
|
|
276
|
+
a.download = name;
|
|
277
|
+
document.body.appendChild(a);
|
|
278
|
+
a.click();
|
|
279
|
+
a.remove();
|
|
280
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Download a string as a file. */
|
|
284
|
+
export function download(name, text, type) {
|
|
285
|
+
const blob = new Blob([text], {type});
|
|
286
|
+
const url = URL.createObjectURL(blob);
|
|
287
|
+
const a = document.createElement('a');
|
|
288
|
+
a.href = url;
|
|
289
|
+
a.download = name;
|
|
290
|
+
document.body.appendChild(a);
|
|
291
|
+
a.click();
|
|
292
|
+
a.remove();
|
|
293
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Print a document on its own - the browser's dialog, where "Save as PDF"
|
|
298
|
+
* lives. A hidden frame holding just the document, so the admin around it
|
|
299
|
+
* never reaches the page.
|
|
300
|
+
*/
|
|
301
|
+
export function printHtml(fullHtml) {
|
|
302
|
+
const frame = document.createElement('iframe');
|
|
303
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
304
|
+
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
|
|
305
|
+
document.body.appendChild(frame);
|
|
306
|
+
const doc = frame.contentDocument;
|
|
307
|
+
doc.open();
|
|
308
|
+
doc.write(fullHtml);
|
|
309
|
+
doc.close();
|
|
310
|
+
const go = () => {
|
|
311
|
+
try { frame.contentWindow.focus(); frame.contentWindow.print(); } catch { /* blocked */ }
|
|
312
|
+
setTimeout(() => frame.remove(), 60000);
|
|
313
|
+
};
|
|
314
|
+
// Images must be in before the page is laid out for print.
|
|
315
|
+
const imgs = [...doc.images].filter(i => !i.complete);
|
|
316
|
+
if (!imgs.length) setTimeout(go, 50);
|
|
317
|
+
else Promise.all(imgs.map(i => new Promise(r => { i.onload = i.onerror = r; }))).then(go);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* E.prompt with a starting value that survives any characters. E.prompt's own
|
|
322
|
+
* `inputValue` is sanitised into a value="…" string, which turns " back
|
|
323
|
+
* into a bare quote - a title like Q3 "draft" opened as `Q3 ` and saved cut
|
|
324
|
+
* short. The dialog's input exists as soon as E.prompt returns, so the value is
|
|
325
|
+
* set as a property instead.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} message
|
|
328
|
+
* @param {object} options - E.prompt options, without inputValue
|
|
329
|
+
* @param {string} value
|
|
330
|
+
* @returns {Promise<string|null>}
|
|
331
|
+
*/
|
|
332
|
+
export function promptWith(message, options, value) {
|
|
333
|
+
const answer = E.prompt(message, options);
|
|
334
|
+
const input = [...document.querySelectorAll('.dm-dialog-input')].at(-1);
|
|
335
|
+
if (input) {
|
|
336
|
+
input.value = String(value ?? '');
|
|
337
|
+
setTimeout(() => { try { input.select(); } catch { /* gone */ } }, 60);
|
|
338
|
+
}
|
|
339
|
+
return answer;
|
|
340
|
+
}
|