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/history.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Version history in git: saves are committed (debounced) with a message naming what changed, pushed to origin when
|
|
2
|
+
there is one (the backup), listed for File → Version history."""
|
|
3
|
+
|
|
4
|
+
import os, shutil, re, sys, threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# History: when the deck lives in a git repo, saves are committed (deck.html + assets/), at most one commit per
|
|
8
|
+
# GLIDESHOW_GIT_DELAY seconds of quiet (30 by default), with a message naming the first changed slide and its new words.
|
|
9
|
+
GIT = {"on": True, "push": True, "delay": float(os.environ.get("GLIDESHOW_GIT_DELAY") or 30), "timer": None, "lock": __import__("threading").Lock()}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def git(deck: Path, *args):
|
|
13
|
+
import subprocess
|
|
14
|
+
return subprocess.run(["git", "-C", str(deck), *args], capture_output=True, text=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def commit_message(old: str, new: str) -> str:
|
|
18
|
+
if not old:
|
|
19
|
+
m = re.search(r"<title>(.*?)</title>", new, re.S)
|
|
20
|
+
return f"deck: {(m.group(1).strip() if m else '') or 'first save'}"[:72]
|
|
21
|
+
split = lambda h: [x.split(">", 1)[-1] for x in re.split(r"<section\b", h.split('class="slides"', 1)[-1])[1:]] # each slide's inside
|
|
22
|
+
words = lambda h: re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", re.sub(r"<(svg|script|style)\b.*?</\1>", " ", h, flags=re.S))).split()
|
|
23
|
+
a, b = split(old), split(new)
|
|
24
|
+
# which slides changed, came or went, matched by content (a slide inserted early doesn't make every later one "changed")
|
|
25
|
+
import difflib
|
|
26
|
+
changed, added, removed, first, gone_at = [], [], 0, None, None
|
|
27
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=a, b=b, autojunk=False).get_opcodes():
|
|
28
|
+
if tag == "equal": continue
|
|
29
|
+
pairs = min(i2 - i1, j2 - j1) if tag == "replace" else 0
|
|
30
|
+
for k in range(pairs):
|
|
31
|
+
changed.append(j1 + k); first = first if first is not None else (i1 + k, j1 + k)
|
|
32
|
+
added += range(j1 + pairs, j2); removed += (i2 - i1) - pairs
|
|
33
|
+
if (i2 - i1) > pairs and gone_at is None: gone_at = i1 + pairs
|
|
34
|
+
if first is None and j2 > j1 + pairs: first = (None, j1 + pairs)
|
|
35
|
+
if first is None: return f"slide {gone_at + 1} removed" + (f" (and {removed - 1} more)" if removed > 1 else "") if removed else "deck: edit"
|
|
36
|
+
before, after = words(a[first[0]]) if first[0] is not None else [], words(b[first[1]])
|
|
37
|
+
fresh = " ".join(([w for w in after if w not in before] or after)[:6]) or "edit"
|
|
38
|
+
if len(changed) == 1 and not added and not removed: return f"slide {changed[0] + 1}: {fresh}"[:72]
|
|
39
|
+
nums = lambda xs: ", ".join(str(x + 1) for x in xs[:4]) + (" …" if len(xs) > 4 else "")
|
|
40
|
+
parts = ([f"slide{'s' if len(changed) > 1 else ''} {nums(changed)}"] if changed else []) + ([f"new slide{'s' if len(added) > 1 else ''} {nums(added)}"] if added else []) \
|
|
41
|
+
+ ([f"{removed} removed"] if removed else [])
|
|
42
|
+
return f"{' + '.join(parts)}: {fresh}"[:72]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def commit_outside(deck: Path, saved: str):
|
|
46
|
+
"""deck.html was changed outside the editor. The editor's last save, if still waiting for its commit, is committed first
|
|
47
|
+
(from memory, as it was), so each side's change is its own version; the outside edit is committed once the file has
|
|
48
|
+
been quiet for GLIDESHOW_OUTSIDE_DELAY seconds (3), so an agent's run of writes makes one version, not dozens."""
|
|
49
|
+
if GIT["timer"] and GIT["timer"].is_alive():
|
|
50
|
+
GIT["timer"].cancel(); GIT["timer"] = None
|
|
51
|
+
commit_text(deck, saved)
|
|
52
|
+
if GIT.get("outside"): GIT["outside"].cancel()
|
|
53
|
+
GIT["outside"] = threading.Timer(float(os.environ.get("GLIDESHOW_OUTSIDE_DELAY") or 3), commit_now, [deck, "outside the editor: "]); GIT["outside"].daemon = True; GIT["outside"].start()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def commit_text(deck: Path, html: str):
|
|
57
|
+
"""Commit this text as deck.html without touching the file on disk (git's index, not the working tree)."""
|
|
58
|
+
import subprocess
|
|
59
|
+
with GIT["lock"]:
|
|
60
|
+
if git(deck, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true": return
|
|
61
|
+
top = Path(git(deck, "rev-parse", "--show-toplevel").stdout.strip())
|
|
62
|
+
rel = (deck / "deck.html").resolve().relative_to(top.resolve()).as_posix()
|
|
63
|
+
sha = subprocess.run(["git", "-C", str(deck), "hash-object", "-w", "--stdin"], input=html, capture_output=True, text=True).stdout.strip()
|
|
64
|
+
if not sha: return
|
|
65
|
+
git(top, "update-index", "--add", "--cacheinfo", f"100644,{sha},{rel}")
|
|
66
|
+
git(top, "commit", "-q", "-m", commit_message(git(deck, "show", f"HEAD:{rel}").stdout, html))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def commit_now(deck: Path, label: str = ""):
|
|
70
|
+
with GIT["lock"]:
|
|
71
|
+
if git(deck, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true":
|
|
72
|
+
return
|
|
73
|
+
top = Path(git(deck, "rev-parse", "--show-toplevel").stdout.strip())
|
|
74
|
+
rel = (deck / "deck.html").resolve().relative_to(top.resolve()).as_posix()
|
|
75
|
+
old = git(deck, "show", f"HEAD:{rel}").stdout
|
|
76
|
+
paths = ["deck.html"] + (["assets"] if any(f.is_file() for f in (deck / "assets").rglob("*")) else []) + (["comments.json"] if (deck / "comments.json").exists() else []) # git refuses an empty pathspec
|
|
77
|
+
git(deck, "add", "--", *paths)
|
|
78
|
+
made = git(deck, "commit", "-q", "-m", (label + commit_message(old, (deck / "deck.html").read_text()))[:72], "--", *paths).returncode == 0
|
|
79
|
+
if made: sync(deck) # backup: the commit goes to origin too
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def commit_library(home: Path, message: str):
|
|
84
|
+
"""A library change (a deck moved, a folder made or removed): commit everything under $GLIDESHOW_HOME, then push."""
|
|
85
|
+
if not GIT["on"] or git(home, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true":
|
|
86
|
+
return
|
|
87
|
+
with GIT["lock"]:
|
|
88
|
+
git(home, "add", "-A", "--", ".")
|
|
89
|
+
made = git(home, "commit", "-q", "-m", message[:72]).returncode == 0
|
|
90
|
+
if made: sync(home)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def commit_outside_library(home: Path, skip: Path | None = None) -> bool:
|
|
94
|
+
"""What changed in the library outside the editor (an agent editing a deck that isn't open, a new deck folder, a
|
|
95
|
+
file deleted by hand): one commit, so syncing, which waits for a clean tree, never stalls on it. Files over 50 MB
|
|
96
|
+
are left out (GitHub refuses them, which would stall the push instead). `skip` is the open deck's deck.html: its
|
|
97
|
+
outside edits are commit_outside's, named by slide, even when this runs in the second before that one notices."""
|
|
98
|
+
if not GIT["on"] or git(home, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true": return False
|
|
99
|
+
with GIT["lock"]:
|
|
100
|
+
paths = [line[3:].split(" -> ")[-1].strip('"') for line in git(home, "status", "--porcelain").stdout.splitlines()]
|
|
101
|
+
big = [p for p in paths if (home / p).is_file() and (home / p).stat().st_size > 50_000_000]
|
|
102
|
+
paths = [p for p in paths if p not in big and Path(p).name not in (".DS_Store", "Thumbs.db")] # Finder's / Explorer's litter
|
|
103
|
+
try: mine = skip.resolve().relative_to(home.resolve()).as_posix() if skip else None
|
|
104
|
+
except ValueError: mine = None
|
|
105
|
+
paths = [p for p in paths if p != mine]
|
|
106
|
+
if big: print(f"git: not committing files over 50 MB: {', '.join(big[:3])}")
|
|
107
|
+
if not paths: return False
|
|
108
|
+
deck_of = lambda p: next((q.as_posix() for q in [Path(p), *Path(p).parents] if q != Path(".") and (home / q / "deck.html").is_file()), Path(p).parts[0])
|
|
109
|
+
names = sorted({deck_of(p) for p in paths})
|
|
110
|
+
git(home, "add", "-A", "--", *paths)
|
|
111
|
+
made = git(home, "commit", "-q", "-m", ("outside the editor: " + ", ".join(names[:3]) + (" …" if len(names) > 3 else ""))[:72]).returncode == 0
|
|
112
|
+
return made
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prune_emptied(repo: Path, before: str):
|
|
116
|
+
"""Folders that commits brought in from elsewhere emptied (a deck removed online): git takes away the files but leaves
|
|
117
|
+
the folders, with any empty ones inside (a deck's assets/), and Home would show them as empty folders. Only folders
|
|
118
|
+
the pulled commits removed files from, and only once nothing is left in them but empty folders."""
|
|
119
|
+
gone = git(repo, "diff", "--name-only", "--diff-filter=D", before, "HEAD").stdout.split("\n")
|
|
120
|
+
for d in sorted({(repo / f).parent for f in gone if f}, key=lambda p: -len(p.parts)):
|
|
121
|
+
while d != repo and repo in d.parents and d.is_dir() and not any(f.is_file() or f.is_symlink() for f in d.rglob("*")):
|
|
122
|
+
shutil.rmtree(d, ignore_errors=True); d = d.parent
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# A deck.html both sides changed merges slide by slide and box by box (glideshow/merge.py, the rules of the editor and of
|
|
126
|
+
# glideshow.org), not line by line: unrelated edits sit on nearby lines of a deck, and a line merge where this Mac wins
|
|
127
|
+
# could drop a web edit. In a rebase the Mac's commit being replayed is git's "theirs" (%B), so the driver is told it is
|
|
128
|
+
# mine. A deck it can't read as slides is left to git's line merge.
|
|
129
|
+
DECK_MERGE = ("-c", "merge.glideshow-deck.name=Glideshow deck merge", "-c", f'merge.glideshow-deck.driver="{sys.executable}" "{Path(__file__).with_name("merge.py")}" %O %A %B theirs') # (by its path: it needs nothing else)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def deck_merge_attributes(repo: Path):
|
|
133
|
+
"""deck.html files use the deck merge (in .git/info/attributes: nothing for the library to commit)."""
|
|
134
|
+
p = Path(repo) / git(repo, "rev-parse", "--git-path", "info/attributes").stdout.strip()
|
|
135
|
+
line = "deck.html merge=glideshow-deck"
|
|
136
|
+
if not p.exists() or line not in p.read_text().splitlines():
|
|
137
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
with p.open("a") as f: f.write(line + "\n")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def sync(repo: Path) -> str:
|
|
142
|
+
"""Trade commits with origin: bring in what was committed elsewhere (the online editor, docs/self-hosting.md) under the
|
|
143
|
+
local commits, then push those. Only with nothing uncommitted (a save waiting for its commit, or a hand edit), so a
|
|
144
|
+
pull never writes into a file being worked on; the next try comes after the commit. Returns what happened."""
|
|
145
|
+
if not GIT["push"] or git(repo, "remote", "get-url", "origin").returncode: return "no origin"
|
|
146
|
+
with GIT["lock"]:
|
|
147
|
+
if git(repo, "status", "--porcelain", "--untracked-files=no").stdout.strip(): return "uncommitted changes"
|
|
148
|
+
deck_merge_attributes(repo)
|
|
149
|
+
branch = git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
150
|
+
if git(repo, "fetch", "-q", "origin", branch).returncode: return "offline"
|
|
151
|
+
behind, ahead = (int(n) for n in git(repo, "rev-list", "--left-right", "--count", "FETCH_HEAD...HEAD").stdout.split() or (0, 0))
|
|
152
|
+
before = git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
153
|
+
if behind:
|
|
154
|
+
if git(repo, *DECK_MERGE, "rebase", "-q", "FETCH_HEAD").returncode:
|
|
155
|
+
# The same lines changed in both places: this Mac's version wins where they overlap (the other one stays in
|
|
156
|
+
# the history), so the two never stay apart, which would stop the backup.
|
|
157
|
+
git(repo, "rebase", "--abort")
|
|
158
|
+
if git(repo, *DECK_MERGE, "rebase", "-q", "-X", "theirs", "FETCH_HEAD").returncode:
|
|
159
|
+
git(repo, "rebase", "--abort"); print("git: couldn't bring in commits from origin; local commits kept"); return "conflict"
|
|
160
|
+
print("git: an edit made elsewhere overlapped this Mac's; this Mac's version was kept (the other is in the history)")
|
|
161
|
+
prune_emptied(repo, before)
|
|
162
|
+
if ahead:
|
|
163
|
+
r = git(repo, "push", "-q", "origin", f"HEAD:{branch}")
|
|
164
|
+
if r.returncode: print(f"git push failed (kept locally): {r.stderr.strip()[:200]}"); return "push failed"
|
|
165
|
+
return f"in {behind}, out {ahead}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def schedule_commit(deck: Path):
|
|
169
|
+
|
|
170
|
+
if not GIT["on"]:
|
|
171
|
+
return
|
|
172
|
+
if GIT["timer"]: GIT["timer"].cancel()
|
|
173
|
+
GIT["timer"] = threading.Timer(GIT["delay"], commit_now, [deck]); GIT["timer"].daemon = True; GIT["timer"].start()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def history(deck: Path, slide=None, limit=100) -> list:
|
|
177
|
+
if git(deck, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true":
|
|
178
|
+
return []
|
|
179
|
+
out = []
|
|
180
|
+
for line in git(deck, "log", f"-{limit}", "--format=%H%x09%ct%x09%an%x09%s", "--", "deck.html").stdout.splitlines():
|
|
181
|
+
h, t, who, msg = line.split("\t", 3)
|
|
182
|
+
out.append({"rev": h, "time": int(t), "author": who, "message": msg})
|
|
183
|
+
names = {} # named versions: annotated tags under refs/tags/slides/
|
|
184
|
+
for line in git(deck, "for-each-ref", "refs/tags/slides", "--format=%(*objectname)%09%(contents:subject)").stdout.splitlines():
|
|
185
|
+
rev, _, name = line.partition("\t")
|
|
186
|
+
if rev: names[rev] = name
|
|
187
|
+
for c in out: c["name"] = names.get(c["rev"], "")
|
|
188
|
+
web, pushed = github_url(deck), set(git(deck, "rev-list", "-n", "1000", "@{upstream}").stdout.split())
|
|
189
|
+
for c in out: c["url"] = f"{web}/commit/{c['rev']}" if web and c["rev"] in pushed else "" # only commits already on GitHub
|
|
190
|
+
if slide: # keep the commits where slide N's <section> differs from the commit before it
|
|
191
|
+
sec = lambda h: (re.split(r"<section\b", h.split('class="slides"', 1)[-1])[1:] + [""] * slide)[slide - 1]
|
|
192
|
+
texts = [git(deck, "show", f"{c['rev']}:./deck.html").stdout for c in out] + [""]
|
|
193
|
+
out = [c for i, c in enumerate(out) if sec(texts[i]) != sec(texts[i + 1])]
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def github_url(deck: Path) -> str:
|
|
198
|
+
"""https://github.com/OWNER/REPO for the repo's origin when it is on GitHub (ssh or https remote), else ''."""
|
|
199
|
+
m = re.match(r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?/?$", git(deck, "remote", "get-url", "origin").stdout.strip())
|
|
200
|
+
return f"https://github.com/{m.group(1)}" if m else ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def init_repo(deck: Path):
|
|
204
|
+
|
|
205
|
+
if git(deck, "rev-parse", "--is-inside-work-tree").stdout.strip() == "true":
|
|
206
|
+
return print(f"{deck} is already in a git repo")
|
|
207
|
+
git(deck, "init", "-q")
|
|
208
|
+
(deck / ".gitignore").write_text(".DS_Store\n*.tmp\n")
|
|
209
|
+
git(deck, "add", "-A"); git(deck, "commit", "-q", "-m", "deck: start")
|
|
210
|
+
print(f"{deck} is now a git repo; saves are committed")
|
glideshow/merge.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Three-way merges of deck files, by slide and by box: editor/lib/merge.js in Python, rule for rule (smoke checks that
|
|
2
|
+
the two agree), for the Mac's side of a library online: history.sync pulls glideshow.org's commits under the Mac's own
|
|
3
|
+
with this as git's merge of deck.html, where a line merge would mix unrelated edits that sit on nearby lines.
|
|
4
|
+
|
|
5
|
+
python glideshow/merge.py BASE OURS THEIRS [MINE] # git's merge driver (%O %A %B): the result is written to OURS
|
|
6
|
+
|
|
7
|
+
MINE names which of OURS / THEIRS keeps a box (or slide) both changed: 'theirs' in a rebase, where THEIRS is this Mac's
|
|
8
|
+
commit being replayed on top of what came in. A file that can't be read as slides gets git's line merge, MINE winning
|
|
9
|
+
where lines overlap."""
|
|
10
|
+
import re, sys
|
|
11
|
+
|
|
12
|
+
_ID = re.compile(r'\sid="[^"]*"')
|
|
13
|
+
norm = lambda h: _ID.sub("", h)
|
|
14
|
+
same_html = lambda a, b: a == b or norm(a) == norm(b)
|
|
15
|
+
_SID = re.compile(r'^<section\b[^>]*\sdata-sid="([^"]+)"')
|
|
16
|
+
sid_of = lambda h: (m.group(1) if (m := _SID.match(h)) else "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def merge_lists(base, mine, theirs, key, both=lambda b, m, t: m, val=lambda x: x):
|
|
20
|
+
"""Keyed lists, three ways (merge.js mergeLists): (list, clash, order)."""
|
|
21
|
+
same = lambda a, b: a is not None and b is not None and same_html(val(a), val(b))
|
|
22
|
+
B, M, T = ({key(h): h for h in l} for l in (base, mine, theirs))
|
|
23
|
+
out, clash = {}, []
|
|
24
|
+
for i in dict.fromkeys([*M, *T, *B]):
|
|
25
|
+
b, m, t = B.get(i), M.get(i), T.get(i)
|
|
26
|
+
if m is not None and t is not None:
|
|
27
|
+
if same(m, b): out[i] = t
|
|
28
|
+
elif same(t, b) or same(t, m): out[i] = m
|
|
29
|
+
else: out[i] = both(b, m, t); clash.append(i)
|
|
30
|
+
elif m is not None:
|
|
31
|
+
if b is None or not same(m, b): out[i] = m
|
|
32
|
+
elif t is not None:
|
|
33
|
+
if b is None or not same(t, b): out[i] = t
|
|
34
|
+
ids = lambda l: [key(h) for h in l]
|
|
35
|
+
moved = [i for i in ids(mine) if i in B] != [i for i in ids(base) if i in M]
|
|
36
|
+
lead, other = (mine, theirs) if moved else (theirs, mine)
|
|
37
|
+
order = [i for i in ids(lead) if i in out]
|
|
38
|
+
oids = ids(other)
|
|
39
|
+
for n, i in enumerate(oids):
|
|
40
|
+
if i not in out or i in order: continue
|
|
41
|
+
prev = next((x for x in reversed(oids[:n]) if x in order), None)
|
|
42
|
+
order.insert(order.index(prev) + 1 if prev is not None else 0, i)
|
|
43
|
+
order += [i for i in out if i not in order]
|
|
44
|
+
return [out[i] for i in order], [i for i in clash if i in order], order
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}
|
|
48
|
+
TOKEN = re.compile(r"""<!--[\s\S]*?-->|<(script|style|textarea|title|xmp|noscript|template)\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?</\1\s*>|<(/?)([a-zA-Z][\w:-]*)\b(?:[^>"']|"[^"]*"|'[^']*')*?(/?)>""")
|
|
49
|
+
OPEN = re.compile(r"""^\s*<([a-zA-Z][\w-]*)\b(?:[^>"']|"[^"]*"|'[^']*')*>""")
|
|
50
|
+
CLOSE = re.compile(r"</\s*([a-zA-Z][\w-]*)\s*>\s*$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parts(html):
|
|
54
|
+
"""An element's opening tag, top-level children (each whole) and closing tag; None when the tags don't pair up."""
|
|
55
|
+
o, c = OPEN.match(html), CLOSE.search(html)
|
|
56
|
+
if not o or not c or c.group(1).lower() != o.group(1).lower(): return None
|
|
57
|
+
body, kids = html[len(o.group(0)):len(html) - len(c.group(0))], []
|
|
58
|
+
depth, start, last = 0, -1, 0
|
|
59
|
+
def text(a, z):
|
|
60
|
+
t = body[a:z].strip()
|
|
61
|
+
if t: kids.append(t)
|
|
62
|
+
for m in TOKEN.finditer(body):
|
|
63
|
+
at, end = m.start(), m.end()
|
|
64
|
+
whole = m.group(0).startswith("<!--") or m.group(1) or (not m.group(2) and (m.group(4) or m.group(3).lower() in VOID))
|
|
65
|
+
if depth == 0: text(last, at)
|
|
66
|
+
if whole:
|
|
67
|
+
if depth == 0: kids.append(m.group(0)); last = end
|
|
68
|
+
continue
|
|
69
|
+
if m.group(2):
|
|
70
|
+
depth -= 1
|
|
71
|
+
if depth < 0: return None
|
|
72
|
+
if depth == 0: kids.append(body[start:end]); last = end
|
|
73
|
+
else:
|
|
74
|
+
if depth == 0: start = at
|
|
75
|
+
depth += 1
|
|
76
|
+
if depth: return None
|
|
77
|
+
text(last, len(body))
|
|
78
|
+
return {"open": o.group(0).strip(), "kids": kids, "close": c.group(0).strip()}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_KID_ID = re.compile(r"""^<[a-zA-Z][\w-]*\b(?:[^>"']|"[^"]*"|'[^']*')*?\sid="([^"]+)\"""")
|
|
82
|
+
_KID_TAG = re.compile(r"""^<([a-zA-Z][\w-]*)\b(?:[^>"']|"[^"]*"|'[^']*')*?(?:\sclass="([^"]*)")?""")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def keyed(kids):
|
|
86
|
+
seen, out = {}, []
|
|
87
|
+
for h in kids:
|
|
88
|
+
if m := _KID_ID.match(h): out.append(("id:" + m.group(1), h)); continue
|
|
89
|
+
t = _KID_TAG.match(h)
|
|
90
|
+
k = (t.group(1).lower() + "." + (t.group(2) or "")) if t and t.group(1) else "#text"
|
|
91
|
+
n = seen.get(k, 0); seen[k] = n + 1; out.append((f"{k}#{n}", h))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def merge_boxes(b, m, t, note=None):
|
|
96
|
+
"""One slide both sides changed, box by box (merge.js mergeBoxes). note['lost']: something of theirs gave way."""
|
|
97
|
+
note = note if note is not None else {}
|
|
98
|
+
note["lost"] = True
|
|
99
|
+
B, M, T = (parts(b) if b is not None else None), parts(m), parts(t)
|
|
100
|
+
if not B or not M or not T or re.search(r"<section\b", "".join(B["kids"] + M["kids"] + T["kids"]), re.I): return m
|
|
101
|
+
bk = keyed(B["kids"]); base = dict(bk)
|
|
102
|
+
def match(side):
|
|
103
|
+
out = keyed(side["kids"]); have = {k for k, _ in out}; free = [x for x in bk if x[0] not in have]; unmatched = 0; res = []
|
|
104
|
+
for k, h in out:
|
|
105
|
+
if k in base: res.append((k, h)); continue
|
|
106
|
+
i = next((j for j, (_, bh) in enumerate(free) if norm(bh) == norm(h)), -1)
|
|
107
|
+
if i >= 0: res.append((free.pop(i)[0], h)); continue
|
|
108
|
+
unmatched += 1; res.append((k, h))
|
|
109
|
+
got = {k for k, _ in res}
|
|
110
|
+
return res, len(bk) > 1 and sum(k not in got for k, _ in bk) > len(bk) / 2 and unmatched > 0
|
|
111
|
+
mm, ms = match(M); tt, ts = match(T)
|
|
112
|
+
if ms or ts: return m
|
|
113
|
+
lst, clash, _ = merge_lists(bk, mm, tt, lambda x: x[0], val=lambda x: x[1])
|
|
114
|
+
same = lambda a, b: same_html(a, b)
|
|
115
|
+
open_ = T["open"] if same(M["open"], B["open"]) else M["open"]
|
|
116
|
+
note["lost"] = bool(clash) or (not same(M["open"], B["open"]) and not same(T["open"], B["open"]) and not same(M["open"], T["open"]))
|
|
117
|
+
return f"{open_}\n" + "\n".join(" " + h for _, h in lst) + f"\n{M['close']}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_SLIDE = re.compile(r"""<!--[\s\S]*?-->|<(script|style|textarea|title|xmp|noscript|template)\b[\s\S]*?</\1\s*>|<section\b(?:[^>"']|"[^"]*"|'[^']*')*>|</section\s*>""", re.I)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def split_deck(html):
|
|
124
|
+
slides, depth, start, first, end = [], 0, -1, -1, -1
|
|
125
|
+
for m in _SLIDE.finditer(html):
|
|
126
|
+
if m.group(1) or m.group(0).startswith("<!--"): continue
|
|
127
|
+
if m.group(0)[1] == "/":
|
|
128
|
+
depth -= 1
|
|
129
|
+
if depth < 0: return None
|
|
130
|
+
if not depth: slides.append(html[start:m.end()]); end = m.end()
|
|
131
|
+
else:
|
|
132
|
+
if not depth:
|
|
133
|
+
start = m.start()
|
|
134
|
+
if first < 0: first = start
|
|
135
|
+
depth += 1
|
|
136
|
+
if depth or first < 0: return None
|
|
137
|
+
return {"head": html[:first], "slides": slides, "tail": html[end:]}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _hash(t): # merge.js hashStr (FNV-1a, 32-bit, base 36)
|
|
141
|
+
h = 2166136261
|
|
142
|
+
for ch in t:
|
|
143
|
+
h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
|
|
144
|
+
out, digits = "", "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
145
|
+
while True:
|
|
146
|
+
h, r = divmod(h, 36); out = digits[r] + out
|
|
147
|
+
if not h: return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_INNER_SID = re.compile(r'<section\b[^>]*\sdata-sid="([^"]+)"')
|
|
151
|
+
def _print(h): return re.sub(r"\s+", " ", re.sub(r">\s+<", "><", re.sub(r'\sdata-sid="[^"]*"', "", norm(h)))).strip()
|
|
152
|
+
def _own_key(h):
|
|
153
|
+
if s := sid_of(h): return s
|
|
154
|
+
m = _INNER_SID.search(h); return "stack:" + m.group(1) if m else ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def slide_keys(lists):
|
|
158
|
+
"""merge.js slideKeys: ids, or content (matched to an id only when unambiguous)."""
|
|
159
|
+
owners, loose = {}, [dict() for _ in lists]
|
|
160
|
+
for i, l in enumerate(lists):
|
|
161
|
+
for h in l:
|
|
162
|
+
k, f = _own_key(h), _print(h)
|
|
163
|
+
if k: owners.setdefault(f, set()).add(k)
|
|
164
|
+
else: loose[i][f] = loose[i].get(f, 0) + 1
|
|
165
|
+
sure = lambda f: next(iter(owners[f])) if len(owners.get(f, ())) == 1 and all(m.get(f, 0) <= 1 for m in loose) else ""
|
|
166
|
+
out = []
|
|
167
|
+
for l in lists:
|
|
168
|
+
seen, row = {}, []
|
|
169
|
+
for h in l:
|
|
170
|
+
if k := _own_key(h): row.append((k, h)); continue
|
|
171
|
+
f = _print(h)
|
|
172
|
+
if s := sure(f): row.append((s, h)); continue
|
|
173
|
+
n = seen.get(f, 0); seen[f] = n + 1; row.append((f"c:{_hash(f)}:{n}", h))
|
|
174
|
+
out.append(row)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def merge_decks(base, mine, theirs):
|
|
179
|
+
"""Whole deck files (merge.js mergeDecks): {'html', 'both'}, or None when one can't be read as slides."""
|
|
180
|
+
B, M, T = split_deck(base) if base else None, split_deck(mine), split_deck(theirs)
|
|
181
|
+
if not M or not T or (base and not B): return None
|
|
182
|
+
kb, km, kt = slide_keys([B["slides"] if B else [], M["slides"], T["slides"]])
|
|
183
|
+
lost = []
|
|
184
|
+
def both(b, m, t):
|
|
185
|
+
n = {"lost": True}
|
|
186
|
+
h = merge_boxes(b[1] if b else None, m[1], t[1], n) if sid_of(m[1]) else m[1]
|
|
187
|
+
if n["lost"]: lost.append(m[0])
|
|
188
|
+
return (m[0], h)
|
|
189
|
+
lst, _, order = merge_lists(kb, km, kt, lambda x: x[0], both, lambda x: x[1])
|
|
190
|
+
third = lambda b, m, t: t if (b is not None and same_html(m, b)) else m
|
|
191
|
+
html = third(B["head"] if B else None, M["head"], T["head"]) + "\n\n".join(h for _, h in lst) + third(B["tail"] if B else None, M["tail"], T["tail"])
|
|
192
|
+
return {"html": html, "both": [order.index(i) + 1 for i in lost if i in order]}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv):
|
|
196
|
+
base, ours, theirs = argv[1:4]
|
|
197
|
+
mine = argv[4] if len(argv) > 4 else "ours"
|
|
198
|
+
read = lambda p: open(p, encoding="utf-8").read()
|
|
199
|
+
o, a, b = read(base), read(ours), read(theirs)
|
|
200
|
+
r = merge_decks(o, b, a) if mine == "theirs" else merge_decks(o, a, b)
|
|
201
|
+
if r is None: # not slides: git's line merge, mine winning where lines overlap (as history.sync did before)
|
|
202
|
+
import subprocess
|
|
203
|
+
return 1 if subprocess.run(["git", "merge-file", "-q", "--theirs" if mine == "theirs" else "--ours", ours, base, theirs]).returncode < 0 else 0
|
|
204
|
+
with open(ours, "w", encoding="utf-8") as f: f.write(r["html"])
|
|
205
|
+
if r["both"]: print(f"glideshow: deck merged; slide{'s' if len(r['both']) > 1 else ''} {', '.join(map(str, r['both']))} changed on both sides, this Mac's version kept", file=sys.stderr)
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main(sys.argv))
|
glideshow/osutil.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What differs by operating system: the Trash, showing a file in the file manager, and where fonts are installed."""
|
|
2
|
+
|
|
3
|
+
import datetime, os, shutil, subprocess, sys, time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def trash(p: Path) -> Path:
|
|
9
|
+
"""Move a deck folder to the Trash (macOS), the freedesktop trash (Linux) or, elsewhere, $GLIDESHOW_HOME/.trash.
|
|
10
|
+
GLIDESHOW_TRASH overrides (tests use a temp dir)."""
|
|
11
|
+
stamp = f"{p.name}-{int(time.time())}"
|
|
12
|
+
if os.environ.get("GLIDESHOW_TRASH"):
|
|
13
|
+
d = Path(os.environ["GLIDESHOW_TRASH"])
|
|
14
|
+
elif sys.platform == "darwin":
|
|
15
|
+
d = Path.home() / ".Trash"
|
|
16
|
+
elif sys.platform.startswith("linux"):
|
|
17
|
+
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "Trash"
|
|
18
|
+
(base / "info").mkdir(parents=True, exist_ok=True); (base / "files").mkdir(parents=True, exist_ok=True)
|
|
19
|
+
(base / "info" / f"{stamp}.trashinfo").write_text(
|
|
20
|
+
f"[Trash Info]\nPath={quote(str(p.resolve()))}\nDeletionDate={datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')}\n")
|
|
21
|
+
d = base / "files"
|
|
22
|
+
else:
|
|
23
|
+
d = p.parent / ".trash"
|
|
24
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
dst = d / stamp
|
|
26
|
+
shutil.move(str(p), dst)
|
|
27
|
+
return dst
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reveal(p: Path) -> None:
|
|
31
|
+
"""Show the file in Finder / Explorer / the default file manager."""
|
|
32
|
+
if sys.platform == "darwin": subprocess.run(["open", "-R", str(p)], check=False)
|
|
33
|
+
elif sys.platform == "win32": subprocess.run(["explorer", "/select,", str(p)], check=False)
|
|
34
|
+
else: subprocess.run(["xdg-open", str(p.parent)], check=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def font_dirs() -> list:
|
|
38
|
+
"""Folders holding fonts installed on this computer, for the font menu's "Your fonts"."""
|
|
39
|
+
home = Path.home()
|
|
40
|
+
if sys.platform == "darwin":
|
|
41
|
+
return [home / "Library" / "Fonts", Path("/Library/Fonts")]
|
|
42
|
+
if sys.platform == "win32":
|
|
43
|
+
return [Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts", Path(os.environ.get("LOCALAPPDATA", home)) / "Microsoft" / "Windows" / "Fonts"]
|
|
44
|
+
return [home / ".local" / "share" / "fonts", home / ".fonts", Path("/usr/share/fonts"), Path("/usr/local/share/fonts")]
|