server folder improvements

This commit is contained in:
Reece
2026-04-01 14:43:47 +01:00
parent 54b3f0553d
commit c530ae286f
8 changed files with 322 additions and 195 deletions
@@ -48,7 +48,7 @@ import tools.jackson.databind.ObjectMapper;
public class PipelineDirectoryProcessor {
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("[/\\\\]watchedFolders");
private final ObjectMapper objectMapper;
private final ApiDocService apiDocService;
@@ -105,7 +105,7 @@ public class PipelineDirectoryProcessor {
processedDirsInScan.get().clear();
try {
handleDirectory(dir.toAbsolutePath().normalize());
} catch (IOException e) {
} catch (Exception e) {
log.error("Error processing directory: {}", dir, e);
} finally {
processedDirsInScan.remove();
@@ -153,7 +153,8 @@ public class PipelineDirectoryProcessor {
&& !"processing".equals(dirName)
&& !"processed".equals(dirName)
&& !"error".equals(dirName)) {
// Skip server-managed folders — they are processed on-demand via
// Skip server-managed folders — they are processed on-demand
// via
// the trigger endpoint; session.json marks them as managed.
if (Files.exists(dir.resolve("session.json"))) {
return FileVisitResult.SKIP_SUBTREE;
@@ -241,7 +242,7 @@ public class PipelineDirectoryProcessor {
validateOperation(operation);
File[] files = collectFilesForProcessing(dir, jsonFile, operation);
if (files.length == 0) {
log.debug("No files detected for {} ", dir);
log.info("No files ready for processing in {}", dir);
return;
}
@@ -274,6 +275,13 @@ public class PipelineDirectoryProcessor {
operation.getOperation(),
inputExtensions);
if (inputExtensions == null) {
log.warn(
"No input extension info found for operation {} — skipping directory {}",
operation.getOperation(),
dir);
return new File[0];
}
boolean allowAllFiles = inputExtensions.contains("ALL");
// Server-managed folders (session.json present) only process files that have a
// corresponding .ready marker, preventing partial-upload races.
@@ -313,7 +321,8 @@ public class PipelineDirectoryProcessor {
// Check against allowed extensions
String extension =
fname.contains(".")
? fname.substring(fname.lastIndexOf('.') + 1)
? fname.substring(
fname.lastIndexOf('.') + 1)
.toLowerCase(Locale.ROOT)
: "";
boolean isAllowed =
@@ -331,6 +340,9 @@ public class PipelineDirectoryProcessor {
.map(Path::toAbsolutePath)
.filter(
path -> {
// Server-managed folders use the .ready marker to
// guarantee upload completion — skip the timestamp delay.
if (isServerManaged) return true;
boolean isReady =
fileMonitor.isFileReadyForProcessing(path);
if (!isReady) {
@@ -382,9 +394,10 @@ public class PipelineDirectoryProcessor {
if (moved) {
filesToProcess.add(targetPath.toFile());
// Remove the .ready marker now that the file is safely in processingDir
String stem = file.getName().contains(".")
? file.getName().substring(0, file.getName().lastIndexOf('.'))
: file.getName();
String stem =
file.getName().contains(".")
? file.getName().substring(0, file.getName().lastIndexOf('.'))
: file.getName();
try {
Files.deleteIfExists(file.toPath().getParent().resolve(stem + ".ready"));
} catch (IOException ignore) {
@@ -504,13 +517,17 @@ public class PipelineDirectoryProcessor {
}
private Path determineOutputPath(PipelineConfig config, Path dir) {
String rawOutputDir =
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString());
// Only strip the watchedFolders segment for relative (legacy) output paths.
// Server-managed folders set an absolute output path — leave it untouched so
// output lands in the correct {folderId}/processed directory.
String outputDir =
WATCHED_FOLDERS_PATTERN
.matcher(
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString()))
.replaceAll("");
Paths.get(rawOutputDir).isAbsolute()
? rawOutputDir
: WATCHED_FOLDERS_PATTERN.matcher(rawOutputDir).replaceAll("");
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
}
@@ -14,17 +14,17 @@ public sealed interface PipelineEvent {
record JobFailed(String sessionId, String jobId, String error) implements PipelineEvent {}
/**
* Fired by {@link
* stirling.software.SPDF.controller.api.pipeline.PipelineDirectoryProcessor} when a server
* watch folder batch succeeds. {@code outputFiles} are filenames of the form {@code
* {fileId}.{ext}} — the frontend strips the extension to recover the IDB fileId.
* Fired by {@link stirling.software.SPDF.controller.api.pipeline.PipelineDirectoryProcessor}
* when a server watch folder batch succeeds. {@code outputFiles} are filenames of the form
* {@code {fileId}.{ext}} — the frontend strips the extension to recover the IDB fileId.
*/
record FolderCompleted(String sessionId, String folderId, List<String> outputFiles)
implements PipelineEvent {}
/**
* Fired when a server watch folder batch fails (pipeline reported errors). {@code failedFileIds}
* are the IDB fileIds extracted from the input filenames ({@code {fileId}.{ext}}).
* Fired when a server watch folder batch fails (pipeline reported errors). {@code
* failedFileIds} are the IDB fileIds extracted from the input filenames ({@code
* {fileId}.{ext}}).
*/
record FolderError(String sessionId, String folderId, List<String> failedFileIds)
implements PipelineEvent {}
@@ -14,8 +14,8 @@ import lombok.extern.slf4j.Slf4j;
* is set to {@code COMPLETED} or {@code FAILED}, so any thread that observes the terminal status is
* guaranteed (by the Java Memory Model volatile write/read ordering) to see the result fields.
*
* <p>Results are stored as a temp file on disk rather than in the JVM heap, preventing large
* PDFs from causing memory pressure when many jobs are in flight simultaneously.
* <p>Results are stored as a temp file on disk rather than in the JVM heap, preventing large PDFs
* from causing memory pressure when many jobs are in flight simultaneously.
*/
@Getter
@Slf4j
@@ -38,6 +38,7 @@ public class PipelineJob {
private volatile Status status = Status.PENDING;
private String resultFilename;
/** Temp file holding the result bytes; deleted when the job is cleaned up. */
private Path resultPath;
@@ -1,24 +1,37 @@
package stirling.software.SPDF.model;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Persisted to session.json inside each server watch folder.
*
* <ul>
* <li>{@code sessionId} — routes SSE notifications to the current browser tab.</li>
* <li>{@code folderId} — folder UUID (redundant but avoids directory traversal).</li>
* <li>{@code outputTtlHours} — delete output files older than this many hours; {@code null} = keep forever.</li>
* <li>{@code deleteOutputOnDownload} — if {@code true}, the frontend sends a DELETE after downloading an output file.</li>
* <li>{@code sessionId} — routes SSE notifications to the current browser tab.
* <li>{@code folderId} — folder UUID (redundant but avoids directory traversal).
* <li>{@code outputTtlHours} — delete output files older than this many hours; {@code null} =
* keep forever.
* <li>{@code deleteOutputOnDownload} — if {@code true}, the frontend sends a DELETE after
* downloading an output file.
* </ul>
*/
public record SessionConfig(
String sessionId,
String folderId,
Integer outputTtlHours,
Boolean deleteOutputOnDownload) {
@JsonProperty("sessionId") String sessionId,
@JsonProperty("folderId") String folderId,
@JsonProperty("outputTtlHours") Integer outputTtlHours,
@JsonProperty("deleteOutputOnDownload") Boolean deleteOutputOnDownload) {
/** Compact constructor — treat null booleans as false. */
public SessionConfig {
if (deleteOutputOnDownload == null) deleteOutputOnDownload = false;
@JsonCreator
public SessionConfig(
@JsonProperty("sessionId") String sessionId,
@JsonProperty("folderId") String folderId,
@JsonProperty("outputTtlHours") Integer outputTtlHours,
@JsonProperty("deleteOutputOnDownload") Boolean deleteOutputOnDownload) {
this.sessionId = sessionId;
this.folderId = folderId;
this.outputTtlHours = outputTtlHours;
this.deleteOutputOnDownload =
deleteOutputOnDownload == null ? false : deleteOutputOnDownload;
}
/** Convenience accessor with a sensible default. */
@@ -7,17 +7,20 @@ import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
import { ViewerProvider } from '@app/contexts/ViewerContext';
interface FilePreviewModalProps {
fileId: FileId | null;
fileId?: FileId | null;
/** Pass a File directly (e.g. server-folder outputs not stored in IDB). */
file?: File | null;
fileName: string;
onClose: () => void;
}
export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModalProps) {
export function FilePreviewModal({ fileId, file: fileProp, fileName, onClose }: FilePreviewModalProps) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
if (fileProp) { setFile(fileProp); setError(false); setLoading(false); return; }
if (!fileId) { setFile(null); setError(false); setLoading(false); return; }
setError(false);
setLoading(true);
@@ -28,11 +31,13 @@ export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModal
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [fileId]);
}, [fileId, fileProp]);
const opened = !!(fileId || fileProp);
return (
<Modal
opened={!!fileId}
opened={opened}
onClose={onClose}
title={fileName}
size="90%"
@@ -12,6 +12,7 @@ import {
Switch,
Select,
Box,
Collapse,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SmartFolder } from '@app/types/smartFolders';
@@ -94,7 +95,6 @@ export function SmartFolderManagementModal({
}, [opened]); // eslint-disable-line react-hooks/exhaustive-deps
const [name, setName] = useState(editFolder?.name ?? '');
const [description, setDescription] = useState(editFolder?.description ?? '');
const [icon, setIcon] = useState(editFolder?.icon ?? 'FolderIcon');
const [accentColor, setAccentColor] = useState(editFolder?.accentColor ?? '#3b82f6');
const [maxRetries, setMaxRetries] = useState<number>(editFolder?.maxRetries ?? 3);
@@ -114,12 +114,12 @@ export function SmartFolderManagementModal({
const [nameError, setNameError] = useState('');
const [automationError, setAutomationError] = useState('');
const [saveError, setSaveError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(isEditMode);
const automationSaveTrigger = useRef<(() => void) | null>(null);
const resetState = useCallback(() => {
setName(editFolder?.name ?? '');
setDescription(editFolder?.description ?? '');
setIcon(editFolder?.icon ?? 'FolderIcon');
setAccentColor(editFolder?.accentColor ?? '#3b82f6');
setMaxRetries(editFolder?.maxRetries ?? 3);
@@ -131,6 +131,7 @@ export function SmartFolderManagementModal({
setOutputTtlHours(editFolder?.outputTtlHours != null ? String(editFolder.outputTtlHours) : 'forever');
setDeleteOutputOnDownload(editFolder?.deleteOutputOnDownload ?? false);
outputNameDirty.current = !!editFolder?.outputName;
setShowAdvanced(!!editFolder);
setNameError('');
setAutomationError('');
setSaveError(null);
@@ -155,7 +156,6 @@ export function SmartFolderManagementModal({
const trimmedName = name.trim();
const isServerFolder = inputSource === 'server-folder';
// Validate server-folder compatibility before touching IDB
let configJson: string | null = null;
if (isServerFolder) {
configJson = buildPipelineJson(automation, toolRegistry);
@@ -172,7 +172,7 @@ export function SmartFolderManagementModal({
const ttlHoursNum = isServerFolder && outputTtlHours !== 'forever' ? Number(outputTtlHours) : null;
const folderData = {
name: trimmedName,
description: description.trim(),
description: '',
icon,
accentColor,
automationId: automation.id,
@@ -195,7 +195,6 @@ export function SmartFolderManagementModal({
} else if (!hasOutputDirectory) {
await folderDirectoryHandleStorage.remove(editFolder.id);
}
// Sync server watch folder
if (isServerFolder && configJson) {
if (wasServerFolder) {
await updateServerFolder(editFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload);
@@ -203,7 +202,7 @@ export function SmartFolderManagementModal({
await createServerFolder(editFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload);
}
} else if (wasServerFolder && !isServerFolder) {
await deleteServerFolder(editFolder.id).catch(() => {}); // best-effort
await deleteServerFolder(editFolder.id).catch(() => {});
}
} else {
const newFolder = await smartFolderStorage.createFolder(folderData);
@@ -223,7 +222,7 @@ export function SmartFolderManagementModal({
} finally {
setSaving(false);
}
}, [name, description, icon, accentColor, outputMode, outputName, outputNamePosition, outputDirName, maxRetries, retryDelayMinutes, inputSource, outputTtlHours, deleteOutputOnDownload, isEditMode, editFolder, toolRegistry, resetState, onSaved, onClose, t]);
}, [name, icon, accentColor, outputMode, outputName, outputNamePosition, outputDirName, maxRetries, retryDelayMinutes, inputSource, outputTtlHours, deleteOutputOnDownload, isEditMode, editFolder, toolRegistry, resetState, onSaved, onClose, t]);
const handleSave = () => {
const trimmedName = name.trim();
@@ -285,19 +284,11 @@ export function SmartFolderManagementModal({
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: 'var(--mantine-radius-sm)',
color: 'var(--mantine-color-dimmed)',
fontSize: '1.25rem',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '2rem',
height: '2rem',
background: 'none', border: 'none', cursor: 'pointer',
padding: '0.25rem', borderRadius: 'var(--mantine-radius-sm)',
color: 'var(--mantine-color-dimmed)', fontSize: '1.25rem',
lineHeight: 1, display: 'flex', alignItems: 'center',
justifyContent: 'center', width: '2rem', height: '2rem',
}}
aria-label="Close"
>
@@ -310,7 +301,7 @@ export function SmartFolderManagementModal({
{/* ── Left panel: folder config ── */}
<div style={{
width: '30rem',
width: '28rem',
flexShrink: 0,
borderRight: '0.0625rem solid var(--border-subtle)',
display: 'flex',
@@ -318,12 +309,12 @@ export function SmartFolderManagementModal({
overflow: 'hidden',
}}>
<div style={{ flex: 1, overflowY: 'auto', padding: '1.25rem 1.5rem' }}>
<Stack gap="xl">
<Stack gap="lg">
{/* Identity */}
{/* ── Identity ── */}
<div>
<SectionLabel>Folder</SectionLabel>
<Stack gap="sm">
<Stack gap="xs">
<Group gap="xs" align="flex-end">
<TextInput
placeholder={t('smartFolders.modal.namePlaceholder', 'My Watch Folder')}
@@ -342,6 +333,7 @@ export function SmartFolderManagementModal({
/>
<IconSelector value={icon} onChange={setIcon} size="sm" />
</Group>
<ColorInput
label={t('smartFolders.modal.color', 'Accent colour')}
value={accentColor}
@@ -354,24 +346,76 @@ export function SmartFolderManagementModal({
</Stack>
</div>
{/* Output */}
{/* ── Source & Output ── */}
<div>
<SectionLabel>Output</SectionLabel>
<SectionLabel>Source &amp; Output</SectionLabel>
<Stack gap="sm">
<Select
label="Input source"
value={inputSource}
onChange={(v) => v && setInputSource(v as NonNullable<SmartFolder['inputSource']>)}
data={[
{ value: 'idb', label: 'Browser — drop files here' },
{ value: 'server-folder', label: 'Server watch folder' },
]}
size="sm"
comboboxProps={{ withinPortal: true, zIndex: 400 }}
/>
{/* Server-specific options, visually indented under the select */}
{inputSource === 'server-folder' && (
<Box style={{
marginLeft: '0.75rem',
paddingLeft: '0.75rem',
borderLeft: '2px solid var(--border-subtle)',
}}>
<Stack gap="sm">
<Select
label="Keep processed files on server"
value={outputTtlHours}
onChange={(v) => v && setOutputTtlHours(v)}
data={[
{ value: '1', label: '1 hour' },
{ value: '6', label: '6 hours' },
{ value: '24', label: '24 hours' },
{ value: '168', label: '7 days' },
{ value: '720', label: '30 days' },
{ value: 'forever', label: 'Forever' },
]}
size="sm"
comboboxProps={{ withinPortal: true, zIndex: 400 }}
/>
<Switch
label="Delete from server after download"
description="Output file is removed from the server after downloading"
checked={deleteOutputOnDownload}
onChange={(e) => setDeleteOutputOnDownload(e.currentTarget.checked)}
size="sm"
/>
</Stack>
</Box>
)}
{/* Local output folder */}
<Box
style={{
padding: '0.625rem 0.75rem',
padding: '0.5rem 0.75rem',
borderRadius: 'var(--mantine-radius-sm)',
border: `0.0625rem solid ${outputDirName ? 'rgba(34,197,94,0.4)' : 'var(--border-subtle)'}`,
backgroundColor: outputDirName ? 'rgba(34,197,94,0.06)' : 'transparent',
}}
>
<Group gap="xs" align="center" wrap="nowrap">
<FolderSpecialIcon style={{ fontSize: '1rem', color: outputDirName ? '#22c55e' : 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
<FolderSpecialIcon style={{
fontSize: '1rem',
color: outputDirName ? '#22c55e' : 'var(--mantine-color-dimmed)',
flexShrink: 0,
}} />
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500}>Local output folder</Text>
<Text size="xs" c="dimmed" lineClamp={1}>{outputDirName ?? 'App storage only'}</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{outputDirName ?? 'Not set — outputs stay in app'}
</Text>
</Stack>
<Button
size="xs"
@@ -387,131 +431,119 @@ export function SmartFolderManagementModal({
{outputDirName ? 'Change' : 'Choose'}
</Button>
{outputDirName && (
<Button size="xs" variant="subtle" color="red" onClick={() => { pendingDirHandle.current = null; setOutputDirName(null); }}>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => { pendingDirHandle.current = null; setOutputDirName(null); }}
>
Clear
</Button>
)}
</Group>
</Box>
</Stack>
</div>
{!outputDirName && (
<Switch
label={t('smartFolders.modal.outputModeVersion', 'Replace original')}
description={outputMode === 'new_version' ? 'Output replaces the input as a new version' : 'Output saved as a separate new file'}
checked={outputMode === 'new_version'}
onChange={(e) => setOutputMode(e.currentTarget.checked ? 'new_version' : 'new_file')}
size="sm"
/>
)}
{/* ── Advanced (collapsible) ── */}
<div>
<button
onClick={() => setShowAdvanced(v => !v)}
style={{
display: 'flex', alignItems: 'center', gap: '0.35rem',
background: 'none', border: 'none', cursor: 'pointer',
padding: '0.25rem 0', width: '100%',
color: 'var(--tool-subcategory-text-color)',
fontSize: '0.7rem', fontWeight: 600,
letterSpacing: '0.06em', textTransform: 'uppercase',
}}
>
<span style={{
display: 'inline-block', fontSize: '0.55rem',
transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 160ms ease',
}}>
</span>
Advanced
</button>
<Box style={{ opacity: outputMode === 'new_version' ? 0.4 : 1, pointerEvents: outputMode === 'new_version' ? 'none' : 'auto' }}>
<Group gap="xs" align="flex-end">
{outputNamePosition === 'auto-number' ? (
<Box style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>Auto-number</Text>
<Text size="xs" c="dimmed">e.g. document.pdf document (1).pdf</Text>
</Box>
) : (
<TextInput
label={outputNamePosition === 'suffix' ? 'Filename suffix' : 'Filename prefix'}
value={outputName}
onChange={(e) => { outputNameDirty.current = true; setOutputName(e.currentTarget.value); }}
maxLength={100}
size="sm"
style={{ flex: 1 }}
<Collapse in={showAdvanced} transitionDuration={180}>
<Stack gap="sm" mt="sm">
{/* Replace original — only meaningful for browser mode */}
{inputSource !== 'server-folder' && (
<Switch
label="Replace original file"
description={outputMode === 'new_version'
? 'Output replaces input as a new version'
: 'Output saved as a separate new file'}
checked={outputMode === 'new_version'}
onChange={(e) => setOutputMode(e.currentTarget.checked ? 'new_version' : 'new_file')}
size="sm"
/>
)}
{/* Filename prefix / suffix */}
<Box style={{
opacity: outputMode === 'new_version' ? 0.4 : 1,
pointerEvents: outputMode === 'new_version' ? 'none' : 'auto',
}}>
<Group gap="xs" align="flex-end">
{outputNamePosition === 'auto-number' ? (
<Box style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>Auto-number</Text>
<Text size="xs" c="dimmed">e.g. document.pdf document (1).pdf</Text>
</Box>
) : (
<TextInput
label={outputNamePosition === 'suffix' ? 'Filename suffix' : 'Filename prefix'}
value={outputName}
onChange={(e) => { outputNameDirty.current = true; setOutputName(e.currentTarget.value); }}
maxLength={100}
size="sm"
style={{ flex: 1 }}
/>
)}
<Select
size="xs"
value={outputNamePosition}
onChange={(v) => v && setOutputNamePosition(v as 'prefix' | 'suffix' | 'auto-number')}
data={[
{ value: 'prefix', label: 'Prefix' },
{ value: 'suffix', label: 'Suffix' },
{ value: 'auto-number', label: 'Auto-number' },
]}
style={{ width: '8rem', flexShrink: 0 }}
mb={4}
comboboxProps={{ withinPortal: true, zIndex: 400 }}
/>
)}
<Select
size="xs"
value={outputNamePosition}
onChange={(v) => v && setOutputNamePosition(v as 'prefix' | 'suffix' | 'auto-number')}
data={[
{ value: 'prefix', label: 'Prefix' },
{ value: 'suffix', label: 'Suffix' },
{ value: 'auto-number', label: 'Auto-number' },
]}
style={{ width: '8rem', flexShrink: 0 }}
mb={4}
comboboxProps={{ withinPortal: true, zIndex: 400 }}
</Group>
</Box>
{/* Retry settings */}
<Group gap="sm" grow>
<NumberInput
label="Max auto retries"
value={maxRetries}
onChange={(v) => setMaxRetries(typeof v === 'number' ? Math.max(0, Math.min(10, v)) : 0)}
min={0}
max={10}
size="sm"
/>
<NumberInput
label="Retry interval (min)"
value={retryDelayMinutes}
onChange={(v) => setRetryDelayMinutes(typeof v === 'number' ? Math.max(1, Math.min(60, v)) : 5)}
min={1}
max={60}
size="sm"
disabled={maxRetries === 0}
/>
</Group>
</Box>
</Stack>
</div>
{/* Auto-retry */}
<div>
<SectionLabel>Auto-retry</SectionLabel>
<Group gap="sm" grow>
<NumberInput
label="Max auto retries"
value={maxRetries}
onChange={(v) => setMaxRetries(typeof v === 'number' ? Math.max(0, Math.min(10, v)) : 0)}
min={0}
max={10}
size="sm"
/>
<NumberInput
label="Retry interval (minutes)"
value={retryDelayMinutes}
onChange={(v) => setRetryDelayMinutes(typeof v === 'number' ? Math.max(1, Math.min(60, v)) : 5)}
min={1}
max={60}
size="sm"
disabled={maxRetries === 0}
/>
</Group>
</div>
{/* Data Flow */}
<div>
<SectionLabel>Data Flow</SectionLabel>
<Stack gap="sm">
<Select
label="Input source"
value={inputSource}
onChange={(v) => v && setInputSource(v as NonNullable<SmartFolder['inputSource']>)}
data={[
{ value: 'idb', label: 'Browser storage (default)' },
{ value: 'server-folder', label: 'Server watch folder' },
]}
size="sm"
description={
inputSource === 'server-folder'
? 'Files are placed in a server directory and processed on a 60 s scan cycle. All automation steps must run server-side.'
: 'Files stay in the browser and are processed locally.'
}
comboboxProps={{ withinPortal: true, zIndex: 400 }}
/>
{inputSource === 'server-folder' && (
<>
<Select
label="Keep output files on server"
value={outputTtlHours}
onChange={(v) => v && setOutputTtlHours(v)}
data={[
{ value: '1', label: '1 hour' },
{ value: '6', label: '6 hours' },
{ value: '24', label: '24 hours' },
{ value: '168', label: '7 days' },
{ value: '720', label: '30 days' },
{ value: 'forever', label: 'Forever' },
]}
size="sm"
comboboxProps={{ withinPortal: true, zIndex: 400 }}
/>
<Switch
label="Delete from server after local export"
description="Output file is removed from the server after it has been written to the configured local output folder."
checked={deleteOutputOnDownload}
onChange={(e) => setDeleteOutputOnDownload(e.currentTarget.checked)}
size="sm"
disabled={outputDirName === null}
/>
</>
)}
</Stack>
</Stack>
</Collapse>
</div>
</Stack>
@@ -529,13 +561,15 @@ export function SmartFolderManagementModal({
{t('cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={handleSave} loading={saving} disabled={!name.trim()}>
{isEditMode ? t('smartFolders.modal.saveChanges', 'Save changes') : t('smartFolders.modal.createFolder', 'Create folder')}
{isEditMode
? t('smartFolders.modal.saveChanges', 'Save changes')
: t('smartFolders.modal.createFolder', 'Create folder')}
</Button>
</Group>
</div>
</div>
{/* ── Right panel: automation / tool steps ── */}
{/* ── Right panel: automation steps ── */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '1rem 1.5rem 0.5rem', flexShrink: 0 }}>
<SectionLabel>Steps</SectionLabel>
@@ -547,7 +581,10 @@ export function SmartFolderManagementModal({
existingAutomation={existingAutomation ?? undefined}
onBack={handleClose}
onComplete={handleAutomationComplete}
onSaveFailed={() => { setSaving(false); setAutomationError(t('smartFolders.modal.automationRequired', 'Add at least one configured step before saving.')); }}
onSaveFailed={() => {
setSaving(false);
setAutomationError(t('smartFolders.modal.automationRequired', 'Add at least one configured step before saving.'));
}}
toolRegistry={toolRegistry}
hideMetadata
nameOverride={name.trim() || t('smartFolders.modal.automationNameFallback', 'Watch Folder Automation')}
@@ -164,6 +164,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
const [inputFiles, setInputFiles] = useState<StirlingFile[]>([]);
const [previewFileId, setPreviewFileId] = useState<FileId | null>(null);
const [previewFileName, setPreviewFileName] = useState('');
const [previewFile, setPreviewFile] = useState<File | null>(null);
// Filter / sort state
const [activitySearch, setActivitySearch] = useState('');
@@ -318,16 +319,28 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
}, []);
/** Download a server-folder output file on demand (it is not in IDB). */
const handleServerOutputDownload = useCallback(async (filename: string) => {
const handleServerOutputDownload = useCallback(async (serverFilename: string, displayName?: string) => {
if (!folderId) return;
try {
const file = await downloadServerFolderOutput(folderId, filename);
await handleDownload(file, filename);
const file = await downloadServerFolderOutput(folderId, serverFilename);
await handleDownload(file, displayName ?? serverFilename);
} catch {
// Surface as a no-op — the file may have expired from the server
}
}, [folderId, handleDownload]);
/** Preview a server-folder output file by fetching it on demand. */
const handleServerOutputPreview = useCallback(async (serverFilename: string, displayName?: string) => {
if (!folderId) return;
try {
const file = await downloadServerFolderOutput(folderId, serverFilename);
setPreviewFileName(displayName ?? serverFilename);
setPreviewFile(file);
} catch {
// no-op
}
}, [folderId]);
const goHome = useCallback(() => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null });
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
@@ -1032,7 +1045,10 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleDownload(primaryFile, primaryFile.name); }} title="Export"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
)}
{!isExpanded && isServerFolder && hasPrimaryServerOutput && (
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleServerOutputDownload(serverOutputNames[0]); }} title="Export from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const fn = serverOutputNames[0]; const ext = fn.includes('.') ? fn.substring(fn.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputPreview(fn, base + ext); }} title="Preview"><VisibilityIcon style={{ fontSize: '0.875rem' }} /></button>
)}
{!isExpanded && isServerFolder && hasPrimaryServerOutput && (
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const fn = serverOutputNames[0]; const ext = fn.includes('.') ? fn.substring(fn.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputDownload(fn, base + ext); }} title="Export from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
)}
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleDeleteOne(fileId); }} title="Delete"><DeleteOutlineIcon style={{ fontSize: '0.875rem' }} /></button>
</Box>
@@ -1067,7 +1083,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<Text style={{ fontSize: '0.625rem', letterSpacing: '0.04em', color: '#22c55e', textTransform: 'uppercase', flexShrink: 0 }}>out</Text>
<Text size="xs" style={{ flex: 1, minWidth: 0 }} lineClamp={1}>{fname}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>on server</Text>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); void handleServerOutputDownload(fname); }} title="Download from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const ext = fname.includes('.') ? fname.substring(fname.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputPreview(fname, base + ext); }} title="Preview"><VisibilityIcon style={{ fontSize: '0.875rem' }} /></button>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.2rem', borderRadius: '0.25rem', display: 'flex', alignItems: 'center', color: 'var(--mantine-color-dimmed)' }} onClick={(e) => { e.stopPropagation(); const ext = fname.includes('.') ? fname.substring(fname.lastIndexOf('.')) : ''; const base = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; void handleServerOutputDownload(fname, base + ext); }} title="Download from server"><DownloadIcon style={{ fontSize: '0.875rem' }} /></button>
</Box>
))}
{/* Error detail + retry */}
@@ -1284,8 +1301,9 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<FilePreviewModal
fileId={previewFileId}
file={previewFile}
fileName={previewFileName}
onClose={() => setPreviewFileId(null)}
onClose={() => { setPreviewFileId(null); setPreviewFile(null); }}
/>
{/* Delete confirmation */}
+42 -6
View File
@@ -25,10 +25,12 @@ import {
submitBackendJob,
getBackendJobStatus,
getBackendJobResult,
buildPipelineJson,
} from '@app/utils/automationExecutor';
import {
uploadFileToServerFolder,
updateServerFolderSession,
createServerFolder,
listServerFolderOutput,
downloadServerFolderOutput,
deleteServerFolderOutput,
@@ -349,7 +351,11 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
if (dirHandle) {
const hasPermission = await folderDirectoryHandleStorage.ensurePermission(dirHandle);
if (hasPermission) {
await folderDirectoryHandleStorage.writeFile(dirHandle, outputFilename, resultFile);
const origName = meta?.name ?? outputFilename;
const outExt = outputFilename.includes('.') ? outputFilename.substring(outputFilename.lastIndexOf('.')) : '';
const origBase = origName.includes('.') ? origName.substring(0, origName.lastIndexOf('.')) : origName;
const displayName = origBase + outExt;
await folderDirectoryHandleStorage.writeFile(dirHandle, displayName, resultFile);
}
}
} catch {
@@ -515,7 +521,9 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
if (dirHandle) {
const hasPermission = await folderDirectoryHandleStorage.ensurePermission(dirHandle);
if (hasPermission) {
await folderDirectoryHandleStorage.writeFile(dirHandle, outputFile.filename, resultFile);
const outExt = outputFile.filename.includes('.') ? outputFile.filename.substring(outputFile.filename.lastIndexOf('.')) : '';
const origBase = inputFile.name.includes('.') ? inputFile.name.substring(0, inputFile.name.lastIndexOf('.')) : inputFile.name;
await folderDirectoryHandleStorage.writeFile(dirHandle, origBase + outExt, resultFile);
}
}
} catch { /* best-effort */ }
@@ -558,7 +566,11 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
// Server-folder input — upload to watch folder, trigger immediate processing via SSE
if (isServerFolderInput(folder)) {
await uploadFileToServerFolder(folder.id, inputFileId, file);
// Load from IDB to guarantee we have the full file bytes.
// The `file` parameter may be a stale drag-event reference whose data
// is no longer readable after the async resolveInputFile call completed.
const uploadFile = await fileStorage.getStirlingFile(inputFileId as FileId) ?? file;
await uploadFileToServerFolder(folder.id, inputFileId, uploadFile);
await folderStorage.updateFileMetadata(folder.id, inputFileId, {
pendingOnServerFolder: true,
});
@@ -638,11 +650,35 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
if (!isServerFolderInput(folder)) continue;
try {
await updateServerFolderSession(folder.id);
} catch {
// Best-effort — server folder may not exist yet or server may be down
} catch (err: any) {
// 404 means the server directory was never provisioned (e.g. backend wasn't running
// when the folder was created). Re-provision it now using the stored automation.
if (err?.response?.status === 404) {
try {
const automation = await automationStorage.getAutomation(folder.automationId);
if (!automation) {
console.warn(`[watch-folders] Cannot re-provision ${folder.id}: automation ${folder.automationId} not found in IDB`);
} else {
const configJson = buildPipelineJson(automation, toolRegistry);
if (!configJson) {
console.warn(`[watch-folders] Cannot re-provision ${folder.id}: automation has browser-only steps`);
} else {
await createServerFolder(
folder.id, folder.name, configJson,
folder.outputTtlHours ?? null, folder.deleteOutputOnDownload ?? false
);
console.info(`[watch-folders] Re-provisioned server folder ${folder.id}`);
}
}
} catch (reprovisionErr) {
console.warn(`[watch-folders] Re-provision failed for ${folder.id}:`, reprovisionErr);
}
} else {
console.warn(`[watch-folders] updateSession failed for ${folder.id}:`, err?.response?.status, err?.message);
}
}
}
}, []);
}, [toolRegistry]);
// ── Lifecycle effects ──────────────────────────────────────────────────────
useEffect(() => {