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.
+ >
+ )}
+