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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.4.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.4.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> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
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> [--deck ID]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
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 the markdown file's
71
- directory.
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.4.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> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
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> [--deck ID]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
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 the markdown file's
53
- directory.
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.4.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.4.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
@@ -30,7 +30,7 @@ from slidesync._sync import (
30
30
  write_slidev,
31
31
  )
32
32
 
33
- __version__ = "0.4.0"
33
+ __version__ = "0.5.0"
34
34
 
35
35
  __all__ = [
36
36
  "Para",
@@ -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
- p = base_dir / p
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
- source = load_slides(args.source)
1870
- logger.info(f"parsed {len(source)} slides from {args.source}")
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.deck or deck_from_source(args.source)
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=args.source.parent, force=args.force)
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, _marker_ = _live_state(live[oid])
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 == text_lines_md(base_src)
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 and live_lines == text_lines_md(sl.src or "")
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
- path = args.source
2140
- source = load_slides(path)
2141
- deck = args.deck or deck_from_source(path)
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
- state = {"text": path.read_text(), "dirty": False, "pushable": False}
2153
- key_by_kh = {sl.key_hash: sl.key for sl in source}
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(key: str | None, c: dict, page: str) -> None:
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
- if " ".join(c["content"].split()) in " ".join(state["text"].split()):
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(state["text"], key, block) if key else state["text"]
2162
- if new != state["text"]:
2163
- state.update(text=new, dirty=True, pushable=True)
2164
- logger.info(f"[comment ] {key} — captured thread by {c['author']}")
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(sl.key, c, s["objectId"])
2266
+ capture(origin, c, s["objectId"])
2178
2267
  if sl.custom is not None:
2179
- continue # pull-authoritative; drawings are captured by pull, not diffed
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 = text_lines_md(base_src) if base_src is not None else None
2182
- status = classify_drift(base, text_lines_md(sl.src or ""), live_lines)
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(state["text"], sl.key, _render_body(rebuilt))
2195
- if new != state["text"]:
2196
- state.update(text=new, dirty=True, pushable=True)
2197
- logger.info(f"[pulled ] {sl.key} live edit written back")
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
- key = key_by_kh.get(m.group("kh")) if m else None
2313
+ origin = origin_by_kh.get(m.group("kh")) if m else None
2208
2314
  for c in cs:
2209
- capture(key, c, page)
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
- if state["dirty"]:
2215
- path.write_text(state["text"])
2216
- logger.success(f"updated {path}")
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, load_slides(path), anchor=None,
2222
- prune=False, base_dir=path.parent)
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="report drift vs the live deck (comments, live edits, conflicts)")
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.4.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.4.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> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
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> [--deck ID]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
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 the markdown file's
71
- directory.
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