diff --git a/src/services/NotionService/blocks/lists/BlockTable.tsx b/src/services/NotionService/blocks/lists/BlockTable.tsx index a2ae7ad7a..173a8c61c 100644 --- a/src/services/NotionService/blocks/lists/BlockTable.tsx +++ b/src/services/NotionService/blocks/lists/BlockTable.tsx @@ -35,7 +35,8 @@ export function tableRowsToCards( handler: BlockHandler ): TableCard[] { const rows = children.results.filter( - (r): r is TableRowBlockObjectResponse => r.type === 'table_row' + (r): r is TableRowBlockObjectResponse => + 'type' in r && r.type === 'table_row' ); if (block.table.table_width < 2) { @@ -90,7 +91,8 @@ export async function BlockTable( }); const rows = children.results.filter( - (r): r is TableRowBlockObjectResponse => r.type === 'table_row' + (r): r is TableRowBlockObjectResponse => + 'type' in r && r.type === 'table_row' ); if (rows.length === 0) { 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) { 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; } 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 && ( + + )}