slidesync 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {slidesync-0.2.0/slidesync.egg-info → slidesync-0.3.0}/PKG-INFO +23 -2
- {slidesync-0.2.0 → slidesync-0.3.0}/README.md +22 -1
- {slidesync-0.2.0 → slidesync-0.3.0}/pyproject.toml +2 -2
- {slidesync-0.2.0 → slidesync-0.3.0}/slidesync/__init__.py +1 -1
- {slidesync-0.2.0 → slidesync-0.3.0}/slidesync/_sync.py +203 -0
- {slidesync-0.2.0 → slidesync-0.3.0/slidesync.egg-info}/PKG-INFO +23 -2
- {slidesync-0.2.0 → slidesync-0.3.0}/slidesync.egg-info/SOURCES.txt +2 -1
- slidesync-0.3.0/tests/test_sync_drift.py +67 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/LICENSE +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/requirements.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/setup.cfg +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/slidesync.egg-info/dependency_links.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/slidesync.egg-info/entry_points.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/slidesync.egg-info/requires.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/slidesync.egg-info/top_level.txt +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/tests/test_comment_preservation.py +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/tests/test_markdown.py +0 -0
- {slidesync-0.2.0 → slidesync-0.3.0}/tests/test_pull.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
|
|
5
5
|
Author-email: Daniel Hails <slidesync@hails.info>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
22
22
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
23
23
|
images, brand-styled text boxes), not pasted screenshots.
|
|
24
24
|
|
|
25
|
-
Version: 0.
|
|
25
|
+
Version: 0.3.0
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -63,6 +63,8 @@ Support path.)
|
|
|
63
63
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
64
64
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
65
65
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
66
|
+
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
67
|
+
| `slidesync sync <file.slidev.md> [--deck ID]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
|
|
66
68
|
|
|
67
69
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
68
70
|
`deck:` frontmatter key. Relative image paths resolve against the markdown file's
|
|
@@ -87,6 +89,25 @@ 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 (detection, not resolution)
|
|
93
|
+
|
|
94
|
+
The marker's last-pushed source is a true per-slide **merge base**, so `sync`
|
|
95
|
+
classifies each slide three-way without timestamps (the Slides API has no
|
|
96
|
+
per-slide edit times — only file-level `modifiedTime`; the marker's `at` stamp
|
|
97
|
+
records when *we* last pushed each slide):
|
|
98
|
+
|
|
99
|
+
| status | meaning | action |
|
|
100
|
+
|--------|---------|--------|
|
|
101
|
+
| `clean` / `converged` | nothing changed, or both sides made the same change | — |
|
|
102
|
+
| `local-edit` | markdown changed, deck untouched | `push` (normal) |
|
|
103
|
+
| `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
|
|
104
|
+
| `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
|
|
105
|
+
|
|
106
|
+
Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
|
|
107
|
+
blocks on their slide (replies as extra `@Author:` lines) — paste them into the
|
|
108
|
+
source, where they round-trip from then on. Threads anchor to slide objectIds,
|
|
109
|
+
so a re-render orphans them: `sync` reports those too. Capture before you push.
|
|
110
|
+
|
|
90
111
|
## Markdown dialect
|
|
91
112
|
|
|
92
113
|
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.3.0
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
uvx slidesync --help # run without installing
|
|
@@ -45,6 +45,8 @@ Support path.)
|
|
|
45
45
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
46
46
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
47
47
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
48
|
+
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
49
|
+
| `slidesync sync <file.slidev.md> [--deck ID]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
|
|
48
50
|
|
|
49
51
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
50
52
|
`deck:` frontmatter key. Relative image paths resolve against the markdown file's
|
|
@@ -69,6 +71,25 @@ 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 (detection, not resolution)
|
|
75
|
+
|
|
76
|
+
The marker's last-pushed source is a true per-slide **merge base**, so `sync`
|
|
77
|
+
classifies each slide three-way without timestamps (the Slides API has no
|
|
78
|
+
per-slide edit times — only file-level `modifiedTime`; the marker's `at` stamp
|
|
79
|
+
records when *we* last pushed each slide):
|
|
80
|
+
|
|
81
|
+
| status | meaning | action |
|
|
82
|
+
|--------|---------|--------|
|
|
83
|
+
| `clean` / `converged` | nothing changed, or both sides made the same change | — |
|
|
84
|
+
| `local-edit` | markdown changed, deck untouched | `push` (normal) |
|
|
85
|
+
| `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
|
|
86
|
+
| `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
|
|
87
|
+
|
|
88
|
+
Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
|
|
89
|
+
blocks on their slide (replies as extra `@Author:` lines) — paste them into the
|
|
90
|
+
source, where they round-trip from then on. Threads anchor to slide objectIds,
|
|
91
|
+
so a re-render orphans them: `sync` reports those too. Capture before you push.
|
|
92
|
+
|
|
72
93
|
## Markdown dialect
|
|
73
94
|
|
|
74
95
|
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.3.0"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name = "Daniel Hails", email = "slidesync@hails.info" },
|
|
6
6
|
]
|
|
@@ -32,7 +32,7 @@ include = ["slidesync*"]
|
|
|
32
32
|
dev = ["pytest>=8"]
|
|
33
33
|
|
|
34
34
|
[tool.bumpver]
|
|
35
|
-
current_version = "0.
|
|
35
|
+
current_version = "0.3.0"
|
|
36
36
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
37
37
|
commit_message = "bump version {old_version} -> {new_version}"
|
|
38
38
|
commit = true
|
|
@@ -36,6 +36,8 @@ from __future__ import annotations
|
|
|
36
36
|
|
|
37
37
|
import argparse
|
|
38
38
|
import base64
|
|
39
|
+
import datetime
|
|
40
|
+
import difflib
|
|
39
41
|
import hashlib
|
|
40
42
|
import html
|
|
41
43
|
import json
|
|
@@ -580,6 +582,10 @@ def _marker(slide: Slide) -> str:
|
|
|
580
582
|
data["src"] = base64.b64encode(slide.src.encode()).decode()
|
|
581
583
|
elif slide.layout_name and slide.layout_name not in SECTION_LAYOUTS:
|
|
582
584
|
data["tpl"] = slide.layout_name
|
|
585
|
+
# Last-push stamp: `sync` reports it alongside drift. The Slides/Drive APIs
|
|
586
|
+
# have no per-slide edit times (file-level modifiedTime only), so this is
|
|
587
|
+
# the only per-slide timestamp that exists.
|
|
588
|
+
data["at"] = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
583
589
|
return f"<!-- s2g {json.dumps(data, separators=(',', ':'))} -->"
|
|
584
590
|
|
|
585
591
|
|
|
@@ -1890,6 +1896,192 @@ def cmd_layouts(args):
|
|
|
1890
1896
|
logger.info(f"{name:<24} {', '.join(phs) or '(no placeholders)'}")
|
|
1891
1897
|
|
|
1892
1898
|
|
|
1899
|
+
# ---------------------------------------------------------------------------
|
|
1900
|
+
# Comments + sync (drift detection)
|
|
1901
|
+
# ---------------------------------------------------------------------------
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def shape_comments(raw: list[dict]) -> list[dict]:
|
|
1905
|
+
"""Drive comment threads -> [{id, page, author, content, resolved, replies}].
|
|
1906
|
+
|
|
1907
|
+
`page` is the anchored slide objectId (None for file-level comments) —
|
|
1908
|
+
note an objectId outlives its slide, so the anchor may point at a deleted
|
|
1909
|
+
page after a re-render. Resolve-action replies with no text are dropped.
|
|
1910
|
+
"""
|
|
1911
|
+
out = []
|
|
1912
|
+
for c in raw:
|
|
1913
|
+
try:
|
|
1914
|
+
anchor = json.loads(c.get("anchor") or "{}")
|
|
1915
|
+
except json.JSONDecodeError:
|
|
1916
|
+
anchor = {}
|
|
1917
|
+
pages = anchor.get("pages") or []
|
|
1918
|
+
out.append({
|
|
1919
|
+
"id": c.get("id", ""),
|
|
1920
|
+
"page": pages[0] if pages else None,
|
|
1921
|
+
"author": (c.get("author") or {}).get("displayName", ""),
|
|
1922
|
+
"content": (c.get("content") or "").strip(),
|
|
1923
|
+
"resolved": bool(c.get("resolved")),
|
|
1924
|
+
"modified": c.get("modifiedTime", ""),
|
|
1925
|
+
"replies": [
|
|
1926
|
+
{"author": (r.get("author") or {}).get("displayName", ""),
|
|
1927
|
+
"content": (r.get("content") or "").strip()}
|
|
1928
|
+
for r in c.get("replies", []) if (r.get("content") or "").strip()
|
|
1929
|
+
],
|
|
1930
|
+
})
|
|
1931
|
+
return out
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
_COMMENT_FIELDS = ("nextPageToken,comments(id,content,author(displayName),"
|
|
1935
|
+
"anchor,resolved,modifiedTime,replies(content,author(displayName)))")
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def list_comments(drive, deck: str) -> list[dict]:
|
|
1939
|
+
raw, token = [], None
|
|
1940
|
+
while True:
|
|
1941
|
+
resp = drive.comments().list(fileId=deck, pageSize=100, pageToken=token,
|
|
1942
|
+
fields=_COMMENT_FIELDS).execute()
|
|
1943
|
+
raw += resp.get("comments", [])
|
|
1944
|
+
token = resp.get("nextPageToken")
|
|
1945
|
+
if not token:
|
|
1946
|
+
return raw
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
def cmd_comments(args):
|
|
1950
|
+
_, drive = get_services(args.account)
|
|
1951
|
+
print(json.dumps(shape_comments(list_comments(drive, args.deck)), indent=2))
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
def text_lines_md(src: str) -> list[str]:
|
|
1955
|
+
"""Markdown slide body -> normalised visible text lines (comments excluded)."""
|
|
1956
|
+
fences = [m.group("text") for m in VERBATIM_RE.finditer(src)]
|
|
1957
|
+
headings, paras, _img, _alt, table, _ = parse_body(VERBATIM_RE.sub("", src))
|
|
1958
|
+
lines = [headings[k] for k in sorted(headings)]
|
|
1959
|
+
lines += [p.text for p in paras]
|
|
1960
|
+
if table:
|
|
1961
|
+
lines += [" | ".join(row) for row in table]
|
|
1962
|
+
for f in fences:
|
|
1963
|
+
lines += f.splitlines()
|
|
1964
|
+
return _norm_lines(lines)
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
def text_lines_native(s: dict) -> list[str]:
|
|
1968
|
+
"""Native slide JSON -> normalised visible text lines (notes excluded)."""
|
|
1969
|
+
boxes = []
|
|
1970
|
+
for el in s.get("pageElements", []):
|
|
1971
|
+
if el.get("shape", {}).get("text"):
|
|
1972
|
+
paras = _paras_from_shape(el["shape"])
|
|
1973
|
+
if paras:
|
|
1974
|
+
boxes.append((_el_y(el), _el_x(el), paras))
|
|
1975
|
+
elif el.get("table"):
|
|
1976
|
+
rows = _table_from_native(el["table"])
|
|
1977
|
+
boxes.append((_el_y(el), _el_x(el),
|
|
1978
|
+
[Para(" | ".join(r)) for r in rows]))
|
|
1979
|
+
boxes.sort(key=lambda t: (t[0], t[1]))
|
|
1980
|
+
return _norm_lines([p.text for _, _, paras in boxes for p in paras])
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
def _norm_lines(lines: list[str]) -> list[str]:
|
|
1984
|
+
# Sorted: box reading-order vs markdown order differ legitimately (e.g. a
|
|
1985
|
+
# kicker renders above the headline but is written below it).
|
|
1986
|
+
return sorted(" ".join(line.split()) for line in lines if line.strip())
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
def classify_drift(base: list[str] | None, local: list[str],
|
|
1990
|
+
live: list[str]) -> str:
|
|
1991
|
+
"""Three-way status for one slide; base is the last-pushed source (marker)."""
|
|
1992
|
+
if base is None:
|
|
1993
|
+
return "clean" if local == live else "drift-no-base"
|
|
1994
|
+
if local == base and live == base:
|
|
1995
|
+
return "clean"
|
|
1996
|
+
if local != base and live == base:
|
|
1997
|
+
return "local-edit"
|
|
1998
|
+
if local == base and live != base:
|
|
1999
|
+
return "live-drift"
|
|
2000
|
+
return "converged" if local == live else "conflict"
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _diff(a: list[str], b: list[str], a_name: str, b_name: str) -> str:
|
|
2004
|
+
return "\n".join(difflib.unified_diff(a, b, a_name, b_name, lineterm=""))
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def cmd_sync(args):
|
|
2008
|
+
"""Report per-slide drift between a markdown deck and its live Slides copy.
|
|
2009
|
+
|
|
2010
|
+
Detection, not resolution: each slide is classified against the marker's
|
|
2011
|
+
last-pushed source (the merge base). Additive changes (comments, notes-pane
|
|
2012
|
+
edits) are printed as ready-to-paste `<!-- @Author: ... -->` blocks; text
|
|
2013
|
+
conflicts are printed as unified diffs for a human/LLM to fold into the
|
|
2014
|
+
markdown, after which a normal `push` (or `push --force` to clobber
|
|
2015
|
+
live-only drift) makes the file authoritative again. Exits 1 if anything
|
|
2016
|
+
drifted.
|
|
2017
|
+
"""
|
|
2018
|
+
slides_api, drive = get_services(args.account)
|
|
2019
|
+
source = load_slides(args.source)
|
|
2020
|
+
deck = args.deck or deck_from_source(args.source)
|
|
2021
|
+
if not deck:
|
|
2022
|
+
sys.exit("no target deck: pass --deck or add `deck:` frontmatter")
|
|
2023
|
+
pres = slides_api.presentations().get(presentationId=deck).execute()
|
|
2024
|
+
live = {s["objectId"].split("_")[1]: s for s in pres.get("slides", [])
|
|
2025
|
+
if MANAGED_RE.match(s["objectId"])}
|
|
2026
|
+
by_page = {}
|
|
2027
|
+
for c in shape_comments(list_comments(drive, deck)):
|
|
2028
|
+
if not c["resolved"] and c["page"]:
|
|
2029
|
+
by_page.setdefault(c["page"], []).append(c)
|
|
2030
|
+
drifted = 0
|
|
2031
|
+
for sl in source:
|
|
2032
|
+
s = live.pop(sl.key_hash, None)
|
|
2033
|
+
if s is None:
|
|
2034
|
+
logger.warning(f"[missing ] {sl.key} — not in deck; push will create it")
|
|
2035
|
+
drifted += 1
|
|
2036
|
+
continue
|
|
2037
|
+
for c in by_page.pop(s["objectId"], []):
|
|
2038
|
+
drifted += 1
|
|
2039
|
+
lines = [f"@{c['author']}: {c['content']}"]
|
|
2040
|
+
lines += [f"@{r['author']}: {r['content']}" for r in c["replies"]]
|
|
2041
|
+
logger.info(f"[comment ] {sl.key} ({c['modified']}):\n"
|
|
2042
|
+
+ "<!-- " + "\n".join(lines) + " -->")
|
|
2043
|
+
if sl.custom is not None:
|
|
2044
|
+
continue # pull-authoritative; drawings are captured, not diffed
|
|
2045
|
+
notes_raw = _read_notes(s)
|
|
2046
|
+
marker = _read_marker(notes_raw)
|
|
2047
|
+
base_src = (base64.b64decode(marker["src"]).decode()
|
|
2048
|
+
if "src" in marker else None)
|
|
2049
|
+
base = text_lines_md(base_src) if base_src is not None else None
|
|
2050
|
+
local = text_lines_md(sl.src or "")
|
|
2051
|
+
live_lines = text_lines_native(s)
|
|
2052
|
+
status = classify_drift(base, local, live_lines)
|
|
2053
|
+
pushed_at = marker.get("at", "?")
|
|
2054
|
+
if status in ("clean", "converged"):
|
|
2055
|
+
continue
|
|
2056
|
+
drifted += 1
|
|
2057
|
+
if status == "local-edit":
|
|
2058
|
+
logger.info(f"[local-edit] {sl.key} — push will update it")
|
|
2059
|
+
elif status in ("live-drift", "drift-no-base"):
|
|
2060
|
+
logger.warning(f"[live-drift] {sl.key} (pushed {pushed_at}) — edited in "
|
|
2061
|
+
f"Slides; fold into the markdown or `push --force` to clobber:\n"
|
|
2062
|
+
+ _diff(base if base is not None else local, live_lines,
|
|
2063
|
+
"last-pushed" if base is not None else "local", "live"))
|
|
2064
|
+
else: # conflict
|
|
2065
|
+
logger.error(f"[conflict ] {sl.key} (pushed {pushed_at}) — both sides "
|
|
2066
|
+
f"changed since last push:\n"
|
|
2067
|
+
+ _diff(base, local, "last-pushed", "local") + "\n"
|
|
2068
|
+
+ _diff(base, live_lines, "last-pushed", "live"))
|
|
2069
|
+
live_notes = " ".join(MARKER_RE.sub("", notes_raw).split())
|
|
2070
|
+
base_notes = " ".join(_extract_notes(base_src).split()) if base_src else ""
|
|
2071
|
+
if base_src is not None and live_notes != base_notes:
|
|
2072
|
+
logger.info(f"[notes ] {sl.key} — notes pane edited live:\n{live_notes}")
|
|
2073
|
+
for kh, s in live.items():
|
|
2074
|
+
logger.warning(f"[unmanaged ] {s['objectId']} in deck has no local slide "
|
|
2075
|
+
"(--prune on push would delete it)")
|
|
2076
|
+
for page, cs in by_page.items():
|
|
2077
|
+
for c in cs:
|
|
2078
|
+
logger.warning(f"[orphaned ] comment by {c['author']} anchored to deleted "
|
|
2079
|
+
f"slide {page}: {c['content']!r}")
|
|
2080
|
+
logger.log("WARNING" if drifted else "SUCCESS",
|
|
2081
|
+
f"{drifted} drifted item(s)" if drifted else "deck matches source")
|
|
2082
|
+
sys.exit(1 if drifted else 0)
|
|
2083
|
+
|
|
2084
|
+
|
|
1893
2085
|
def _loop_hop(slides_api, drive, title, slides):
|
|
1894
2086
|
"""One md->slides hop: build a deck, push, pull back."""
|
|
1895
2087
|
deck = new_deck(slides_api, title)
|
|
@@ -1966,6 +2158,17 @@ def main():
|
|
|
1966
2158
|
p.add_argument("deck")
|
|
1967
2159
|
p.set_defaults(func=cmd_layouts)
|
|
1968
2160
|
|
|
2161
|
+
p = sub.add_parser("comments",
|
|
2162
|
+
help="list comment threads as JSON (page anchor, author, replies)")
|
|
2163
|
+
p.add_argument("deck")
|
|
2164
|
+
p.set_defaults(func=cmd_comments)
|
|
2165
|
+
|
|
2166
|
+
p = sub.add_parser("sync",
|
|
2167
|
+
help="report drift vs the live deck (comments, live edits, conflicts)")
|
|
2168
|
+
p.add_argument("source", type=Path)
|
|
2169
|
+
p.add_argument("--deck")
|
|
2170
|
+
p.set_defaults(func=cmd_sync)
|
|
2171
|
+
|
|
1969
2172
|
p = sub.add_parser("make-templates",
|
|
1970
2173
|
help="add branded tagged template slides to a deck")
|
|
1971
2174
|
p.add_argument("deck")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
|
|
5
5
|
Author-email: Daniel Hails <slidesync@hails.info>
|
|
6
6
|
License: MIT
|
|
@@ -22,7 +22,7 @@ Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Googl
|
|
|
22
22
|
Slides** — as native, editable objects (title/body/bullets/tables/positioned
|
|
23
23
|
images, brand-styled text boxes), not pasted screenshots.
|
|
24
24
|
|
|
25
|
-
Version: 0.
|
|
25
|
+
Version: 0.3.0
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -63,6 +63,8 @@ Support path.)
|
|
|
63
63
|
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
|
|
64
64
|
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
|
|
65
65
|
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
|
|
66
|
+
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
|
|
67
|
+
| `slidesync sync <file.slidev.md> [--deck ID]` | report drift vs the live deck — comments, live edits, conflicts (exit 1 on drift) |
|
|
66
68
|
|
|
67
69
|
`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
|
|
68
70
|
`deck:` frontmatter key. Relative image paths resolve against the markdown file's
|
|
@@ -87,6 +89,25 @@ 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 (detection, not resolution)
|
|
93
|
+
|
|
94
|
+
The marker's last-pushed source is a true per-slide **merge base**, so `sync`
|
|
95
|
+
classifies each slide three-way without timestamps (the Slides API has no
|
|
96
|
+
per-slide edit times — only file-level `modifiedTime`; the marker's `at` stamp
|
|
97
|
+
records when *we* last pushed each slide):
|
|
98
|
+
|
|
99
|
+
| status | meaning | action |
|
|
100
|
+
|--------|---------|--------|
|
|
101
|
+
| `clean` / `converged` | nothing changed, or both sides made the same change | — |
|
|
102
|
+
| `local-edit` | markdown changed, deck untouched | `push` (normal) |
|
|
103
|
+
| `live-drift` | slide edited in Google Slides | fold the printed diff into the markdown, or `push --force` to clobber |
|
|
104
|
+
| `conflict` | both changed since last push | resolve the two printed diffs by hand/LLM, then `push` |
|
|
105
|
+
|
|
106
|
+
Unresolved comment threads print as ready-to-paste `<!-- @Author: text -->`
|
|
107
|
+
blocks on their slide (replies as extra `@Author:` lines) — paste them into the
|
|
108
|
+
source, where they round-trip from then on. Threads anchor to slide objectIds,
|
|
109
|
+
so a re-render orphans them: `sync` reports those too. Capture before you push.
|
|
110
|
+
|
|
90
111
|
## Markdown dialect
|
|
91
112
|
|
|
92
113
|
Top-level frontmatter: `theme:`, `deck:`. Slides separated by `---`; each slide
|
|
@@ -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
|