From a880ea57631e6ae947feb3afb70de9304a30c16a Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Tue, 19 May 2026 15:40:23 +0200 Subject: [PATCH 1/4] fix: rewrite empty-deck response with spec copy and docs link EmptyDeckError now returns code 'empty_export' with a docsLink and copy that works for any file format, not just Notion. The previous message ("No toggles found in [filename]") read wrong for markdown, csv, and PDF uploads since none of those use Notion toggles. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/UploadService.test.ts | 15 ++++++++++++--- src/services/UploadService.ts | 7 ++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/services/UploadService.test.ts b/src/services/UploadService.test.ts index fcd20506e..75aaf495d 100644 --- a/src/services/UploadService.test.ts +++ b/src/services/UploadService.test.ts @@ -120,7 +120,7 @@ describe('UploadService.handleUpload — error paths', () => { MockGeneratePackagesUseCase.mockClear(); }); - it('returns 400 JSON with filename in message when no packages are produced', async () => { + it('returns 400 JSON with empty_export code, spec copy and docs link when no packages are produced', async () => { MockGeneratePackagesUseCase.mockImplementation(() => ({ execute: jest.fn().mockResolvedValue({ packages: [] }), }) as unknown as InstanceType); @@ -132,13 +132,22 @@ describe('UploadService.handleUpload — error paths', () => { await service.handleUpload(req, res); expect(capturedStatus()).toBe(400); - const body = capturedJson() as { message: string; filename: string }; + const body = capturedJson() as { + code: string; + message: string; + filename: string; + docsLink: string; + }; + expect(body.code).toBe('empty_export'); expect(typeof body.message).toBe('string'); expect(body.message).not.toMatch(/rules/i); expect(body.message).not.toMatch(/valid toggle/i); expect(body.message).not.toMatch(/<[a-z]/i); - expect(body.message).toContain('study-notes.zip'); + expect(body.message).toBe( + 'No cards were found in this file. Most files need a toggle-list (Notion) or a question/answer pair to become cards. See common problems for the formats that work.' + ); expect(body.filename).toBe('study-notes.zip'); + expect(body.docsLink).toBe('/documentation/help/common-problems'); }); it('EmptyDeckError response body contains no HTML tags', async () => { diff --git a/src/services/UploadService.ts b/src/services/UploadService.ts index fdbe962f0..d8600b991 100644 --- a/src/services/UploadService.ts +++ b/src/services/UploadService.ts @@ -30,8 +30,10 @@ import { } from '../lib/pdf/pdfPasswordSentinel'; interface EmptyDeckResponse { + code: 'empty_export'; message: string; filename: string; + docsLink: string; } interface DeckTooLargeResponse { @@ -209,8 +211,11 @@ class UploadService { const files = req.files as UploadedFile[] | undefined; const filename = files?.[0]?.originalname ?? 'your file'; const body: EmptyDeckResponse = { - message: `No toggles found in ${filename}. 2anki turns Notion toggle blocks into cards — the toggle title becomes the question, what's inside becomes the answer. Open the page in Notion, wrap your content in toggles (/toggle), export as HTML, and upload again.`, + code: 'empty_export', + message: + 'No cards were found in this file. Most files need a toggle-list (Notion) or a question/answer pair to become cards. See common problems for the formats that work.', filename, + docsLink: '/documentation/help/common-problems', }; return res.status(400).json(body); } else if (err instanceof DeckTooLargeError) { From 537c7c3899cefee974bca05b2fa51ae0447aa9c2 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Tue, 19 May 2026 15:40:31 +0200 Subject: [PATCH 2/4] fix: separate parser-error fallback from generic error fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyUploadError now falls back to copy that names the file context ("Something broke while reading this file…"). The generic fallback stays in place for thrown errors (network, etc.) where mentioning "the file" would be wrong. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/errors/helpers/getErrorMessage.test.ts | 9 +++++++++ web/src/components/errors/helpers/getErrorMessage.ts | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/web/src/components/errors/helpers/getErrorMessage.test.ts b/web/src/components/errors/helpers/getErrorMessage.test.ts index dc9935c70..b250b970a 100644 --- a/web/src/components/errors/helpers/getErrorMessage.test.ts +++ b/web/src/components/errors/helpers/getErrorMessage.test.ts @@ -110,4 +110,13 @@ describe('classifyUploadError', () => { const result = classifyUploadError(body); expect(result.title).toBe('Notion error.'); }); + + test('empty message under an unknown code returns the parser-error spec copy', () => { + const body: UploadErrorBody = { code: 'unknown', message: '' }; + const result = classifyUploadError(body); + expect(result.title).toBe('Something broke while reading this file.'); + expect(result.detail).toBe( + 'Try again, or send the file to support@2anki.net so we can fix the parser.' + ); + }); }); diff --git a/web/src/components/errors/helpers/getErrorMessage.ts b/web/src/components/errors/helpers/getErrorMessage.ts index 2fa2dbe81..320c1f585 100644 --- a/web/src/components/errors/helpers/getErrorMessage.ts +++ b/web/src/components/errors/helpers/getErrorMessage.ts @@ -13,6 +13,11 @@ const FALLBACK: FriendlyError = { detail: 'Try again. If the problem keeps happening, email support@2anki.net.', }; +const UPLOAD_FALLBACK: FriendlyError = { + title: 'Something broke while reading this file.', + detail: 'Try again, or send the file to support@2anki.net so we can fix the parser.', +}; + function toText(error: unknown): string { if (typeof error === 'string') return error; if (error instanceof Error) return error.message; @@ -127,5 +132,5 @@ export function classifyUploadError(body: UploadErrorBody): FriendlyError { if (stripped.length > 0 && stripped.length < 280) { return { title: stripped }; } - return FALLBACK; + return UPLOAD_FALLBACK; } From 7d7411afa87e9fbdd42be7513c1ddc053676729f Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Tue, 19 May 2026 15:40:38 +0200 Subject: [PATCH 3/4] fix: route empty_export 400 into emptyDeck info card Server-thrown EmptyDeckError now lands in the emptyDeck UI rather than the generic error state, matching the 200+0-cards flow. The download button is hidden when no blob is available. The default empty-deck body shows the spec copy with an inline link to common-problems. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/UploadForm/UploadForm.test.tsx | 63 +++++++++++++++++++ .../components/UploadForm/UploadForm.tsx | 52 +++++++-------- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx b/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx index 6fa0cb7fc..80a3dbf81 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx @@ -577,6 +577,69 @@ describe('UploadForm analytics events', () => { expect(errorBody?.textContent).toMatch(/file type/i); }); }); + + it('renders empty-deck spec copy with docs link on the 200 + 0-cards path', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + redirected: false, + status: 200, + headers: new Headers({ + 'Content-Type': 'application/octet-stream', + 'X-Card-Count': '0', + }), + blob: () => Promise.resolve(new Blob(['fake'])), + })); + + const { container } = renderUploadForm(); + const form = container.querySelector('form')!; + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + + await waitFor(() => { + const body = container.querySelector('[class*="emptyBody"]'); + expect(body?.textContent).toContain( + 'No cards were found in this file.' + ); + expect(body?.textContent).toContain( + 'Most files need a toggle-list (Notion) or a question/answer pair' + ); + const link = container.querySelector('a[href="/documentation/help/common-problems"]'); + expect(link?.textContent).toBe('common problems'); + }); + }); + + it('routes a 400 with code=empty_export into the emptyDeck info card', async () => { + const jsonBody = { + code: 'empty_export', + message: + 'No cards were found in this file. Most files need a toggle-list (Notion) or a question/answer pair to become cards. See common problems for the formats that work.', + filename: 'notes.zip', + docsLink: '/documentation/help/common-problems', + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + redirected: false, + status: 400, + clone: () => ({ json: () => Promise.resolve(jsonBody) }), + text: () => Promise.resolve(JSON.stringify(jsonBody)), + headers: new Headers({ 'Content-Type': 'application/json' }), + })); + + const { container } = renderUploadForm(); + const form = container.querySelector('form')!; + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + + await waitFor(() => { + const body = container.querySelector('[class*="emptyBody"]'); + expect(body?.textContent).toContain( + 'Most files need a toggle-list (Notion) or a question/answer pair' + ); + expect(container.querySelector('[class*="errorBody"]')).toBeNull(); + expect(container.querySelector('[class*="emptyDownloadButton"]')).toBeNull(); + }); + }); + }); describe('limit state — start trial button', () => { diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx index 0f2355044..34bcae7bc 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx @@ -98,6 +98,10 @@ function toFriendlyThrownError(error: unknown): UploadErrorBody { return { code: 'unknown', message: REJECTED_FALLBACK }; } +function zoneStateForUploadError(message: UploadErrorBody): 'emptyDeck' | 'error' { + return message.code === 'empty_export' ? 'emptyDeck' : 'error'; +} + function buildFormData(form: HTMLFormElement): FormData { const formData = new FormData(form); for (const [key, value] of Object.entries(globalThis.localStorage)) { @@ -430,7 +434,7 @@ function UploadForm({ setErrorMessage }: Readonly) { if (request.status !== 200) { const message = await extractErrorMessage(request); setLocalError(message); - setZoneState('error'); + setZoneState(zoneStateForUploadError(message)); return; } setWarningMessage(request.headers.get('X-Warning')); @@ -520,7 +524,7 @@ function UploadForm({ setErrorMessage }: Readonly) { if (request.status !== 200) { const message = await extractErrorMessage(request); setLocalError(message); - setZoneState('error'); + setZoneState(zoneStateForUploadError(message)); return; } setWarningMessage(request.headers.get('X-Warning')); @@ -616,7 +620,7 @@ function UploadForm({ setErrorMessage }: Readonly) { } const message = await extractErrorMessage(request); setLocalError(message); - setZoneState('error'); + setZoneState(zoneStateForUploadError(message)); return false; } setWarningMessage(request.headers.get('X-Warning')); @@ -866,20 +870,12 @@ function UploadForm({ setErrorMessage }: Readonly) { ); } return ( - <> -

- 2anki turns Notion toggle blocks (the little triangles you click to - expand) into flashcards. We didn't find any toggles in this file. -

-

- If this came from Notion, open the page, add some toggle blocks, and - export again.{' '} - - See examples - - . -

- +

+ No cards were found in this file. Most files need a toggle-list (Notion) + or a question/answer pair to become cards. See{' '} + common problems for the + formats that work. +

); }; @@ -898,13 +894,15 @@ function UploadForm({ setErrorMessage }: Readonly) {

{emptyTitle}

{renderEmptyDeckBody()}
- + {downloadLink && ( + + )}