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