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.
- {slidesync-0.1.0/slidesync.egg-info → slidesync-0.3.0}/PKG-INFO +38 -4
- {slidesync-0.1.0 → slidesync-0.3.0}/README.md +37 -3
- {slidesync-0.1.0 → slidesync-0.3.0}/pyproject.toml +2 -2
- {slidesync-0.1.0 → slidesync-0.3.0}/slidesync/__init__.py +1 -1
- {slidesync-0.1.0 → slidesync-0.3.0}/slidesync/_sync.py +244 -3
- {slidesync-0.1.0 → slidesync-0.3.0/slidesync.egg-info}/PKG-INFO +38 -4
- {slidesync-0.1.0 → slidesync-0.3.0}/slidesync.egg-info/SOURCES.txt +3 -1
- slidesync-0.3.0/tests/test_comment_preservation.py +132 -0
- slidesync-0.3.0/tests/test_sync_drift.py +67 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/LICENSE +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/requirements.txt +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/setup.cfg +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/slidesync.egg-info/dependency_links.txt +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/slidesync.egg-info/entry_points.txt +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/slidesync.egg-info/requires.txt +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/slidesync.egg-info/top_level.txt +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/tests/test_markdown.py +0 -0
- {slidesync-0.1.0 → slidesync-0.3.0}/tests/test_pull.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 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.
|
|
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
|
|
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
|
`` 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.
|
|
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
|
|
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
|
`` 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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
`` 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
|
+

|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|