slidesync 0.1.0__tar.gz → 0.2.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.2.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.2.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -84,7 +84,8 @@ is a no-op. Diff per run: identical hash → skip; same key, new content → rep
84
84
  new key → create. Removed slides are **kept** unless `--prune`. **Only `s2g_`
85
85
  slides are ever touched** — hand-authored slides are invisible to the sync. A
86
86
  hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
- path, and template vars so `pull` can recover them.
87
+ path, template vars — and, for template slides, the authored body markdown
88
+ (base64) — so `pull` recovers the source verbatim.
88
89
 
89
90
  ## Markdown dialect
90
91
 
@@ -96,7 +97,11 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
96
97
  `**bold**` / `*italic*` / `` `code` `` / `[link](url)`. GFM tables.
97
98
  `![alt](path)` images (uploaded to Drive; `alt` becomes the accessibility
98
99
  description, round-tripped on pull). Blank lines preserved as spacing.
99
- `<!-- notes -->` become speaker notes.
100
+ `<!-- notes -->` become speaker notes — and round-trip as **comments, in
101
+ place**: template slides carry their authored source in the marker, so `pull`
102
+ re-emits each comment where it was written instead of one merged trailing
103
+ blob. Speaker notes edited live in Slides come back as one extra trailing
104
+ comment.
100
105
  - **Internal links:** `[text](#slide-id)` becomes a native Slides link to the
101
106
  slide whose `id:` (or title slug) is `slide-id`.
102
107
 
@@ -105,6 +110,9 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
105
110
  Select per slide via `template:` — native styled boxes, no in-deck templates:
106
111
  `dark`/`title`, `appendix`, `question`/`label`, `topic`, `content`,
107
112
  `graph`/`full` (single full-bleed image), `prompt`/`code` (verbatim monospace).
113
+ Title cards (`dark`/`title`/`appendix`) render body lines as a small dimmed
114
+ **byline** beneath the headline (e.g. `Project · Presenter`) — they still have
115
+ no linkable body region.
108
116
  Slides with no `template:` fall back to a generative path (section /
109
117
  title+body / table / image) that also brands the background + IBM Plex.
110
118
 
@@ -131,6 +139,11 @@ Releases publish to PyPI via Trusted Publishing (OIDC) on a `v*.*.*` tag — see
131
139
  — this is a content mapper, not a CSS renderer.
132
140
  - On `pull`, the slide model holds a single image, so a slide with multiple
133
141
  images keeps the first; image `contentUrl`s from foreign decks are ephemeral.
142
+ - Verbatim-source markers are seeded at push time, so comment preservation
143
+ applies from the first push with v0.2+ (older slides re-render once: the
144
+ content hash is now over the authored source). Generative-path slides (no
145
+ `template:`) still merge comments into a single trailing comment on pull,
146
+ since their live Slides edits — not the marker — are the source of truth.
134
147
 
135
148
  ## License
136
149
 
@@ -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.2.0
8
8
 
9
9
  ```bash
10
10
  uvx slidesync --help # run without installing
@@ -66,7 +66,8 @@ is a no-op. Diff per run: identical hash → skip; same key, new content → rep
66
66
  new key → create. Removed slides are **kept** unless `--prune`. **Only `s2g_`
67
67
  slides are ever touched** — hand-authored slides are invisible to the sync. A
68
68
  hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
69
- path, and template vars so `pull` can recover them.
69
+ path, template vars — and, for template slides, the authored body markdown
70
+ (base64) — so `pull` recovers the source verbatim.
70
71
 
71
72
  ## Markdown dialect
72
73
 
@@ -78,7 +79,11 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
78
79
  `**bold**` / `*italic*` / `` `code` `` / `[link](url)`. GFM tables.
79
80
  `![alt](path)` images (uploaded to Drive; `alt` becomes the accessibility
80
81
  description, round-tripped on pull). Blank lines preserved as spacing.
81
- `<!-- notes -->` become speaker notes.
82
+ `<!-- notes -->` become speaker notes — and round-trip as **comments, in
83
+ place**: template slides carry their authored source in the marker, so `pull`
84
+ re-emits each comment where it was written instead of one merged trailing
85
+ blob. Speaker notes edited live in Slides come back as one extra trailing
86
+ comment.
82
87
  - **Internal links:** `[text](#slide-id)` becomes a native Slides link to the
83
88
  slide whose `id:` (or title slug) is `slide-id`.
84
89
 
@@ -87,6 +92,9 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
87
92
  Select per slide via `template:` — native styled boxes, no in-deck templates:
88
93
  `dark`/`title`, `appendix`, `question`/`label`, `topic`, `content`,
89
94
  `graph`/`full` (single full-bleed image), `prompt`/`code` (verbatim monospace).
95
+ Title cards (`dark`/`title`/`appendix`) render body lines as a small dimmed
96
+ **byline** beneath the headline (e.g. `Project · Presenter`) — they still have
97
+ no linkable body region.
90
98
  Slides with no `template:` fall back to a generative path (section /
91
99
  title+body / table / image) that also brands the background + IBM Plex.
92
100
 
@@ -113,6 +121,11 @@ Releases publish to PyPI via Trusted Publishing (OIDC) on a `v*.*.*` tag — see
113
121
  — this is a content mapper, not a CSS renderer.
114
122
  - On `pull`, the slide model holds a single image, so a slide with multiple
115
123
  images keeps the first; image `contentUrl`s from foreign decks are ephemeral.
124
+ - Verbatim-source markers are seeded at push time, so comment preservation
125
+ applies from the first push with v0.2+ (older slides re-render once: the
126
+ content hash is now over the authored source). Generative-path slides (no
127
+ `template:`) still merge comments into a single trailing comment on pull,
128
+ since their live Slides edits — not the marker — are the source of truth.
116
129
 
117
130
  ## License
118
131
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slidesync"
3
- version = "0.1.0"
3
+ version = "0.2.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.2.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.2.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,7 @@ Usage:
33
35
  from __future__ import annotations
34
36
 
35
37
  import argparse
38
+ import base64
36
39
  import hashlib
37
40
  import html
38
41
  import json
@@ -81,6 +84,7 @@ INK = {"red": 0.011764706, "green": 0.02745098, "blue": 0.07058824} # #03070F
81
84
  BODY_INK = {"red": 0.11764706, "green": 0.1254902, "blue": 0.14117648} # #1E2024
82
85
  PAPER = {"red": 0.98039216, "green": 0.98039216, "blue": 0.98039216} # #FAFAFA
83
86
  WHITE = {"red": 1.0, "green": 1.0, "blue": 1.0} # #FFFFFF
87
+ MUTED = {"red": 0.62, "green": 0.65, "blue": 0.69} # dimmed byline on dark cards
84
88
  LIGHT_BG, DARK_BG = PAPER, BODY_INK
85
89
 
86
90
  # ---------------------------------------------------------------------------
@@ -187,6 +191,7 @@ class Slide:
187
191
  vars: dict = field(default_factory=dict) # extra frontmatter -> {{token}} values
188
192
  custom: str | None = None # ```gslides``` literal Slides API requests (JSON)
189
193
  verbatim: str | None = None # ``` ``` fenced body for prompt/code slides
194
+ src: str | None = None # body markdown as authored (comments in place)
190
195
  key_hash: str = ""
191
196
  content_hash: str = ""
192
197
  object_id: str = ""
@@ -282,6 +287,7 @@ def build_slides(chunks: list[tuple[dict, str]]) -> list[Slide]:
282
287
 
283
288
 
284
289
  def build_slide(meta: dict, body: str, index: int) -> Slide:
290
+ authored = body.strip("\n")
285
291
  custom, body = _extract_custom(body)
286
292
  verbatim = None
287
293
  if (meta.get("template") or "").lower() in ("prompt", "code"):
@@ -299,6 +305,8 @@ def build_slide(meta: dict, body: str, index: int) -> Slide:
299
305
  slide.vars = {k: v for k, v in meta.items() if k not in RESERVED_KEYS}
300
306
  slide.custom = custom
301
307
  slide.verbatim = verbatim
308
+ if custom is None: # custom slides are pull-authoritative; their source goes stale
309
+ slide.src = authored
302
310
  return _finalize(slide)
303
311
 
304
312
 
@@ -467,6 +475,18 @@ def to_slidev(slide: Slide, include_id: bool = True) -> str:
467
475
  out = []
468
476
  if fm:
469
477
  out += ["---"] + [f"{k}: {v}" for k, v in fm.items()] + ["---"]
478
+ if slide.src is not None:
479
+ # Authored source is the canonical render: comments stay comments, in
480
+ # place. Speaker notes are emitted only when they no longer match the
481
+ # authored comments (i.e. someone edited the notes pane in Slides) —
482
+ # compared whitespace-normalised, since the notes shape flattens
483
+ # paragraphs when read back.
484
+ out.append(slide.src)
485
+ extra = slide.notes.strip()
486
+ if extra and " ".join(extra.split()) != \
487
+ " ".join(_extract_notes(slide.src).split()):
488
+ out.append(f"<!-- {extra} -->")
489
+ return "\n".join(out).strip() + "\n"
470
490
  if slide.custom is not None:
471
491
  out += ["```gslides", slide.custom, "```"]
472
492
  if slide.notes:
@@ -553,6 +573,11 @@ def _marker(slide: Slide) -> str:
553
573
  data["body"] = body_md
554
574
  if slide.vars:
555
575
  data["vars"] = slide.vars
576
+ if slide.src is not None:
577
+ # base64: MARKER_RE is delimiter-based, so a `}` + `-->` sequence
578
+ # in raw authored markdown would truncate the JSON mid-string;
579
+ # encoding also keeps the visible notes pane free of a giant blob.
580
+ data["src"] = base64.b64encode(slide.src.encode()).decode()
556
581
  elif slide.layout_name and slide.layout_name not in SECTION_LAYOUTS:
557
582
  data["tpl"] = slide.layout_name
558
583
  return f"<!-- s2g {json.dumps(data, separators=(',', ':'))} -->"
@@ -664,9 +689,15 @@ def _styled_requests(slide: Slide, style: Style, image_url, image_px) -> list[di
664
689
  head_pt = _fit_headline_pt(headline_text, style.headline_pt,
665
690
  max_lines=style.head_lines)
666
691
  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.
692
+ # Title cards have no body region; body lines render as a small dimmed
693
+ # byline beneath the headline (e.g. "Project · Presenter" on a title slide).
694
+ byline = ""
695
+ if style.body_align is None and slide.paras:
696
+ byline = "\n".join(p.text for p in slide.paras if p.text)
697
+ by_h = (byline.count("\n") + 1) * 0.32 if byline else 0.0
698
+ # Title cards (no body, no image) vertically centre kicker+headline+byline.
668
699
  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
700
+ block = (0.36 if kicker_text else 0) + head_h + (by_h + 0.1 if byline else 0)
670
701
  y = max(0.4, (5.63 - block) / 2)
671
702
  else:
672
703
  y = style.top
@@ -682,6 +713,11 @@ def _styled_requests(slide: Slide, style: Style, image_url, image_px) -> list[di
682
713
  headline_text, head_pt, style.headline_rgb, True,
683
714
  halign=head_align)
684
715
  y += head_h + 0.15
716
+ if byline:
717
+ rgb = MUTED if style.bg == DARK_BG else BODY_INK
718
+ reqs += _text_box(sid, sid + "_by", (0.34, y, 9.32, by_h),
719
+ byline, 14, rgb, False)
720
+ y += by_h + 0.1
685
721
  if style.body_align and slide.paras:
686
722
  bid = sid + "_b"
687
723
  reqs.append({"createShape": {"objectId": bid, "shapeType": "TEXT_BOX",
@@ -1503,6 +1539,8 @@ def _slide_from_marker(marker: dict, notes: str) -> Slide:
1503
1539
  slide.kicker = marker.get("h2", "")
1504
1540
  slide.template_name = marker["template"]
1505
1541
  slide.vars = marker.get("vars", {})
1542
+ if "src" in marker:
1543
+ slide.src = base64.b64decode(marker["src"]).decode()
1506
1544
  return slide
1507
1545
 
1508
1546
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.1.0
3
+ Version: 0.2.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.2.0
26
26
 
27
27
  ```bash
28
28
  uvx slidesync --help # run without installing
@@ -84,7 +84,8 @@ is a no-op. Diff per run: identical hash → skip; same key, new content → rep
84
84
  new key → create. Removed slides are **kept** unless `--prune`. **Only `s2g_`
85
85
  slides are ever touched** — hand-authored slides are invisible to the sync. A
86
86
  hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
87
- path, and template vars so `pull` can recover them.
87
+ path, template vars — and, for template slides, the authored body markdown
88
+ (base64) — so `pull` recovers the source verbatim.
88
89
 
89
90
  ## Markdown dialect
90
91
 
@@ -96,7 +97,11 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
96
97
  `**bold**` / `*italic*` / `` `code` `` / `[link](url)`. GFM tables.
97
98
  `![alt](path)` images (uploaded to Drive; `alt` becomes the accessibility
98
99
  description, round-tripped on pull). Blank lines preserved as spacing.
99
- `<!-- notes -->` become speaker notes.
100
+ `<!-- notes -->` become speaker notes — and round-trip as **comments, in
101
+ place**: template slides carry their authored source in the marker, so `pull`
102
+ re-emits each comment where it was written instead of one merged trailing
103
+ blob. Speaker notes edited live in Slides come back as one extra trailing
104
+ comment.
100
105
  - **Internal links:** `[text](#slide-id)` becomes a native Slides link to the
101
106
  slide whose `id:` (or title slug) is `slide-id`.
102
107
 
@@ -105,6 +110,9 @@ may have its own frontmatter (`id:`, `template:`, `layout:`).
105
110
  Select per slide via `template:` — native styled boxes, no in-deck templates:
106
111
  `dark`/`title`, `appendix`, `question`/`label`, `topic`, `content`,
107
112
  `graph`/`full` (single full-bleed image), `prompt`/`code` (verbatim monospace).
113
+ Title cards (`dark`/`title`/`appendix`) render body lines as a small dimmed
114
+ **byline** beneath the headline (e.g. `Project · Presenter`) — they still have
115
+ no linkable body region.
108
116
  Slides with no `template:` fall back to a generative path (section /
109
117
  title+body / table / image) that also brands the background + IBM Plex.
110
118
 
@@ -131,6 +139,11 @@ Releases publish to PyPI via Trusted Publishing (OIDC) on a `v*.*.*` tag — see
131
139
  — this is a content mapper, not a CSS renderer.
132
140
  - On `pull`, the slide model holds a single image, so a slide with multiple
133
141
  images keeps the first; image `contentUrl`s from foreign decks are ephemeral.
142
+ - Verbatim-source markers are seeded at push time, so comment preservation
143
+ applies from the first push with v0.2+ (older slides re-render once: the
144
+ content hash is now over the authored source). Generative-path slides (no
145
+ `template:`) still merge comments into a single trailing comment on pull,
146
+ since their live Slides edits — not the marker — are the source of truth.
134
147
 
135
148
  ## License
136
149
 
@@ -10,5 +10,6 @@ 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
15
  tests/test_pull.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")]
File without changes
File without changes
File without changes
File without changes