local folder as source

This commit is contained in:
Reece
2026-04-02 18:25:15 +01:00
parent c530ae286f
commit 4b9ee40c8d
6 changed files with 392 additions and 17 deletions
@@ -13,6 +13,7 @@ import {
Select,
Box,
Collapse,
Tooltip,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SmartFolder } from '@app/types/smartFolders';
@@ -28,6 +29,12 @@ import {
deleteServerFolder,
} from '@app/services/serverFolderApiService';
import { buildPipelineJson } from '@app/utils/automationExecutor';
import {
canReadLocalFolder,
canWriteLocalFolder,
FS_READ_UNSUPPORTED_MSG,
FS_WRITE_UNSUPPORTED_MSG,
} from '@app/utils/fsAccessCapability';
import FolderSpecialIcon from '@mui/icons-material/FolderSpecial';
const ACCENT_SWATCHES = [
@@ -111,6 +118,8 @@ export function SmartFolderManagementModal({
const [saving, setSaving] = useState(false);
const [outputDirName, setOutputDirName] = useState<string | null>(editFolder?.hasOutputDirectory ? '(loading…)' : null);
const pendingDirHandle = useRef<FileSystemDirectoryHandle | null>(null);
const [inputDirName, setInputDirName] = useState<string | null>(editFolder?.inputSource === 'local-folder' ? '(loading…)' : null);
const pendingInputDirHandle = useRef<FileSystemDirectoryHandle | null>(null);
const [nameError, setNameError] = useState('');
const [automationError, setAutomationError] = useState('');
const [saveError, setSaveError] = useState<string | null>(null);
@@ -128,6 +137,7 @@ export function SmartFolderManagementModal({
setOutputName(editFolder?.outputName ?? editFolder?.name ?? '');
setOutputNamePosition((editFolder?.outputNamePosition as 'prefix' | 'suffix' | 'auto-number') ?? 'prefix');
setInputSource(editFolder?.inputSource ?? 'idb');
setInputDirName(editFolder?.inputSource === 'local-folder' ? '(loading…)' : null);
setOutputTtlHours(editFolder?.outputTtlHours != null ? String(editFolder.outputTtlHours) : 'forever');
setDeleteOutputOnDownload(editFolder?.deleteOutputOnDownload ?? false);
outputNameDirty.current = !!editFolder?.outputName;
@@ -142,11 +152,17 @@ export function SmartFolderManagementModal({
if (opened) {
resetState();
pendingDirHandle.current = null;
pendingInputDirHandle.current = null;
if (editFolder?.hasOutputDirectory && editFolder.id) {
folderDirectoryHandleStorage.get(editFolder.id).then(h => setOutputDirName(h?.name ?? null));
} else {
setOutputDirName(null);
}
if (editFolder?.inputSource === 'local-folder' && editFolder.id) {
folderDirectoryHandleStorage.getInput(editFolder.id).then(h => setInputDirName(h?.name ?? null));
} else {
setInputDirName(null);
}
}
}, [opened, resetState, editFolder]);
@@ -189,12 +205,20 @@ export function SmartFolderManagementModal({
if (isEditMode && editFolder) {
const wasServerFolder = editFolder.inputSource === 'server-folder';
const wasLocalFolder = editFolder.inputSource === 'local-folder';
await smartFolderStorage.updateFolder({ ...editFolder, ...folderData });
if (pendingDirHandle.current) {
await folderDirectoryHandleStorage.set(editFolder.id, pendingDirHandle.current);
} else if (!hasOutputDirectory) {
await folderDirectoryHandleStorage.remove(editFolder.id);
}
if (inputSource === 'local-folder') {
if (pendingInputDirHandle.current) {
await folderDirectoryHandleStorage.setInput(editFolder.id, pendingInputDirHandle.current);
}
} else if (wasLocalFolder) {
await folderDirectoryHandleStorage.removeInput(editFolder.id);
}
if (isServerFolder && configJson) {
if (wasServerFolder) {
await updateServerFolder(editFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload);
@@ -209,6 +233,9 @@ export function SmartFolderManagementModal({
if (pendingDirHandle.current) {
await folderDirectoryHandleStorage.set(newFolder.id, pendingDirHandle.current);
}
if (inputSource === 'local-folder' && pendingInputDirHandle.current) {
await folderDirectoryHandleStorage.setInput(newFolder.id, pendingInputDirHandle.current);
}
if (isServerFolder && configJson) {
await createServerFolder(newFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload);
}
@@ -356,12 +383,79 @@ export function SmartFolderManagementModal({
onChange={(v) => v && setInputSource(v as NonNullable<SmartFolder['inputSource']>)}
data={[
{ value: 'idb', label: 'Browser — drop files here' },
{
value: 'local-folder',
label: canReadLocalFolder
? 'Local folder (auto-scan)'
: 'Local folder (auto-scan) — Chrome/Edge only',
disabled: !canReadLocalFolder,
},
{ value: 'server-folder', label: 'Server watch folder' },
]}
size="sm"
comboboxProps={{ withinPortal: true, zIndex: 400 }}
/>
{/* Local-folder input directory picker */}
{inputSource === 'local-folder' && (
<Box
style={{
marginLeft: '0.75rem',
paddingLeft: '0.75rem',
borderLeft: '2px solid var(--border-subtle)',
}}
>
<Box
style={{
padding: '0.5rem 0.75rem',
borderRadius: 'var(--mantine-radius-sm)',
border: `0.0625rem solid ${inputDirName ? 'rgba(34,197,94,0.4)' : 'var(--mantine-color-yellow-5)'}`,
backgroundColor: inputDirName ? 'rgba(34,197,94,0.06)' : 'rgba(234,179,8,0.06)',
}}
>
<Group gap="xs" align="center" wrap="nowrap">
<FolderSpecialIcon style={{
fontSize: '1rem',
color: inputDirName ? '#22c55e' : 'var(--mantine-color-yellow-6)',
flexShrink: 0,
}} />
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={500}>Input folder</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{inputDirName ?? 'No folder chosen — required for auto-scan'}
</Text>
</Stack>
<Button
size="xs"
variant="subtle"
onClick={async () => {
try {
const handle = await (window as any).showDirectoryPicker({ mode: 'read' });
pendingInputDirHandle.current = handle;
setInputDirName(handle.name);
} catch { /* cancelled */ }
}}
>
{inputDirName ? 'Change' : 'Choose'}
</Button>
{inputDirName && (
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => { pendingInputDirHandle.current = null; setInputDirName(null); }}
>
Clear
</Button>
)}
</Group>
</Box>
<Text size="xs" c="dimmed" mt={6}>
New PDF files in this folder are processed automatically every 10 seconds.
</Text>
</Box>
)}
{/* Server-specific options, visually indented under the select */}
{inputSource === 'server-folder' && (
<Box style={{
@@ -414,22 +508,32 @@ export function SmartFolderManagementModal({
<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 ?? 'Not set — outputs stay in app'}
{!canWriteLocalFolder
? 'Not supported in this browser'
: (outputDirName ?? 'Not set — outputs stay in app')}
</Text>
</Stack>
<Button
size="xs"
variant="subtle"
onClick={async () => {
try {
const handle = await (window as any).showDirectoryPicker({ mode: 'readwrite' });
pendingDirHandle.current = handle;
setOutputDirName(handle.name);
} catch { /* cancelled */ }
}}
<Tooltip
label={FS_WRITE_UNSUPPORTED_MSG}
disabled={canWriteLocalFolder}
withinPortal
zIndex={500}
>
{outputDirName ? 'Change' : 'Choose'}
</Button>
<Button
size="xs"
variant="subtle"
disabled={!canWriteLocalFolder}
onClick={async () => {
try {
const handle = await (window as any).showDirectoryPicker({ mode: 'readwrite' });
pendingDirHandle.current = handle;
setOutputDirName(handle.name);
} catch { /* cancelled */ }
}}
>
{outputDirName ? 'Change' : 'Choose'}
</Button>
</Tooltip>
{outputDirName && (
<Button
size="xs"
@@ -38,6 +38,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
import { automationStorage } from '@app/services/automationStorage';
import { useFolderAutomation, resolveInputFile } from '@app/hooks/useFolderAutomation';
import { useLocalFolderPoller } from '@app/hooks/useLocalFolderPoller';
import { AutomationConfig } from '@app/types/automation';
import { iconMap } from '@app/components/tools/automate/iconMap';
import { fileStorage } from '@app/services/fileStorage';
@@ -50,6 +51,7 @@ import { useNavigationActions } from '@app/contexts/NavigationContext';
import { FilePreviewModal } from '@app/components/smartFolders/FilePreviewModal';
import { isServerFolderInput } from '@app/types/smartFolders';
import { downloadServerFolderOutput } from '@app/services/serverFolderApiService';
import { folderDirectoryHandleStorage } from '@app/services/folderDirectoryHandleStorage';
const SORT_OPTIONS = [
{ value: 'newest', label: 'Newest' },
@@ -144,6 +146,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
const { folders, updateFolder } = useSmartFolders();
const folder = folders.find(f => f.id === folderId);
const { runPipeline } = useFolderAutomation(toolRegistry);
useLocalFolderPoller(runPipeline);
const {
folderRecord,
@@ -158,8 +161,10 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
const { recentRuns } = useFolderRunState(folderId ?? '');
const isServerFolder = folder ? isServerFolderInput(folder) : false;
const isLocalFolder = folder?.inputSource === 'local-folder';
const [isDragOver, setIsDragOver] = useState(false);
const [localInputFolderName, setLocalInputFolderName] = useState<string | null>(null);
const [outputFiles, setOutputFiles] = useState<StirlingFile[]>([]);
const [inputFiles, setInputFiles] = useState<StirlingFile[]>([]);
const [previewFileId, setPreviewFileId] = useState<FileId | null>(null);
@@ -201,6 +206,14 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
}
}, [folder?.automationId]);
useEffect(() => {
if (isLocalFolder && folderId) {
folderDirectoryHandleStorage.getInput(folderId).then(h => setLocalInputFolderName(h?.name ?? null));
} else {
setLocalInputFolderName(null);
}
}, [isLocalFolder, folderId]);
const handleFiles = useCallback(
async (files: FileList | File[], sourceFileId?: string) => {
@@ -726,6 +739,13 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
{folder.description && !ops.length && (
<Text size="xs" c="dimmed">{folder.description}</Text>
)}
{isLocalFolder && (
<Text size="xs" c={localInputFolderName ? 'green' : 'yellow'} style={{ fontSize: '0.6875rem' }}>
{localInputFolderName
? `Watching: ${localInputFolderName}`
: 'No input folder — edit to configure'}
</Text>
)}
</Stack>
</Group>
@@ -0,0 +1,115 @@
/**
* Polls local input directories for Watch Folders with inputSource === 'local-folder'.
* On each cycle it scans the chosen directory, skips already-seen files,
* registers new ones in folderStorage, and kicks off the automation pipeline.
*
* Polling only happens while the page is visible; the interval is reset on visibility restore.
*/
import { useEffect, useRef } from 'react';
import { SmartFolder } from '@app/types/smartFolders';
import { smartFolderStorage } from '@app/services/smartFolderStorage';
import { folderStorage } from '@app/services/folderStorage';
import { folderDirectoryHandleStorage } from '@app/services/folderDirectoryHandleStorage';
import { folderSeenFilesStorage, makeSeenKey } from '@app/services/folderSeenFilesStorage';
import { resolveInputFile } from '@app/hooks/useFolderAutomation';
import { canReadLocalFolder } from '@app/utils/fsAccessCapability';
const POLL_INTERVAL_MS = 10_000;
export function useLocalFolderPoller(
runPipeline: (folder: SmartFolder, file: File, inputFileId: string, ownedByFolder: boolean) => Promise<void>
): void {
const runPipelineRef = useRef(runPipeline);
useEffect(() => { runPipelineRef.current = runPipeline; });
useEffect(() => {
let cancelled = false;
async function poll() {
if (cancelled || document.visibilityState !== 'visible' || !canReadLocalFolder) return;
let folders: SmartFolder[];
try {
folders = await smartFolderStorage.getAllFolders();
} catch {
return;
}
const localFolders = folders.filter(f => f.inputSource === 'local-folder' && !f.isPaused);
if (localFolders.length === 0) return;
for (const folder of localFolders) {
if (cancelled) return;
try {
const inputHandle = await folderDirectoryHandleStorage.getInput(folder.id);
if (!inputHandle) continue;
const hasPermission = await folderDirectoryHandleStorage.ensureReadPermission(inputHandle);
if (!hasPermission) continue;
const folderData = await folderStorage.getFolderData(folder.id);
// Build set of file names already in the folder (any status) to avoid duplicates
// keyed by name+size (can't use lastModified — file handle gives same value each time)
const processingNames = new Set(
Object.values(folderData?.files ?? {})
.filter(m => m.status === 'processing' || m.status === 'pending')
.map(m => m.name)
.filter(Boolean) as string[]
);
for await (const [, entry] of (inputHandle as any).entries()) {
if (cancelled) return;
if (entry.kind !== 'file') continue;
let file: File;
try {
file = await (entry as FileSystemFileHandle).getFile();
} catch {
continue; // file may have been deleted between listing and reading
}
// Only process PDFs
if (!file.name.toLowerCase().endsWith('.pdf')) continue;
// Skip if currently processing (prevents duplicate submissions within a session)
if (processingNames.has(file.name)) continue;
const seenKey = makeSeenKey(folder.id, file);
const alreadySeen = await folderSeenFilesStorage.isSeen(seenKey);
if (alreadySeen) continue;
// Mark seen before submitting so a slow pipeline doesn't cause double-submission
await folderSeenFilesStorage.markSeen(seenKey);
const { inputFileId, ownedByFolder } = await resolveInputFile(file);
await folderStorage.addFileToFolder(folder.id, inputFileId, {
status: 'pending',
name: file.name,
ownedByFolder,
});
void runPipelineRef.current(folder, file, inputFileId, ownedByFolder);
}
} catch (err) {
console.warn(`[local-folder-poller] Error scanning folder ${folder.id}:`, err);
}
}
}
poll();
const interval = setInterval(poll, POLL_INTERVAL_MS);
function handleVisibility() {
if (document.visibilityState === 'visible') poll();
}
document.addEventListener('visibilitychange', handleVisibility);
return () => {
cancelled = true;
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []); // deliberately empty — uses refs for mutable callbacks
}
@@ -17,7 +17,14 @@ function openDB(): Promise<IDBDatabase> {
});
}
type ExtendedDirHandle = FileSystemDirectoryHandle & {
queryPermission(opts: object): Promise<PermissionState>;
requestPermission(opts: object): Promise<PermissionState>;
};
export const folderDirectoryHandleStorage = {
// ── Output directory handles (readwrite) ─────────────────────────────────
async get(folderId: string): Promise<FileSystemDirectoryHandle | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
@@ -51,10 +58,50 @@ export const folderDirectoryHandleStorage = {
*/
async ensurePermission(handle: FileSystemDirectoryHandle): Promise<boolean> {
const opts = { mode: 'readwrite' };
const h = handle as FileSystemDirectoryHandle & {
queryPermission(opts: object): Promise<PermissionState>;
requestPermission(opts: object): Promise<PermissionState>;
};
const h = handle as ExtendedDirHandle;
if ((await h.queryPermission(opts)) === 'granted') return true;
return (await h.requestPermission(opts)) === 'granted';
},
// ── Input directory handles (readonly) ────────────────────────────────────
// Stored under key "input:{folderId}" to avoid collisions with output handles.
async getInput(folderId: string): Promise<FileSystemDirectoryHandle | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE).objectStore(STORE).get(`input:${folderId}`);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
},
async setInput(folderId: string, handle: FileSystemDirectoryHandle): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).put(handle, `input:${folderId}`);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
async removeInput(folderId: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).delete(`input:${folderId}`);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
/**
* Verifies the handle still has read permission, requesting it if needed.
* On browsers that don't support queryPermission/requestPermission (Firefox),
* returns true optimistically — access errors will surface naturally during iteration.
*/
async ensureReadPermission(handle: FileSystemDirectoryHandle): Promise<boolean> {
const h = handle as ExtendedDirHandle;
if (typeof h.queryPermission !== 'function') return true; // Firefox — no permission API
const opts = { mode: 'read' };
if ((await h.queryPermission(opts)) === 'granted') return true;
return (await h.requestPermission(opts)) === 'granted';
},
@@ -0,0 +1,59 @@
/**
* Tracks which local-folder input files have already been submitted for processing.
* Key: `{folderId}|{filename}|{size}|{lastModified}` — uniquely identifies a file version.
* Prevents re-submitting the same file on every poll cycle.
*/
const DB_NAME = 'stirling-pdf-folder-seen-files';
const DB_VERSION = 1;
const STORE = 'seenFiles';
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export function makeSeenKey(folderId: string, file: File): string {
return `${folderId}|${file.name}|${file.size}|${file.lastModified}`;
}
export const folderSeenFilesStorage = {
async isSeen(key: string): Promise<boolean> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE).objectStore(STORE).get(key);
req.onsuccess = () => resolve(req.result != null);
req.onerror = () => reject(req.error);
});
},
async markSeen(key: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE, 'readwrite').objectStore(STORE).put(Date.now(), key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
/** Remove all seen-file entries for a folder (called when folder is deleted or reset). */
async clearFolder(folderId: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
const req = store.openCursor();
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) { resolve(); return; }
if ((cursor.key as string).startsWith(`${folderId}|`)) cursor.delete();
cursor.continue();
};
req.onerror = () => reject(req.error);
});
},
};
@@ -0,0 +1,30 @@
/**
* Feature detection for the File System Access API.
*
* Browser support matrix (as of 2025):
* Chrome/Edge: full support — showDirectoryPicker, createWritable, queryPermission
* Firefox: showDirectoryPicker + read-only iteration only; no createWritable, no queryPermission
* Safari 15.2+: showDirectoryPicker + read only; no createWritable
*/
/** True when the browser can pick a directory and read files from it. */
export const canReadLocalFolder: boolean =
typeof window !== 'undefined' && typeof (window as any).showDirectoryPicker === 'function';
/** True when the browser supports writing files (createWritable). Requires Chrome/Edge. */
export const canWriteLocalFolder: boolean =
canReadLocalFolder &&
typeof FileSystemFileHandle !== 'undefined' &&
typeof (FileSystemFileHandle.prototype as any).createWritable === 'function';
/** True when the browser supports permission querying across sessions (queryPermission). */
export const canPersistFsPermission: boolean =
canReadLocalFolder &&
typeof FileSystemHandle !== 'undefined' &&
typeof (FileSystemHandle.prototype as any).queryPermission === 'function';
export const FS_READ_UNSUPPORTED_MSG =
'Your browser does not support the File System Access API. Use Chrome or Edge.';
export const FS_WRITE_UNSUPPORTED_MSG =
'Your browser cannot write to local folders. Use Chrome or Edge for this feature.';