slidesync 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {slidesync-0.2.0/slidesync.egg-info → slidesync-0.4.0}/PKG-INFO +32 -3
- {slidesync-0.2.0 → slidesync-0.4.0}/README.md +31 -2
- {slidesync-0.2.0 → slidesync-0.4.0}/pyproject.toml +2 -2
- {slidesync-0.2.0 → slidesync-0.4.0}/slidesync/__init__.py +1 -1
- {slidesync-0.2.0 → slidesync-0.4.0}/slidesync/_sync.py +347 -0
- {slidesync-0.2.0 → slidesync-0.4.0/slidesync.egg-info}/PKG-INFO +32 -3
- {slidesync-0.2.0 → slidesync-0.4.0}/slidesync.egg-info/SOURCES.txt +3 -1
- slidesync-0.4.0/tests/test_e2e_scenarios.py +292 -0
- slidesync-0.4.0/tests/test_sync_drift.py +67 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/LICENSE +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/requirements.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/setup.cfg +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/slidesync.egg-info/dependency_links.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/slidesync.egg-info/entry_points.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/slidesync.egg-info/requires.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/slidesync.egg-info/top_level.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/tests/test_comment_preservation.py +0 -0
- {slidesync-0.2.0 → slidesync-0.4.0}/tests/test_markdown.py +0 -0
- {slidesync-0.2.0 → slidesync-0.4.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.4.0
|
|
4
4
|
Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
|
|
5
5
|
Author-email: Daniel Hails <slidesync@hails.info>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
22
22
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
23
23
|
images, brand-styled text boxes), not pasted screenshots.
|
|
24
24
|
|
|
25
|
-
Version: 0.
|
|
25
|
+
Version: 0.4.0
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -58,11 +58,13 @@ Support path.)
|
|
|
58
58
|
|
|
59
59
|
| Command | Purpose |
|
|
60
60
|
|---------|---------|
|
|
61
|
-
| `slidesync push <file.slidev.md> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides |
|
|
61
|
+
| `slidesync push <file.slidev.md> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
|
|
62
62
|
| `slidesync pull <deckId> --out <file.md> [--all]` | Slides → markdown (`--all` includes non-managed slides) |
|
|
63
63
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
64
64
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
65
65
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
66
|
+
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
67
|
+
| `slidesync sync <file.slidev.md> [--deck ID]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
|
|
66
68
|
|
|
67
69
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
68
70
|
`deck:` frontmatter key. Relative image paths resolve against the markdown file's
|
|
@@ -87,6 +89,33 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
|
|
|
87
89
|
path, template vars — and, for template slides, the authored body markdown
|
|
88
90
|
(base64) — so `pull` recovers the source verbatim.
|
|
89
91
|
|
|
92
|
+
## Sync & drift
|
|
93
|
+
|
|
94
|
+
`push` is guarded like a non-fast-forward git push: if a slide it would replace
|
|
95
|
+
(or prune) was edited in Google Slides since the last push — and the local
|
|
96
|
+
markdown doesn't already carry that edit — the push is **rejected** with no
|
|
97
|
+
changes made (`--force` overwrites). Live edits on slides the push wouldn't
|
|
98
|
+
touch are left alone.
|
|
99
|
+
|
|
100
|
+
`sync` reconciles the two sides, applying whatever is safe. The marker's
|
|
101
|
+
last-pushed source is a true per-slide **merge base**, so each slide classifies
|
|
102
|
+
three-way without timestamps (the APIs expose no per-slide edit times — only
|
|
103
|
+
file-level `modifiedTime`; the marker's `at` stamp records our last push):
|
|
104
|
+
|
|
105
|
+
| status | meaning | sync does |
|
|
106
|
+
|--------|---------|-----------|
|
|
107
|
+
| `clean` / `converged` | nothing changed, or both sides made the same change | nothing |
|
|
108
|
+
| `local-edit` | markdown changed, deck untouched | pushes it |
|
|
109
|
+
| `live-drift` | slide edited in Google Slides | writes the live content back into the markdown (reconstructed from its styled boxes, formatting runs included), then pushes |
|
|
110
|
+
| `conflict` | both changed since last push | prints both diffs vs the base for a human/LLM to resolve; skips the push; exits 1 |
|
|
111
|
+
|
|
112
|
+
Unresolved comment threads are appended to their slide as
|
|
113
|
+
`<!-- @Author: text -->` blocks (replies as extra `@Author:` lines), and threads
|
|
114
|
+
orphaned by a re-render are re-anchored to their slide via the objectId's
|
|
115
|
+
key-hash. Captured text round-trips from then on. Write-back caveat: a slide
|
|
116
|
+
edited live is rewritten canonically, so its authored comments collapse into
|
|
117
|
+
one trailing block (untouched slides keep comments in place).
|
|
118
|
+
|
|
90
119
|
## Markdown dialect
|
|
91
120
|
|
|
92
121
|
Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
|
|
@@ -4,7 +4,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
4
4
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
5
5
|
images, brand-styled text boxes), not pasted screenshots.
|
|
6
6
|
|
|
7
|
-
Version: 0.
|
|
7
|
+
Version: 0.4.0
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
uvx slidesync --help # run without installing
|
|
@@ -40,11 +40,13 @@ Support path.)
|
|
|
40
40
|
|
|
41
41
|
| Command | Purpose |
|
|
42
42
|
|---------|---------|
|
|
43
|
-
| `slidesync push <file.slidev.md> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides |
|
|
43
|
+
| `slidesync push <file.slidev.md> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
|
|
44
44
|
| `slidesync pull <deckId> --out <file.md> [--all]` | Slides → markdown (`--all` includes non-managed slides) |
|
|
45
45
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
46
46
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
47
47
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
48
|
+
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
49
|
+
| `slidesync sync <file.slidev.md> [--deck ID]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
|
|
48
50
|
|
|
49
51
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
50
52
|
`deck:` frontmatter key. Relative image paths resolve against the markdown file's
|
|
@@ -69,6 +71,33 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
|
|
|
69
71
|
path, template vars — and, for template slides, the authored body markdown
|
|
70
72
|
(base64) — so `pull` recovers the source verbatim.
|
|
71
73
|
|
|
74
|
+
## Sync & drift
|
|
75
|
+
|
|
76
|
+
`push` is guarded like a non-fast-forward git push: if a slide it would replace
|
|
77
|
+
(or prune) was edited in Google Slides since the last push — and the local
|
|
78
|
+
markdown doesn't already carry that edit — the push is **rejected** with no
|
|
79
|
+
changes made (`--force` overwrites). Live edits on slides the push wouldn't
|
|
80
|
+
touch are left alone.
|
|
81
|
+
|
|
82
|
+
`sync` reconciles the two sides, applying whatever is safe. The marker's
|
|
83
|
+
last-pushed source is a true per-slide **merge base**, so each slide classifies
|
|
84
|
+
three-way without timestamps (the APIs expose no per-slide edit times — only
|
|
85
|
+
file-level `modifiedTime`; the marker's `at` stamp records our last push):
|
|
86
|
+
|
|
87
|
+
| status | meaning | sync does |
|
|
88
|
+
|--------|---------|-----------|
|
|
89
|
+
| `clean` / `converged` | nothing changed, or both sides made the same change | nothing |
|
|
90
|
+
| `local-edit` | markdown changed, deck untouched | pushes it |
|
|
91
|
+
| `live-drift` | slide edited in Google Slides | writes the live content back into the markdown (reconstructed from its styled boxes, formatting runs included), then pushes |
|
|
92
|
+
| `conflict` | both changed since last push | prints both diffs vs the base for a human/LLM to resolve; skips the push; exits 1 |
|
|
93
|
+
|
|
94
|
+
Unresolved comment threads are appended to their slide as
|
|
95
|
+
`<!-- @Author: text -->` blocks (replies as extra `@Author:` lines), and threads
|
|
96
|
+
orphaned by a re-render are re-anchored to their slide via the objectId's
|
|
97
|
+
key-hash. Captured text round-trips from then on. Write-back caveat: a slide
|
|
98
|
+
edited live is rewritten canonically, so its authored comments collapse into
|
|
99
|
+
one trailing block (untouched slides keep comments in place).
|
|
100
|
+
|
|
72
101
|
## Markdown dialect
|
|
73
102
|
|
|
74
103
|
Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "slidesync"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name = "Daniel Hails", email = "slidesync@hails.info" },
|
|
6
6
|
]
|
|
@@ -32,7 +32,7 @@ include = ["slidesync*"]
|
|
|
32
32
|
dev = ["pytest>=8"]
|
|
33
33
|
|
|
34
34
|
[tool.bumpver]
|
|
35
|
-
current_version = "0.
|
|
35
|
+
current_version = "0.4.0"
|
|
36
36
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
37
37
|
commit_message = "bump version {old_version} -> {new_version}"
|
|
38
38
|
commit = true
|
|
@@ -36,6 +36,9 @@ from __future__ import annotations
|
|
|
36
36
|
|
|
37
37
|
import argparse
|
|
38
38
|
import base64
|
|
39
|
+
import copy
|
|
40
|
+
import datetime
|
|
41
|
+
import difflib
|
|
39
42
|
import hashlib
|
|
40
43
|
import html
|
|
41
44
|
import json
|
|
@@ -580,6 +583,10 @@ def _marker(slide: Slide) -> str:
|
|
|
580
583
|
data["src"] = base64.b64encode(slide.src.encode()).decode()
|
|
581
584
|
elif slide.layout_name and slide.layout_name not in SECTION_LAYOUTS:
|
|
582
585
|
data["tpl"] = slide.layout_name
|
|
586
|
+
# Last-push stamp: `sync` reports it alongside drift. The Slides/Drive APIs
|
|
587
|
+
# have no per-slide edit times (file-level modifiedTime only), so this is
|
|
588
|
+
# the only per-slide timestamp that exists.
|
|
589
|
+
data["at"] = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
583
590
|
return f"<!-- s2g {json.dumps(data, separators=(',', ':'))} -->"
|
|
584
591
|
|
|
585
592
|
|
|
@@ -1141,6 +1148,16 @@ def push(slides_api, drive, deck, source, anchor, prune, base_dir=Path("."),
|
|
|
1141
1148
|
creates, deletes, skips, pruned = plan_sync(source, managed, prune, force)
|
|
1142
1149
|
if not (creates or deletes or pruned): # nothing changed — skip reorder/links/gets
|
|
1143
1150
|
return {"create": 0, "skip": len(skips), "replace": 0, "prune": 0}
|
|
1151
|
+
if not force:
|
|
1152
|
+
# Non-fast-forward guard: replacing or pruning a slide that was edited
|
|
1153
|
+
# in Google Slides since its last push would silently discard that edit.
|
|
1154
|
+
risks = _clobber_risks(pres, managed, source, pruned)
|
|
1155
|
+
if risks:
|
|
1156
|
+
for key in risks:
|
|
1157
|
+
logger.error(f"live edit would be lost: {key}")
|
|
1158
|
+
sys.exit("push rejected: the deck changed in Google Slides since the "
|
|
1159
|
+
"last push (slides above). Run `slidesync sync` to reconcile, "
|
|
1160
|
+
"or `push --force` to overwrite.")
|
|
1144
1161
|
layouts = _layout_map(slides_api, deck, pres)
|
|
1145
1162
|
templates = _template_index(slides_api, deck, pres)
|
|
1146
1163
|
reqs = [{"deleteObject": {"objectId": oid}} for oid in deletes + pruned]
|
|
@@ -1890,6 +1907,325 @@ def cmd_layouts(args):
|
|
|
1890
1907
|
logger.info(f"{name:<24} {', '.join(phs) or '(no placeholders)'}")
|
|
1891
1908
|
|
|
1892
1909
|
|
|
1910
|
+
# ---------------------------------------------------------------------------
|
|
1911
|
+
# Comments + sync (drift detection)
|
|
1912
|
+
# ---------------------------------------------------------------------------
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def shape_comments(raw: list[dict]) -> list[dict]:
|
|
1916
|
+
"""Drive comment threads -> [{id, page, author, content, resolved, replies}].
|
|
1917
|
+
|
|
1918
|
+
`page` is the anchored slide objectId (None for file-level comments) —
|
|
1919
|
+
note an objectId outlives its slide, so the anchor may point at a deleted
|
|
1920
|
+
page after a re-render. Resolve-action replies with no text are dropped.
|
|
1921
|
+
"""
|
|
1922
|
+
out = []
|
|
1923
|
+
for c in raw:
|
|
1924
|
+
try:
|
|
1925
|
+
anchor = json.loads(c.get("anchor") or "{}")
|
|
1926
|
+
except json.JSONDecodeError:
|
|
1927
|
+
anchor = {}
|
|
1928
|
+
pages = anchor.get("pages") or []
|
|
1929
|
+
out.append({
|
|
1930
|
+
"id": c.get("id", ""),
|
|
1931
|
+
"page": pages[0] if pages else None,
|
|
1932
|
+
"author": (c.get("author") or {}).get("displayName", ""),
|
|
1933
|
+
"content": (c.get("content") or "").strip(),
|
|
1934
|
+
"resolved": bool(c.get("resolved")),
|
|
1935
|
+
"modified": c.get("modifiedTime", ""),
|
|
1936
|
+
"replies": [
|
|
1937
|
+
{"author": (r.get("author") or {}).get("displayName", ""),
|
|
1938
|
+
"content": (r.get("content") or "").strip()}
|
|
1939
|
+
for r in c.get("replies", []) if (r.get("content") or "").strip()
|
|
1940
|
+
],
|
|
1941
|
+
})
|
|
1942
|
+
return out
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
_COMMENT_FIELDS = ("nextPageToken,comments(id,content,author(displayName),"
|
|
1946
|
+
"anchor,resolved,modifiedTime,replies(content,author(displayName)))")
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
def list_comments(drive, deck: str) -> list[dict]:
|
|
1950
|
+
raw, token = [], None
|
|
1951
|
+
while True:
|
|
1952
|
+
resp = drive.comments().list(fileId=deck, pageSize=100, pageToken=token,
|
|
1953
|
+
fields=_COMMENT_FIELDS).execute()
|
|
1954
|
+
raw += resp.get("comments", [])
|
|
1955
|
+
token = resp.get("nextPageToken")
|
|
1956
|
+
if not token:
|
|
1957
|
+
return raw
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def cmd_comments(args):
|
|
1961
|
+
_, drive = get_services(args.account)
|
|
1962
|
+
print(json.dumps(shape_comments(list_comments(drive, args.deck)), indent=2))
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def text_lines_md(src: str) -> list[str]:
|
|
1966
|
+
"""Markdown slide body -> normalised visible text lines (comments excluded)."""
|
|
1967
|
+
fences = [m.group("text") for m in VERBATIM_RE.finditer(src)]
|
|
1968
|
+
headings, paras, _img, _alt, table, _ = parse_body(VERBATIM_RE.sub("", src))
|
|
1969
|
+
lines = [headings[k] for k in sorted(headings)]
|
|
1970
|
+
lines += [p.text for p in paras]
|
|
1971
|
+
if table:
|
|
1972
|
+
lines += [" | ".join(row) for row in table]
|
|
1973
|
+
for f in fences:
|
|
1974
|
+
lines += f.splitlines()
|
|
1975
|
+
return _norm_lines(lines)
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def text_lines_native(s: dict) -> list[str]:
|
|
1979
|
+
"""Native slide JSON -> normalised visible text lines (notes excluded)."""
|
|
1980
|
+
boxes = []
|
|
1981
|
+
for el in s.get("pageElements", []):
|
|
1982
|
+
if el.get("shape", {}).get("text"):
|
|
1983
|
+
paras = _paras_from_shape(el["shape"])
|
|
1984
|
+
if paras:
|
|
1985
|
+
boxes.append((_el_y(el), _el_x(el), paras))
|
|
1986
|
+
elif el.get("table"):
|
|
1987
|
+
rows = _table_from_native(el["table"])
|
|
1988
|
+
boxes.append((_el_y(el), _el_x(el),
|
|
1989
|
+
[Para(" | ".join(r)) for r in rows]))
|
|
1990
|
+
boxes.sort(key=lambda t: (t[0], t[1]))
|
|
1991
|
+
return _norm_lines([p.text for _, _, paras in boxes for p in paras])
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def _norm_lines(lines: list[str]) -> list[str]:
|
|
1995
|
+
# Sorted: box reading-order vs markdown order differ legitimately (e.g. a
|
|
1996
|
+
# kicker renders above the headline but is written below it).
|
|
1997
|
+
return sorted(" ".join(line.split()) for line in lines if line.strip())
|
|
1998
|
+
|
|
1999
|
+
|
|
2000
|
+
def classify_drift(base: list[str] | None, local: list[str],
|
|
2001
|
+
live: list[str]) -> str:
|
|
2002
|
+
"""Three-way status for one slide; base is the last-pushed source (marker)."""
|
|
2003
|
+
if base is None:
|
|
2004
|
+
return "clean" if local == live else "drift-no-base"
|
|
2005
|
+
if local == base and live == base:
|
|
2006
|
+
return "clean"
|
|
2007
|
+
if local != base and live == base:
|
|
2008
|
+
return "local-edit"
|
|
2009
|
+
if local == base and live != base:
|
|
2010
|
+
return "live-drift"
|
|
2011
|
+
return "converged" if local == live else "conflict"
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
def _diff(a: list[str], b: list[str], a_name: str, b_name: str) -> str:
|
|
2015
|
+
return "\n".join(difflib.unified_diff(a, b, a_name, b_name, lineterm=""))
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def _live_state(s) -> tuple[list[str], str, str | None, dict]:
|
|
2019
|
+
"""(text lines, normalised notes, base source, marker) of a live slide."""
|
|
2020
|
+
notes_raw = _read_notes(s)
|
|
2021
|
+
marker = _read_marker(notes_raw)
|
|
2022
|
+
base_src = base64.b64decode(marker["src"]).decode() if "src" in marker else None
|
|
2023
|
+
notes = " ".join(MARKER_RE.sub("", notes_raw).split())
|
|
2024
|
+
return text_lines_native(s), notes, base_src, marker
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def _clobber_risks(pres, managed, source, pruned) -> list[str]:
|
|
2028
|
+
"""Keys of slides whose live edits a push would silently discard.
|
|
2029
|
+
|
|
2030
|
+
A replace (or prune) deletes the live slide, which loses information only
|
|
2031
|
+
when the live copy drifted from its merge base AND the local markdown does
|
|
2032
|
+
not already contain the live content (as it does right after `sync`).
|
|
2033
|
+
Legacy slides without a src marker can't be checked and are allowed.
|
|
2034
|
+
"""
|
|
2035
|
+
live = {s["objectId"]: s for s in pres.get("slides", [])}
|
|
2036
|
+
by_kh = {s.key_hash: s for s in source}
|
|
2037
|
+
out = []
|
|
2038
|
+
for kh, (oid, _ch) in managed.items():
|
|
2039
|
+
sl = by_kh.get(kh)
|
|
2040
|
+
replacing = sl is not None and sl.custom is None and sl.object_id != oid
|
|
2041
|
+
if not (replacing or oid in pruned) or live.get(oid) is None:
|
|
2042
|
+
continue
|
|
2043
|
+
live_lines, live_notes, base_src, _marker_ = _live_state(live[oid])
|
|
2044
|
+
if base_src is None:
|
|
2045
|
+
continue
|
|
2046
|
+
if (live_lines == text_lines_md(base_src)
|
|
2047
|
+
and live_notes == " ".join(_extract_notes(base_src).split())):
|
|
2048
|
+
continue # deck untouched since last push
|
|
2049
|
+
if (sl is not None and live_lines == text_lines_md(sl.src or "")
|
|
2050
|
+
and live_notes == " ".join((sl.notes or "").split())):
|
|
2051
|
+
continue # local already carries the live edit
|
|
2052
|
+
out.append(sl.key if sl is not None else oid)
|
|
2053
|
+
return out
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def _slide_from_live_boxes(s, marker: dict) -> Slide:
|
|
2057
|
+
"""Rebuild a template slide's content from its deterministically-named boxes
|
|
2058
|
+
(`_k` kicker, `_h` headline, `_by` byline, `_b` body), formatting runs
|
|
2059
|
+
included — so live text edits can be written back into the markdown."""
|
|
2060
|
+
sid = s["objectId"]
|
|
2061
|
+
shapes = {el.get("objectId", ""): el["shape"]
|
|
2062
|
+
for el in s.get("pageElements", [])
|
|
2063
|
+
if el.get("shape", {}).get("text")}
|
|
2064
|
+
|
|
2065
|
+
def paras(suffix: str) -> list[Para]:
|
|
2066
|
+
shape = shapes.get(sid + suffix)
|
|
2067
|
+
return _paras_from_shape(shape) if shape else []
|
|
2068
|
+
|
|
2069
|
+
slide = Slide(marker.get("id", sid), "content")
|
|
2070
|
+
slide.template_name = marker.get("template")
|
|
2071
|
+
slide.vars = marker.get("vars", {})
|
|
2072
|
+
if (slide.template_name or "").lower() in ("prompt", "code"):
|
|
2073
|
+
slide.title = _flatten(paras("_k")).strip()
|
|
2074
|
+
slide.verbatim = "\n".join(p.text for p in paras("_b"))
|
|
2075
|
+
return slide
|
|
2076
|
+
headline = _flatten(paras("_h")).strip()
|
|
2077
|
+
kicker = _flatten(paras("_k")).strip()
|
|
2078
|
+
if headline:
|
|
2079
|
+
slide.title, slide.kicker = headline, kicker
|
|
2080
|
+
else:
|
|
2081
|
+
slide.title = kicker # content template: the kicker IS the title
|
|
2082
|
+
slide.paras = paras("_b") or paras("_by")
|
|
2083
|
+
if marker.get("img"):
|
|
2084
|
+
slide.layout = "image"
|
|
2085
|
+
slide.image, slide.image_alt = marker["img"], marker.get("alt", "")
|
|
2086
|
+
return slide
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
def _render_body(slide: Slide) -> str:
|
|
2090
|
+
"""Slide -> markdown body only (the slide's frontmatter stays as authored)."""
|
|
2091
|
+
bare = copy.copy(slide)
|
|
2092
|
+
bare.template_name, bare.layout_name, bare.vars = None, None, {}
|
|
2093
|
+
return to_slidev(bare, include_id=False).strip()
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
_SEP_RE = re.compile(r"(?m)^---[ \t]*$")
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _slide_span(text: str, key: str) -> tuple[int, int] | None:
|
|
2100
|
+
"""(start, end) of the body of the slide whose frontmatter id is `key`."""
|
|
2101
|
+
m = re.search(rf"(?m)^id:\s*{re.escape(key)}\s*$", text)
|
|
2102
|
+
if not m:
|
|
2103
|
+
return None # slide has no id: frontmatter — can't anchor file surgery
|
|
2104
|
+
closer = _SEP_RE.search(text, m.end())
|
|
2105
|
+
if not closer:
|
|
2106
|
+
return None
|
|
2107
|
+
nxt = _SEP_RE.search(text, closer.end())
|
|
2108
|
+
return closer.end(), nxt.start() if nxt else len(text)
|
|
2109
|
+
|
|
2110
|
+
|
|
2111
|
+
def _replace_slide_body(text: str, key: str, body: str) -> str:
|
|
2112
|
+
span = _slide_span(text, key)
|
|
2113
|
+
if span is None:
|
|
2114
|
+
return text
|
|
2115
|
+
return text[:span[0]] + "\n" + body.strip("\n") + "\n\n" + text[span[1]:]
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _append_to_slide_body(text: str, key: str, block: str) -> str:
|
|
2119
|
+
span = _slide_span(text, key)
|
|
2120
|
+
if span is None:
|
|
2121
|
+
return text
|
|
2122
|
+
return text[:span[1]].rstrip("\n") + "\n" + block + "\n\n" + text[span[1]:]
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def cmd_sync(args):
|
|
2126
|
+
"""Reconcile a markdown deck with its live Slides copy — applying what's safe.
|
|
2127
|
+
|
|
2128
|
+
Pull side: unresolved comment threads are appended to their slide as
|
|
2129
|
+
`<!-- @Author: text -->` blocks (orphaned threads are re-anchored via the
|
|
2130
|
+
objectId's key-hash), and live-drift slides — edited in Slides, untouched
|
|
2131
|
+
locally — are written back into the markdown, reconstructed from their
|
|
2132
|
+
styled boxes. Conflict slides (both sides changed) are never touched: their
|
|
2133
|
+
diffs print for a human/LLM to resolve, and the push step is skipped.
|
|
2134
|
+
Push side: when no conflicts remain, the (updated) file is pushed — safe,
|
|
2135
|
+
because local now matches live wherever the deck had drifted. Exits 1 when
|
|
2136
|
+
conflicts remain.
|
|
2137
|
+
"""
|
|
2138
|
+
slides_api, drive = get_services(args.account)
|
|
2139
|
+
path = args.source
|
|
2140
|
+
source = load_slides(path)
|
|
2141
|
+
deck = args.deck or deck_from_source(path)
|
|
2142
|
+
if not deck:
|
|
2143
|
+
sys.exit("no target deck: pass --deck or add `deck:` frontmatter")
|
|
2144
|
+
pres = slides_api.presentations().get(presentationId=deck).execute()
|
|
2145
|
+
live = {s["objectId"].split("_")[1]: s for s in pres.get("slides", [])
|
|
2146
|
+
if MANAGED_RE.match(s["objectId"])}
|
|
2147
|
+
by_page = {}
|
|
2148
|
+
for c in shape_comments(list_comments(drive, deck)):
|
|
2149
|
+
if not c["resolved"] and c["page"]:
|
|
2150
|
+
by_page.setdefault(c["page"], []).append(c)
|
|
2151
|
+
|
|
2152
|
+
state = {"text": path.read_text(), "dirty": False, "pushable": False}
|
|
2153
|
+
key_by_kh = {sl.key_hash: sl.key for sl in source}
|
|
2154
|
+
|
|
2155
|
+
def capture(key: str | None, c: dict, page: str) -> None:
|
|
2156
|
+
lines = [f"@{c['author']}: {c['content']}"]
|
|
2157
|
+
lines += [f"@{r['author']}: {r['content']}" for r in c["replies"]]
|
|
2158
|
+
block = "<!-- " + "\n".join(lines) + " -->"
|
|
2159
|
+
if " ".join(c["content"].split()) in " ".join(state["text"].split()):
|
|
2160
|
+
return # already captured
|
|
2161
|
+
new = _append_to_slide_body(state["text"], key, block) if key else state["text"]
|
|
2162
|
+
if new != state["text"]:
|
|
2163
|
+
state.update(text=new, dirty=True, pushable=True)
|
|
2164
|
+
logger.info(f"[comment ] {key} — captured thread by {c['author']}")
|
|
2165
|
+
else:
|
|
2166
|
+
logger.warning(f"[comment ] thread by {c['author']} on {key or page} "
|
|
2167
|
+
f"couldn't be placed; paste manually:\n{block}")
|
|
2168
|
+
|
|
2169
|
+
conflicts = []
|
|
2170
|
+
for sl in source:
|
|
2171
|
+
s = live.pop(sl.key_hash, None)
|
|
2172
|
+
if s is None:
|
|
2173
|
+
logger.info(f"[missing ] {sl.key} — push will create it")
|
|
2174
|
+
state["pushable"] = True
|
|
2175
|
+
continue
|
|
2176
|
+
for c in by_page.pop(s["objectId"], []):
|
|
2177
|
+
capture(sl.key, c, s["objectId"])
|
|
2178
|
+
if sl.custom is not None:
|
|
2179
|
+
continue # pull-authoritative; drawings are captured by pull, not diffed
|
|
2180
|
+
live_lines, live_notes, base_src, marker = _live_state(s)
|
|
2181
|
+
base = text_lines_md(base_src) if base_src is not None else None
|
|
2182
|
+
status = classify_drift(base, text_lines_md(sl.src or ""), live_lines)
|
|
2183
|
+
if status in ("clean", "converged"):
|
|
2184
|
+
continue
|
|
2185
|
+
if status == "local-edit":
|
|
2186
|
+
logger.info(f"[local-edit] {sl.key} — push will update it")
|
|
2187
|
+
state["pushable"] = True
|
|
2188
|
+
continue
|
|
2189
|
+
rebuilt = (_slide_from_live_boxes(s, marker)
|
|
2190
|
+
if status in ("live-drift", "drift-no-base")
|
|
2191
|
+
and marker.get("template") else None)
|
|
2192
|
+
if rebuilt and (rebuilt.title or rebuilt.paras or rebuilt.verbatim):
|
|
2193
|
+
rebuilt.notes = " ".join(MARKER_RE.sub("", _read_notes(s)).split())
|
|
2194
|
+
new = _replace_slide_body(state["text"], sl.key, _render_body(rebuilt))
|
|
2195
|
+
if new != state["text"]:
|
|
2196
|
+
state.update(text=new, dirty=True, pushable=True)
|
|
2197
|
+
logger.info(f"[pulled ] {sl.key} — live edit written back")
|
|
2198
|
+
continue
|
|
2199
|
+
conflicts.append(sl.key)
|
|
2200
|
+
logger.error(f"[conflict ] {sl.key} (pushed {marker.get('at', '?')}) — "
|
|
2201
|
+
f"resolve in the markdown, then push:\n"
|
|
2202
|
+
+ _diff(base or [], text_lines_md(sl.src or ""),
|
|
2203
|
+
"last-pushed", "local") + "\n"
|
|
2204
|
+
+ _diff(base or [], live_lines, "last-pushed", "live"))
|
|
2205
|
+
for page, cs in by_page.items(): # threads on re-rendered (deleted) pages
|
|
2206
|
+
m = re.match(r"s2g_(?P<kh>[0-9a-f]{10})_", page)
|
|
2207
|
+
key = key_by_kh.get(m.group("kh")) if m else None
|
|
2208
|
+
for c in cs:
|
|
2209
|
+
capture(key, c, page)
|
|
2210
|
+
for _kh, s in live.items():
|
|
2211
|
+
logger.warning(f"[unmanaged ] {s['objectId']} has no local slide "
|
|
2212
|
+
"(push --prune would delete it)")
|
|
2213
|
+
|
|
2214
|
+
if state["dirty"]:
|
|
2215
|
+
path.write_text(state["text"])
|
|
2216
|
+
logger.success(f"updated {path}")
|
|
2217
|
+
if conflicts:
|
|
2218
|
+
sys.exit(f"sync stopped: {len(conflicts)} conflict(s) above — resolve in "
|
|
2219
|
+
"the markdown, then `slidesync push` (its guard protects the rest).")
|
|
2220
|
+
if state["pushable"]:
|
|
2221
|
+
stats = push(slides_api, drive, deck, load_slides(path), anchor=None,
|
|
2222
|
+
prune=False, base_dir=path.parent)
|
|
2223
|
+
logger.success(f"{stats} -> "
|
|
2224
|
+
f"https://docs.google.com/presentation/d/{deck}/edit")
|
|
2225
|
+
else:
|
|
2226
|
+
logger.success("deck matches source — nothing to do")
|
|
2227
|
+
|
|
2228
|
+
|
|
1893
2229
|
def _loop_hop(slides_api, drive, title, slides):
|
|
1894
2230
|
"""One md->slides hop: build a deck, push, pull back."""
|
|
1895
2231
|
deck = new_deck(slides_api, title)
|
|
@@ -1966,6 +2302,17 @@ def main():
|
|
|
1966
2302
|
p.add_argument("deck")
|
|
1967
2303
|
p.set_defaults(func=cmd_layouts)
|
|
1968
2304
|
|
|
2305
|
+
p = sub.add_parser("comments",
|
|
2306
|
+
help="list comment threads as JSON (page anchor, author, replies)")
|
|
2307
|
+
p.add_argument("deck")
|
|
2308
|
+
p.set_defaults(func=cmd_comments)
|
|
2309
|
+
|
|
2310
|
+
p = sub.add_parser("sync",
|
|
2311
|
+
help="report drift vs the live deck (comments, live edits, conflicts)")
|
|
2312
|
+
p.add_argument("source", type=Path)
|
|
2313
|
+
p.add_argument("--deck")
|
|
2314
|
+
p.set_defaults(func=cmd_sync)
|
|
2315
|
+
|
|
1969
2316
|
p = sub.add_parser("make-templates",
|
|
1970
2317
|
help="add branded tagged template slides to a deck")
|
|
1971
2318
|
p.add_argument("deck")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
|
|
5
5
|
Author-email: Daniel Hails <slidesync@hails.info>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
22
22
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
23
23
|
images, brand-styled text boxes), not pasted screenshots.
|
|
24
24
|
|
|
25
|
-
Version: 0.
|
|
25
|
+
Version: 0.4.0
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -58,11 +58,13 @@ Support path.)
|
|
|
58
58
|
|
|
59
59
|
| Command | Purpose |
|
|
60
60
|
|---------|---------|
|
|
61
|
-
| `slidesync push <file.slidev.md> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides |
|
|
61
|
+
| `slidesync push <file.slidev.md> [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
|
|
62
62
|
| `slidesync pull <deckId> --out <file.md> [--all]` | Slides → markdown (`--all` includes non-managed slides) |
|
|
63
63
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
64
64
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
65
65
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
66
|
+
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
67
|
+
| `slidesync sync <file.slidev.md> [--deck ID]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |
|
|
66
68
|
|
|
67
69
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
68
70
|
`deck:` frontmatter key. Relative image paths resolve against the markdown file's
|
|
@@ -87,6 +89,33 @@ hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
|
|
|
87
89
|
path, template vars — and, for template slides, the authored body markdown
|
|
88
90
|
(base64) — so `pull` recovers the source verbatim.
|
|
89
91
|
|
|
92
|
+
## Sync & drift
|
|
93
|
+
|
|
94
|
+
`push` is guarded like a non-fast-forward git push: if a slide it would replace
|
|
95
|
+
(or prune) was edited in Google Slides since the last push — and the local
|
|
96
|
+
markdown doesn't already carry that edit — the push is **rejected** with no
|
|
97
|
+
changes made (`--force` overwrites). Live edits on slides the push wouldn't
|
|
98
|
+
touch are left alone.
|
|
99
|
+
|
|
100
|
+
`sync` reconciles the two sides, applying whatever is safe. The marker's
|
|
101
|
+
last-pushed source is a true per-slide **merge base**, so each slide classifies
|
|
102
|
+
three-way without timestamps (the APIs expose no per-slide edit times — only
|
|
103
|
+
file-level `modifiedTime`; the marker's `at` stamp records our last push):
|
|
104
|
+
|
|
105
|
+
| status | meaning | sync does |
|
|
106
|
+
|--------|---------|-----------|
|
|
107
|
+
| `clean` / `converged` | nothing changed, or both sides made the same change | nothing |
|
|
108
|
+
| `local-edit` | markdown changed, deck untouched | pushes it |
|
|
109
|
+
| `live-drift` | slide edited in Google Slides | writes the live content back into the markdown (reconstructed from its styled boxes, formatting runs included), then pushes |
|
|
110
|
+
| `conflict` | both changed since last push | prints both diffs vs the base for a human/LLM to resolve; skips the push; exits 1 |
|
|
111
|
+
|
|
112
|
+
Unresolved comment threads are appended to their slide as
|
|
113
|
+
`<!-- @Author: text -->` blocks (replies as extra `@Author:` lines), and threads
|
|
114
|
+
orphaned by a re-render are re-anchored to their slide via the objectId's
|
|
115
|
+
key-hash. Captured text round-trips from then on. Write-back caveat: a slide
|
|
116
|
+
edited live is rewritten canonically, so its authored comments collapse into
|
|
117
|
+
one trailing block (untouched slides keep comments in place).
|
|
118
|
+
|
|
90
119
|
## Markdown dialect
|
|
91
120
|
|
|
92
121
|
Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
|
|
@@ -11,5 +11,7 @@ slidesync.egg-info/entry_points.txt
|
|
|
11
11
|
slidesync.egg-info/requires.txt
|
|
12
12
|
slidesync.egg-info/top_level.txt
|
|
13
13
|
tests/test_comment_preservation.py
|
|
14
|
+
tests/test_e2e_scenarios.py
|
|
14
15
|
tests/test_markdown.py
|
|
15
|
-
tests/test_pull.py
|
|
16
|
+
tests/test_pull.py
|
|
17
|
+
tests/test_sync_drift.py
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""End-to-end scenarios for push/sync against an in-memory Slides+Drive fake.
|
|
2
|
+
|
|
3
|
+
The fake interprets exactly the request types slidesync emits (createSlide,
|
|
4
|
+
deleteObject, createShape, insertText, deleteText, updateSlidesPosition) and
|
|
5
|
+
renders state back in the wire format `presentations.get` returns — so push,
|
|
6
|
+
the non-fast-forward guard, and sync's pull-back/capture all run unmodified.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from slidesync import _sync
|
|
16
|
+
from slidesync._sync import cmd_sync, load_slides, push
|
|
17
|
+
|
|
18
|
+
DECK = "fakedeck"
|
|
19
|
+
|
|
20
|
+
MD = """---
|
|
21
|
+
theme: seriph
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
template: topic
|
|
26
|
+
id: thread-a
|
|
27
|
+
---
|
|
28
|
+
# Takeaway A
|
|
29
|
+
## FINDING · DATA
|
|
30
|
+
|
|
31
|
+
One solid point.
|
|
32
|
+
|
|
33
|
+
<!-- presenter note A -->
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
---
|
|
37
|
+
template: content
|
|
38
|
+
id: overview
|
|
39
|
+
---
|
|
40
|
+
## OVERVIEW
|
|
41
|
+
|
|
42
|
+
First result line.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text_els(text):
|
|
47
|
+
els = []
|
|
48
|
+
for line in text.split("\n"):
|
|
49
|
+
els.append({"paragraphMarker": {}})
|
|
50
|
+
els.append({"textRun": {"content": line + "\n", "style": {}}})
|
|
51
|
+
return els if text else []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FakeStore:
|
|
55
|
+
"""In-memory deck: ordered slides, each with text shapes + speaker notes."""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self.slides = [] # {"id", "shapes": {sid: {"y", "x", "text"}}, "notes"}
|
|
59
|
+
|
|
60
|
+
def _slide(self, oid):
|
|
61
|
+
return next(s for s in self.slides if s["id"] == oid)
|
|
62
|
+
|
|
63
|
+
def _owner_of_shape(self, sid):
|
|
64
|
+
for s in self.slides:
|
|
65
|
+
if sid in s["shapes"] or sid == s["id"] + "_n":
|
|
66
|
+
return s
|
|
67
|
+
raise KeyError(sid)
|
|
68
|
+
|
|
69
|
+
def apply(self, reqs):
|
|
70
|
+
for r in reqs:
|
|
71
|
+
if "createSlide" in r:
|
|
72
|
+
self.slides.append({"id": r["createSlide"]["objectId"],
|
|
73
|
+
"shapes": {}, "notes": ""})
|
|
74
|
+
elif "deleteObject" in r:
|
|
75
|
+
self.slides = [s for s in self.slides
|
|
76
|
+
if s["id"] != r["deleteObject"]["objectId"]]
|
|
77
|
+
elif "createShape" in r:
|
|
78
|
+
ep = r["createShape"]["elementProperties"]
|
|
79
|
+
tr = ep.get("transform", {})
|
|
80
|
+
self._slide(ep["pageObjectId"])["shapes"][
|
|
81
|
+
r["createShape"]["objectId"]] = {
|
|
82
|
+
"y": tr.get("translateY", 0), "x": tr.get("translateX", 0),
|
|
83
|
+
"text": ""}
|
|
84
|
+
elif "insertText" in r:
|
|
85
|
+
sid = r["insertText"]["objectId"]
|
|
86
|
+
s = self._owner_of_shape(sid)
|
|
87
|
+
if sid == s["id"] + "_n":
|
|
88
|
+
s["notes"] += r["insertText"]["text"]
|
|
89
|
+
else:
|
|
90
|
+
s["shapes"][sid]["text"] += r["insertText"]["text"]
|
|
91
|
+
elif "deleteText" in r:
|
|
92
|
+
sid = r["deleteText"]["objectId"]
|
|
93
|
+
s = self._owner_of_shape(sid)
|
|
94
|
+
if sid == s["id"] + "_n":
|
|
95
|
+
s["notes"] = ""
|
|
96
|
+
else:
|
|
97
|
+
s["shapes"][sid]["text"] = ""
|
|
98
|
+
elif "updateSlidesPosition" in r:
|
|
99
|
+
oid = r["updateSlidesPosition"]["slideObjectIds"][0]
|
|
100
|
+
moved = self._slide(oid)
|
|
101
|
+
rest = [s for s in self.slides if s["id"] != oid]
|
|
102
|
+
rest.insert(r["updateSlidesPosition"]["insertionIndex"], moved)
|
|
103
|
+
self.slides = rest
|
|
104
|
+
# styling-only requests are ignored
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
def render(self):
|
|
108
|
+
out = []
|
|
109
|
+
for s in self.slides:
|
|
110
|
+
els = [{"objectId": sid,
|
|
111
|
+
"transform": {"translateY": sh["y"], "translateX": sh["x"]},
|
|
112
|
+
"shape": {"text": {"textElements": _text_els(sh["text"])}}}
|
|
113
|
+
for sid, sh in s["shapes"].items() if sh["text"]]
|
|
114
|
+
nid = s["id"] + "_n"
|
|
115
|
+
out.append({
|
|
116
|
+
"objectId": s["id"], "pageElements": els,
|
|
117
|
+
"slideProperties": {"notesPage": {
|
|
118
|
+
"notesProperties": {"speakerNotesObjectId": nid},
|
|
119
|
+
"pageElements": [{"objectId": nid, "shape": {
|
|
120
|
+
"text": {"textElements": _text_els(s["notes"])}}}]}}})
|
|
121
|
+
return {"slides": out, "layouts": []}
|
|
122
|
+
|
|
123
|
+
# test helpers ---------------------------------------------------------
|
|
124
|
+
def oid_of(self, needle):
|
|
125
|
+
for s in self.slides:
|
|
126
|
+
if any(needle in sh["text"] for sh in s["shapes"].values()):
|
|
127
|
+
return s["id"]
|
|
128
|
+
raise AssertionError(f"no live slide contains {needle!r}")
|
|
129
|
+
|
|
130
|
+
def edit_text(self, old, new):
|
|
131
|
+
for s in self.slides:
|
|
132
|
+
for sh in s["shapes"].values():
|
|
133
|
+
if old in sh["text"]:
|
|
134
|
+
sh["text"] = sh["text"].replace(old, new)
|
|
135
|
+
return
|
|
136
|
+
raise AssertionError(f"no live shape contains {old!r}")
|
|
137
|
+
|
|
138
|
+
def all_text(self):
|
|
139
|
+
return " ".join(sh["text"] for s in self.slides
|
|
140
|
+
for sh in s["shapes"].values())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _Req:
|
|
144
|
+
def __init__(self, fn):
|
|
145
|
+
self.execute = fn
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class FakeSlides:
|
|
149
|
+
def __init__(self, store):
|
|
150
|
+
self.store = store
|
|
151
|
+
|
|
152
|
+
def presentations(self):
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def get(self, presentationId, fields=None):
|
|
156
|
+
return _Req(lambda: self.store.render())
|
|
157
|
+
|
|
158
|
+
def batchUpdate(self, presentationId, body):
|
|
159
|
+
return _Req(lambda: self.store.apply(body["requests"]))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class FakeDrive:
|
|
163
|
+
def __init__(self):
|
|
164
|
+
self.threads = [] # raw Drive comment dicts
|
|
165
|
+
|
|
166
|
+
def comments(self):
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def list(self, fileId, pageSize=None, pageToken=None, fields=None):
|
|
170
|
+
return _Req(lambda: {"comments": self.threads})
|
|
171
|
+
|
|
172
|
+
def add(self, page_oid, content, author="Daniel Hails"):
|
|
173
|
+
self.threads.append({
|
|
174
|
+
"id": f"c{len(self.threads)}",
|
|
175
|
+
"anchor": json.dumps({"type": "page", "pages": [page_oid]}),
|
|
176
|
+
"author": {"displayName": author},
|
|
177
|
+
"content": content, "replies": []})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.fixture
|
|
181
|
+
def env(tmp_path, monkeypatch):
|
|
182
|
+
store, drive = FakeStore(), FakeDrive()
|
|
183
|
+
slides_api = FakeSlides(store)
|
|
184
|
+
monkeypatch.setattr(_sync, "get_services", lambda account: (slides_api, drive))
|
|
185
|
+
path = tmp_path / "deck.slidev.md"
|
|
186
|
+
path.write_text(MD)
|
|
187
|
+
push(slides_api, drive, DECK, load_slides(path), anchor=None, prune=False,
|
|
188
|
+
base_dir=tmp_path)
|
|
189
|
+
return SimpleNamespace(store=store, drive=drive, slides=slides_api, path=path)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _sync_cmd(env):
|
|
193
|
+
cmd_sync(SimpleNamespace(source=env.path, deck=DECK, account=None))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _push(env, force=False, prune=False):
|
|
197
|
+
return push(env.slides, env.drive, DECK, load_slides(env.path), anchor=None,
|
|
198
|
+
prune=prune, base_dir=env.path.parent, force=force)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _local_edit(env):
|
|
202
|
+
env.path.write_text(env.path.read_text().replace(
|
|
203
|
+
"One solid point.", "One solid point, sharpened."))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _live_edit(env):
|
|
207
|
+
env.store.edit_text("Takeaway A", "Takeaway A, live-edited")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
SCENARIOS = {
|
|
211
|
+
"clean": dict(setup=[], sync_conflicts=False,
|
|
212
|
+
file_has=[], live_has=["Takeaway A"]),
|
|
213
|
+
"local-edit": dict(setup=[_local_edit], sync_conflicts=False,
|
|
214
|
+
file_has=["sharpened"], live_has=["sharpened"]),
|
|
215
|
+
"live-drift": dict(setup=[_live_edit], sync_conflicts=False,
|
|
216
|
+
file_has=["live-edited"], live_has=["live-edited"]),
|
|
217
|
+
"conflict": dict(setup=[_live_edit,
|
|
218
|
+
lambda e: e.path.write_text(e.path.read_text().replace(
|
|
219
|
+
"Takeaway A", "Takeaway A, locally-reworded"))],
|
|
220
|
+
sync_conflicts=True,
|
|
221
|
+
file_has=["locally-reworded"], live_has=["live-edited"]),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@pytest.mark.parametrize("name", SCENARIOS)
|
|
226
|
+
def test_sync_scenarios(env, name):
|
|
227
|
+
sc = SCENARIOS[name]
|
|
228
|
+
for step in sc["setup"]:
|
|
229
|
+
step(env)
|
|
230
|
+
if sc["sync_conflicts"]:
|
|
231
|
+
with pytest.raises(SystemExit):
|
|
232
|
+
_sync_cmd(env)
|
|
233
|
+
else:
|
|
234
|
+
_sync_cmd(env)
|
|
235
|
+
text, live = env.path.read_text(), env.store.all_text()
|
|
236
|
+
for needle in sc["file_has"]:
|
|
237
|
+
assert needle in text, f"{name}: {needle!r} missing from markdown"
|
|
238
|
+
for needle in sc["live_has"]:
|
|
239
|
+
assert needle in live, f"{name}: {needle!r} missing from deck"
|
|
240
|
+
if not sc["sync_conflicts"]: # reconciled: a follow-up sync is a no-op
|
|
241
|
+
before = env.path.read_text()
|
|
242
|
+
_sync_cmd(env)
|
|
243
|
+
assert env.path.read_text() == before
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_push_rejects_conflicting_replace_and_force_overrides(env):
|
|
247
|
+
_live_edit(env)
|
|
248
|
+
env.path.write_text(env.path.read_text().replace(
|
|
249
|
+
"Takeaway A", "Takeaway A, locally-reworded"))
|
|
250
|
+
with pytest.raises(SystemExit):
|
|
251
|
+
_push(env)
|
|
252
|
+
assert "live-edited" in env.store.all_text() # guard left the deck intact
|
|
253
|
+
_push(env, force=True)
|
|
254
|
+
assert "locally-reworded" in env.store.all_text()
|
|
255
|
+
assert "live-edited" not in env.store.all_text()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_push_rejects_pruning_a_live_edited_slide(env):
|
|
259
|
+
_live_edit(env)
|
|
260
|
+
env.path.write_text(re.sub(r"(?ms)^---\ntemplate: topic\n.*?(?=^---$\n---\n)",
|
|
261
|
+
"", env.path.read_text(), count=1))
|
|
262
|
+
assert "thread-a" not in env.path.read_text()
|
|
263
|
+
with pytest.raises(SystemExit):
|
|
264
|
+
_push(env, prune=True)
|
|
265
|
+
_push(env, prune=True, force=True)
|
|
266
|
+
assert "Takeaway A" not in env.store.all_text()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_push_ignores_live_drift_on_untouched_slides(env):
|
|
270
|
+
# No local change -> nothing is replaced -> nothing can be lost -> no guard.
|
|
271
|
+
_live_edit(env)
|
|
272
|
+
stats = _push(env)
|
|
273
|
+
assert stats["replace"] == 0
|
|
274
|
+
assert "live-edited" in env.store.all_text()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_sync_captures_comment_onto_its_slide(env):
|
|
278
|
+
env.drive.add(env.store.oid_of("Takeaway A"), "This is obviously dodgy. To debug")
|
|
279
|
+
_sync_cmd(env)
|
|
280
|
+
text = env.path.read_text()
|
|
281
|
+
assert "<!-- @Daniel Hails: This is obviously dodgy. To debug -->" in text
|
|
282
|
+
assert text.index("Takeaway A") < text.index("obviously dodgy") < text.index("OVERVIEW")
|
|
283
|
+
_sync_cmd(env) # idempotent: captured text is not duplicated
|
|
284
|
+
assert env.path.read_text().count("obviously dodgy") == 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_sync_recaptures_orphaned_comment_via_key_hash(env):
|
|
288
|
+
env.drive.add(env.store.oid_of("Takeaway A"), "anchored before the re-render")
|
|
289
|
+
_local_edit(env)
|
|
290
|
+
_push(env) # re-render replaces the slide; the thread's page id now dangles
|
|
291
|
+
_sync_cmd(env)
|
|
292
|
+
assert "<!-- @Daniel Hails: anchored before the re-render -->" in env.path.read_text()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Offline tests for drift detection: comment shaping, text-line extraction
|
|
2
|
+
parity (markdown vs native), and the three-way classification."""
|
|
3
|
+
|
|
4
|
+
from slidesync._sync import (
|
|
5
|
+
classify_drift,
|
|
6
|
+
shape_comments,
|
|
7
|
+
text_lines_md,
|
|
8
|
+
text_lines_native,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
RAW_COMMENTS = [
|
|
12
|
+
{"id": "c1",
|
|
13
|
+
"anchor": '{"type":"page","uid":1,"pages":["s2g_aaaaaaaaaa_bbbbbbbbbb"]}',
|
|
14
|
+
"author": {"displayName": "Daniel Hails"},
|
|
15
|
+
"content": "This is obviously dodgy. To debug",
|
|
16
|
+
"modifiedTime": "2026-06-12T15:00:00Z",
|
|
17
|
+
"replies": [
|
|
18
|
+
{"author": {"displayName": "Ted"}, "content": "agree"},
|
|
19
|
+
{"author": {"displayName": "Daniel Hails"}, "content": ""}, # resolve action
|
|
20
|
+
]},
|
|
21
|
+
{"id": "c2", "anchor": "not json", "author": {}, "content": "file-level",
|
|
22
|
+
"resolved": True},
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_shape_comments_extracts_page_author_replies():
|
|
27
|
+
a, b = shape_comments(RAW_COMMENTS)
|
|
28
|
+
assert a["page"] == "s2g_aaaaaaaaaa_bbbbbbbbbb"
|
|
29
|
+
assert a["author"] == "Daniel Hails" and not a["resolved"]
|
|
30
|
+
assert a["replies"] == [{"author": "Ted", "content": "agree"}]
|
|
31
|
+
assert b["page"] is None and b["resolved"] is True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _native(texts):
|
|
35
|
+
"""A native slide with one text box per entry, stacked top to bottom."""
|
|
36
|
+
els = []
|
|
37
|
+
for i, text in enumerate(texts):
|
|
38
|
+
els.append({"transform": {"translateY": i * 914400, "translateX": 0},
|
|
39
|
+
"shape": {"text": {"textElements": [
|
|
40
|
+
{"paragraphMarker": {}},
|
|
41
|
+
{"textRun": {"content": text, "style": {}}}]}}})
|
|
42
|
+
return {"objectId": "s", "pageElements": els}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_md_and_native_text_lines_agree_for_a_styled_slide():
|
|
46
|
+
md = "# 2026/06/15\n## RELIABLE MONITORS\n\nWeekly Update\n<!-- a note -->"
|
|
47
|
+
# live render stacks kicker above headline — order differs, content matches
|
|
48
|
+
native = _native(["RELIABLE MONITORS", "2026/06/15", "Weekly Update"])
|
|
49
|
+
assert text_lines_md(md) == text_lines_native(native)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_md_text_lines_include_verbatim_fences_and_skip_comments():
|
|
53
|
+
md = "## PROMPT\n```text\nYou are a monitor.\n```\n<!-- hidden -->"
|
|
54
|
+
lines = text_lines_md(md)
|
|
55
|
+
assert "You are a monitor." in lines
|
|
56
|
+
assert not any("hidden" in line for line in lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_classify_drift_three_way():
|
|
60
|
+
base, local, live = ["a"], ["a"], ["a"]
|
|
61
|
+
assert classify_drift(base, local, live) == "clean"
|
|
62
|
+
assert classify_drift(base, ["b"], live) == "local-edit"
|
|
63
|
+
assert classify_drift(base, local, ["b"]) == "live-drift"
|
|
64
|
+
assert classify_drift(base, ["b"], ["c"]) == "conflict"
|
|
65
|
+
assert classify_drift(base, ["b"], ["b"]) == "converged"
|
|
66
|
+
assert classify_drift(None, ["a"], ["a"]) == "clean"
|
|
67
|
+
assert classify_drift(None, ["a"], ["b"]) == "drift-no-base"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|