slidesync 0.2.0__tar.gz → 0.4.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.2.0
3
+ Version: 0.4.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.2.0
25
+ Version: 0.4.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -58,11 +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
+ | `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) |
66
68
 
67
69
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
68
70
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -87,6 +89,33 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
89
  path, template vars — and, for template slides, the authored body markdown
88
90
  (base64) — so `pull` recovers the source verbatim.
89
91
 
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).
118
+
90
119
  ## Markdown dialect
91
120
 
92
121
  Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
@@ -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.2.0
7
+ Version: 0.4.0
8
8
 
9
9
  ```bash
10
10
  uvx slidesync --help # run without installing
@@ -40,11 +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
+ | `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) |
48
50
 
49
51
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
50
52
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -69,6 +71,33 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
69
71
  path, template vars — and, for template slides, the authored body markdown
70
72
  (base64) — so `pull` recovers the source verbatim.
71
73
 
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).
100
+
72
101
  ## Markdown dialect
73
102
 
74
103
  Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slidesync"
3
- version = "0.2.0"
3
+ version = "0.4.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.2.0"
35
+ current_version = "0.4.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.2.0"
33
+ __version__ = "0.4.0"
34
34
 
35
35
  __all__ = [
36
36
  "Para",
@@ -36,6 +36,9 @@ from __future__ import annotations
36
36
 
37
37
  import argparse
38
38
  import base64
39
+ import copy
40
+ import datetime
41
+ import difflib
39
42
  import hashlib
40
43
  import html
41
44
  import json
@@ -580,6 +583,10 @@ def _marker(slide: Slide) -> str:
580
583
  data["src"] = base64.b64encode(slide.src.encode()).decode()
581
584
  elif slide.layout_name and slide.layout_name not in SECTION_LAYOUTS:
582
585
  data["tpl"] = slide.layout_name
586
+ # Last-push stamp: `sync` reports it alongside drift. The Slides/Drive APIs
587
+ # have no per-slide edit times (file-level modifiedTime only), so this is
588
+ # the only per-slide timestamp that exists.
589
+ data["at"] = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
583
590
  return f"<!-- s2g {json.dumps(data, separators=(',', ':'))} -->"
584
591
 
585
592
 
@@ -1141,6 +1148,16 @@ def push(slides_api, drive, deck, source, anchor, prune, base_dir=Path("."),
1141
1148
  creates, deletes, skips, pruned = plan_sync(source, managed, prune, force)
1142
1149
  if not (creates or deletes or pruned): # nothing changed — skip reorder/links/gets
1143
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.")
1144
1161
  layouts = _layout_map(slides_api, deck, pres)
1145
1162
  templates = _template_index(slides_api, deck, pres)
1146
1163
  reqs = [{"deleteObject": {"objectId": oid}} for oid in deletes + pruned]
@@ -1890,6 +1907,325 @@ def cmd_layouts(args):
1890
1907
  logger.info(f"{name:<24} {', '.join(phs) or '(no placeholders)'}")
1891
1908
 
1892
1909
 
1910
+ # ---------------------------------------------------------------------------
1911
+ # Comments + sync (drift detection)
1912
+ # ---------------------------------------------------------------------------
1913
+
1914
+
1915
+ def shape_comments(raw: list[dict]) -> list[dict]:
1916
+ """Drive comment threads -> [{id, page, author, content, resolved, replies}].
1917
+
1918
+ `page` is the anchored slide objectId (None for file-level comments) —
1919
+ note an objectId outlives its slide, so the anchor may point at a deleted
1920
+ page after a re-render. Resolve-action replies with no text are dropped.
1921
+ """
1922
+ out = []
1923
+ for c in raw:
1924
+ try:
1925
+ anchor = json.loads(c.get("anchor") or "{}")
1926
+ except json.JSONDecodeError:
1927
+ anchor = {}
1928
+ pages = anchor.get("pages") or []
1929
+ out.append({
1930
+ "id": c.get("id", ""),
1931
+ "page": pages[0] if pages else None,
1932
+ "author": (c.get("author") or {}).get("displayName", ""),
1933
+ "content": (c.get("content") or "").strip(),
1934
+ "resolved": bool(c.get("resolved")),
1935
+ "modified": c.get("modifiedTime", ""),
1936
+ "replies": [
1937
+ {"author": (r.get("author") or {}).get("displayName", ""),
1938
+ "content": (r.get("content") or "").strip()}
1939
+ for r in c.get("replies", []) if (r.get("content") or "").strip()
1940
+ ],
1941
+ })
1942
+ return out
1943
+
1944
+
1945
+ _COMMENT_FIELDS = ("nextPageToken,comments(id,content,author(displayName),"
1946
+ "anchor,resolved,modifiedTime,replies(content,author(displayName)))")
1947
+
1948
+
1949
+ def list_comments(drive, deck: str) -> list[dict]:
1950
+ raw, token = [], None
1951
+ while True:
1952
+ resp = drive.comments().list(fileId=deck, pageSize=100, pageToken=token,
1953
+ fields=_COMMENT_FIELDS).execute()
1954
+ raw += resp.get("comments", [])
1955
+ token = resp.get("nextPageToken")
1956
+ if not token:
1957
+ return raw
1958
+
1959
+
1960
+ def cmd_comments(args):
1961
+ _, drive = get_services(args.account)
1962
+ print(json.dumps(shape_comments(list_comments(drive, args.deck)), indent=2))
1963
+
1964
+
1965
+ def text_lines_md(src: str) -> list[str]:
1966
+ """Markdown slide body -> normalised visible text lines (comments excluded)."""
1967
+ fences = [m.group("text") for m in VERBATIM_RE.finditer(src)]
1968
+ headings, paras, _img, _alt, table, _ = parse_body(VERBATIM_RE.sub("", src))
1969
+ lines = [headings[k] for k in sorted(headings)]
1970
+ lines += [p.text for p in paras]
1971
+ if table:
1972
+ lines += [" | ".join(row) for row in table]
1973
+ for f in fences:
1974
+ lines += f.splitlines()
1975
+ return _norm_lines(lines)
1976
+
1977
+
1978
+ def text_lines_native(s: dict) -> list[str]:
1979
+ """Native slide JSON -> normalised visible text lines (notes excluded)."""
1980
+ boxes = []
1981
+ for el in s.get("pageElements", []):
1982
+ if el.get("shape", {}).get("text"):
1983
+ paras = _paras_from_shape(el["shape"])
1984
+ if paras:
1985
+ boxes.append((_el_y(el), _el_x(el), paras))
1986
+ elif el.get("table"):
1987
+ rows = _table_from_native(el["table"])
1988
+ boxes.append((_el_y(el), _el_x(el),
1989
+ [Para(" | ".join(r)) for r in rows]))
1990
+ boxes.sort(key=lambda t: (t[0], t[1]))
1991
+ return _norm_lines([p.text for _, _, paras in boxes for p in paras])
1992
+
1993
+
1994
+ def _norm_lines(lines: list[str]) -> list[str]:
1995
+ # Sorted: box reading-order vs markdown order differ legitimately (e.g. a
1996
+ # kicker renders above the headline but is written below it).
1997
+ return sorted(" ".join(line.split()) for line in lines if line.strip())
1998
+
1999
+
2000
+ def classify_drift(base: list[str] | None, local: list[str],
2001
+ live: list[str]) -> str:
2002
+ """Three-way status for one slide; base is the last-pushed source (marker)."""
2003
+ if base is None:
2004
+ return "clean" if local == live else "drift-no-base"
2005
+ if local == base and live == base:
2006
+ return "clean"
2007
+ if local != base and live == base:
2008
+ return "local-edit"
2009
+ if local == base and live != base:
2010
+ return "live-drift"
2011
+ return "converged" if local == live else "conflict"
2012
+
2013
+
2014
+ def _diff(a: list[str], b: list[str], a_name: str, b_name: str) -> str:
2015
+ return "\n".join(difflib.unified_diff(a, b, a_name, b_name, lineterm=""))
2016
+
2017
+
2018
+ def _live_state(s) -> tuple[list[str], str, str | None, dict]:
2019
+ """(text lines, normalised notes, base source, marker) of a live slide."""
2020
+ notes_raw = _read_notes(s)
2021
+ marker = _read_marker(notes_raw)
2022
+ base_src = base64.b64decode(marker["src"]).decode() if "src" in marker else None
2023
+ notes = " ".join(MARKER_RE.sub("", notes_raw).split())
2024
+ return text_lines_native(s), notes, base_src, marker
2025
+
2026
+
2027
+ def _clobber_risks(pres, managed, source, pruned) -> list[str]:
2028
+ """Keys of slides whose live edits a push would silently discard.
2029
+
2030
+ A replace (or prune) deletes the live slide, which loses information only
2031
+ when the live copy drifted from its merge base AND the local markdown does
2032
+ not already contain the live content (as it does right after `sync`).
2033
+ Legacy slides without a src marker can't be checked and are allowed.
2034
+ """
2035
+ live = {s["objectId"]: s for s in pres.get("slides", [])}
2036
+ by_kh = {s.key_hash: s for s in source}
2037
+ out = []
2038
+ for kh, (oid, _ch) in managed.items():
2039
+ sl = by_kh.get(kh)
2040
+ replacing = sl is not None and sl.custom is None and sl.object_id != oid
2041
+ if not (replacing or oid in pruned) or live.get(oid) is None:
2042
+ continue
2043
+ live_lines, live_notes, base_src, _marker_ = _live_state(live[oid])
2044
+ if base_src is None:
2045
+ continue
2046
+ if (live_lines == text_lines_md(base_src)
2047
+ and live_notes == " ".join(_extract_notes(base_src).split())):
2048
+ continue # deck untouched since last push
2049
+ if (sl is not None and live_lines == text_lines_md(sl.src or "")
2050
+ and live_notes == " ".join((sl.notes or "").split())):
2051
+ continue # local already carries the live edit
2052
+ out.append(sl.key if sl is not None else oid)
2053
+ return out
2054
+
2055
+
2056
+ def _slide_from_live_boxes(s, marker: dict) -> Slide:
2057
+ """Rebuild a template slide's content from its deterministically-named boxes
2058
+ (`_k` kicker, `_h` headline, `_by` byline, `_b` body), formatting runs
2059
+ included — so live text edits can be written back into the markdown."""
2060
+ sid = s["objectId"]
2061
+ shapes = {el.get("objectId", ""): el["shape"]
2062
+ for el in s.get("pageElements", [])
2063
+ if el.get("shape", {}).get("text")}
2064
+
2065
+ def paras(suffix: str) -> list[Para]:
2066
+ shape = shapes.get(sid + suffix)
2067
+ return _paras_from_shape(shape) if shape else []
2068
+
2069
+ slide = Slide(marker.get("id", sid), "content")
2070
+ slide.template_name = marker.get("template")
2071
+ slide.vars = marker.get("vars", {})
2072
+ if (slide.template_name or "").lower() in ("prompt", "code"):
2073
+ slide.title = _flatten(paras("_k")).strip()
2074
+ slide.verbatim = "\n".join(p.text for p in paras("_b"))
2075
+ return slide
2076
+ headline = _flatten(paras("_h")).strip()
2077
+ kicker = _flatten(paras("_k")).strip()
2078
+ if headline:
2079
+ slide.title, slide.kicker = headline, kicker
2080
+ else:
2081
+ slide.title = kicker # content template: the kicker IS the title
2082
+ slide.paras = paras("_b") or paras("_by")
2083
+ if marker.get("img"):
2084
+ slide.layout = "image"
2085
+ slide.image, slide.image_alt = marker["img"], marker.get("alt", "")
2086
+ return slide
2087
+
2088
+
2089
+ def _render_body(slide: Slide) -> str:
2090
+ """Slide -> markdown body only (the slide's frontmatter stays as authored)."""
2091
+ bare = copy.copy(slide)
2092
+ bare.template_name, bare.layout_name, bare.vars = None, None, {}
2093
+ return to_slidev(bare, include_id=False).strip()
2094
+
2095
+
2096
+ _SEP_RE = re.compile(r"(?m)^---[ \t]*$")
2097
+
2098
+
2099
+ def _slide_span(text: str, key: str) -> tuple[int, int] | None:
2100
+ """(start, end) of the body of the slide whose frontmatter id is `key`."""
2101
+ m = re.search(rf"(?m)^id:\s*{re.escape(key)}\s*$", text)
2102
+ if not m:
2103
+ return None # slide has no id: frontmatter — can't anchor file surgery
2104
+ closer = _SEP_RE.search(text, m.end())
2105
+ if not closer:
2106
+ return None
2107
+ nxt = _SEP_RE.search(text, closer.end())
2108
+ return closer.end(), nxt.start() if nxt else len(text)
2109
+
2110
+
2111
+ def _replace_slide_body(text: str, key: str, body: str) -> str:
2112
+ span = _slide_span(text, key)
2113
+ if span is None:
2114
+ return text
2115
+ return text[:span[0]] + "\n" + body.strip("\n") + "\n\n" + text[span[1]:]
2116
+
2117
+
2118
+ def _append_to_slide_body(text: str, key: str, block: str) -> str:
2119
+ span = _slide_span(text, key)
2120
+ if span is None:
2121
+ return text
2122
+ return text[:span[1]].rstrip("\n") + "\n" + block + "\n\n" + text[span[1]:]
2123
+
2124
+
2125
+ def cmd_sync(args):
2126
+ """Reconcile a markdown deck with its live Slides copy — applying what's safe.
2127
+
2128
+ Pull side: unresolved comment threads are appended to their slide as
2129
+ `<!-- @Author: text -->` blocks (orphaned threads are re-anchored via the
2130
+ objectId's key-hash), and live-drift slides — edited in Slides, untouched
2131
+ locally — are written back into the markdown, reconstructed from their
2132
+ styled boxes. Conflict slides (both sides changed) are never touched: their
2133
+ diffs print for a human/LLM to resolve, and the push step is skipped.
2134
+ Push side: when no conflicts remain, the (updated) file is pushed — safe,
2135
+ because local now matches live wherever the deck had drifted. Exits 1 when
2136
+ conflicts remain.
2137
+ """
2138
+ 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)
2142
+ if not deck:
2143
+ sys.exit("no target deck: pass --deck or add `deck:` frontmatter")
2144
+ pres = slides_api.presentations().get(presentationId=deck).execute()
2145
+ live = {s["objectId"].split("_")[1]: s for s in pres.get("slides", [])
2146
+ if MANAGED_RE.match(s["objectId"])}
2147
+ by_page = {}
2148
+ for c in shape_comments(list_comments(drive, deck)):
2149
+ if not c["resolved"] and c["page"]:
2150
+ by_page.setdefault(c["page"], []).append(c)
2151
+
2152
+ state = {"text": path.read_text(), "dirty": False, "pushable": False}
2153
+ key_by_kh = {sl.key_hash: sl.key for sl in source}
2154
+
2155
+ def capture(key: str | None, c: dict, page: str) -> None:
2156
+ lines = [f"@{c['author']}: {c['content']}"]
2157
+ lines += [f"@{r['author']}: {r['content']}" for r in c["replies"]]
2158
+ block = "<!-- " + "\n".join(lines) + " -->"
2159
+ if " ".join(c["content"].split()) in " ".join(state["text"].split()):
2160
+ 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']}")
2165
+ else:
2166
+ logger.warning(f"[comment ] thread by {c['author']} on {key or page} "
2167
+ f"couldn't be placed; paste manually:\n{block}")
2168
+
2169
+ conflicts = []
2170
+ for sl in source:
2171
+ s = live.pop(sl.key_hash, None)
2172
+ if s is None:
2173
+ logger.info(f"[missing ] {sl.key} — push will create it")
2174
+ state["pushable"] = True
2175
+ continue
2176
+ for c in by_page.pop(s["objectId"], []):
2177
+ capture(sl.key, c, s["objectId"])
2178
+ if sl.custom is not None:
2179
+ continue # pull-authoritative; drawings are captured by pull, not diffed
2180
+ 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)
2183
+ if status in ("clean", "converged"):
2184
+ continue
2185
+ if status == "local-edit":
2186
+ logger.info(f"[local-edit] {sl.key} — push will update it")
2187
+ state["pushable"] = True
2188
+ continue
2189
+ rebuilt = (_slide_from_live_boxes(s, marker)
2190
+ if status in ("live-drift", "drift-no-base")
2191
+ and marker.get("template") else None)
2192
+ if rebuilt and (rebuilt.title or rebuilt.paras or rebuilt.verbatim):
2193
+ 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")
2198
+ continue
2199
+ conflicts.append(sl.key)
2200
+ logger.error(f"[conflict ] {sl.key} (pushed {marker.get('at', '?')}) — "
2201
+ f"resolve in the markdown, then push:\n"
2202
+ + _diff(base or [], text_lines_md(sl.src or ""),
2203
+ "last-pushed", "local") + "\n"
2204
+ + _diff(base or [], live_lines, "last-pushed", "live"))
2205
+ for page, cs in by_page.items(): # threads on re-rendered (deleted) pages
2206
+ 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
2208
+ for c in cs:
2209
+ capture(key, c, page)
2210
+ for _kh, s in live.items():
2211
+ logger.warning(f"[unmanaged ] {s['objectId']} has no local slide "
2212
+ "(push --prune would delete it)")
2213
+
2214
+ if state["dirty"]:
2215
+ path.write_text(state["text"])
2216
+ logger.success(f"updated {path}")
2217
+ if conflicts:
2218
+ sys.exit(f"sync stopped: {len(conflicts)} conflict(s) above — resolve in "
2219
+ "the markdown, then `slidesync push` (its guard protects the rest).")
2220
+ if state["pushable"]:
2221
+ stats = push(slides_api, drive, deck, load_slides(path), anchor=None,
2222
+ prune=False, base_dir=path.parent)
2223
+ logger.success(f"{stats} -> "
2224
+ f"https://docs.google.com/presentation/d/{deck}/edit")
2225
+ else:
2226
+ logger.success("deck matches source — nothing to do")
2227
+
2228
+
1893
2229
  def _loop_hop(slides_api, drive, title, slides):
1894
2230
  """One md->slides hop: build a deck, push, pull back."""
1895
2231
  deck = new_deck(slides_api, title)
@@ -1966,6 +2302,17 @@ def main():
1966
2302
  p.add_argument("deck")
1967
2303
  p.set_defaults(func=cmd_layouts)
1968
2304
 
2305
+ p = sub.add_parser("comments",
2306
+ help="list comment threads as JSON (page anchor, author, replies)")
2307
+ p.add_argument("deck")
2308
+ p.set_defaults(func=cmd_comments)
2309
+
2310
+ p = sub.add_parser("sync",
2311
+ help="report drift vs the live deck (comments, live edits, conflicts)")
2312
+ p.add_argument("source", type=Path)
2313
+ p.add_argument("--deck")
2314
+ p.set_defaults(func=cmd_sync)
2315
+
1969
2316
  p = sub.add_parser("make-templates",
1970
2317
  help="add branded tagged template slides to a deck")
1971
2318
  p.add_argument("deck")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.2.0
3
+ Version: 0.4.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.2.0
25
+ Version: 0.4.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -58,11 +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
+ | `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) |
66
68
 
67
69
  `push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
68
70
  `deck:` frontmatter key. Relative image paths resolve against the markdown file's
@@ -87,6 +89,33 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
89
  path, template vars — and, for template slides, the authored body markdown
88
90
  (base64) — so `pull` recovers the source verbatim.
89
91
 
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).
118
+
90
119
  ## Markdown dialect
91
120
 
92
121
  Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
@@ -11,5 +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
- tests/test_pull.py
16
+ tests/test_pull.py
17
+ tests/test_sync_drift.py
@@ -0,0 +1,292 @@
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()
@@ -0,0 +1,67 @@
1
+ """Offline tests for drift detection: comment shaping, text-line extraction
2
+ parity (markdown vs native), and the three-way classification."""
3
+
4
+ from slidesync._sync import (
5
+ classify_drift,
6
+ shape_comments,
7
+ text_lines_md,
8
+ text_lines_native,
9
+ )
10
+
11
+ RAW_COMMENTS = [
12
+ {"id": "c1",
13
+ "anchor": '{"type":"page","uid":1,"pages":["s2g_aaaaaaaaaa_bbbbbbbbbb"]}',
14
+ "author": {"displayName": "Daniel Hails"},
15
+ "content": "This is obviously dodgy. To debug",
16
+ "modifiedTime": "2026-06-12T15:00:00Z",
17
+ "replies": [
18
+ {"author": {"displayName": "Ted"}, "content": "agree"},
19
+ {"author": {"displayName": "Daniel Hails"}, "content": ""}, # resolve action
20
+ ]},
21
+ {"id": "c2", "anchor": "not json", "author": {}, "content": "file-level",
22
+ "resolved": True},
23
+ ]
24
+
25
+
26
+ def test_shape_comments_extracts_page_author_replies():
27
+ a, b = shape_comments(RAW_COMMENTS)
28
+ assert a["page"] == "s2g_aaaaaaaaaa_bbbbbbbbbb"
29
+ assert a["author"] == "Daniel Hails" and not a["resolved"]
30
+ assert a["replies"] == [{"author": "Ted", "content": "agree"}]
31
+ assert b["page"] is None and b["resolved"] is True
32
+
33
+
34
+ def _native(texts):
35
+ """A native slide with one text box per entry, stacked top to bottom."""
36
+ els = []
37
+ for i, text in enumerate(texts):
38
+ els.append({"transform": {"translateY": i * 914400, "translateX": 0},
39
+ "shape": {"text": {"textElements": [
40
+ {"paragraphMarker": {}},
41
+ {"textRun": {"content": text, "style": {}}}]}}})
42
+ return {"objectId": "s", "pageElements": els}
43
+
44
+
45
+ def test_md_and_native_text_lines_agree_for_a_styled_slide():
46
+ md = "# 2026/06/15\n## RELIABLE MONITORS\n\nWeekly Update\n<!-- a note -->"
47
+ # live render stacks kicker above headline — order differs, content matches
48
+ native = _native(["RELIABLE MONITORS", "2026/06/15", "Weekly Update"])
49
+ assert text_lines_md(md) == text_lines_native(native)
50
+
51
+
52
+ def test_md_text_lines_include_verbatim_fences_and_skip_comments():
53
+ md = "## PROMPT\n```text\nYou are a monitor.\n```\n<!-- hidden -->"
54
+ lines = text_lines_md(md)
55
+ assert "You are a monitor." in lines
56
+ assert not any("hidden" in line for line in lines)
57
+
58
+
59
+ def test_classify_drift_three_way():
60
+ base, local, live = ["a"], ["a"], ["a"]
61
+ assert classify_drift(base, local, live) == "clean"
62
+ assert classify_drift(base, ["b"], live) == "local-edit"
63
+ assert classify_drift(base, local, ["b"]) == "live-drift"
64
+ assert classify_drift(base, ["b"], ["c"]) == "conflict"
65
+ assert classify_drift(base, ["b"], ["b"]) == "converged"
66
+ assert classify_drift(None, ["a"], ["a"]) == "clean"
67
+ assert classify_drift(None, ["a"], ["b"]) == "drift-no-base"
File without changes
File without changes
File without changes
File without changes