local folder as source
This commit is contained in:
@@ -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.';
|
||||
Reference in New Issue
Block a user