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/build.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""--build: a self-contained copy of a deck that presents without the network."""
|
|
2
|
+
|
|
3
|
+
import hashlib, re, urllib.request
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# --build: a copy of the deck that needs no network. reveal.js (and the notes plugin shapes.js loads beside it) is
|
|
7
|
+
# vendored under vendor/, and the deck's Google Fonts / Material Symbols links become assets/fonts/google-fonts.css and
|
|
8
|
+
# google-icons.css with the font files
|
|
9
|
+
# beside it. Only https URLs in those two places are fetched; everything else is copied as is.
|
|
10
|
+
MAX_FETCH = 25_000_000 # bytes: pasted image URLs larger than this are refused
|
|
11
|
+
REVEAL_FILES = ["dist/reveal.css", "dist/reveal.js", "plugin/notes/notes.js", "plugin/highlight/highlight.js"]
|
|
12
|
+
WOFF2_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch(url: str, ua: str = "slides-build") -> bytes:
|
|
16
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": ua}), timeout=30) as r:
|
|
17
|
+
return r.read(MAX_FETCH)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build(deck: Path, out: Path) -> None:
|
|
21
|
+
import shutil
|
|
22
|
+
if out.exists() and any(out.iterdir()):
|
|
23
|
+
raise SystemExit(f"{out} is not empty")
|
|
24
|
+
shutil.copytree(deck, out, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".*"))
|
|
25
|
+
html = (out / "deck.html").read_text()
|
|
26
|
+
for m in sorted(set(re.findall(r"https://cdn\.jsdelivr\.net/npm/(reveal\.js@[\d.]+)/dist/reveal\.(?:css|js)", html))):
|
|
27
|
+
for rel in REVEAL_FILES:
|
|
28
|
+
dst = out / "vendor" / m.replace("@", "-") / rel
|
|
29
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
dst.write_bytes(fetch(f"https://cdn.jsdelivr.net/npm/{m}/{rel}"))
|
|
31
|
+
html = html.replace(f"https://cdn.jsdelivr.net/npm/{m}/", f"vendor/{m.replace('@', '-')}/")
|
|
32
|
+
for ver in sorted(set(re.findall(r"https://cdn\.jsdelivr\.net/npm/katex@([\d.]+)/dist/", html))): # equations (shapes.js)
|
|
33
|
+
base, dst = f"https://cdn.jsdelivr.net/npm/katex@{ver}/dist/", out / "vendor" / f"katex-{ver}"
|
|
34
|
+
for rel in ["katex.min.css", "katex.min.js", "contrib/auto-render.min.js"]:
|
|
35
|
+
(dst / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
(dst / rel).write_bytes(fetch(base + rel))
|
|
37
|
+
for rel in sorted(set(re.findall(r"url\((fonts/[^)]+?\.woff2)\)", (dst / "katex.min.css").read_text()))):
|
|
38
|
+
(dst / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
(dst / rel).write_bytes(fetch(base + rel))
|
|
40
|
+
html = html.replace(base, f"vendor/katex-{ver}/")
|
|
41
|
+
for link in re.finditer(r'<link id="deck-(fonts|icons)"[^>]*href="(https://fonts\.googleapis\.com/css2\?[^"]+)"', html):
|
|
42
|
+
css = fetch(link.group(2).replace("&", "&"), WOFF2_UA).decode()
|
|
43
|
+
fonts = out / "assets" / "fonts"
|
|
44
|
+
fonts.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
def local(m):
|
|
46
|
+
data = fetch(m.group(1))
|
|
47
|
+
name = hashlib.sha1(data).hexdigest()[:12] + ".woff2"
|
|
48
|
+
(fonts / name).write_bytes(data)
|
|
49
|
+
return f"url({name})"
|
|
50
|
+
css = re.sub(r"url\((https://fonts\.gstatic\.com/[^)]+)\)", local, css)
|
|
51
|
+
(fonts / f"google-{link.group(1)}.css").write_text(css)
|
|
52
|
+
html = html.replace(link.group(2), f"assets/fonts/google-{link.group(1)}.css")
|
|
53
|
+
(out / "deck.html").write_text(html)
|
|
54
|
+
print(f"Built {out / 'deck.html'} (no network needed)")
|
glideshow/cli.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""The `glideshow` command.
|
|
2
|
+
|
|
3
|
+
glideshow open the home screen (starting the server if needed)
|
|
4
|
+
glideshow new "Lab meeting" a new deck in $GLIDESHOW_HOME (default ~/decks), opened in the editor
|
|
5
|
+
(--no-open: only made, its folder printed; the editor isn't touched)
|
|
6
|
+
glideshow find <words> the folder of the deck that best matches (nothing is opened)
|
|
7
|
+
glideshow <name> open the deck whose folder name best matches <name> (in any folder)
|
|
8
|
+
glideshow build <name> <out> a self-contained copy that presents offline
|
|
9
|
+
glideshow serve [DECK] [...] run the server in the foreground (--port N, --build OUT, --init, --add-image FILE)
|
|
10
|
+
glideshow url print the running server's address (starting it if needed)
|
|
11
|
+
glideshow selection [--json] what the open editor shows: the deck, the slide on show, the selected boxes and their text
|
|
12
|
+
glideshow render [deck] [N ...] the deck's slides as 960 × 540 PNGs (default: the deck open in the editor; -o DIR; --full:
|
|
13
|
+
1920 × 1080), one path per line, then any problems (overflow, cut-off code, off-slide,
|
|
14
|
+
overlaps, small text, inside figures, notes left; --json); --sheet: nine slides to a picture
|
|
15
|
+
instead; --morph: each Morph step's pairs and a picture halfway through it
|
|
16
|
+
glideshow check [deck] [N ...] only the problems, no pictures (exit 1 if any)
|
|
17
|
+
glideshow docs [reference] for an agent: the guide and recipes (reference: the full deck format)
|
|
18
|
+
glideshow layout [NAME] the layouts' exact placeholder boxes; with NAME, a slide in that layout to paste
|
|
19
|
+
glideshow slides <deck> [N ...] only the deck's <section>s (no inlined CSS / JS), each with its line in deck.html;
|
|
20
|
+
--outline: one line per slide (its layout, title, boxes' ids and first words)
|
|
21
|
+
glideshow put <deck> N replace slide N with the <section>s on stdin (--after N inserts, --delete N removes, --all replaces every slide)
|
|
22
|
+
glideshow figure add|refresh|size a picture placed or replaced in place, remembering the command that made it (see
|
|
23
|
+
`glideshow figure`); refresh re-runs those commands; size gives matplotlib settings
|
|
24
|
+
glideshow comments <deck> the open comments, each on the slide it's on now (--all, --json)
|
|
25
|
+
glideshow stop stop the background server
|
|
26
|
+
glideshow skill teach Claude Code to make decks here: links the Glideshow skill into ~/.claude/skills
|
|
27
|
+
glideshow install start the server at login (macOS, Linux, Windows), and link the skill (glideshow skill)
|
|
28
|
+
glideshow init-remote OWNER/REPO back the library up to a new private GitHub repo (needs `gh`); every save is pushed
|
|
29
|
+
glideshow publish <name> share the deck at an unguessable glideshow.org link (the library on GitHub, with the Glideshow app on it)
|
|
30
|
+
glideshow unpublish <name> take it down again; `glideshow published` lists the links
|
|
31
|
+
glideshow import-google <url> a Google Slides deck as a new deck, through the Slides API (--login once; --report scores it)
|
|
32
|
+
|
|
33
|
+
One server serves every deck, in the background, on port 47800 (or the next free one; GLIDESHOW_PORT overrides). It is
|
|
34
|
+
recorded in ~/.glideshow/server.json (server-PORT.json with GLIDESHOW_PORT set; GLIDESHOW_CACHE moves the whole folder),
|
|
35
|
+
restarts itself when its code changes, and a second one is never started. A separate library for tests:
|
|
36
|
+
GLIDESHOW_HOME=/tmp/x/decks GLIDESHOW_CACHE=/tmp/x/cache GLIDESHOW_PORT=48900 glideshow …
|
|
37
|
+
--no-open skips the browser and prints the address instead (`glideshow url` prints it too)."""
|
|
38
|
+
import datetime, json, os, re, subprocess, sys, time, webbrowser
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from .service import CACHE, DEFAULT_PORT, code_version, live, post, same_install
|
|
42
|
+
|
|
43
|
+
HERE = Path(__file__).resolve().parent
|
|
44
|
+
HOME = Path(os.environ.get("GLIDESHOW_HOME") or Path.home() / "decks").expanduser()
|
|
45
|
+
URL = f"http://localhost:{DEFAULT_PORT}/" # updated to the live server's address by running()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def slug(title: str) -> str:
|
|
49
|
+
s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "deck"
|
|
50
|
+
return f"{s}-{datetime.date.today().isoformat()}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decks() -> list:
|
|
54
|
+
"""Every deck in the library, in any folder, most recently edited first."""
|
|
55
|
+
from .decks import walk_library
|
|
56
|
+
return sorted(walk_library()[0], key=lambda d: -(d / "deck.html").stat().st_mtime)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def match(name: str) -> Path:
|
|
60
|
+
"""Exact folder, then substring, then every word (in any order), then letters in order (fuzzy); spaces, dashes and
|
|
61
|
+
underscores are alike ("lab meeting" finds lab-meeting-2026-10-15); the most recently edited wins ties."""
|
|
62
|
+
norm = lambda t: re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")
|
|
63
|
+
q, ds = norm(name), decks()
|
|
64
|
+
words = [w for w in q.split("-") if w]
|
|
65
|
+
for test in (lambda d: norm(d.name) == q, lambda d: q in norm(d.name), lambda d: words and all(w in norm(d.name) for w in words),
|
|
66
|
+
lambda d: re.search(".*?".join(map(re.escape, q.replace("-", ""))), norm(d.name).replace("-", ""))):
|
|
67
|
+
hit = [d for d in ds if test(d)]
|
|
68
|
+
if hit: return hit[0]
|
|
69
|
+
raise SystemExit(f"no deck in {HOME} matches {name!r}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def running() -> bool:
|
|
73
|
+
"""Is our one server up, on the current code? A server running older code is told to quit (a fresh one replaces it)."""
|
|
74
|
+
global URL
|
|
75
|
+
s = live()
|
|
76
|
+
if s and not same_install(s):
|
|
77
|
+
print(f"(the running server is another install of slides: {s.get('install')})", file=sys.stderr)
|
|
78
|
+
elif s and s["version"] != code_version():
|
|
79
|
+
|
|
80
|
+
try: post(s["url"], "api/quit", {})
|
|
81
|
+
except Exception: pass
|
|
82
|
+
time.sleep(0.5); return False
|
|
83
|
+
if s: URL = s["url"]
|
|
84
|
+
return bool(s)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def start(deck: Path | None = None):
|
|
88
|
+
"""Start the background server (detached, logging to ~/.glideshow/server.log) and wait until it answers."""
|
|
89
|
+
log = open(CACHE.mkdir(parents=True, exist_ok=True) or CACHE / "server.log", "a")
|
|
90
|
+
subprocess.Popen([sys.executable, "-m", "glideshow.server", *([str(deck)] if deck else []), "--no-open"],
|
|
91
|
+
stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True)
|
|
92
|
+
for _ in range(600): # (a cold first start can take a while: imports, a fresh .pyc cache)
|
|
93
|
+
if running(): return
|
|
94
|
+
time.sleep(0.1)
|
|
95
|
+
raise SystemExit(f"the slides server did not start; see {CACHE / 'server.log'}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def serve(deck: Path, open_browser: bool):
|
|
99
|
+
"""Open the deck in the editor; the terminal shows its address only when no browser opens (--no-open)."""
|
|
100
|
+
if running(): # switch the running server to this deck
|
|
101
|
+
err = post(URL, "api/open", {"path": str(deck), "create": True}).get("error")
|
|
102
|
+
if err: raise SystemExit(err)
|
|
103
|
+
else:
|
|
104
|
+
start(deck)
|
|
105
|
+
if open_browser: webbrowser.open(URL + "edit")
|
|
106
|
+
else: print(f"Editing {deck} at {URL}edit")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def new(title: str) -> Path:
|
|
110
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
if not (HOME / ".git").exists() and subprocess.run(["git", "-C", str(HOME), "rev-parse"], capture_output=True).returncode:
|
|
112
|
+
subprocess.run(["git", "init", "-q", str(HOME)], check=False) # decks are versioned from the first one
|
|
113
|
+
if not (HOME / ".gitignore").exists(): (HOME / ".gitignore").write_text(IGNORE)
|
|
114
|
+
deck, n = HOME / slug(title), 2
|
|
115
|
+
while deck.exists(): deck, n = HOME / f"{slug(title)}-{n}", n + 1
|
|
116
|
+
(deck / "assets").mkdir(parents=True)
|
|
117
|
+
from .decks import new_deck # the template with base.css / shapes.js / present.js filled in, + AGENTS.md
|
|
118
|
+
new_deck(deck / "deck.html", title)
|
|
119
|
+
return deck
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
BIG = 50 * 1024 * 1024 # GitHub warns above 50 MB and refuses 100 MB
|
|
123
|
+
# `**/` so the rules still hold once decks are moved into folders (a plain `*/refs/` matches one level only)
|
|
124
|
+
IGNORE = ".DS_Store\n*.tmp\n# regenerable comparison renders (gslides2deck refs, test/snap.py shots), at any folder depth\n**/refs/\n**/refs-svg/\n**/source/snap*/\n**/source/cmp/\n"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def init_remote(repo: str):
|
|
129
|
+
"""The whole library ($GLIDESHOW_HOME) as one git repo with a private GitHub origin; the server pushes after each commit."""
|
|
130
|
+
git = lambda *a, **k: subprocess.run(["git", "-C", str(HOME), *a], capture_output=True, text=True, **k)
|
|
131
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
if git("rev-parse", "--is-inside-work-tree").stdout.strip() != "true":
|
|
133
|
+
git("init", "-q", "-b", "main")
|
|
134
|
+
ig = HOME / ".gitignore"
|
|
135
|
+
big = sorted({f"**/{p.name}" for p in HOME.rglob("*") if p.is_file() and ".git" not in p.parts and p.stat().st_size > BIG}) # by name: still ignored after a move
|
|
136
|
+
|
|
137
|
+
text = ig.read_text() if ig.exists() else IGNORE
|
|
138
|
+
new = [b for b in big if b not in text.split("\n")]
|
|
139
|
+
if new: text += "# over 50 MB: kept on this Mac only\n" + "".join(b + "\n" for b in new)
|
|
140
|
+
ig.write_text(text)
|
|
141
|
+
git("add", "-A")
|
|
142
|
+
if git("diff", "--cached", "--quiet").returncode:
|
|
143
|
+
git("commit", "-q", "-m", "library: back up decks")
|
|
144
|
+
if git("remote", "get-url", "origin").returncode == 0:
|
|
145
|
+
print(f"origin is already {git('remote', 'get-url', 'origin').stdout.strip()}; pushing")
|
|
146
|
+
r = git("push", "-u", "origin", "HEAD")
|
|
147
|
+
elif subprocess.run(["gh", "repo", "view", repo], capture_output=True).returncode == 0:
|
|
148
|
+
url = subprocess.run(["gh", "repo", "view", repo, "--json", "sshUrl", "-q", ".sshUrl"], capture_output=True, text=True).stdout.strip()
|
|
149
|
+
git("remote", "add", "origin", url); r = git("push", "-u", "origin", "HEAD")
|
|
150
|
+
else:
|
|
151
|
+
r = subprocess.run(["gh", "repo", "create", repo, "--private", "--source", str(HOME), "--remote", "origin", "--push",
|
|
152
|
+
"--description", "Slide decks (backed up by the slides editor)"], capture_output=True, text=True)
|
|
153
|
+
if r.returncode:
|
|
154
|
+
raise SystemExit(f"push failed: {(r.stderr or r.stdout).strip()[:400]}")
|
|
155
|
+
print(f"{HOME} is backed up to {repo} (private); the server pushes after every commit (--no-push to stop)"
|
|
156
|
+
+ (f"; not uploaded (over 50 MB): {', '.join(big)}" if big else ""))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
SKILL = (HERE / "skills" if (HERE / "skills").is_dir() else HERE.parent / "skills") / "glideshow"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def link_skill():
|
|
163
|
+
"""The Glideshow skill (skills/glideshow/SKILL.md) in ~/.claude/skills, so every Claude Code session on this computer, in
|
|
164
|
+
any folder, knows how to make and check decks here. A link, so it follows updates; a real folder there is left alone."""
|
|
165
|
+
dst = Path.home() / ".claude" / "skills" / "glideshow"
|
|
166
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
if dst.is_symlink(): dst.unlink()
|
|
168
|
+
if dst.exists(): return print(f"{dst} exists and isn't a link; left as it is (the skill is at {SKILL})")
|
|
169
|
+
dst.symlink_to(SKILL, target_is_directory=True)
|
|
170
|
+
print(f"Claude Code now knows Glideshow: {dst} -> {SKILL}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render(args: list[str], check: bool = False):
|
|
174
|
+
"""`glideshow render` (the slides as PNGs, then any problems) and `glideshow check` (only the problems; exit 1 if
|
|
175
|
+
any) through glideshow/render.py, a uv script that brings Playwright with it."""
|
|
176
|
+
out = None
|
|
177
|
+
if "-o" in args: i = args.index("-o"); out = Path(args[i + 1]).expanduser(); args = args[:i] + args[i + 2:]
|
|
178
|
+
flags = [a for a in args if a in ("--json", "--morph", "--full", "--sheet")] + (["--check"] if check else [])
|
|
179
|
+
args = [a for a in args if not a.startswith("--")]
|
|
180
|
+
is_num = lambda a: re.fullmatch(r"\d+(\.\d+)?", a)
|
|
181
|
+
nums, names = [a for a in args if is_num(a)], [a for a in args if not is_num(a)]
|
|
182
|
+
if names: deck = Path(names[0]).expanduser() if (Path(names[0]).expanduser() / "deck.html").exists() else match(names[0])
|
|
183
|
+
else:
|
|
184
|
+
s = live()
|
|
185
|
+
if not s: raise SystemExit("Name a deck (glideshow render <deck> [N ...]); no editor is open to take it from.")
|
|
186
|
+
deck = Path(s["deck"])
|
|
187
|
+
from .agent import ensure_server, scan, slide_number
|
|
188
|
+
ensure_server(deck) # the edits being checked are kept as versions by it
|
|
189
|
+
text = (deck / "deck.html").read_text()
|
|
190
|
+
nums = [str(slide_number(text, a)) for a in nums] # the deck's own numbers (8.1) as the file's
|
|
191
|
+
os.environ["GLIDESHOW_STRUCTURE"] = json.dumps(scan(text)[1]) # what render.py can't see: the file's own structure
|
|
192
|
+
out = out or CACHE / "renders" / deck.name
|
|
193
|
+
try: r = subprocess.run(["uv", "run", "--quiet", str(HERE / "render.py"), str(deck.resolve()), str(out), *nums, *flags])
|
|
194
|
+
except FileNotFoundError: raise SystemExit("glideshow render needs uv (https://docs.astral.sh/uv/), which brings Playwright with it")
|
|
195
|
+
raise SystemExit(r.returncode)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def install():
|
|
199
|
+
"""Start the server at login: a launchd agent (macOS), a systemd user service (Linux), the Startup folder (Windows)."""
|
|
200
|
+
link_skill()
|
|
201
|
+
py, log = sys.executable, CACHE / "server.log"
|
|
202
|
+
CACHE.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
if sys.platform == "darwin":
|
|
204
|
+
f = Path.home() / "Library" / "LaunchAgents" / "com.glideshow.server.plist"
|
|
205
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
f.write_text(f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
207
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
208
|
+
<plist version="1.0"><dict>
|
|
209
|
+
<key>Label</key><string>com.glideshow.server</string>
|
|
210
|
+
<key>ProgramArguments</key><array><string>{py}</string><string>-m</string><string>glideshow.server</string><string>--no-open</string></array>
|
|
211
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
212
|
+
<key>StandardOutPath</key><string>{log}</string><key>StandardErrorPath</key><string>{log}</string>
|
|
213
|
+
</dict></plist>
|
|
214
|
+
""")
|
|
215
|
+
subprocess.run(["launchctl", "unload", str(f)], capture_output=True)
|
|
216
|
+
subprocess.run(["launchctl", "load", str(f)], check=False)
|
|
217
|
+
elif sys.platform.startswith("linux"):
|
|
218
|
+
f = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user" / "glideshow.service"
|
|
219
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
f.write_text(f"[Unit]\nDescription=Glideshow editor server\n\n[Service]\nExecStart={py} -m glideshow.server --no-open\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n")
|
|
221
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
|
222
|
+
subprocess.run(["systemctl", "--user", "enable", "--now", "glideshow.service"], check=False)
|
|
223
|
+
elif sys.platform == "win32":
|
|
224
|
+
f = Path(os.environ["APPDATA"]) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / "slides-server.cmd"
|
|
225
|
+
f.write_text(f'@start "" /min "{Path(py).with_name("pythonw.exe")}" -m glideshow.server --no-open\r\n')
|
|
226
|
+
else:
|
|
227
|
+
raise SystemExit("glideshow install knows macOS, Linux and Windows")
|
|
228
|
+
print(f"The Glideshow server now starts at login ({f}). `glideshow` opens it.")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def main(argv):
|
|
232
|
+
open_browser = "--no-open" not in argv
|
|
233
|
+
args = [a for a in argv if a != "--no-open"]
|
|
234
|
+
if not args: # the home screen, starting the server if needed
|
|
235
|
+
if not running(): start()
|
|
236
|
+
if open_browser: webbrowser.open(URL)
|
|
237
|
+
else: print(URL) # the address only when no browser opens
|
|
238
|
+
return
|
|
239
|
+
cmd, rest = args[0], args[1:]
|
|
240
|
+
if cmd in ("-h", "--help", "help") or any(a in ("-h", "--help") for a in rest): return print(__doc__) # `glideshow new --help` too: usage, not a deck called "--help"
|
|
241
|
+
if cmd == "url": # the running server's address, starting it if needed (the desktop app asks this)
|
|
242
|
+
if not running(): start()
|
|
243
|
+
return print(URL)
|
|
244
|
+
if cmd == "selection": # for an agent working beside the editor: what "this" means
|
|
245
|
+
s = live()
|
|
246
|
+
import urllib.request
|
|
247
|
+
if not s:
|
|
248
|
+
if "--json" in rest: print(json.dumps({"slide": None, "boxes": [], "error": "no server, so no editor is open"}))
|
|
249
|
+
raise SystemExit(1 if "--json" in rest else "No editor is open (the Glideshow server isn't running), so there is no selection: ask the user which slide they mean.")
|
|
250
|
+
sel = json.load(urllib.request.urlopen(s["url"] + "api/selection", timeout=5))
|
|
251
|
+
age = round(time.time() - sel["time"]) if sel.get("time") else None
|
|
252
|
+
if age is not None: sel["age"] = age
|
|
253
|
+
if "--json" in rest:
|
|
254
|
+
print(json.dumps(sel, indent=2))
|
|
255
|
+
if not sel.get("slide"): raise SystemExit(1)
|
|
256
|
+
return
|
|
257
|
+
if not sel.get("slide"): raise SystemExit(f"{sel['file']}\nNo editor window has reported what it shows (the server is running, but no editor is open in a browser): ask the user which slide they mean.")
|
|
258
|
+
from .agent import sections
|
|
259
|
+
n_file = len(sections(Path(sel["file"]).read_text())) if Path(sel["file"]).exists() else None
|
|
260
|
+
if n_file and sel.get("slides") and n_file != sel["slides"]: print(f"(the editor reported {sel['slides']} slides but deck.html now has {n_file}: it may not have reloaded yet, or the report is old)")
|
|
261
|
+
lines = [sel["file"], f"slide {sel['slide']} of {sel.get('slides', '?')}" + (f" (reveal #/{sel['h']}/{sel['v']})" if sel.get("v") else "")
|
|
262
|
+
+ (f" (reported {age} s ago{'; the editor may be closed' if age > 600 else ''})" if age is not None else "")]
|
|
263
|
+
for b in sel.get("boxes", []):
|
|
264
|
+
lines.append(f" box id={b['id']} ({b['shape']}, box {b['index'] + 1} on the slide, at {b['x']},{b['y']} size {b['w']}×{b['h']})" + (f": {b['text'][:120]}" if b.get("text") else ""))
|
|
265
|
+
if not sel.get("boxes"): lines.append(" nothing selected (the whole slide)")
|
|
266
|
+
if sel.get("selectedText"): lines.append(f" selected text: {sel['selectedText'][:200]!r}")
|
|
267
|
+
return print("\n".join(lines))
|
|
268
|
+
if cmd == "serve": # the server itself, in the foreground: `glideshow serve DECK [--port N] [--build OUT] [--init] [--add-image F]`
|
|
269
|
+
from .server import main as server_main
|
|
270
|
+
sys.argv = ["glideshow serve", *argv[1:]]; return server_main()
|
|
271
|
+
if cmd == "new":
|
|
272
|
+
if rest and rest[0].startswith("-"): raise SystemExit('usage: glideshow new "<title>" [--no-open] (a title can\'t start with "-")')
|
|
273
|
+
deck = new(" ".join(rest) or "Untitled")
|
|
274
|
+
if not open_browser: # an agent's: made and printed; the editor (and what it shows) is left alone
|
|
275
|
+
if not running(): start(deck) # the background server, so every edit to the deck becomes a version
|
|
276
|
+
return print(deck)
|
|
277
|
+
return serve(deck, open_browser)
|
|
278
|
+
if cmd in ("layout", "slides", "put", "figure", "find", "stop", "comments"):
|
|
279
|
+
from . import agent
|
|
280
|
+
if cmd == "stop": return agent.stop_cmd()
|
|
281
|
+
return {"layout": agent.layout_cmd, "slides": agent.slides_cmd, "put": agent.put_cmd, "figure": agent.figure_cmd, "find": agent.find_cmd, "comments": agent.comments_cmd}[cmd](rest)
|
|
282
|
+
|
|
283
|
+
if cmd == "build":
|
|
284
|
+
if len(rest) != 2: raise SystemExit("usage: glideshow build <name> <out>")
|
|
285
|
+
return subprocess.run([sys.executable, "-m", "glideshow.server", str(match(rest[0])), "--build", rest[1]], check=True)
|
|
286
|
+
if cmd == "install": return install()
|
|
287
|
+
if cmd == "skill": return link_skill()
|
|
288
|
+
if cmd in ("render", "check"): return render(rest, check=cmd == "check")
|
|
289
|
+
if cmd == "docs": # the guide and recipes; `docs reference` the format reference (AGENTS.md beside each deck holds both)
|
|
290
|
+
from .decks import docs_text
|
|
291
|
+
return print(docs_text("reference" if rest[:1] == ["reference"] else "guide"))
|
|
292
|
+
if cmd in ("publish", "unpublish", "published"):
|
|
293
|
+
from . import publish as pub
|
|
294
|
+
if cmd == "published":
|
|
295
|
+
return print("\n".join(f"{v.get('url', '(not deployed)')} {v.get('title', rel)}" for rel, v in pub.ledger().items()) or "nothing published")
|
|
296
|
+
if not rest: raise SystemExit(f"usage: glideshow {cmd} <name>")
|
|
297
|
+
deck = match(" ".join(rest))
|
|
298
|
+
if cmd == "unpublish": pub.unpublish(deck); return print(f"{deck.name} is no longer shared")
|
|
299
|
+
return print(f"{deck.name} is at {pub.publish(deck)['url']} (anyone with this link can view it once the library is pushed; Share sets who can)")
|
|
300
|
+
|
|
301
|
+
if cmd == "import-google": # a Google Slides deck → a deck here (slides/gslides.py: OAuth once, then the Slides API)
|
|
302
|
+
from . import gslides
|
|
303
|
+
return gslides.main(rest + ([] if open_browser else ["--no-open"]))
|
|
304
|
+
if cmd == "init-remote":
|
|
305
|
+
|
|
306
|
+
if len(rest) != 1 or "/" not in rest[0]: raise SystemExit("usage: glideshow init-remote OWNER/REPO")
|
|
307
|
+
return init_remote(rest[0])
|
|
308
|
+
|
|
309
|
+
serve(match(" ".join(args)), open_browser)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def run(): # the `glideshow` entry point
|
|
313
|
+
import signal
|
|
314
|
+
if hasattr(signal, "SIGPIPE"): signal.signal(signal.SIGPIPE, signal.SIG_DFL) # `glideshow … | head` just stops
|
|
315
|
+
main(sys.argv[1:])
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
run()
|
|
320
|
+
|
glideshow/comments.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Comments on a deck: comments.json beside deck.html, the same file glideshow.org writes when people comment on a
|
|
2
|
+
shared deck (cloud/share.js has the format). Committed with the deck's saves."""
|
|
3
|
+
|
|
4
|
+
import re, json, secrets, time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read(deck: Path) -> dict:
|
|
9
|
+
try: c = json.loads((deck / "comments.json").read_text())
|
|
10
|
+
except (OSError, ValueError): c = {}
|
|
11
|
+
return {"comments": c.get("comments") if isinstance(c.get("comments"), list) else []}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def change(deck: Path, r: dict, by: dict) -> dict:
|
|
15
|
+
"""add {slide, sid, box, x, y, quote, text} / reply {id, text} / resolve {id, resolved} / delete {id}."""
|
|
16
|
+
doc = read(deck); items = doc["comments"]
|
|
17
|
+
find = lambda i: next((c for c in items if c.get("id") == i), None)
|
|
18
|
+
text, now, rid = str(r.get("text", "")).strip()[:4000], time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()), lambda: secrets.token_hex(4)
|
|
19
|
+
who = {"n": by.get("n", ""), "e": by.get("e", "")}
|
|
20
|
+
op = r.get("op")
|
|
21
|
+
if op == "add" and text:
|
|
22
|
+
sid = str(r.get("sid") or "") if re.fullmatch(r"[\w-]{1,40}", str(r.get("sid") or "")) else "" # the slide's data-sid: it finds the slide after slides move
|
|
23
|
+
items.append({"id": rid(), "slide": max(1, int(r.get("slide") or 1)), **({"sid": sid} if sid else {}), "box": str(r.get("box", ""))[:40], "x": round(float(r.get("x") or 0)), "y": round(float(r.get("y") or 0)),
|
|
24
|
+
"quote": str(r.get("quote", ""))[:200], "text": text, "by": who, "at": now, "resolved": False, "replies": []})
|
|
25
|
+
elif op == "reply" and (c := find(r.get("id"))) and text:
|
|
26
|
+
c.setdefault("replies", []).append({"id": rid(), "text": text, "by": who, "at": now})
|
|
27
|
+
elif op == "resolve" and (c := find(r.get("id"))):
|
|
28
|
+
c["resolved"] = bool(r.get("resolved"))
|
|
29
|
+
c.setdefault("replies", []).append({"id": rid(), "text": "(resolved)" if c["resolved"] else "(reopened)", "by": who, "at": now, "event": True})
|
|
30
|
+
elif op == "delete" and (c := find(r.get("id"))):
|
|
31
|
+
items.remove(c)
|
|
32
|
+
else:
|
|
33
|
+
return {**doc, "error": "nothing to do"}
|
|
34
|
+
(deck / "comments.json").write_text(json.dumps({"comments": items}, indent=2) + "\n")
|
|
35
|
+
return {"comments": items}
|
glideshow/decks.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Deck folders: a new deck from the template (+ its AGENTS.md), pictures added from the command line, the recent
|
|
2
|
+
list, the library ($GLIDESHOW_HOME) and switching the server to another deck."""
|
|
3
|
+
|
|
4
|
+
import hashlib, json, os, re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HERE = Path(__file__).resolve().parent
|
|
8
|
+
# The editor's files and the docs copied into decks: inside the package when installed, beside it in the repository.
|
|
9
|
+
EDITOR = HERE / "editor" if (HERE / "editor").is_dir() else HERE.parent / "editor"
|
|
10
|
+
DOCS = HERE / "docs" if (HERE / "docs").is_dir() else HERE.parent / "docs"
|
|
11
|
+
DECK_FILES = EDITOR / "deck" # the template and what every deck carries inline: base.css, shapes.js, present.js
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def new_deck(path: Path, title: str = "") -> None:
|
|
15
|
+
html = (DECK_FILES / "template.html").read_text()
|
|
16
|
+
html = html.replace("{{BASE}}", (DECK_FILES / "base.css").read_text().strip())
|
|
17
|
+
html = html.replace("{{SHAPES}}", (DECK_FILES / "shapes.js").read_text().strip())
|
|
18
|
+
html = html.replace("{{PRESENT}}", (DECK_FILES / "present.js").read_text().strip())
|
|
19
|
+
if title:
|
|
20
|
+
import html as _html
|
|
21
|
+
html = re.sub(r"<title>.*?</title>", f"<title>{_html.escape(title)}</title>", html, count=1, flags=re.S)
|
|
22
|
+
import datetime # every deck has a date (the talk's or meeting's), today's to start with
|
|
23
|
+
html = html.replace("</title>", f'</title>\n<meta name="date" content="{datetime.date.today().isoformat()}">', 1)
|
|
24
|
+
path.write_text(html)
|
|
25
|
+
write_agents_md(path.parent)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def docs_text(part: str = "") -> str:
|
|
29
|
+
"""AGENTS.md: the guide and recipes (docs/agents.md, with the layouts' table written from the editor's own) and the
|
|
30
|
+
format reference (docs/deck-format.md); `glideshow docs` prints the first ("guide"), `glideshow docs reference` the
|
|
31
|
+
second, so an agent reads the reference only when it needs it."""
|
|
32
|
+
from .agent import layout_table
|
|
33
|
+
guide = (DOCS / "agents.md").read_text().replace("{{LAYOUTS}}", layout_table()).rstrip()
|
|
34
|
+
ref = (DOCS / "deck-format.md").read_text()
|
|
35
|
+
return {"guide": guide, "reference": ref}.get(part) or guide + "\n\n" + ref
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_agents_md(deck: Path) -> None:
|
|
39
|
+
"""AGENTS.md beside deck.html (docs_text)."""
|
|
40
|
+
(deck / "AGENTS.md").write_text(docs_text())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def image_size(data: bytes):
|
|
44
|
+
"""(width, height) of a PNG, JPEG or GIF, read from its header; None if unknown."""
|
|
45
|
+
import struct
|
|
46
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n": return struct.unpack(">II", data[16:24])
|
|
47
|
+
if data[:6] in (b"GIF87a", b"GIF89a"): return struct.unpack("<HH", data[6:10])
|
|
48
|
+
if data[:2] == b"\xff\xd8":
|
|
49
|
+
i = 2
|
|
50
|
+
while i < len(data) - 9:
|
|
51
|
+
if data[i] != 0xFF: i += 1; continue
|
|
52
|
+
marker, size = data[i + 1], struct.unpack(">H", data[i + 2:i + 4])[0]
|
|
53
|
+
if marker in (0xC0, 0xC1, 0xC2): h, w = struct.unpack(">HH", data[i + 5:i + 9]); return w, h
|
|
54
|
+
i += 2 + size
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_image(deck: Path, src: Path, slide: int | None = None) -> str:
|
|
59
|
+
"""Copy a picture into assets/ and place it on slide N (1-based; default the last), fitted into the body area."""
|
|
60
|
+
data = src.read_bytes()
|
|
61
|
+
name = f"{hashlib.sha1(data).hexdigest()[:12]}{src.suffix.lower() or '.png'}"
|
|
62
|
+
(deck / "assets").mkdir(exist_ok=True); (deck / "assets" / name).write_bytes(data)
|
|
63
|
+
w0, h0 = image_size(data) or (1600, 900)
|
|
64
|
+
k = min(1680 / w0, 760 / h0); w, h = round(w0 * k), round(h0 * k)
|
|
65
|
+
x, y = 120 + (1680 - w) // 2, 240 + (760 - h) // 2
|
|
66
|
+
box = f' <div class="box image" style="left: {x}px; top: {y}px; width: {w}px; height: {h}px;"><div class="content"><img src="assets/{name}" alt=""></div></div>\n'
|
|
67
|
+
html = (deck / "deck.html").read_text()
|
|
68
|
+
ends = [m.start() for m in re.finditer(r"</section>", html)]
|
|
69
|
+
leaves = [e for e in ends if not re.search(r"</section>\s*$", html[:e])] # a stack's outer </section> follows an inner one
|
|
70
|
+
if not leaves: raise SystemExit("the deck has no slides")
|
|
71
|
+
at = leaves[(slide - 1) if slide else -1]
|
|
72
|
+
(deck / "deck.html").write_text(html[:at] + box + html[at:])
|
|
73
|
+
return f"assets/{name}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Decks opened recently (newest first), for File → Open; the server can switch to another deck folder.
|
|
77
|
+
RECENT = Path(os.environ.get("GLIDESHOW_CACHE") or Path.home() / ".glideshow") / "recent.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def recent_decks() -> list:
|
|
81
|
+
try:
|
|
82
|
+
paths = json.loads(RECENT.read_text())
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
paths = []
|
|
85
|
+
return [p for p in paths if (Path(p) / "deck.html").exists()]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# A new presentation is a draft until it has a title: it lives in ~/.glideshow/drafts/, outside the library (not listed,
|
|
89
|
+
# not committed, not backed up), and moves into the library folder it was started from once it is named.
|
|
90
|
+
DRAFTS = RECENT.parent / "drafts"
|
|
91
|
+
UNTITLED = "Untitled deck"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_draft(deck: Path) -> bool:
|
|
95
|
+
return DRAFTS.resolve() in deck.resolve().parents
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def new_draft(folder: str = "") -> Path:
|
|
99
|
+
import shutil, time as _t
|
|
100
|
+
for old in DRAFTS.glob("*") if DRAFTS.is_dir() else []: # drafts nobody named within a day are dropped
|
|
101
|
+
if old.is_dir() and _t.time() - old.stat().st_mtime > 86400: shutil.rmtree(old, ignore_errors=True)
|
|
102
|
+
d = DRAFTS / f"draft-{int(_t.time() * 1000)}"
|
|
103
|
+
(d / "assets").mkdir(parents=True)
|
|
104
|
+
new_deck(d / "deck.html")
|
|
105
|
+
(d / ".draft.json").write_text(json.dumps({"folder": folder}))
|
|
106
|
+
return d
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def name_draft(d: Path) -> Path | None:
|
|
110
|
+
"""The draft's new home in the library once it has a title, moved there; None while it is still untitled."""
|
|
111
|
+
import shutil
|
|
112
|
+
title = deck_title(d)
|
|
113
|
+
if title in ("", UNTITLED) or title == d.name: return None
|
|
114
|
+
try: folder = json.loads((d / ".draft.json").read_text()).get("folder", "")
|
|
115
|
+
except (OSError, ValueError): folder = ""
|
|
116
|
+
base = in_library(folder) / (re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60] or "deck")
|
|
117
|
+
dst, n = base, 2
|
|
118
|
+
while dst.exists(): dst, n = Path(f"{base}-{n}"), n + 1
|
|
119
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
(d / ".draft.json").unlink(missing_ok=True)
|
|
121
|
+
shutil.move(str(d), dst)
|
|
122
|
+
return dst
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def remember(deck: Path) -> None:
|
|
126
|
+
if is_draft(deck): return # drafts never show in recent decks
|
|
127
|
+
paths = [str(deck)] + [p for p in recent_decks() if p != str(deck)]
|
|
128
|
+
|
|
129
|
+
RECENT.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
RECENT.write_text(json.dumps(paths[:12]))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def deck_date(deck: Path) -> str:
|
|
134
|
+
"""The deck's date, <meta name="date" content="YYYY-MM-DD">, or '' when it has none."""
|
|
135
|
+
m = re.search(r'<meta name="date" content="(\d{4}-\d{2}-\d{2})"', (deck / "deck.html").read_text(errors="replace")[:20000])
|
|
136
|
+
return m.group(1) if m else ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def deck_title(deck: Path) -> str:
|
|
140
|
+
m = re.search(r"<title>(.*?)</title>", (deck / "deck.html").read_text(errors="replace"), re.S)
|
|
141
|
+
return (m.group(1).strip() if m else "") or deck.name
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
HOME = Path(os.environ.get("GLIDESHOW_HOME") or Path.home() / "decks").expanduser()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def walk_library():
|
|
148
|
+
"""(decks, folders) under $GLIDESHOW_HOME: a folder holding deck.html is a deck (not looked inside); any other folder
|
|
149
|
+
groups decks and may nest. Hidden folders are skipped."""
|
|
150
|
+
decks, folders = [], []
|
|
151
|
+
def visit(d: Path, depth: int):
|
|
152
|
+
for c in sorted(d.iterdir()) if d.is_dir() else []:
|
|
153
|
+
if not c.is_dir() or c.name.startswith(".") or c.name == "assets": continue # assets/ outside a deck is a leftover, not a folder
|
|
154
|
+
|
|
155
|
+
if (c / "deck.html").exists(): decks.append(c)
|
|
156
|
+
elif depth < 6: folders.append(c); visit(c, depth + 1)
|
|
157
|
+
visit(HOME, 0)
|
|
158
|
+
return decks, folders
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def folder_of(d: Path) -> str | None:
|
|
162
|
+
"""The deck's folder in the library ('' at the top, 'Lab/2026' nested), or None for a deck outside it."""
|
|
163
|
+
try: rel = d.parent.resolve().relative_to(HOME.resolve())
|
|
164
|
+
except ValueError: return None
|
|
165
|
+
return "" if rel == Path(".") else rel.as_posix()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def deck_id(d: Path) -> str:
|
|
170
|
+
return hashlib.sha1(str(d.resolve()).encode()).hexdigest()[:12] # stable, unique even when two folders share a name
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def all_decks(current: Path) -> list:
|
|
174
|
+
"""The current deck, recent decks and every deck in $GLIDESHOW_HOME (in any folder), newest edit first."""
|
|
175
|
+
seen, out = set(), []
|
|
176
|
+
for d in [current, *map(Path, recent_decks()), *walk_library()[0]]:
|
|
177
|
+
d = d.resolve()
|
|
178
|
+
if d in seen or not (d / "deck.html").exists() or d.name.startswith(".") or is_draft(d):
|
|
179
|
+
|
|
180
|
+
continue
|
|
181
|
+
seen.add(d)
|
|
182
|
+
out.append({"path": str(d), "id": deck_id(d), "name": d.name, "folder": folder_of(d), "title": deck_title(d), "shown": shown_title(d), "date": deck_date(d),
|
|
183
|
+
|
|
184
|
+
"mtime": (d / "deck.html").stat().st_mtime, "current": d == current})
|
|
185
|
+
return sorted(out, key=lambda x: -x["mtime"])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def shown_title(d: Path) -> str:
|
|
189
|
+
"""What the home screen calls a deck: its title, or for a generic one ('Untitled deck', 'Untitled presentation')
|
|
190
|
+
the first line of text on its first slide ('' if it has none either)."""
|
|
191
|
+
t = deck_title(d)
|
|
192
|
+
if not re.fullmatch(r"\s*untitled( deck| presentation)?\s*", t, re.I): return t
|
|
193
|
+
html = (d / "deck.html").read_text(errors="replace")
|
|
194
|
+
first = html[html.find("<section"):][:200000]
|
|
195
|
+
first = first[:first.find("</section>")] if "</section>" in first else first
|
|
196
|
+
for m in re.finditer(r'<div class="box(?![^"]*\bnote\b)[^"]*"[^>]*>\s*<div class="content"[^>]*>(.*?)</div>\s*</div>', first, re.S):
|
|
197
|
+
lines = [re.sub(r"\s+", " ", l).strip() for l in re.sub(r"<[^>]+>", " ", re.sub(r"<(br|/div|/p|/li)[^>]*>", "\n", m.group(1))).split("\n")]
|
|
198
|
+
if line := next((l for l in lines if l), ""): return html_unescape(line)[:80]
|
|
199
|
+
|
|
200
|
+
return ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def html_unescape(s: str) -> str:
|
|
204
|
+
import html
|
|
205
|
+
return html.unescape(s)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def library_folders() -> list:
|
|
209
|
+
|
|
210
|
+
"""Every folder in the library as a path relative to $GLIDESHOW_HOME ('Lab', 'Lab/2026')."""
|
|
211
|
+
return [f.resolve().relative_to(HOME.resolve()).as_posix() for f in walk_library()[1]]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def in_library(rel: str) -> Path:
|
|
215
|
+
"""A folder path relative to $GLIDESHOW_HOME, refusing anything that would leave it."""
|
|
216
|
+
p = (HOME / (rel or "")).resolve()
|
|
217
|
+
if p != HOME.resolve() and HOME.resolve() not in p.parents: raise ValueError(f"{rel!r} is outside the library")
|
|
218
|
+
return p
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def forget(deck: Path) -> None:
|
|
222
|
+
RECENT.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
RECENT.write_text(json.dumps([p for p in recent_decks() if Path(p).resolve() != deck.resolve()]))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def set_title(deck: Path, title: str) -> None:
|
|
228
|
+
import html as _html
|
|
229
|
+
f = deck / "deck.html"
|
|
230
|
+
f.write_text(re.sub(r"<title>.*?</title>", f"<title>{_html.escape(title)}</title>", f.read_text(), count=1, flags=re.S))
|