Metadata-Version: 2.4
Name: slidesync
Version: 0.14.0
Summary: Bidirectional sync between a Slidev markdown deck and Google Slides as native, editable objects
Author-email: Daniel Hails <slidesync@hails.info>
License: MIT
Project-URL: Homepage, https://github.com/DJRHails/slidesync
Project-URL: Bug Tracker, https://github.com/DJRHails/slidesync/issues
Keywords: slidev,google-slides,markdown,presentations,slides
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: google-api-python-client>=2
Requires-Dist: google-auth>=2
Requires-Dist: python-frontmatter>=1
Requires-Dist: loguru>=0.7
Requires-Dist: matplotlib>=3.8
Dynamic: license-file

# slidesync

Bidirectional sync between a [Slidev](https://sli.dev) markdown deck and **Google
Slides** — as native, editable objects (title/body/bullets/tables/positioned
images, brand-styled text boxes), not pasted screenshots.

Version: 0.14.0

```bash
uvx slidesync --help            # run without installing
pip install slidesync           # or install the CLI + library
```

## Why

Exporting a deck to images gives you something you can't edit; pasting markdown
by hand gives you something you can't version. `slidesync` keeps a `.slidev.md`
file as the source of truth and renders it into **real** Slides objects, so the
result stays fully editable in Google Slides — and `pull` reconstructs the
markdown back from those objects, so the loop is reversible.

- **`push`** — markdown → Slides (idempotent upsert, never a blind append).
- **`pull`** — Slides → markdown (handles multi-text-box and externally-authored
  decks, bullet nesting, tables, images, and speaker notes).
- **`roundtrip`** — push a sample to a scratch deck, pull it back, assert the two
  are semantically identical, delete the scratch deck.

## Auth (no setup)

Auth is **borrowed from the [`gog`](https://github.com/) CLI** — no separate
OAuth client. `slidesync` reads the client id/secret from
`~/Library/Application Support/gogcli/credentials.json` and the refresh token via
`gog auth tokens export`, then mints a short-lived access token. The stored token
already carries the `slides` + `drive` scopes; the Slides API must be enabled on
the gog Cloud project. Override the account with `--account` or
`$SLIDESYNC_ACCOUNT`. (Currently macOS-only — it reads gog's macOS Application
Support path.)

## Commands

| Command | Purpose |
|---------|---------|
| `slidesync push <file.slidev.md>... [--deck ID] [--new "Title"] [--anchor SLIDE] [--prune] [--force] [--allow-rekey]` | markdown → Slides (rejected if it would discard live edits; `--force` overrides) |
| `slidesync pull <deckId> --out <file.md> [--all]` | Slides → markdown (`--all` includes non-managed slides) |
| `slidesync roundtrip [--keep]` | self-test: push a sample, pull, assert identical |
| `slidesync layouts <deckId>` | list a deck's theme layouts + placeholders |
| `slidesync make-templates <deckId>` | inject branded `{{token}}` template slides |
| `slidesync comments <deckId>` | list comment threads as JSON (page anchor, author, content, replies) |
| `slidesync sync <file.slidev.md>... [--deck ID] [--prune] [--allow-rekey]` | reconcile with the live deck: pull comments + live edits into the markdown, push local changes; conflicts stop it (exit 1) |

`push` resolves the target deck from (in order) `--deck`, `--new`, or a top-level
`deck:` frontmatter key. Relative image paths resolve against each slide's own
source file.

**Multi-file decks**: `push`/`sync` accept several files (e.g.
`slidesync sync $(ls -r meetings/*.slidev.md)` — one file per meeting, newest
first). Deck order follows the argument order; slide ids namespace as
`<file-stem>-<id>` (`2026-06-15-overview`) so files can reuse ids; intra-file
`[text](#id)` links rewrite to the namespaced target, while fully-qualified
cross-file targets pass through. `sync` routes comment capture and live-edit
write-backs into the right source file under its local id.

```bash
slidesync push deck.slidev.md            # targets `deck:` frontmatter
slidesync push deck.slidev.md --new "Talk"
slidesync pull <id> --out deck.slidev.md
slidesync roundtrip
```

## Idempotent sync (upsert)

Each managed slide is created with `objectId = s2g_<keyHash>_<contentHash>`.
`keyHash` = per-slide `id:` frontmatter, else title slug, else index (survives
edits/reorders); `contentHash` is over a canonical render, so push → pull → push
is a no-op. Diff per run: identical hash → skip; same key, new content → replace;
new key → create. Removed slides are **kept** unless `--prune`. **Only `s2g_`
slides are ever touched** — hand-authored slides are invisible to the sync. A
hidden `<!-- s2g {...} -->` marker in speaker notes carries the human id, image
path, template vars — and, for template slides, the authored body markdown
(base64) — so `pull` recovers the source verbatim.

## Sync & drift

`push` is guarded like a non-fast-forward git push: if a slide it would replace
(or prune) was edited in Google Slides since the last push — and the local
markdown doesn't already carry that edit — the push is **rejected** with no
changes made (`--force` overwrites). Live edits on slides the push wouldn't
touch are left alone.

**Mass re-key guard**: a push that would recreate a deck-scale number of slides
under new ids — `max(10, 30% of managed slides)` created while at least as many
live `s2g_` slides match no local slide — is refused outright, *even with
`--force`* (an id-scheme change or key bug re-keys every slide; pushing that
plan destroys live styling/edits on all the old copies). Capture the live deck
first (`pull`/`sync` — `sync` matches re-keyed slides via their notes-marker id
and writes live edits, including styling-only highlights, back into the
markdown), then re-run with `--allow-rekey`.

`sync` reconciles the two sides, applying whatever is safe. The marker's
last-pushed source is a true per-slide **merge base**, so each slide classifies
three-way without timestamps (the APIs expose no per-slide edit times — only
file-level `modifiedTime`; the marker's `at` stamp records our last push):

| status | meaning | sync does |
|--------|---------|-----------|
| `clean` / `converged` | nothing changed, or both sides made the same change | nothing |
| `local-edit` | markdown changed, deck untouched | pushes it |
| `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 |
| `conflict` | both changed since last push | prints both diffs vs the base for a human/LLM to resolve; skips the push; exits 1 |

Unresolved comment threads are appended to their slide as
`<!-- @Author: text -->` blocks (replies as extra `@Author:` lines). These
mirrors are **comments, not presenter notes**: they stay out of the
speaker-notes pane, and when a re-render orphans the live thread, push
re-creates it anchored to the slide's new objectId (replies preserved; the
re-created thread is authored by the authenticated account). Resolving a
thread in Slides retires it — sync stops capturing and push won't revive it.
Write-back caveat: a slide edited live is rewritten canonically, so its
authored comments collapse into one trailing block (untouched slides keep
comments in place).

## Markdown dialect

Top-level frontmatter: `theme:`, `deck:`, `infer:`. Slides separated by `---`;
each slide may have its own frontmatter (`id:`, `template:`, `layout:`,
`hidden:`) — all of it optional.

- `# h1` = headline, `## h2` above an `# h1` = kicker; a lone `##` is the title.
- **Derived ids (always on):** a slide without `id:` frontmatter is keyed by
  its `#` headline slug, else its `##` title slug, else its figure filename
  stem (a `_deck` variant suffix is stripped), else its index. Internal links
  target the derived id (`[back](#cross-fires)` for `# CROSS-FIRES`), and
  comment capture / live-edit write-back anchor into id-less slides the same
  way. Duplicate ids — derived or explicit — fail the push; give one slide an
  explicit `id:` to break the tie.
- **Template inference (opt-in):** with `infer: true` in the file-level
  frontmatter, an untagged slide gets its `template:` from its shape — the
  first slide of a file is the `dark` title card; a `##` kicker above the `#`
  headline is a `dark` section divider; an `#` headline is a `topic`; a lone
  `##` title is `content`; a figure with no headings is a `graph`; a fenced
  block is a `prompt`. Explicit `template:` / `layout:` always wins; slides
  with tables or only a mermaid block stay untagged (the styled templates
  have no slot for them). The content hash carries the *effective* template,
  so deleting a `template:` line that matches what inference picks is a
  no-op push.
- **Hidden slides:** `hidden: true` (or `hide: true`) marks a slide **skipped**
  in the presentation — still a native, editable slide, just hidden in present
  mode (via the Slides API's `isSkipped`), the analogue of Slidev's `hidden`
  frontmatter. It's part of the content hash, so toggling it re-pushes, and it
  round-trips: `pull` reads the live skipped state back to `hidden: true` (so a
  slide skipped natively in Slides comes back hidden too).
- Bullets `-`/`*`; ordered `1.` (nest with 2-space indent). Inline
  `**bold**` / `*italic*` / `` `code` `` / `==highlight==` / `[link](url)`.
  GFM tables.
  `![alt](path)` images (uploaded to Drive; `alt` becomes the accessibility
  description, round-tripped on pull). Blank lines preserved as spacing.
  `<!-- notes -->` become speaker notes — and round-trip as **comments, in
  place**: template slides carry their authored source in the marker, so `pull`
  re-emits each comment where it was written instead of one merged trailing
  blob. Speaker notes edited live in Slides come back as one extra trailing
  comment.
- **Highlight:** `==text==` (the markdown-it-mark dialect; delimiters must be
  non-space-adjacent, so a bare `a == b` stays prose) renders as a warm amber
  wash (`#FFE08A`) behind the run, with the text pinned to ink so the mark
  stays legible on the dark templates too. Composes with the other inline
  styles run-by-run, and round-trips: `pull` reads the background wash back to
  `==text==` byte-identically.
- **Internal links:** `[text](#slide-id)` becomes a native Slides link to the
  slide whose `id:` (or title slug) is `slide-id`, and round-trips: `pull` reads
  the native page-link back to `[text](#slide-id)` (so it no longer churns).
- **Display equations:** a paragraph that is exactly a `$$...$$` block
  (single- or multi-line; several per slide allowed) is rendered to a
  tight-bbox transparent PNG via matplotlib **mathtext** (no TeX install
  needed) in the Computer Modern fontset — the classic TeX serif math face,
  so equations read like paper typography — and embedded as a centred image,
  sized above body text —
  presentation-equation scale (`template: equation` goes further: a full-slide
  focal equation, rendered at double density so the blow-up stays crisp). Covers the common constructs (`\frac`,
  `\approx`, sub/superscripts, `\times`, `\max`, `\text{}`); a construct
  outside the mathtext subset logs a warning and skips that graphic. Renders
  are cached by source hash. The LaTeX source is part of the content hash
  (editing an equation re-pushes the slide) and is stashed verbatim in the
  hidden notes marker, so `pull` reconstructs the original `$$...$$` block
  byte-identically. Inline `$x$` maths is out of scope.
- **Mermaid diagrams:** a fenced ```` ```mermaid ```` block is rendered to a PNG
  and embedded as an image (Slides has no native Mermaid renderer). Renders are
  cached by diagram hash, so an unchanged diagram is never re-rendered or
  re-uploaded; a render failure logs a warning and skips the graphic rather than
  aborting the push. Backend: `mmdc` (mermaid-cli) if it's on `PATH` (offline),
  else the [kroki.io](https://kroki.io) HTTP API (no extra dependency). The
  diagram source lives in the markdown, so it's the source of truth — `pull`
  recovers the rendered image, not the Mermaid source.

### Built-in brand kit (IBM Plex; red `#C0392B` kicker)

Select per slide via `template:` — native styled boxes, no in-deck templates:

| `template:` | Layout |
|---|---|
| `dark` / `title` | dark title card: centred kicker + 72pt headline; body lines render as a small dimmed **byline** (e.g. `Project · Presenter`) |
| `appendix` | light title card (same shape as `dark`, paper background) |
| `question` / `label` | red kicker + 50pt headline + centred body |
| `topic` | one-line 40pt headline, left-aligned body |
| `content` | the `##` kicker **is** the title (red, 18pt); left-aligned bullet body |
| `equation` | focal `$$…$$` equation, centred and LARGE (scaled up to fill ~85% of the width, clamped to fit); one `##` red kicker on top; **`# h1` is parsed but not rendered** (like `graph` ignoring title/body — it still round-trips via the marker); body text renders as a short centred plain-text caption under the equation |
| `graph` / `full` | single full-bleed image, no text |
| `prompt` / `code` | verbatim monospace block at the largest size that fits |

Title cards and `equation`/`graph`/`full` have no linkable body region.
Slides with no `template:` fall back to a generative path (section /
title+body / table / image) that also brands the background + IBM Plex.

### Custom slides (diagrams) — pull-authoritative

Give a slide a fenced ```` ```gslides ```` block holding literal Slides API
requests (use `__PAGE__` for the slide page id). Sync is **pull-authoritative /
push-if-missing**: the Slides copy is the source of truth — `push` only creates
the slide when missing, `pull` captures the live drawing back into the block.

### Template-slot validation

`push`/`sync` refuse up front when a slide carries content its template has no
slot for (it would render as nothing, silently): a heading, table, or prose
paragraph on a text-free `graph`/`full` slide; an `# h1` alongside the kicker
on an `equation` slide; an image/table on `prompt`/`code`; an image on
`equation`. Each violation names the slide and the fix — usually moving the
content into a `<!-- comment -->`, which becomes speaker notes.

**Graph-slide link footer**: the one exception on `graph`/`full` — a single
link-only paragraph (`[a →](url) · [b →](url)`, the trace-link convention)
renders as an 11pt right-aligned footer strip at the bottom. The fit is
aspect-aware: a wide image whose centred fit already leaves the strip free
keeps its full size; a taller image shrinks just enough to clear it. More
than one link-only line is a slot error — merge them into one line.

### Overlays — raw requests on top of a templated slide

A ```` ```gslides-overlay ```` block rides on a **normal templated/generative
slide** (unlike ```` ```gslides ````, which replaces the whole slide): its
literal Slides API requests are replayed **after** the slide's own render on
every push, with `__PAGE__` substituted by the slide page id. Use it for
annotation text boxes, arrows, or callouts a template can't express:

````markdown
---
template: graph
id: fig-thinking-off
---
![...](figure.png)

```gslides-overlay
{"requests": [
  {"createShape": {"objectId": "__PAGE___label", "shapeType": "TEXT_BOX",
    "elementProperties": {"pageObjectId": "__PAGE__", "size": {...}, "transform": {...}}}},
  {"insertText": {"objectId": "__PAGE___label", "text": "Thinking Off"}}
]}
```
````

The **markdown is the source of truth**: the block is part of the content hash
(edits re-push), it round-trips through the notes marker on `pull`, and a
content-changing push recreates the drawn elements — native edits to them in
Slides are not written back. Drift detection counts the overlay's `insertText`
lines as visible text, so an overlaid slide reads as clean. Prefix element ids
with `__PAGE__` so they stay unique across re-pushes.

## Development

```bash
uv sync
uv run pytest -q          # offline tests (no network/auth)
```

Releases publish to PyPI via Trusted Publishing (OIDC) on a `v*.*.*` tag — see
`.github/workflows/release.yml`. Bump with `uvx bumpver update --patch`.

## Caveats

- Slidev-only constructs (`<v-clicks>`, `<div grid>`, CSS) are flattened/stripped
  — this is a content mapper, not a CSS renderer.
- On `pull`, the slide model holds a single image, so a slide with multiple
  images keeps the first; image `contentUrl`s from foreign decks are ephemeral.
- Verbatim-source markers are seeded at push time, so comment preservation
  applies from the first push with v0.2+ (older slides re-render once: the
  content hash is now over the authored source). Generative-path slides (no
  `template:`) still merge comments into a single trailing comment on pull,
  since their live Slides edits — not the marker — are the source of truth.

## License

MIT © Daniel Hails
