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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidesync
3
- Version: 0.6.0
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.0
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.0
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.0"
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.0"
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
@@ -30,7 +30,7 @@ from slidesync._sync import (
30
30
  write_slidev,
31
31
  )
32
32
 
33
- __version__ = "0.6.0"
33
+ __version__ = "0.6.2"
34
34
 
35
35
  __all__ = [
36
36
  "Para",
@@ -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
- match = next((c for c in live if not c["resolved"]
1290
- and " ".join(c["content"].split()) == norm), None)
1291
- if match is None or match["page"] == s.object_id:
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 match["replies"]:
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
- try:
1305
- drive.comments().delete(fileId=deck,
1306
- commentId=match["id"]).execute()
1307
- except Exception as exc: # noqa: BLE001 — foreign-authored thread
1308
- logger.warning(f"left dangling thread {match['id']} in place "
1309
- f"(not deletable): {exc}")
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.0
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.0
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
- self.d.threads = [t for t in self.d.threads if t["id"] != commentId]
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