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
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)
|