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
glideshow/server.py ADDED
@@ -0,0 +1,549 @@
1
+ """Local server for the slide editor.
2
+
3
+ glideshow serve path/to/deck [--port N] [--no-open] (or: python -m glideshow.server …)
4
+ python -m glideshow.server path/to/deck --build OUT_DIR # self-contained copy for hosting
5
+
6
+ Serves the editor at http://localhost:PORT, reads and writes DECK/deck.html, and stores
7
+ pasted images in DECK/assets/. The deck folder is self-contained: open DECK/deck.html in
8
+ any browser to present it, or send/host the folder to share it.
9
+ """
10
+
11
+ import argparse, atexit, hashlib, json, os, re, shutil, sys, threading, time, urllib.request, webbrowser
12
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
13
+ from pathlib import Path
14
+ from urllib.parse import parse_qs, unquote, urlparse
15
+
16
+ HERE = Path(__file__).resolve().parent
17
+
18
+ from . import comments, publish
19
+ from .build import MAX_FETCH, build
20
+ from .decks import deck_id, EDITOR, HOME, add_image, all_decks, deck_title, forget, in_library, is_draft, library_folders, name_draft, new_deck, new_draft, recent_decks, remember, set_title
21
+ from .fonts import FONT_EXTS, font_catalogue, local_fonts
22
+ from .history import GIT, commit_library, commit_now, commit_outside, commit_outside_library, git, history, init_repo, schedule_commit, sync
23
+ from .osutil import reveal, trash
24
+ from .service import DEFAULT_PORT, code_version, forget_me, live, post, record, same_install
25
+
26
+
27
+
28
+
29
+ def pptx_cmd(script: str) -> list:
30
+ """How to run a PowerPoint script: here if python-pptx is installed (`pip install glideshow[pptx]`), else through uv,
31
+ which fetches it from the script's inline metadata."""
32
+ import importlib.util, shutil as _sh
33
+ if importlib.util.find_spec("pptx"): return [sys.executable, str(HERE / script)]
34
+ if _sh.which("uv"): return ["uv", "run", "--quiet", str(HERE / script)]
35
+ return [sys.executable, "-c", "raise SystemExit('PowerPoint needs python-pptx: pip install python-pptx')"]
36
+
37
+
38
+ class Handler(SimpleHTTPRequestHandler):
39
+ deck: Path
40
+ selection: dict = {} # what the editor shows and has selected (POST /api/selection), for `glideshow selection`
41
+ written: str | None = None # deck.html as this server last wrote it: any other content on disk came from outside
42
+ writing = threading.Lock() # held while this server writes deck.html, so the watcher never reads it half-saved
43
+
44
+ def log_message(self, *args):
45
+ pass
46
+
47
+ @property
48
+ def deck_file(self) -> Path:
49
+ return self.deck / "deck.html"
50
+
51
+ def mtime(self) -> str:
52
+ return str(self.deck_file.stat().st_mtime_ns)
53
+
54
+ def send_json(self, obj, code=200):
55
+ body = json.dumps(obj).encode()
56
+ self.send_response(code)
57
+ self.send_header("Content-Type", "application/json")
58
+ self.send_header("Content-Length", str(len(body)))
59
+ self.end_headers()
60
+ self.wfile.write(body)
61
+
62
+ def send_file(self, base: Path, rel: str):
63
+ path = (base / rel).resolve()
64
+ if not path.is_relative_to(base.resolve()) or not path.is_file():
65
+ return self.send_error(404)
66
+ data = path.read_bytes()
67
+ self.send_response(200)
68
+ self.send_header("Content-Type", self.guess_type(str(path)))
69
+ self.send_header("Content-Length", str(len(data)))
70
+ self.send_header("Cache-Control", "no-store")
71
+ if path.suffix.lower() in (".html", ".htm") and "assets" in Path(rel).parts: # a live box's page: sandboxed even opened on its own
72
+ self.send_header("Content-Security-Policy", "sandbox allow-scripts allow-pointer-lock")
73
+ self.end_headers()
74
+ self.wfile.write(data)
75
+
76
+ def allowed(self, post=False) -> bool:
77
+ """Only this machine's editor may talk to the server. The Host must be localhost (stops DNS rebinding), and a
78
+ POST must carry X-Glideshow: another website can't add that header without a CORS preflight, which is never
79
+ granted, so it can't save, delete or fetch through the server from a page you happen to visit."""
80
+ host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]")
81
+ ok = host in ("localhost", "127.0.0.1", "::1") and (not post or self.headers.get("X-Glideshow") == "1")
82
+ if not ok: self.send_error(403, "not from this machine's editor")
83
+ return ok
84
+
85
+ def do_GET(self):
86
+ if not self.allowed(): return
87
+ path = unquote(urlparse(self.path).path)
88
+ if path in ("/", "/home"): # the home screen: every deck as a card
89
+ return self.send_file(EDITOR, "home.html")
90
+ if path in ("/edit", "/index.html"):
91
+ return self.send_file(EDITOR, "index.html")
92
+ if path == "/api/history": # File → Version history: commits touching deck.html (?slide=N: those that changed that slide)
93
+ q = parse_qs(urlparse(self.path).query)
94
+ return self.send_json(history(self.deck, int(q["slide"][0]) if q.get("slide") else None))
95
+ if path == "/api/version": # deck.html as of one commit
96
+ rev = parse_qs(urlparse(self.path).query).get("rev", [""])[0]
97
+ if not re.fullmatch(r"[0-9a-f]{7,40}", rev):
98
+ return self.send_error(400)
99
+ return self.send_json({"html": git(self.deck, "show", f"{rev}:./deck.html").stdout})
100
+ if path == "/api/selection": # `glideshow selection`: the slide on show and the selected boxes, for an agent working beside you
101
+ return self.send_json({**Handler.selection, "deck": str(self.deck), "file": str(self.deck_file)} if Handler.selection.get("deck") == str(self.deck) else {"deck": str(self.deck), "file": str(self.deck_file), "slide": None, "boxes": []})
102
+ if path == "/api/ping": # is this our server, which code, which deck (glideshow / the app ask before reusing it)
103
+ return self.send_json({"app": "glideshow", "version": code_version(), "pid": os.getpid(), "deck": str(self.deck), "install": str(Path(__file__).resolve().parent)})
104
+ if path == "/api/decks":
105
+ return self.send_json(all_decks(self.deck))
106
+ if path == "/api/publish": # Share: the open deck's link ('' when not shared), who can open it, until when
107
+ return self.send_json(publish.status(self.deck))
108
+ if path == "/api/comments": # comments.json beside deck.html (left on a shared deck at glideshow.org, or here)
109
+ return self.send_json(comments.read(self.deck))
110
+ if path == "/api/library": # where the library is, for the home screen's welcome (~/decks)
111
+ return self.send_json({"home": str(HOME).replace(str(Path.home()), "~", 1)})
112
+ if path == "/api/folders": # the library's folders, relative to $GLIDESHOW_HOME
113
+ return self.send_json(library_folders())
114
+ if path.startswith("/library/"): # a file inside any known deck, by its id (home-screen thumbnails)
115
+ rel = Path(path.removeprefix("/library/"))
116
+ deck = next((Path(d["path"]) for d in all_decks(self.deck) if d["id"] == rel.parts[0]), None) if rel.parts else None
117
+ return self.send_file(deck, str(Path(*rel.parts[1:]))) if deck and len(rel.parts) > 1 else self.send_error(404)
118
+ if path.startswith("/editor/"):
119
+ return self.send_file(EDITOR, path.removeprefix("/editor/"))
120
+ if path.startswith("/deck/"):
121
+ return self.send_file(self.deck, path.removeprefix("/deck/"))
122
+ if path == "/api/deck":
123
+ return self.send_json({"html": self.deck_file.read_text(), "mtime": self.mtime(), "draft": is_draft(self.deck), "id": deck_id(self.deck)}) # id: its files under /library/<id>/ (slides copied to another deck)
124
+
125
+ if path == "/api/events":
126
+ return self.events()
127
+ if path == "/api/fonts":
128
+ return self.send_json(font_catalogue())
129
+ if path == "/api/recent":
130
+ return self.send_json([{"path": p, "title": deck_title(Path(p)), "current": Path(p) == self.deck} for p in recent_decks()])
131
+ if path == "/api/pptx": # File → Download → PowerPoint: pptx_export.py runs under uv with python-pptx
132
+ import subprocess, tempfile
133
+ out = Path(tempfile.mkdtemp()) / f"{self.deck.name}.pptx"
134
+ r = subprocess.run([*pptx_cmd("pptx_export.py"), str(self.deck_file), str(out)], capture_output=True, text=True, timeout=300)
135
+ if r.returncode or not out.exists():
136
+ return self.send_json({"error": (r.stderr or "export failed").strip().splitlines()[-1]}, 500)
137
+ data = out.read_bytes()
138
+ self.send_response(200); self.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.presentationml.presentation")
139
+ self.send_header("Content-Disposition", f'attachment; filename="{out.name}"'); self.send_header("Content-Length", str(len(data))); self.end_headers()
140
+ return self.wfile.write(data)
141
+ if path == "/api/zip": # the deck folder as a .zip (File → Download → Web page)
142
+ import io, zipfile
143
+ buf = io.BytesIO()
144
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
145
+ for f in sorted(self.deck.rglob("*")):
146
+ if f.is_file() and not any(part.startswith(".") for part in f.relative_to(self.deck).parts):
147
+ z.write(f, Path(self.deck.name) / f.relative_to(self.deck))
148
+ data = buf.getvalue()
149
+ self.send_response(200); self.send_header("Content-Type", "application/zip")
150
+ self.send_header("Content-Disposition", f'attachment; filename="{self.deck.name}.zip"'); self.send_header("Content-Length", str(len(data))); self.end_headers()
151
+ return self.wfile.write(data)
152
+ if path == "/api/localfonts":
153
+ return self.send_json([{"name": f.stem.replace("-", " ").replace("_", " "), "path": str(f)} for f in local_fonts()])
154
+ if path == "/api/localfont": # the file itself, for the font menu's preview
155
+ f = Path(parse_qs(urlparse(self.path).query).get("path", [""])[0])
156
+ if f not in local_fonts():
157
+ return self.send_error(404)
158
+ data = f.read_bytes()
159
+ self.send_response(200); self.send_header("Content-Type", "font/" + f.suffix[1:].lower()); self.send_header("Content-Length", str(len(data))); self.end_headers()
160
+ return self.wfile.write(data)
161
+ self.send_error(404)
162
+
163
+ def events(self):
164
+ """Server-sent events: pushes deck.html's mtime whenever it changes on disk."""
165
+ self.send_response(200)
166
+ self.send_header("Content-Type", "text/event-stream")
167
+ self.send_header("Cache-Control", "no-store")
168
+ self.end_headers()
169
+ last = None
170
+ try:
171
+ while True:
172
+ m = self.mtime()
173
+ if m != last:
174
+ self.wfile.write(f"data: {m}\n\n".encode())
175
+ self.wfile.flush()
176
+ last = m
177
+ time.sleep(0.4)
178
+ except (BrokenPipeError, ConnectionResetError, FileNotFoundError):
179
+ pass
180
+
181
+ def store(self, data, ext):
182
+ """Save bytes in assets/ under a content hash; returns the deck-relative path."""
183
+ if not re.fullmatch(r"[a-z0-9]{1,5}", ext):
184
+ ext = "png"
185
+ name = f"{hashlib.sha1(data).hexdigest()[:12]}.{ext}"
186
+ assets = self.deck / "assets"
187
+ assets.mkdir(exist_ok=True)
188
+ if not (assets / name).exists():
189
+ (assets / name).write_bytes(data)
190
+ return f"assets/{name}"
191
+
192
+ def do_POST(self):
193
+ if not self.allowed(post=True): return
194
+ url = urlparse(self.path)
195
+
196
+ body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
197
+ if url.path == "/api/save":
198
+ req = json.loads(body)
199
+ tmp = self.deck_file.with_suffix(".tmp")
200
+ with Handler.writing: # the version check and the write as one: two editors saving at the same moment can't both pass it
201
+ if req.get("mtime") != self.mtime():
202
+ return self.send_json({"error": "conflict", "mtime": self.mtime()}, 409)
203
+ Handler.written = req["html"]
204
+ tmp.write_text(req["html"])
205
+ tmp.replace(self.deck_file)
206
+ if is_draft(self.deck): # untitled: kept out of the library until it is named, then moved in
207
+ if not (dst := name_draft(self.deck)): return self.send_json({"mtime": self.mtime(), "draft": True})
208
+ open_deck(dst); schedule_commit(dst)
209
+ return self.send_json({"mtime": self.mtime(), "moved": str(dst)})
210
+
211
+ schedule_commit(self.deck)
212
+ return self.send_json({"mtime": self.mtime()})
213
+ if url.path == "/api/selection":
214
+ Handler.selection = {**json.loads(body or b"{}"), "deck": str(self.deck), "time": time.time()}
215
+ return self.send_json({"ok": True})
216
+ if url.path == "/api/discard": # leave an untitled draft without keeping it: back to the most recent deck
217
+ if is_draft(self.deck):
218
+ gone = self.deck; open_deck(default_deck()); shutil.rmtree(gone, ignore_errors=True)
219
+ return self.send_json({"ok": True})
220
+ if url.path == "/api/importpptx": # File → Import slides / a dropped .pptx: pptx_import.py (python-pptx via uv)
221
+ import subprocess, tempfile
222
+ src = Path(tempfile.mkdtemp()) / "in.pptx"
223
+ src.write_bytes(body)
224
+ r = subprocess.run([*pptx_cmd("pptx_import.py"), str(src), str(self.deck / "assets")], capture_output=True, text=True, timeout=300)
225
+ if r.returncode:
226
+ return self.send_json({"error": (r.stderr or "import failed").strip().splitlines()[-1]})
227
+ return self.send_json(json.loads(r.stdout))
228
+ if url.path == "/api/importgslides": # File → Import from Google Slides: the Slides API (signed in once with `glideshow import-google --login`)
229
+ from . import gslides
230
+ req = json.loads(body or b"{}")
231
+ try:
232
+ if req.get("file"): # a saved presentations.get response (tests; `glideshow import-google` keeps one under source/)
233
+ pres = json.loads(Path(req["file"]).expanduser().read_text())
234
+ else:
235
+ pres = gslides.fetch(gslides.presentation_id(req.get("url", "")))
236
+ conv = gslides.Converter(pres, self.deck)
237
+ sections = conv.convert()
238
+ conv.fonts = {f for f in conv.fonts if f in gslides.Converter._google_fonts()}
239
+ return self.send_json({"slides": sections, "title": pres.get("title", ""), "fonts": sorted(conv.fonts), "log": conv.log})
240
+ except SystemExit as e: # not signed in / no client / API errors come this way, with the steps to take
241
+ return self.send_json({"error": str(e)})
242
+ except (OSError, ValueError, KeyError) as e:
243
+ return self.send_json({"error": f"import failed: {e}"})
244
+ if url.path == "/api/name": # Version history → Name this version (an annotated tag; an empty name removes it)
245
+ req = json.loads(body or b"{}")
246
+ rev, name = req.get("rev", ""), req.get("name", "").strip()[:100]
247
+ if not re.fullmatch(r"[0-9a-f]{7,40}", rev):
248
+ return self.send_json({"error": "bad version"})
249
+ tag = f"slides/{rev[:12]}"
250
+ git(self.deck, "tag", "-d", tag)
251
+ if name:
252
+ r = git(self.deck, "tag", "-a", tag, "-m", name, rev)
253
+ if r.returncode:
254
+ return self.send_json({"error": r.stderr.strip()})
255
+ return self.send_json({"ok": True})
256
+ if url.path == "/api/restore": # Version history → Restore this version: that deck.html (and its pictures) back, committed
257
+ rev = json.loads(body or b"{}").get("rev", "")
258
+ if not re.fullmatch(r"[0-9a-f]{7,40}", rev):
259
+ return self.send_json({"error": "bad version"})
260
+ html = git(self.deck, "show", f"{rev}:./deck.html").stdout
261
+ if not html:
262
+ return self.send_json({"error": "no such version"})
263
+ git(self.deck, "checkout", rev, "--", "./assets") # pictures that version used (newer ones stay)
264
+ with Handler.writing:
265
+ Handler.written = html
266
+ self.deck_file.write_text(html)
267
+ when = git(self.deck, "log", "-1", "--format=%cd", "--date=format:%b %-d, %H:%M", rev).stdout.strip()
268
+ paths = ["deck.html"] + (["assets"] if any(f.is_file() for f in (self.deck / "assets").rglob("*")) else [])
269
+ git(self.deck, "add", "--", *paths); git(self.deck, "commit", "-q", "-m", f"deck: restore the version of {when}", "--", *paths)
270
+ return self.send_json({"mtime": self.mtime()})
271
+ if url.path == "/api/quit": # home screen ⋮ → Quit server: a clean exit, which `glideshow install`'s launchd agent doesn't restart
272
+ self.send_json({"ok": True})
273
+ return threading.Thread(target=self.server.shutdown, daemon=True).start()
274
+ if url.path == "/api/deckop": # home-screen card menu: duplicate / rename / delete (to the Trash) / reveal in Finder
275
+ req = json.loads(body or b"{}")
276
+ deck = next((Path(d["path"]) for d in all_decks(self.deck) if d["path"] == req.get("path")), None)
277
+ if deck is None:
278
+ return self.send_json({"error": "unknown deck"})
279
+ op = req.get("op")
280
+ if op == "rename":
281
+ set_title(deck, req.get("title", "").strip() or deck.name)
282
+ elif op == "duplicate":
283
+ dst, n = deck.parent / f"{deck.name}-copy", 2
284
+ while dst.exists(): dst, n = deck.parent / f"{deck.name}-copy-{n}", n + 1
285
+ shutil.copytree(deck, dst, ignore=shutil.ignore_patterns(".*"))
286
+ set_title(dst, "Copy of " + deck_title(deck)); remember(dst)
287
+ elif op == "delete":
288
+ if deck == self.deck: # the open deck: serve the next most recent one (or a new blank) first
289
+ others = [Path(d["path"]) for d in all_decks(self.deck) if Path(d["path"]) != deck]
290
+ open_deck(others[0] if others else new_draft())
291
+ publish.drop(deck); trash(deck); forget(deck); commit_library(HOME, f"library: remove {deck.name}")
292
+ elif op == "move": # into another library folder ('' = the top), keeping its folder name
293
+ dst, n = in_library(req.get("folder", "")) / deck.name, 2
294
+ if dst.parent == deck.parent: return self.send_json({"ok": True})
295
+ while dst.exists(): dst, n = dst.parent / f"{deck.name}-{n}", n + 1
296
+ dst.parent.mkdir(parents=True, exist_ok=True)
297
+ deck.rename(dst); forget(deck); publish.rekey(deck, dst)
298
+ if deck == self.deck: open_deck(dst)
299
+ commit_library(HOME, f"library: move {deck.name} to {req.get('folder') or 'the top'}")
300
+ elif op == "reveal":
301
+ reveal(deck / "deck.html")
302
+ return self.send_json({"ok": True})
303
+ if url.path == "/api/open": # File → New / Open / Make a copy: switch this server to another deck folder
304
+ req = json.loads(body or b"{}")
305
+ try:
306
+ if req.get("copy"):
307
+ dst = (self.deck.parent / req["copy"]).resolve()
308
+ if dst.exists():
309
+ return self.send_json({"error": f"{dst.name} already exists"})
310
+ shutil.copytree(self.deck, dst, ignore=shutil.ignore_patterns(".*"))
311
+ deck = open_deck(dst)
312
+ elif req.get("new"): # New presentation: a draft, moved into this library folder once it has a title
313
+ in_library(req.get("folder", ""))
314
+ deck = open_deck(new_draft(req.get("folder", "")))
315
+ else:
316
+ target = Path(req.get("path", "")).expanduser()
317
+ if not target.is_absolute():
318
+ target = self.deck.parent / target
319
+ deck = open_deck(target, create=bool(req.get("create")))
320
+ except (OSError, KeyError, ValueError) as e:
321
+ return self.send_json({"error": str(e)})
322
+ return self.send_json({"path": str(deck)})
323
+ if url.path == "/api/publish": # Share: a glideshow.org link (an entry in published.json), its settings, or taking it down
324
+ req = json.loads(body or b"{}")
325
+ try:
326
+ if req.get("op") == "unpublish": publish.unpublish(self.deck); return self.send_json(publish.status(self.deck))
327
+ return self.send_json(publish.publish(self.deck, req.get("op", "publish"), req.get("access"), req.get("people"), req.get("expires"), req.get("editors")))
328
+ except SystemExit as e: # publish reports problems (not logged in, outside the library) this way
329
+ return self.send_json({"error": str(e)})
330
+ except (OSError, ValueError) as e:
331
+ return self.send_json({"error": str(e)})
332
+ if url.path == "/api/folder": # home screen: make, rename or remove (only when empty) a library folder
333
+
334
+ req = json.loads(body or b"{}")
335
+ try:
336
+ op, at = req.get("op"), in_library(req.get("path", ""))
337
+ name = re.sub(r'[/\\:]+', "-", req.get("name", "")).strip()
338
+ if op == "create":
339
+ if not name or name.startswith("."): return self.send_json({"error": "a folder needs a name"})
340
+ (at / name).mkdir(parents=True, exist_ok=False); (at / name / ".keep").touch() # git keeps empty folders by a file
341
+ elif op == "rename":
342
+ if at == HOME.resolve() or not name: return self.send_json({"error": "nothing to rename"})
343
+ dst = at.parent / name
344
+ if dst.exists(): return self.send_json({"error": f"{name} already exists"})
345
+ moved_current = at in self.deck.parents
346
+ at.rename(dst); publish.rekey(at, dst)
347
+
348
+ if moved_current: open_deck(dst / self.deck.relative_to(at))
349
+ elif op == "delete":
350
+ if at == HOME.resolve() or any(p.name != ".keep" for p in at.iterdir()): return self.send_json({"error": "only an empty folder can be removed"})
351
+ for p in at.iterdir(): p.unlink()
352
+ at.rmdir()
353
+ else:
354
+ return self.send_json({"error": "unknown folder op"})
355
+ except (OSError, ValueError) as e:
356
+ return self.send_json({"error": str(e)})
357
+ commit_library(HOME, " ".join(filter(None, ["library:", op, "folder", req.get("path"), name])))
358
+
359
+ return self.send_json({"ok": True})
360
+
361
+ if url.path == "/api/comments":
362
+ me = git(HOME, "config", "user.name").stdout.strip() or "You" # the owner, as git knows them
363
+ r = comments.change(self.deck, json.loads(body or b"{}"), {"n": me, "e": "", "owner": True})
364
+ schedule_commit(self.deck)
365
+ return self.send_json(r)
366
+ if url.path == "/api/font": # a font file (dropped, or copied from the computer's fonts) into assets/fonts/
367
+ q = parse_qs(url.query)
368
+ if "path" in q:
369
+ f = Path(q["path"][0])
370
+ if f not in local_fonts():
371
+ return self.send_json({"error": "not a font on this computer"})
372
+ body, name = f.read_bytes(), f.name
373
+ else:
374
+ name = q.get("name", [""])[0]
375
+ stem, ext = Path(name).stem, Path(name).suffix.lower()
376
+ if ext not in FONT_EXTS or not body:
377
+ return self.send_json({"error": "not a font file"})
378
+ stem = re.sub(r"[^A-Za-z0-9 _-]", "", stem).strip().replace(" ", "-") or hashlib.sha1(body).hexdigest()[:12]
379
+ (self.deck / "assets" / "fonts").mkdir(parents=True, exist_ok=True)
380
+ (self.deck / "assets" / "fonts" / (stem + ext)).write_bytes(body)
381
+ return self.send_json({"src": f"assets/fonts/{stem}{ext}", "family": stem.replace("-", " ").replace("_", " ")})
382
+ if url.path == "/api/upload":
383
+ ext = parse_qs(url.query).get("ext", ["png"])[0].lower()
384
+ return self.send_json({"src": self.store(body, ext)})
385
+ if url.path == "/api/fetch": # a pasted image URL: download it into assets/ so the deck stays self-contained
386
+ src = json.loads(body or b"{}").get("url", "")
387
+ if not re.match(r"https?://", src):
388
+ return self.send_json({"error": "not a web address"})
389
+ try:
390
+ with urllib.request.urlopen(urllib.request.Request(src, headers={"User-Agent": "glideshow"}), timeout=15) as r:
391
+ kind = r.headers.get_content_type()
392
+ data = r.read(MAX_FETCH + 1)
393
+ except Exception as e:
394
+ return self.send_json({"error": str(e)})
395
+ if not kind.startswith("image/") or len(data) > MAX_FETCH:
396
+ return self.send_json({"error": "not an image" if not kind.startswith("image/") else "too large"}) # an ordinary answer: the editor pastes the text
397
+ ext = {"image/jpeg": "jpg", "image/svg+xml": "svg"}.get(kind, kind.split("/")[1])
398
+ return self.send_json({"src": self.store(data, ext)})
399
+ self.send_error(404)
400
+
401
+
402
+
403
+ def open_deck(deck: Path, create: bool = False) -> Path:
404
+ deck = deck.expanduser().resolve()
405
+ if not (deck / "deck.html").exists():
406
+ if not create:
407
+ raise FileNotFoundError(f"no deck.html in {deck}")
408
+ fresh = not deck.exists()
409
+ try:
410
+ (deck / "assets").mkdir(parents=True, exist_ok=True)
411
+ new_deck(deck / "deck.html")
412
+ except Exception:
413
+ if fresh: shutil.rmtree(deck, ignore_errors=True) # no half-made deck folder left behind
414
+ raise
415
+ Handler.deck = deck
416
+ try: Handler.written = (deck / "deck.html").read_text()
417
+ except OSError: Handler.written = None
418
+ remember(deck)
419
+ return deck
420
+
421
+
422
+ def default_deck() -> Path:
423
+ """No deck named: the most recently opened one, else the newest in the library, else a new draft."""
424
+ recent = [Path(r) for r in recent_decks()]
425
+ found = recent or [Path(d["path"]) for d in all_decks(HOME / ".none")]
426
+ return found[0] if found else new_draft() # an empty library: a draft, kept out of ~/decks until it is named
427
+
428
+
429
+ def main():
430
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
431
+ ap.add_argument("deck", nargs="?", help="deck folder (created if missing); default: the most recent deck")
432
+ ap.add_argument("--port", type=int, help=f"serve on this port and skip the one-server check (default {DEFAULT_PORT}, or the next free one)")
433
+ ap.add_argument("--no-open", action="store_true", help="don't open a browser")
434
+ ap.add_argument("--no-git", action="store_true", help="don't commit saves even if the deck is in a git repo")
435
+ ap.add_argument("--no-push", action="store_true", help="commit saves but don't push them to the repo's origin remote")
436
+ ap.add_argument("--init", action="store_true", help="make the deck folder a git repo (saves are then committed) and exit")
437
+ ap.add_argument("--add-image", metavar="FILE", help="copy a picture into the deck and place it (fitted to the body area) on --slide N (default: the last slide), then exit")
438
+ ap.add_argument("--slide", type=int, help="slide number for --add-image (1-based)")
439
+ ap.add_argument("--build", metavar="OUT_DIR", help="write a self-contained copy (reveal.js vendored, Google Fonts embedded) and exit")
440
+ ap.add_argument("--exit-with", type=int, metavar="PID", help="shut down when process PID is gone (tests: no server outlives its test run)")
441
+ args = ap.parse_args()
442
+
443
+ deck = Path(args.deck).expanduser().resolve() if args.deck else default_deck().resolve()
444
+ if args.build:
445
+ return build(deck, Path(args.build).expanduser().resolve())
446
+ if args.add_image:
447
+ return print(f"Added {add_image(deck, Path(args.add_image).expanduser(), args.slide)} to {deck / 'deck.html'}")
448
+ GIT["on"] = not args.no_git
449
+ GIT["push"] = not args.no_push
450
+ if args.init:
451
+ open_deck(deck, create=True)
452
+ return init_repo(deck)
453
+ shared = args.port is None # the one background server: reuse it, or replace one running old code
454
+ if shared and (other := live()):
455
+ if other["version"] == code_version() or not same_install(other): # another install's server is left alone
456
+
457
+ post(other["url"], "api/open", {"path": str(deck), "create": True})
458
+ print(f"Already running at {other['url']}; switched it to {deck.name}")
459
+ if not args.no_open: webbrowser.open(other["url"] + "edit")
460
+ return
461
+ try: post(other["url"], "api/quit", {})
462
+ except Exception: pass
463
+ time.sleep(0.5)
464
+ open_deck(deck, create=True)
465
+ # The shared server walks up past a port someone else holds; an explicit --port must be that port, so a caller (a test)
466
+ # never ends up talking to an older server still sitting on it.
467
+ start = args.port or DEFAULT_PORT
468
+ for port in range(start, start + (1 if args.port else 20)):
469
+ try:
470
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
471
+ break
472
+ except OSError:
473
+ continue
474
+ else:
475
+ raise SystemExit(f"port {start} is in use (another server, perhaps an old one: see /api/ping)" if args.port else f"no free port between {start} and {start + 19}")
476
+ server.daemon_threads = True
477
+ if shared:
478
+ record(port); atexit.register(forget_me)
479
+ url = f"http://localhost:{port}/"
480
+ print(f"Editing {deck / 'deck.html'}\n editor: {url}edit\n home: {url}\n viewer: {url}deck/deck.html", flush=True)
481
+ if not args.no_open:
482
+ webbrowser.open(url + "edit")
483
+ # When the server's own code changes (an update, a git pull), restart in place on the same port and deck.
484
+ version, restart = code_version(), threading.Event()
485
+ def watch():
486
+ while not restart.wait(1):
487
+ if code_version() != version:
488
+ restart.set(); server.shutdown()
489
+ threading.Thread(target=watch, daemon=True).start()
490
+ def outside(): # deck.html changed on disk, not by this server (an agent, a text editor): its own version in git
491
+ while not restart.wait(1):
492
+ with Handler.writing:
493
+ deck = Handler.deck
494
+ try: text = (deck / "deck.html").read_text()
495
+ except OSError: continue
496
+ if Handler.written is None or text == Handler.written: continue
497
+ saved, Handler.written = Handler.written, text
498
+ if GIT["on"]: commit_outside(deck, saved)
499
+ threading.Thread(target=outside, daemon=True).start()
500
+ def trade(): # the library repo and origin: edits made online come in every 30 s; edits made here go out within seconds
501
+ busy = lambda t: t and t.is_alive() # a save, or an outside edit to the open deck, waiting for its own commit
502
+ every, quiet = float(os.environ.get("GLIDESHOW_SYNC_EVERY") or 30), float(os.environ.get("GLIDESHOW_OUTSIDE_DELAY") or 3)
503
+ last_sync, seen = time.time(), None
504
+ def in_deck(f: Path) -> bool: # inside a deck's folder (an agent's edit), not the library's own files (published.json, …)
505
+ return any((d / "deck.html").is_file() for d in [f.parent, *f.parent.parents] if d != HOME and HOME in d.parents)
506
+ def changed(): # what is changed in the library's decks, and when each file last was (a run of writes keeps it moving)
507
+ out = []
508
+ for line in git(HOME, "status", "--porcelain").stdout.splitlines():
509
+ f = HOME / line[3:].split(" -> ")[-1].strip('"')
510
+ if not in_deck(f): return [] # something else changed too (a command of glideshow's own writing): the 30 s turn
511
+ try: out.append((str(f), f.stat().st_mtime_ns))
512
+ except OSError: out.append((str(f), 0))
513
+ return out
514
+ # An agent editing a deck that isn't open here: committed once its files have been still for `quiet` seconds
515
+ # (as the open deck's outside edits are, commit_outside) and pushed at once, rather than at the next 30 s turn,
516
+ # so glideshow.org (its push webhook) shows it within seconds (2026-10-22: agent edits arrived late or in bursts).
517
+ while not restart.wait(quiet):
518
+ if busy(GIT["timer"]) or busy(GIT.get("outside")): continue
519
+ now = changed()
520
+ settled = bool(now) and now == seen
521
+ seen = now
522
+ if settled and commit_outside_library(HOME, Handler.deck / "deck.html"): sync(HOME); last_sync = time.time(); seen = None
523
+ elif time.time() - last_sync >= every:
524
+ commit_outside_library(HOME, Handler.deck / "deck.html"); sync(HOME); last_sync = time.time(); seen = None # edits to other decks first, or syncing would wait on them
525
+ if GIT["on"] and HOME.is_dir(): threading.Thread(target=trade, daemon=True).start()
526
+ if args.exit_with: # stop when the process that started us is gone, even if it was killed outright
527
+ def orphaned():
528
+ while True:
529
+ time.sleep(1)
530
+ try: os.kill(args.exit_with, 0)
531
+ except ProcessLookupError: return server.shutdown()
532
+ except PermissionError: pass
533
+ threading.Thread(target=orphaned, daemon=True).start()
534
+
535
+ try:
536
+ server.serve_forever()
537
+ except KeyboardInterrupt:
538
+ restart.clear()
539
+ server.server_close()
540
+ if restart.is_set():
541
+ if GIT["timer"]: GIT["timer"].cancel(); commit_now(Handler.deck) # don't lose a pending commit
542
+ forget_me()
543
+ flags = [f for f, on in (("--no-git", args.no_git), ("--no-push", args.no_push)) if on] + (["--exit-with", str(args.exit_with)] if args.exit_with else [])
544
+
545
+ os.execv(sys.executable, [sys.executable, "-m", "glideshow.server", str(Handler.deck), "--no-open", *flags, *(["--port", str(port)] if not shared else [])])
546
+
547
+
548
+ if __name__ == "__main__":
549
+ main()
glideshow/service.py ADDED
@@ -0,0 +1,65 @@
1
+ """The one background server: where it runs, whether it is ours, and whether it runs the current code.
2
+
3
+ A default-port server records itself in ~/.glideshow/server.json (pid, port, code version). `glideshow` and the app ask that
4
+ server (GET /api/ping) instead of guessing ports; a server whose code version differs from the files on disk is told to
5
+ quit and a fresh one takes over, and a running server restarts itself when its own Python files change."""
6
+
7
+ import hashlib, json, os, urllib.request
8
+ from pathlib import Path
9
+
10
+ HERE = Path(__file__).resolve().parent
11
+ CACHE = Path(os.environ.get("GLIDESHOW_CACHE") or Path.home() / ".glideshow")
12
+ DEFAULT_PORT = int(os.environ.get("GLIDESHOW_PORT") or 47800) # out of the way of the usual dev-server ports
13
+ # A server on its own port (GLIDESHOW_PORT, a test's) records itself apart, so it never finds or replaces the usual one
14
+ STATE = CACHE / (f"server-{DEFAULT_PORT}.json" if os.environ.get("GLIDESHOW_PORT") else "server.json")
15
+
16
+
17
+ def code_version() -> str:
18
+ """Changes whenever a Python file of the server changes (the editor's own files are served fresh anyway)."""
19
+ h = hashlib.sha1()
20
+ for f in sorted(HERE.glob("*.py")):
21
+ st = f.stat(); h.update(f"{f.name}:{st.st_size}:{st.st_mtime_ns};".encode())
22
+ return h.hexdigest()[:12]
23
+
24
+
25
+ def ping(port: int) -> dict | None:
26
+ try:
27
+ with urllib.request.urlopen(f"http://localhost:{port}/api/ping", timeout=1) as r:
28
+ j = json.load(r)
29
+ return j if j.get("app") in ("glideshow", "slides") else None # (a server from before the rename answers "slides")
30
+ except Exception:
31
+ return None
32
+
33
+
34
+ def live() -> dict | None:
35
+ """The recorded server if it answers as ours: {port, pid, version, url}."""
36
+ try: s = json.loads(STATE.read_text())
37
+ except (OSError, ValueError): return None
38
+ p = ping(s.get("port", 0))
39
+ return {**s, **p, "url": f"http://localhost:{s['port']}/"} if p and p.get("pid") == s.get("pid") else None
40
+
41
+
42
+ def same_install(s: dict) -> bool:
43
+ """Is that running server this copy of slides (the checkout, or the installed package)? Only then may we replace it
44
+ for running older code; two installs would otherwise keep replacing each other's server."""
45
+ return not s.get("install") or Path(s["install"]).resolve() == HERE # a server too old to say is replaceable
46
+
47
+
48
+
49
+ def record(port: int) -> None:
50
+
51
+ CACHE.mkdir(parents=True, exist_ok=True)
52
+ STATE.write_text(json.dumps({"port": port, "pid": os.getpid(), "version": code_version()}))
53
+
54
+
55
+ def forget_me() -> None:
56
+ try:
57
+ if json.loads(STATE.read_text()).get("pid") == os.getpid(): STATE.unlink()
58
+ except (OSError, ValueError):
59
+ pass
60
+
61
+
62
+ def post(url: str, path: str, body: dict) -> dict:
63
+ req = urllib.request.Request(url + path, data=json.dumps(body).encode(), method="POST", headers={"X-Glideshow": "1"})
64
+ with urllib.request.urlopen(req, timeout=10) as r:
65
+ return json.load(r)