Commit ab95001
feat(ankify): nest synced decks per page, refresh styling, support Notion images (#2064)
## Summary
Three pre-existing gaps surfaced once polling started actually producing
cards (PR #2063 yesterday). All three addressed in one PR because they
share a code path and a deploy:
- **Per-page nested decks.** `Notion Sync::<page-title>` instead of one
flat `Notion Sync`. Anki auto-creates the parent. Sanitises `::` out of
titles, falls back to `Untitled`.
- **Styling refresh on every sync.** Always pushes canonical CSS +
templates via `updateModelStyling` / `updateModelTemplates` after
`ensureAnkifyModels`. Cheap, idempotent, self-heals desktop Anki
installs that pulled an empty-CSS variant of the model from AnkiWeb
during earlier sync attempts.
- **Notion images.** Walker now picks up `image`-type children of
toggles. `external` URLs are referenced directly; `file` URLs are
downloaded and pushed to AnkiConnect via `storeMediaFile` with a stable
`ankify-<blockId>.<ext>` filename so they survive the Notion signed-URL
expiry.
## Why
Al's first real polling tick today created 42 cards across 4
subscriptions. In desktop Anki he saw: one giant flat deck, no styling,
no images. After this:
- New cards land in `Notion Sync::<page>` per subscription — students
keep the page-level hierarchy in Anki.
- `ensureAnkifyModels` no longer relies on the model's first-create
being authoritative; the canonical CSS is repushed every sync.
- Toggles with images actually render the image, not a paragraph hole.
Direct path to the 300K-user goal: Ankify's whole pitch is "study Notion
in Anki without thinking about it." Decks that are organised, styled,
and visually complete are the ship-stopper for keeping users.
## How
- `SyncNotionPageToRacUseCase`:
- `buildDeckName(title)` derives `Notion Sync::<sanitized>` (strip `::`,
trim, fallback `Untitled`); used at `createDeck`, `addNote.deckName`,
and `mappings.upsert.deck_name`.
- `refreshAnkifyModelStyling` runs after `ensureAnkifyModels` and pushes
CSS + templates for both `Ankify Basic` and `Ankify Cloze`.
- `uploadCardImages` runs before each `addNote`. File-type images are
fetched, base64-encoded, and sent to `storeMediaFile`. Download failures
push to `result.errors` (which already flows to `sync_logs`), the card
still gets added.
- New optional `imageFetcher: typeof fetch` constructor param; defaults
to `fetch`. Test-only injection point.
- `notionPageWalker`:
- Adds `WalkedNotionImageRef` (`{block_id, source, url, filename?}`) to
`WalkedNotionFlashcard`.
- Recognises `image` children: emits `<img src="...">` in the back text
and tracks the ref. `file` images get a deterministic filename;
`external` images keep their URL.
- `AnkiConnectClient`: adds `updateModelStyling`,
`updateModelTemplates`, `storeMediaFile` methods + matching `*Params`
interfaces.
## Testing
- Unit tests added:
- [x] Walker: extracts file-type image, embeds `<img>`, returns ref with
derived filename
- [x] Walker: keeps external image URL as-is, no filename
- [x] Walker: skips empty-front toggles (regression)
- [x] AnkiConnectClient: `updateModelStyling`, `updateModelTemplates`,
`storeMediaFile` send the right action + params shape
- [x] Use case: `addNote` deck name nested under `Notion Sync::<title>`
- [x] Use case: falls back to `Untitled` when title is null
- [x] Use case: strips `::` from titles
- [x] Use case: `updateModelStyling` + `updateModelTemplates` invoked
after model ensure
- [x] Use case: `storeMediaFile` called with base64 data **before**
`addNote`
- [x] Use case: external images skip `storeMediaFile`
- [x] Use case: image download failure logs an error and still calls
`addNote`
- Manually verified:
- [x] Container's AnkiConnect `modelStyling` already returns full CSS —
confirmed before shipping the styling refresh; we ship it because Al's
desktop is downstream of AnkiWeb where an earlier empty-CSS variant of
the model is the most plausible cause.
- [ ] After deploy, Al confirms the next polling tick produces nested
decks + styled cards + visible images.
- `pnpm build`, web typecheck, web vitest, web biome lint: all clean.
- Server-side `pnpm test src/services/ankify/ src/usecases/ankify/`: 84
passed, 0 failed.
## Risks
- AnkiConnect `updateModelTemplates` adds two extra round-trips per
sync. Both calls are local container traffic so the cost is negligible
vs. the Notion fetch.
- Image downloads happen synchronously in the walker loop. A 5-image
card hits `fetch` 5 times in series. If this bites we move to a job
queue, but for the typical 0–2 images per toggle it's fine.
- Risk of double-base64-encoding on retried syncs is nil —
`storeMediaFile` is idempotent on (filename, content).
## Notes
- Existing 42 cards in Al's flat `Notion Sync` deck stay where they are.
Their mappings match by `source_id` on the next poll, hit the `existing
!= null` branch, and reconcile via `updateNoteFields` — the deck is not
changed for already-mapped cards. If Al wants a clean nested layout he
can delete the 42 cards manually; the next poll re-creates them in the
new structure.
- AnkiWeb sync still runs at the end of every successful sync; first
nested-deck sync after this PR will push the new `Notion Sync::<page>`
decks to AnkiWeb so desktop Anki sees them.
- Stale `.js` artefacts removed from the touched ankify files (Jest was
resolving them ahead of `.ts`, masking the new tests).
## Goal alignment
Hits all three pillars: **simpler** (decks organised by page; users
don't search a wall of cards), **faster** (styling and images render
correctly the first time, no manual fix-up), **more beautiful** (the
whole point of Ankify is the aesthetic). Direct contributor toward the
300K-user goal — flashcards that look broken are the fastest way to lose
a free trial.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 0825541 commit ab95001
7 files changed
Lines changed: 721 additions & 39 deletions
File tree
- src
- services/ankify
- usecases/ankify
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
155 | 155 | | |
156 | 156 | | |
157 | 157 | | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
158 | 221 | | |
159 | 222 | | |
160 | 223 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
94 | 106 | | |
95 | 107 | | |
96 | 108 | | |
| |||
185 | 197 | | |
186 | 198 | | |
187 | 199 | | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
188 | 220 | | |
189 | 221 | | |
190 | 222 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
1 | 2 | | |
2 | 3 | | |
3 | 4 | | |
| |||
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
9 | | - | |
10 | | - | |
11 | | - | |
| 10 | + | |
12 | 11 | | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | | - | |
18 | | - | |
19 | | - | |
20 | | - | |
21 | | - | |
22 | | - | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
23 | 48 | | |
24 | 49 | | |
25 | | - | |
26 | | - | |
27 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
28 | 57 | | |
29 | 58 | | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
34 | 64 | | |
35 | | - | |
| 65 | + | |
36 | 66 | | |
37 | 67 | | |
38 | | - | |
39 | | - | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
40 | 76 | | |
41 | 77 | | |
42 | 78 | | |
| 79 | + | |
| 80 | + | |
43 | 81 | | |
44 | 82 | | |
45 | 83 | | |
| |||
63 | 101 | | |
64 | 102 | | |
65 | 103 | | |
66 | | - | |
| 104 | + | |
67 | 105 | | |
68 | 106 | | |
69 | 107 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
0 commit comments