slidesync 0.6.0__tar.gz → 0.6.2__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.6.0/slidesync.egg-info → slidesync-0.6.2}/PKG-INFO +2 -2
- {slidesync-0.6.0 → slidesync-0.6.2}/README.md +1 -1
- {slidesync-0.6.0 → slidesync-0.6.2}/pyproject.toml +2 -2
- {slidesync-0.6.0 → slidesync-0.6.2}/slidesync/__init__.py +1 -1
- {slidesync-0.6.0 → slidesync-0.6.2}/slidesync/_sync.py +30 -13
- {slidesync-0.6.0 → slidesync-0.6.2/slidesync.egg-info}/PKG-INFO +2 -2
- {slidesync-0.6.0 → slidesync-0.6.2}/tests/test_e2e_scenarios.py +83 -2
- {slidesync-0.6.0 → slidesync-0.6.2}/LICENSE +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/requirements.txt +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/setup.cfg +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/slidesync.egg-info/SOURCES.txt +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/slidesync.egg-info/dependency_links.txt +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/slidesync.egg-info/entry_points.txt +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/slidesync.egg-info/requires.txt +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/slidesync.egg-info/top_level.txt +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/tests/test_comment_preservation.py +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/tests/test_markdown.py +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/tests/test_pull.py +0 -0
- {slidesync-0.6.0 → slidesync-0.6.2}/tests/test_sync_drift.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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.6.
|
|
25
|
+
Version: 0.6.2
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -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.6.
|
|
7
|
+
Version: 0.6.2
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
uvx slidesync --help # run without installing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "slidesync"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.2"
|
|
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.6.
|
|
35
|
+
current_version = "0.6.2"
|
|
36
36
|
version_pattern = "MAJOR.MINOR.PATCH"
|
|
37
37
|
commit_message = "bump version {old_version} -> {new_version}"
|
|
38
38
|
commit = true
|
|
@@ -1282,31 +1282,43 @@ def _restore_threads(drive, deck, creates) -> None:
|
|
|
1282
1282
|
if not slides_with:
|
|
1283
1283
|
return
|
|
1284
1284
|
live = shape_comments(list_comments(drive, deck))
|
|
1285
|
+
me = drive.about().get(
|
|
1286
|
+
fields="user(displayName)").execute()["user"]["displayName"]
|
|
1287
|
+
|
|
1288
|
+
def bare(content: str) -> str:
|
|
1289
|
+
# A re-created foreign thread carries its attribution in-content
|
|
1290
|
+
# ("@Fabien: ..."); strip it so every generation of a thread matches.
|
|
1291
|
+
return " ".join(re.sub(r"^@[^:]+:\s*", "", content).split())
|
|
1292
|
+
|
|
1285
1293
|
for s, blocks in slides_with:
|
|
1286
1294
|
for entries in blocks:
|
|
1287
1295
|
_author, head = entries[0]
|
|
1288
1296
|
norm = " ".join(head.split())
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
if
|
|
1297
|
+
matches = [c for c in live if not c["resolved"]
|
|
1298
|
+
and bare(c["content"]) == norm]
|
|
1299
|
+
if not matches or any(c["page"] == s.object_id for c in matches):
|
|
1292
1300
|
continue # resolved/deleted (don't revive) or already anchored
|
|
1301
|
+
content = matches[0]["content"]
|
|
1302
|
+
if matches[0]["author"] not in ("", me) \
|
|
1303
|
+
and not content.lstrip().startswith("@"):
|
|
1304
|
+
content = f"@{matches[0]['author']}: {content}"
|
|
1293
1305
|
anchor_json = json.dumps({"type": "page", "pages": [s.object_id]})
|
|
1294
1306
|
new = drive.comments().create(
|
|
1295
|
-
fileId=deck,
|
|
1296
|
-
body={"content": match["content"], "anchor": anchor_json},
|
|
1307
|
+
fileId=deck, body={"content": content, "anchor": anchor_json},
|
|
1297
1308
|
fields="id").execute()
|
|
1298
|
-
for r in
|
|
1309
|
+
for r in matches[0]["replies"]:
|
|
1299
1310
|
drive.replies().create(
|
|
1300
1311
|
fileId=deck, commentId=new["id"],
|
|
1301
1312
|
body={"content": f"@{r['author']}: {r['content']}"
|
|
1302
|
-
if r["author"] else r["content"]},
|
|
1313
|
+
if r["author"] not in ("", me) else r["content"]},
|
|
1303
1314
|
fields="id").execute()
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1315
|
+
for c in matches: # retire every stale copy we are allowed to
|
|
1316
|
+
try:
|
|
1317
|
+
drive.comments().delete(fileId=deck,
|
|
1318
|
+
commentId=c["id"]).execute()
|
|
1319
|
+
except Exception as exc: # noqa: BLE001 — foreign-authored
|
|
1320
|
+
logger.warning(f"left dangling thread {c['id']} by "
|
|
1321
|
+
f"{c['author']} in place (not deletable): {exc}")
|
|
1310
1322
|
logger.info(f"re-anchored comment thread on '{s.key}'")
|
|
1311
1323
|
|
|
1312
1324
|
|
|
@@ -2366,6 +2378,11 @@ def cmd_sync(args):
|
|
|
2366
2378
|
local = _content_lines(sl.src or "", sl.template_name) or []
|
|
2367
2379
|
status = classify_drift(base, local, live_lines)
|
|
2368
2380
|
if status in ("clean", "converged"):
|
|
2381
|
+
if s["objectId"] != sl.object_id:
|
|
2382
|
+
# Rendered text matches, but the content hash moved — a
|
|
2383
|
+
# comment/notes-level local change that still needs a push.
|
|
2384
|
+
logger.info(f"[notes-edit] {sl.key} — push will update it")
|
|
2385
|
+
state["pushable"] = True
|
|
2369
2386
|
continue
|
|
2370
2387
|
if status == "local-edit":
|
|
2371
2388
|
logger.info(f"[local-edit] {sl.key} — push will update it")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slidesync
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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.6.
|
|
25
|
+
Version: 0.6.2
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
uvx slidesync --help # run without installing
|
|
@@ -168,13 +168,16 @@ class _FakeComments:
|
|
|
168
168
|
|
|
169
169
|
def create(self, fileId, body=None, fields=None):
|
|
170
170
|
def go():
|
|
171
|
-
self.d.add_raw(body.get("anchor"), body["content"])
|
|
171
|
+
self.d.add_raw(body.get("anchor"), body["content"], author=self.d.me)
|
|
172
172
|
return {"id": self.d.threads[-1]["id"]}
|
|
173
173
|
return _Req(go)
|
|
174
174
|
|
|
175
175
|
def delete(self, fileId, commentId):
|
|
176
176
|
def go():
|
|
177
|
-
|
|
177
|
+
t = next(t for t in self.d.threads if t["id"] == commentId)
|
|
178
|
+
if t["author"]["displayName"] != self.d.me:
|
|
179
|
+
raise RuntimeError("403: insufficient permissions") # like Drive
|
|
180
|
+
self.d.threads.remove(t)
|
|
178
181
|
return {}
|
|
179
182
|
return _Req(go)
|
|
180
183
|
|
|
@@ -193,6 +196,8 @@ class _FakeReplies:
|
|
|
193
196
|
|
|
194
197
|
|
|
195
198
|
class FakeDrive:
|
|
199
|
+
me = "Daniel Hails" # the authenticated account's display name
|
|
200
|
+
|
|
196
201
|
def __init__(self):
|
|
197
202
|
self.threads = [] # raw Drive comment dicts
|
|
198
203
|
self.n = 0
|
|
@@ -203,6 +208,10 @@ class FakeDrive:
|
|
|
203
208
|
def replies(self):
|
|
204
209
|
return _FakeReplies(self)
|
|
205
210
|
|
|
211
|
+
def about(self):
|
|
212
|
+
return SimpleNamespace(get=lambda fields=None: _Req(
|
|
213
|
+
lambda: {"user": {"displayName": self.me}}))
|
|
214
|
+
|
|
206
215
|
def add_raw(self, anchor, content, author="Daniel Hails"):
|
|
207
216
|
self.n += 1
|
|
208
217
|
self.threads.append({
|
|
@@ -492,3 +501,75 @@ def test_resolved_threads_are_not_revived(env):
|
|
|
492
501
|
_sync_cmd(env)
|
|
493
502
|
assert all(t.get("resolved") for t in env.drive.threads), \
|
|
494
503
|
"push must not re-create resolved threads"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_foreign_thread_mirrors_with_attribution_and_survives_rerenders(env):
|
|
507
|
+
# Fabien (not the authenticated account) comments on a slide.
|
|
508
|
+
env.drive.add(env.store.oid_of("Takeaway A"), "have you controlled for length?",
|
|
509
|
+
author="Fabien")
|
|
510
|
+
_sync_cmd(env)
|
|
511
|
+
# Mirrored with HIS name, kept out of the speaker notes.
|
|
512
|
+
text = env.path.read_text()
|
|
513
|
+
assert "<!-- @Fabien: have you controlled for length? -->" in text
|
|
514
|
+
slide = next(s for s in env.store.slides
|
|
515
|
+
if any("Takeaway A" in sh["text"] for sh in s["shapes"].values()))
|
|
516
|
+
assert "controlled for length" not in slide["notes"]
|
|
517
|
+
# Re-anchored copy exists on the current page, attributed in-content; the
|
|
518
|
+
# undeletable foreign original may dangle, but the count must stay BOUNDED
|
|
519
|
+
# across further re-render cycles (no duplication).
|
|
520
|
+
def live_pages():
|
|
521
|
+
return {json.loads(t["anchor"])["pages"][0] for t in env.drive.threads}
|
|
522
|
+
assert slide["id"] in live_pages()
|
|
523
|
+
n_after_first = len(env.drive.threads)
|
|
524
|
+
for marker in ("cycle two", "cycle three"):
|
|
525
|
+
env.path.write_text(env.path.read_text().replace(
|
|
526
|
+
"One solid point", f"One solid point ({marker})"))
|
|
527
|
+
_sync_cmd(env)
|
|
528
|
+
assert len(env.drive.threads) == n_after_first, "threads must not duplicate"
|
|
529
|
+
current = next(s for s in env.store.slides
|
|
530
|
+
if any("Takeaway A" in sh["text"] for sh in s["shapes"].values()))
|
|
531
|
+
assert current["id"] in live_pages(), "thread follows the slide across renders"
|
|
532
|
+
anchored = [t for t in env.drive.threads
|
|
533
|
+
if json.loads(t["anchor"])["pages"][0] == current["id"]]
|
|
534
|
+
assert any("@Fabien:" in t["content"] for t in anchored), \
|
|
535
|
+
"re-created head keeps Fabien's attribution in-content"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_plain_attribution_notes_are_speaker_notes_not_threads(tmp_path, monkeypatch):
|
|
539
|
+
# The 2026-06-01 deck's mentor annotations are unprefixed attribution notes
|
|
540
|
+
# ("Fabien: ..."), not @-mirrors — they must stay presenter notes.
|
|
541
|
+
store, drive = FakeStore(), FakeDrive()
|
|
542
|
+
slides_api = FakeSlides(store)
|
|
543
|
+
monkeypatch.setattr(_sync, "get_services", lambda account: (slides_api, drive))
|
|
544
|
+
path = tmp_path / "deck.slidev.md"
|
|
545
|
+
path.write_text(MD.replace(
|
|
546
|
+
"<!-- presenter note A -->",
|
|
547
|
+
'<!-- Fabien: over-triggering is "the classic one." Stack-rank fixes '
|
|
548
|
+
"by difficulty / importance / access. -->"))
|
|
549
|
+
push(slides_api, drive, DECK, load_slides(path), anchor=None, prune=False,
|
|
550
|
+
base_dir=tmp_path)
|
|
551
|
+
slide = next(s for s in store.slides
|
|
552
|
+
if any("Takeaway A" in sh["text"] for sh in s["shapes"].values()))
|
|
553
|
+
assert "over-triggering" in slide["notes"], "plain notes stay in the pane"
|
|
554
|
+
assert not drive.threads, "no comment thread is fabricated from notes"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def test_force_push_does_not_churn_already_anchored_threads(env):
|
|
558
|
+
env.drive.add(env.store.oid_of("Takeaway A"), "anchored and settled")
|
|
559
|
+
_sync_cmd(env) # capture + re-anchor onto the current page
|
|
560
|
+
stable_ids = {t["id"] for t in env.drive.threads}
|
|
561
|
+
_push(env, force=True) # re-render with UNCHANGED content -> same objectIds
|
|
562
|
+
assert {t["id"] for t in env.drive.threads} == stable_ids, \
|
|
563
|
+
"an already-anchored thread must not be deleted/recreated"
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def test_comment_only_local_change_still_syncs(env):
|
|
567
|
+
# Converting a presenter note to an @-annotation changes no rendered text,
|
|
568
|
+
# but it changes the speaker notes + marker — sync must still push it.
|
|
569
|
+
env.path.write_text(env.path.read_text().replace(
|
|
570
|
+
"<!-- presenter note A -->", "<!-- @Ted: presenter note A -->"))
|
|
571
|
+
_sync_cmd(env)
|
|
572
|
+
slide = next(s for s in env.store.slides
|
|
573
|
+
if any("Takeaway A" in sh["text"] for sh in s["shapes"].values()))
|
|
574
|
+
assert "presenter note A" not in slide["notes"], \
|
|
575
|
+
"the @-annotation must leave the speaker-notes pane on the next sync"
|
|
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
|
|
File without changes
|