Backend smart foldering
This commit is contained in:
@@ -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);
|
||||
|
||||
+20
-7
@@ -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) {
|
||||
|
||||
+4
-2
@@ -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>
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+5
-4
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() })];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user