Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/services/NotionService/blocks/lists/BlockTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 12 additions & 3 deletions src/services/UploadService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof GeneratePackagesUseCase>);
Expand All @@ -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 () => {
Expand Down
7 changes: 6 additions & 1 deletion src/services/UploadService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ import {
} from '../lib/pdf/pdfPasswordSentinel';

interface EmptyDeckResponse {
code: 'empty_export';
message: string;
filename: string;
docsLink: string;
}

interface DeckTooLargeResponse {
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions web/src/components/errors/helpers/getErrorMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
);
});
});
7 changes: 6 additions & 1 deletion web/src/components/errors/helpers/getErrorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
63 changes: 63 additions & 0 deletions web/src/pages/UploadPage/components/UploadForm/UploadForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<UploadForm setErrorMessage={vi.fn()} />);
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(<UploadForm setErrorMessage={vi.fn()} />);
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', () => {
Expand Down
52 changes: 27 additions & 25 deletions web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -430,7 +434,7 @@ function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
if (request.status !== 200) {
const message = await extractErrorMessage(request);
setLocalError(message);
setZoneState('error');
setZoneState(zoneStateForUploadError(message));
return;
}
setWarningMessage(request.headers.get('X-Warning'));
Expand Down Expand Up @@ -520,7 +524,7 @@ function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
if (request.status !== 200) {
const message = await extractErrorMessage(request);
setLocalError(message);
setZoneState('error');
setZoneState(zoneStateForUploadError(message));
return;
}
setWarningMessage(request.headers.get('X-Warning'));
Expand Down Expand Up @@ -616,7 +620,7 @@ function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
}
const message = await extractErrorMessage(request);
setLocalError(message);
setZoneState('error');
setZoneState(zoneStateForUploadError(message));
return false;
}
setWarningMessage(request.headers.get('X-Warning'));
Expand Down Expand Up @@ -866,20 +870,12 @@ function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
);
}
return (
<>
<p className={formStyles.emptyBody}>
2anki turns Notion toggle blocks (the little triangles you click to
expand) into flashcards. We didn't find any toggles in this file.
</p>
<p className={formStyles.emptyBody}>
If this came from Notion, open the page, add some toggle blocks, and
export again.{' '}
<a href="/documentation/help/common-problems#could-not-create-a-deck-using-your-file-and-rules">
See examples
</a>
.
</p>
</>
<p className={formStyles.emptyBody}>
No cards were found in this file. Most files need a toggle-list (Notion)
or a question/answer pair to become cards. See{' '}
<a href="/documentation/help/common-problems">common problems</a> for the
formats that work.
</p>
);
};

Expand All @@ -898,13 +894,15 @@ function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
<p className={formStyles.emptyTitle}>{emptyTitle}</p>
{renderEmptyDeckBody()}
<div className={formStyles.emptyActions}>
<button
type="button"
className={formStyles.emptyDownloadButton}
onClick={() => downloadRef.current?.click()}
>
Download empty deck
</button>
{downloadLink && (
<button
type="button"
className={formStyles.emptyDownloadButton}
onClick={() => downloadRef.current?.click()}
>
Download empty deck
</button>
)}
<button
type="button"
className={formStyles.resetLink}
Expand Down Expand Up @@ -984,7 +982,11 @@ function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
const renderErrorState = () => {
const classified = localError
? classifyUploadError(localError)
: { title: "We couldn't make your deck.", detail: 'Try again, or email us at support@2anki.net.' };
: {
title: 'Something broke while reading this file.',
detail:
'Try again, or send the file to support@2anki.net so we can fix the parser.',
};
const errorText = classified.detail
? `${classified.title} ${classified.detail}`
: classified.title;
Expand Down