glideshow 0.1.2__py3-none-any.whl

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 (103) hide show
  1. glideshow/__init__.py +15 -0
  2. glideshow/__main__.py +3 -0
  3. glideshow/agent.py +681 -0
  4. glideshow/build.py +54 -0
  5. glideshow/cli.py +320 -0
  6. glideshow/comments.py +35 -0
  7. glideshow/decks.py +230 -0
  8. glideshow/docs/agents.md +223 -0
  9. glideshow/docs/deck-format.md +125 -0
  10. glideshow/editor/.assetsignore +5 -0
  11. glideshow/editor/apple-touch-icon.png +0 -0
  12. glideshow/editor/data/font-metrics.json +1 -0
  13. glideshow/editor/data/fonts.json +1 -0
  14. glideshow/editor/data/icons.json +1 -0
  15. glideshow/editor/data/symbols.js +42 -0
  16. glideshow/editor/data/themes.js +15 -0
  17. glideshow/editor/deck/base.css +92 -0
  18. glideshow/editor/deck/present.js +222 -0
  19. glideshow/editor/deck/shapes.js +377 -0
  20. glideshow/editor/deck/template.html +37 -0
  21. glideshow/editor/editor.css +657 -0
  22. glideshow/editor/glideshow-1024.png +0 -0
  23. glideshow/editor/glideshow.svg +10 -0
  24. glideshow/editor/globals.d.ts +21 -0
  25. glideshow/editor/home.html +358 -0
  26. glideshow/editor/index.html +386 -0
  27. glideshow/editor/js/arrange.js +122 -0
  28. glideshow/editor/js/boot.js +66 -0
  29. glideshow/editor/js/boxes.js +25 -0
  30. glideshow/editor/js/comments.js +85 -0
  31. glideshow/editor/js/context-menus.js +93 -0
  32. glideshow/editor/js/dialogs.js +168 -0
  33. glideshow/editor/js/dock.js +22 -0
  34. glideshow/editor/js/draw.js +107 -0
  35. glideshow/editor/js/file.js +318 -0
  36. glideshow/editor/js/find.js +72 -0
  37. glideshow/editor/js/format-panel.js +101 -0
  38. glideshow/editor/js/geometry.js +153 -0
  39. glideshow/editor/js/guides.js +37 -0
  40. glideshow/editor/js/insert.js +73 -0
  41. glideshow/editor/js/keyboard.js +256 -0
  42. glideshow/editor/js/live.js +48 -0
  43. glideshow/editor/js/menubar.js +290 -0
  44. glideshow/editor/js/menus.js +93 -0
  45. glideshow/editor/js/motion.js +148 -0
  46. glideshow/editor/js/notes.js +63 -0
  47. glideshow/editor/js/objects.js +202 -0
  48. glideshow/editor/js/people.js +73 -0
  49. glideshow/editor/js/phone-comments.js +43 -0
  50. glideshow/editor/js/phone-edit.js +253 -0
  51. glideshow/editor/js/phone-home.js +51 -0
  52. glideshow/editor/js/phone-mode.js +73 -0
  53. glideshow/editor/js/phone-sheets.js +153 -0
  54. glideshow/editor/js/phone-touch.js +145 -0
  55. glideshow/editor/js/phone-view.js +150 -0
  56. glideshow/editor/js/pointer.js +407 -0
  57. glideshow/editor/js/render.js +353 -0
  58. glideshow/editor/js/shortcuts.js +67 -0
  59. glideshow/editor/js/slides.js +120 -0
  60. glideshow/editor/js/spacing.js +23 -0
  61. glideshow/editor/js/state.js +58 -0
  62. glideshow/editor/js/storage.js +250 -0
  63. glideshow/editor/js/text.js +366 -0
  64. glideshow/editor/js/theme-toggle.js +41 -0
  65. glideshow/editor/js/theme.js +173 -0
  66. glideshow/editor/js/toolbar-groups.js +109 -0
  67. glideshow/editor/js/toolbar.js +372 -0
  68. glideshow/editor/lib/browser-db.js +47 -0
  69. glideshow/editor/lib/library.js +612 -0
  70. glideshow/editor/lib/merge.js +174 -0
  71. glideshow/editor/lib/move-local.js +51 -0
  72. glideshow/editor/lib/onefile.js +108 -0
  73. glideshow/editor/lib/store-browser.js +75 -0
  74. glideshow/editor/lib/store-folder.js +109 -0
  75. glideshow/editor/local/sw.js +66 -0
  76. glideshow/editor/local/tsconfig.json +5 -0
  77. glideshow/editor/looks/glider.svg +9 -0
  78. glideshow/editor/looks/thermal-phone.css +144 -0
  79. glideshow/editor/looks/thermal.css +268 -0
  80. glideshow/editor/manifest.json +23 -0
  81. glideshow/editor/phone.css +345 -0
  82. glideshow/editor/share/share.css +124 -0
  83. glideshow/editor/share/share.js +256 -0
  84. glideshow/editor/share/speaker.html +88 -0
  85. glideshow/editor/tsconfig.json +6 -0
  86. glideshow/figure.py +33 -0
  87. glideshow/fonts.py +63 -0
  88. glideshow/gslides.py +1065 -0
  89. glideshow/history.py +210 -0
  90. glideshow/merge.py +210 -0
  91. glideshow/osutil.py +44 -0
  92. glideshow/pptx_export.py +207 -0
  93. glideshow/pptx_import.py +115 -0
  94. glideshow/publish.py +106 -0
  95. glideshow/render.py +288 -0
  96. glideshow/server.py +549 -0
  97. glideshow/service.py +65 -0
  98. glideshow/skills/glideshow/SKILL.md +85 -0
  99. glideshow-0.1.2.dist-info/METADATA +69 -0
  100. glideshow-0.1.2.dist-info/RECORD +103 -0
  101. glideshow-0.1.2.dist-info/WHEEL +4 -0
  102. glideshow-0.1.2.dist-info/entry_points.txt +2 -0
  103. glideshow-0.1.2.dist-info/licenses/LICENSE +662 -0
@@ -0,0 +1,612 @@
1
+ /* A library of decks, as the local server's API (glideshow/server.py) answers for ~/decks: the code behind glideshow.org
2
+ (cloud/library.js, a Durable Object per person, over a GitHub repo or a Drive folder) and behind a library kept in a
3
+ browser (local/sw.js, in a service worker, over IndexedDB). Saves land in its storage at once and are committed
4
+ together after 30 s of quiet (as the Mac does); library changes (move, rename, folders) are committed straight away.
5
+
6
+ Plain modules, no build step: what differs by place comes in `deps` (the stores, sharing) and `ctx` (a Durable Object's
7
+ storage and alarms, or the browser's stand-in for them). A store answers head / read / write / lastChange / history /
8
+ readAt / filesAt (and pin / pinned / unpin for shared links): cloud/store-github.js, cloud/store-drive.js,
9
+ lib/store-browser.js. */
10
+
11
+ export const json = (obj, status = 200, headers = {}) => new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...headers } });
12
+ export const cookie = (name, value, maxAge) => `${name}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
13
+ export const getCookie = (req, name) => (req.headers.get('cookie') || '').split(/;\s*/).find(c => c.startsWith(name + '='))?.slice(name.length + 1) || '';
14
+ import './merge.js'; // globalThis.glideMerge (a classic script too: the editor loads it)
15
+ const { mergeDecks } = /** @type {any} */ (globalThis).glideMerge;
16
+
17
+ const concat = parts => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let i = 0; for (const p of parts) { out.set(p, i); i += p.length; } return out; };
18
+ const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
19
+
20
+ const QUIET_MS = 30_000, CHUNK = 100_000, MAX_FETCH = 25_000_000;
21
+ const MAC_ONLY = 'This works in the Mac app only.';
22
+
23
+ /* ---------- deck files: the same rules as decks.py ---------- */
24
+
25
+ export const TYPES = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', json: 'application/json', md: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8',
26
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon',
27
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', pdf: 'application/pdf',
28
+ woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' };
29
+ export const typeOf = p => TYPES[p.split('.').pop().toLowerCase()] || 'application/octet-stream';
30
+ export const dirOf = p => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
31
+ export const baseOf = p => p.slice(p.lastIndexOf('/') + 1);
32
+ export const join = (...ps) => ps.filter(Boolean).join('/');
33
+ export const enc = s => new TextEncoder().encode(s), dec = b => new TextDecoder().decode(b);
34
+ export const b64url = s => btoa(String.fromCharCode(...enc(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
35
+ export const unb64url = s => dec(Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)));
36
+ const UNTITLED = 'Untitled deck';
37
+
38
+ /* A library path from the editor: relative to the library, no '..' out of it, nothing hidden. */
39
+ export function clean(p) {
40
+ const out = [];
41
+ for (const part of String(p || '').replace(/^~\/decks\/?/, '').split('/')) {
42
+ if (!part || part === '.') continue;
43
+ if (part === '..') { if (!out.length) throw new Error(`${p} is outside the library`); out.pop(); continue; }
44
+ if (part.startsWith('.')) throw new Error(`${p}: hidden folders can't be used`);
45
+ out.push(part);
46
+ }
47
+ return out.join('/');
48
+ }
49
+ export const unhtml = s => s.replace(/&(amp|lt|gt|quot|#39|#x27|nbsp);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", '#x27': "'", nbsp: ' ' })[e]);
50
+ export const escHTML = s => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;' })[c]);
51
+ export function titleOf(html, fallback = '') { const m = html.match(/<title>([\s\S]*?)<\/title>/); return (m ? unhtml(m[1].trim()) : '') || fallback; }
52
+ export function dateOf(html) { return html.slice(0, 20000).match(/<meta name="date" content="(\d{4}-\d{2}-\d{2})"/)?.[1] || ''; }
53
+ export function setTitle(html, title) { return html.replace(/<title>[\s\S]*?<\/title>/, `<title>${escHTML(title)}</title>`); }
54
+ /* What the home screen calls an untitled deck: the first line of text on its first slide (decks.py shown_title). */
55
+ export function shownTitle(html, title) {
56
+ if (!/^\s*untitled( deck| presentation)?\s*$/i.test(title)) return title;
57
+ let first = html.slice(Math.max(0, html.indexOf('<section'))).slice(0, 200000);
58
+ if (first.includes('</section>')) first = first.slice(0, first.indexOf('</section>'));
59
+ for (const m of first.matchAll(/<div class="box(?![^"]*\bnote\b)[^"]*"[^>]*>\s*<div class="content"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g)) {
60
+ const lines = m[1].replace(/<(br|\/div|\/p|\/li)[^>]*>/g, '\n').replace(/<[^>]+>/g, ' ').split('\n').map(l => l.replace(/\s+/g, ' ').trim());
61
+ const line = lines.find(Boolean); if (line) return unhtml(line).slice(0, 80);
62
+ }
63
+ return '';
64
+ }
65
+ /* history.py commit_message: the first changed slide and its new words. */
66
+ export function commitMessage(old, neu) {
67
+ if (!old) return `deck: ${titleOf(neu) || 'first save'}`.slice(0, 72);
68
+ const split = h => { const i = h.indexOf('class="slides"'); return (i < 0 ? h : h.slice(i + 14)).split(/<section\b/).slice(1).map(x => x.includes('>') ? x.slice(x.indexOf('>') + 1) : x); };
69
+ const words = h => h.replace(/<(svg|script|style)\b[\s\S]*?<\/\1>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').split(' ').filter(Boolean);
70
+ const a = split(old), b = split(neu);
71
+ for (let i = 0; i < b.length; i++) {
72
+ if (i >= a.length || b[i] !== a[i]) {
73
+ const before = i < a.length ? words(a[i]) : [], after = words(b[i]), fresh = after.filter(w => !before.includes(w));
74
+ return `slide ${i + 1}: ${(fresh.length ? fresh : after).slice(0, 6).join(' ') || 'edit'}`.slice(0, 72);
75
+ }
76
+ }
77
+ return b.length < a.length ? `slide ${b.length}: removed` : 'deck: edit';
78
+ }
79
+
80
+ /* ---------- the library ---------- */
81
+
82
+ export class LibraryCore {
83
+ /** @param {any} ctx @param {any} env @param {any} deps */
84
+ constructor(ctx, env, deps) { this.init(ctx, env, deps); }
85
+ /** @param {any} ctx @param {any} env @param {any} deps */
86
+ init(ctx, env, deps) {
87
+ /** @type {any} */ this.ctx = ctx; /** @type {any} */ this.env = env; /** @type {any} */ this.deps = deps;
88
+ this.base = null; this.checked = 0; this.gen = 0; this.mem = new Map(); this.memBytes = 0; this.flushing = null; this._store = null;
89
+ this.loaded = ctx.blockConcurrencyWhile(async () => {
90
+ const s = await ctx.storage.get(['pending', 'msgs', 'recent', 'drafts', 'alias', 'cfg']);
91
+ this.cfg = s.get('cfg') || {}; // {user: {u, l, n}} and where the library is: {repo, branch} (GitHub) or {kind: 'drive', drive: {root, refresh}, handle}
92
+ this.pending = s.get('pending') || {}; // path -> {data, n} (bytes kept here) | {ref, bv} (a file already in the store) | {del: 1}; each with v (its version) and t
93
+ for (const e of Object.values(this.pending)) if (e.sha && !e.ref) { e.ref = e.bv = e.sha; delete e.sha; } // kept before stores had refs
94
+ this.msgs = s.get('msgs') || []; // commit messages for library changes not yet committed
95
+ this.recent = s.get('recent') || []; // decks opened here, newest first
96
+ this.drafts = s.get('drafts') || {}; // draft id -> {folder, t}
97
+ this.alias = s.get('alias') || {}; // a stored version -> the version it had before it was written, so an open editor's version still matches
98
+ });
99
+ }
100
+
101
+ /* --- whose library, and which repo (auth.js signs in; /setup picks the repo) --- */
102
+ async signedIn(who, userToken) {
103
+ this.cfg.user = { u: who.u, l: who.l, n: who.n };
104
+ if (!this.cfg.repo && this.env.DEV_REPO) { // wrangler dev: the test library, without /setup
105
+ Object.assign(this.cfg, { repo: this.env.DEV_REPO, branch: this.env.DEV_BRANCH || 'main' });
106
+ await this.env.DIR.get(this.env.DIR.idFromName('directory')).claim(this.env.DEV_REPO.split('/')[0].toLowerCase(), who.u); // first come: another test login can't take the owner's links
107
+ }
108
+ await this.ctx.storage.put({ cfg: this.cfg, userToken: { token: userToken, t: Date.now() } });
109
+ }
110
+ async setupInfo() { // the repos this person can reach that the Glideshow app is installed on
111
+ const ut = await this.ctx.storage.get('userToken');
112
+ if (!ut || Date.now() - ut.t > 7 * 3600_000) { if (ut) await this.ctx.storage.delete('userToken'); return { repos: [], current: this.cfg.repo || '', stale: true }; } // GitHub's user tokens last 8 hours
113
+ const h = { authorization: `Bearer ${ut.token}`, accept: 'application/vnd.github+json', 'user-agent': 'glideshow' };
114
+ const inst = await fetch('https://api.github.com/user/installations?per_page=100', { headers: h }).then(r => r.json()).catch(() => ({}));
115
+ const repos = [];
116
+ for (const i of inst.installations || []) {
117
+ const r = await fetch(`https://api.github.com/user/installations/${i.id}/repositories?per_page=100`, { headers: h }).then(r => r.json()).catch(() => ({}));
118
+ for (const x of r.repositories || []) repos.push({ repo: x.full_name, private: x.private, branch: x.default_branch, writable: !!x.permissions?.push });
119
+ }
120
+ return { repos, current: this.cfg.repo || '' };
121
+ }
122
+ async chooseRepo(repo) {
123
+ const pick = (await this.setupInfo()).repos.find(r => r.repo === repo && r.writable);
124
+ if (!pick) return { error: `You can't write to ${repo} through Glideshow.` };
125
+ if (this.cfg.repo !== pick.repo) { // a different library: start clean
126
+ if (Object.keys(this.pending).some(p => !p.startsWith('.drafts/'))) await this.flush();
127
+ Object.assign(this, { base: null, pending: {}, msgs: [], recent: [], drafts: {}, alias: {}, _store: null });
128
+ }
129
+ // the <owner> in its share links: the repo's owner, unless another person's library has it (two people with repos in
130
+ // one organisation): then <owner>--2, which no GitHub name can be (GitHub allows no double hyphens)
131
+ const dir = this.env.DIR.get(this.env.DIR.idFromName('directory')), owner = pick.repo.split('/')[0].toLowerCase();
132
+ let handle = owner;
133
+ for (let n = 2; !(await dir.claim(handle, this.cfg.user.u)); n++) handle = `${owner}--${n}`;
134
+ const branch = this.env.DEV_BRANCH && pick.repo === this.env.DEV_REPO ? this.env.DEV_BRANCH : pick.branch; // staging keeps the test branch (wrangler.toml env.dev)
135
+ Object.assign(this.cfg, { kind: 'github', repo: pick.repo, branch, handle });
136
+ await this.savePending(); await this.ctx.storage.put('cfg', this.cfg);
137
+ await this.ctx.storage.delete('userToken'); // only setup uses the person's own GitHub token; the app's token does the rest
138
+ return { ok: true };
139
+ }
140
+ /* A Google account's library: the Glideshow folder in their Drive, made on first sign-in (auth.js asks for drive.file). */
141
+ async driveConnected(who, tokens) {
142
+ this.cfg.user = { u: who.u, l: who.l, n: who.n, e: who.e?.[0] };
143
+ const drive = { ...(this.cfg.drive || {}) };
144
+ if (tokens.refresh_token) drive.refresh = tokens.refresh_token;
145
+ if (!drive.refresh) return { error: 'Google gave no lasting access: remove Glideshow at myaccount.google.com/permissions and sign in again.' };
146
+ if (!drive.root) drive.root = await this.deps.makeRoot(this.env, tokens.access_token);
147
+ Object.assign(this.cfg, { kind: 'drive', drive, handle: this.cfg.handle || await this.claimHandle(who) });
148
+ this._store = null; this.base = null;
149
+ await this.ctx.storage.put('cfg', this.cfg);
150
+ return { ok: true };
151
+ }
152
+ async claimHandle(who) { // the <owner> in this library's share links: g.<name>, unique
153
+ const dir = this.env.DIR.get(this.env.DIR.idFromName('directory')), stem = 'g.' + ((who.e?.[0] || who.l).split('@')[0].toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '') || 'user');
154
+ for (let n = 1; ; n++) { const h = n === 1 ? stem : `${stem}${n}`; if (await dir.claim(h, who.u)) return h; }
155
+ }
156
+ async info() { return { ...this.cfg, drive: this.cfg.drive ? { root: this.cfg.drive.root, connected: !!this.cfg.drive.refresh } : undefined, ready: this.ready(), label: this.label() }; }
157
+ ready() { return this.cfg.kind === 'browser' || (this.cfg.kind === 'drive' ? !!(this.cfg.drive?.root && this.cfg.drive?.refresh) : !!this.cfg.repo); }
158
+ label() { return this.cfg.kind === 'browser' ? 'This browser' : this.cfg.kind === 'drive' ? 'Google Drive › Glideshow' : this.cfg.repo || ''; }
159
+ shareOwner() { return this.cfg.handle || this.cfg.repo.split('/')[0].toLowerCase(); } // share links' <owner>
160
+ get store() { return this._store ??= this.deps.store(this); }
161
+
162
+ /* The library's files now, checked at most every 5 s. */
163
+ async refresh(force = false) {
164
+ if (this.base && !force && Date.now() - this.checked < 5000) return;
165
+ this.checked = Date.now();
166
+ const gen = this.gen, next = await this.store.head(this.base, force); // a write made here while this was reading wins over what it read
167
+ if (next && gen === this.gen) {
168
+ const was = this.base?.tree || {};
169
+ this.base = next;
170
+ if (this.deps.notify) for (const [p, e] of Object.entries(next.tree)) if (p.endsWith('/deck.html') && was[p]?.v !== e.v && !this.pending[p]) this.deps.notify(this, p, e.v); // a push from the Mac, a change in Drive
171
+ await this.rebaseDecks();
172
+ }
173
+ }
174
+ /* A deck saved here and not yet committed whose file changed in the store meanwhile (the Mac pushed an agent's edit):
175
+ the save is merged with that change (merge.js mergeDecks), three ways from the version it was made from, rather
176
+ than committed over it. Each saved deck keeps that version (`from`: its v and its bytes) until it is committed.
177
+ When the two can't be merged (a deck whose slides can't be told apart in its text), theirs stays the deck and this save is kept beside
178
+ it as deck-conflict-<time>.html. */
179
+ async rebaseDecks() {
180
+ for (const [p, e] of Object.entries(this.pending)) {
181
+ const now = this.base.tree[p];
182
+ if (!p.endsWith('/deck.html') || !e.data || !e.from || !now || now.v === e.from.v) continue;
183
+ const theirs = await this.read({ ref: now.ref, bv: now.v, v: now.v }), b = e.from.data ? await this.read({ data: e.from.data, n: e.from.n, v: e.from.data }) : enc('');
184
+ const merged = mergeDecks(dec(b), dec(await this.read(e)), dec(theirs)), from = { v: now.v, ...(await this.keep(theirs)) };
185
+ if (this.pending[p] !== e) continue; // saved again while this was reading: the next refresh takes that one
186
+ if (merged) {
187
+ const mine = await this.keep(enc(merged.html));
188
+ if (this.pending[p] !== e) continue;
189
+ this.pending[p] = { ...mine, v: mine.data, t: Date.now(), from };
190
+ this.merged = { ...this.merged, [p]: { v: mine.data, both: merged.bothIds } }; // which slides kept this side's over theirs (cloud/library.js notify)
191
+ this.deps.notify?.(this, p, mine.data); // (not as a save made here: it brings in the Mac's)
192
+ } else {
193
+ delete this.pending[p];
194
+ this.set(join(dirOf(p), `deck-conflict-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}.html`), { data: e.data, n: e.n, v: e.v, t: Date.now() });
195
+ this.deps.notify?.(this, p, now.v);
196
+ }
197
+ }
198
+ await this.savePending();
199
+ }
200
+
201
+ /* --- the working tree: the branch's files with the changes not yet committed on top --- */
202
+ view() {
203
+ const v = {};
204
+ for (const [p, x] of Object.entries(this.base.tree)) v[p] = { ref: x.ref, bv: x.v, v: this.alias[x.v] || x.v, t: x.t, folder: x.folder };
205
+ for (const [p, e] of Object.entries(this.pending)) if (e.del) delete v[p]; else v[p] = e;
206
+ return v;
207
+ }
208
+ async read(e) {
209
+ if (!e) return null;
210
+ if (this.mem.has(e.v)) return this.mem.get(e.v);
211
+ let bytes;
212
+ if (e.data) {
213
+ const keys = Array.from({ length: e.n }, (_, i) => `d:${e.data}:${i}`), parts = [];
214
+ for (let i = 0; i < keys.length; i += 128) { const got = await this.ctx.storage.get(keys.slice(i, i + 128)); parts.push(...keys.slice(i, i + 128).map(k => got.get(k))); }
215
+ bytes = concat(parts.map(p => new Uint8Array(p)));
216
+ } else bytes = await this.store.read(e);
217
+ this.mem.set(e.v, bytes); this.memBytes += bytes.length;
218
+ for (const [k, b] of this.mem) { if (this.memBytes < 40e6) break; this.mem.delete(k); this.memBytes -= b.length; }
219
+ return bytes;
220
+ }
221
+ async text(path) { const b = await this.read(this.view()[path]); return b && dec(b); }
222
+ async keep(bytes) { // bytes kept in storage, in chunks: {data, n}
223
+ const data = crypto.randomUUID().replace(/-/g, '').slice(0, 16), n = Math.max(1, Math.ceil(bytes.length / CHUNK)), obj = {};
224
+ for (let i = 0; i < n; i++) obj[`d:${data}:${i}`] = bytes.slice(i * CHUNK, (i + 1) * CHUNK);
225
+ const keys = Object.keys(obj);
226
+ for (let i = 0; i < keys.length; i += 128) await this.ctx.storage.put(Object.fromEntries(keys.slice(i, i + 128).map(k => [k, obj[k]])));
227
+ this.mem.set(data, bytes); this.memBytes += bytes.length;
228
+ return { data, n };
229
+ }
230
+ async put(path, bytes) { // a file changed here: kept in storage until the next commit
231
+ // a deck remembers the committed version its saves started from (rebaseDecks merges against it)
232
+ const was = this.pending[path], at = this.base?.tree[path];
233
+ const from = !path.endsWith('/deck.html') ? undefined : was ? was.from : at ? { v: at.v, ...(await this.keep(await this.read({ ref: at.ref, bv: at.v, v: at.v }))) } : { v: '' };
234
+ const { data, n } = await this.keep(bytes), v = data;
235
+ this.set(path, { data, n, v, t: Date.now(), ...(from ? { from } : {}) });
236
+ return v;
237
+ }
238
+ set(path, e) { this.pending[path] = e; if (path.endsWith('/deck.html')) this.deps.notify?.(this, path, e.v, true); } // a deck saved: every editor open on it hears (cloud/library.js)
239
+ remove(path) { if (this.base.tree[path]) this.pending[path] = { del: 1, v: 'del', t: Date.now() }; else delete this.pending[path]; }
240
+ /* Copy (or move) everything under a folder to another; versions are shared, so nothing is uploaded again. */
241
+ copyTree(from, to, move = false) {
242
+ const v = this.view();
243
+ for (const p of Object.keys(v)) if (p.startsWith(from + '/')) { this.set(to + p.slice(from.length), { ...v[p], t: Date.now() }); if (move) this.remove(p); }
244
+ }
245
+ async savePending() {
246
+ const live = new Set(Object.values(this.pending).flatMap(e => [e.data, e.from?.data]).filter(Boolean)), dead = []; // chunks no change refers to any more
247
+ const known = (await this.ctx.storage.get('chunks')) || {};
248
+ for (const [id, n] of Object.entries(known)) if (!live.has(id)) { for (let i = 0; i < n; i++) dead.push(`d:${id}:${i}`); delete known[id]; }
249
+ for (const e of Object.values(this.pending)) { if (e.data) known[e.data] = e.n; if (e.from?.data) known[e.from.data] = e.from.n; }
250
+ for (let i = 0; i < dead.length; i += 128) await this.ctx.storage.delete(dead.slice(i, i + 128));
251
+ await this.ctx.storage.put({ pending: this.pending, msgs: this.msgs, recent: this.recent, drafts: this.drafts, alias: this.alias, chunks: known });
252
+ }
253
+
254
+ /* Decks and folders, as decks.py walk_library: a folder holding deck.html is a deck (not looked inside); hidden
255
+ folders and stray assets/ are skipped. */
256
+ walk(v = this.view()) {
257
+ const decks = new Set(), dirs = new Set();
258
+ for (const p of Object.keys(v)) {
259
+ const parts = p.split('/');
260
+ if (parts.some(x => x.startsWith('.') && x !== '.keep')) continue;
261
+ if (parts.at(-1) === 'deck.html' && parts.length > 1) decks.add(parts.slice(0, -1).join('/'));
262
+ for (let i = 1; i < parts.length && i <= 6; i++) dirs.add(parts.slice(0, i).join('/'));
263
+ }
264
+ const inDeck = d => { const parts = d.split('/'); for (let i = 1; i < parts.length; i++) if (decks.has(parts.slice(0, i).join('/'))) return true; return false; };
265
+ const deckList = [...decks].filter(d => !inDeck(d)).sort();
266
+ const folders = [...dirs].filter(d => !decks.has(d) && !inDeck(d) && !d.split('/').includes('assets')).sort();
267
+ return { decks: deckList, folders };
268
+ }
269
+ async meta(deck, e) { // title, shown title, date and last change of a deck, kept per version of its deck.html
270
+ const key = 'm:' + e.v;
271
+ let m = await this.ctx.storage.get(key);
272
+ if (!m) {
273
+ const html = dec(await this.read(e)), title = titleOf(html, baseOf(deck));
274
+ let time = e.t ? e.t / 1000 : 0;
275
+ if (!time) time = await this.store.lastChange(deck + '/deck.html');
276
+ m = { title, shown: shownTitle(html, title), date: dateOf(html), time };
277
+ await this.ctx.storage.put(key, m);
278
+ }
279
+ return m;
280
+ }
281
+
282
+ current(req) { // the deck this browser has open (a cookie), else the most recently opened one
283
+ const v = this.view(), has = d => d && v[d + '/deck.html'];
284
+ let d = ''; try { d = decodeURIComponent(getCookie(req, 'deck')); } catch {}
285
+ if (has(d)) return d;
286
+ return this.recent.find(has) || this.walk(v).decks.map(x => [x, v[x + '/deck.html']]).sort((a, b) => (b[1].t || 0) - (a[1].t || 0))[0]?.[0] || '';
287
+ }
288
+ open(deck, res) {
289
+ if (!deck.startsWith('.drafts/')) { this.recent = [deck, ...this.recent.filter(d => d !== deck)].slice(0, 12); this.ctx.storage.put('recent', this.recent); }
290
+ res.headers.append('set-cookie', cookie('deck', encodeURIComponent(deck), 365 * 86400));
291
+ return res;
292
+ }
293
+ async newDeck(title = '') { // decks.py new_deck: the template with base.css, shapes.js and present.js inline, dated today
294
+ const get = f => this.env.ASSETS.fetch(new Request('https://assets.local/deck/' + f)).then(r => r.text());
295
+ const [tpl, base, shapes, present] = await Promise.all(['template.html', 'base.css', 'shapes.js', 'present.js'].map(get));
296
+ let html = tpl.replace('{{BASE}}', () => base.trim()).replace('{{SHAPES}}', () => shapes.trim()).replace('{{PRESENT}}', () => present.trim());
297
+ if (title) html = setTitle(html, title);
298
+ return html.replace('</title>', `</title>\n<meta name="date" content="${new Date().toISOString().slice(0, 10)}">`);
299
+ }
300
+ agentsDoc() { // AGENTS.md for a new deck: the one the library's decks already carry
301
+ const n = {}; for (const [p, e] of Object.entries(this.view())) if (p.endsWith('/AGENTS.md') && e.ref && !e.data) (n[e.bv] ||= [e, 0])[1]++;
302
+ const best = Object.values(n).sort((a, b) => b[1] - a[1])[0]?.[0];
303
+ return best ? { ref: best.ref, bv: best.bv, v: best.bv, t: Date.now() } : null;
304
+ }
305
+ async makeDeck(dir, title = '') {
306
+ await this.put(dir + '/deck.html', enc(await this.newDeck(title)));
307
+ const a = this.agentsDoc(); if (a) this.set(dir + '/AGENTS.md', a);
308
+ }
309
+ unique(path) { const v = this.view(), taken = p => Object.keys(v).some(k => k === p || k.startsWith(p + '/')); let p = path, n = 2; while (taken(p)) p = `${path}-${n++}`; return p; }
310
+
311
+ /* --- commits --- */
312
+ schedule() { return this.ctx.storage.setAlarm(Date.now() + QUIET_MS); }
313
+ async alarm() { await this.flush(); }
314
+ async flush() { // one commit at a time
315
+ const prev = this.flushing, p = (async () => { await prev?.catch(() => {}); await this.commit(); })();
316
+ this.flushing = p;
317
+ try { return await p; } finally { if (this.flushing === p) this.flushing = null; }
318
+ }
319
+ async commit(retry = 0) {
320
+ await this.refresh(true);
321
+ const snap = Object.fromEntries(Object.entries(this.pending).filter(([p]) => !p.startsWith('.drafts/')));
322
+ const paths = Object.keys(snap); if (!paths.length) return;
323
+ const msgs = this.msgs.slice(), changes = [];
324
+ for (const p of paths) {
325
+ const e = snap[p];
326
+ changes.push(e.del ? { path: p, del: true } : e.data ? { path: p, bytes: await this.read(e) } : { path: p, ref: e.ref, bv: e.bv });
327
+ }
328
+ let message = msgs.join('; ');
329
+ if (!message) {
330
+ const decks = paths.filter(p => p.endsWith('/deck.html') && !snap[p].del);
331
+ if (decks.length) {
332
+ const was = this.base.tree[decks[0]], old = was ? dec(await this.store.read({ ref: was.ref, bv: was.v })) : '';
333
+ message = commitMessage(old, dec(await this.read(snap[decks[0]]))) + (decks.length > 1 ? ` (+${decks.length - 1} more)` : '');
334
+ } else {
335
+ const notes = paths.filter(p => p.endsWith('/comments.json')).map(p => baseOf(dirOf(p)));
336
+ message = notes.length === paths.length ? `comments: ${notes.join(', ')}` : 'deck: assets';
337
+ }
338
+ }
339
+ let wrote;
340
+ try { wrote = await this.store.write(this.base, changes, message); }
341
+ catch (err) { // a deck changed in the store since this read it (the Mac pushed): merged in (refresh → rebaseDecks), then again
342
+ if (!err.conflict || retry > 2) throw err;
343
+ return this.commit(retry + 1);
344
+ }
345
+ const { base, versions } = wrote;
346
+ this.base = base; this.gen++;
347
+ for (const p of paths) if (this.pending[p] && this.pending[p] !== snap[p] && this.pending[p].from && !snap[p].del && snap[p].data) this.pending[p].from = { v: base.tree[p]?.v, data: snap[p].data, n: snap[p].n };
348
+ for (const p of paths) if (!snap[p].del && versions[p] && snap[p].v !== versions[p]) this.alias[versions[p]] = snap[p].v;
349
+ const live = new Set(Object.values(this.base.tree).map(x => x.v)); for (const k of Object.keys(this.alias)) if (!live.has(k)) delete this.alias[k];
350
+ for (const p of paths) if (this.pending[p] === snap[p]) delete this.pending[p]; // left alone if it changed during the commit
351
+ this.msgs = this.msgs.slice(msgs.length);
352
+ await this.savePending();
353
+ }
354
+ author() { const u = this.cfg.user || {}; return { name: u.n || u.l || 'Glideshow', email: u.u ? `${u.u}+${u.l}@users.noreply.github.com` : 'glideshow@users.noreply.github.com' }; }
355
+ async libraryChange(message) { this.msgs.push(message); await this.savePending(); await this.flush(); }
356
+
357
+ /* ---------- the local server's API ---------- */
358
+ async fetch(req) {
359
+ try {
360
+ if (!this.ready()) return json({ error: 'No library yet: set it up at /setup.' }, 409);
361
+ await this.refresh();
362
+ const url = new URL(req.url);
363
+ if (url.pathname.startsWith('/__share/')) return await this.deps.share.shareRoutes(this, req, url); // the Worker's call for a viewer (share.js)
364
+ if (url.pathname.startsWith('/__collab/') && this.deps.share.collabRoutes) return await this.deps.share.collabRoutes(this, req, url); // a collaborator's editor (share.js)
365
+ return await this.handle(req, url);
366
+ }
367
+ catch (e) { return json({ error: String(e.message || e) }, 500); }
368
+ }
369
+ async handle(req, url) {
370
+ const path = decodeURIComponent(url.pathname), q = url.searchParams;
371
+ const body = req.method === 'POST' ? await req.arrayBuffer() : null, j = () => { try { return JSON.parse(dec(new Uint8Array(body))); } catch { return {}; } };
372
+ const deck = this.current(req), deckFile = deck + '/deck.html', v = this.view();
373
+ const isDraft = deck.startsWith('.drafts/');
374
+ if (path === '/api/selection') return json({ ok: true }); // the Mac's `glideshow selection`; nothing online asks for it
375
+ if (path === '/api/publish') return this.deps.share.publishAPI(this, req.method, j(), url, deck, isDraft); // Share (share.js)
376
+ if (path === '/api/comments') return this.deps.share.commentsAPI(this, req.method, j(), deck);
377
+ // a deck's files; all but deck.html itself sandboxed, so a picture or page in it (an SVG with a script) opened on its own can't act as you
378
+ // every file but the deck is sandboxed; a live box's page (an .html in assets/) may still run its own script
379
+ const csp = p => p.endsWith('/deck.html') ? {} : { 'content-security-policy': /\/assets\/.*\.html?$/i.test(p) ? 'sandbox allow-scripts allow-pointer-lock' : 'sandbox' };
380
+ /* deck.html is never kept; a deck's other files are, by the browser, for 5 minutes, then asked again with their version
381
+ (304 when unchanged), and come in parts when asked (Range): a looping video sent whole with no-store was fetched
382
+ again on every loop, every few seconds per video (2026-10-21: the day's free Durable Object requests in 3 hours).
383
+ The editor fetches a file again after a change elsewhere anyway (render.js freshSrc). */
384
+ const file = async p => {
385
+ const e = v[p]; if (!e) return new Response('not found', { status: 404 });
386
+ const deck = p.endsWith('/deck.html'), tag = `"${e.v}"`;
387
+ const head = { 'content-type': typeOf(p), 'x-content-type-options': 'nosniff', ...csp(p), ...(deck ? { 'cache-control': 'no-store' } : { 'cache-control': 'private, max-age=300', etag: tag, 'accept-ranges': 'bytes' }) };
388
+ if (!deck && req.headers.get('if-none-match') === tag) return new Response(null, { status: 304, headers: head });
389
+ const b = await this.read(e); if (!b) return new Response('not found', { status: 404 });
390
+ const range = !deck && /^bytes=(\d*)-(\d*)$/.exec(req.headers.get('range') || '');
391
+ if (range && (range[1] || range[2])) {
392
+ const n = b.length, start = range[1] ? +range[1] : Math.max(0, n - +range[2]), end = range[1] && range[2] ? Math.min(+range[2], n - 1) : n - 1;
393
+ if (start >= n || start > end) return new Response(null, { status: 416, headers: { ...head, 'content-range': `bytes */${n}` } });
394
+ return new Response(b.slice(start, end + 1), { status: 206, headers: { ...head, 'content-range': `bytes ${start}-${end}/${n}` } });
395
+ }
396
+ return new Response(b, { headers: head });
397
+ };
398
+
399
+ if (req.method === 'GET') {
400
+ if (path === '/api/ping') return json({ app: 'glideshow', cloud: true, library: this.label(), user: this.cfg.user?.l, deck });
401
+ if (path === '/api/deck') { // with each of the deck's files' versions: the editor fetches again only the ones that changed (render.js fresh)
402
+ const files = {}; for (const [p, e] of Object.entries(v)) if (p.startsWith(deck + '/') && p !== deckFile && !e.folder) files[p.slice(deck.length + 1)] = e.v;
403
+ return json({ html: await this.text(deckFile) || '', mtime: v[deckFile]?.v || '', draft: isDraft, files, id: b64url(deck) }); // id: its files under /library/<id>/
404
+ }
405
+ if (path === '/api/events') return new Response(`retry: 8000\ndata: ${v[deckFile]?.v || ''}\n\n`, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' } }); // one event per reconnect: a poll every 8 s
406
+ if (path.startsWith('/deck/')) return file(join(deck, clean(path.slice(6))));
407
+ if (path.startsWith('/library/')) { // home-screen thumbnails: a file in any deck, by the deck's id
408
+ const [id, ...rest] = path.slice(9).split('/');
409
+ let d = ''; try { d = clean(unb64url(id)); } catch { return new Response('not found', { status: 404 }); }
410
+ return file(join(d, clean(rest.join('/'))));
411
+ }
412
+ if (path === '/api/decks') {
413
+ const out = (await Promise.all(this.walk(v).decks.map(async d => {
414
+ const m = await this.meta(d, v[d + '/deck.html']).catch(() => null); // gone since the last look (deleted in Drive, say): left out
415
+ if (!m) { this.checked = 0; return null; }
416
+ return { path: d, id: b64url(d), name: baseOf(d), folder: dirOf(d), title: m.title, shown: m.shown, date: m.date, mtime: m.time, current: d === deck };
417
+ }))).filter(Boolean);
418
+ return json(out.sort((a, b) => b.mtime - a.mtime));
419
+ }
420
+ if (path === '/api/folders') return json(this.walk(v).folders);
421
+ if (path === '/api/recent') return json(await Promise.all(this.recent.filter(d => v[d + '/deck.html']).map(async d => ({ path: d, title: (await this.meta(d, v[d + '/deck.html'])).title, current: d === deck }))));
422
+ if (path === '/api/localfonts') return json([]);
423
+ if (path === '/api/history') return json(await this.history(deck, q.get('slide') ? +q.get('slide') : null));
424
+ if (path === '/api/version') {
425
+ if (!/^[\w.:-]{1,200}$/.test(q.get('rev') || '')) return json({ error: 'bad version' }, 400);
426
+ return json({ html: await this.at(q.get('rev'), deckFile) });
427
+ }
428
+ return new Response('not found', { status: 404 });
429
+ }
430
+
431
+ if (path === '/api/save') {
432
+ const r = j();
433
+ if (r.mtime !== (v[deckFile]?.v || '')) return json({ error: 'conflict', mtime: v[deckFile]?.v || '' }, 409);
434
+ const mtime = await this.put(deckFile, enc(r.html));
435
+ if (isDraft) { // decks.py name_draft: into the library once it has a title
436
+ const title = titleOf(r.html), id = deck.slice(8);
437
+ if (!title || title === UNTITLED || title === baseOf(deck)) { await this.savePending(); return json({ mtime, draft: true }); }
438
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'deck';
439
+ const dst = this.unique(join(clean(this.drafts[id]?.folder || ''), slug));
440
+ this.copyTree(deck, dst, true); delete this.drafts[id];
441
+ const a = this.agentsDoc(); if (a) this.set(dst + '/AGENTS.md', a);
442
+ await this.savePending(); await this.schedule();
443
+ return this.open(dst, json({ mtime, moved: dst }));
444
+ }
445
+ await this.savePending(); await this.schedule();
446
+ return json({ mtime });
447
+ }
448
+ if (path === '/api/discard') {
449
+ if (isDraft) { for (const p of Object.keys(v)) if (p.startsWith(deck + '/')) this.remove(p); delete this.drafts[deck.slice(8)]; await this.savePending(); }
450
+ return json({ ok: true }, 200, { 'set-cookie': cookie('deck', '', 0) });
451
+ }
452
+ if (path === '/api/open') {
453
+ const r = j();
454
+ try {
455
+ if (r.new) { // a draft, kept out of the library until it has a title
456
+ for (const [id, d] of Object.entries(this.drafts)) if (Date.now() - d.t > 86400_000) { for (const p of Object.keys(v)) if (p.startsWith(`.drafts/${id}/`)) this.remove(p); delete this.drafts[id]; }
457
+ const folder = clean(r.folder), id = `draft-${Date.now()}`;
458
+ this.drafts[id] = { folder, t: Date.now() };
459
+ await this.put(`.drafts/${id}/deck.html`, enc(await this.newDeck()));
460
+ await this.savePending();
461
+ return this.open(`.drafts/${id}`, json({ path: `.drafts/${id}` }));
462
+ }
463
+ if (r.copy) {
464
+ const dst = join(dirOf(deck), clean(r.copy));
465
+ if (Object.keys(v).some(p => p.startsWith(dst + '/'))) return json({ error: `${baseOf(dst)} already exists` });
466
+ this.copyTree(deck, dst);
467
+ await this.libraryChange(`library: copy ${baseOf(deck)} to ${dst}`);
468
+ return this.open(dst, json({ path: dst }));
469
+ }
470
+ const asked = clean(r.path), target = v[asked + '/deck.html'] ? asked : join(dirOf(deck), asked);
471
+ if (!v[target + '/deck.html']) {
472
+ if (!r.create) return json({ error: `no deck.html in ${target}` });
473
+ await this.makeDeck(target);
474
+ await this.libraryChange(`library: new deck ${target}`);
475
+ }
476
+ return this.open(target, json({ path: target }));
477
+ } catch (e) { return json({ error: String(e.message || e) }); }
478
+ }
479
+ if (path === '/api/deckop') {
480
+ const r = j(), d = this.walk(v).decks.find(x => x === r.path);
481
+ if (!d) return json({ error: 'unknown deck' });
482
+ if (r.op === 'rename') {
483
+ await this.put(d + '/deck.html', enc(setTitle(dec(await this.read(v[d + '/deck.html'])), String(r.title || '').trim() || baseOf(d))));
484
+ await this.libraryChange(`library: rename ${baseOf(d)}`);
485
+ } else if (r.op === 'duplicate') {
486
+ const dst = this.unique(d + '-copy');
487
+ this.copyTree(d, dst);
488
+ await this.put(dst + '/deck.html', enc(setTitle(dec(await this.read(v[d + '/deck.html'])), 'Copy of ' + titleOf(dec(await this.read(v[d + '/deck.html'])), baseOf(d)))));
489
+ await this.libraryChange(`library: duplicate ${baseOf(d)}`);
490
+ } else if (r.op === 'delete') {
491
+ for (const p of Object.keys(v)) if (p.startsWith(d + '/')) this.remove(p);
492
+ await this.unshare(d);
493
+ this.recent = this.recent.filter(x => x !== d);
494
+ await this.libraryChange(`library: remove ${baseOf(d)}`);
495
+ } else if (r.op === 'move') {
496
+ let folder; try { folder = clean(r.folder); } catch (e) { return json({ error: e.message }); }
497
+ if (folder === dirOf(d)) return json({ ok: true });
498
+ const dst = this.unique(join(folder, baseOf(d)));
499
+ this.copyTree(d, dst, true); await this.rekey(d, dst);
500
+ this.recent = this.recent.map(x => x === d ? dst : x);
501
+ await this.libraryChange(`library: move ${baseOf(d)} to ${folder || 'the top'}`);
502
+ if (d === deck) return this.open(dst, json({ ok: true }));
503
+ } else return json({ error: r.op === 'reveal' ? MAC_ONLY : 'unknown op' });
504
+ return json({ ok: true });
505
+ }
506
+ if (path === '/api/folder') {
507
+ const r = j();
508
+ try {
509
+ const at = clean(r.path), name = String(r.name || '').replace(/[/\\:]+/g, '-').trim(), inside = p => Object.keys(v).filter(k => k.startsWith(p + '/'));
510
+ if (r.op === 'create') {
511
+ if (!name || name.startsWith('.')) return json({ error: 'a folder needs a name' });
512
+ if (inside(join(at, name)).length) return json({ error: `${name} already exists` });
513
+ await this.put(join(at, name, '.keep'), new Uint8Array());
514
+ } else if (r.op === 'rename') {
515
+ if (!at || !name) return json({ error: 'nothing to rename' });
516
+ const dst = join(dirOf(at), name);
517
+ if (inside(dst).length) return json({ error: `${name} already exists` });
518
+ this.copyTree(at, dst, true); await this.rekey(at, dst);
519
+ this.recent = this.recent.map(x => x.startsWith(at + '/') ? dst + x.slice(at.length) : x);
520
+ if (deck.startsWith(at + '/')) { await this.libraryChange(`library: rename folder ${at} ${name}`); return this.open(dst + deck.slice(at.length), json({ ok: true })); }
521
+ } else if (r.op === 'delete') {
522
+ if (!at || inside(at).some(p => baseOf(p) !== '.keep')) return json({ error: 'only an empty folder can be removed' });
523
+ for (const p of inside(at)) this.remove(p);
524
+ } else return json({ error: 'unknown folder op' });
525
+ await this.libraryChange(['library:', r.op, 'folder', r.path, name].filter(Boolean).join(' '));
526
+ return json({ ok: true });
527
+ } catch (e) { return json({ error: String(e.message || e) }); }
528
+ }
529
+ if ((path === '/api/upload' || path === '/api/font' || path === '/api/save' || path === '/api/importdeck') && body?.byteLength > MAX_FETCH) return json({ error: 'That file is too large (25 MB at most).' });
530
+ // a deck from elsewhere (a downloaded .html, or one moved from a browser's library): its deck.html makes the deck, then
531
+ // /api/importfile brings each of its other files, raw, one request each (small requests: Workers' CPU limits)
532
+ if (path === '/api/importdeck') {
533
+ const r = j();
534
+ if (typeof r.html !== 'string' || !/<section[\s>]/.test(r.html)) return json({ error: "That isn't a Glideshow deck." });
535
+ let folder; try { folder = clean(r.folder || ''); } catch (e) { return json({ error: e.message }); }
536
+ const dst = this.unique(join(folder, String(r.name || 'deck').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'deck'));
537
+ await this.put(dst + '/deck.html', enc(r.html));
538
+ const a = this.agentsDoc(); if (a) this.set(dst + '/AGENTS.md', a);
539
+ await this.libraryChange(`library: bring in ${baseOf(dst)}`);
540
+ return json({ ok: true, path: dst });
541
+ }
542
+ if (path === '/api/importfile') {
543
+ let d = '', rel = ''; try { d = clean(q.get('deck') || ''); rel = clean(q.get('file') || ''); } catch (e) { return json({ error: e.message }); }
544
+ if (!d || !rel || rel === 'deck.html' || !v[d + '/deck.html']) return json({ error: 'no such deck' });
545
+ if (body.byteLength > MAX_FETCH) return json({ error: `${baseOf(rel)} is too large (25 MB at most).` });
546
+ await this.put(join(d, rel), new Uint8Array(body)); await this.savePending(); await this.schedule();
547
+ return json({ ok: true });
548
+ }
549
+ if (path === '/api/upload') return json({ src: await this.storeAsset(deck, new Uint8Array(body), (q.get('ext') || 'png').toLowerCase()) });
550
+ if (path === '/api/fetch') { // a pasted image address: the picture is copied into the deck
551
+ const src = j().url || '';
552
+ if (!/^https?:\/\//.test(src)) return json({ error: 'not a web address' });
553
+ let r; try { r = await fetch(src, { headers: { 'user-agent': 'glideshow' } }); } catch (e) { return json({ error: String(e) }); }
554
+ const kind = (r.headers.get('content-type') || '').split(';')[0].trim(), size = +r.headers.get('content-length') || 0;
555
+ if (!kind.startsWith('image/')) return json({ error: 'not an image' });
556
+ if (size > MAX_FETCH) return json({ error: 'too large' });
557
+ const data = new Uint8Array(await r.arrayBuffer());
558
+ if (data.length > MAX_FETCH) return json({ error: 'too large' });
559
+ return json({ src: await this.storeAsset(deck, data, { 'image/jpeg': 'jpg', 'image/svg+xml': 'svg' }[kind] || kind.split('/')[1]) });
560
+ }
561
+ if (path === '/api/font') { // a dropped font file into assets/fonts/
562
+ if (q.has('path')) return json({ error: MAC_ONLY });
563
+ const name = q.get('name') || '', ext = (name.match(/\.[^.]+$/)?.[0] || '').toLowerCase(), data = new Uint8Array(body);
564
+ if (!['.ttf', '.otf', '.woff', '.woff2'].includes(ext) || !data.length) return json({ error: 'not a font file' });
565
+ const stem = name.slice(0, -ext.length).replace(/[^A-Za-z0-9 _-]/g, '').trim().replace(/ /g, '-') || 'font';
566
+ await this.put(`${deck}/assets/fonts/${stem}${ext}`, data); await this.savePending(); await this.schedule();
567
+ return json({ src: `assets/fonts/${stem}${ext}`, family: stem.replace(/[-_]/g, ' ') });
568
+ }
569
+ if (path === '/api/restore') { // Version history → Restore: that deck.html and the pictures it used, back
570
+ const rev = j().rev || '';
571
+ if (!/^[\w.:-]{1,200}$/.test(rev)) return json({ error: 'bad version' });
572
+ const html = await this.at(rev, deckFile); if (!html) return json({ error: 'no such version' });
573
+ const then = await this.store.filesAt(rev).catch(() => null) || {}; // the pictures that version used (GitHub; Drive versions are per file)
574
+ for (const [p, e] of Object.entries(then)) if (p.startsWith(deck + '/assets/') && !v[p]) this.set(p, { ref: e.ref, bv: e.v, v: e.v, t: Date.now() });
575
+ const mtime = await this.put(deckFile, enc(html));
576
+ this.msgs.push(`deck: restore a version of ${baseOf(deck)}`); await this.savePending(); await this.flush();
577
+ return json({ mtime });
578
+ }
579
+ if (path === '/api/name') return json({ error: 'Naming versions works in the Mac app only.' });
580
+ if (['/api/importpptx', '/api/importgslides', '/api/quit'].includes(path)) return json({ error: MAC_ONLY });
581
+ return new Response('not found', { status: 404 });
582
+ }
583
+
584
+ async storeAsset(deck, data, ext) { // server.py store: assets/ under a content hash
585
+ if (!/^[a-z0-9]{1,5}$/.test(ext)) ext = 'png';
586
+ const name = `${hex(await crypto.subtle.digest('SHA-1', data)).slice(0, 12)}.${ext}`, p = `${deck}/assets/${name}`;
587
+ if (!this.view()[p]) { await this.put(p, data); await this.savePending(); await this.schedule(); }
588
+ return `assets/${name}`;
589
+ }
590
+ async rekey(from, to) { // publish.py rekey: published links follow a moved deck or folder
591
+ let led; try { led = JSON.parse(await this.text('published.json') || ''); } catch { return; }
592
+ const moved = Object.fromEntries(Object.entries(led).map(([k, x]) => [k === from || k.startsWith(from + '/') ? to + k.slice(from.length) : k, x]));
593
+ if (JSON.stringify(moved) !== JSON.stringify(led)) await this.put('published.json', enc(JSON.stringify(moved, null, 2) + '\n'));
594
+ }
595
+ async unshare(d) { // publish.py drop: a removed deck stops being shared, its frozen copy (Drive) gone too
596
+ let led; try { led = JSON.parse(await this.text('published.json') || ''); } catch { return; }
597
+ const gone = Object.keys(led).filter(k => k === d || k.startsWith(d + '/'));
598
+ if (!gone.length) return;
599
+ for (const k of gone) { await this.store.unpin(led[k].slug).catch(() => {}); delete led[k]; }
600
+ await this.put('published.json', enc(JSON.stringify(led, null, 2) + '\n'));
601
+ }
602
+ async at(rev, path) { return this.store.readAt(rev, path); } // a file as of one version
603
+ async history(deck, slide) { // history.py history: the store's versions of the deck's deck.html
604
+ let out = await this.store.history(deck + '/deck.html', slide ? 40 : 100).catch(() => []);
605
+ if (slide) {
606
+ const sec = h => { const i = h.indexOf('class="slides"'); return [...(i < 0 ? h : h.slice(i)).split(/<section\b/).slice(1), ...Array(slide).fill('')][slide - 1]; };
607
+ const texts = [...await Promise.all(out.map(c => this.at(c.rev, deck + '/deck.html'))), ''];
608
+ out = out.filter((c, i) => sec(texts[i]) !== sec(texts[i + 1]));
609
+ }
610
+ return out;
611
+ }
612
+ }