Skip to content

Commit ab95001

Browse files
aalemayhuclaude
andauthored
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/AnkiConnectClient.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,69 @@ describe('AnkiConnectClient', () => {
155155
});
156156
});
157157

158+
test('updateModelStyling posts the updateModelStyling action with model.css payload', async () => {
159+
const fetchImpl = makeFetch({ result: null, error: null });
160+
const client = new AnkiConnectClient('http://localhost:8765', fetchImpl);
161+
162+
await client.updateModelStyling({
163+
name: 'Ankify Basic',
164+
css: '.card { color: red; }',
165+
});
166+
167+
const body = JSON.parse((fetchImpl as jest.Mock).mock.calls[0][1].body);
168+
expect(body).toEqual({
169+
action: 'updateModelStyling',
170+
version: 6,
171+
params: {
172+
model: { name: 'Ankify Basic', css: '.card { color: red; }' },
173+
},
174+
});
175+
});
176+
177+
test('updateModelTemplates posts the updateModelTemplates action with templates payload', async () => {
178+
const fetchImpl = makeFetch({ result: null, error: null });
179+
const client = new AnkiConnectClient('http://localhost:8765', fetchImpl);
180+
181+
await client.updateModelTemplates({
182+
name: 'Ankify Basic',
183+
templates: {
184+
'Card 1': { Front: '{{Front}}', Back: '{{Back}}' },
185+
},
186+
});
187+
188+
const body = JSON.parse((fetchImpl as jest.Mock).mock.calls[0][1].body);
189+
expect(body).toEqual({
190+
action: 'updateModelTemplates',
191+
version: 6,
192+
params: {
193+
model: {
194+
name: 'Ankify Basic',
195+
templates: {
196+
'Card 1': { Front: '{{Front}}', Back: '{{Back}}' },
197+
},
198+
},
199+
},
200+
});
201+
});
202+
203+
test('storeMediaFile posts the storeMediaFile action with filename + base64 data', async () => {
204+
const fetchImpl = makeFetch({ result: 'ankify-x.png', error: null });
205+
const client = new AnkiConnectClient('http://localhost:8765', fetchImpl);
206+
207+
const stored = await client.storeMediaFile({
208+
filename: 'ankify-x.png',
209+
data: 'UEFTREFUQQ==',
210+
});
211+
212+
expect(stored).toBe('ankify-x.png');
213+
const body = JSON.parse((fetchImpl as jest.Mock).mock.calls[0][1].body);
214+
expect(body).toEqual({
215+
action: 'storeMediaFile',
216+
version: 6,
217+
params: { filename: 'ankify-x.png', data: 'UEFTREFUQQ==' },
218+
});
219+
});
220+
158221
test('throws AnkiConnectUnreachableError when fetch rejects', async () => {
159222
const fetchImpl = jest.fn(async () => {
160223
throw new Error('connect ECONNREFUSED');

src/services/ankify/AnkiConnectClient.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ export class AnkiConnectClient {
9191
return this.invoke('createModel', params as unknown as Record<string, unknown>);
9292
}
9393

94+
async updateModelStyling(params: AnkiConnectUpdateStylingParams): Promise<null> {
95+
return this.invoke('updateModelStyling', { model: params });
96+
}
97+
98+
async updateModelTemplates(params: AnkiConnectUpdateTemplatesParams): Promise<null> {
99+
return this.invoke('updateModelTemplates', { model: params });
100+
}
101+
102+
async storeMediaFile(params: AnkiConnectStoreMediaFileParams): Promise<string> {
103+
return this.invoke('storeMediaFile', params as unknown as Record<string, unknown>);
104+
}
105+
94106
async getNumCardsReviewedByDay(): Promise<Array<[string, number]>> {
95107
return this.invoke('getNumCardsReviewedByDay');
96108
}
@@ -185,6 +197,26 @@ export interface AnkiConnectCreateModelParams {
185197
cardTemplates: AnkiConnectCardTemplate[];
186198
}
187199

200+
export interface AnkiConnectUpdateStylingParams {
201+
name: string;
202+
css: string;
203+
}
204+
205+
export interface AnkiConnectTemplateBody {
206+
Front: string;
207+
Back: string;
208+
}
209+
210+
export interface AnkiConnectUpdateTemplatesParams {
211+
name: string;
212+
templates: Record<string, AnkiConnectTemplateBody>;
213+
}
214+
215+
export interface AnkiConnectStoreMediaFileParams {
216+
filename: string;
217+
data: string;
218+
}
219+
188220
export interface AnkiNoteInfo {
189221
noteId: number;
190222
modelName: string;
Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { NOTION_STYLE } from '../../templates/helper';
12
import { AnkiConnectCreateModelParams } from './AnkiConnectClient';
23

34
export const ANKIFY_BASIC_MODEL = 'Ankify Basic';
@@ -6,40 +7,77 @@ export const ANKIFY_CLOZE_MODEL = 'Ankify Cloze';
67
export const ANKIFY_BASIC_FIELDS = ['Front', 'Back'] as const;
78
export const ANKIFY_CLOZE_FIELDS = ['Text', 'Back Extra'] as const;
89

9-
const ANKIFY_CARD_STYLING = `
10-
html, body { text-align: center; }
11-
10+
const ANKIFY_CARD_OVERRIDES = `
1211
.card {
13-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica,
14-
"Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";
15-
color: black;
16-
background-color: white;
17-
border: lightgray 1px solid;
18-
padding: 16px;
19-
border-radius: 8px;
20-
margin: 16px;
21-
width: 80%;
22-
display: inline-block;
12+
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
13+
font-size: 18px;
14+
line-height: 1.5;
15+
color: rgb(55, 53, 47);
16+
background-color: rgb(255, 255, 255);
17+
text-align: left;
18+
padding: 32px 24px;
19+
max-width: 720px;
20+
margin: 0 auto;
21+
-webkit-font-smoothing: antialiased;
22+
text-rendering: optimizeLegibility;
23+
}
24+
25+
.nightMode.card,
26+
.night_mode .card {
27+
color: rgba(255, 255, 255, 0.9);
28+
background-color: rgb(25, 25, 25);
29+
}
30+
31+
.front-text-pre {
32+
display: block;
33+
font-size: 1.5rem;
34+
font-weight: 600;
35+
line-height: 1.3;
36+
letter-spacing: -0.01em;
37+
text-align: center;
38+
margin-bottom: 0.25em;
39+
}
40+
41+
.front-text-post {
42+
display: block;
43+
font-size: 1rem;
44+
font-weight: 500;
45+
color: rgba(55, 53, 47, 0.65);
46+
text-align: center;
47+
letter-spacing: -0.005em;
2348
}
2449
25-
.card:hover {
26-
box-shadow: 0 0 8px #ccc;
27-
border: 1px solid #fff;
50+
.back-text { display: block; }
51+
52+
.extra {
53+
display: block;
54+
margin-top: 1em;
55+
color: rgba(55, 53, 47, 0.6);
56+
font-size: 0.9em;
2857
}
2958
30-
.front-text-pre { font-size: 1.5rem; }
31-
.front-text-post { color: gray; font-size: 1rem; }
32-
.back-text { font-size: 1.5rem; text-align: left; }
33-
.extra { color: gray; }
59+
hr#answer {
60+
border: none;
61+
border-top: 1px solid rgba(55, 53, 47, 0.16);
62+
margin: 1.5em 0;
63+
}
3464
35-
hr { border: none; border-bottom: 1px solid rgba(55, 53, 47, 0.18); }
65+
mark { background: rgba(255, 212, 0, 0.25); color: inherit; padding: 0 2px; border-radius: 2px; }
3666
3767
.cloze {
38-
font-weight: bold;
39-
color: #2563eb;
68+
font-weight: 600;
69+
color: rgb(11, 110, 153);
70+
}
71+
.nightMode .cloze, .night_mode .cloze { color: rgb(82, 156, 202); }
72+
73+
@media (max-width: 480px) {
74+
.card { padding: 20px 16px; font-size: 17px; }
75+
.front-text-pre { font-size: 1.3rem; }
4076
}
4177
`.trim();
4278

79+
const ANKIFY_CARD_STYLING = `${NOTION_STYLE}\n\n${ANKIFY_CARD_OVERRIDES}`;
80+
4381
export const ankifyBasicCreateModelParams = (): AnkiConnectCreateModelParams => ({
4482
modelName: ANKIFY_BASIC_MODEL,
4583
inOrderFields: [...ANKIFY_BASIC_FIELDS],
@@ -63,7 +101,7 @@ export const ankifyClozeCreateModelParams = (): AnkiConnectCreateModelParams =>
63101
{
64102
Name: 'Cloze',
65103
Front: '{{cloze:Text}}',
66-
Back: '{{cloze:Text}}<br><span class="extra">{{Back Extra}}</span>',
104+
Back: '{{cloze:Text}}<hr id="answer"><span class="extra">{{Back Extra}}</span>',
67105
},
68106
],
69107
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { walkNotionPageForFlashcards } from './notionPageWalker';
2+
3+
const toggleBlock = (overrides: Record<string, unknown> = {}) => ({
4+
id: 'toggle-1',
5+
type: 'toggle',
6+
has_children: true,
7+
last_edited_time: '2026-05-09T12:00:00.000Z',
8+
toggle: { rich_text: [{ plain_text: 'What is Anki?' }] },
9+
...overrides,
10+
});
11+
12+
const paragraphChild = (text: string) => ({
13+
type: 'paragraph',
14+
paragraph: { rich_text: [{ plain_text: text }] },
15+
});
16+
17+
describe('walkNotionPageForFlashcards', () => {
18+
test('extracts image blocks from toggle children and embeds <img> in the back', async () => {
19+
const fetchChildren = jest.fn(async (blockId: string) => {
20+
if (blockId === 'page-id') {
21+
return [toggleBlock()];
22+
}
23+
return [
24+
paragraphChild('Spaced repetition.'),
25+
{
26+
id: 'img-block-77',
27+
type: 'image',
28+
image: {
29+
type: 'file',
30+
file: {
31+
url: 'https://prod-files.notion.so/abc/img.png?signed=1',
32+
expiry_time: '2026-05-09T13:00:00.000Z',
33+
},
34+
},
35+
},
36+
];
37+
});
38+
39+
const cards = await walkNotionPageForFlashcards('page-id', fetchChildren);
40+
41+
expect(cards).toHaveLength(1);
42+
expect(cards[0].back).toContain('Spaced repetition.');
43+
expect(cards[0].back).toContain('<img');
44+
expect(cards[0].images).toEqual([
45+
{
46+
block_id: 'img-block-77',
47+
source: 'file',
48+
url: 'https://prod-files.notion.so/abc/img.png?signed=1',
49+
filename: 'ankify-img-block-77.png',
50+
},
51+
]);
52+
});
53+
54+
test('keeps external-hosted image URLs as-is in the back HTML', async () => {
55+
const fetchChildren = jest.fn(async (blockId: string) => {
56+
if (blockId === 'page-id') {
57+
return [toggleBlock()];
58+
}
59+
return [
60+
{
61+
id: 'img-block-ext',
62+
type: 'image',
63+
image: {
64+
type: 'external',
65+
external: { url: 'https://cdn.example.com/diagram.png' },
66+
},
67+
},
68+
];
69+
});
70+
71+
const cards = await walkNotionPageForFlashcards('page-id', fetchChildren);
72+
73+
expect(cards[0].back).toContain(
74+
'<img src="https://cdn.example.com/diagram.png">'
75+
);
76+
expect(cards[0].images).toEqual([
77+
{
78+
block_id: 'img-block-ext',
79+
source: 'external',
80+
url: 'https://cdn.example.com/diagram.png',
81+
},
82+
]);
83+
});
84+
85+
test('skips toggles with empty front text', async () => {
86+
const fetchChildren = jest.fn(async (blockId: string) => {
87+
if (blockId === 'page-id') {
88+
return [
89+
toggleBlock({ id: 't-empty', toggle: { rich_text: [] } }),
90+
toggleBlock({ id: 't-real' }),
91+
];
92+
}
93+
return [paragraphChild('answer')];
94+
});
95+
96+
const cards = await walkNotionPageForFlashcards('page-id', fetchChildren);
97+
98+
expect(cards).toHaveLength(1);
99+
expect(cards[0].notion_block_id).toBe('t-real');
100+
});
101+
});

0 commit comments

Comments
 (0)