slidesync 0.1.0__tar.gz → 0.3.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.1.0
3
+ Version: 0.3.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.1.0
25
+ Version: 0.3.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -63,6 +63,8 @@ Support path.)
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]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
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
@@ -84,7 +86,27 @@ is a no-op. Diff per run: identical hash → skip; same key, new content → rep
84
86
  new key → create. Removed slides are **kept** unless `--prune`. **Only `s2g_`
85
87
  slides are ever touched** — hand-authored slides are invisible to the sync. A
86
88
  hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
- path, and template vars so `pull` can recover them.
89
+ path, template vars — and, for template slides, the authored body markdown
90
+ (base64) — so `pull` recovers the source verbatim.
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.
88
110
 
89
111
  ## Markdown dialect
90
112
 
@@ -96,7 +118,11 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
96
118
  `**bold**` / `*italic*` / `` `code` `` / `[link](url)`. GFM tables.
97
119
  `![alt](path)` images (uploaded to Drive; `alt` becomes the accessibility
98
120
  description, round-tripped on pull). Blank lines preserved as spacing.
99
- `<!-- notes -->` become speaker notes.
121
+ `<!-- notes -->` become speaker notes — and round-trip as **comments, in
122
+ place**: template slides carry their authored source in the marker, so `pull`
123
+ re-emits each comment where it was written instead of one merged trailing
124
+ blob. Speaker notes edited live in Slides come back as one extra trailing
125
+ comment.
100
126
  - **Internal links:** `[text](#slide-id)` becomes a native Slides link to the
101
127
  slide whose `id:` (or title slug) is `slide-id`.
102
128
 
@@ -105,6 +131,9 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
105
131
  Select per slide via `template:` — native styled boxes, no in-deck templates:
106
132
  `dark`/`title`, `appendix`, `question`/`label`, `topic`, `content`,
107
133
  `graph`/`full` (single full-bleed image), `prompt`/`code` (verbatim monospace).
134
+ Title cards (`dark`/`title`/`appendix`) render body lines as a small dimmed
135
+ **byline** beneath the headline (e.g. `Project · Presenter`) — they still have
136
+ no linkable body region.
108
137
  Slides with no `template:` fall back to a generative path (section /
109
138
  title+body / table / image) that also brands the background + IBM Plex.
110
139
 
@@ -131,6 +160,11 @@ Releases publish to PyPI via Trusted Publishing (OIDC) on a `v*.*.*` tag — see
131
160
  — this is a content mapper, not a CSS renderer.
132
161
  - On `pull`, the slide model holds a single image, so a slide with multiple
133
162
  images keeps the first; image `contentUrl`s from foreign decks are ephemeral.
163
+ - Verbatim-source markers are seeded at push time, so comment preservation
164
+ applies from the first push with v0.2+ (older slides re-render once: the
165
+ content hash is now over the authored source). Generative-path slides (no
166
+ `template:`) still merge comments into a single trailing comment on pull,
167
+ since their live Slides edits — not the marker — are the source of truth.
134
168
 
135
169
  ## License
136
170
 
@@ -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.1.0
7
+ Version: 0.3.0
8
8
 
9
9
  ```bash
10
10
  uvx slidesync --help # run without installing
@@ -45,6 +45,8 @@ Support path.)
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]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
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
@@ -66,7 +68,27 @@ is a no-op. Diff per run: identical hash → skip; same key, new content → rep
66
68
  new key → create. Removed slides are **kept** unless `--prune`. **Only `s2g_`
67
69
  slides are ever touched** — hand-authored slides are invisible to the sync. A
68
70
  hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
69
- path, and template vars so `pull` can recover them.
71
+ path, template vars — and, for template slides, the authored body markdown
72
+ (base64) — so `pull` recovers the source verbatim.
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.
70
92
 
71
93
  ## Markdown dialect
72
94
 
@@ -78,7 +100,11 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
78
100
  `**bold**` / `*italic*` / `` `code` `` / `[link](url)`. GFM tables.
79
101
  `![alt](path)` images (uploaded to Drive; `alt` becomes the accessibility
80
102
  description, round-tripped on pull). Blank lines preserved as spacing.
81
- `<!-- notes -->` become speaker notes.
103
+ `<!-- notes -->` become speaker notes — and round-trip as **comments, in
104
+ place**: template slides carry their authored source in the marker, so `pull`
105
+ re-emits each comment where it was written instead of one merged trailing
106
+ blob. Speaker notes edited live in Slides come back as one extra trailing
107
+ comment.
82
108
  - **Internal links:** `[text](#slide-id)` becomes a native Slides link to the
83
109
  slide whose `id:` (or title slug) is `slide-id`.
84
110
 
@@ -87,6 +113,9 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
87
113
  Select per slide via `template:` — native styled boxes, no in-deck templates:
88
114
  `dark`/`title`, `appendix`, `question`/`label`, `topic`, `content`,
89
115
  `graph`/`full` (single full-bleed image), `prompt`/`code` (verbatim monospace).
116
+ Title cards (`dark`/`title`/`appendix`) render body lines as a small dimmed
117
+ **byline** beneath the headline (e.g. `Project · Presenter`) — they still have
118
+ no linkable body region.
90
119
  Slides with no `template:` fall back to a generative path (section /
91
120
  title+body / table / image) that also brands the background + IBM Plex.
92
121
 
@@ -113,6 +142,11 @@ Releases publish to PyPI via Trusted Publishing (OIDC) on a `v*.*.*` tag — see
113
142
  — this is a content mapper, not a CSS renderer.
114
143
  - On `pull`, the slide model holds a single image, so a slide with multiple
115
144
  images keeps the first; image `contentUrl`s from foreign decks are ephemeral.
145
+ - Verbatim-source markers are seeded at push time, so comment preservation
146
+ applies from the first push with v0.2+ (older slides re-render once: the
147
+ content hash is now over the authored source). Generative-path slides (no
148
+ `template:`) still merge comments into a single trailing comment on pull,
149
+ since their live Slides edits — not the marker — are the source of truth.
116
150
 
117
151
  ## License
118
152
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slidesync"
3
- version = "0.1.0"
3
+ version = "0.3.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.1.0"
35
+ current_version = "0.3.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.1.0"
33
+ __version__ = "0.3.0"
34
34
 
35
35
  __all__ = [
36
36
  "Para",
@@ -21,7 +21,9 @@ Idempotent sync (upsert), never a blind append:
21
21
  create. Removed slides are kept by default (`--prune` to delete).
22
22
  - Only `s2g_`-prefixed slides are ever touched; hand-authored slides are
23
23
  invisible to the sync. A tiny `<!-- s2g {...} -->` marker in speaker notes
24
- carries the human id + image path so `pull` can recover them.
24
+ carries the human id + image path and, for template slides, the authored
25
+ body markdown (base64) — so `pull` recovers the source verbatim: comments
26
+ stay comments, in place, instead of collapsing into one speaker-notes blob.
25
27
 
26
28
  Usage:
27
29
  bin/slidesync.py push deck.slidev.md --deck <id> [--anchor <slideId>] [--prune]
@@ -33,6 +35,9 @@ Usage:
33
35
  from __future__ import annotations
34
36
 
35
37
  import argparse
38
+ import base64
39
+ import datetime
40
+ import difflib
36
41
  import hashlib
37
42
  import html
38
43
  import json
@@ -81,6 +86,7 @@ INK = {"red": 0.011764706, "green": 0.02745098, "blue": 0.07058824} # #03070F
81
86
  BODY_INK = {"red": 0.11764706, "green": 0.1254902, "blue": 0.14117648} # #1E2024
82
87
  PAPER = {"red": 0.98039216, "green": 0.98039216, "blue": 0.98039216} # #FAFAFA
83
88
  WHITE = {"red": 1.0, "green": 1.0, "blue": 1.0} # #FFFFFF
89
+ MUTED = {"red": 0.62, "green": 0.65, "blue": 0.69} # dimmed byline on dark cards
84
90
  LIGHT_BG, DARK_BG = PAPER, BODY_INK
85
91
 
86
92
  # ---------------------------------------------------------------------------
@@ -187,6 +193,7 @@ class Slide:
187
193
  vars: dict = field(default_factory=dict) # extra frontmatter -> {{token}} values
188
194
  custom: str | None = None # ```gslides``` literal Slides API requests (JSON)
189
195
  verbatim: str | None = None # ``` ``` fenced body for prompt/code slides
196
+ src: str | None = None # body markdown as authored (comments in place)
190
197
  key_hash: str = ""
191
198
  content_hash: str = ""
192
199
  object_id: str = ""
@@ -282,6 +289,7 @@ def build_slides(chunks: list[tuple[dict, str]]) -> list[Slide]:
282
289
 
283
290
 
284
291
  def build_slide(meta: dict, body: str, index: int) -> Slide:
292
+ authored = body.strip("\n")
285
293
  custom, body = _extract_custom(body)
286
294
  verbatim = None
287
295
  if (meta.get("template") or "").lower() in ("prompt", "code"):
@@ -299,6 +307,8 @@ def build_slide(meta: dict, body: str, index: int) -> Slide:
299
307
  slide.vars = {k: v for k, v in meta.items() if k not in RESERVED_KEYS}
300
308
  slide.custom = custom
301
309
  slide.verbatim = verbatim
310
+ if custom is None: # custom slides are pull-authoritative; their source goes stale
311
+ slide.src = authored
302
312
  return _finalize(slide)
303
313
 
304
314
 
@@ -467,6 +477,18 @@ def to_slidev(slide: Slide, include_id: bool = True) -> str:
467
477
  out = []
468
478
  if fm:
469
479
  out += ["---"] + [f"{k}: {v}" for k, v in fm.items()] + ["---"]
480
+ if slide.src is not None:
481
+ # Authored source is the canonical render: comments stay comments, in
482
+ # place. Speaker notes are emitted only when they no longer match the
483
+ # authored comments (i.e. someone edited the notes pane in Slides) —
484
+ # compared whitespace-normalised, since the notes shape flattens
485
+ # paragraphs when read back.
486
+ out.append(slide.src)
487
+ extra = slide.notes.strip()
488
+ if extra and " ".join(extra.split()) != \
489
+ " ".join(_extract_notes(slide.src).split()):
490
+ out.append(f"<!-- {extra} -->")
491
+ return "\n".join(out).strip() + "\n"
470
492
  if slide.custom is not None:
471
493
  out += ["```gslides", slide.custom, "```"]
472
494
  if slide.notes:
@@ -553,8 +575,17 @@ def _marker(slide: Slide) -> str:
553
575
  data["body"] = body_md
554
576
  if slide.vars:
555
577
  data["vars"] = slide.vars
578
+ if slide.src is not None:
579
+ # base64: MARKER_RE is delimiter-based, so a `}` + `-->` sequence
580
+ # in raw authored markdown would truncate the JSON mid-string;
581
+ # encoding also keeps the visible notes pane free of a giant blob.
582
+ data["src"] = base64.b64encode(slide.src.encode()).decode()
556
583
  elif slide.layout_name and slide.layout_name not in SECTION_LAYOUTS:
557
584
  data["tpl"] = slide.layout_name
585
+ # Last-push stamp: `sync` reports it alongside drift. The Slides/Drive APIs
586
+ # have no per-slide edit times (file-level modifiedTime only), so this is
587
+ # the only per-slide timestamp that exists.
588
+ data["at"] = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
558
589
  return f"<!-- s2g {json.dumps(data, separators=(',', ':'))} -->"
559
590
 
560
591
 
@@ -664,9 +695,15 @@ def _styled_requests(slide: Slide, style: Style, image_url, image_px) -> list[di
664
695
  head_pt = _fit_headline_pt(headline_text, style.headline_pt,
665
696
  max_lines=style.head_lines)
666
697
  head_h = _est_lines(headline_text, head_pt) * head_pt * 1.25 / 72 + 0.1
667
- # Title cards (no body, no image) vertically centre the kicker+headline.
698
+ # Title cards have no body region; body lines render as a small dimmed
699
+ # byline beneath the headline (e.g. "Project · Presenter" on a title slide).
700
+ byline = ""
701
+ if style.body_align is None and slide.paras:
702
+ byline = "\n".join(p.text for p in slide.paras if p.text)
703
+ by_h = (byline.count("\n") + 1) * 0.32 if byline else 0.0
704
+ # Title cards (no body, no image) vertically centre kicker+headline+byline.
668
705
  if (style.body_align is None or not slide.paras) and not slide.image:
669
- block = (0.36 if kicker_text else 0) + head_h
706
+ block = (0.36 if kicker_text else 0) + head_h + (by_h + 0.1 if byline else 0)
670
707
  y = max(0.4, (5.63 - block) / 2)
671
708
  else:
672
709
  y = style.top
@@ -682,6 +719,11 @@ def _styled_requests(slide: Slide, style: Style, image_url, image_px) -> list[di
682
719
  headline_text, head_pt, style.headline_rgb, True,
683
720
  halign=head_align)
684
721
  y += head_h + 0.15
722
+ if byline:
723
+ rgb = MUTED if style.bg == DARK_BG else BODY_INK
724
+ reqs += _text_box(sid, sid + "_by", (0.34, y, 9.32, by_h),
725
+ byline, 14, rgb, False)
726
+ y += by_h + 0.1
685
727
  if style.body_align and slide.paras:
686
728
  bid = sid + "_b"
687
729
  reqs.append({"createShape": {"objectId": bid, "shapeType": "TEXT_BOX",
@@ -1503,6 +1545,8 @@ def _slide_from_marker(marker: dict, notes: str) -> Slide:
1503
1545
  slide.kicker = marker.get("h2", "")
1504
1546
  slide.template_name = marker["template"]
1505
1547
  slide.vars = marker.get("vars", {})
1548
+ if "src" in marker:
1549
+ slide.src = base64.b64decode(marker["src"]).decode()
1506
1550
  return slide
1507
1551
 
1508
1552
 
@@ -1852,6 +1896,192 @@ def cmd_layouts(args):
1852
1896
  logger.info(f"{name:<24} {', '.join(phs) or '(no placeholders)'}")
1853
1897
 
1854
1898
 
1899
+ # ---------------------------------------------------------------------------
1900
+ # Comments + sync (drift detection)
1901
+ # ---------------------------------------------------------------------------
1902
+
1903
+
1904
+ def shape_comments(raw: list[dict]) -> list[dict]:
1905
+ """Drive comment threads -> [{id, page, author, content, resolved, replies}].
1906
+
1907
+ `page` is the anchored slide objectId (None for file-level comments) —
1908
+ note an objectId outlives its slide, so the anchor may point at a deleted
1909
+ page after a re-render. Resolve-action replies with no text are dropped.
1910
+ """
1911
+ out = []
1912
+ for c in raw:
1913
+ try:
1914
+ anchor = json.loads(c.get("anchor") or "{}")
1915
+ except json.JSONDecodeError:
1916
+ anchor = {}
1917
+ pages = anchor.get("pages") or []
1918
+ out.append({
1919
+ "id": c.get("id", ""),
1920
+ "page": pages[0] if pages else None,
1921
+ "author": (c.get("author") or {}).get("displayName", ""),
1922
+ "content": (c.get("content") or "").strip(),
1923
+ "resolved": bool(c.get("resolved")),
1924
+ "modified": c.get("modifiedTime", ""),
1925
+ "replies": [
1926
+ {"author": (r.get("author") or {}).get("displayName", ""),
1927
+ "content": (r.get("content") or "").strip()}
1928
+ for r in c.get("replies", []) if (r.get("content") or "").strip()
1929
+ ],
1930
+ })
1931
+ return out
1932
+
1933
+
1934
+ _COMMENT_FIELDS = ("nextPageToken,comments(id,content,author(displayName),"
1935
+ "anchor,resolved,modifiedTime,replies(content,author(displayName)))")
1936
+
1937
+
1938
+ def list_comments(drive, deck: str) -> list[dict]:
1939
+ raw, token = [], None
1940
+ while True:
1941
+ resp = drive.comments().list(fileId=deck, pageSize=100, pageToken=token,
1942
+ fields=_COMMENT_FIELDS).execute()
1943
+ raw += resp.get("comments", [])
1944
+ token = resp.get("nextPageToken")
1945
+ if not token:
1946
+ return raw
1947
+
1948
+
1949
+ def cmd_comments(args):
1950
+ _, drive = get_services(args.account)
1951
+ print(json.dumps(shape_comments(list_comments(drive, args.deck)), indent=2))
1952
+
1953
+
1954
+ def text_lines_md(src: str) -> list[str]:
1955
+ """Markdown slide body -> normalised visible text lines (comments excluded)."""
1956
+ fences = [m.group("text") for m in VERBATIM_RE.finditer(src)]
1957
+ headings, paras, _img, _alt, table, _ = parse_body(VERBATIM_RE.sub("", src))
1958
+ lines = [headings[k] for k in sorted(headings)]
1959
+ lines += [p.text for p in paras]
1960
+ if table:
1961
+ lines += [" | ".join(row) for row in table]
1962
+ for f in fences:
1963
+ lines += f.splitlines()
1964
+ return _norm_lines(lines)
1965
+
1966
+
1967
+ def text_lines_native(s: dict) -> list[str]:
1968
+ """Native slide JSON -> normalised visible text lines (notes excluded)."""
1969
+ boxes = []
1970
+ for el in s.get("pageElements", []):
1971
+ if el.get("shape", {}).get("text"):
1972
+ paras = _paras_from_shape(el["shape"])
1973
+ if paras:
1974
+ boxes.append((_el_y(el), _el_x(el), paras))
1975
+ elif el.get("table"):
1976
+ rows = _table_from_native(el["table"])
1977
+ boxes.append((_el_y(el), _el_x(el),
1978
+ [Para(" | ".join(r)) for r in rows]))
1979
+ boxes.sort(key=lambda t: (t[0], t[1]))
1980
+ return _norm_lines([p.text for _, _, paras in boxes for p in paras])
1981
+
1982
+
1983
+ def _norm_lines(lines: list[str]) -> list[str]:
1984
+ # Sorted: box reading-order vs markdown order differ legitimately (e.g. a
1985
+ # kicker renders above the headline but is written below it).
1986
+ return sorted(" ".join(line.split()) for line in lines if line.strip())
1987
+
1988
+
1989
+ def classify_drift(base: list[str] | None, local: list[str],
1990
+ live: list[str]) -> str:
1991
+ """Three-way status for one slide; base is the last-pushed source (marker)."""
1992
+ if base is None:
1993
+ return "clean" if local == live else "drift-no-base"
1994
+ if local == base and live == base:
1995
+ return "clean"
1996
+ if local != base and live == base:
1997
+ return "local-edit"
1998
+ if local == base and live != base:
1999
+ return "live-drift"
2000
+ return "converged" if local == live else "conflict"
2001
+
2002
+
2003
+ def _diff(a: list[str], b: list[str], a_name: str, b_name: str) -> str:
2004
+ return "\n".join(difflib.unified_diff(a, b, a_name, b_name, lineterm=""))
2005
+
2006
+
2007
+ 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.
2017
+ """
2018
+ slides_api, drive = get_services(args.account)
2019
+ source = load_slides(args.source)
2020
+ deck = args.deck or deck_from_source(args.source)
2021
+ if not deck:
2022
+ sys.exit("no target deck: pass --deck or add `deck:` frontmatter")
2023
+ pres = slides_api.presentations().get(presentationId=deck).execute()
2024
+ live = {s["objectId"].split("_")[1]: s for s in pres.get("slides", [])
2025
+ if MANAGED_RE.match(s["objectId"])}
2026
+ by_page = {}
2027
+ for c in shape_comments(list_comments(drive, deck)):
2028
+ if not c["resolved"] and c["page"]:
2029
+ by_page.setdefault(c["page"], []).append(c)
2030
+ drifted = 0
2031
+ for sl in source:
2032
+ s = live.pop(sl.key_hash, None)
2033
+ if s is None:
2034
+ logger.warning(f"[missing ] {sl.key} — not in deck; push will create it")
2035
+ drifted += 1
2036
+ continue
2037
+ 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) + " -->")
2043
+ 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)
2052
+ status = classify_drift(base, local, live_lines)
2053
+ pushed_at = marker.get("at", "?")
2054
+ if status in ("clean", "converged"):
2055
+ continue
2056
+ drifted += 1
2057
+ if status == "local-edit":
2058
+ 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():
2077
+ 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)
2083
+
2084
+
1855
2085
  def _loop_hop(slides_api, drive, title, slides):
1856
2086
  """One md->slides hop: build a deck, push, pull back."""
1857
2087
  deck = new_deck(slides_api, title)
@@ -1928,6 +2158,17 @@ def main():
1928
2158
  p.add_argument("deck")
1929
2159
  p.set_defaults(func=cmd_layouts)
1930
2160
 
2161
+ p = sub.add_parser("comments",
2162
+ help="list comment threads as JSON (page anchor, author, replies)")
2163
+ p.add_argument("deck")
2164
+ p.set_defaults(func=cmd_comments)
2165
+
2166
+ p = sub.add_parser("sync",
2167
+ help="report drift vs the live deck (comments, live edits, conflicts)")
2168
+ p.add_argument("source", type=Path)
2169
+ p.add_argument("--deck")
2170
+ p.set_defaults(func=cmd_sync)
2171
+
1931
2172
  p = sub.add_parser("make-templates",
1932
2173
  help="add branded tagged template slides to a deck")
1933
2174
  p.add_argument("deck")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.1.0
3
+ Version: 0.3.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.1.0
25
+ Version: 0.3.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -63,6 +63,8 @@ Support path.)
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]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
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
@@ -84,7 +86,27 @@ is a no-op. Diff per run: identical hash → skip; same key, new content → rep
84
86
  new key → create. Removed slides are **kept** unless `--prune`. **Only `s2g_`
85
87
  slides are ever touched** — hand-authored slides are invisible to the sync. A
86
88
  hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
- path, and template vars so `pull` can recover them.
89
+ path, template vars — and, for template slides, the authored body markdown
90
+ (base64) — so `pull` recovers the source verbatim.
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.
88
110
 
89
111
  ## Markdown dialect
90
112
 
@@ -96,7 +118,11 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
96
118
  `**bold**` / `*italic*` / `` `code` `` / `[link](url)`. GFM tables.
97
119
  `![alt](path)` images (uploaded to Drive; `alt` becomes the accessibility
98
120
  description, round-tripped on pull). Blank lines preserved as spacing.
99
- `<!-- notes -->` become speaker notes.
121
+ `<!-- notes -->` become speaker notes — and round-trip as **comments, in
122
+ place**: template slides carry their authored source in the marker, so `pull`
123
+ re-emits each comment where it was written instead of one merged trailing
124
+ blob. Speaker notes edited live in Slides come back as one extra trailing
125
+ comment.
100
126
  - **Internal links:** `[text](#slide-id)` becomes a native Slides link to the
101
127
  slide whose `id:` (or title slug) is `slide-id`.
102
128
 
@@ -105,6 +131,9 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
105
131
  Select per slide via `template:` — native styled boxes, no in-deck templates:
106
132
  `dark`/`title`, `appendix`, `question`/`label`, `topic`, `content`,
107
133
  `graph`/`full` (single full-bleed image), `prompt`/`code` (verbatim monospace).
134
+ Title cards (`dark`/`title`/`appendix`) render body lines as a small dimmed
135
+ **byline** beneath the headline (e.g. `Project · Presenter`) — they still have
136
+ no linkable body region.
108
137
  Slides with no `template:` fall back to a generative path (section /
109
138
  title+body / table / image) that also brands the background + IBM Plex.
110
139
 
@@ -131,6 +160,11 @@ Releases publish to PyPI via Trusted Publishing (OIDC) on a `v*.*.*` tag — see
131
160
  — this is a content mapper, not a CSS renderer.
132
161
  - On `pull`, the slide model holds a single image, so a slide with multiple
133
162
  images keeps the first; image `contentUrl`s from foreign decks are ephemeral.
163
+ - Verbatim-source markers are seeded at push time, so comment preservation
164
+ applies from the first push with v0.2+ (older slides re-render once: the
165
+ content hash is now over the authored source). Generative-path slides (no
166
+ `template:`) still merge comments into a single trailing comment on pull,
167
+ since their live Slides edits — not the marker — are the source of truth.
134
168
 
135
169
  ## License
136
170
 
@@ -10,5 +10,7 @@ slidesync.egg-info/dependency_links.txt
10
10
  slidesync.egg-info/entry_points.txt
11
11
  slidesync.egg-info/requires.txt
12
12
  slidesync.egg-info/top_level.txt
13
+ tests/test_comment_preservation.py
13
14
  tests/test_markdown.py
14
- tests/test_pull.py
15
+ tests/test_pull.py
16
+ tests/test_sync_drift.py
@@ -0,0 +1,132 @@
1
+ """Comments round-trip as comments, in place — not one merged speaker-notes blob.
2
+
3
+ The authored body of every non-custom slide is stored (base64) in the `s2g`
4
+ marker; `pull` re-emits it verbatim, so comment positions, prompt fences, and
5
+ formatting quirks all survive. Speaker notes edited live in Slides surface as
6
+ one extra trailing comment instead of silently replacing the authored ones.
7
+ """
8
+
9
+ from slidesync._sync import (
10
+ STYLES,
11
+ _marker,
12
+ _read_marker,
13
+ _slide_from_marker,
14
+ build_slides,
15
+ split_slides,
16
+ to_slidev,
17
+ )
18
+
19
+ DECK = """---
20
+ theme: seriph
21
+ ---
22
+
23
+ ---
24
+ template: topic
25
+ id: thread-finding
26
+ ---
27
+
28
+ # Compression hurts recall
29
+ ## FINDING
30
+
31
+ <!-- framing: say this slowly -->
32
+
33
+ - one solid point
34
+
35
+ ![self-titled figure](../figures/fig.png)
36
+
37
+ <!-- data-source: scripts/fig.py -->
38
+
39
+ ---
40
+ template: prompt
41
+ id: appendix-prompt-monitor
42
+ ---
43
+
44
+ ## MONITOR PROMPT
45
+
46
+ ```text
47
+ You are a monitor. Score 0-100 in <verdict></verdict>.
48
+ ```
49
+
50
+ ---
51
+ template: dark
52
+ id: weekly
53
+ ---
54
+
55
+ # 2026/06/12
56
+ ## WEEKLY UPDATE
57
+
58
+ Touchstone · Daniel Hails
59
+ """
60
+
61
+
62
+ def _slide(key):
63
+ return next(s for s in build_slides(split_slides(DECK)) if s.key == key)
64
+
65
+
66
+ def _roundtrip(slide, notes=None):
67
+ marker = _read_marker(_marker(slide))
68
+ return _slide_from_marker(marker, slide.notes if notes is None else notes)
69
+
70
+
71
+ def test_marker_parses_despite_comment_terminators_in_src():
72
+ # The authored body contains comments (`-->`) and braces; base64 keeps the
73
+ # marker JSON immune to `}` + `-->` sequences that would truncate the
74
+ # delimiter-based MARKER_RE match mid-string.
75
+ slide = _slide("thread-finding")
76
+ slide.src += "\n<!-- edge: {brace} -->"
77
+ marker = _read_marker(_marker(slide))
78
+ assert marker.get("src"), "marker should carry the authored source"
79
+
80
+
81
+ def test_comments_survive_in_place_not_merged():
82
+ md = to_slidev(_roundtrip(_slide("thread-finding")))
83
+ assert md.count("<!--") == 2, "each comment stays its own comment"
84
+ assert md.index("framing: say this slowly") < md.index("one solid point")
85
+ assert md.index("data-source: scripts/fig.py") > md.index("../figures/fig.png")
86
+
87
+
88
+ def test_prompt_fence_survives_pull():
89
+ md = to_slidev(_roundtrip(_slide("appendix-prompt-monitor")))
90
+ assert "You are a monitor." in md # previously lost: marker had no body
91
+
92
+
93
+ def test_unchanged_notes_are_not_duplicated():
94
+ slide = _slide("thread-finding")
95
+ # The notes shape flattens paragraphs to spaces when read back.
96
+ flattened = " ".join(slide.notes.split())
97
+ md = to_slidev(_roundtrip(slide, notes=flattened))
98
+ assert md.count("<!--") == 2
99
+
100
+
101
+ def test_live_notes_edit_appends_trailing_comment():
102
+ slide = _slide("thread-finding")
103
+ md = to_slidev(_roundtrip(slide, notes=slide.notes + "\nadded in slides"))
104
+ assert md.count("<!--") == 3
105
+ assert md.rstrip().endswith("-->") and "added in slides" in md
106
+
107
+
108
+ def _rendered(slide):
109
+ slide.object_id = "s2g_aaaaaaaaaa_bbbbbbbbbb"
110
+ return _styled(slide)
111
+
112
+
113
+ def _styled(slide):
114
+ from slidesync._sync import _styled_requests
115
+
116
+ return _styled_requests(slide, STYLES["dark"], None, None)
117
+
118
+
119
+ def test_dark_template_renders_body_as_byline():
120
+ reqs = _rendered(_slide("weekly"))
121
+ boxes = [r["createShape"]["objectId"] for r in reqs if "createShape" in r]
122
+ assert "s2g_aaaaaaaaaa_bbbbbbbbbb_by" in boxes
123
+ texts = [r["insertText"]["text"] for r in reqs if "insertText" in r]
124
+ assert "Touchstone · Daniel Hails" in texts
125
+
126
+
127
+ def test_dark_template_without_body_has_no_byline():
128
+ slide = _slide("weekly")
129
+ slide.paras = []
130
+ reqs = _rendered(slide)
131
+ boxes = [r["createShape"]["objectId"] for r in reqs if "createShape" in r]
132
+ assert not [b for b in boxes if b.endswith("_by")]
@@ -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