slidesync 0.3.0__tar.gz → 0.4.1__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.3.0
3
+ Version: 0.4.1
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.3.0
25
+ Version: 0.4.1
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -58,13 +58,13 @@ 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 |
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]` | report drift vs the live deck comments, live edits, conflicts (exit 1 on drift) |
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) |
68
68
 
69
69
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
70
70
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -89,24 +89,32 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
89
89
  path, template vars — and, for template slides, the authored body markdown
90
90
  (base64) — so `pull` recovers the source verbatim.
91
91
 
92
- ## Sync & drift (detection, not resolution)
93
-
94
- The marker's last-pushed source is a true per-slide **merge base**, so `sync`
95
- classifies each slide three-way without timestamps (the Slides API has no
96
- per-slide edit times only file-level `modifiedTime`; the marker's `at` stamp
97
- records when *we* last pushed each slide):
98
-
99
- | status | meaning | action |
100
- |--------|---------|--------|
101
- | `clean` / `converged` | nothing changed, or both sides made the same change | — |
102
- | `local-edit` | markdown changed, deck untouched | `push` (normal) |
103
- | `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
104
- | `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
105
-
106
- Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
107
- blocks on their slide (replies as extra `@Author:` lines) paste them into the
108
- source, where they round-trip from then on. Threads anchor to slide objectIds,
109
- so a re-render orphans them: `sync` reports those too. Capture before you push.
92
+ ## Sync & drift
93
+
94
+ `push` is guarded like a non-fast-forward git push: if a slide it would replace
95
+ (or prune) was edited in Google Slides since the last push and the local
96
+ markdown doesn't already carry that edit the push is **rejected** with no
97
+ changes made (`--force` overwrites). Live edits on slides the push wouldn't
98
+ touch are left alone.
99
+
100
+ `sync` reconciles the two sides, applying whatever is safe. The marker's
101
+ last-pushed source is a true per-slide **merge base**, so each slide classifies
102
+ three-way without timestamps (the APIs expose no per-slide edit times — only
103
+ file-level `modifiedTime`; the marker's `at` stamp records our last push):
104
+
105
+ | status | meaning | sync does |
106
+ |--------|---------|-----------|
107
+ | `clean` / `converged` | nothing changed, or both sides made the same change | nothing |
108
+ | `local-edit` | markdown changed, deck untouched | pushes it |
109
+ | `live-drift` | slide edited in Google Slides | writes the live content back into the markdown (reconstructed from its styled boxes, formatting runs included), then pushes |
110
+ | `conflict` | both changed since last push | prints both diffs vs the base for a human/LLM to resolve; skips the push; exits 1 |
111
+
112
+ Unresolved comment threads are appended to their slide as
113
+ `<!-- @Author: text -->` blocks (replies as extra `@Author:` lines), and threads
114
+ orphaned by a re-render are re-anchored to their slide via the objectId's
115
+ key-hash. Captured text round-trips from then on. Write-back caveat: a slide
116
+ edited live is rewritten canonically, so its authored comments collapse into
117
+ one trailing block (untouched slides keep comments in place).
110
118
 
111
119
  ## Markdown dialect
112
120
 
@@ -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.3.0
7
+ Version: 0.4.1
8
8
 
9
9
  ```bash
10
10
  uvx slidesync --help # run without installing
@@ -40,13 +40,13 @@ 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 |
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]` | report drift vs the live deck comments, live edits, conflicts (exit 1 on drift) |
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) |
50
50
 
51
51
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
52
52
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -71,24 +71,32 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
71
71
  path, template vars — and, for template slides, the authored body markdown
72
72
  (base64) — so `pull` recovers the source verbatim.
73
73
 
74
- ## Sync & drift (detection, not resolution)
75
-
76
- The marker's last-pushed source is a true per-slide **merge base**, so `sync`
77
- classifies each slide three-way without timestamps (the Slides API has no
78
- per-slide edit times only file-level `modifiedTime`; the marker's `at` stamp
79
- records when *we* last pushed each slide):
80
-
81
- | status | meaning | action |
82
- |--------|---------|--------|
83
- | `clean` / `converged` | nothing changed, or both sides made the same change | — |
84
- | `local-edit` | markdown changed, deck untouched | `push` (normal) |
85
- | `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
86
- | `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
87
-
88
- Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
89
- blocks on their slide (replies as extra `@Author:` lines) paste them into the
90
- source, where they round-trip from then on. Threads anchor to slide objectIds,
91
- so a re-render orphans them: `sync` reports those too. Capture before you push.
74
+ ## Sync & drift
75
+
76
+ `push` is guarded like a non-fast-forward git push: if a slide it would replace
77
+ (or prune) was edited in Google Slides since the last push and the local
78
+ markdown doesn't already carry that edit the push is **rejected** with no
79
+ changes made (`--force` overwrites). Live edits on slides the push wouldn't
80
+ touch are left alone.
81
+
82
+ `sync` reconciles the two sides, applying whatever is safe. The marker's
83
+ last-pushed source is a true per-slide **merge base**, so each slide classifies
84
+ three-way without timestamps (the APIs expose no per-slide edit times — only
85
+ file-level `modifiedTime`; the marker's `at` stamp records our last push):
86
+
87
+ | status | meaning | sync does |
88
+ |--------|---------|-----------|
89
+ | `clean` / `converged` | nothing changed, or both sides made the same change | nothing |
90
+ | `local-edit` | markdown changed, deck untouched | pushes it |
91
+ | `live-drift` | slide edited in Google Slides | writes the live content back into the markdown (reconstructed from its styled boxes, formatting runs included), then pushes |
92
+ | `conflict` | both changed since last push | prints both diffs vs the base for a human/LLM to resolve; skips the push; exits 1 |
93
+
94
+ Unresolved comment threads are appended to their slide as
95
+ `<!-- @Author: text -->` blocks (replies as extra `@Author:` lines), and threads
96
+ orphaned by a re-render are re-anchored to their slide via the objectId's
97
+ key-hash. Captured text round-trips from then on. Write-back caveat: a slide
98
+ edited live is rewritten canonically, so its authored comments collapse into
99
+ one trailing block (untouched slides keep comments in place).
92
100
 
93
101
  ## Markdown dialect
94
102
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slidesync"
3
- version = "0.3.0"
3
+ version = "0.4.1"
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.3.0"
35
+ current_version = "0.4.1"
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.3.0"
33
+ __version__ = "0.4.1"
34
34
 
35
35
  __all__ = [
36
36
  "Para",
@@ -36,6 +36,7 @@ from __future__ import annotations
36
36
 
37
37
  import argparse
38
38
  import base64
39
+ import copy
39
40
  import datetime
40
41
  import difflib
41
42
  import hashlib
@@ -1147,6 +1148,16 @@ def push(slides_api, drive, deck, source, anchor, prune, base_dir=Path("."),
1147
1148
  creates, deletes, skips, pruned = plan_sync(source, managed, prune, force)
1148
1149
  if not (creates or deletes or pruned): # nothing changed — skip reorder/links/gets
1149
1150
  return {"create": 0, "skip": len(skips), "replace": 0, "prune": 0}
1151
+ if not force:
1152
+ # Non-fast-forward guard: replacing or pruning a slide that was edited
1153
+ # in Google Slides since its last push would silently discard that edit.
1154
+ risks = _clobber_risks(pres, managed, source, pruned)
1155
+ if risks:
1156
+ for key in risks:
1157
+ logger.error(f"live edit would be lost: {key}")
1158
+ sys.exit("push rejected: the deck changed in Google Slides since the "
1159
+ "last push (slides above). Run `slidesync sync` to reconcile, "
1160
+ "or `push --force` to overwrite.")
1150
1161
  layouts = _layout_map(slides_api, deck, pres)
1151
1162
  templates = _template_index(slides_api, deck, pres)
1152
1163
  reqs = [{"deleteObject": {"objectId": oid}} for oid in deletes + pruned]
@@ -2004,20 +2015,145 @@ def _diff(a: list[str], b: list[str], a_name: str, b_name: str) -> str:
2004
2015
  return "\n".join(difflib.unified_diff(a, b, a_name, b_name, lineterm=""))
2005
2016
 
2006
2017
 
2018
+ def _content_lines(src: str | None, template: str | None) -> list[str] | None:
2019
+ """Markdown text lines as they would actually RENDER for this template.
2020
+
2021
+ graph/full slides are text-free (only the image renders), so markdown body
2022
+ text on them can never reach the deck — comparing it against the live slide
2023
+ would report drift forever.
2024
+ """
2025
+ if src is None:
2026
+ return None
2027
+ if (template or "").lower() in ("graph", "full"):
2028
+ return []
2029
+ return text_lines_md(src)
2030
+
2031
+
2032
+ def _live_state(s) -> tuple[list[str], str, str | None, dict]:
2033
+ """(text lines, normalised notes, base source, marker) of a live slide."""
2034
+ notes_raw = _read_notes(s)
2035
+ marker = _read_marker(notes_raw)
2036
+ base_src = base64.b64decode(marker["src"]).decode() if "src" in marker else None
2037
+ notes = " ".join(MARKER_RE.sub("", notes_raw).split())
2038
+ return text_lines_native(s), notes, base_src, marker
2039
+
2040
+
2041
+ def _clobber_risks(pres, managed, source, pruned) -> list[str]:
2042
+ """Keys of slides whose live edits a push would silently discard.
2043
+
2044
+ A replace (or prune) deletes the live slide, which loses information only
2045
+ when the live copy drifted from its merge base AND the local markdown does
2046
+ not already contain the live content (as it does right after `sync`).
2047
+ Legacy slides without a src marker can't be checked and are allowed.
2048
+ """
2049
+ live = {s["objectId"]: s for s in pres.get("slides", [])}
2050
+ by_kh = {s.key_hash: s for s in source}
2051
+ out = []
2052
+ for kh, (oid, _ch) in managed.items():
2053
+ sl = by_kh.get(kh)
2054
+ replacing = sl is not None and sl.custom is None and sl.object_id != oid
2055
+ if not (replacing or oid in pruned) or live.get(oid) is None:
2056
+ continue
2057
+ live_lines, live_notes, base_src, marker = _live_state(live[oid])
2058
+ if base_src is None:
2059
+ continue
2060
+ if (live_lines == _content_lines(base_src, marker.get("template"))
2061
+ and live_notes == " ".join(_extract_notes(base_src).split())):
2062
+ continue # deck untouched since last push
2063
+ if (sl is not None
2064
+ and live_lines == _content_lines(sl.src or "", sl.template_name)
2065
+ and live_notes == " ".join((sl.notes or "").split())):
2066
+ continue # local already carries the live edit
2067
+ out.append(sl.key if sl is not None else oid)
2068
+ return out
2069
+
2070
+
2071
+ def _slide_from_live_boxes(s, marker: dict) -> Slide:
2072
+ """Rebuild a template slide's content from its deterministically-named boxes
2073
+ (`_k` kicker, `_h` headline, `_by` byline, `_b` body), formatting runs
2074
+ included — so live text edits can be written back into the markdown."""
2075
+ sid = s["objectId"]
2076
+ shapes = {el.get("objectId", ""): el["shape"]
2077
+ for el in s.get("pageElements", [])
2078
+ if el.get("shape", {}).get("text")}
2079
+
2080
+ def paras(suffix: str) -> list[Para]:
2081
+ shape = shapes.get(sid + suffix)
2082
+ return _paras_from_shape(shape) if shape else []
2083
+
2084
+ slide = Slide(marker.get("id", sid), "content")
2085
+ slide.template_name = marker.get("template")
2086
+ slide.vars = marker.get("vars", {})
2087
+ if (slide.template_name or "").lower() in ("prompt", "code"):
2088
+ slide.title = _flatten(paras("_k")).strip()
2089
+ slide.verbatim = "\n".join(p.text for p in paras("_b"))
2090
+ return slide
2091
+ headline = _flatten(paras("_h")).strip()
2092
+ kicker = _flatten(paras("_k")).strip()
2093
+ if headline:
2094
+ slide.title, slide.kicker = headline, kicker
2095
+ else:
2096
+ slide.title = kicker # content template: the kicker IS the title
2097
+ slide.paras = paras("_b") or paras("_by")
2098
+ if marker.get("img"):
2099
+ slide.layout = "image"
2100
+ slide.image, slide.image_alt = marker["img"], marker.get("alt", "")
2101
+ return slide
2102
+
2103
+
2104
+ def _render_body(slide: Slide) -> str:
2105
+ """Slide -> markdown body only (the slide's frontmatter stays as authored)."""
2106
+ bare = copy.copy(slide)
2107
+ bare.template_name, bare.layout_name, bare.vars = None, None, {}
2108
+ return to_slidev(bare, include_id=False).strip()
2109
+
2110
+
2111
+ _SEP_RE = re.compile(r"(?m)^---[ \t]*$")
2112
+
2113
+
2114
+ def _slide_span(text: str, key: str) -> tuple[int, int] | None:
2115
+ """(start, end) of the body of the slide whose frontmatter id is `key`."""
2116
+ m = re.search(rf"(?m)^id:\s*{re.escape(key)}\s*$", text)
2117
+ if not m:
2118
+ return None # slide has no id: frontmatter — can't anchor file surgery
2119
+ closer = _SEP_RE.search(text, m.end())
2120
+ if not closer:
2121
+ return None
2122
+ nxt = _SEP_RE.search(text, closer.end())
2123
+ return closer.end(), nxt.start() if nxt else len(text)
2124
+
2125
+
2126
+ def _replace_slide_body(text: str, key: str, body: str) -> str:
2127
+ span = _slide_span(text, key)
2128
+ if span is None:
2129
+ return text
2130
+ return text[:span[0]] + "\n" + body.strip("\n") + "\n\n" + text[span[1]:]
2131
+
2132
+
2133
+ def _append_to_slide_body(text: str, key: str, block: str) -> str:
2134
+ span = _slide_span(text, key)
2135
+ if span is None:
2136
+ return text
2137
+ return text[:span[1]].rstrip("\n") + "\n" + block + "\n\n" + text[span[1]:]
2138
+
2139
+
2007
2140
  def cmd_sync(args):
2008
- """Report per-slide drift between a markdown deck and its live Slides copy.
2009
-
2010
- Detection, not resolution: each slide is classified against the marker's
2011
- last-pushed source (the merge base). Additive changes (comments, notes-pane
2012
- edits) are printed as ready-to-paste `<!-- @Author: ... -->` blocks; text
2013
- conflicts are printed as unified diffs for a human/LLM to fold into the
2014
- markdown, after which a normal `push` (or `push --force` to clobber
2015
- live-only drift) makes the file authoritative again. Exits 1 if anything
2016
- drifted.
2141
+ """Reconcile a markdown deck with its live Slides copy — applying what's safe.
2142
+
2143
+ Pull side: unresolved comment threads are appended to their slide as
2144
+ `<!-- @Author: text -->` blocks (orphaned threads are re-anchored via the
2145
+ objectId's key-hash), and live-drift slides edited in Slides, untouched
2146
+ locally are written back into the markdown, reconstructed from their
2147
+ styled boxes. Conflict slides (both sides changed) are never touched: their
2148
+ diffs print for a human/LLM to resolve, and the push step is skipped.
2149
+ Push side: when no conflicts remain, the (updated) file is pushed — safe,
2150
+ because local now matches live wherever the deck had drifted. Exits 1 when
2151
+ conflicts remain.
2017
2152
  """
2018
2153
  slides_api, drive = get_services(args.account)
2019
- source = load_slides(args.source)
2020
- deck = args.deck or deck_from_source(args.source)
2154
+ path = args.source
2155
+ source = load_slides(path)
2156
+ deck = args.deck or deck_from_source(path)
2021
2157
  if not deck:
2022
2158
  sys.exit("no target deck: pass --deck or add `deck:` frontmatter")
2023
2159
  pres = slides_api.presentations().get(presentationId=deck).execute()
@@ -2027,59 +2163,83 @@ def cmd_sync(args):
2027
2163
  for c in shape_comments(list_comments(drive, deck)):
2028
2164
  if not c["resolved"] and c["page"]:
2029
2165
  by_page.setdefault(c["page"], []).append(c)
2030
- drifted = 0
2166
+
2167
+ state = {"text": path.read_text(), "dirty": False, "pushable": False}
2168
+ key_by_kh = {sl.key_hash: sl.key for sl in source}
2169
+
2170
+ def capture(key: str | None, c: dict, page: str) -> None:
2171
+ lines = [f"@{c['author']}: {c['content']}"]
2172
+ lines += [f"@{r['author']}: {r['content']}" for r in c["replies"]]
2173
+ block = "<!-- " + "\n".join(lines) + " -->"
2174
+ if " ".join(c["content"].split()) in " ".join(state["text"].split()):
2175
+ return # already captured
2176
+ new = _append_to_slide_body(state["text"], key, block) if key else state["text"]
2177
+ if new != state["text"]:
2178
+ state.update(text=new, dirty=True, pushable=True)
2179
+ logger.info(f"[comment ] {key} — captured thread by {c['author']}")
2180
+ else:
2181
+ logger.warning(f"[comment ] thread by {c['author']} on {key or page} "
2182
+ f"couldn't be placed; paste manually:\n{block}")
2183
+
2184
+ conflicts = []
2031
2185
  for sl in source:
2032
2186
  s = live.pop(sl.key_hash, None)
2033
2187
  if s is None:
2034
- logger.warning(f"[missing ] {sl.key} — not in deck; push will create it")
2035
- drifted += 1
2188
+ logger.info(f"[missing ] {sl.key} — push will create it")
2189
+ state["pushable"] = True
2036
2190
  continue
2037
2191
  for c in by_page.pop(s["objectId"], []):
2038
- drifted += 1
2039
- lines = [f"@{c['author']}: {c['content']}"]
2040
- lines += [f"@{r['author']}: {r['content']}" for r in c["replies"]]
2041
- logger.info(f"[comment ] {sl.key} ({c['modified']}):\n"
2042
- + "<!-- " + "\n".join(lines) + " -->")
2192
+ capture(sl.key, c, s["objectId"])
2043
2193
  if sl.custom is not None:
2044
- continue # pull-authoritative; drawings are captured, not diffed
2045
- notes_raw = _read_notes(s)
2046
- marker = _read_marker(notes_raw)
2047
- base_src = (base64.b64decode(marker["src"]).decode()
2048
- if "src" in marker else None)
2049
- base = text_lines_md(base_src) if base_src is not None else None
2050
- local = text_lines_md(sl.src or "")
2051
- live_lines = text_lines_native(s)
2194
+ continue # pull-authoritative; drawings are captured by pull, not diffed
2195
+ live_lines, live_notes, base_src, marker = _live_state(s)
2196
+ base = _content_lines(base_src, marker.get("template"))
2197
+ local = _content_lines(sl.src or "", sl.template_name) or []
2052
2198
  status = classify_drift(base, local, live_lines)
2053
- pushed_at = marker.get("at", "?")
2054
2199
  if status in ("clean", "converged"):
2055
2200
  continue
2056
- drifted += 1
2057
2201
  if status == "local-edit":
2058
2202
  logger.info(f"[local-edit] {sl.key} — push will update it")
2059
- elif status in ("live-drift", "drift-no-base"):
2060
- logger.warning(f"[live-drift] {sl.key} (pushed {pushed_at}) — edited in "
2061
- f"Slides; fold into the markdown or `push --force` to clobber:\n"
2062
- + _diff(base if base is not None else local, live_lines,
2063
- "last-pushed" if base is not None else "local", "live"))
2064
- else: # conflict
2065
- logger.error(f"[conflict ] {sl.key} (pushed {pushed_at}) — both sides "
2066
- f"changed since last push:\n"
2067
- + _diff(base, local, "last-pushed", "local") + "\n"
2068
- + _diff(base, live_lines, "last-pushed", "live"))
2069
- live_notes = " ".join(MARKER_RE.sub("", notes_raw).split())
2070
- base_notes = " ".join(_extract_notes(base_src).split()) if base_src else ""
2071
- if base_src is not None and live_notes != base_notes:
2072
- logger.info(f"[notes ] {sl.key} notes pane edited live:\n{live_notes}")
2073
- for kh, s in live.items():
2074
- logger.warning(f"[unmanaged ] {s['objectId']} in deck has no local slide "
2075
- "(--prune on push would delete it)")
2076
- for page, cs in by_page.items():
2203
+ state["pushable"] = True
2204
+ continue
2205
+ rebuilt = (_slide_from_live_boxes(s, marker)
2206
+ if status in ("live-drift", "drift-no-base")
2207
+ and marker.get("template") else None)
2208
+ if rebuilt and (rebuilt.title or rebuilt.paras or rebuilt.verbatim):
2209
+ rebuilt.notes = " ".join(MARKER_RE.sub("", _read_notes(s)).split())
2210
+ new = _replace_slide_body(state["text"], sl.key, _render_body(rebuilt))
2211
+ if new != state["text"]:
2212
+ state.update(text=new, dirty=True, pushable=True)
2213
+ logger.info(f"[pulled ] {sl.key} — live edit written back")
2214
+ continue
2215
+ conflicts.append(sl.key)
2216
+ logger.error(f"[conflict ] {sl.key} (pushed {marker.get('at', '?')}) "
2217
+ f"resolve in the markdown, then push:\n"
2218
+ + _diff(base or [], text_lines_md(sl.src or ""),
2219
+ "last-pushed", "local") + "\n"
2220
+ + _diff(base or [], live_lines, "last-pushed", "live"))
2221
+ for page, cs in by_page.items(): # threads on re-rendered (deleted) pages
2222
+ m = re.match(r"s2g_(?P<kh>[0-9a-f]{10})_", page)
2223
+ key = key_by_kh.get(m.group("kh")) if m else None
2077
2224
  for c in cs:
2078
- logger.warning(f"[orphaned ] comment by {c['author']} anchored to deleted "
2079
- f"slide {page}: {c['content']!r}")
2080
- logger.log("WARNING" if drifted else "SUCCESS",
2081
- f"{drifted} drifted item(s)" if drifted else "deck matches source")
2082
- sys.exit(1 if drifted else 0)
2225
+ capture(key, c, page)
2226
+ for _kh, s in live.items():
2227
+ logger.warning(f"[unmanaged ] {s['objectId']} has no local slide "
2228
+ "(push --prune would delete it)")
2229
+
2230
+ if state["dirty"]:
2231
+ path.write_text(state["text"])
2232
+ logger.success(f"updated {path}")
2233
+ if conflicts:
2234
+ sys.exit(f"sync stopped: {len(conflicts)} conflict(s) above — resolve in "
2235
+ "the markdown, then `slidesync push` (its guard protects the rest).")
2236
+ if state["pushable"]:
2237
+ stats = push(slides_api, drive, deck, load_slides(path), anchor=None,
2238
+ prune=False, base_dir=path.parent)
2239
+ logger.success(f"{stats} -> "
2240
+ f"https://docs.google.com/presentation/d/{deck}/edit")
2241
+ else:
2242
+ logger.success("deck matches source — nothing to do")
2083
2243
 
2084
2244
 
2085
2245
  def _loop_hop(slides_api, drive, title, slides):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.3.0
3
+ Version: 0.4.1
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.3.0
25
+ Version: 0.4.1
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -58,13 +58,13 @@ 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 |
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]` | report drift vs the live deck comments, live edits, conflicts (exit 1 on drift) |
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) |
68
68
 
69
69
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
70
70
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -89,24 +89,32 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
89
89
  path, template vars — and, for template slides, the authored body markdown
90
90
  (base64) — so `pull` recovers the source verbatim.
91
91
 
92
- ## Sync & drift (detection, not resolution)
93
-
94
- The marker's last-pushed source is a true per-slide **merge base**, so `sync`
95
- classifies each slide three-way without timestamps (the Slides API has no
96
- per-slide edit times only file-level `modifiedTime`; the marker's `at` stamp
97
- records when *we* last pushed each slide):
98
-
99
- | status | meaning | action |
100
- |--------|---------|--------|
101
- | `clean` / `converged` | nothing changed, or both sides made the same change | — |
102
- | `local-edit` | markdown changed, deck untouched | `push` (normal) |
103
- | `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
104
- | `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
105
-
106
- Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
107
- blocks on their slide (replies as extra `@Author:` lines) paste them into the
108
- source, where they round-trip from then on. Threads anchor to slide objectIds,
109
- so a re-render orphans them: `sync` reports those too. Capture before you push.
92
+ ## Sync & drift
93
+
94
+ `push` is guarded like a non-fast-forward git push: if a slide it would replace
95
+ (or prune) was edited in Google Slides since the last push and the local
96
+ markdown doesn't already carry that edit the push is **rejected** with no
97
+ changes made (`--force` overwrites). Live edits on slides the push wouldn't
98
+ touch are left alone.
99
+
100
+ `sync` reconciles the two sides, applying whatever is safe. The marker's
101
+ last-pushed source is a true per-slide **merge base**, so each slide classifies
102
+ three-way without timestamps (the APIs expose no per-slide edit times — only
103
+ file-level `modifiedTime`; the marker's `at` stamp records our last push):
104
+
105
+ | status | meaning | sync does |
106
+ |--------|---------|-----------|
107
+ | `clean` / `converged` | nothing changed, or both sides made the same change | nothing |
108
+ | `local-edit` | markdown changed, deck untouched | pushes it |
109
+ | `live-drift` | slide edited in Google Slides | writes the live content back into the markdown (reconstructed from its styled boxes, formatting runs included), then pushes |
110
+ | `conflict` | both changed since last push | prints both diffs vs the base for a human/LLM to resolve; skips the push; exits 1 |
111
+
112
+ Unresolved comment threads are appended to their slide as
113
+ `<!-- @Author: text -->` blocks (replies as extra `@Author:` lines), and threads
114
+ orphaned by a re-render are re-anchored to their slide via the objectId's
115
+ key-hash. Captured text round-trips from then on. Write-back caveat: a slide
116
+ edited live is rewritten canonically, so its authored comments collapse into
117
+ one trailing block (untouched slides keep comments in place).
110
118
 
111
119
  ## Markdown dialect
112
120
 
@@ -11,6 +11,7 @@ slidesync.egg-info/entry_points.txt
11
11
  slidesync.egg-info/requires.txt
12
12
  slidesync.egg-info/top_level.txt
13
13
  tests/test_comment_preservation.py
14
+ tests/test_e2e_scenarios.py
14
15
  tests/test_markdown.py
15
16
  tests/test_pull.py
16
17
  tests/test_sync_drift.py
@@ -0,0 +1,320 @@
1
+ """End-to-end scenarios for push/sync against an in-memory Slides+Drive fake.
2
+
3
+ The fake interprets exactly the request types slidesync emits (createSlide,
4
+ deleteObject, createShape, insertText, deleteText, updateSlidesPosition) and
5
+ renders state back in the wire format `presentations.get` returns — so push,
6
+ the non-fast-forward guard, and sync's pull-back/capture all run unmodified.
7
+ """
8
+
9
+ import json
10
+ import re
11
+ from types import SimpleNamespace
12
+
13
+ import pytest
14
+
15
+ from slidesync import _sync
16
+ from slidesync._sync import cmd_sync, load_slides, push
17
+
18
+ DECK = "fakedeck"
19
+
20
+ MD = """---
21
+ theme: seriph
22
+ ---
23
+
24
+ ---
25
+ template: topic
26
+ id: thread-a
27
+ ---
28
+ # Takeaway A
29
+ ## FINDING · DATA
30
+
31
+ One solid point.
32
+
33
+ <!-- presenter note A -->
34
+
35
+ ---
36
+ ---
37
+ template: content
38
+ id: overview
39
+ ---
40
+ ## OVERVIEW
41
+
42
+ First result line.
43
+ """
44
+
45
+
46
+ def _text_els(text):
47
+ els = []
48
+ for line in text.split("\n"):
49
+ els.append({"paragraphMarker": {}})
50
+ els.append({"textRun": {"content": line + "\n", "style": {}}})
51
+ return els if text else []
52
+
53
+
54
+ class FakeStore:
55
+ """In-memory deck: ordered slides, each with text shapes + speaker notes."""
56
+
57
+ def __init__(self):
58
+ self.slides = [] # {"id", "shapes": {sid: {"y", "x", "text"}}, "notes"}
59
+
60
+ def _slide(self, oid):
61
+ return next(s for s in self.slides if s["id"] == oid)
62
+
63
+ def _owner_of_shape(self, sid):
64
+ for s in self.slides:
65
+ if sid in s["shapes"] or sid == s["id"] + "_n":
66
+ return s
67
+ raise KeyError(sid)
68
+
69
+ def apply(self, reqs):
70
+ for r in reqs:
71
+ if "createSlide" in r:
72
+ self.slides.append({"id": r["createSlide"]["objectId"],
73
+ "shapes": {}, "notes": ""})
74
+ elif "deleteObject" in r:
75
+ self.slides = [s for s in self.slides
76
+ if s["id"] != r["deleteObject"]["objectId"]]
77
+ elif "createShape" in r:
78
+ ep = r["createShape"]["elementProperties"]
79
+ tr = ep.get("transform", {})
80
+ self._slide(ep["pageObjectId"])["shapes"][
81
+ r["createShape"]["objectId"]] = {
82
+ "y": tr.get("translateY", 0), "x": tr.get("translateX", 0),
83
+ "text": ""}
84
+ elif "insertText" in r:
85
+ sid = r["insertText"]["objectId"]
86
+ s = self._owner_of_shape(sid)
87
+ if sid == s["id"] + "_n":
88
+ s["notes"] += r["insertText"]["text"]
89
+ else:
90
+ s["shapes"][sid]["text"] += r["insertText"]["text"]
91
+ elif "deleteText" in r:
92
+ sid = r["deleteText"]["objectId"]
93
+ s = self._owner_of_shape(sid)
94
+ if sid == s["id"] + "_n":
95
+ s["notes"] = ""
96
+ else:
97
+ s["shapes"][sid]["text"] = ""
98
+ elif "updateSlidesPosition" in r:
99
+ oid = r["updateSlidesPosition"]["slideObjectIds"][0]
100
+ moved = self._slide(oid)
101
+ rest = [s for s in self.slides if s["id"] != oid]
102
+ rest.insert(r["updateSlidesPosition"]["insertionIndex"], moved)
103
+ self.slides = rest
104
+ # styling-only requests are ignored
105
+ return {}
106
+
107
+ def render(self):
108
+ out = []
109
+ for s in self.slides:
110
+ els = [{"objectId": sid,
111
+ "transform": {"translateY": sh["y"], "translateX": sh["x"]},
112
+ "shape": {"text": {"textElements": _text_els(sh["text"])}}}
113
+ for sid, sh in s["shapes"].items() if sh["text"]]
114
+ nid = s["id"] + "_n"
115
+ out.append({
116
+ "objectId": s["id"], "pageElements": els,
117
+ "slideProperties": {"notesPage": {
118
+ "notesProperties": {"speakerNotesObjectId": nid},
119
+ "pageElements": [{"objectId": nid, "shape": {
120
+ "text": {"textElements": _text_els(s["notes"])}}}]}}})
121
+ return {"slides": out, "layouts": []}
122
+
123
+ # test helpers ---------------------------------------------------------
124
+ def oid_of(self, needle):
125
+ for s in self.slides:
126
+ if any(needle in sh["text"] for sh in s["shapes"].values()):
127
+ return s["id"]
128
+ raise AssertionError(f"no live slide contains {needle!r}")
129
+
130
+ def edit_text(self, old, new):
131
+ for s in self.slides:
132
+ for sh in s["shapes"].values():
133
+ if old in sh["text"]:
134
+ sh["text"] = sh["text"].replace(old, new)
135
+ return
136
+ raise AssertionError(f"no live shape contains {old!r}")
137
+
138
+ def all_text(self):
139
+ return " ".join(sh["text"] for s in self.slides
140
+ for sh in s["shapes"].values())
141
+
142
+
143
+ class _Req:
144
+ def __init__(self, fn):
145
+ self.execute = fn
146
+
147
+
148
+ class FakeSlides:
149
+ def __init__(self, store):
150
+ self.store = store
151
+
152
+ def presentations(self):
153
+ return self
154
+
155
+ def get(self, presentationId, fields=None):
156
+ return _Req(lambda: self.store.render())
157
+
158
+ def batchUpdate(self, presentationId, body):
159
+ return _Req(lambda: self.store.apply(body["requests"]))
160
+
161
+
162
+ class FakeDrive:
163
+ def __init__(self):
164
+ self.threads = [] # raw Drive comment dicts
165
+
166
+ def comments(self):
167
+ return self
168
+
169
+ def list(self, fileId, pageSize=None, pageToken=None, fields=None):
170
+ return _Req(lambda: {"comments": self.threads})
171
+
172
+ def add(self, page_oid, content, author="Daniel Hails"):
173
+ self.threads.append({
174
+ "id": f"c{len(self.threads)}",
175
+ "anchor": json.dumps({"type": "page", "pages": [page_oid]}),
176
+ "author": {"displayName": author},
177
+ "content": content, "replies": []})
178
+
179
+
180
+ @pytest.fixture
181
+ def env(tmp_path, monkeypatch):
182
+ store, drive = FakeStore(), FakeDrive()
183
+ slides_api = FakeSlides(store)
184
+ monkeypatch.setattr(_sync, "get_services", lambda account: (slides_api, drive))
185
+ path = tmp_path / "deck.slidev.md"
186
+ path.write_text(MD)
187
+ push(slides_api, drive, DECK, load_slides(path), anchor=None, prune=False,
188
+ base_dir=tmp_path)
189
+ return SimpleNamespace(store=store, drive=drive, slides=slides_api, path=path)
190
+
191
+
192
+ def _sync_cmd(env):
193
+ cmd_sync(SimpleNamespace(source=env.path, deck=DECK, account=None))
194
+
195
+
196
+ def _push(env, force=False, prune=False):
197
+ return push(env.slides, env.drive, DECK, load_slides(env.path), anchor=None,
198
+ prune=prune, base_dir=env.path.parent, force=force)
199
+
200
+
201
+ def _local_edit(env):
202
+ env.path.write_text(env.path.read_text().replace(
203
+ "One solid point.", "One solid point, sharpened."))
204
+
205
+
206
+ def _live_edit(env):
207
+ env.store.edit_text("Takeaway A", "Takeaway A, live-edited")
208
+
209
+
210
+ SCENARIOS = {
211
+ "clean": dict(setup=[], sync_conflicts=False,
212
+ file_has=[], live_has=["Takeaway A"]),
213
+ "local-edit": dict(setup=[_local_edit], sync_conflicts=False,
214
+ file_has=["sharpened"], live_has=["sharpened"]),
215
+ "live-drift": dict(setup=[_live_edit], sync_conflicts=False,
216
+ file_has=["live-edited"], live_has=["live-edited"]),
217
+ "conflict": dict(setup=[_live_edit,
218
+ lambda e: e.path.write_text(e.path.read_text().replace(
219
+ "Takeaway A", "Takeaway A, locally-reworded"))],
220
+ sync_conflicts=True,
221
+ file_has=["locally-reworded"], live_has=["live-edited"]),
222
+ }
223
+
224
+
225
+ @pytest.mark.parametrize("name", SCENARIOS)
226
+ def test_sync_scenarios(env, name):
227
+ sc = SCENARIOS[name]
228
+ for step in sc["setup"]:
229
+ step(env)
230
+ if sc["sync_conflicts"]:
231
+ with pytest.raises(SystemExit):
232
+ _sync_cmd(env)
233
+ else:
234
+ _sync_cmd(env)
235
+ text, live = env.path.read_text(), env.store.all_text()
236
+ for needle in sc["file_has"]:
237
+ assert needle in text, f"{name}: {needle!r} missing from markdown"
238
+ for needle in sc["live_has"]:
239
+ assert needle in live, f"{name}: {needle!r} missing from deck"
240
+ if not sc["sync_conflicts"]: # reconciled: a follow-up sync is a no-op
241
+ before = env.path.read_text()
242
+ _sync_cmd(env)
243
+ assert env.path.read_text() == before
244
+
245
+
246
+ def test_push_rejects_conflicting_replace_and_force_overrides(env):
247
+ _live_edit(env)
248
+ env.path.write_text(env.path.read_text().replace(
249
+ "Takeaway A", "Takeaway A, locally-reworded"))
250
+ with pytest.raises(SystemExit):
251
+ _push(env)
252
+ assert "live-edited" in env.store.all_text() # guard left the deck intact
253
+ _push(env, force=True)
254
+ assert "locally-reworded" in env.store.all_text()
255
+ assert "live-edited" not in env.store.all_text()
256
+
257
+
258
+ def test_push_rejects_pruning_a_live_edited_slide(env):
259
+ _live_edit(env)
260
+ env.path.write_text(re.sub(r"(?ms)^---\ntemplate: topic\n.*?(?=^---$\n---\n)",
261
+ "", env.path.read_text(), count=1))
262
+ assert "thread-a" not in env.path.read_text()
263
+ with pytest.raises(SystemExit):
264
+ _push(env, prune=True)
265
+ _push(env, prune=True, force=True)
266
+ assert "Takeaway A" not in env.store.all_text()
267
+
268
+
269
+ def test_push_ignores_live_drift_on_untouched_slides(env):
270
+ # No local change -> nothing is replaced -> nothing can be lost -> no guard.
271
+ _live_edit(env)
272
+ stats = _push(env)
273
+ assert stats["replace"] == 0
274
+ assert "live-edited" in env.store.all_text()
275
+
276
+
277
+ def test_sync_captures_comment_onto_its_slide(env):
278
+ env.drive.add(env.store.oid_of("Takeaway A"), "This is obviously dodgy. To debug")
279
+ _sync_cmd(env)
280
+ text = env.path.read_text()
281
+ assert "<!-- @Daniel Hails: This is obviously dodgy. To debug -->" in text
282
+ assert text.index("Takeaway A") < text.index("obviously dodgy") < text.index("OVERVIEW")
283
+ _sync_cmd(env) # idempotent: captured text is not duplicated
284
+ assert env.path.read_text().count("obviously dodgy") == 1
285
+
286
+
287
+ def test_sync_recaptures_orphaned_comment_via_key_hash(env):
288
+ env.drive.add(env.store.oid_of("Takeaway A"), "anchored before the re-render")
289
+ _local_edit(env)
290
+ _push(env) # re-render replaces the slide; the thread's page id now dangles
291
+ _sync_cmd(env)
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))
320
+ assert path.read_text() == before # no write-back, no conflict, no churn
File without changes
File without changes
File without changes
File without changes