Backend smart foldering

This commit is contained in:
Reece
2026-03-23 21:37:27 +00:00
parent 1cade6c030
commit 2139edc157
15 changed files with 213 additions and 62 deletions
@@ -341,7 +341,8 @@ public class EmlProcessingUtils {
}
private String getFallbackStyles() {
return """
return
"""
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
@@ -105,24 +105,37 @@ public class PipelineProcessor {
boolean filtersApplied = false;
for (PipelineOperation pipelineOperation : config.getOperations()) {
String operation = pipelineOperation.getOperation();
boolean isMultiInputOperation = apiDocService.isMultiInput(operation);
// Normalize to OpenAPI path format (leading "/") for apiDocService lookups.
// The frontend may omit the leading slash when building pipeline JSON.
String normalizedOperation = operation.startsWith("/") ? operation : "/" + operation;
boolean isMultiInputOperation = apiDocService.isMultiInput(normalizedOperation);
log.info(
"Running operation: {} isMultiInputOperation {}",
operation,
normalizedOperation,
isMultiInputOperation);
Map<String, Object> parameters = pipelineOperation.getParameters();
List<String> inputFileTypes = apiDocService.getExtensionTypes(false, operation);
List<String> inputFileTypes =
apiDocService.getExtensionTypes(false, normalizedOperation);
if (inputFileTypes == null) {
inputFileTypes = new ArrayList<>(List.of("ALL"));
}
if (!apiDocService.isValidOperation(operation, parameters)) {
log.error("Invalid operation or parameters: o:{} p:{}", operation, parameters);
if (!apiDocService.isValidOperation(normalizedOperation, parameters)) {
log.error(
"Invalid operation or parameters: o:{} p:{}",
normalizedOperation,
parameters);
throw new IllegalArgumentException(
"Invalid operation: " + operation + " with parameters: " + parameters);
"Invalid operation: "
+ normalizedOperation
+ " with parameters: "
+ parameters);
}
String url = getBaseUrl() + operation;
// getBaseUrl() ends with "/"; strip leading "/" from normalizedOperation to avoid
// double slash
String operationPath = normalizedOperation.substring(1);
String url = getBaseUrl() + operationPath;
List<Resource> newOutputFiles = new ArrayList<>();
if (!isMultiInputOperation) {
for (Resource file : outputFiles) {
@@ -178,7 +178,8 @@ public class ReactRoutingController {
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
return
"""
<!doctype html>
<html>
<head>
@@ -237,7 +238,8 @@ public class ReactRoutingController {
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
return
"""
<!doctype html>
<html>
<head>
@@ -581,9 +581,10 @@ public class PdfJsonFallbackFontService {
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
return switch (script) {
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU, etc.)
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU,
// etc.)
case HAN -> FALLBACK_FONT_CJK_ID;
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
case HANGUL -> FALLBACK_FONT_KR_ID;
@@ -21,8 +21,7 @@ class ApiEndpointTest {
return postNodeWithParams(description, true, names);
}
private JsonNode postNodeWithParams(
String description, boolean required, String... names) {
private JsonNode postNodeWithParams(String description, boolean required, String... names) {
ObjectNode post = mapper.createObjectNode();
post.put("description", description);
ArrayNode params = mapper.createArrayNode();
@@ -103,7 +103,9 @@ class LanguageServiceBasicTest {
// Verify filtering by restrictions
assertTrue(supportedLanguages.contains("en_US"), "Allowed language should be included");
assertTrue(supportedLanguages.contains("fr_FR"), "Allowed language should be included");
assertFalse(supportedLanguages.contains("en_GB"), "en_GB should NOT be included when not in whitelist");
assertFalse(
supportedLanguages.contains("en_GB"),
"en_GB should NOT be included when not in whitelist");
assertFalse(supportedLanguages.contains("de_DE"), "Restricted language should be excluded");
}
@@ -84,11 +84,13 @@ class LanguageServiceTest {
// Verify
assertEquals(
allowedLanguages,
supportedLanguages,
"Should return only whitelisted languages");
assertFalse(supportedLanguages.contains("en_GB"), "en_GB should NOT be included when not in whitelist");
assertFalse(supportedLanguages.contains("de_DE"), "de_DE should NOT be included when not in whitelist");
allowedLanguages, supportedLanguages, "Should return only whitelisted languages");
assertFalse(
supportedLanguages.contains("en_GB"),
"en_GB should NOT be included when not in whitelist");
assertFalse(
supportedLanguages.contains("de_DE"),
"de_DE should NOT be included when not in whitelist");
}
@Test
@@ -161,7 +161,7 @@ public class EmailService {
String subject = "Welcome to Stirling PDF";
String body =
"""
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
@@ -220,7 +220,7 @@ public class EmailService {
String subject = "You've been invited to Stirling PDF";
String body =
"""
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
@@ -269,7 +269,8 @@ public class EmailService {
String passwordSection =
newPassword == null
? ""
: """
:
"""
<div style=\"background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;\">
<p style=\"margin: 0;\"><strong>Temporary Password:</strong> %s</p>
</div>
@@ -277,7 +278,7 @@ public class EmailService {
.formatted(newPassword);
String body =
"""
"""
<html><body style=\"margin: 0; padding: 0;\">
<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">
<div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Text, ActionIcon, ScrollArea } from '@mantine/core';
import { CardModalPhase, CARD_MODAL_TIMINGS } from '@app/hooks/useCardModalAnimation';
@@ -40,14 +40,23 @@ export function CardExpansionModal({
children,
footer,
}: CardExpansionModalProps) {
const [viewportW, setViewportW] = useState(window.innerWidth);
const [viewportH, setViewportH] = useState(window.innerHeight);
useEffect(() => {
const handler = () => { setViewportW(window.innerWidth); setViewportH(window.innerHeight); };
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
if (phase === 'closed' || !cardRect) return null;
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const modalW = Math.min(MODAL_W_REM * rootFontSize, window.innerWidth * 0.9);
const modalH = MODAL_H_REM * rootFontSize;
const modalW = Math.min(MODAL_W_REM * rootFontSize, viewportW * 0.9);
const modalH = Math.min(MODAL_H_REM * rootFontSize, viewportH * 0.85);
const headerH = HEADER_H_REM * rootFontSize;
const finalLeft = (window.innerWidth - modalW) / 2;
const finalTop = window.innerHeight * MODAL_TOP_FRACTION;
const finalLeft = (viewportW - modalW) / 2;
const finalTop = Math.min(viewportH * MODAL_TOP_FRACTION, viewportH - modalH - 16);
const isAtCard = phase === 'entering' || phase === 'closing-header';
const isAtHeader = phase === 'header-open';
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Modal, Center, Text, Box } from '@mantine/core';
import { Modal, Center, Text, Box, Loader } from '@mantine/core';
import { FileId } from '@app/types/fileContext';
import { fileStorage } from '@app/services/fileStorage';
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
@@ -14,15 +14,20 @@ interface FilePreviewModalProps {
export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModalProps) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
if (!fileId) { setFile(null); setError(false); return; }
if (!fileId) { setFile(null); setError(false); setLoading(false); return; }
setError(false);
fileStorage.getStirlingFile(fileId).then(f => {
if (f) setFile(f);
else setError(true);
});
setLoading(true);
fileStorage.getStirlingFile(fileId)
.then(f => {
if (f) setFile(f);
else setError(true);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [fileId]);
return (
@@ -34,15 +39,19 @@ export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModal
zIndex={400}
styles={{ body: { height: '82vh', padding: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
{error ? (
{loading ? (
<Center h="100%"><Loader size="sm" /></Center>
) : error ? (
<Center h="100%">
<Text c="dimmed">Could not load file preview.</Text>
</Center>
) : !file ? (
<Center h="100%"><Loader size="sm" /></Center>
) : (
<ViewerProvider>
<PdfViewerToolbar />
<Box style={{ flex: 1, minHeight: 0 }}>
<LocalEmbedPDF file={file ?? undefined} fileName={fileName} />
<LocalEmbedPDF file={file} fileName={fileName} />
</Box>
</ViewerProvider>
)}
@@ -569,6 +569,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
{status === 'error' && meta?.nextRetryAt && <ReplayIcon style={{ fontSize: '0.875rem', color: '#f59e0b', flexShrink: 0 }} />}
{status === 'pending' && <Box style={{ width: '0.5rem', height: '0.5rem', borderRadius: '50%', backgroundColor: 'var(--mantine-color-yellow-5)', flexShrink: 0 }} />}
<Text size="xs" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>{filename}</Text>
{status === 'error' && (meta?.failedAttempts ?? 0) > 0 && (
<Box style={{ padding: '0.0625rem 0.3rem', borderRadius: '0.25rem', backgroundColor: 'rgba(239,68,68,0.22)', border: '0.0625rem solid rgba(239,68,68,0.45)', flexShrink: 0 }}>
<Text style={{ fontSize: '0.625rem', fontWeight: 700, color: '#ef4444', letterSpacing: '0.03em' }}>{meta!.failedAttempts}×</Text>
</Box>
)}
{meta?.nextRetryAt && (
<RetryCountdown nextRetryAt={meta.nextRetryAt} t={t} />
)}
@@ -635,7 +640,9 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('smartFolders.workbench.noInputFiles', 'No input files stored yet')}
</Text>
) : inputFiles.map((file) => (
) : inputFiles.map((file) => {
const inputMeta = folderRecord?.files[file.fileId];
return (
<Box
key={file.fileId}
style={{
@@ -650,6 +657,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
>
<FolderOpenIcon style={{ fontSize: '0.875rem', color: 'var(--mantine-color-blue-filled)', flexShrink: 0 }} />
<Text size="sm" style={{ flex: 1, minWidth: 0, fontWeight: 500 }} lineClamp={1}>{file.name}</Text>
{inputMeta?.addedAt && <Text size="xs" c="dimmed" style={{ fontSize: '0.6875rem', flexShrink: 0 }}>{timeAgo(new Date(inputMeta.addedAt), t)}</Text>}
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'nowrap' }}>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.3rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={() => handleView(file)} title={t('smartFolders.actions.view', 'Preview')}>
<VisibilityIcon style={{ fontSize: '1.125rem' }} />
@@ -659,7 +667,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
</button>
</div>
</Box>
))}
);
})}
</Stack>
</CardExpansionModal>
@@ -689,7 +698,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('smartFolders.workbench.noOutputFiles', 'No output files stored yet')}
</Text>
) : outputFiles.map((file) => (
) : outputFiles.map((file) => {
const processedAt = Object.values(folderRecord?.files ?? {}).find(m =>
m.displayFileIds?.includes(file.fileId) || m.displayFileId === file.fileId
)?.processedAt;
return (
<Box
key={file.fileId}
style={{
@@ -704,6 +717,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
>
<TaskAltIcon style={{ fontSize: '0.875rem', color: '#22c55e', flexShrink: 0 }} />
<Text size="sm" style={{ flex: 1, minWidth: 0, fontWeight: 500 }} lineClamp={1}>{file.name}</Text>
{processedAt && <Text size="xs" c="dimmed" style={{ fontSize: '0.6875rem', flexShrink: 0 }}>{timeAgo(new Date(processedAt), t)}</Text>}
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'nowrap' }}>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.3rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={() => handleView(file)} title={t('smartFolders.actions.view', 'View')}>
<VisibilityIcon style={{ fontSize: '1.125rem' }} />
@@ -713,7 +727,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
</button>
</div>
</Box>
))}
);
})}
</Stack>
</CardExpansionModal>
@@ -775,6 +790,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<Group gap="0.625rem" style={{ padding: '0.5rem 0.625rem' }} wrap="nowrap">
<ErrorOutlineIcon style={{ fontSize: '0.875rem', color: '#ef4444', flexShrink: 0 }} />
<Text size="sm" style={{ flex: 1, minWidth: 0, fontWeight: 500 }} lineClamp={1}>{filename}</Text>
{meta?.lastFailedAt && <Text size="xs" c="dimmed" style={{ fontSize: '0.6875rem', flexShrink: 0 }}>{timeAgo(new Date(meta.lastFailedAt), t)}</Text>}
{attempts > 0 && (
<Box style={{
padding: '0.125rem 0.375rem',
@@ -8,13 +8,13 @@ import SearchIcon from '@mui/icons-material/Search';
import TuneIcon from '@mui/icons-material/Tune';
import VisibilityIcon from '@mui/icons-material/Visibility';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { FileId, StirlingFileStub } from '@app/types/fileContext';
import { FileId, StirlingFileStub, createFileId, createStirlingFile, createQuickKey } from '@app/types/fileContext';
import { fileStorage } from '@app/services/fileStorage';
import { FilePreviewModal } from '@app/components/smartFolders/FilePreviewModal';
import { useFolderOutputIds } from '@app/hooks/useFolderOutputIds';
import { useFolderMembership } from '@app/hooks/useFolderMembership';
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
import { iconMap } from '@app/components/tools/automate/iconMap';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
interface WatchFolderFileListProps {
@@ -124,7 +124,7 @@ function FileRow({
.filter(Boolean) as typeof folders;
const hasTags = isInCurrentFolder || otherFolders.length > 0;
const hasBottomRow = !!file.size || hasTags;
const hasBottomRow = file.size > 0 || otherFolders.length > 0;
const handleRowClick = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-no-select]')) return;
@@ -319,7 +319,7 @@ function FileRow({
paddingLeft: '1.125rem',
}}
>
{file.size && (
{file.size > 0 && (
<Text
style={{
fontSize: '0.625rem',
@@ -414,21 +414,41 @@ export function WatchFolderFileList({ files, folderId, onSendToFolder, onNavigat
const [inFoldersExpanded, setInFoldersExpanded] = useState(true);
const [outputsExpanded, setOutputsExpanded] = useState(false);
const { addFiles } = useFileHandler();
// Store files directly to IndexedDB without adding them to the active workbench state.
// The sidebar "My Files" list refreshes automatically via the stirling:files-changed event.
const storeFilesOnly = useCallback(async (files: File[]) => {
for (const file of files) {
const fileId = createFileId();
const stub: StirlingFileStub = {
id: fileId,
name: file.name,
type: file.type || 'application/pdf',
size: file.size,
lastModified: file.lastModified,
isLeaf: true,
originalFileId: fileId,
versionNumber: 1,
toolHistory: [],
quickKey: createQuickKey(file),
createdAt: Date.now(),
};
await fileStorage.storeStirlingFile(createStirlingFile(file, fileId), stub);
}
}, []);
const handleUploadClick = useCallback(async () => {
const pickedFiles = await openFilesFromDisk({
multiple: true,
onFallbackOpen: () => uploadInputRef.current?.click(),
});
if (pickedFiles.length > 0) await addFiles(pickedFiles);
}, [addFiles]);
if (pickedFiles.length > 0) await storeFilesOnly(pickedFiles);
}, [storeFilesOnly]);
const handleInputChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) await addFiles(picked);
if (picked.length > 0) await storeFilesOnly(picked);
e.target.value = '';
}, [addFiles]);
}, [storeFilesOnly]);
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('date-desc');
+3 -11
View File
@@ -14,7 +14,7 @@ import { fileStorage } from '@app/services/fileStorage';
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
import { folderRetryScheduleStorage } from '@app/services/folderRetryScheduleStorage';
import { smartFolderStorage } from '@app/services/smartFolderStorage';
import { executeAutomationSequence } from '@app/utils/automationExecutor';
import { executeBackendPipeline } from '@app/utils/automationExecutor';
import {
FileId,
StirlingFileStub,
@@ -101,16 +101,7 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
await folderStorage.updateFileMetadata(folder.id, inputFileId, { status: 'processing' });
// Step-level callbacks not needed for background folder processing
const noop = () => {};
const resultFiles = await executeAutomationSequence(
automation,
[file],
toolRegistry as ToolRegistry,
noop,
noop,
noop
);
const resultFiles = await executeBackendPipeline(automation, [file], toolRegistry as ToolRegistry);
// Load input stub for version chain info and name fallback
const inputStub = await fileStorage.getStirlingFileStub(inputFileId as FileId);
@@ -197,6 +188,7 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
errorMessage: err instanceof Error ? err.message : 'Unknown error',
failedAttempts: attempts,
nextRetryAt,
lastFailedAt: new Date(),
});
if (willRetry) {
+1
View File
@@ -35,6 +35,7 @@ export interface FolderFileMetadata {
errorMessage?: string;
failedAttempts?: number;
nextRetryAt?: number; // ms timestamp — set when an automatic retry is scheduled
lastFailedAt?: Date;
name?: string; // original filename
}
@@ -5,6 +5,7 @@ import { AUTOMATION_CONSTANTS } from '@app/constants/automation';
import { AutomationFileProcessor } from '@app/utils/automationFileProcessor';
import { ToolType } from '@app/hooks/tools/shared/useToolOperation';
import { processResponse } from '@app/utils/toolResponseProcessor';
import { getFilenameFromHeaders } from '@app/utils/fileResponseUtils';
/**
* Process multi-file tool response (handles ZIP or single PDF responses)
@@ -232,3 +233,85 @@ export const executeAutomationSequence = async (
console.log(`\n🎉 Automation complete: ${currentFiles.length} file(s)`);
return currentFiles;
};
/**
* Execute an automation pipeline via POST /api/v1/pipeline/handleData.
*
* Falls back to executeAutomationSequence for automations that contain a step requiring
* client-side processing (e.g. Adjust Contrast, Remove Annotations, Extract Pages).
*/
export const executeBackendPipeline = async (
automation: any,
initialFiles: File[],
toolRegistry: ToolRegistry
): Promise<File[]> => {
if (!automation?.operations || automation.operations.length === 0) {
throw new Error('No operations in automation');
}
// Fall back to frontend execution if any step needs client-side processing
const needsFrontendFallback = automation.operations.some((op: any) =>
toolRegistry[op.operation as ToolId]?.operationConfig?.customProcessor != null
);
if (needsFrontendFallback) {
return executeAutomationSequence(automation, initialFiles, toolRegistry);
}
// Build PipelineConfig JSON — "pipeline" is the @JsonProperty key the backend expects.
const pipeline = automation.operations.map((op: any) => {
const toolConfig = toolRegistry[op.operation as ToolId]?.operationConfig;
if (!toolConfig) throw new Error(`Tool operation not supported: ${op.operation}`);
// Apply frontend defaults so the backend receives complete parameters
const parameters = { ...toolConfig.defaultParameters, ...(op.parameters ?? {}) };
// Backend builds URL as getBaseUrl() + operation where getBaseUrl() ends with "/"
const rawEndpoint = typeof toolConfig.endpoint === 'function'
? toolConfig.endpoint(parameters)
: toolConfig.endpoint;
const operation = rawEndpoint.replace(/^\//, '');
return { operation, parameters };
});
const formData = new FormData();
for (const file of initialFiles) {
formData.append('fileInput', file);
}
formData.append('json', JSON.stringify({ name: automation.name, pipeline }));
const response = await apiClient.post<Blob>('/api/v1/pipeline/handleData', formData, {
responseType: 'blob',
// Allow per-step timeout headroom proportional to the number of operations
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT * automation.operations.length,
});
const blob: Blob = response.data;
// Validate the response is an actual PDF or ZIP before storing it.
// An empty or XML/HTML error body from the backend would otherwise be
// silently stored and render as "unknown length" in the PDF viewer.
if (blob.size === 0) {
throw new Error('Backend pipeline returned an empty response');
}
const header = new Uint8Array(await blob.slice(0, 5).arrayBuffer());
const isPdf = header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46 && header[4] === 0x2D; // %PDF-
const isZip = header[0] === 0x50 && header[1] === 0x4B; // PK
if (!isPdf && !isZip) {
let hint = '';
try { hint = ` Response preview: ${await blob.slice(0, 200).text()}`; } catch { /* ignore */ }
throw new Error(`Backend pipeline returned unexpected content (not a PDF or ZIP).${hint}`);
}
const contentType: string = response.headers['content-type'] ?? '';
if (contentType.includes('zip')) {
const { files } = await AutomationFileProcessor.extractAutomationZipFiles(blob);
return files;
}
const filename =
getFilenameFromHeaders(response.headers['content-disposition'] ?? '') ??
`${automation.name ?? 'output'}.pdf`;
return [new File([blob], filename, { type: blob.type || 'application/pdf', lastModified: Date.now() })];
};