diff --git a/apps/web/src/dialogs/resume/import.dialog.test.tsx b/apps/web/src/dialogs/resume/import.dialog.test.tsx index 4ac905f77..a6dbd5fd6 100644 --- a/apps/web/src/dialogs/resume/import.dialog.test.tsx +++ b/apps/web/src/dialogs/resume/import.dialog.test.tsx @@ -88,25 +88,54 @@ const renderDialog = () => { ); }; -// A real "%PDF" header so the dialog auto-detects the type and shows the provider notice. -const createPdfFile = () => - new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.pdf", { type: "application/pdf" }); +// Word still needs a provider, so it is what surfaces the notice. PDF falls back to a local parse. +const createWordFile = () => + new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], "resume.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); // The dialog renders through a portal, so query the document rather than the render container. -async function selectPdfFile() { +async function selectWordFile() { const input = document.querySelector('input[type="file"]'); if (!input) throw new Error("File input not found"); - fireEvent.change(input, { target: { files: [createPdfFile()] } }); + fireEvent.change(input, { target: { files: [createWordFile()] } }); return await screen.findByText("Set up a provider"); } +const createPdfFile = () => + new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.pdf", { type: "application/pdf" }); + +describe("ImportResumeDialog — PDF without a provider", () => { + it("offers a local parse instead of demanding an AI provider", async () => { + renderDialog(); + const input = document.querySelector('input[type="file"]'); + if (!input) throw new Error("File input not found"); + + fireEvent.change(input, { target: { files: [createPdfFile()] } }); + + expect(await screen.findByText(/read the text out of the PDF here in your browser/)).toBeInTheDocument(); + expect(screen.queryByText("Set up a provider")).not.toBeInTheDocument(); + }); + + it("keeps the import button usable", async () => { + renderDialog(); + const input = document.querySelector('input[type="file"]'); + if (!input) throw new Error("File input not found"); + + fireEvent.change(input, { target: { files: [createPdfFile()] } }); + await screen.findByText(/read the text out of the PDF here in your browser/); + + expect(screen.getByRole("button", { name: "Import" })).not.toBeDisabled(); + }); +}); + describe("ImportResumeDialog — Set up a provider", () => { // https://github.com/amruthpillai/reactive-resume/issues/3307 it("confirms before leaving instead of navigating behind the dialog", async () => { renderDialog(); - const link = await selectPdfFile(); + const link = await selectWordFile(); fireEvent.click(link); @@ -118,7 +147,7 @@ describe("ImportResumeDialog — Set up a provider", () => { it("stays put and keeps the selected file when the user cancels", async () => { renderDialog(); - const link = await selectPdfFile(); + const link = await selectWordFile(); fireEvent.click(link); fireEvent.click(await screen.findByText("Stay")); @@ -130,12 +159,12 @@ describe("ImportResumeDialog — Set up a provider", () => { expect(routerNavigate).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); expect(useDialogStore.getState().open).toBe(true); - expect(screen.getByText("resume.pdf")).toBeInTheDocument(); + expect(screen.getByText("resume.docx")).toBeInTheDocument(); }); it("closes the dialog and navigates once the user confirms", async () => { renderDialog(); - const link = await selectPdfFile(); + const link = await selectWordFile(); fireEvent.click(link); fireEvent.click(await screen.findByText("Leave")); @@ -152,7 +181,7 @@ describe("ImportResumeDialog — Set up a provider", () => { it("leaves modifier clicks to the browser so the link can open in a new tab", async () => { renderDialog(); - const link = await selectPdfFile(); + const link = await selectWordFile(); const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true }); fireEvent(link, event); @@ -164,7 +193,7 @@ describe("ImportResumeDialog — Set up a provider", () => { it("leaves middle clicks to the browser too", async () => { renderDialog(); - const link = await selectPdfFile(); + const link = await selectWordFile(); const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 }); fireEvent(link, event); diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx index 6f245efa1..9ef752ab4 100644 --- a/apps/web/src/dialogs/resume/import.tsx +++ b/apps/web/src/dialogs/resume/import.tsx @@ -141,10 +141,15 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { setIsImporting(true); + // A PDF parsed in the browser never touches a provider, so promising one would be a lie. + const isLocalPdf = value.type === "pdf" && !hasUsableProvider; + const toastId = toast.add({ type: "loading", title: t`Importing your resume...`, - description: t`This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page.`, + description: isLocalPdf + ? t`This may take a moment. Please do not close the window or refresh the page.` + : t`This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page.`, }); try { @@ -160,14 +165,31 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { if (value.type === "pdf") { if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`); - if (!hasUsableProvider) - throw new Error(t`This feature requires a connected AI provider. Please set one up in the settings.`); - const base64 = await fileToBase64(value.file); + if (hasUsableProvider) { + const base64 = await fileToBase64(value.file); - data = await client.ai.parsePdf({ - file: { name: value.file.name, data: base64 }, - }); + data = await client.ai.parsePdf({ + file: { name: value.file.name, data: base64 }, + }); + } else { + const [{ extractPdfLines }, { parseResumeText }] = await Promise.all([ + import("@/features/resume/import/pdf-text"), + import("@reactive-resume/import/plain-text"), + ]); + + const lines = await extractPdfLines(value.file); + if (lines.length === 0) { + throw new Error( + t({ + comment: "Error shown when a PDF has no extractable text layer during import", + message: "This PDF has no readable text. It is likely a scan, so there is nothing to import.", + }), + ); + } + + data = parseResumeText(lines.join("\n")); + } } if (value.type === "docx") { @@ -236,7 +258,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { const type = useStore(form.store, (s) => s.values.type); const file = useStore(form.store, (s) => s.values.file); - const aiRequired = type === "pdf" || type === "docx"; + const aiRequired = type === "docx"; + const pdfWithoutAi = type === "pdf" && !isLoadingAiProviders && !hasUsableProvider; const onSelectFile = () => { if (!inputRef.current) return; @@ -372,12 +395,7 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { { value: "pdf", textValue: t({ comment: "File format label in import source selector", message: "PDF" }), - label: ( -
- {t({ comment: "File format label in import source selector", message: "PDF" })}{" "} - {t`AI`} -
- ), + label: t({ comment: "File format label in import source selector", message: "PDF" }), }, { value: "docx", @@ -413,7 +431,7 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { {aiRequired && !isLoadingAiProviders && !hasUsableProvider && (
- Importing from PDF or Word requires a connected AI provider. + Importing from Word requires a connected AI provider.
)} + {pdfWithoutAi && ( +
+ + No AI provider is connected, so we will read the text out of the PDF here in your browser and fill in what + we can recognize. Expect to tidy up the result. + +
+ )} +