slidesync 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {slidesync-0.4.0/slidesync.egg-info → slidesync-0.5.0}/PKG-INFO +14 -6
- {slidesync-0.4.0 → slidesync-0.5.0}/README.md +13 -5
- {slidesync-0.4.0 → slidesync-0.5.0}/pyproject.toml +2 -2
- {slidesync-0.4.0 → slidesync-0.5.0}/slidesync/__init__.py +1 -1
- {slidesync-0.4.0 → slidesync-0.5.0}/slidesync/_sync.py +148 -37
- {slidesync-0.4.0 → slidesync-0.5.0/slidesync.egg-info}/PKG-INFO +14 -6
- {slidesync-0.4.0 → slidesync-0.5.0}/tests/test_e2e_scenarios.py +131 -2
- {slidesync-0.4.0 → slidesync-0.5.0}/LICENSE +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/requirements.txt +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/setup.cfg +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/slidesync.egg-info/SOURCES.txt +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/slidesync.egg-info/dependency_links.txt +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/slidesync.egg-info/entry_points.txt +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/slidesync.egg-info/requires.txt +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/slidesync.egg-info/top_level.txt +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/tests/test_comment_preservation.py +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/tests/test_markdown.py +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/tests/test_pull.py +0 -0
- {slidesync-0.4.0 → slidesync-0.5.0}/tests/test_sync_drift.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
|
|
5
5
|
Author-email: Daniel Hails <slidesync@hails.info>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
22
22
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
23
23
|
images, brand-styled text boxes), not pasted screenshots.
|
|
24
24
|
|
|
25
|
-
Version: 0.
|
|
25
|
+
Version: 0.5.0
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -58,17 +58,25 @@ Support path.)
|
|
|
58
58
|
|
|
59
59
|
| Command | Purpose |
|
|
60
60
|
|---------|---------|
|
|
61
|
-
| `slidesync push <file.slidev.md
|
|
61
|
+
| `slidesync push <file.slidev.md>... [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
|
|
62
62
|
| `slidesync pull <deckId> --out <file.md> [--all]` | Slides → markdown (`--all` includes non-managed slides) |
|
|
63
63
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
64
64
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
65
65
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
66
66
|
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
67
|
-
| `slidesync sync <file.slidev.md
|
|
67
|
+
| `slidesync sync <file.slidev.md>... [--deck ID] [--prune]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
|
|
68
68
|
|
|
69
69
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
70
|
-
`deck:` frontmatter key. Relative image paths resolve against
|
|
71
|
-
|
|
70
|
+
`deck:` frontmatter key. Relative image paths resolve against each slide's own
|
|
71
|
+
source file.
|
|
72
|
+
|
|
73
|
+
**Multi-file decks**: `push`/`sync` accept several files (e.g.
|
|
74
|
+
`slidesync sync $(ls -r meetings/*.slidev.md)` — one file per meeting, newest
|
|
75
|
+
first). Deck order follows the argument order; slide ids namespace as
|
|
76
|
+
`<file-stem>-<id>` (`2026-06-15-overview`) so files can reuse ids; intra-file
|
|
77
|
+
`[text](#id)` links rewrite to the namespaced target, while fully-qualified
|
|
78
|
+
cross-file targets pass through. `sync` routes comment capture and live-edit
|
|
79
|
+
write-backs into the right source file under its local id.
|
|
72
80
|
|
|
73
81
|
```bash
|
|
74
82
|
slidesync push deck.slidev.md # targets `deck:` frontmatter
|
|
@@ -4,7 +4,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
4
4
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
5
5
|
images, brand-styled text boxes), not pasted screenshots.
|
|
6
6
|
|
|
7
|
-
Version: 0.
|
|
7
|
+
Version: 0.5.0
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
uvx slidesync --help # run without installing
|
|
@@ -40,17 +40,25 @@ Support path.)
|
|
|
40
40
|
|
|
41
41
|
| Command | Purpose |
|
|
42
42
|
|---------|---------|
|
|
43
|
-
| `slidesync push <file.slidev.md
|
|
43
|
+
| `slidesync push <file.slidev.md>... [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
|
|
44
44
|
| `slidesync pull <deckId> --out <file.md> [--all]` | Slides → markdown (`--all` includes non-managed slides) |
|
|
45
45
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
46
46
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
47
47
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
48
48
|
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
49
|
-
| `slidesync sync <file.slidev.md
|
|
49
|
+
| `slidesync sync <file.slidev.md>... [--deck ID] [--prune]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
|
|
50
50
|
|
|
51
51
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
52
|
-
`deck:` frontmatter key. Relative image paths resolve against
|
|
53
|
-
|
|
52
|
+
`deck:` frontmatter key. Relative image paths resolve against each slide's own
|
|
53
|
+
source file.
|
|
54
|
+
|
|
55
|
+
**Multi-file decks**: `push`/`sync` accept several files (e.g.
|
|
56
|
+
`slidesync sync $(ls -r meetings/*.slidev.md)` — one file per meeting, newest
|
|
57
|
+
first). Deck order follows the argument order; slide ids namespace as
|
|
58
|
+
`<file-stem>-<id>` (`2026-06-15-overview`) so files can reuse ids; intra-file
|
|
59
|
+
`[text](#id)` links rewrite to the namespaced target, while fully-qualified
|
|
60
|
+
cross-file targets pass through. `sync` routes comment capture and live-edit
|
|
61
|
+
write-backs into the right source file under its local id.
|
|
54
62
|
|
|
55
63
|
```bash
|
|
56
64
|
slidesync push deck.slidev.md # targets `deck:` frontmatter
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "slidesync"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name = "Daniel Hails", email = "slidesync@hails.info" },
|
|
6
6
|
]
|
|
@@ -32,7 +32,7 @@ include = ["slidesync*"]
|
|
|
32
32
|
dev = ["pytest>=8"]
|
|
33
33
|
|
|
34
34
|
[tool.bumpver]
|
|
35
|
-
current_version = "0.
|
|
35
|
+
current_version = "0.5.0"
|
|
36
36
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
37
37
|
commit_message = "bump version {old_version} -> {new_version}"
|
|
38
38
|
commit = true
|
|
@@ -195,6 +195,8 @@ class Slide:
|
|
|
195
195
|
custom: str | None = None # ```gslides``` literal Slides API requests (JSON)
|
|
196
196
|
verbatim: str | None = None # ``` ``` fenced body for prompt/code slides
|
|
197
197
|
src: str | None = None # body markdown as authored (comments in place)
|
|
198
|
+
src_path: Path | None = None # source file this slide was loaded from
|
|
199
|
+
src_key: str = "" # id as written in that file (un-namespaced)
|
|
198
200
|
key_hash: str = ""
|
|
199
201
|
content_hash: str = ""
|
|
200
202
|
object_id: str = ""
|
|
@@ -254,6 +256,55 @@ def load_slides(path: Path) -> list[Slide]:
|
|
|
254
256
|
return build_slides(split_slides(path.read_text()))
|
|
255
257
|
|
|
256
258
|
|
|
259
|
+
LOCAL_LINK_RE = re.compile(
|
|
260
|
+
r"""(?x)
|
|
261
|
+
\]\(\# # close of [text], open of (#...
|
|
262
|
+
(?P<target>[\w-]+) # the slide id being linked to
|
|
263
|
+
\) # close paren
|
|
264
|
+
"""
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def load_deck(paths: list[Path]) -> list[Slide]:
|
|
269
|
+
"""Load one or many source files into a single slide list.
|
|
270
|
+
|
|
271
|
+
With several files (e.g. `slidesync sync meetings/*.slidev.md`, one file per
|
|
272
|
+
meeting), deck order follows the argument order and every slide id is
|
|
273
|
+
namespaced with its file's stem (`2026-06-15-overview`) so files can reuse
|
|
274
|
+
ids without colliding. Intra-file `[text](#id)` links are rewritten to the
|
|
275
|
+
namespaced target; already-qualified cross-file links pass through. Each
|
|
276
|
+
slide remembers its origin (`src_path`/`src_key`) so `sync` writes captures
|
|
277
|
+
and live edits back into the right file. Duplicate keys are an error.
|
|
278
|
+
"""
|
|
279
|
+
if len(paths) == 1:
|
|
280
|
+
slides = load_slides(paths[0])
|
|
281
|
+
for s in slides:
|
|
282
|
+
s.src_path, s.src_key = paths[0], s.key
|
|
283
|
+
else:
|
|
284
|
+
slides = []
|
|
285
|
+
for path in paths:
|
|
286
|
+
prefix = path.name.split(".")[0]
|
|
287
|
+
chunks = split_slides(path.read_text())
|
|
288
|
+
local = {m.get("id") or f"slide{i}" for i, (m, _b) in enumerate(chunks)}
|
|
289
|
+
|
|
290
|
+
def relink(m, prefix=prefix, local=local):
|
|
291
|
+
t = m.group("target")
|
|
292
|
+
return f"](#{prefix}-{t})" if t in local else m.group(0)
|
|
293
|
+
|
|
294
|
+
for i, (meta, body) in enumerate(chunks):
|
|
295
|
+
src_key = meta.get("id") or f"slide{i}"
|
|
296
|
+
meta = {**meta, "id": f"{prefix}-{src_key}"}
|
|
297
|
+
slide = build_slide(meta, LOCAL_LINK_RE.sub(relink, body), i)
|
|
298
|
+
slide.src_path, slide.src_key = path, src_key
|
|
299
|
+
slides.append(slide)
|
|
300
|
+
seen, dupes = set(), set()
|
|
301
|
+
for s in slides:
|
|
302
|
+
(dupes if s.key in seen else seen).add(s.key)
|
|
303
|
+
if dupes:
|
|
304
|
+
sys.exit(f"duplicate slide ids across sources: {sorted(dupes)}")
|
|
305
|
+
return slides
|
|
306
|
+
|
|
307
|
+
|
|
257
308
|
def split_slides(text: str) -> list[tuple[dict, str]]:
|
|
258
309
|
post = frontmatter.loads(text)
|
|
259
310
|
chunks = re.split(r"(?m)^---[ \t]*$", post.content)
|
|
@@ -1227,7 +1278,9 @@ def _resolve_image(drive, slide: Slide, base_dir=Path(".")):
|
|
|
1227
1278
|
return None, None
|
|
1228
1279
|
p = Path(slide.image)
|
|
1229
1280
|
if not p.is_absolute():
|
|
1230
|
-
|
|
1281
|
+
# Relative to the slide's own source file (multi-file decks), else the
|
|
1282
|
+
# caller's base directory.
|
|
1283
|
+
p = (slide.src_path.parent if slide.src_path else base_dir) / p
|
|
1231
1284
|
if not p.exists():
|
|
1232
1285
|
logger.warning(f"image not found, graphic skipped: {p}")
|
|
1233
1286
|
return None, None
|
|
@@ -1865,18 +1918,33 @@ def deck_from_source(path: Path) -> str | None:
|
|
|
1865
1918
|
return m.group("id") if m else str(val)
|
|
1866
1919
|
|
|
1867
1920
|
|
|
1921
|
+
def _source_paths(args) -> list[Path]:
|
|
1922
|
+
paths = args.source if isinstance(args.source, list) else [args.source]
|
|
1923
|
+
return [Path(p) for p in paths]
|
|
1924
|
+
|
|
1925
|
+
|
|
1926
|
+
def _deck_of(args, paths: list[Path]) -> str | None:
|
|
1927
|
+
if args.deck:
|
|
1928
|
+
return args.deck
|
|
1929
|
+
for p in paths:
|
|
1930
|
+
if d := deck_from_source(p):
|
|
1931
|
+
return d
|
|
1932
|
+
return None
|
|
1933
|
+
|
|
1934
|
+
|
|
1868
1935
|
def cmd_push(args):
|
|
1869
|
-
|
|
1870
|
-
|
|
1936
|
+
paths = _source_paths(args)
|
|
1937
|
+
source = load_deck(paths)
|
|
1938
|
+
logger.info(f"parsed {len(source)} slides from {len(paths)} file(s)")
|
|
1871
1939
|
slides_api, drive = get_services(args.account)
|
|
1872
|
-
deck = args
|
|
1940
|
+
deck = _deck_of(args, paths)
|
|
1873
1941
|
if args.new:
|
|
1874
1942
|
deck = new_deck(slides_api, args.new)
|
|
1875
1943
|
logger.info(f"created https://docs.google.com/presentation/d/{deck}/edit")
|
|
1876
1944
|
if not deck:
|
|
1877
1945
|
sys.exit("no target deck: pass --deck/--new or add `deck:` frontmatter")
|
|
1878
1946
|
stats = push(slides_api, drive, deck, source, args.anchor, args.prune,
|
|
1879
|
-
base_dir=
|
|
1947
|
+
base_dir=paths[0].parent, force=args.force)
|
|
1880
1948
|
logger.success(f"{stats} -> https://docs.google.com/presentation/d/{deck}/edit")
|
|
1881
1949
|
|
|
1882
1950
|
|
|
@@ -2015,6 +2083,20 @@ def _diff(a: list[str], b: list[str], a_name: str, b_name: str) -> str:
|
|
|
2015
2083
|
return "\n".join(difflib.unified_diff(a, b, a_name, b_name, lineterm=""))
|
|
2016
2084
|
|
|
2017
2085
|
|
|
2086
|
+
def _content_lines(src: str | None, template: str | None) -> list[str] | None:
|
|
2087
|
+
"""Markdown text lines as they would actually RENDER for this template.
|
|
2088
|
+
|
|
2089
|
+
graph/full slides are text-free (only the image renders), so markdown body
|
|
2090
|
+
text on them can never reach the deck — comparing it against the live slide
|
|
2091
|
+
would report drift forever.
|
|
2092
|
+
"""
|
|
2093
|
+
if src is None:
|
|
2094
|
+
return None
|
|
2095
|
+
if (template or "").lower() in ("graph", "full"):
|
|
2096
|
+
return []
|
|
2097
|
+
return text_lines_md(src)
|
|
2098
|
+
|
|
2099
|
+
|
|
2018
2100
|
def _live_state(s) -> tuple[list[str], str, str | None, dict]:
|
|
2019
2101
|
"""(text lines, normalised notes, base source, marker) of a live slide."""
|
|
2020
2102
|
notes_raw = _read_notes(s)
|
|
@@ -2040,13 +2122,14 @@ def _clobber_risks(pres, managed, source, pruned) -> list[str]:
|
|
|
2040
2122
|
replacing = sl is not None and sl.custom is None and sl.object_id != oid
|
|
2041
2123
|
if not (replacing or oid in pruned) or live.get(oid) is None:
|
|
2042
2124
|
continue
|
|
2043
|
-
live_lines, live_notes, base_src,
|
|
2125
|
+
live_lines, live_notes, base_src, marker = _live_state(live[oid])
|
|
2044
2126
|
if base_src is None:
|
|
2045
2127
|
continue
|
|
2046
|
-
if (live_lines ==
|
|
2128
|
+
if (live_lines == _content_lines(base_src, marker.get("template"))
|
|
2047
2129
|
and live_notes == " ".join(_extract_notes(base_src).split())):
|
|
2048
2130
|
continue # deck untouched since last push
|
|
2049
|
-
if (sl is not None
|
|
2131
|
+
if (sl is not None
|
|
2132
|
+
and live_lines == _content_lines(sl.src or "", sl.template_name)
|
|
2050
2133
|
and live_notes == " ".join((sl.notes or "").split())):
|
|
2051
2134
|
continue # local already carries the live edit
|
|
2052
2135
|
out.append(sl.key if sl is not None else oid)
|
|
@@ -2136,9 +2219,9 @@ def cmd_sync(args):
|
|
|
2136
2219
|
conflicts remain.
|
|
2137
2220
|
"""
|
|
2138
2221
|
slides_api, drive = get_services(args.account)
|
|
2139
|
-
|
|
2140
|
-
source =
|
|
2141
|
-
deck = args
|
|
2222
|
+
paths = _source_paths(args)
|
|
2223
|
+
source = load_deck(paths)
|
|
2224
|
+
deck = _deck_of(args, paths)
|
|
2142
2225
|
if not deck:
|
|
2143
2226
|
sys.exit("no target deck: pass --deck or add `deck:` frontmatter")
|
|
2144
2227
|
pres = slides_api.presentations().get(presentationId=deck).execute()
|
|
@@ -2149,37 +2232,56 @@ def cmd_sync(args):
|
|
|
2149
2232
|
if not c["resolved"] and c["page"]:
|
|
2150
2233
|
by_page.setdefault(c["page"], []).append(c)
|
|
2151
2234
|
|
|
2152
|
-
|
|
2153
|
-
|
|
2235
|
+
texts = {p: p.read_text() for p in paths}
|
|
2236
|
+
state = {"dirty": set(), "pushable": False}
|
|
2237
|
+
origin_by_kh = {sl.key_hash: (sl.src_path, sl.src_key) for sl in source}
|
|
2154
2238
|
|
|
2155
|
-
def capture(
|
|
2239
|
+
def capture(origin: tuple[Path, str] | None, c: dict, page: str) -> None:
|
|
2156
2240
|
lines = [f"@{c['author']}: {c['content']}"]
|
|
2157
2241
|
lines += [f"@{r['author']}: {r['content']}" for r in c["replies"]]
|
|
2158
2242
|
block = "<!-- " + "\n".join(lines) + " -->"
|
|
2159
|
-
|
|
2243
|
+
path, key = origin if origin else (None, None)
|
|
2244
|
+
if path and " ".join(c["content"].split()) in " ".join(texts[path].split()):
|
|
2160
2245
|
return # already captured
|
|
2161
|
-
new = _append_to_slide_body(
|
|
2162
|
-
if new !=
|
|
2163
|
-
|
|
2164
|
-
|
|
2246
|
+
new = _append_to_slide_body(texts[path], key, block) if path else None
|
|
2247
|
+
if new is not None and new != texts[path]:
|
|
2248
|
+
texts[path] = new
|
|
2249
|
+
state["dirty"].add(path)
|
|
2250
|
+
state["pushable"] = True
|
|
2251
|
+
logger.info(f"[comment ] {key} — captured thread by {c['author']} "
|
|
2252
|
+
f"-> {path.name}")
|
|
2165
2253
|
else:
|
|
2166
2254
|
logger.warning(f"[comment ] thread by {c['author']} on {key or page} "
|
|
2167
2255
|
f"couldn't be placed; paste manually:\n{block}")
|
|
2168
2256
|
|
|
2169
2257
|
conflicts = []
|
|
2170
2258
|
for sl in source:
|
|
2259
|
+
origin = (sl.src_path, sl.src_key)
|
|
2171
2260
|
s = live.pop(sl.key_hash, None)
|
|
2172
2261
|
if s is None:
|
|
2173
2262
|
logger.info(f"[missing ] {sl.key} — push will create it")
|
|
2174
2263
|
state["pushable"] = True
|
|
2175
2264
|
continue
|
|
2176
2265
|
for c in by_page.pop(s["objectId"], []):
|
|
2177
|
-
capture(
|
|
2266
|
+
capture(origin, c, s["objectId"])
|
|
2178
2267
|
if sl.custom is not None:
|
|
2179
|
-
|
|
2268
|
+
# Pull-authoritative: refresh the ```gslides``` block from the live
|
|
2269
|
+
# drawing so hand-drawn edits land back in the source file.
|
|
2270
|
+
marker = _read_marker(_read_notes(s))
|
|
2271
|
+
live_json = (_custom_slide_from_native(s, marker, "").custom
|
|
2272
|
+
if "id" in marker else None)
|
|
2273
|
+
if live_json and live_json.strip() != (sl.custom or "").strip():
|
|
2274
|
+
new = texts[sl.src_path].replace(sl.custom, live_json, 1)
|
|
2275
|
+
if new != texts[sl.src_path]:
|
|
2276
|
+
texts[sl.src_path] = new
|
|
2277
|
+
state["dirty"].add(sl.src_path)
|
|
2278
|
+
logger.info(f"[drawing ] {sl.key} — captured live drawing "
|
|
2279
|
+
f"-> {sl.src_path.name}")
|
|
2280
|
+
continue
|
|
2180
2281
|
live_lines, live_notes, base_src, marker = _live_state(s)
|
|
2181
|
-
base =
|
|
2182
|
-
|
|
2282
|
+
base = _content_lines(base_src, marker.get("template"))
|
|
2283
|
+
local = _content_lines(sl.src or "", sl.template_name) or []
|
|
2284
|
+
status = classify_drift(base, local, live_lines)
|
|
2183
2285
|
if status in ("clean", "converged"):
|
|
2184
2286
|
continue
|
|
2185
2287
|
if status == "local-edit":
|
|
@@ -2191,10 +2293,14 @@ def cmd_sync(args):
|
|
|
2191
2293
|
and marker.get("template") else None)
|
|
2192
2294
|
if rebuilt and (rebuilt.title or rebuilt.paras or rebuilt.verbatim):
|
|
2193
2295
|
rebuilt.notes = " ".join(MARKER_RE.sub("", _read_notes(s)).split())
|
|
2194
|
-
new = _replace_slide_body(
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2296
|
+
new = _replace_slide_body(texts[sl.src_path], sl.src_key,
|
|
2297
|
+
_render_body(rebuilt))
|
|
2298
|
+
if new != texts[sl.src_path]:
|
|
2299
|
+
texts[sl.src_path] = new
|
|
2300
|
+
state["dirty"].add(sl.src_path)
|
|
2301
|
+
state["pushable"] = True
|
|
2302
|
+
logger.info(f"[pulled ] {sl.key} — live edit written back "
|
|
2303
|
+
f"-> {sl.src_path.name}")
|
|
2198
2304
|
continue
|
|
2199
2305
|
conflicts.append(sl.key)
|
|
2200
2306
|
logger.error(f"[conflict ] {sl.key} (pushed {marker.get('at', '?')}) — "
|
|
@@ -2204,22 +2310,22 @@ def cmd_sync(args):
|
|
|
2204
2310
|
+ _diff(base or [], live_lines, "last-pushed", "live"))
|
|
2205
2311
|
for page, cs in by_page.items(): # threads on re-rendered (deleted) pages
|
|
2206
2312
|
m = re.match(r"s2g_(?P<kh>[0-9a-f]{10})_", page)
|
|
2207
|
-
|
|
2313
|
+
origin = origin_by_kh.get(m.group("kh")) if m else None
|
|
2208
2314
|
for c in cs:
|
|
2209
|
-
capture(
|
|
2315
|
+
capture(origin, c, page)
|
|
2210
2316
|
for _kh, s in live.items():
|
|
2211
2317
|
logger.warning(f"[unmanaged ] {s['objectId']} has no local slide "
|
|
2212
2318
|
"(push --prune would delete it)")
|
|
2213
2319
|
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
logger.success(f"updated {
|
|
2320
|
+
for p in sorted(state["dirty"]):
|
|
2321
|
+
p.write_text(texts[p])
|
|
2322
|
+
logger.success(f"updated {p}")
|
|
2217
2323
|
if conflicts:
|
|
2218
2324
|
sys.exit(f"sync stopped: {len(conflicts)} conflict(s) above — resolve in "
|
|
2219
2325
|
"the markdown, then `slidesync push` (its guard protects the rest).")
|
|
2220
2326
|
if state["pushable"]:
|
|
2221
|
-
stats = push(slides_api, drive, deck,
|
|
2222
|
-
prune=
|
|
2327
|
+
stats = push(slides_api, drive, deck, load_deck(paths), anchor=None,
|
|
2328
|
+
prune=args.prune, base_dir=paths[0].parent)
|
|
2223
2329
|
logger.success(f"{stats} -> "
|
|
2224
2330
|
f"https://docs.google.com/presentation/d/{deck}/edit")
|
|
2225
2331
|
else:
|
|
@@ -2279,7 +2385,9 @@ def main():
|
|
|
2279
2385
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
2280
2386
|
|
|
2281
2387
|
p = sub.add_parser("push")
|
|
2282
|
-
p.add_argument("source", type=Path
|
|
2388
|
+
p.add_argument("source", type=Path, nargs="+",
|
|
2389
|
+
help="one or more .slidev.md files; several files merge into "
|
|
2390
|
+
"one deck with ids namespaced by file stem")
|
|
2283
2391
|
p.add_argument("--deck")
|
|
2284
2392
|
p.add_argument("--new")
|
|
2285
2393
|
p.add_argument("--anchor")
|
|
@@ -2308,9 +2416,12 @@ def main():
|
|
|
2308
2416
|
p.set_defaults(func=cmd_comments)
|
|
2309
2417
|
|
|
2310
2418
|
p = sub.add_parser("sync",
|
|
2311
|
-
help="
|
|
2312
|
-
p.add_argument("source", type=Path
|
|
2419
|
+
help="reconcile with the live deck (comments, live edits, conflicts)")
|
|
2420
|
+
p.add_argument("source", type=Path, nargs="+",
|
|
2421
|
+
help="one or more .slidev.md files (ids namespaced by stem when several)")
|
|
2313
2422
|
p.add_argument("--deck")
|
|
2423
|
+
p.add_argument("--prune", action="store_true",
|
|
2424
|
+
help="the final push deletes managed slides missing from the sources")
|
|
2314
2425
|
p.set_defaults(func=cmd_sync)
|
|
2315
2426
|
|
|
2316
2427
|
p = sub.add_parser("make-templates",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
|
|
5
5
|
Author-email: Daniel Hails <slidesync@hails.info>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
22
22
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
23
23
|
images, brand-styled text boxes), not pasted screenshots.
|
|
24
24
|
|
|
25
|
-
Version: 0.
|
|
25
|
+
Version: 0.5.0
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -58,17 +58,25 @@ Support path.)
|
|
|
58
58
|
|
|
59
59
|
| Command | Purpose |
|
|
60
60
|
|---------|---------|
|
|
61
|
-
| `slidesync push <file.slidev.md
|
|
61
|
+
| `slidesync push <file.slidev.md>... [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
|
|
62
62
|
| `slidesync pull <deckId> --out <file.md> [--all]` | Slides → markdown (`--all` includes non-managed slides) |
|
|
63
63
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
64
64
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
65
65
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
66
66
|
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
67
|
-
| `slidesync sync <file.slidev.md
|
|
67
|
+
| `slidesync sync <file.slidev.md>... [--deck ID] [--prune]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
|
|
68
68
|
|
|
69
69
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
70
|
-
`deck:` frontmatter key. Relative image paths resolve against
|
|
71
|
-
|
|
70
|
+
`deck:` frontmatter key. Relative image paths resolve against each slide's own
|
|
71
|
+
source file.
|
|
72
|
+
|
|
73
|
+
**Multi-file decks**: `push`/`sync` accept several files (e.g.
|
|
74
|
+
`slidesync sync $(ls -r meetings/*.slidev.md)` — one file per meeting, newest
|
|
75
|
+
first). Deck order follows the argument order; slide ids namespace as
|
|
76
|
+
`<file-stem>-<id>` (`2026-06-15-overview`) so files can reuse ids; intra-file
|
|
77
|
+
`[text](#id)` links rewrite to the namespaced target, while fully-qualified
|
|
78
|
+
cross-file targets pass through. `sync` routes comment capture and live-edit
|
|
79
|
+
write-backs into the right source file under its local id.
|
|
72
80
|
|
|
73
81
|
```bash
|
|
74
82
|
slidesync push deck.slidev.md # targets `deck:` frontmatter
|
|
@@ -13,7 +13,7 @@ from types import SimpleNamespace
|
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
15
|
from slidesync import _sync
|
|
16
|
-
from slidesync._sync import cmd_sync, load_slides, push
|
|
16
|
+
from slidesync._sync import cmd_sync, load_deck, load_slides, push
|
|
17
17
|
|
|
18
18
|
DECK = "fakedeck"
|
|
19
19
|
|
|
@@ -190,7 +190,7 @@ def env(tmp_path, monkeypatch):
|
|
|
190
190
|
|
|
191
191
|
|
|
192
192
|
def _sync_cmd(env):
|
|
193
|
-
cmd_sync(SimpleNamespace(source=env.path, deck=DECK, account=None))
|
|
193
|
+
cmd_sync(SimpleNamespace(source=env.path, deck=DECK, account=None, prune=False))
|
|
194
194
|
|
|
195
195
|
|
|
196
196
|
def _push(env, force=False, prune=False):
|
|
@@ -290,3 +290,132 @@ def test_sync_recaptures_orphaned_comment_via_key_hash(env):
|
|
|
290
290
|
_push(env) # re-render replaces the slide; the thread's page id now dangles
|
|
291
291
|
_sync_cmd(env)
|
|
292
292
|
assert "<!-- @Daniel Hails: anchored before the re-render -->" in env.path.read_text()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
GRAPH_MD = MD + """
|
|
296
|
+
---
|
|
297
|
+
template: graph
|
|
298
|
+
id: fig-placeholder
|
|
299
|
+
---
|
|
300
|
+
![[Bracketed placeholder alt that defeats IMAGE_RE]](../figures/missing.png)
|
|
301
|
+
|
|
302
|
+
<!-- caption comment -->
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_graph_slides_with_unrenderable_text_stay_clean(tmp_path, monkeypatch):
|
|
307
|
+
# A graph/full slide is text-free: body text in the markdown (e.g. an image
|
|
308
|
+
# line whose bracketed alt breaks IMAGE_RE) can never render, so it must
|
|
309
|
+
# not register as drift. Regression: the live probe flagged placeholder
|
|
310
|
+
# graph slides as live-drift forever.
|
|
311
|
+
store, drive = FakeStore(), FakeDrive()
|
|
312
|
+
slides_api = FakeSlides(store)
|
|
313
|
+
monkeypatch.setattr(_sync, "get_services", lambda account: (slides_api, drive))
|
|
314
|
+
path = tmp_path / "deck.slidev.md"
|
|
315
|
+
path.write_text(GRAPH_MD)
|
|
316
|
+
push(slides_api, drive, DECK, load_slides(path), anchor=None, prune=False,
|
|
317
|
+
base_dir=tmp_path)
|
|
318
|
+
before = path.read_text()
|
|
319
|
+
cmd_sync(SimpleNamespace(source=path, deck=DECK, account=None, prune=False))
|
|
320
|
+
assert path.read_text() == before # no write-back, no conflict, no churn
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
WEEK_A = """---
|
|
324
|
+
theme: seriph
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
template: topic
|
|
329
|
+
id: thread-x
|
|
330
|
+
---
|
|
331
|
+
# Newer week takeaway
|
|
332
|
+
## FINDING
|
|
333
|
+
|
|
334
|
+
Newer point one.
|
|
335
|
+
|
|
336
|
+
[appendix link](#thread-y)
|
|
337
|
+
[cross week](#2026-06-01-thread-x)
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
---
|
|
341
|
+
template: topic
|
|
342
|
+
id: thread-y
|
|
343
|
+
---
|
|
344
|
+
# Newer week appendix
|
|
345
|
+
## BACKUP
|
|
346
|
+
|
|
347
|
+
Appendix detail.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
WEEK_B = """---
|
|
351
|
+
theme: seriph
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
template: topic
|
|
356
|
+
id: thread-x
|
|
357
|
+
---
|
|
358
|
+
# Older week takeaway
|
|
359
|
+
## FINDING
|
|
360
|
+
|
|
361
|
+
Older point one.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@pytest.fixture
|
|
366
|
+
def multi(tmp_path, monkeypatch):
|
|
367
|
+
store, drive = FakeStore(), FakeDrive()
|
|
368
|
+
slides_api = FakeSlides(store)
|
|
369
|
+
monkeypatch.setattr(_sync, "get_services", lambda account: (slides_api, drive))
|
|
370
|
+
a = tmp_path / "2026-06-08.slidev.md"
|
|
371
|
+
b = tmp_path / "2026-06-01.slidev.md"
|
|
372
|
+
a.write_text(WEEK_A)
|
|
373
|
+
b.write_text(WEEK_B)
|
|
374
|
+
push(slides_api, drive, DECK, load_deck([a, b]), anchor=None, prune=False,
|
|
375
|
+
base_dir=tmp_path)
|
|
376
|
+
return SimpleNamespace(store=store, drive=drive, slides=slides_api, a=a, b=b)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _multi_sync(env):
|
|
380
|
+
cmd_sync(SimpleNamespace(source=[env.a, env.b], deck=DECK, account=None,
|
|
381
|
+
prune=False))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_multi_file_namespaces_ids_and_intra_file_links(multi):
|
|
385
|
+
slides = load_deck([multi.a, multi.b])
|
|
386
|
+
assert [s.key for s in slides] == ["2026-06-08-thread-x", "2026-06-08-thread-y",
|
|
387
|
+
"2026-06-01-thread-x"]
|
|
388
|
+
newer = slides[0]
|
|
389
|
+
links = [r.link for p in newer.paras for r in p.runs if r.style == "link"]
|
|
390
|
+
assert "#2026-06-08-thread-y" in links # intra-file: namespaced
|
|
391
|
+
assert "#2026-06-01-thread-x" in links # cross-file: already qualified
|
|
392
|
+
assert (newer.src_path, newer.src_key) == (multi.a, "thread-x")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_multi_file_comment_capture_routes_to_origin_file(multi):
|
|
396
|
+
multi.drive.add(multi.store.oid_of("Older point one"), "comment on the old week")
|
|
397
|
+
_multi_sync(multi)
|
|
398
|
+
assert "<!-- @Daniel Hails: comment on the old week -->" in multi.b.read_text()
|
|
399
|
+
assert "comment on the old week" not in multi.a.read_text()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_multi_file_live_drift_writes_back_to_origin_file(multi):
|
|
403
|
+
multi.store.edit_text("Older point one", "Older point one, live-edited")
|
|
404
|
+
_multi_sync(multi)
|
|
405
|
+
assert "live-edited" in multi.b.read_text()
|
|
406
|
+
assert "live-edited" not in multi.a.read_text()
|
|
407
|
+
# the source file keeps its LOCAL id, not the namespaced one
|
|
408
|
+
assert "id: thread-x" in multi.b.read_text()
|
|
409
|
+
assert "id: 2026-06-01-thread-x" not in multi.b.read_text()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_duplicate_keys_across_files_are_rejected(tmp_path):
|
|
413
|
+
a = tmp_path / "same.slidev.md"
|
|
414
|
+
b = tmp_path / "same2.slidev.md"
|
|
415
|
+
a.write_text(WEEK_B)
|
|
416
|
+
b.write_text(WEEK_B.replace("same", "same"))
|
|
417
|
+
# same stem prefix collision: copy a file so namespaced keys collide
|
|
418
|
+
c = tmp_path / "same.copy.slidev.md" # stem "same" again (split on first dot)
|
|
419
|
+
c.write_text(WEEK_B)
|
|
420
|
+
with pytest.raises(SystemExit):
|
|
421
|
+
load_deck([a, c])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|