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.
- glideshow/__init__.py +15 -0
- glideshow/__main__.py +3 -0
- glideshow/agent.py +681 -0
- glideshow/build.py +54 -0
- glideshow/cli.py +320 -0
- glideshow/comments.py +35 -0
- glideshow/decks.py +230 -0
- glideshow/docs/agents.md +223 -0
- glideshow/docs/deck-format.md +125 -0
- glideshow/editor/.assetsignore +5 -0
- glideshow/editor/apple-touch-icon.png +0 -0
- glideshow/editor/data/font-metrics.json +1 -0
- glideshow/editor/data/fonts.json +1 -0
- glideshow/editor/data/icons.json +1 -0
- glideshow/editor/data/symbols.js +42 -0
- glideshow/editor/data/themes.js +15 -0
- glideshow/editor/deck/base.css +92 -0
- glideshow/editor/deck/present.js +222 -0
- glideshow/editor/deck/shapes.js +377 -0
- glideshow/editor/deck/template.html +37 -0
- glideshow/editor/editor.css +657 -0
- glideshow/editor/glideshow-1024.png +0 -0
- glideshow/editor/glideshow.svg +10 -0
- glideshow/editor/globals.d.ts +21 -0
- glideshow/editor/home.html +358 -0
- glideshow/editor/index.html +386 -0
- glideshow/editor/js/arrange.js +122 -0
- glideshow/editor/js/boot.js +66 -0
- glideshow/editor/js/boxes.js +25 -0
- glideshow/editor/js/comments.js +85 -0
- glideshow/editor/js/context-menus.js +93 -0
- glideshow/editor/js/dialogs.js +168 -0
- glideshow/editor/js/dock.js +22 -0
- glideshow/editor/js/draw.js +107 -0
- glideshow/editor/js/file.js +318 -0
- glideshow/editor/js/find.js +72 -0
- glideshow/editor/js/format-panel.js +101 -0
- glideshow/editor/js/geometry.js +153 -0
- glideshow/editor/js/guides.js +37 -0
- glideshow/editor/js/insert.js +73 -0
- glideshow/editor/js/keyboard.js +256 -0
- glideshow/editor/js/live.js +48 -0
- glideshow/editor/js/menubar.js +290 -0
- glideshow/editor/js/menus.js +93 -0
- glideshow/editor/js/motion.js +148 -0
- glideshow/editor/js/notes.js +63 -0
- glideshow/editor/js/objects.js +202 -0
- glideshow/editor/js/people.js +73 -0
- glideshow/editor/js/phone-comments.js +43 -0
- glideshow/editor/js/phone-edit.js +253 -0
- glideshow/editor/js/phone-home.js +51 -0
- glideshow/editor/js/phone-mode.js +73 -0
- glideshow/editor/js/phone-sheets.js +153 -0
- glideshow/editor/js/phone-touch.js +145 -0
- glideshow/editor/js/phone-view.js +150 -0
- glideshow/editor/js/pointer.js +407 -0
- glideshow/editor/js/render.js +353 -0
- glideshow/editor/js/shortcuts.js +67 -0
- glideshow/editor/js/slides.js +120 -0
- glideshow/editor/js/spacing.js +23 -0
- glideshow/editor/js/state.js +58 -0
- glideshow/editor/js/storage.js +250 -0
- glideshow/editor/js/text.js +366 -0
- glideshow/editor/js/theme-toggle.js +41 -0
- glideshow/editor/js/theme.js +173 -0
- glideshow/editor/js/toolbar-groups.js +109 -0
- glideshow/editor/js/toolbar.js +372 -0
- glideshow/editor/lib/browser-db.js +47 -0
- glideshow/editor/lib/library.js +612 -0
- glideshow/editor/lib/merge.js +174 -0
- glideshow/editor/lib/move-local.js +51 -0
- glideshow/editor/lib/onefile.js +108 -0
- glideshow/editor/lib/store-browser.js +75 -0
- glideshow/editor/lib/store-folder.js +109 -0
- glideshow/editor/local/sw.js +66 -0
- glideshow/editor/local/tsconfig.json +5 -0
- glideshow/editor/looks/glider.svg +9 -0
- glideshow/editor/looks/thermal-phone.css +144 -0
- glideshow/editor/looks/thermal.css +268 -0
- glideshow/editor/manifest.json +23 -0
- glideshow/editor/phone.css +345 -0
- glideshow/editor/share/share.css +124 -0
- glideshow/editor/share/share.js +256 -0
- glideshow/editor/share/speaker.html +88 -0
- glideshow/editor/tsconfig.json +6 -0
- glideshow/figure.py +33 -0
- glideshow/fonts.py +63 -0
- glideshow/gslides.py +1065 -0
- glideshow/history.py +210 -0
- glideshow/merge.py +210 -0
- glideshow/osutil.py +44 -0
- glideshow/pptx_export.py +207 -0
- glideshow/pptx_import.py +115 -0
- glideshow/publish.py +106 -0
- glideshow/render.py +288 -0
- glideshow/server.py +549 -0
- glideshow/service.py +65 -0
- glideshow/skills/glideshow/SKILL.md +85 -0
- glideshow-0.1.2.dist-info/METADATA +69 -0
- glideshow-0.1.2.dist-info/RECORD +103 -0
- glideshow-0.1.2.dist-info/WHEEL +4 -0
- glideshow-0.1.2.dist-info/entry_points.txt +2 -0
- glideshow-0.1.2.dist-info/licenses/LICENSE +662 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/* Three-way merges of slides and of whole decks, on their HTML text (no DOM): the editor merges with them when a deck
|
|
2
|
+
was saved elsewhere while it has changes of its own (storage.js mergeIn), and a library online does when a push from
|
|
3
|
+
the Mac lands under a save of its own not yet committed (lib/library.js rebaseDecks).
|
|
4
|
+
|
|
5
|
+
`base` is the version both sides started from; `mine` and `theirs` were made from it apart. A slide (by data-sid)
|
|
6
|
+
comes from whichever side changed it. One both changed is merged box by box (by id): a box added on either side is
|
|
7
|
+
kept, one removed on one side and left alone on the other goes, and one changed on both keeps mine. A slide or box
|
|
8
|
+
one side removed and the other changed is kept, changed. The order is theirs, unless I moved things, then mine;
|
|
9
|
+
what is new on one side goes after what it followed there.
|
|
10
|
+
|
|
11
|
+
A classic script and a module at once (it exports nothing): the editor loads it with a <script>, lib/library.js
|
|
12
|
+
imports it; both find it on globalThis.glideMerge. */
|
|
13
|
+
(() => {
|
|
14
|
+
const normHTML = h => h.replace(/\sid="[^"]*"/g, ''); // ids the editor gives id-less boxes differ on every load
|
|
15
|
+
const sameHTML = (a, b) => a === b || normHTML(a) === normHTML(b);
|
|
16
|
+
const same = (a, b) => a !== undefined && b !== undefined && sameHTML(a, b);
|
|
17
|
+
const sidOf = h => (h.match(/^<section\b[^>]*\sdata-sid="([^"]+)"/) || [])[1] || '';
|
|
18
|
+
|
|
19
|
+
/* Keyed lists (key(item) → id), three ways. both(b, m, t) settles an item both sides changed (default: mine);
|
|
20
|
+
val(item) is the HTML compared. */
|
|
21
|
+
function mergeLists(base, mine, theirs, key, both = (b, m, t) => m, val = x => x) {
|
|
22
|
+
const map = list => new Map(list.map(h => [key(h), h]));
|
|
23
|
+
const same = (a, b) => a !== undefined && b !== undefined && sameHTML(val(a), val(b));
|
|
24
|
+
const B = map(base), M = map(mine), T = map(theirs), clash = [], out = new Map();
|
|
25
|
+
for (const id of new Set([...M.keys(), ...T.keys(), ...B.keys()])) {
|
|
26
|
+
const b = B.get(id), m = M.get(id), t = T.get(id);
|
|
27
|
+
if (m !== undefined && t !== undefined) {
|
|
28
|
+
if (same(m, b)) out.set(id, t);
|
|
29
|
+
else if (same(t, b) || same(t, m)) out.set(id, m);
|
|
30
|
+
else { out.set(id, both(b, m, t)); clash.push(id); }
|
|
31
|
+
}
|
|
32
|
+
else if (m !== undefined) { if (b === undefined || !same(m, b)) out.set(id, m); } // new here, or changed here though removed there
|
|
33
|
+
else if (t !== undefined) { if (b === undefined || !same(t, b)) out.set(id, t); }
|
|
34
|
+
}
|
|
35
|
+
const ids = list => list.map(key);
|
|
36
|
+
const moved = ids(mine).filter(id => B.has(id)).join() !== ids(base).filter(id => M.has(id)).join();
|
|
37
|
+
const [lead, other] = moved ? [mine, theirs] : [theirs, mine];
|
|
38
|
+
const order = ids(lead).filter(id => out.has(id));
|
|
39
|
+
ids(other).forEach((id, i) => { // what only the other side has: after what it follows there
|
|
40
|
+
if (!out.has(id) || order.includes(id)) return;
|
|
41
|
+
const prev = ids(other).slice(0, i).reverse().find(x => order.includes(x));
|
|
42
|
+
order.splice(prev ? order.indexOf(prev) + 1 : 0, 0, id);
|
|
43
|
+
});
|
|
44
|
+
for (const id of out.keys()) if (!order.includes(id)) order.push(id);
|
|
45
|
+
return { list: order.map(id => out.get(id)), clash: clash.filter(id => order.includes(id)), order };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* An element's parts, read off its text: what opens it, its top-level children (each whole), and what closes it.
|
|
49
|
+
null when the tags don't pair up. Comments and raw-text elements (script, style, …) are taken whole. */
|
|
50
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
51
|
+
const TOKEN = /<!--[\s\S]*?-->|<(script|style|textarea|title|xmp|noscript|template)\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1\s*>|<(\/?)([a-zA-Z][\w:-]*)\b(?:[^>"']|"[^"]*"|'[^']*')*?(\/?)>/g;
|
|
52
|
+
function parts(html) {
|
|
53
|
+
const open = html.match(/^\s*<([a-zA-Z][\w-]*)\b(?:[^>"']|"[^"]*"|'[^']*')*>/);
|
|
54
|
+
if (!open) return null;
|
|
55
|
+
const close = html.match(/<\/\s*([a-zA-Z][\w-]*)\s*>\s*$/);
|
|
56
|
+
if (!close || close[1].toLowerCase() !== open[1].toLowerCase()) return null;
|
|
57
|
+
const body = html.slice(open[0].length, html.length - close[0].length), kids = [];
|
|
58
|
+
let depth = 0, start = -1, last = 0;
|
|
59
|
+
const text = (a, z) => { const t = body.slice(a, z).trim(); if (t) kids.push(t); }; // loose text between elements
|
|
60
|
+
for (const m of body.matchAll(TOKEN)) {
|
|
61
|
+
const at = m.index, end = at + m[0].length;
|
|
62
|
+
const whole = m[0].startsWith('<!--') || m[1] || (!m[2] && (m[4] || VOID.has(m[3].toLowerCase())));
|
|
63
|
+
if (depth === 0) text(last, at);
|
|
64
|
+
if (whole) { if (depth === 0) { kids.push(m[0]); last = end; } continue; }
|
|
65
|
+
if (m[2]) { if (--depth < 0) return null; if (depth === 0) { kids.push(body.slice(start, end)); last = end; } }
|
|
66
|
+
else { if (depth === 0) start = at; depth++; }
|
|
67
|
+
}
|
|
68
|
+
if (depth) return null;
|
|
69
|
+
text(last, body.length);
|
|
70
|
+
return { open: open[0].trim(), kids, close: close[0].trim() };
|
|
71
|
+
}
|
|
72
|
+
/* A child's key: its id, else its tag and class with how many such came before it (a slide's notes, its style). */
|
|
73
|
+
function keyer() {
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
return h => {
|
|
76
|
+
const id = h.match(/^<[a-zA-Z][\w-]*\b(?:[^>"']|"[^"]*"|'[^']*')*?\sid="([^"]+)"/);
|
|
77
|
+
if (id) return 'id:' + id[1];
|
|
78
|
+
const tag = (h.match(/^<([a-zA-Z][\w-]*)\b(?:[^>"']|"[^"]*"|'[^']*')*?(?:\sclass="([^"]*)")?/) || []);
|
|
79
|
+
const k = tag[1] ? tag[1].toLowerCase() + '.' + (tag[2] || '') : '#text', n = seen.get(k) || 0;
|
|
80
|
+
seen.set(k, n + 1); return k + '#' + n;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const keyed = list => { const k = keyer(); return list.map(h => [k(h), h]); };
|
|
84
|
+
|
|
85
|
+
/* One slide both sides changed, box by box. Its own attributes (background, transition…) come from whichever side
|
|
86
|
+
changed them, mine if both. A box without an id that was left as it was keeps its place by its content; when most
|
|
87
|
+
of a side's boxes can't be matched (the slide was written anew), the slide is mine whole, as before. */
|
|
88
|
+
function mergeBoxes(b, m, t, note = {}) { // note.lost: something of theirs gave way to mine (a box, the slide's attributes, the slide)
|
|
89
|
+
note.lost = true;
|
|
90
|
+
const B = b === undefined ? null : parts(b), M = parts(m), T = parts(t);
|
|
91
|
+
if (!B || !M || !T || /<section\b/i.test(B.kids.join('') + M.kids.join('') + T.kids.join(''))) return m;
|
|
92
|
+
const bk = keyed(B.kids), base = new Map(bk);
|
|
93
|
+
const match = side => { // a side's children keyed as in base; id-less ones the editor numbered anew are found by content
|
|
94
|
+
const out = keyed(side.kids), free = bk.filter(([k]) => !out.some(([o]) => o === k));
|
|
95
|
+
let unmatched = 0;
|
|
96
|
+
const res = out.map(([k, h]) => {
|
|
97
|
+
if (base.has(k)) return [k, h];
|
|
98
|
+
const i = free.findIndex(([, bh]) => normHTML(bh) === normHTML(h));
|
|
99
|
+
if (i >= 0) { const [fk] = free.splice(i, 1); return [fk, h]; }
|
|
100
|
+
unmatched++; return [k, h];
|
|
101
|
+
});
|
|
102
|
+
return { res, stale: bk.length > 1 && bk.filter(([k]) => !res.some(([o]) => o === k)).length > bk.length / 2 && unmatched > 0 };
|
|
103
|
+
};
|
|
104
|
+
const mm = match(M), tt = match(T);
|
|
105
|
+
if (mm.stale || tt.stale) return m;
|
|
106
|
+
const { list, clash } = mergeLists(bk, mm.res, tt.res, x => x[0], undefined, x => x[1]), kids = list.map(x => x[1]);
|
|
107
|
+
const open = same(M.open, B.open) ? T.open : M.open;
|
|
108
|
+
note.lost = clash.length > 0 || (!same(M.open, B.open) && !same(T.open, B.open) && !same(M.open, T.open));
|
|
109
|
+
return `${open}\n${kids.map(k => ' ' + k).join('\n')}\n${M.close}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* The editor's slides (each one <section>, as storage.js sectionHTML writes them), three ways: { html, both, bothIds },
|
|
113
|
+
both being the slides (1-based, in the result) where something both sides changed kept mine, so theirs of it is
|
|
114
|
+
only in the version history (said plainly to whoever kept theirs out: storage.js mergeNote). */
|
|
115
|
+
function mergeSlides(base, mine, theirs) {
|
|
116
|
+
const lost = new Set();
|
|
117
|
+
const { list, order } = mergeLists(base, mine, theirs, sidOf, (b, m, t) => { const n = {}, h = mergeBoxes(b, m, t, n); if (n.lost) lost.add(sidOf(m)); return h; });
|
|
118
|
+
const ids = [...lost].filter(id => order.includes(id));
|
|
119
|
+
return { html: list, both: ids.map(id => order.indexOf(id) + 1), bothIds: ids };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* A deck file's slides: what comes before the first one, each top-level <section> (a stack, of sections, is one), and
|
|
123
|
+
what comes after. null when they don't pair up. */
|
|
124
|
+
function splitDeck(html) {
|
|
125
|
+
const re = /<!--[\s\S]*?-->|<(script|style|textarea|title|xmp|noscript|template)\b[\s\S]*?<\/\1\s*>|<section\b(?:[^>"']|"[^"]*"|'[^']*')*>|<\/section\s*>/gi;
|
|
126
|
+
const slides = []; let depth = 0, start = -1, first = -1, end = -1;
|
|
127
|
+
for (const m of html.matchAll(re)) {
|
|
128
|
+
if (m[1] || m[0].startsWith('<!--')) continue;
|
|
129
|
+
if (m[0][1] === '/') { if (--depth < 0) return null; if (!depth) { slides.push(html.slice(start, m.index + m[0].length)); end = m.index + m[0].length; } }
|
|
130
|
+
else { if (!depth) { start = m.index; if (first < 0) first = start; } depth++; }
|
|
131
|
+
}
|
|
132
|
+
if (depth || first < 0) return null;
|
|
133
|
+
return { head: html.slice(0, first), slides, tail: html.slice(end) };
|
|
134
|
+
}
|
|
135
|
+
/* Each version's top-level slides keyed: by data-sid (a stack by the first one inside it). A slide without one (an
|
|
136
|
+
agent's, not yet opened in an editor) by its exact content: it takes the id of the one slide elsewhere with that
|
|
137
|
+
same content (an editor has since given it an id), but only when that is unambiguous: one id goes with that
|
|
138
|
+
content in all the versions and it is the only one without an id in its own. Otherwise (two identical slides, a
|
|
139
|
+
candidate beside its hidden original) each keeps a key of its content and how many came before it, so nothing is
|
|
140
|
+
paired with the wrong partner; at worst both are kept. Near-identical slides (Morph steps) never match: the
|
|
141
|
+
content must be the same. A slide without an id that a side changed is that side removing it and adding another,
|
|
142
|
+
which is what a three-way merge of it should do. */
|
|
143
|
+
const hashStr = t => { let h = 2166136261; for (let i = 0; i < t.length; i++) h = Math.imul(h ^ t.charCodeAt(i), 16777619); return (h >>> 0).toString(36); };
|
|
144
|
+
const print = h => normHTML(h).replace(/\sdata-sid="[^"]*"/g, '').replace(/>\s+</g, '><').replace(/\s+/g, ' ').trim();
|
|
145
|
+
const innerSid = h => (h.match(/<section\b[^>]*\sdata-sid="([^"]+)"/) || [])[1];
|
|
146
|
+
const ownKey = h => sidOf(h) || (innerSid(h) ? 'stack:' + innerSid(h) : '');
|
|
147
|
+
function slideKeys(lists) {
|
|
148
|
+
const owners = new Map(), loose = lists.map(() => new Map()); // print → the ids with it; print → how many without one, per version
|
|
149
|
+
lists.forEach((l, i) => l.forEach(h => { const k = ownKey(h), f = print(h);
|
|
150
|
+
if (k) owners.set(f, new Set([...(owners.get(f) || []), k])); else loose[i].set(f, (loose[i].get(f) || 0) + 1); }));
|
|
151
|
+
const sure = f => owners.get(f)?.size === 1 && loose.every(m => (m.get(f) || 0) <= 1) ? [...owners.get(f)][0] : '';
|
|
152
|
+
return lists.map(l => { const seen = new Map(); return l.map(h => {
|
|
153
|
+
const k = ownKey(h); if (k) return [k, h];
|
|
154
|
+
const f = print(h), id = sure(f); if (id) return [id, h];
|
|
155
|
+
const n = seen.get(f) || 0; seen.set(f, n + 1); return ['c:' + hashStr(f) + ':' + n, h];
|
|
156
|
+
}); });
|
|
157
|
+
}
|
|
158
|
+
const third = (b, m, t) => same(m, b) ? t : m;
|
|
159
|
+
/* Whole deck files, three ways: { html, both } (both as in mergeSlides), or null when one can't be read as slides. */
|
|
160
|
+
function mergeDecks(base, mine, theirs) {
|
|
161
|
+
const [B, M, T] = [base, mine, theirs].map(splitDeck);
|
|
162
|
+
if (!M || !T || (base && !B)) return null;
|
|
163
|
+
const [kb, km, kt] = slideKeys([B ? B.slides : [], M.slides, T.slides]);
|
|
164
|
+
const lost = new Set();
|
|
165
|
+
const { list, order } = mergeLists(kb, km, kt, x => x[0], (b, m, t) => { const n = { lost: true }, h = sidOf(m[1]) ? mergeBoxes(b?.[1], m[1], t[1], n) : m[1]; if (n.lost) lost.add(m[0]); return [m[0], h]; }, x => x[1]);
|
|
166
|
+
const ids = [...lost].filter(id => order.includes(id));
|
|
167
|
+
return {
|
|
168
|
+
html: third(B?.head, M.head, T.head) + list.map(x => x[1]).join('\n\n') + third(B?.tail, M.tail, T.tail),
|
|
169
|
+
both: ids.map(id => order.indexOf(id) + 1), bothIds: ids,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @type {any} */ (globalThis).glideMerge = { mergeSlides, mergeDecks, mergeBoxes, splitDeck, sidOf, normHTML };
|
|
174
|
+
})();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* After signing in: the decks made in this browser without an account (local/sw.js), offered for moving into the
|
|
2
|
+
account's library. The browser's library is read here with the same code that keeps it (library.js over
|
|
3
|
+
store-browser.js); each deck goes over with its pictures (onefile.js bringIn) and leaves the browser once all of it is in. */
|
|
4
|
+
|
|
5
|
+
import { LibraryCore, baseOf, dirOf } from './library.js';
|
|
6
|
+
import { BrowserStore } from './store-browser.js';
|
|
7
|
+
import { storage } from './browser-db.js';
|
|
8
|
+
import { bringIn } from './onefile.js';
|
|
9
|
+
|
|
10
|
+
const LATER = 'glideshow-move-later';
|
|
11
|
+
|
|
12
|
+
async function browserLibrary() {
|
|
13
|
+
if (indexedDB.databases && !(await indexedDB.databases()).some(d => d.name === 'glideshow')) return null; // never used without an account
|
|
14
|
+
const lib = new LibraryCore({ storage, blockConcurrencyWhile: fn => fn() }, {}, { store: l => new BrowserStore(l), share: {} });
|
|
15
|
+
await lib.loaded;
|
|
16
|
+
if (lib.cfg.kind !== 'browser') return null;
|
|
17
|
+
await lib.refresh(true);
|
|
18
|
+
return lib;
|
|
19
|
+
}
|
|
20
|
+
const call = (lib, path, body) => lib.fetch(new Request('https://browser' + path, { method: 'POST', body: JSON.stringify(body) })).then(r => r.json());
|
|
21
|
+
|
|
22
|
+
/** @param {HTMLElement} where the home screen's place for it */
|
|
23
|
+
export async function offer(where) {
|
|
24
|
+
let later = 0; try { later = +(localStorage.getItem(LATER) || 0); } catch {}
|
|
25
|
+
if (Date.now() - later < 7 * 86400_000) return; // "Not now" holds for a week
|
|
26
|
+
const lib = await browserLibrary(); if (!lib) return;
|
|
27
|
+
const v = lib.view(), decks = lib.walk(v).decks; if (!decks.length) return;
|
|
28
|
+
const bar = document.createElement('div'); bar.className = 'hm-move';
|
|
29
|
+
const say = document.createElement('span'), move = document.createElement('button'), not = document.createElement('button');
|
|
30
|
+
say.textContent = `${decks.length} ${decks.length === 1 ? 'deck' : 'decks'} you made in this browser without an account can move into your library.`;
|
|
31
|
+
move.textContent = 'Move them here'; move.className = 'hm-move-go primary'; not.textContent = 'Not now'; not.className = 'hm-move-not';
|
|
32
|
+
not.onclick = () => { try { localStorage.setItem(LATER, String(Date.now())); } catch {} bar.remove(); };
|
|
33
|
+
move.onclick = async () => {
|
|
34
|
+
move.disabled = not.disabled = true; let moved = 0; const failed = [];
|
|
35
|
+
for (const d of decks) {
|
|
36
|
+
say.textContent = `Moving ${moved + 1} of ${decks.length}…`;
|
|
37
|
+
const /** @type {Record<string, Uint8Array>} */ files = {}; let html = '';
|
|
38
|
+
for (const p of Object.keys(v)) if (p.startsWith(d + '/')) {
|
|
39
|
+
const rel = p.slice(d.length + 1), bytes = await lib.read(v[p]);
|
|
40
|
+
if (rel === 'deck.html') html = new TextDecoder().decode(bytes); else if (rel !== 'AGENTS.md') files[rel] = bytes;
|
|
41
|
+
}
|
|
42
|
+
const j = await bringIn({ name: baseOf(d), folder: dirOf(d), html, files });
|
|
43
|
+
if (j.error) { failed.push(`${baseOf(d)} (${j.error})`); continue; }
|
|
44
|
+
await call(lib, '/api/deckop', { path: d, op: 'delete' }); // in the library now: out of the browser
|
|
45
|
+
moved++;
|
|
46
|
+
}
|
|
47
|
+
if (!failed.length) return location.reload();
|
|
48
|
+
say.textContent = `Moved ${moved}. Still in this browser: ${failed.join(', ')}.`; move.disabled = not.disabled = false;
|
|
49
|
+
};
|
|
50
|
+
bar.append(say, move, not); where.prepend(bar);
|
|
51
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/* A deck as one .html file, and back (File → Download → Glideshow deck, File → Open a deck file…). Packing puts the
|
|
2
|
+
deck's pictures, fonts and videos in the file as data: addresses and reveal.js itself in <style> / <script>, so it
|
|
3
|
+
opens and presents in any browser, offline. Unpacking undoes both: data: addresses become files under assets/ (named by
|
|
4
|
+
content, as the editor names what it stores) and reveal.js goes back to its address.
|
|
5
|
+
|
|
6
|
+
A file can come from anyone, and slides run in the editor on glideshow.org with the reader's session, so unpacking also
|
|
7
|
+
takes out what could run there (`safe`): every script but reveal.js and the deck's own two (put back as the editor's
|
|
8
|
+
own copies), event-handler attributes, javascript: and data:text/html addresses, srcdoc, forms, objects and <base>. */
|
|
9
|
+
|
|
10
|
+
const REVEAL = /https:\/\/cdn\.jsdelivr\.net\/npm\/reveal\.js@[^"']+/;
|
|
11
|
+
const TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp', avif: 'image/avif',
|
|
12
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', pdf: 'application/pdf',
|
|
13
|
+
woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' };
|
|
14
|
+
const EXT = Object.fromEntries(Object.entries(TYPES).map(([e, t]) => [t, e === 'jpeg' ? 'jpg' : e]));
|
|
15
|
+
|
|
16
|
+
/* Bring a deck into the library the editor talks to: deck.html first, then each other file raw. Returns its path, or
|
|
17
|
+
{error}. `files` maps paths in the deck (assets/…) to their bytes. */
|
|
18
|
+
/** @param {{ name: string, folder: string, html: string, files: Record<string, Uint8Array> }} deck */
|
|
19
|
+
export async function bringIn({ name, folder, html, files }) {
|
|
20
|
+
const post = (url, body) => fetch(url, { method: 'POST', headers: { 'X-Glideshow': '1' }, body }).then(r => r.json()).catch(() => ({ error: 'network' }));
|
|
21
|
+
const made = await post('/api/importdeck', JSON.stringify({ name, folder, html }));
|
|
22
|
+
if (made.error) return made;
|
|
23
|
+
const queue = Object.entries(files); let failed = '';
|
|
24
|
+
const send = async () => { // four at a time
|
|
25
|
+
while (queue.length && !failed) {
|
|
26
|
+
const [p, bytes] = /** @type {[string, Uint8Array]} */ (queue.shift());
|
|
27
|
+
const r = await post(`/api/importfile?deck=${encodeURIComponent(made.path)}&file=${encodeURIComponent(p)}`, bytes);
|
|
28
|
+
if (r.error) failed ||= `${p}: ${r.error}`;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, send));
|
|
32
|
+
if (failed) { await post('/api/deckop', JSON.stringify({ path: made.path, op: 'delete' })); return { error: failed }; } // all or nothing
|
|
33
|
+
return { path: made.path };
|
|
34
|
+
}
|
|
35
|
+
/** @param {number} c */
|
|
36
|
+
const isB64 = c => (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 47 && c <= 57) || c === 43 || c === 61; // A-Z a-z / 0-9 + =
|
|
37
|
+
/** @param {Uint8Array} b */
|
|
38
|
+
export function toB64(b) { let s = ''; for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000)); return btoa(s); }
|
|
39
|
+
/** @param {string} s */
|
|
40
|
+
export const fromB64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
|
41
|
+
|
|
42
|
+
/** @param {string} html @param {(path: string) => Promise<Uint8Array | null>} read a deck file by its path in the deck */
|
|
43
|
+
export async function pack(html, read) {
|
|
44
|
+
const refs = [...new Set([...html.matchAll(/(?<=["'(])assets\/[^"')\s]+/g)].map(m => m[0]))], inline = {};
|
|
45
|
+
for (const p of refs) {
|
|
46
|
+
const b = await read(p).catch(() => null);
|
|
47
|
+
if (b) inline[p] = `data:${TYPES[p.split('.').pop().toLowerCase()] || 'application/octet-stream'};base64,${toB64(b)}`;
|
|
48
|
+
}
|
|
49
|
+
html = html.replace(/(?<=["'(])assets\/[^"')\s]+/g, p => inline[p] || p);
|
|
50
|
+
const text = async u => { try { const r = await fetch(u); return r.ok ? r.text() : null; } catch { return null; } }; // offline as it can be
|
|
51
|
+
for (const m of [...html.matchAll(/<link rel="stylesheet" href="([^"]+)">/g)].filter(m => REVEAL.test(m[1]))) {
|
|
52
|
+
const css = await text(m[1]); if (css) html = html.replace(m[0], () => `<style data-glideshow-src="${m[1]}">${css}</style>`);
|
|
53
|
+
}
|
|
54
|
+
for (const m of [...html.matchAll(/<script src="([^"]+)"><\/script>/g)].filter(m => REVEAL.test(m[1]))) {
|
|
55
|
+
const js = await text(m[1]); if (js) html = html.replace(m[0], () => `<script data-glideshow-src="${m[1]}">${js.replace(/<\/script/gi, '<\\/script')}</script>`);
|
|
56
|
+
}
|
|
57
|
+
return html;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {string} html @returns {Promise<{html: string, files: Record<string, Uint8Array>}>} */
|
|
61
|
+
export async function unpack(html) {
|
|
62
|
+
html = await safe(html.replace(/<style data-glideshow-src="([^"]+)">[\s\S]*?<\/style>/g, (_, u) => `<link rel="stylesheet" href="${u}">`)
|
|
63
|
+
.replace(/<script data-glideshow-src="([^"]+)">[\s\S]*?<\/script>/g, (_, u) => `<script src="${u}"></script>`));
|
|
64
|
+
// data: addresses out into files. A scan, not a regular expression: a pattern over one picture's megabytes of base64
|
|
65
|
+
// overflows the browser's regex engine (about 5 MB and up).
|
|
66
|
+
const /** @type {Record<string, Uint8Array>} */ files = {}, /** @type {Record<string, string>} */ named = {};
|
|
67
|
+
let out = '', at = 0, i = html.indexOf('data:');
|
|
68
|
+
while (i >= 0) {
|
|
69
|
+
const mark = html.indexOf(';base64,', i), type = mark > 0 && mark - i < 200 ? html.slice(i + 5, mark) : ''; // a stray "data:" in text: not one
|
|
70
|
+
if (!/^[\w.+-]+\/[\w.+-]+$/.test(type)) { i = html.indexOf('data:', i + 5); continue; }
|
|
71
|
+
let j = mark + 8; while (j < html.length && isB64(html.charCodeAt(j))) j++;
|
|
72
|
+
if (j - mark - 8 >= 2000) { // small ones (icons in the deck's own CSS) stay inline
|
|
73
|
+
const uri = html.slice(i, j);
|
|
74
|
+
if (!named[uri]) {
|
|
75
|
+
const bytes = fromB64(html.slice(mark + 8, j));
|
|
76
|
+
const hash = [...new Uint8Array(await crypto.subtle.digest('SHA-1', bytes))].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 12);
|
|
77
|
+
named[uri] = `assets/${hash}.${EXT[type] || 'bin'}`; files[named[uri]] = bytes;
|
|
78
|
+
}
|
|
79
|
+
out += html.slice(at, i) + named[uri]; at = j;
|
|
80
|
+
}
|
|
81
|
+
i = html.indexOf('data:', j);
|
|
82
|
+
}
|
|
83
|
+
html = out + html.slice(at);
|
|
84
|
+
return { html, files };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* A deck from elsewhere, with nothing left in it that could run as the person opening it. */
|
|
88
|
+
const URLISH = new Set(['href', 'src', 'xlink:href', 'action', 'data', 'poster', 'background', 'from', 'to', 'values', 'dynsrc', 'lowsrc']);
|
|
89
|
+
const GOES = new Set(['href', 'xlink:href', 'action', 'data']); // opened as a page (an SVG there runs its scripts), not shown as a picture
|
|
90
|
+
/** @param {string} html */
|
|
91
|
+
export async function safe(html) {
|
|
92
|
+
const d = new DOMParser().parseFromString(html, 'text/html');
|
|
93
|
+
const own = async f => { try { const r = await fetch('/editor/deck/' + f); return r.ok ? (await r.text()).trim() : ''; } catch { return ''; } };
|
|
94
|
+
for (const el of [...d.querySelectorAll('script')]) {
|
|
95
|
+
if (el.id === 'deck-shapes' || el.id === 'deck-present') { // the deck's own: the editor's copies, nothing else
|
|
96
|
+
const fresh = d.createElement('script'); fresh.id = el.id; fresh.textContent = '\n' + await own(el.id === 'deck-shapes' ? 'shapes.js' : 'present.js') + '\n'; el.replaceWith(fresh);
|
|
97
|
+
} else if (!(REVEAL.test(el.getAttribute('src') || '') && /^https:\/\/cdn\.jsdelivr\.net\/npm\/reveal\.js@[\w.]+\/dist\/reveal\.js$/.test(el.getAttribute('src') || '') && !el.textContent.trim())) el.remove();
|
|
98
|
+
}
|
|
99
|
+
d.querySelectorAll('object, embed, applet, form, base, frame, frameset, portal, meta[http-equiv], link[rel="import"]').forEach(e => e.remove());
|
|
100
|
+
d.querySelectorAll('iframe').forEach(f => { if (!/^https?:\/\//i.test((f.getAttribute('src') || '').trim())) f.remove(); });
|
|
101
|
+
d.querySelectorAll('animate, set, animateMotion, animateTransform').forEach(e => { if (/href/i.test(e.getAttribute('attributeName') || '')) e.remove(); });
|
|
102
|
+
for (const el of d.querySelectorAll('*')) for (const a of [...el.attributes]) {
|
|
103
|
+
const n = a.name.toLowerCase(), v = a.value.replace(/[\s\u0000-\u001f]+/g, '').toLowerCase();
|
|
104
|
+
if (n.startsWith('on') || n === 'srcdoc' || n === 'formaction' || (URLISH.has(n) && /^(javascript|vbscript|data:text\/html|data:application\/xhtml)/.test(v))
|
|
105
|
+
|| (GOES.has(n) && /^data:image\/svg/.test(v))) el.removeAttribute(a.name);
|
|
106
|
+
}
|
|
107
|
+
return '<!doctype html>\n' + d.documentElement.outerHTML + '\n';
|
|
108
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* A library kept in this browser: the store behind local/sw.js, with the interface of cloud/store-github.js and
|
|
2
|
+
cloud/store-drive.js. Each commit is a whole tree of paths -> file ids, kept in IndexedDB with the files themselves
|
|
3
|
+
(browser-db.js), so version history and restore work as they do online. Nothing leaves the browser: shared links need
|
|
4
|
+
a library in Drive or GitHub. */
|
|
5
|
+
|
|
6
|
+
import { get, put, del, keys } from './browser-db.js';
|
|
7
|
+
import { dec } from './library.js';
|
|
8
|
+
|
|
9
|
+
const id = () => crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
|
10
|
+
const KEEP = 100, EVERY = 25; // versions kept; how often (in commits) the older ones and their files are cleared out
|
|
11
|
+
export const SIGN_IN_TO_SHARE = 'Sign in to share decks: shared decks live in your Google Drive or GitHub.';
|
|
12
|
+
|
|
13
|
+
export class BrowserStore {
|
|
14
|
+
/** @param {any} lib */
|
|
15
|
+
constructor(lib) { this.lib = lib; }
|
|
16
|
+
|
|
17
|
+
/* The newest commit's tree (made empty the first time), or null when it is the one the library already has. */
|
|
18
|
+
async head(prev) {
|
|
19
|
+
let at = await get('commits', 'head');
|
|
20
|
+
if (!at) { at = 'root'; await put('commits', [['root', { id: 'root', parent: null, time: Date.now(), message: 'library: start', tree: {} }], ['head', 'root']]); }
|
|
21
|
+
if (prev?.id === at) return null;
|
|
22
|
+
const c = await get('commits', at);
|
|
23
|
+
return { id: at, sig: at, tree: c.tree };
|
|
24
|
+
}
|
|
25
|
+
async read(e) {
|
|
26
|
+
const b = await get('blobs', e.bv || e.v || e.ref);
|
|
27
|
+
if (!b) throw new Error('A file of this library is missing from the browser.');
|
|
28
|
+
return new Uint8Array(b);
|
|
29
|
+
}
|
|
30
|
+
async write(base, changes, message) {
|
|
31
|
+
const tree = { ...base.tree }, now = Date.now(), /** @type {[string, Uint8Array][]} */ blobs = [];
|
|
32
|
+
for (const c of changes) {
|
|
33
|
+
if (c.del) { delete tree[c.path]; continue; }
|
|
34
|
+
if (c.ref) { tree[c.path] = { ref: c.ref, v: c.bv || c.ref, t: now }; continue; }
|
|
35
|
+
const f = id(); blobs.push([f, c.bytes]); tree[c.path] = { ref: f, v: f, t: now };
|
|
36
|
+
}
|
|
37
|
+
const at = id(), n = ((await get('commits', base.id))?.n || 0) + 1;
|
|
38
|
+
if (blobs.length) await put('blobs', blobs);
|
|
39
|
+
await put('commits', [[at, { id: at, parent: base.id, n, time: now, message, tree }], ['head', at]]);
|
|
40
|
+
if (n % EVERY === 0) await this.prune(at).catch(e => console.warn('glideshow: clearing old versions', e));
|
|
41
|
+
const versions = {}; for (const c of changes) if (!c.del && tree[c.path]) versions[c.path] = tree[c.path].v;
|
|
42
|
+
return { base: { id: at, sig: at, tree }, versions };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The newest KEEP versions stay; older ones go, and every file none of the kept ones uses. */
|
|
46
|
+
async prune(head) {
|
|
47
|
+
const kept = [];
|
|
48
|
+
for (let c = await get('commits', head); c && kept.length < KEEP; c = c.parent && await get('commits', c.parent)) kept.push(c);
|
|
49
|
+
const keep = new Set(kept.map(c => c.id)), used = new Set(kept.flatMap(c => Object.values(c.tree).map(e => e.v)));
|
|
50
|
+
await del('commits', (await keys('commits')).filter(k => k !== 'head' && !keep.has(k)));
|
|
51
|
+
await del('blobs', (await keys('blobs')).filter(k => !used.has(k)));
|
|
52
|
+
}
|
|
53
|
+
async lastChange(path) { return (this.lib.base.tree[path]?.t || 0) / 1000; }
|
|
54
|
+
/* The commits that changed a file, newest first. */
|
|
55
|
+
async history(path, n = 100) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (let at = await get('commits', 'head'), c = at && await get('commits', at); c && out.length < n; c = c.parent && await get('commits', c.parent)) {
|
|
58
|
+
const before = c.parent ? (await get('commits', c.parent))?.tree[path]?.v : undefined;
|
|
59
|
+
if (c.tree[path] && c.tree[path].v !== before) out.push({ rev: c.id, time: c.time / 1000, author: 'You', message: c.message, name: '', url: '' });
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
async readAt(rev, path) {
|
|
64
|
+
const e = (await get('commits', rev))?.tree[path];
|
|
65
|
+
return e ? dec(await this.read(e)) : '';
|
|
66
|
+
}
|
|
67
|
+
async filesAt(rev) {
|
|
68
|
+
const c = await get('commits', rev);
|
|
69
|
+
return c ? Object.fromEntries(Object.entries(c.tree).map(([p, e]) => [p, { ref: e.ref, v: e.v }])) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async pin() { throw new Error(SIGN_IN_TO_SHARE); }
|
|
73
|
+
async pinned() { return {}; }
|
|
74
|
+
async unpin() {}
|
|
75
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* A library kept in a folder on this computer (glideshow.org's "Use a folder on this computer", Chrome and Edge): the
|
|
2
|
+
browser library (store-browser.js) whose files are also the files of a folder the person picked, so the decks are real
|
|
3
|
+
folders they can see, back up, put in Dropbox or hand to an agent. Version history stays in the browser, as it does for
|
|
4
|
+
a library kept only there.
|
|
5
|
+
|
|
6
|
+
Both ways: every save is written into the folder, and on each look at the library (`head`, at most every few seconds)
|
|
7
|
+
files changed, added or removed in the folder from outside (an agent, a text editor) come in as one version,
|
|
8
|
+
"outside the editor". What was last written or read is remembered per file (its version and the file's modified
|
|
9
|
+
time), so a file that changed on neither side is left alone. Paths starting with a dot (drafts, the library's own
|
|
10
|
+
state) stay in the browser only. If the browser has dropped its permission for the folder (a new session), the
|
|
11
|
+
library keeps working in the browser and `needsPermission` says so; once the page has asked again, the next look
|
|
12
|
+
writes out what the folder missed. */
|
|
13
|
+
|
|
14
|
+
import { get, put } from './browser-db.js';
|
|
15
|
+
import { BrowserStore } from './store-browser.js';
|
|
16
|
+
import { dec } from './library.js';
|
|
17
|
+
|
|
18
|
+
const HANDLE = 'folder:handle', MAP = 'folder:map', EVERY = 3000;
|
|
19
|
+
/** The folder this browser keeps its library in, or null. @returns {Promise<any>} */
|
|
20
|
+
export const getFolder = () => get('kv', HANDLE).catch(() => null);
|
|
21
|
+
/** @param {any} h */
|
|
22
|
+
export const setFolder = h => put('kv', [[HANDLE, h]]);
|
|
23
|
+
/** @param {any} dir @param {boolean} [ask] whether to ask (a page, with the person's click); a worker can only look */
|
|
24
|
+
export async function folderAllowed(dir, ask = false) {
|
|
25
|
+
const o = { mode: 'readwrite' };
|
|
26
|
+
if (await dir.queryPermission(o) === 'granted') return true;
|
|
27
|
+
return ask ? await dir.requestPermission(o) === 'granted' : false;
|
|
28
|
+
}
|
|
29
|
+
const hidden = (/** @type {string} */ p) => p.split('/').some(s => s.startsWith('.'));
|
|
30
|
+
|
|
31
|
+
export class FolderStore extends BrowserStore {
|
|
32
|
+
/** @param {any} lib @param {any} dir a FileSystemDirectoryHandle */
|
|
33
|
+
constructor(lib, dir) { super(lib); this.dir = dir; this.last = 0; this.needsPermission = false; /** @type {Promise<any>} */ this.queue = Promise.resolve(); }
|
|
34
|
+
/* Saves and folder syncs take turns, so neither moves the library's newest version from under the other. */
|
|
35
|
+
/** @template T @param {() => Promise<T>} fn @returns {Promise<T>} */
|
|
36
|
+
turn(fn) { const p = this.queue.then(fn); this.queue = p.then(() => {}, () => {}); return p; }
|
|
37
|
+
|
|
38
|
+
/** @param {any} prev @param {boolean} [force] */
|
|
39
|
+
async head(prev, force) {
|
|
40
|
+
if (force || Date.now() - this.last > EVERY) await this.turn(() => this.sync()).finally(() => { this.last = Date.now(); });
|
|
41
|
+
return super.head(prev);
|
|
42
|
+
}
|
|
43
|
+
/** @param {any} base @param {any[]} changes @param {string} message */
|
|
44
|
+
write(base, changes, message) { return this.turn(() => this.save(base, changes, message)); }
|
|
45
|
+
/** @param {any} base @param {any[]} changes @param {string} message */
|
|
46
|
+
async save(base, changes, message) {
|
|
47
|
+
const now = await super.head(null);
|
|
48
|
+
if (now && now.id !== base.id) base = now; // a change from the folder came in since the library last looked: build on it, not over it
|
|
49
|
+
const out = await super.write(base, changes, message);
|
|
50
|
+
if (await this.allowed()) {
|
|
51
|
+
const map = await this.map();
|
|
52
|
+
for (const c of changes) if (!hidden(c.path)) await this.out(c.path, out.base.tree[c.path], map).catch(e => console.warn('glideshow: writing to the folder', c.path, e));
|
|
53
|
+
await put('kv', [[MAP, map]]);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async allowed() { this.needsPermission = !(await folderAllowed(this.dir).catch(() => false)); return !this.needsPermission; }
|
|
59
|
+
/** @returns {Promise<Record<string, { v: string, m: number }>>} */
|
|
60
|
+
async map() { return (await get('kv', MAP)) || {}; }
|
|
61
|
+
|
|
62
|
+
/* Every file of the folder, by path (dot files and folders left out). */
|
|
63
|
+
async files(dir = this.dir, at = '', /** @type {Map<string, any>} */ out = new Map()) {
|
|
64
|
+
for await (const [name, h] of dir.entries()) {
|
|
65
|
+
if (name.startsWith('.')) continue;
|
|
66
|
+
if (h.kind === 'directory') await this.files(h, at + name + '/', out); else out.set(at + name, h);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** @param {string} path @param {boolean} [make] */
|
|
71
|
+
async parent(path, make = false) {
|
|
72
|
+
let d = this.dir; const parts = path.split('/'); parts.pop();
|
|
73
|
+
for (const p of parts) d = await d.getDirectoryHandle(p, { create: make });
|
|
74
|
+
return d;
|
|
75
|
+
}
|
|
76
|
+
/* The library's version of `path` into the folder (or its removal), remembered in `map`. */
|
|
77
|
+
async out(path, e, map) {
|
|
78
|
+
const name = path.split('/').pop();
|
|
79
|
+
if (!e) { await (await this.parent(path)).removeEntry(name).catch(() => {}); delete map[path]; return; }
|
|
80
|
+
const fh = await (await this.parent(path, true)).getFileHandle(name, { create: true }), w = await fh.createWritable();
|
|
81
|
+
await w.write(await this.read(e)); await w.close();
|
|
82
|
+
map[path] = { v: e.v, m: (await fh.getFile()).lastModified };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Folder and library made the same: outside changes come in as one version, then what the folder lacks goes out. */
|
|
86
|
+
async sync() {
|
|
87
|
+
if (!(await this.allowed())) return;
|
|
88
|
+
const head = await super.head(null), tree = head?.tree || {}, map = await this.map(), files = await this.files();
|
|
89
|
+
/** @type {any[]} */ const changes = [], came = new Set();
|
|
90
|
+
for (const [path, fh] of files) {
|
|
91
|
+
const f = await fh.getFile(), known = map[path];
|
|
92
|
+
if (known && known.m === f.lastModified && tree[path]?.v === known.v) continue; // unchanged on both sides
|
|
93
|
+
if (known && known.m === f.lastModified) continue; // only the library changed it: goes out below
|
|
94
|
+
const bytes = new Uint8Array(await f.arrayBuffer());
|
|
95
|
+
if (tree[path] && dec(await this.read(tree[path]).catch(() => new Uint8Array())) === dec(bytes)) { map[path] = { v: tree[path].v, m: f.lastModified }; continue; }
|
|
96
|
+
changes.push({ path, bytes }); came.add(path); map[path] = { v: '', m: f.lastModified };
|
|
97
|
+
}
|
|
98
|
+
for (const path of Object.keys(map)) if (!files.has(path) && tree[path]) { changes.push({ path, del: true }); came.add(path); delete map[path]; } // removed outside
|
|
99
|
+
let now = head;
|
|
100
|
+
if (changes.length) {
|
|
101
|
+
const names = [...new Set([...came].map(p => p.split('/').slice(0, -1).join('/') || p))].slice(0, 3).join(', ');
|
|
102
|
+
now = (await super.write(head, changes, `outside the editor: ${names}`)).base;
|
|
103
|
+
for (const p of came) if (map[p]) map[p].v = now.tree[p]?.v || '';
|
|
104
|
+
}
|
|
105
|
+
for (const [path, e] of Object.entries(now?.tree || {})) // what the folder lacks or has older: written out
|
|
106
|
+
if (!hidden(path) && !came.has(path) && map[path]?.v !== e.v) await this.out(path, e, map).catch(err => console.warn('glideshow: writing to the folder', path, err));
|
|
107
|
+
await put('kv', [[MAP, map]]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Glideshow without an account: the editor at glideshow.org with its library kept in this browser. glideshow.org/start
|
|
2
|
+
installs this service worker (scope /, which the Worker allows it); from then on it answers the editor's own requests here, as the local server
|
|
3
|
+
(glideshow/server.py) and a signed-in library (cloud/library.js) do: the pages, /api/…, the open deck's files under
|
|
4
|
+
/deck/ and the home screen's thumbnails under /library/. The library is lib/library.js itself, over IndexedDB
|
|
5
|
+
(lib/store-browser.js). Everything else (sign-in, shared links, the editor's own files) goes to glideshow.org as
|
|
6
|
+
usual. Signing in (/login) unregisters it; the decks stay in the browser for the next visit to /start. */
|
|
7
|
+
|
|
8
|
+
import { LibraryCore, json } from '../lib/library.js';
|
|
9
|
+
import { BrowserStore, SIGN_IN_TO_SHARE } from '../lib/store-browser.js';
|
|
10
|
+
import { FolderStore, getFolder } from '../lib/store-folder.js';
|
|
11
|
+
import { storage } from '../lib/browser-db.js';
|
|
12
|
+
|
|
13
|
+
const sw = /** @type {any} */ (self);
|
|
14
|
+
const PASS = /^\/(p|share|editor|auth)\/|^\/(login|logout|setup|admin|report|privacy|terms|start|robots\.txt|favicon\.svg|favicon\.ico|apple-touch-icon\.png)$/;
|
|
15
|
+
const PAGES = { '/': '/editor/home.html', '/home': '/editor/home.html', '/edit': '/editor/index.html', '/index.html': '/editor/index.html' };
|
|
16
|
+
|
|
17
|
+
class BrowserLibrary extends LibraryCore {
|
|
18
|
+
/* The deck this browser has open: kept in the library's storage (a service worker's responses can't set cookies). */
|
|
19
|
+
current(req) { const v = this.view(); return this.cur && v[this.cur + '/deck.html'] ? this.cur : super.current(req); }
|
|
20
|
+
open(deck, res) { this.cur = deck; this.ctx.storage.put('cur', deck); return super.open(deck, res); }
|
|
21
|
+
schedule() { clearTimeout(this.timer); this.timer = setTimeout(() => this.flush().catch(e => console.error('glideshow: commit', e)), 1500); } // commits are local and cheap
|
|
22
|
+
}
|
|
23
|
+
let folder = null; // the folder picked on glideshow.org ("Use a folder on this computer"), when there is one
|
|
24
|
+
const deps = {
|
|
25
|
+
store: lib => folder ? new FolderStore(lib, folder) : new BrowserStore(lib),
|
|
26
|
+
share: {
|
|
27
|
+
publishAPI: (lib, method) => json(method === 'GET' ? { url: '', access: 'link', people: [], expires: '', inLibrary: false, online: true, error: SIGN_IN_TO_SHARE } : { error: SIGN_IN_TO_SHARE }),
|
|
28
|
+
commentsAPI: () => json({ comments: [] }),
|
|
29
|
+
shareRoutes: () => json({ error: 'not found' }, 404),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const env = { ASSETS: { fetch: req => fetch('/editor' + new URL(req.url).pathname) } }; // the deck template, as the Worker's static files
|
|
33
|
+
|
|
34
|
+
const init = async () => {
|
|
35
|
+
folder = await getFolder();
|
|
36
|
+
const lib = new BrowserLibrary({ storage, blockConcurrencyWhile: fn => fn() }, env, deps);
|
|
37
|
+
await lib.loaded;
|
|
38
|
+
if (lib.cfg.kind !== 'browser') { lib.cfg = { kind: 'browser', user: { n: 'You' } }; await storage.put('cfg', lib.cfg); }
|
|
39
|
+
lib.cur = await storage.get('cur');
|
|
40
|
+
if (Object.keys(lib.pending).some(p => !p.startsWith('.drafts/'))) lib.schedule(); // saved but not committed when the browser last stopped this worker
|
|
41
|
+
return lib;
|
|
42
|
+
};
|
|
43
|
+
let ready = init();
|
|
44
|
+
/* A page picked a folder, or gave its permission back: the library starts again over it. */
|
|
45
|
+
sw.addEventListener('message', e => { if (e.data?.type === 'library-changed') { ready = init(); e.waitUntil?.(ready); } });
|
|
46
|
+
let queue = Promise.resolve(); // one request at a time, as a Durable Object takes them
|
|
47
|
+
const answer = req => { const p = queue.then(async () => (await ready).fetch(req)); queue = p.catch(() => {}); return p; };
|
|
48
|
+
|
|
49
|
+
/* The editor's pages, told they are online in this browser (the editor hides what only the Mac or an account can do). */
|
|
50
|
+
async function page(file) {
|
|
51
|
+
const html = await (await fetch(file, { cache: 'no-cache' })).text();
|
|
52
|
+
const lib = await ready.catch(() => null), store = /** @type {any} */ (lib)?.store;
|
|
53
|
+
const info = { local: true, user: '', name: '', repo: folder ? folder.name : 'This browser',
|
|
54
|
+
folder: folder ? { name: folder.name, ok: store instanceof FolderStore ? await store.allowed() : false } : null };
|
|
55
|
+
return new Response(html.replace('<head>', `<head>\n<script>window.GLIDESHOW_CLOUD = ${JSON.stringify(info)}</script>`), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
sw.addEventListener('install', () => sw.skipWaiting());
|
|
59
|
+
sw.addEventListener('activate', e => e.waitUntil(sw.clients.claim()));
|
|
60
|
+
sw.addEventListener('fetch', e => {
|
|
61
|
+
const url = new URL(e.request.url);
|
|
62
|
+
if (url.origin !== location.origin || PASS.test(url.pathname)) return; // glideshow.org's, as usual
|
|
63
|
+
if (PAGES[url.pathname] && e.request.mode === 'navigate') return e.respondWith(page(PAGES[url.pathname]));
|
|
64
|
+
if (url.pathname === '/api/fonts') return e.respondWith(fetch('/editor/data/fonts.json'));
|
|
65
|
+
if (/^\/(api|deck|library)\//.test(url.pathname)) return e.respondWith(answer(e.request).catch(err => json({ error: String(err?.message || err) }, 500)));
|
|
66
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Type-checks browser mode's service worker and the library modules it imports: `npx -p typescript@5.6.3 tsc -p editor/local`.
|
|
3
|
+
"compilerOptions": { "allowJs": true, "checkJs": true, "noEmit": true, "target": "es2022", "module": "es2022", "moduleResolution": "bundler", "lib": ["es2023", "webworker"], "strict": false, "skipLibCheck": true },
|
|
4
|
+
"include": ["sw.js"]
|
|
5
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
|
2
|
+
<title>Glideshow</title>
|
|
3
|
+
<g transform="rotate(-10 128 128)">
|
|
4
|
+
<path d="M36 100 Q128 -4 220 100" fill="none" stroke="#fff" stroke-width="24" stroke-linecap="round"/>
|
|
5
|
+
<g stroke="#fff" stroke-width="10" stroke-linecap="round"><path d="M44 94 L74 142"/><path d="M212 94 L182 142"/></g>
|
|
6
|
+
<rect x="60" y="136" width="136" height="94" rx="28" fill="#fff"/>
|
|
7
|
+
<g fill="#14204A"><circle cx="120" cy="176" r="10.5"/><circle cx="154" cy="176" r="10.5"/></g>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|