Compare commits

...
Author SHA1 Message Date
Anthony Stirling e0dea581db Restrict tool upload picker to the formats each tool accepts
The "Files" step's Upload link on the left tool panel synthesises a
native file input — it now derives the `accept` attribute from the
active tool's `supportedFormats` instead of hard-coding PDF, so users
can no longer pick e.g. a .docx for /add-stamp. Convert and Automate
keep the broad CONVERT_SUPPORTED_FORMATS list, ScannerImageSplit gains
PDF + raw image formats to match the controller, everything else
defaults to PDF. Includes a stubbed Playwright spec covering 7 PDF-only
tools, Convert, and ScannerImageSplit.
2026-05-01 10:23:53 +01:00
8 changed files with 223 additions and 4 deletions
@@ -12,7 +12,10 @@ import {
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { zipFileService } from "@app/services/zipFileService";
import { detectFileExtension } from "@app/utils/fileUtils";
import {
buildAcceptAttribute,
detectFileExtension,
} from "@app/utils/fileUtils";
import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail";
import AddFileCard from "@app/components/fileEditor/AddFileCard";
import FilePickerModal from "@app/components/shared/FilePickerModal";
@@ -521,6 +524,9 @@ const FileEditor = ({
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
accept={
buildAcceptAttribute(supportedExtensions) || undefined
}
/>
)}
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Text, Anchor } from "@mantine/core";
import { useTranslation } from "react-i18next";
import FolderIcon from "@mui/icons-material/Folder";
@@ -8,15 +8,18 @@ import { useAllFiles } from "@app/contexts/FileContext";
import { useFileManager } from "@app/hooks/useFileManager";
import { StirlingFile } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { buildAcceptAttribute } from "@app/utils/fileUtils";
export interface FileStatusIndicatorProps {
selectedFiles?: StirlingFile[];
minFiles?: number;
supportedFormats?: string[];
}
const FileStatusIndicator = ({
selectedFiles = [],
minFiles = 1,
supportedFormats,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFileUpload } = useFilesModalContext();
@@ -24,6 +27,11 @@ const FileStatusIndicator = ({
const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
const acceptAttribute = useMemo(
() => buildAcceptAttribute(supportedFormats ?? ["pdf"]),
[supportedFormats],
);
// Check if there are recent files
useEffect(() => {
const checkRecentFiles = async () => {
@@ -42,7 +50,9 @@ const FileStatusIndicator = ({
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = ".pdf,application/pdf";
if (acceptAttribute) {
input.accept = acceptAttribute;
}
input.onchange = (event) => {
const files = Array.from((event.target as HTMLInputElement).files || []);
if (files.length > 0) {
@@ -8,6 +8,7 @@ export interface FilesToolStepProps {
isCollapsed?: boolean;
onCollapsedClick?: () => void;
minFiles?: number;
supportedFormats?: string[];
}
export function createFilesToolStep(
@@ -28,6 +29,7 @@ export function createFilesToolStep(
<FileStatusIndicator
selectedFiles={props.selectedFiles}
minFiles={props.minFiles}
supportedFormats={props.supportedFormats}
/>,
);
}
@@ -13,6 +13,7 @@ import {
import { StirlingFile } from "@app/types/fileContext";
import type { TooltipTip } from "@app/types/tips";
import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
@@ -20,6 +21,12 @@ export interface FilesStepConfig {
minFiles?: number;
onCollapsedClick?: () => void;
isVisible?: boolean;
/**
* Override the file types that the upload picker accepts. Defaults to the
* `supportedFormats` declared on the currently selected tool (or just
* `["pdf"]` when a tool does not declare any).
*/
supportedFormats?: string[];
}
export interface MiddleStepConfig {
@@ -94,11 +101,18 @@ export interface ToolFlowConfig<TParams = unknown> {
/**
* Creates a flexible tool flow with configurable steps and state management left to the tool.
* Reduces boilerplate while allowing tools to manage their own collapse/expansion logic.
*
* NOTE: This must be called from inside a React function component (or another
* hook) — it uses `useToolWorkflow()` to look up the active tool's
* `supportedFormats` for the files-step upload picker.
*/
export function createToolFlow<TParams = unknown>(
config: ToolFlowConfig<TParams>,
) {
const { selectedTool } = useToolWorkflow();
const steps = createToolSteps();
const filesSupportedFormats =
config.files.supportedFormats ?? selectedTool?.supportedFormats;
return (
<Stack gap="sm" p="sm">
@@ -113,6 +127,7 @@ export function createToolFlow<TParams = unknown>(
isCollapsed: config.files.isCollapsed,
minFiles: config.files.minFiles,
onCollapsedClick: config.files.onCollapsedClick,
supportedFormats: filesSupportedFormats,
})}
{/* Middle Steps */}
@@ -1042,6 +1042,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
// Backend accepts a PDF *or* a raw scanned image (jpg/png/...).
supportedFormats: [
"pdf",
"jpg",
"jpeg",
"png",
"bmp",
"gif",
"tif",
"tiff",
"webp",
],
endpoints: ["extract-image-scans"],
operationConfig: scannerImageSplitOperationConfig,
automationSettings: ScannerImageSplitSettings,
@@ -0,0 +1,117 @@
import type { Page } from "@playwright/test";
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* The "Files" step on the left-hand tool panel renders an Upload link (when
* the user has no recent files cached in IndexedDB) that synthesises a native
* `<input type="file">` and triggers a click. Each tool's upload picker should
* advertise an `accept` attribute that matches the formats the underlying
* endpoint actually consumes — PDF for everything except Convert (and a small
* number of other tools that accept raw images, e.g. ScannerImageSplit).
*
* These specs install a hook on `HTMLInputElement.prototype.click` that
* captures the synthesised input's `accept` value before the native picker
* would open, then asserts on it. The hook also no-ops the click so the
* browser does not try to show a file dialog under Playwright.
*/
const TOOL_PANEL = '[data-sidebar="tool-panel"]';
async function captureUploadAccept(page: Page): Promise<string> {
// Wait for the tool panel and upload link to render — FileStatusIndicator
// returns null until its async recent-files check resolves.
const uploadLink = page
.locator(TOOL_PANEL)
.getByText(/^upload$/i)
.first();
await expect(uploadLink).toBeVisible({ timeout: 10_000 });
await page.evaluate(() => {
const w = window as unknown as {
__uploadAccepts?: string[];
__originalInputClick?: () => void;
};
w.__uploadAccepts = [];
w.__originalInputClick = HTMLInputElement.prototype.click;
HTMLInputElement.prototype.click = function () {
if (this.type === "file") {
w.__uploadAccepts!.push(this.accept);
return;
}
w.__originalInputClick!.call(this);
};
});
await uploadLink.click();
const accepts = await page.evaluate(
() => (window as unknown as { __uploadAccepts: string[] }).__uploadAccepts,
);
expect(accepts.length).toBeGreaterThan(0);
return accepts[0]!;
}
const PDF_ONLY_TOOLS = [
{ route: "/add-stamp", label: "Add Stamp" },
{ route: "/add-password", label: "Add Password" },
{ route: "/compress", label: "Compress" },
{ route: "/rotate", label: "Rotate" },
{ route: "/sanitize", label: "Sanitize" },
{ route: "/split", label: "Split" },
{ route: "/watermark", label: "Add Watermark" },
];
test.describe("Tool upload picker — accept attribute", () => {
for (const tool of PDF_ONLY_TOOLS) {
test(`${tool.label} upload picker accepts PDF only`, async ({ page }) => {
await page.goto(tool.route);
await page.waitForLoadState("domcontentloaded");
const accept = await captureUploadAccept(page);
const tokens = accept.split(",").map((t) => t.trim());
expect(tokens).toContain(".pdf");
expect(tokens).toContain("application/pdf");
// Should NOT advertise non-PDF formats
expect(tokens).not.toContain(".docx");
expect(tokens).not.toContain(".png");
expect(tokens).not.toContain(".jpg");
expect(tokens).not.toContain(".html");
});
}
test("Convert upload picker accepts the broad CONVERT_SUPPORTED_FORMATS list", async ({
page,
}) => {
await page.goto("/convert");
await page.waitForLoadState("domcontentloaded");
const accept = await captureUploadAccept(page);
const tokens = accept.split(",").map((t) => t.trim());
// Convert supports many input formats — sample a representative few to
// catch a regression where supportedFormats stops flowing through.
expect(tokens).toContain(".pdf");
expect(tokens).toContain(".docx");
expect(tokens).toContain(".png");
expect(tokens).toContain(".jpg");
expect(tokens).toContain(".html");
expect(tokens).toContain(".epub");
});
test("ScannerImageSplit upload picker accepts both PDF and raw images", async ({
page,
}) => {
await page.goto("/scanner-image-split");
await page.waitForLoadState("domcontentloaded");
const accept = await captureUploadAccept(page);
const tokens = accept.split(",").map((t) => t.trim());
expect(tokens).toContain(".pdf");
expect(tokens).toContain(".jpg");
expect(tokens).toContain(".png");
// Should NOT pick up Convert-only formats like .docx
expect(tokens).not.toContain(".docx");
});
});
+2 -1
View File
@@ -36,7 +36,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
});
const automateOperation = useAutomateOperation();
const { regularTools: toolRegistry } = useToolRegistry();
const { regularTools: toolRegistry, superTools } = useToolRegistry();
const hasResults =
automateOperation.files.length > 0 ||
automateOperation.downloadUrl !== null;
@@ -209,6 +209,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const filesStep = createFilesToolStep(createStep, {
selectedFiles,
isCollapsed: hasResults,
supportedFormats: superTools.automate?.supportedFormats,
});
const automationSteps = [
+56
View File
@@ -157,3 +157,59 @@ export function detectNonPdfFileType(
return "unknown";
}
const EXTENSION_MIME_TYPES: Record<string, string> = {
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
svg: "image/svg+xml",
tif: "image/tiff",
tiff: "image/tiff",
webp: "image/webp",
html: "text/html",
htm: "text/html",
txt: "text/plain",
text: "text/plain",
csv: "text/csv",
json: "application/json",
xml: "application/xml",
rtf: "application/rtf",
zip: "application/zip",
md: "text/markdown",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
odt: "application/vnd.oasis.opendocument.text",
ods: "application/vnd.oasis.opendocument.spreadsheet",
odp: "application/vnd.oasis.opendocument.presentation",
eml: "message/rfc822",
epub: "application/epub+zip",
};
/**
* Builds an HTML file input `accept` attribute string from a list of file
* extensions. The result includes both the extension form (".pdf") and the
* MIME type form ("application/pdf") when known, so browsers reliably restrict
* the native picker.
*/
export function buildAcceptAttribute(extensions?: string[] | null): string {
if (!extensions || extensions.length === 0) return "";
const tokens = new Set<string>();
for (const raw of extensions) {
if (!raw) continue;
const ext = raw.replace(/^\./, "").toLowerCase();
if (!ext) continue;
tokens.add(`.${ext}`);
const mime = EXTENSION_MIME_TYPES[ext];
if (mime) tokens.add(mime);
}
return Array.from(tokens).join(",");
}