From 91525780258f37cf2b146fac815f4ae2edbb9e37 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Wed, 20 May 2026 07:53:34 +0200 Subject: [PATCH] fix: raise free print cap to 1000 and surface count + upgrade path The "too large" failure on /print used to swallow the server's specific "This deck has N cards. PDF export supports up to M cards." message and replace it with a vague one-liner, with no upgrade link. First-time free users hit the 500-card cap with no idea what went wrong. - Bump MAX_CARDS 500 -> 1000 (covers semester-sized decks; 2x cap) - Let the server's specific count/cap message render verbatim on /print - Append an inline "Upgrade for unlimited" link (-> /pricing) on the card-limit error only - /upload: when a user drops an existing .apkg, the error now points them to Print Decks instead of a dead end - Docs: update print-export and common-problems to match the new cap and the new in-product link Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apkg/ExportApkgToPdfUseCase.test.ts | 8 +- src/usecases/apkg/ExportApkgToPdfUseCase.ts | 2 +- web/src/pages/DocsPage/content-links.test.ts | 1 + .../DocsPage/content/help/common-problems.md | 2 +- .../content/reference/print-export.md | 2 +- .../PrintPage/components/PrintForm.test.tsx | 84 +++++++++++++++++++ .../pages/PrintPage/components/PrintForm.tsx | 11 ++- .../components/UploadForm/UploadForm.tsx | 13 ++- web/src/pages/WhatsNewPage/changelog.ts | 2 + 9 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 web/src/pages/PrintPage/components/PrintForm.test.tsx diff --git a/src/usecases/apkg/ExportApkgToPdfUseCase.test.ts b/src/usecases/apkg/ExportApkgToPdfUseCase.test.ts index e8279074c..b1817f7c5 100644 --- a/src/usecases/apkg/ExportApkgToPdfUseCase.test.ts +++ b/src/usecases/apkg/ExportApkgToPdfUseCase.test.ts @@ -91,16 +91,16 @@ describe('ExportApkgToPdfUseCase', () => { expect(htmlArg).toContain('Answer 3'); }); - it('throws CardLimitExceededError for decks over 500 cards', async () => { - const parsed = makeParsed(501); + it('throws CardLimitExceededError for decks over 1000 cards', async () => { + const parsed = makeParsed(1001); previewService.parse.mockResolvedValue(parsed); - previewService.getMeta.mockReturnValue(makeMeta(501)); + previewService.getMeta.mockReturnValue(makeMeta(1001)); await expect(useCase.execute(Buffer.from('fake-apkg'))).rejects.toThrow( CardLimitExceededError ); await expect(useCase.execute(Buffer.from('fake-apkg'))).rejects.toThrow( - /501 cards/ + /1001 cards/ ); expect(pdfRenderService.renderHtml).not.toHaveBeenCalled(); }); diff --git a/src/usecases/apkg/ExportApkgToPdfUseCase.ts b/src/usecases/apkg/ExportApkgToPdfUseCase.ts index b33b3ed75..8f832d6a7 100644 --- a/src/usecases/apkg/ExportApkgToPdfUseCase.ts +++ b/src/usecases/apkg/ExportApkgToPdfUseCase.ts @@ -7,7 +7,7 @@ import { RenderedCard } from '../../services/ApkgPreviewService/types'; const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); -const MAX_CARDS = 500; +const MAX_CARDS = 1000; const SOUND_TAG_REGEX = /\[sound:([^\]]+)\]/g; const AUDIO_EXTENSIONS = new Set([ diff --git a/web/src/pages/DocsPage/content-links.test.ts b/web/src/pages/DocsPage/content-links.test.ts index 7bab69ba8..2b130b304 100644 --- a/web/src/pages/DocsPage/content-links.test.ts +++ b/web/src/pages/DocsPage/content-links.test.ts @@ -16,6 +16,7 @@ const KNOWN_APP_PREFIXES = [ '/about', '/changelog', '/card-options', + '/print', ]; const LINK_RE = /(? + render( + + + + ); + +const pickApkg = (name = 'deck.apkg') => { + const file = new File(['fake'], name, { type: 'application/octet-stream' }); + const input = screen.getByLabelText(/Drop an Anki deck/i, { + selector: 'input[type="file"]', + }) as HTMLInputElement; + fireEvent.change(input, { target: { files: [file] } }); +}; + +describe('PrintForm', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + test('shows the server card-limit message verbatim plus an Upgrade for unlimited link', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 400, + clone() { + return { + json: async () => ({ + message: + 'This deck has 1873 cards. PDF export supports up to 1000 cards.', + }), + }; + }, + } as unknown as Response); + + renderForm(); + pickApkg(); + + await waitFor(() => { + expect(screen.getByText(/1873 cards/)).toBeInTheDocument(); + }); + expect(screen.getByText(/PDF export supports up to 1000 cards/)).toBeInTheDocument(); + + const upgrade = screen.getByRole('link', { name: /Upgrade for unlimited/i }); + expect(upgrade).toHaveAttribute('href', '/pricing'); + }); + + test('shows a friendly corrupted-file message when the server reports Invalid .apkg', async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 400, + clone() { + return { + json: async () => ({ message: 'Invalid .apkg file' }), + }; + }, + } as unknown as Response); + + renderForm(); + pickApkg(); + + await waitFor(() => { + expect( + screen.getByText(/Couldn't read this file/i) + ).toBeInTheDocument(); + }); + expect( + screen.queryByRole('link', { name: /Upgrade for unlimited/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/PrintPage/components/PrintForm.tsx b/web/src/pages/PrintPage/components/PrintForm.tsx index 5bad10b80..2831b5c34 100644 --- a/web/src/pages/PrintPage/components/PrintForm.tsx +++ b/web/src/pages/PrintPage/components/PrintForm.tsx @@ -12,11 +12,11 @@ const WRONG_TYPE_MESSAGE = 'This tool works with Anki deck files (.apkg). To turn notes into an Anki deck, use the Upload page.'; const CORRUPTED_MESSAGE = "Couldn't read this file. Make sure it's a valid Anki deck (.apkg) and try again."; -const TOO_LARGE_MESSAGE = - 'This deck is too large to print right now. Try a deck with fewer cards.'; const GENERIC_ERROR_MESSAGE = 'Something went wrong while generating the PDF. Try again.'; +const CARD_LIMIT_PATTERN = /PDF export supports up to/i; + function isApkgFile(name: string): boolean { return /\.apkg$/i.test(name); } @@ -40,7 +40,6 @@ function toUserMessage(serverMessage: string, status: number): string { if (status === 401) return AUTH_MESSAGE; if (status === 402 || status === 403) return UPGRADE_MESSAGE; if (/Invalid .apkg/i.test(serverMessage)) return CORRUPTED_MESSAGE; - if (/PDF export supports up to/i.test(serverMessage)) return TOO_LARGE_MESSAGE; return serverMessage; } @@ -258,6 +257,12 @@ export default function PrintForm() { Log in )} + {CARD_LIMIT_PATTERN.test(errorMessage) && ( + <> + {' '} + Upgrade for unlimited + + )}

)} diff --git a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx index 9a8dbb812..8c8c770f7 100644 --- a/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx +++ b/web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx @@ -1013,11 +1013,22 @@ function UploadForm({ setErrorMessage }: Readonly) { const errorText = classified.detail ? `${classified.title} ${classified.detail}` : classified.title; + const isExistingApkg = + localError != null && /already an Anki deck/i.test(localError.message); return (

Something went wrong

-

{errorText}

+

+ {errorText} + {isExistingApkg && ( + <> + {' '} + Want to print or share it as a PDF?{' '} + Try Print Decks. + + )} +