mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/v2/smartfolders rebuild (#6480)
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
parent
e0fc5061de
commit
e2536daeb8
@@ -14,6 +14,8 @@ import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||
import ParticipantView from "@app/components/workflow/ParticipantView";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration";
|
||||
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -80,6 +82,7 @@ export default function App() {
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, ActionIcon, ScrollArea } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
CardModalPhase,
|
||||
CARD_MODAL_TIMINGS,
|
||||
} from "@app/hooks/useCardModalAnimation";
|
||||
|
||||
interface CardExpansionModalProps {
|
||||
phase: CardModalPhase;
|
||||
cardRect: DOMRect | null;
|
||||
textExpanded: boolean;
|
||||
onClose: () => void;
|
||||
icon: React.ReactNode;
|
||||
count: number;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
toolbar?: React.ReactNode;
|
||||
widthRem?: number;
|
||||
heightRem?: number;
|
||||
/** Replace ScrollArea with a flex-fill container so children can expand to full body height */
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
const MODAL_W_REM = 56;
|
||||
const MODAL_H_REM = 38;
|
||||
const HEADER_H_REM = 3.5;
|
||||
const MODAL_TOP_FRACTION = 0.12;
|
||||
const EASING = "cubic-bezier(0.22,1,0.36,1)";
|
||||
|
||||
export function CardExpansionModal({
|
||||
phase,
|
||||
cardRect,
|
||||
textExpanded,
|
||||
onClose,
|
||||
icon,
|
||||
count,
|
||||
labelSingular,
|
||||
labelPlural,
|
||||
children,
|
||||
footer,
|
||||
toolbar,
|
||||
widthRem,
|
||||
heightRem,
|
||||
fillHeight,
|
||||
}: CardExpansionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
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(
|
||||
(widthRem ?? MODAL_W_REM) * rootFontSize,
|
||||
viewportW * 0.9,
|
||||
);
|
||||
const modalH = Math.min(
|
||||
(heightRem ?? MODAL_H_REM) * rootFontSize,
|
||||
viewportH * 0.85,
|
||||
);
|
||||
const headerH = HEADER_H_REM * rootFontSize;
|
||||
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";
|
||||
|
||||
const cardH = isAtCard ? cardRect.height : isAtHeader ? headerH : modalH;
|
||||
|
||||
const getTransition = () => {
|
||||
const s = CARD_MODAL_TIMINGS;
|
||||
if (phase === "header-open")
|
||||
return `top ${s.headerStretch}ms ${EASING}, left ${s.headerStretch}ms ${EASING}, width ${s.headerStretch}ms ${EASING}, height ${s.headerStretch}ms ${EASING}`;
|
||||
if (phase === "open") return `height ${s.bodyDrop}ms ${EASING}`;
|
||||
if (phase === "closing-body") return `height ${s.closeBody}ms ease-in`;
|
||||
if (phase === "closing-header")
|
||||
return `top ${s.closeStretch}ms ${EASING}, left ${s.closeStretch}ms ${EASING}, width ${s.closeStretch}ms ${EASING}, height ${s.closeStretch}ms ${EASING}, opacity ${s.closeStretch}ms ease`;
|
||||
return "none";
|
||||
};
|
||||
|
||||
const backdropOpacity =
|
||||
phase === "entering" || phase === "closing-header" ? 0 : 1;
|
||||
const cardOpacity = phase === "closing-header" ? 0 : 1;
|
||||
|
||||
const showBody = phase === "open" || phase === "closing-body";
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 300 }}>
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
opacity: backdropOpacity,
|
||||
transition: "opacity 220ms ease",
|
||||
willChange: "opacity",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: isAtCard ? cardRect.top : finalTop,
|
||||
left: isAtCard ? cardRect.left : finalLeft,
|
||||
width: isAtCard ? cardRect.width : modalW,
|
||||
height: cardH,
|
||||
opacity: cardOpacity,
|
||||
transition: getTransition(),
|
||||
willChange: "top, left, width, height, opacity",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 1.5rem 3rem rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: headerH,
|
||||
flexShrink: 0,
|
||||
borderBottom: "0.0625rem solid var(--border-subtle)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<Text
|
||||
component="span"
|
||||
fw={800}
|
||||
style={{ fontSize: "1.375rem", lineHeight: 1, margin: 0 }}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: textExpanded ? "16rem" : "0",
|
||||
opacity: textExpanded ? 1 : 0,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
transition: `max-width 100ms ${EASING}, opacity 80ms ease`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
component="span"
|
||||
c="dimmed"
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
paddingLeft: "0.5rem",
|
||||
lineHeight: 1,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{count === 1 ? labelSingular : labelPlural}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("close", "Close")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0.25rem",
|
||||
right: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBody && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
}}
|
||||
>
|
||||
{toolbar && (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: "0.0625rem solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
{toolbar}
|
||||
</div>
|
||||
)}
|
||||
{fillHeight ? (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: "0.75rem 1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{footer && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderTop: "0.0625rem solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { Modal, Text, Button, Stack, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
|
||||
interface DeleteFolderConfirmModalProps {
|
||||
opened: boolean;
|
||||
folder: WatchedFolder | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteFolderConfirmModal({
|
||||
opened,
|
||||
folder,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteFolderConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("watchedFolders.deleteConfirmTitle", "Delete folder?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{folder.isDefault && (
|
||||
<Text size="sm" c="orange">
|
||||
{t(
|
||||
"watchedFolders.defaultFolderWarning",
|
||||
"This is a default folder and will be recreated on next reload.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"watchedFolders.deleteConfirmBody",
|
||||
"This will remove the folder and its run history. Files already downloaded are not affected.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button color="red" size="sm" onClick={onConfirm}>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
fileId?: FileId | null;
|
||||
/** Pass a File directly (e.g. outputs not stored in IDB). */
|
||||
file?: File | null;
|
||||
fileName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({
|
||||
fileId,
|
||||
file: fileProp,
|
||||
fileName,
|
||||
onClose,
|
||||
}: FilePreviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
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);
|
||||
fileStorage
|
||||
.getStirlingFile(fileId)
|
||||
.then((f) => {
|
||||
if (f) setFile(f);
|
||||
else setError(true);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fileId, fileProp]);
|
||||
|
||||
const opened = !!(fileId || fileProp);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={fileName}
|
||||
size="90%"
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
height: "82vh",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : error ? (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">
|
||||
{t(
|
||||
"watchedFolders.workbench.previewLoadFailed",
|
||||
"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} fileName={fileName} />
|
||||
</Box>
|
||||
</ViewerProvider>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Icon picker for Watched Folder create/edit.
|
||||
* Re-exports IconSelector from the automate tools so smart folder components
|
||||
* don't need to reach into the automate tools directory.
|
||||
*/
|
||||
|
||||
export { default as IconPicker } from "@app/components/tools/automate/IconSelector";
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
count: React.ReactNode;
|
||||
label: string;
|
||||
hoverColor?: string;
|
||||
onClick?: (rect: DOMRect) => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
icon,
|
||||
count,
|
||||
label,
|
||||
hoverColor,
|
||||
onClick,
|
||||
disabled,
|
||||
isActive,
|
||||
}: StatCardProps) {
|
||||
const isClickable = !!onClick && !disabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (isClickable) onClick(e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem 2rem",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `0.0625rem solid ${isActive && hoverColor ? hoverColor : "var(--border-subtle)"}`,
|
||||
backgroundColor:
|
||||
isActive && hoverColor ? `${hoverColor}10` : "var(--bg-toolbar)",
|
||||
textAlign: "center",
|
||||
cursor: isClickable ? "pointer" : "default",
|
||||
transition: "border-color 0.15s ease, background-color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isClickable && hoverColor && !isActive)
|
||||
e.currentTarget.style.borderColor = hoverColor;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isClickable && !isActive)
|
||||
e.currentTarget.style.borderColor = "var(--border-subtle)";
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "block", marginBottom: "0.375rem" }}>{icon}</div>
|
||||
<Text
|
||||
fw={800}
|
||||
style={{
|
||||
fontSize: "1.375rem",
|
||||
lineHeight: 1,
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: "0.5625rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
c="dimmed"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Text, ActionIcon, Group, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutlined";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { FolderRunStatus } from "@app/hooks/useFolderRunStatuses";
|
||||
import { iconMap } from "@app/components/tools/automate/iconMap";
|
||||
|
||||
interface WatchedFolderCardProps {
|
||||
folder: WatchedFolder;
|
||||
isActive: boolean;
|
||||
status: FolderRunStatus;
|
||||
onSelect: () => void;
|
||||
onEdit: (e: React.MouseEvent) => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
onFileDrop?: (fileIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function WatchedFolderCard({
|
||||
folder,
|
||||
isActive,
|
||||
status,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onFileDrop,
|
||||
}: WatchedFolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const IconComponent =
|
||||
iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
const types = e.dataTransfer.types;
|
||||
if (
|
||||
!types.includes("watchedFolderFileId") &&
|
||||
!types.includes("watchedFolderFileIds")
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => setIsDragOver(false);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const multiRaw = e.dataTransfer.getData("watchedFolderFileIds");
|
||||
if (multiRaw) {
|
||||
try {
|
||||
const ids: string[] = JSON.parse(multiRaw);
|
||||
if (ids.length > 0 && onFileDrop) onFileDrop(ids);
|
||||
return;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const fileId = e.dataTransfer.getData("watchedFolderFileId");
|
||||
if (fileId && onFileDrop) onFileDrop([fileId]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="tool-button-container"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={
|
||||
isDragOver
|
||||
? {
|
||||
backgroundColor: "rgba(59,130,246,0.10)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={isActive ? "light" : "subtle"}
|
||||
className="tool-button"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
px="sm"
|
||||
leftSection={
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: `${folder.accentColor}22`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconComponent
|
||||
style={{ fontSize: 11, color: folder.accentColor }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
rightSection={
|
||||
isHovered ? (
|
||||
<Group gap={2} onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={onEdit}
|
||||
aria-label={t("watchedFolders.card.edit", "Edit folder")}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
{!folder.isDefault && (
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={onDelete}
|
||||
aria-label={t("watchedFolders.card.delete", "Delete folder")}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 11 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
) : folder.isPaused ? (
|
||||
<PauseCircleOutlineIcon
|
||||
style={{ fontSize: 12, color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
) : status === "processing" ? (
|
||||
<Loader size={10} color={folder.accentColor} />
|
||||
) : status === "done" ? (
|
||||
<CheckCircleIcon
|
||||
style={{ fontSize: 12, color: "var(--color-green-500)" }}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Stack,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Button,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import FolderPlusIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutlined";
|
||||
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutlined";
|
||||
import { useWatchedFolders } from "@app/hooks/useWatchedFolders";
|
||||
import { useFolderRunStatuses } from "@app/hooks/useFolderRunStatuses";
|
||||
import {
|
||||
useFolderAutomation,
|
||||
resolveInputFile,
|
||||
} from "@app/hooks/useFolderAutomation";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { type FileId } from "@app/types/file";
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import { automationStorage } from "@app/services/automationStorage";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
import { getWatchedFolderDraggedFileIds } from "@app/components/watchedFolders/watchedFolderDragState";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
||||
import { WatchedFolderManagementModal } from "@app/components/watchedFolders/WatchedFolderManagementModal";
|
||||
import { DeleteFolderConfirmModal } from "@app/components/watchedFolders/DeleteFolderConfirmModal";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import {
|
||||
WATCHED_FOLDER_VIEW_ID,
|
||||
WATCHED_FOLDER_WORKBENCH_ID,
|
||||
} from "@app/components/watchedFolders/WatchedFoldersRegistration";
|
||||
import { timeAgo } from "@app/components/watchedFolders/WatchedFolderWorkbenchView";
|
||||
import "@app/components/watchedFolders/WatchedFolders.css";
|
||||
|
||||
export function humaniseOp(op: string): string {
|
||||
return op
|
||||
.replace(/-pdf$|-pages$|-documents?$/i, "")
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\bocr\b/gi, "OCR")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface FolderCardProps {
|
||||
folder: WatchedFolder;
|
||||
status: "idle" | "processing" | "done";
|
||||
isProcessing: boolean;
|
||||
onEdit: (folder: WatchedFolder) => void;
|
||||
onDelete: (folder: WatchedFolder) => void;
|
||||
onOpen: (folderId: string) => void;
|
||||
onDropFiles: (folder: WatchedFolder, files: File[]) => void;
|
||||
onDropSidebarFile: (folder: WatchedFolder, fileIds: string[]) => void;
|
||||
onTogglePause: (folder: WatchedFolder) => void;
|
||||
}
|
||||
|
||||
function FolderCard({
|
||||
folder,
|
||||
status,
|
||||
isProcessing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onDropFiles,
|
||||
onDropSidebarFile,
|
||||
onTogglePause,
|
||||
}: FolderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
|
||||
const [fileCount, setFileCount] = useState(0);
|
||||
const [lastAdded, setLastAdded] = useState<Date | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
// True while a drag hovers this card AND every dragged file is already in it
|
||||
// (so the drop would be a no-op — see the guard in processFiles).
|
||||
const [dragAlreadyPresent, setDragAlreadyPresent] = useState(false);
|
||||
// Ids of files currently in this folder, used for the live dragover check.
|
||||
const [memberIds, setMemberIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
automationStorage.getAutomation(folder.automationId).then(setAutomation);
|
||||
|
||||
const loadData = () =>
|
||||
watchedFolderFileStorage.getFolderData(folder.id).then((record) => {
|
||||
if (!record) {
|
||||
setFileCount(0);
|
||||
setLastAdded(null);
|
||||
setMemberIds(new Set());
|
||||
return;
|
||||
}
|
||||
const files = Object.values(record.files);
|
||||
setFileCount(files.length);
|
||||
setMemberIds(new Set(Object.keys(record.files)));
|
||||
const dates = files
|
||||
.map((f) => new Date(f.addedAt))
|
||||
.filter((d) => !isNaN(d.getTime()));
|
||||
setLastAdded(
|
||||
dates.length
|
||||
? new Date(Math.max(...dates.map((d) => d.getTime())))
|
||||
: null,
|
||||
);
|
||||
});
|
||||
loadData();
|
||||
|
||||
const unsub = watchedFolderFileStorage.onFolderChange((changedId) => {
|
||||
if (changedId === folder.id) loadData();
|
||||
});
|
||||
return unsub;
|
||||
}, [folder.id, folder.automationId]);
|
||||
|
||||
const isPaused = folder.isPaused ?? false;
|
||||
const isActive = !isPaused && (isProcessing || status === "processing");
|
||||
const isDone = !isPaused && status === "done" && !isActive;
|
||||
|
||||
const statusDotColor = isPaused
|
||||
? "var(--mantine-color-dimmed)"
|
||||
: isActive
|
||||
? "var(--mantine-color-blue-filled)"
|
||||
: isDone
|
||||
? "var(--color-green-500)"
|
||||
: "var(--text-muted)";
|
||||
const statusDotPulse = isActive;
|
||||
|
||||
const statusLabel = isPaused
|
||||
? t("watchedFolders.status.paused", "Paused")
|
||||
: isActive
|
||||
? t("watchedFolders.status.processing", "Processing")
|
||||
: isDone
|
||||
? t("watchedFolders.status.done", "Done")
|
||||
: t("watchedFolders.status.active", "Active");
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
// Live feedback: are all the files being dragged already in this folder?
|
||||
const dragged = getWatchedFolderDraggedFileIds();
|
||||
setDragAlreadyPresent(
|
||||
dragged.length > 0 && dragged.every((id) => memberIds.has(id)),
|
||||
);
|
||||
};
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
setDragAlreadyPresent(false);
|
||||
}
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setDragAlreadyPresent(false);
|
||||
const multiRaw = e.dataTransfer.getData("watchedFolderFileIds");
|
||||
if (multiRaw) {
|
||||
try {
|
||||
const ids: string[] = JSON.parse(multiRaw);
|
||||
if (ids.length > 0) {
|
||||
onDropSidebarFile(folder, ids);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const sidebarFileId = e.dataTransfer.getData("watchedFolderFileId");
|
||||
if (sidebarFileId) {
|
||||
onDropSidebarFile(folder, [sidebarFileId]);
|
||||
} else if (e.dataTransfer.files.length > 0) {
|
||||
onDropFiles(folder, Array.from(e.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
// `automation` is loaded by the per-card effect above; the steps it holds
|
||||
// are no longer rendered on the card but the load is preserved.
|
||||
void automation;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`wf-card${isDragOver ? " is-drop-target" : ""}${
|
||||
isDragOver && dragAlreadyPresent ? " is-already-member" : ""
|
||||
}`}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => onOpen(folder.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onOpen(folder.id);
|
||||
}}
|
||||
>
|
||||
{isDragOver && dragAlreadyPresent && (
|
||||
<div className="wf-card-drag-message" aria-hidden="true">
|
||||
{t("watchedFolders.alreadyInFolder", "Already in this folder")}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="wf-card-thumb"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, color-mix(in srgb, ${folder.accentColor} 18%, var(--bg-surface)), color-mix(in srgb, ${folder.accentColor} 6%, var(--bg-surface)))`,
|
||||
}}
|
||||
>
|
||||
<FolderThumbnail
|
||||
color={folder.accentColor}
|
||||
fileCount={fileCount}
|
||||
size="thumb"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wf-card-body">
|
||||
<div className="wf-card-name" title={folder.name}>
|
||||
{folder.name}
|
||||
</div>
|
||||
<div className="wf-card-meta">
|
||||
<span>
|
||||
{fileCount}{" "}
|
||||
{fileCount === 1
|
||||
? t("watchedFolders.home.file", "file")
|
||||
: t("watchedFolders.home.files", "files")}
|
||||
</span>
|
||||
{lastAdded && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(lastAdded, t)}</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span
|
||||
className={`wf-card-status-dot${statusDotPulse ? " is-pulsing" : ""}`}
|
||||
style={{ backgroundColor: statusDotColor }}
|
||||
/>
|
||||
<span style={{ fontSize: "0.7rem" }}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wf-card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={() => onTogglePause(folder)}
|
||||
aria-label={
|
||||
isPaused
|
||||
? t("watchedFolders.home.resume", "Resume")
|
||||
: t("watchedFolders.home.pause", "Pause")
|
||||
}
|
||||
title={
|
||||
isPaused
|
||||
? t("watchedFolders.home.resume", "Resume")
|
||||
: t("watchedFolders.home.pause", "Pause")
|
||||
}
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayCircleOutlineIcon style={{ fontSize: "1.125rem" }} />
|
||||
) : (
|
||||
<PauseCircleOutlineIcon style={{ fontSize: "1.125rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={() => onEdit(folder)}
|
||||
aria-label={t("watchedFolders.home.editFolder", "Edit folder")}
|
||||
>
|
||||
<EditIcon style={{ fontSize: "1.125rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onDelete(folder)}
|
||||
aria-label={t("watchedFolders.home.deleteFolder", "Delete folder")}
|
||||
>
|
||||
<DeleteOutlineIcon style={{ fontSize: "1.125rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HowItWorks() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem("wf_howItWorks_dismissed") === "1",
|
||||
);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const steps = [
|
||||
{
|
||||
n: "1",
|
||||
title: t("watchedFolders.howItWorks.step1Title", "Drop files"),
|
||||
desc: t(
|
||||
"watchedFolders.howItWorks.step1Desc",
|
||||
"Drag PDFs onto any Watched Folder card — or send them from your file list",
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "2",
|
||||
title: t("watchedFolders.howItWorks.step2Title", "Pipeline runs"),
|
||||
desc: t(
|
||||
"watchedFolders.howItWorks.step2Desc",
|
||||
"Your configured tools process each file automatically",
|
||||
),
|
||||
},
|
||||
{
|
||||
n: "3",
|
||||
title: t("watchedFolders.howItWorks.step3Title", "Output ready"),
|
||||
desc: t(
|
||||
"watchedFolders.howItWorks.step3Desc",
|
||||
"Download processed files from inside the folder",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
mt="lg"
|
||||
style={{
|
||||
padding: "1rem 1.25rem",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
border: "0.0625rem solid var(--border-subtle)",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" mb="sm" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<InfoOutlinedIcon
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: "var(--mantine-color-blue-filled)",
|
||||
}}
|
||||
/>
|
||||
<Text fw={600} size="xs">
|
||||
{t("watchedFolders.howItWorks.title", "How Watched Folders work")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("wf_howItWorks_dismissed", "1");
|
||||
setDismissed(true);
|
||||
}}
|
||||
aria-label={t("watchedFolders.actions.dismiss", "Dismiss")}
|
||||
>
|
||||
<CloseIcon
|
||||
style={{ fontSize: "0.75rem", color: "var(--mantine-color-text)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group gap="xl" wrap="nowrap" align="flex-start">
|
||||
{steps.map((step) => (
|
||||
<Group
|
||||
key={step.n}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: "1.375rem",
|
||||
height: "1.375rem",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--mantine-color-blue-light)",
|
||||
color: "var(--mantine-color-blue-filled)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.6875rem",
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{step.n}
|
||||
</Box>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600}>
|
||||
{step.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ lineHeight: 1.5 }}>
|
||||
{step.desc}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="wf-empty">
|
||||
<span className="wf-empty-icon">
|
||||
<FolderPlusIcon style={{ fontSize: "2.5rem" }} />
|
||||
</span>
|
||||
<div className="wf-empty-title">
|
||||
{t("watchedFolders.home.emptyTitle", "Automate your PDF workflows")}
|
||||
</div>
|
||||
<div className="wf-empty-hint">
|
||||
{t(
|
||||
"watchedFolders.home.emptyDesc",
|
||||
"Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does.",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
leftSection={<AddIcon style={{ fontSize: "1.125rem" }} />}
|
||||
onClick={onCreate}
|
||||
mt="sm"
|
||||
>
|
||||
{t("watchedFolders.home.create", "Create your first Watched Folder")}
|
||||
</Button>
|
||||
|
||||
<HowItWorks />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WatchedFolderHomePage() {
|
||||
const { t } = useTranslation();
|
||||
const { folders, loading, deleteFolder, updateFolder, refreshFolders } =
|
||||
useWatchedFolders();
|
||||
const statuses = useFolderRunStatuses(folders);
|
||||
const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
const { processBatch } = useFolderAutomation(toolRegistry);
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<WatchedFolder | null>(null);
|
||||
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(
|
||||
null,
|
||||
);
|
||||
const [processingFolderIds, setProcessingFolderIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WatchedFolder | null>(null);
|
||||
|
||||
const navigateToFolder = useCallback(
|
||||
(folderId: string) => {
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
},
|
||||
[setCustomWorkbenchViewData, actions],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(async (folder: WatchedFolder) => {
|
||||
setEditFolder(folder);
|
||||
const automation = await automationStorage.getAutomation(
|
||||
folder.automationId,
|
||||
);
|
||||
setEditAutomation(automation);
|
||||
setCreateModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditFolder(null);
|
||||
setEditAutomation(null);
|
||||
};
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (folder: WatchedFolder, files: File[]) => {
|
||||
const pdfs = files.filter((f) => f.name.toLowerCase().endsWith(".pdf"));
|
||||
if (pdfs.length === 0) return;
|
||||
|
||||
// Load existing folder data once so we can skip files already in the folder.
|
||||
const existingData = await watchedFolderFileStorage.getFolderData(
|
||||
folder.id,
|
||||
);
|
||||
// Register files sequentially — addFileToFolder/updateFileMetadata are read-modify-write
|
||||
// without IDB transactions, so concurrent calls on the same folder lose updates.
|
||||
const items: Array<{
|
||||
file: File;
|
||||
inputFileId: string;
|
||||
ownedByFolder: boolean;
|
||||
}> = [];
|
||||
for (const file of pdfs) {
|
||||
const { inputFileId, ownedByFolder } = await resolveInputFile(file);
|
||||
// Guard against multiple adds: if this file is already in the folder,
|
||||
// skip it entirely rather than resetting its status and reprocessing.
|
||||
// (Dropping the same sidebar file onto a card again is a no-op.)
|
||||
if (existingData?.files[inputFileId]) {
|
||||
continue;
|
||||
}
|
||||
await watchedFolderFileStorage.addFileToFolder(folder.id, inputFileId, {
|
||||
status: "pending",
|
||||
name: file.name,
|
||||
ownedByFolder: ownedByFolder || undefined,
|
||||
});
|
||||
items.push({ file, inputFileId, ownedByFolder });
|
||||
}
|
||||
|
||||
// Nothing new to process (every dropped file was already in the folder).
|
||||
if (items.length === 0) return;
|
||||
|
||||
if (folder.isPaused) return;
|
||||
|
||||
setProcessingFolderIds((prev) => new Set([...prev, folder.id]));
|
||||
try {
|
||||
await processBatch(folder, items);
|
||||
} finally {
|
||||
setProcessingFolderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folder.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[processBatch],
|
||||
);
|
||||
|
||||
const handleTogglePause = useCallback(
|
||||
async (folder: WatchedFolder) => {
|
||||
const resuming = folder.isPaused;
|
||||
const updatedFolder = { ...folder, isPaused: !folder.isPaused };
|
||||
await updateFolder(updatedFolder);
|
||||
refreshFolders();
|
||||
|
||||
if (resuming) {
|
||||
const record = await watchedFolderFileStorage.getFolderData(folder.id);
|
||||
if (record) {
|
||||
const pendingEntries = Object.entries(record.files).filter(
|
||||
([, meta]) => meta.status === "pending",
|
||||
);
|
||||
if (pendingEntries.length > 0) {
|
||||
const items: Array<{
|
||||
file: File;
|
||||
inputFileId: string;
|
||||
ownedByFolder: boolean;
|
||||
}> = [];
|
||||
for (const [id, meta] of pendingEntries) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(
|
||||
id as FileId,
|
||||
);
|
||||
if (stirlingFile) {
|
||||
items.push({
|
||||
file: stirlingFile,
|
||||
inputFileId: id,
|
||||
ownedByFolder: meta.ownedByFolder ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (items.length > 0) processBatch(updatedFolder, items);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[updateFolder, refreshFolders, processBatch],
|
||||
);
|
||||
|
||||
const handleDropSidebarFile = useCallback(
|
||||
async (folder: WatchedFolder, fileIds: string[]) => {
|
||||
const results = await Promise.all(
|
||||
fileIds.map((id) => fileStorage.getStirlingFile(id as FileId)),
|
||||
);
|
||||
const stirlingFiles = results.filter(Boolean) as File[];
|
||||
if (stirlingFiles.length > 0) processFiles(folder, stirlingFiles);
|
||||
},
|
||||
[processFiles],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await deleteFolder(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="xl">
|
||||
{loading ? (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "4rem",
|
||||
}}
|
||||
>
|
||||
<Loader size="md" />
|
||||
</Box>
|
||||
) : folders.length === 0 ? (
|
||||
<EmptyState onCreate={() => setCreateModalOpen(true)} />
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<HowItWorks />
|
||||
|
||||
<div className="wf-grid" role="list">
|
||||
{folders.map((folder) => {
|
||||
const status = statuses[folder.id] ?? "idle";
|
||||
const isProcessing =
|
||||
processingFolderIds.has(folder.id) ||
|
||||
status === "processing";
|
||||
return (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
status={status}
|
||||
isProcessing={isProcessing}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteTarget}
|
||||
onOpen={navigateToFolder}
|
||||
onDropFiles={processFiles}
|
||||
onDropSidebarFile={handleDropSidebarFile}
|
||||
onTogglePause={handleTogglePause}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="wf-new-tile"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="wf-new-tile-icon">
|
||||
<FolderPlusIcon style={{ fontSize: "1.5rem" }} />
|
||||
</span>
|
||||
<Text size="sm" fw={600}>
|
||||
{t(
|
||||
"watchedFolders.home.addAnother",
|
||||
"Add another Watched Folder",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"watchedFolders.home.addAnotherDesc",
|
||||
"Automatically process files with a new pipeline",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
<WatchedFolderManagementModal
|
||||
opened={createModalOpen}
|
||||
editFolder={editFolder}
|
||||
existingAutomation={editAutomation}
|
||||
onClose={handleModalClose}
|
||||
onSaved={refreshFolders}
|
||||
/>
|
||||
<DeleteFolderConfirmModal
|
||||
opened={!!deleteTarget}
|
||||
folder={deleteTarget}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Stack,
|
||||
Group,
|
||||
TextInput,
|
||||
ColorInput,
|
||||
NumberInput,
|
||||
Text,
|
||||
Alert,
|
||||
Switch,
|
||||
Select,
|
||||
Box,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Modal,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { AutomationConfig, AutomationMode } from "@app/types/automation";
|
||||
import { IconPicker as IconSelector } from "@app/components/watchedFolders/IconPicker";
|
||||
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
import { folderDirectoryHandleStorage } from "@app/services/folderDirectoryHandleStorage";
|
||||
import {
|
||||
canReadLocalFolder,
|
||||
canWriteLocalFolder,
|
||||
FS_WRITE_UNSUPPORTED_MSG,
|
||||
} from "@app/utils/fsAccessCapability";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import {
|
||||
Z_INDEX_AUTOMATE_MODAL,
|
||||
Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
} from "@app/styles/zIndex";
|
||||
|
||||
const ACCENT_SWATCHES = [
|
||||
"#3b82f6",
|
||||
"#0ea5e9",
|
||||
"#14b8a6",
|
||||
"#22c55e",
|
||||
"#f97316",
|
||||
"#ef4444",
|
||||
"#9333ea",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
"#eab308",
|
||||
"#64748b",
|
||||
"#0f172a",
|
||||
];
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
style={{
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--tool-subcategory-text-color)",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface WatchedFolderManagementModalProps {
|
||||
opened: boolean;
|
||||
editFolder?: WatchedFolder | null;
|
||||
existingAutomation?: AutomationConfig | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function WatchedFolderManagementModal({
|
||||
opened,
|
||||
editFolder,
|
||||
existingAutomation,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: WatchedFolderManagementModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const isEditMode = !!editFolder;
|
||||
|
||||
const [name, setName] = useState(editFolder?.name ?? "");
|
||||
const [icon, setIcon] = useState(editFolder?.icon ?? "FolderIcon");
|
||||
const [accentColor, setAccentColor] = useState(
|
||||
editFolder?.accentColor ?? "#3b82f6",
|
||||
);
|
||||
const [maxRetries, setMaxRetries] = useState<number>(
|
||||
editFolder?.maxRetries ?? 3,
|
||||
);
|
||||
const [retryDelayMinutes, setRetryDelayMinutes] = useState<number>(
|
||||
editFolder?.retryDelayMinutes ?? 5,
|
||||
);
|
||||
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
|
||||
editFolder?.outputMode ?? "new_file",
|
||||
);
|
||||
const [outputName, setOutputName] = useState(
|
||||
editFolder?.outputName ?? editFolder?.name ?? "",
|
||||
);
|
||||
const [outputNamePosition, setOutputNamePosition] = useState<
|
||||
"prefix" | "suffix" | "auto-number"
|
||||
>(editFolder?.outputNamePosition ?? "prefix");
|
||||
const [inputSource, setInputSource] = useState<
|
||||
NonNullable<WatchedFolder["inputSource"]>
|
||||
>(editFolder?.inputSource ?? "idb");
|
||||
const outputNameDirty = useRef(!!editFolder?.outputName);
|
||||
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);
|
||||
const [showAdvanced, setShowAdvanced] = useState(isEditMode);
|
||||
|
||||
const automationSaveTrigger = useRef<(() => void) | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setName(editFolder?.name ?? "");
|
||||
setIcon(editFolder?.icon ?? "FolderIcon");
|
||||
setAccentColor(editFolder?.accentColor ?? "#3b82f6");
|
||||
setMaxRetries(editFolder?.maxRetries ?? 3);
|
||||
setRetryDelayMinutes(editFolder?.retryDelayMinutes ?? 5);
|
||||
setOutputMode(editFolder?.outputMode ?? "new_file");
|
||||
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,
|
||||
);
|
||||
outputNameDirty.current = !!editFolder?.outputName;
|
||||
setShowAdvanced(!!editFolder);
|
||||
setNameError("");
|
||||
setAutomationError("");
|
||||
setSaveError(null);
|
||||
setSaving(false);
|
||||
}, [editFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
const handleClose = () => {
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAutomationComplete = useCallback(
|
||||
async (automation: AutomationConfig) => {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
try {
|
||||
const retryFields = { maxRetries, retryDelayMinutes };
|
||||
const hasOutputDirectory = outputDirName !== null;
|
||||
const folderData = {
|
||||
name: trimmedName,
|
||||
description: "",
|
||||
icon,
|
||||
accentColor,
|
||||
automationId: automation.id,
|
||||
...retryFields,
|
||||
outputMode:
|
||||
outputMode === "new_version" ? ("new_version" as const) : undefined,
|
||||
outputName: outputName.trim() || undefined,
|
||||
outputNamePosition:
|
||||
outputNamePosition !== "prefix" ? outputNamePosition : undefined,
|
||||
hasOutputDirectory,
|
||||
inputSource: inputSource !== "idb" ? inputSource : undefined,
|
||||
};
|
||||
|
||||
if (isEditMode && editFolder) {
|
||||
const wasLocalFolder = editFolder.inputSource === "local-folder";
|
||||
await watchedFolderStorage.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);
|
||||
}
|
||||
} else {
|
||||
const newFolder = await watchedFolderStorage.createFolder(folderData);
|
||||
if (pendingDirHandle.current) {
|
||||
await folderDirectoryHandleStorage.set(
|
||||
newFolder.id,
|
||||
pendingDirHandle.current,
|
||||
);
|
||||
}
|
||||
if (inputSource === "local-folder" && pendingInputDirHandle.current) {
|
||||
await folderDirectoryHandleStorage.setInput(
|
||||
newFolder.id,
|
||||
pendingInputDirHandle.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
resetState();
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save smart folder:", error);
|
||||
setSaveError(
|
||||
t(
|
||||
"watchedFolders.modal.saveFailed",
|
||||
"Failed to save folder. Please try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
name,
|
||||
icon,
|
||||
accentColor,
|
||||
outputMode,
|
||||
outputName,
|
||||
outputNamePosition,
|
||||
outputDirName,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
inputSource,
|
||||
isEditMode,
|
||||
editFolder,
|
||||
resetState,
|
||||
onSaved,
|
||||
onClose,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setNameError(
|
||||
t("watchedFolders.modal.nameRequired", "Folder name is required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (trimmedName.length > 50) {
|
||||
setNameError(
|
||||
t(
|
||||
"watchedFolders.modal.nameTooLong",
|
||||
"Folder name must be 50 characters or less",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAutomationError("");
|
||||
setSaving(true);
|
||||
automationSaveTrigger.current?.();
|
||||
};
|
||||
|
||||
const title = isEditMode
|
||||
? t("watchedFolders.modal.editTitle", "Edit Watched Folder")
|
||||
: t("watchedFolders.modal.createTitle", "New Watched Folder");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={title}
|
||||
size="min(90rem, 94vw)"
|
||||
centered
|
||||
zIndex={Z_INDEX_AUTOMATE_MODAL}
|
||||
styles={{
|
||||
content: {
|
||||
height: "min(90vh, 60rem)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Body: two-column layout */}
|
||||
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
||||
{/* ── Left panel: folder config ── */}
|
||||
<div
|
||||
style={{
|
||||
width: "28rem",
|
||||
flexShrink: 0,
|
||||
borderRight: "0.0625rem solid var(--border-subtle)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ flex: 1, overflowY: "auto", padding: "1.25rem 1.5rem" }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* ── Identity ── */}
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t("watchedFolders.modal.sectionFolder", "Folder")}
|
||||
</SectionLabel>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"watchedFolders.modal.namePlaceholder",
|
||||
"My Watched Folder",
|
||||
)}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
setName(val);
|
||||
setNameError("");
|
||||
if (!outputNameDirty.current) setOutputName(val);
|
||||
}}
|
||||
error={nameError}
|
||||
withAsterisk
|
||||
maxLength={50}
|
||||
style={{ flex: 1 }}
|
||||
size="sm"
|
||||
/>
|
||||
<IconSelector value={icon} onChange={setIcon} size="sm" />
|
||||
</Group>
|
||||
|
||||
<ColorInput
|
||||
label={t("watchedFolders.modal.color", "Accent colour")}
|
||||
value={accentColor}
|
||||
onChange={setAccentColor}
|
||||
format="hex"
|
||||
swatches={ACCENT_SWATCHES}
|
||||
size="sm"
|
||||
popoverProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* ── Source & Output ── */}
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t(
|
||||
"watchedFolders.modal.sectionSourceOutput",
|
||||
"Source & Output",
|
||||
)}
|
||||
</SectionLabel>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t(
|
||||
"watchedFolders.modal.inputSource",
|
||||
"Input source",
|
||||
)}
|
||||
value={inputSource}
|
||||
onChange={(v) =>
|
||||
v &&
|
||||
setInputSource(
|
||||
v as NonNullable<WatchedFolder["inputSource"]>,
|
||||
)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
value: "idb",
|
||||
label: t(
|
||||
"watchedFolders.modal.inputSourceBrowser",
|
||||
"Browser — drop files here",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "local-folder",
|
||||
label: canReadLocalFolder
|
||||
? t(
|
||||
"watchedFolders.modal.inputSourceLocal",
|
||||
"Local folder (auto-scan)",
|
||||
)
|
||||
: t(
|
||||
"watchedFolders.modal.inputSourceLocalUnsupported",
|
||||
"Local folder (auto-scan) — Chrome/Edge only",
|
||||
),
|
||||
disabled: !canReadLocalFolder,
|
||||
},
|
||||
]}
|
||||
size="sm"
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 ? "var(--mantine-color-green-filled)" : "var(--mantine-color-yellow-5)"}`,
|
||||
backgroundColor: inputDirName
|
||||
? "var(--mantine-color-green-light)"
|
||||
: "var(--mantine-color-yellow-light)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<FolderSpecialIcon
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: inputDirName
|
||||
? "var(--color-green-500)"
|
||||
: "var(--mantine-color-yellow-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t(
|
||||
"watchedFolders.modal.inputFolder",
|
||||
"Input folder",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{inputDirName ??
|
||||
t(
|
||||
"watchedFolders.modal.inputFolderNotChosen",
|
||||
"No folder chosen — required for auto-scan",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const handle = await (
|
||||
window as unknown as {
|
||||
showDirectoryPicker: (options?: {
|
||||
mode?: "read" | "readwrite";
|
||||
}) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
).showDirectoryPicker({ mode: "read" });
|
||||
pendingInputDirHandle.current = handle;
|
||||
setInputDirName(handle.name);
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{inputDirName
|
||||
? t("watchedFolders.modal.changeFolder", "Change")
|
||||
: t(
|
||||
"watchedFolders.modal.chooseFolder",
|
||||
"Choose",
|
||||
)}
|
||||
</Button>
|
||||
{inputDirName && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
pendingInputDirHandle.current = null;
|
||||
setInputDirName(null);
|
||||
}}
|
||||
>
|
||||
{t("watchedFolders.modal.clearFolder", "Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
{t(
|
||||
"watchedFolders.modal.autoScanHelp",
|
||||
"New PDF files in this folder are processed automatically every 10 seconds.",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Local output folder */}
|
||||
<Box
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `0.0625rem solid ${outputDirName ? "var(--mantine-color-green-filled)" : "var(--border-subtle)"}`,
|
||||
backgroundColor: outputDirName
|
||||
? "var(--mantine-color-green-light)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<FolderSpecialIcon
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: outputDirName
|
||||
? "var(--color-green-500)"
|
||||
: "var(--mantine-color-dimmed)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Stack gap={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t(
|
||||
"watchedFolders.modal.localOutputFolder",
|
||||
"Local output folder",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{!canWriteLocalFolder
|
||||
? t(
|
||||
"watchedFolders.modal.outputFolderUnsupported",
|
||||
"Not supported in this browser",
|
||||
)
|
||||
: (outputDirName ??
|
||||
t(
|
||||
"watchedFolders.modal.outputFolderNotSet",
|
||||
"Not set — outputs stay in app",
|
||||
))}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Tooltip
|
||||
label={FS_WRITE_UNSUPPORTED_MSG}
|
||||
disabled={canWriteLocalFolder}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={!canWriteLocalFolder}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const handle = await (
|
||||
window as unknown as {
|
||||
showDirectoryPicker: (options?: {
|
||||
mode?: "read" | "readwrite";
|
||||
}) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
).showDirectoryPicker({ mode: "readwrite" });
|
||||
pendingDirHandle.current = handle;
|
||||
setOutputDirName(handle.name);
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{outputDirName
|
||||
? t("watchedFolders.modal.changeFolder", "Change")
|
||||
: t("watchedFolders.modal.chooseFolder", "Choose")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{outputDirName && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
pendingDirHandle.current = null;
|
||||
setOutputDirName(null);
|
||||
}}
|
||||
>
|
||||
{t("watchedFolders.modal.clearFolder", "Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* ── 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",
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
transform: showAdvanced ? "rotate(90deg)" : "none",
|
||||
transition: "transform 160ms ease",
|
||||
}}
|
||||
/>
|
||||
{t("watchedFolders.modal.advanced", "Advanced")}
|
||||
</button>
|
||||
|
||||
<Collapse in={showAdvanced} transitionDuration={180}>
|
||||
<Stack gap="sm" mt="sm">
|
||||
{/* Replace original */}
|
||||
<Switch
|
||||
label={t(
|
||||
"watchedFolders.modal.replaceOriginal",
|
||||
"Replace original file",
|
||||
)}
|
||||
description={
|
||||
outputMode === "new_version"
|
||||
? t(
|
||||
"watchedFolders.modal.outputModeVersionDesc",
|
||||
"Output replaces input as a new version",
|
||||
)
|
||||
: t(
|
||||
"watchedFolders.modal.outputModeNewDesc",
|
||||
"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}>
|
||||
{t(
|
||||
"watchedFolders.modal.autoNumber",
|
||||
"Auto-number",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"watchedFolders.modal.autoNumberExample",
|
||||
"e.g. document.pdf → document (1).pdf",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<TextInput
|
||||
label={
|
||||
outputNamePosition === "suffix"
|
||||
? t(
|
||||
"watchedFolders.modal.outputNameSuffix",
|
||||
"Filename suffix",
|
||||
)
|
||||
: t(
|
||||
"watchedFolders.modal.outputNamePrefix",
|
||||
"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: t(
|
||||
"watchedFolders.modal.positionPrefix",
|
||||
"Prefix",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t(
|
||||
"watchedFolders.modal.positionSuffix",
|
||||
"Suffix",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "auto-number",
|
||||
label: t(
|
||||
"watchedFolders.modal.autoNumber",
|
||||
"Auto-number",
|
||||
),
|
||||
},
|
||||
]}
|
||||
style={{ width: "8rem", flexShrink: 0 }}
|
||||
mb={4}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_AUTOMATE_DROPDOWN,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Retry settings */}
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput
|
||||
label={t(
|
||||
"watchedFolders.modal.maxRetries",
|
||||
"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={t(
|
||||
"watchedFolders.modal.retryDelay",
|
||||
"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>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.5rem",
|
||||
borderTop: "0.0625rem solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{saveError && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
onClose={() => setSaveError(null)}
|
||||
withCloseButton
|
||||
mb="sm"
|
||||
>
|
||||
{saveError}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
{isEditMode
|
||||
? t("watchedFolders.modal.saveChanges", "Save changes")
|
||||
: t("watchedFolders.modal.createFolder", "Create folder")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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>
|
||||
{t("watchedFolders.modal.sectionSteps", "Steps")}
|
||||
</SectionLabel>
|
||||
{automationError && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
{automationError}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
padding: "0 1.5rem 1.5rem",
|
||||
}}
|
||||
>
|
||||
<AutomationCreation
|
||||
mode={isEditMode ? AutomationMode.EDIT : AutomationMode.CREATE}
|
||||
existingAutomation={existingAutomation ?? undefined}
|
||||
onBack={handleClose}
|
||||
onComplete={handleAutomationComplete}
|
||||
onSaveFailed={() => {
|
||||
setSaving(false);
|
||||
setAutomationError(
|
||||
t(
|
||||
"watchedFolders.modal.automationRequired",
|
||||
"Add at least one configured step before saving.",
|
||||
),
|
||||
);
|
||||
}}
|
||||
toolRegistry={toolRegistry}
|
||||
hideMetadata
|
||||
nameOverride={
|
||||
name.trim() ||
|
||||
t(
|
||||
"watchedFolders.modal.automationNameFallback",
|
||||
"Watched Folder Automation",
|
||||
)
|
||||
}
|
||||
saveTriggerRef={automationSaveTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Text, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useWatchedFolders } from "@app/hooks/useWatchedFolders";
|
||||
import { useFolderRunStatuses } from "@app/hooks/useFolderRunStatuses";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { WatchedFolderManagementModal } from "@app/components/watchedFolders/WatchedFolderManagementModal";
|
||||
import { DeleteFolderConfirmModal } from "@app/components/watchedFolders/DeleteFolderConfirmModal";
|
||||
import { WatchedFolderCard } from "@app/components/watchedFolders/WatchedFolderCard";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import { automationStorage } from "@app/services/automationStorage";
|
||||
import {
|
||||
WATCHED_FOLDER_VIEW_ID,
|
||||
WATCHED_FOLDER_WORKBENCH_ID,
|
||||
} from "@app/components/watchedFolders/WatchedFoldersRegistration";
|
||||
|
||||
export function WatchedFolderSection() {
|
||||
const { t } = useTranslation();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<WatchedFolder | null>(null);
|
||||
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<WatchedFolder | null>(null);
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||
|
||||
const { folders, loading, deleteFolder, refreshFolders } =
|
||||
useWatchedFolders();
|
||||
const statuses = useFolderRunStatuses(folders);
|
||||
|
||||
const { setCustomWorkbenchViewData } = useToolWorkflow();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
const handleFolderClick = (folderId: string) => {
|
||||
setActiveFolderId(folderId);
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
};
|
||||
|
||||
const handleEditFolder = async (
|
||||
e: React.MouseEvent,
|
||||
folder: WatchedFolder,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
setEditFolder(folder);
|
||||
const automation = await automationStorage.getAutomation(
|
||||
folder.automationId,
|
||||
);
|
||||
setEditAutomation(automation);
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, folder: WatchedFolder) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(folder);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (activeFolderId === deleteTarget.id) {
|
||||
setActiveFolderId(null);
|
||||
actions.setWorkbench("fileEditor");
|
||||
}
|
||||
await deleteFolder(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditFolder(null);
|
||||
setEditAutomation(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-subtle)",
|
||||
backgroundColor: "var(--bg-toolbar)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box px="sm" pt="xs" pb="2px" style={{ flexShrink: 0 }}>
|
||||
<Box className="tool-subcategory-row">
|
||||
<Text
|
||||
className="tool-subcategory-row-title"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
|
||||
folderId: null,
|
||||
});
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
}}
|
||||
>
|
||||
{t("watchedFolders.title", "Watched Folders")}
|
||||
</Text>
|
||||
<Box className="tool-subcategory-row-rule" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="tool-picker-scrollable" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Stack gap={0} pb="xs">
|
||||
{!loading && folders.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="sm" px="sm">
|
||||
{t("watchedFolders.noFolders", "No watch folders yet")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{folders.map((folder) => (
|
||||
<WatchedFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
isActive={activeFolderId === folder.id}
|
||||
status={statuses[folder.id] ?? "idle"}
|
||||
onSelect={() => handleFolderClick(folder.id)}
|
||||
onEdit={(e) => handleEditFolder(e, folder)}
|
||||
onDelete={(e) => handleDeleteClick(e, folder)}
|
||||
onFileDrop={(fileIds) => {
|
||||
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
|
||||
folderId: folder.id,
|
||||
pendingFileIds: fileIds,
|
||||
});
|
||||
actions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
setActiveFolderId(folder.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
className="tool-button"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
px="sm"
|
||||
leftSection={
|
||||
<AddIcon
|
||||
style={{ fontSize: 14, color: "var(--mantine-color-gray-5)" }}
|
||||
/>
|
||||
}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("watchedFolders.newFolder", "New folder")}
|
||||
</Text>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<WatchedFolderManagementModal
|
||||
opened={createModalOpen}
|
||||
editFolder={editFolder}
|
||||
existingAutomation={editAutomation}
|
||||
onClose={handleModalClose}
|
||||
onSaved={refreshFolders}
|
||||
/>
|
||||
<DeleteFolderConfirmModal
|
||||
opened={!!deleteTarget}
|
||||
folder={deleteTarget}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+2442
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
/* Watched Folders home-page card grid.
|
||||
*
|
||||
* Mirrors the files-page card vocabulary (see filesPage/FilesPage.css) but
|
||||
* with its own `.wf-*` namespace so the two surfaces can evolve
|
||||
* independently. Values are intentionally copied rather than shared. */
|
||||
|
||||
@keyframes wf-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wf-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.85rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.32, 0.72, 0.32, 1),
|
||||
border-color 0.14s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.wf-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 35%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px -8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.wf-card:focus-visible {
|
||||
outline: 2px solid var(--accent-interactive, #6366f1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wf-card.is-drop-target {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 10%,
|
||||
var(--bg-surface)
|
||||
);
|
||||
box-shadow: 0 0 0 2px var(--accent-interactive, #6366f1);
|
||||
border-color: var(--accent-interactive, #6366f1);
|
||||
}
|
||||
|
||||
/* Dragging a file the folder already contains — signal it's a no-op, not an add. */
|
||||
.wf-card.is-already-member {
|
||||
background: color-mix(in srgb, #f59e0b 8%, var(--bg-surface));
|
||||
box-shadow: 0 0 0 2px #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.wf-card-drag-message {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
|
||||
backdrop-filter: blur(1px);
|
||||
border-radius: inherit;
|
||||
/* Let drag/drop events pass through to the card underneath. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wf-card-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--text-muted) 5%, var(--bg-surface)),
|
||||
var(--bg-surface)
|
||||
);
|
||||
aspect-ratio: 1.6 / 1;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.wf-card-body {
|
||||
padding: 0.65rem 0.85rem 0.7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-card-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.wf-card-meta {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.wf-card-status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wf-card-status-dot.is-pulsing {
|
||||
animation: wf-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wf-card-actions {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.wf-card:hover .wf-card-actions,
|
||||
.wf-card:focus-within .wf-card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* "New folder" tile - a dashed call-to-action that sits in the grid
|
||||
alongside the real folder cards. */
|
||||
.wf-new-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
background: var(--bg-surface);
|
||||
border: 1.5px dashed var(--border-subtle);
|
||||
border-radius: 0.85rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
min-height: 9rem;
|
||||
transition:
|
||||
border-color 0.14s ease,
|
||||
color 0.14s ease,
|
||||
background-color 0.14s ease;
|
||||
}
|
||||
|
||||
.wf-new-tile:hover {
|
||||
border-color: var(--accent-interactive, #6366f1);
|
||||
color: var(--accent-interactive, #6366f1);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 6%,
|
||||
var(--bg-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.wf-new-tile-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--accent-interactive, #6366f1);
|
||||
}
|
||||
|
||||
.wf-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4rem 1.5rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wf-empty-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--accent-interactive, #6366f1);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.wf-empty-title {
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.wf-empty-hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 30rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wf-card,
|
||||
.wf-card:hover {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { WatchedFolderWorkbenchView } from "@app/components/watchedFolders/WatchedFolderWorkbenchView";
|
||||
import { seedDefaultFolders } from "@app/data/watchedFolderPresets";
|
||||
import { useWatchedFolderUrlSync } from "@app/hooks/useWatchedFolderUrlSync";
|
||||
|
||||
export const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
||||
export const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder" as const;
|
||||
|
||||
export default function WatchedFoldersRegistration() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
useWatchedFolderUrlSync();
|
||||
|
||||
// Keep refs to latest cleanup callbacks so the registration effect doesn't
|
||||
// re-run (and tear down) when unregisterCustomWorkbenchView changes identity
|
||||
// due to NavigationContext re-renders.
|
||||
const unregisterRef = useRef(unregisterCustomWorkbenchView);
|
||||
const clearRef = useRef(clearCustomWorkbenchViewData);
|
||||
useEffect(() => {
|
||||
unregisterRef.current = unregisterCustomWorkbenchView;
|
||||
});
|
||||
useEffect(() => {
|
||||
clearRef.current = clearCustomWorkbenchViewData;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
seedDefaultFolders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: WATCHED_FOLDER_VIEW_ID,
|
||||
workbenchId: WATCHED_FOLDER_WORKBENCH_ID,
|
||||
label: t("watchedFolders.sidebarTitle", "Watched Folders"),
|
||||
component: WatchedFolderWorkbenchView,
|
||||
// Show the standard workbench bar (view switcher) so users can navigate
|
||||
// back to Viewer / Active Files instead of being stranded in this view.
|
||||
hideTopControls: false,
|
||||
hideToolPanel: true,
|
||||
});
|
||||
return () => {
|
||||
clearRef.current(WATCHED_FOLDER_VIEW_ID);
|
||||
unregisterRef.current(WATCHED_FOLDER_VIEW_ID);
|
||||
};
|
||||
}, [registerCustomWorkbenchView]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Proprietary-build feature gates. Shadows `core/constants/featureFlags.ts` in
|
||||
* the proprietary build (resolved via the `@app/*` alias), so flags here only
|
||||
* affect proprietary/SaaS builds — the open-source core build keeps the core
|
||||
* values.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Watched Folders (a.k.a. Watched Folders) — a proprietary feature whose
|
||||
* implementation lives under `proprietary/`. Still disabled for now; flip to
|
||||
* `true` to surface it in the proprietary build only. The core override stays
|
||||
* `false`, so the shared sidebar entry point never appears in the open-source
|
||||
* build (which has no Watched Folders implementation to navigate to).
|
||||
*/
|
||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
import { useFolderData } from "@app/hooks/useFolderData";
|
||||
import { FolderFileMetadata, FolderRecord } from "@app/types/watchedFolders";
|
||||
|
||||
interface FolderFileContextValue {
|
||||
activeFolderId: string | null;
|
||||
setActiveFolderId: (id: string | null) => void;
|
||||
folderRecord: FolderRecord | null;
|
||||
fileIds: string[];
|
||||
pendingFileIds: string[];
|
||||
processingFileIds: string[];
|
||||
processedFileIds: string[];
|
||||
addFile: (
|
||||
fileId: string,
|
||||
metadata?: Partial<FolderFileMetadata>,
|
||||
) => Promise<void>;
|
||||
removeFile: (fileId: string) => Promise<void>;
|
||||
updateFileMetadata: (
|
||||
fileId: string,
|
||||
updates: Partial<FolderFileMetadata>,
|
||||
) => Promise<void>;
|
||||
clearFolder: () => Promise<void>;
|
||||
getFileMetadata: (fileId: string) => FolderFileMetadata | null;
|
||||
isFileProcessed: (fileId: string) => boolean;
|
||||
isFileProcessing: (fileId: string) => boolean;
|
||||
}
|
||||
|
||||
const FolderFileContext = createContext<FolderFileContextValue | null>(null);
|
||||
|
||||
interface FolderFileContextProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Inner component so useFolderData is only called once activeFolderId is set
|
||||
function FolderFileContextInner({
|
||||
activeFolderId,
|
||||
setActiveFolderId,
|
||||
children,
|
||||
}: {
|
||||
activeFolderId: string | null;
|
||||
setActiveFolderId: (id: string | null) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const folderData = useFolderData(activeFolderId ?? "");
|
||||
return (
|
||||
<FolderFileContext.Provider
|
||||
value={{ activeFolderId, setActiveFolderId, ...folderData }}
|
||||
>
|
||||
{children}
|
||||
</FolderFileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderFileContextProvider({
|
||||
children,
|
||||
}: FolderFileContextProviderProps) {
|
||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||
return (
|
||||
<FolderFileContextInner
|
||||
activeFolderId={activeFolderId}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
>
|
||||
{children}
|
||||
</FolderFileContextInner>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFolderFileContext(): FolderFileContextValue {
|
||||
const ctx = useContext(FolderFileContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useFolderFileContext must be used within FolderFileContextProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Default Watched Folder presets — seeded once on first run
|
||||
*/
|
||||
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { AutomationConfig } from "@app/types/automation";
|
||||
import { automationStorage } from "@app/services/automationStorage";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
|
||||
const SEEDED_FLAG = "watched_folders_seeded";
|
||||
let seedingInProgress = false;
|
||||
|
||||
interface PresetDefinition {
|
||||
folder: Omit<
|
||||
WatchedFolder,
|
||||
"id" | "automationId" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
|
||||
}
|
||||
|
||||
const PRESETS: PresetDefinition[] = [
|
||||
{
|
||||
folder: {
|
||||
name: "Secure Ingestion",
|
||||
description: "Sanitize and flatten all incoming PDFs",
|
||||
icon: "SecurityIcon",
|
||||
accentColor: "#9333ea",
|
||||
order: 0,
|
||||
isDefault: true,
|
||||
},
|
||||
automation: {
|
||||
name: "Secure Ingestion",
|
||||
description: "Sanitize then flatten PDF to remove hidden data",
|
||||
icon: "SecurityIcon",
|
||||
operations: [
|
||||
{ operation: "sanitize", parameters: {} },
|
||||
{ operation: "flatten", parameters: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
folder: {
|
||||
name: "Pre-publish",
|
||||
description: "Sanitize and compress before publishing",
|
||||
icon: "CloudIcon",
|
||||
accentColor: "#0ea5e9",
|
||||
order: 1,
|
||||
isDefault: true,
|
||||
},
|
||||
automation: {
|
||||
name: "Pre-publish",
|
||||
description: "Sanitize and compress PDF for publication",
|
||||
icon: "CloudIcon",
|
||||
operations: [
|
||||
{ operation: "sanitize", parameters: {} },
|
||||
{ operation: "compress", parameters: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
folder: {
|
||||
name: "Email Prep",
|
||||
description: "Compress then sanitize for email distribution",
|
||||
icon: "CompressIcon",
|
||||
accentColor: "#14b8a6",
|
||||
order: 2,
|
||||
isDefault: true,
|
||||
},
|
||||
automation: {
|
||||
name: "Email Prep",
|
||||
description: "Compress then sanitize PDF for email",
|
||||
icon: "CompressIcon",
|
||||
operations: [
|
||||
{ operation: "compress", parameters: {} },
|
||||
{ operation: "sanitize", parameters: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
folder: {
|
||||
name: "Rotate & Optimise",
|
||||
description: "Auto-rotate pages and compress",
|
||||
icon: "RotateRightIcon",
|
||||
accentColor: "#f97316",
|
||||
order: 3,
|
||||
isDefault: true,
|
||||
},
|
||||
automation: {
|
||||
name: "Rotate & Optimise",
|
||||
description: "Rotate pages and compress PDF",
|
||||
icon: "RotateRightIcon",
|
||||
operations: [
|
||||
{ operation: "rotate", parameters: { angle: 90 } },
|
||||
{ operation: "compress", parameters: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedDefaultFolders(): Promise<void> {
|
||||
if (localStorage.getItem(SEEDED_FLAG) || seedingInProgress) return;
|
||||
seedingInProgress = true;
|
||||
|
||||
try {
|
||||
for (const preset of PRESETS) {
|
||||
const savedAutomation = await automationStorage.saveAutomation(
|
||||
preset.automation,
|
||||
);
|
||||
await watchedFolderStorage.createFolder({
|
||||
...preset.folder,
|
||||
automationId: savedAutomation.id,
|
||||
});
|
||||
}
|
||||
localStorage.setItem(SEEDED_FLAG, "true");
|
||||
} catch (error) {
|
||||
console.error("Failed to seed default smart folders:", error);
|
||||
} finally {
|
||||
seedingInProgress = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Shared hook for running a file through a Watched Folder's automation pipeline.
|
||||
*
|
||||
* Local-only synchronous execution: each file is run through executeAutomationSequence
|
||||
* and the outputs are persisted to IndexedDB. Retries are scheduled in
|
||||
* folderRetryScheduleStorage and drained on mount / visibility / SW wake.
|
||||
*
|
||||
* Async server-side jobs and server watched folders are handled in a follow-up PR.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { automationStorage } from "@app/services/automationStorage";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { folderRunStateStorage } from "@app/services/folderRunStateStorage";
|
||||
import { folderRetryScheduleStorage } from "@app/services/folderRetryScheduleStorage";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
import { executeAutomationSequence } from "@app/utils/automationExecutor";
|
||||
import { folderDirectoryHandleStorage } from "@app/services/folderDirectoryHandleStorage";
|
||||
import {
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
createFileId,
|
||||
createStirlingFile,
|
||||
createQuickKey,
|
||||
isStirlingFile,
|
||||
} from "@app/types/fileContext";
|
||||
|
||||
/**
|
||||
* Resolves the storage ID for an input file.
|
||||
* - StirlingFiles (already in fileStorage): returns the existing fileId, ownedByFolder=false.
|
||||
* - Fresh disk drops: creates a new stub in fileStorage, returns new ID, ownedByFolder=true.
|
||||
*/
|
||||
export async function resolveInputFile(
|
||||
file: File,
|
||||
): Promise<{ inputFileId: string; ownedByFolder: boolean }> {
|
||||
if (isStirlingFile(file)) {
|
||||
return { inputFileId: file.fileId, ownedByFolder: false };
|
||||
}
|
||||
const newFileId = createFileId();
|
||||
const stub: StirlingFileStub = {
|
||||
id: newFileId,
|
||||
name: file.name,
|
||||
type: file.type || "application/pdf",
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
isLeaf: true,
|
||||
originalFileId: newFileId,
|
||||
versionNumber: 1,
|
||||
toolHistory: [],
|
||||
quickKey: createQuickKey(file),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await fileStorage.storeStirlingFile(
|
||||
createStirlingFile(file, newFileId),
|
||||
stub,
|
||||
);
|
||||
return { inputFileId: newFileId, ownedByFolder: true };
|
||||
}
|
||||
|
||||
/** Fire-and-forget: tell the service worker a new retry has been scheduled. */
|
||||
function notifySW(message: { type: string }): void {
|
||||
navigator.serviceWorker?.controller?.postMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores pipeline output files, updates folder metadata, and cleans up superseded outputs.
|
||||
*/
|
||||
async function finalizeRun(
|
||||
folder: WatchedFolder,
|
||||
file: File,
|
||||
inputFileId: string,
|
||||
ownedByFolder: boolean,
|
||||
resultFiles: File[],
|
||||
): Promise<void> {
|
||||
const currentFolderData = await watchedFolderFileStorage.getFolderData(
|
||||
folder.id,
|
||||
);
|
||||
const currentMeta = currentFolderData?.files[inputFileId];
|
||||
const prevOutputIds: string[] =
|
||||
currentMeta?.displayFileIds ??
|
||||
(currentMeta?.displayFileId ? [currentMeta.displayFileId] : []);
|
||||
|
||||
const inputStub = await fileStorage.getStirlingFileStub(
|
||||
inputFileId as FileId,
|
||||
);
|
||||
const isVersionMode = folder.outputMode === "new_version";
|
||||
const chainRoot = isVersionMode
|
||||
? (inputStub?.originalFileId ?? inputFileId)
|
||||
: inputFileId;
|
||||
const versionNum = isVersionMode ? (inputStub?.versionNumber ?? 1) + 1 : 2;
|
||||
|
||||
const inputName = inputStub?.name ?? file.name;
|
||||
const outputLabel = folder.outputName?.trim() || folder.name;
|
||||
const isAutoNumber =
|
||||
folder.outputNamePosition === "auto-number" && !isVersionMode;
|
||||
|
||||
// Collect taken names for auto-number deduplication
|
||||
const takenNames = new Set<string>();
|
||||
if (isAutoNumber) {
|
||||
for (const meta of Object.values(currentFolderData?.files ?? {})) {
|
||||
const ids =
|
||||
meta.displayFileIds ?? (meta.displayFileId ? [meta.displayFileId] : []);
|
||||
for (const oid of ids) {
|
||||
const stub = await fileStorage.getStirlingFileStub(oid as FileId);
|
||||
if (stub?.name) takenNames.add(stub.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allOutputIds: string[] = [];
|
||||
|
||||
for (const resultFile of resultFiles) {
|
||||
let outputFileName: string;
|
||||
if (isVersionMode) {
|
||||
outputFileName = inputName;
|
||||
} else if (isAutoNumber) {
|
||||
const lastDot = inputName.lastIndexOf(".");
|
||||
const nameBase = lastDot > 0 ? inputName.slice(0, lastDot) : inputName;
|
||||
const ext = lastDot > 0 ? inputName.slice(lastDot) : "";
|
||||
if (!takenNames.has(inputName)) {
|
||||
outputFileName = inputName;
|
||||
} else {
|
||||
let n = 1;
|
||||
while (takenNames.has(`${nameBase} (${n})${ext}`)) n++;
|
||||
outputFileName = `${nameBase} (${n})${ext}`;
|
||||
}
|
||||
takenNames.add(outputFileName);
|
||||
} else {
|
||||
outputFileName =
|
||||
folder.outputNamePosition === "suffix"
|
||||
? `${inputName}_${outputLabel}`
|
||||
: `${outputLabel}_${inputName}`;
|
||||
}
|
||||
|
||||
const outputId = createFileId();
|
||||
allOutputIds.push(outputId);
|
||||
const outputStub: StirlingFileStub = {
|
||||
id: outputId,
|
||||
name: outputFileName,
|
||||
type: resultFile.type || "application/pdf",
|
||||
size: resultFile.size,
|
||||
lastModified: resultFile.lastModified,
|
||||
isLeaf: true,
|
||||
originalFileId: chainRoot,
|
||||
versionNumber: versionNum,
|
||||
parentFileId: inputFileId as FileId,
|
||||
toolHistory: [],
|
||||
quickKey: createQuickKey(resultFile),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const renamedFile =
|
||||
outputFileName !== resultFile.name
|
||||
? new File([resultFile], outputFileName, {
|
||||
type: resultFile.type,
|
||||
lastModified: resultFile.lastModified,
|
||||
})
|
||||
: resultFile;
|
||||
await fileStorage.storeStirlingFile(
|
||||
createStirlingFile(renamedFile, outputId),
|
||||
outputStub,
|
||||
);
|
||||
|
||||
// Write to local FS output directory if configured
|
||||
if (folder.hasOutputDirectory) {
|
||||
try {
|
||||
const dirHandle = await folderDirectoryHandleStorage.get(folder.id);
|
||||
if (dirHandle) {
|
||||
const hasPermission =
|
||||
await folderDirectoryHandleStorage.ensurePermission(dirHandle);
|
||||
if (hasPermission) {
|
||||
await folderDirectoryHandleStorage.writeFile(
|
||||
dirHandle,
|
||||
outputFileName,
|
||||
renamedFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — FS write failure doesn't block the pipeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stale outputs from a previous run (skip in auto-number mode — outputs accumulate)
|
||||
if (!isAutoNumber) {
|
||||
for (const oldId of prevOutputIds) {
|
||||
try {
|
||||
await fileStorage.deleteStirlingFile(oldId as FileId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In version mode the input is always superseded; otherwise only hide it when the folder owns it.
|
||||
if (isVersionMode || ownedByFolder) {
|
||||
await fileStorage.markFileAsProcessed(inputFileId as FileId);
|
||||
}
|
||||
|
||||
const processedAt = new Date();
|
||||
const accumulatedIds = isAutoNumber
|
||||
? [...prevOutputIds, ...allOutputIds]
|
||||
: allOutputIds;
|
||||
await watchedFolderFileStorage.updateFileMetadata(folder.id, inputFileId, {
|
||||
status: "processed",
|
||||
processedAt,
|
||||
displayFileId: accumulatedIds[0],
|
||||
displayFileIds: accumulatedIds,
|
||||
});
|
||||
|
||||
await folderRunStateStorage.appendRunEntries(folder.id, [
|
||||
{
|
||||
inputFileId,
|
||||
displayFileId: accumulatedIds[0],
|
||||
displayFileIds: accumulatedIds,
|
||||
processedAt,
|
||||
status: "processed",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `runPipeline` function that executes a Watched Folder's automation
|
||||
* against a single input file synchronously, persisting outputs and updating
|
||||
* folder metadata.
|
||||
*/
|
||||
export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
|
||||
const processingRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const runPipeline = useCallback(
|
||||
async (
|
||||
folder: WatchedFolder,
|
||||
file: File,
|
||||
inputFileId: string,
|
||||
ownedByFolder: boolean,
|
||||
): Promise<void> => {
|
||||
if (processingRef.current.has(inputFileId)) return;
|
||||
processingRef.current.add(inputFileId);
|
||||
|
||||
try {
|
||||
const automation = await automationStorage.getAutomation(
|
||||
folder.automationId,
|
||||
);
|
||||
if (!automation) {
|
||||
await watchedFolderFileStorage.updateFileMetadata(
|
||||
folder.id,
|
||||
inputFileId,
|
||||
{
|
||||
status: "error",
|
||||
errorMessage: "Automation not found",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await watchedFolderFileStorage.updateFileMetadata(
|
||||
folder.id,
|
||||
inputFileId,
|
||||
{
|
||||
status: "processing",
|
||||
},
|
||||
);
|
||||
|
||||
const resultFiles = await executeAutomationSequence(
|
||||
automation,
|
||||
[file],
|
||||
toolRegistry as ToolRegistry,
|
||||
);
|
||||
await finalizeRun(
|
||||
folder,
|
||||
file,
|
||||
inputFileId,
|
||||
ownedByFolder,
|
||||
resultFiles,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const existing = await watchedFolderFileStorage.getFolderData(
|
||||
folder.id,
|
||||
);
|
||||
const prev = existing?.files[inputFileId];
|
||||
const attempts = (prev?.failedAttempts ?? 0) + 1;
|
||||
const maxRetries = folder.maxRetries ?? 3;
|
||||
const retryDelayMs = (folder.retryDelayMinutes ?? 5) * 60_000;
|
||||
const willRetry =
|
||||
maxRetries > 0 && attempts < maxRetries && retryDelayMs > 0;
|
||||
const nextRetryAt = willRetry ? Date.now() + retryDelayMs : undefined;
|
||||
|
||||
await watchedFolderFileStorage.updateFileMetadata(
|
||||
folder.id,
|
||||
inputFileId,
|
||||
{
|
||||
status: "error",
|
||||
errorMessage: err instanceof Error ? err.message : "Unknown error",
|
||||
failedAttempts: attempts,
|
||||
nextRetryAt,
|
||||
lastFailedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
if (willRetry) {
|
||||
await folderRetryScheduleStorage.schedule(
|
||||
folder.id,
|
||||
inputFileId,
|
||||
Date.now() + retryDelayMs,
|
||||
attempts,
|
||||
prev?.ownedByFolder ?? ownedByFolder,
|
||||
);
|
||||
notifySW({ type: "SCHEDULE_RETRY" });
|
||||
}
|
||||
} finally {
|
||||
// Safety net: if still 'processing', something went wrong
|
||||
try {
|
||||
const record = await watchedFolderFileStorage.getFolderData(
|
||||
folder.id,
|
||||
);
|
||||
const fileMeta = record?.files[inputFileId];
|
||||
if (fileMeta?.status === "processing") {
|
||||
await watchedFolderFileStorage.updateFileMetadata(
|
||||
folder.id,
|
||||
inputFileId,
|
||||
{
|
||||
status: "error",
|
||||
errorMessage: "Processing failed unexpectedly",
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
processingRef.current.delete(inputFileId);
|
||||
}
|
||||
},
|
||||
[toolRegistry],
|
||||
);
|
||||
|
||||
// Drain due retries on mount, on SW notification, and on visibility restore.
|
||||
useEffect(() => {
|
||||
async function drainDueRetries() {
|
||||
const due = await folderRetryScheduleStorage.claimDue();
|
||||
for (const entry of due) {
|
||||
const freshFolder = await watchedFolderStorage.getFolder(
|
||||
entry.folderId,
|
||||
);
|
||||
if (!freshFolder || freshFolder.isPaused) continue;
|
||||
const freshFile = await fileStorage.getStirlingFile(
|
||||
entry.fileId as FileId,
|
||||
);
|
||||
if (!freshFile) continue;
|
||||
await watchedFolderFileStorage.updateFileMetadata(
|
||||
entry.folderId,
|
||||
entry.fileId,
|
||||
{
|
||||
status: "pending",
|
||||
nextRetryAt: undefined,
|
||||
},
|
||||
);
|
||||
void runPipeline(
|
||||
freshFolder,
|
||||
freshFile,
|
||||
entry.fileId,
|
||||
entry.ownedByFolder,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void drainDueRetries();
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register("/sw-folder-retry.js", { scope: "/" })
|
||||
.catch((err) =>
|
||||
console.warn("Watched Folder retry SW registration failed:", err),
|
||||
);
|
||||
}
|
||||
|
||||
function handleSWMessage(event: MessageEvent) {
|
||||
if (event.data?.type === "PROCESS_DUE_RETRIES") void drainDueRetries();
|
||||
}
|
||||
navigator.serviceWorker?.addEventListener("message", handleSWMessage);
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
void drainDueRetries();
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener("message", handleSWMessage);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [runPipeline]);
|
||||
|
||||
/** Run multiple files through the pipeline concurrently. */
|
||||
const processBatch = useCallback(
|
||||
(
|
||||
folder: WatchedFolder,
|
||||
items: Array<{
|
||||
file: File;
|
||||
inputFileId: string;
|
||||
ownedByFolder: boolean;
|
||||
}>,
|
||||
) =>
|
||||
Promise.all(
|
||||
items.map(({ file, inputFileId, ownedByFolder }) =>
|
||||
runPipeline(folder, file, inputFileId, ownedByFolder),
|
||||
),
|
||||
),
|
||||
[runPipeline],
|
||||
);
|
||||
|
||||
return { runPipeline, processBatch };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Hook for reading and managing files within a Watched Folder
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { FolderFileMetadata, FolderRecord } from "@app/types/watchedFolders";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
|
||||
interface UseFolderDataReturn {
|
||||
folderRecord: FolderRecord | null;
|
||||
fileIds: string[];
|
||||
processingFileIds: string[];
|
||||
processedFileIds: string[];
|
||||
pendingFileIds: string[];
|
||||
addFile: (
|
||||
fileId: string,
|
||||
metadata?: Partial<FolderFileMetadata>,
|
||||
) => Promise<void>;
|
||||
removeFile: (fileId: string) => Promise<void>;
|
||||
updateFileMetadata: (
|
||||
fileId: string,
|
||||
updates: Partial<FolderFileMetadata>,
|
||||
) => Promise<void>;
|
||||
clearFolder: () => Promise<void>;
|
||||
getFileMetadata: (fileId: string) => FolderFileMetadata | null;
|
||||
isFileProcessed: (fileId: string) => boolean;
|
||||
isFileProcessing: (fileId: string) => boolean;
|
||||
}
|
||||
|
||||
export function useFolderData(folderId: string): UseFolderDataReturn {
|
||||
const [folderRecord, setFolderRecord] = useState<FolderRecord | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!folderId) return;
|
||||
try {
|
||||
const record = await watchedFolderFileStorage.getFolderData(folderId);
|
||||
setFolderRecord(record);
|
||||
} catch (error) {
|
||||
console.error("Failed to load folder data:", error);
|
||||
}
|
||||
}, [folderId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = watchedFolderFileStorage.onFolderChange(
|
||||
(changedFolderId) => {
|
||||
if (changedFolderId === folderId) {
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
);
|
||||
return unsubscribe;
|
||||
}, [folderId, refresh]);
|
||||
|
||||
const files = folderRecord?.files ?? {};
|
||||
const fileIds = useMemo(() => Object.keys(files), [files]);
|
||||
const processingFileIds = useMemo(
|
||||
() => fileIds.filter((id) => files[id]?.status === "processing"),
|
||||
[fileIds, files],
|
||||
);
|
||||
const processedFileIds = useMemo(
|
||||
() => fileIds.filter((id) => files[id]?.status === "processed"),
|
||||
[fileIds, files],
|
||||
);
|
||||
const pendingFileIds = useMemo(
|
||||
() => fileIds.filter((id) => files[id]?.status === "pending"),
|
||||
[fileIds, files],
|
||||
);
|
||||
|
||||
const addFile = useCallback(
|
||||
async (fileId: string, metadata?: Partial<FolderFileMetadata>) => {
|
||||
await watchedFolderFileStorage.addFileToFolder(
|
||||
folderId,
|
||||
fileId,
|
||||
metadata,
|
||||
);
|
||||
},
|
||||
[folderId],
|
||||
);
|
||||
|
||||
const removeFile = useCallback(
|
||||
async (fileId: string) => {
|
||||
await watchedFolderFileStorage.removeFileFromFolder(folderId, fileId);
|
||||
},
|
||||
[folderId],
|
||||
);
|
||||
|
||||
const updateFileMetadata = useCallback(
|
||||
async (fileId: string, updates: Partial<FolderFileMetadata>) => {
|
||||
await watchedFolderFileStorage.updateFileMetadata(
|
||||
folderId,
|
||||
fileId,
|
||||
updates,
|
||||
);
|
||||
},
|
||||
[folderId],
|
||||
);
|
||||
|
||||
const clearFolder = useCallback(async () => {
|
||||
await watchedFolderFileStorage.clearFolder(folderId);
|
||||
}, [folderId]);
|
||||
|
||||
const getFileMetadata = useCallback(
|
||||
(fileId: string): FolderFileMetadata | null => {
|
||||
return folderRecord?.files[fileId] ?? null;
|
||||
},
|
||||
[folderRecord],
|
||||
);
|
||||
|
||||
const isFileProcessed = useCallback(
|
||||
(fileId: string) => folderRecord?.files[fileId]?.status === "processed",
|
||||
[folderRecord],
|
||||
);
|
||||
|
||||
const isFileProcessing = useCallback(
|
||||
(fileId: string) => folderRecord?.files[fileId]?.status === "processing",
|
||||
[folderRecord],
|
||||
);
|
||||
|
||||
return {
|
||||
folderRecord,
|
||||
fileIds,
|
||||
processingFileIds,
|
||||
processedFileIds,
|
||||
pendingFileIds,
|
||||
addFile,
|
||||
removeFile,
|
||||
updateFileMetadata,
|
||||
clearFolder,
|
||||
getFileMetadata,
|
||||
isFileProcessed,
|
||||
isFileProcessing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Returns the set of file IDs that are outputs produced by any watched folder automation.
|
||||
* Reactive: re-evaluates whenever folder records change.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
|
||||
export function useFolderOutputIds(): Set<string> {
|
||||
const folders = useAllWatchedFolders();
|
||||
const [outputIds, setOutputIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (folders.length === 0) {
|
||||
setOutputIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
const ids = new Set<string>();
|
||||
for (const folder of folders) {
|
||||
try {
|
||||
const record = await watchedFolderFileStorage.getFolderData(
|
||||
folder.id,
|
||||
);
|
||||
if (record) {
|
||||
Object.values(record.files).forEach((meta) => {
|
||||
const oids =
|
||||
meta?.displayFileIds ??
|
||||
(meta?.displayFileId ? [meta.displayFileId] : []);
|
||||
oids.forEach((id) => ids.add(id));
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore individual folder failures */
|
||||
}
|
||||
}
|
||||
setOutputIds(ids);
|
||||
};
|
||||
|
||||
load();
|
||||
return watchedFolderFileStorage.onFolderChange(load);
|
||||
}, [folders]);
|
||||
|
||||
return outputIds;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Hook for managing Watched Folder run state (recent run entries)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { WatchedFolderRunEntry } from "@app/types/watchedFolders";
|
||||
import { folderRunStateStorage } from "@app/services/folderRunStateStorage";
|
||||
|
||||
interface UseFolderRunStateReturn {
|
||||
recentRuns: WatchedFolderRunEntry[];
|
||||
setRecentRuns: (runs: WatchedFolderRunEntry[]) => Promise<void>;
|
||||
clearRecentRuns: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useFolderRunState(folderId: string): UseFolderRunStateReturn {
|
||||
const [recentRuns, setRecentRunsState] = useState<WatchedFolderRunEntry[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Subscribe before loading to close the race window between load completing
|
||||
// and the listener being established (writes in that gap would be missed).
|
||||
useEffect(() => {
|
||||
if (!folderId) return;
|
||||
setIsLoading(true);
|
||||
const unsub = folderRunStateStorage.onRunStateChange((changedFolderId) => {
|
||||
if (changedFolderId !== folderId) return;
|
||||
folderRunStateStorage
|
||||
.getFolderRunState(folderId)
|
||||
.then(setRecentRunsState)
|
||||
.catch((err) =>
|
||||
console.error("Failed to reload folder run state:", err),
|
||||
);
|
||||
});
|
||||
folderRunStateStorage
|
||||
.getFolderRunState(folderId)
|
||||
.then(setRecentRunsState)
|
||||
.catch((err) => console.error("Failed to load folder run state:", err))
|
||||
.finally(() => setIsLoading(false));
|
||||
return unsub;
|
||||
}, [folderId]);
|
||||
|
||||
const setRecentRuns = useCallback(
|
||||
async (runs: WatchedFolderRunEntry[]) => {
|
||||
await folderRunStateStorage.setFolderRunState(folderId, runs);
|
||||
setRecentRunsState(runs);
|
||||
},
|
||||
[folderId],
|
||||
);
|
||||
|
||||
const clearRecentRuns = useCallback(async () => {
|
||||
await folderRunStateStorage.clearFolderRunState(folderId);
|
||||
setRecentRunsState([]);
|
||||
}, [folderId]);
|
||||
|
||||
return { recentRuns, setRecentRuns, clearRecentRuns, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Hook that derives per-folder run status from run state entries
|
||||
* 'done' automatically reverts to 'idle' after 5 minutes
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
WatchedFolder,
|
||||
WatchedFolderRunEntry,
|
||||
} from "@app/types/watchedFolders";
|
||||
import { folderRunStateStorage } from "@app/services/folderRunStateStorage";
|
||||
|
||||
export type FolderRunStatus = "idle" | "processing" | "done";
|
||||
|
||||
const DONE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function deriveStatus(runs: WatchedFolderRunEntry[]): FolderRunStatus {
|
||||
if (runs.some((r) => r.status === "processing")) return "processing";
|
||||
// Only treat recent runs (within TTL) as 'done' — avoids permanent green tick on old folders
|
||||
if (
|
||||
runs.some(
|
||||
(r) =>
|
||||
r.status === "processed" &&
|
||||
r.processedAt != null &&
|
||||
Date.now() - r.processedAt.getTime() < DONE_TTL_MS,
|
||||
)
|
||||
)
|
||||
return "done";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function useFolderRunStatuses(
|
||||
folders: WatchedFolder[],
|
||||
): Record<string, FolderRunStatus> {
|
||||
const [statuses, setStatuses] = useState<Record<string, FolderRunStatus>>({});
|
||||
const doneTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const foldersRef = useRef(folders);
|
||||
foldersRef.current = folders;
|
||||
|
||||
useEffect(() => {
|
||||
if (folders.length === 0) return;
|
||||
|
||||
const load = async () => {
|
||||
const results = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
try {
|
||||
const runs = await folderRunStateStorage.getFolderRunState(
|
||||
folder.id,
|
||||
);
|
||||
return [folder.id, deriveStatus(runs)] as const;
|
||||
} catch {
|
||||
return [folder.id, "idle" as FolderRunStatus] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const newStatuses: Record<string, FolderRunStatus> = {};
|
||||
for (const [id, status] of results) {
|
||||
newStatuses[id] = status;
|
||||
}
|
||||
setStatuses(newStatuses);
|
||||
};
|
||||
|
||||
load();
|
||||
}, [folders]);
|
||||
|
||||
useEffect(() => {
|
||||
return folderRunStateStorage.onRunStateChange((changedFolderId) => {
|
||||
if (!foldersRef.current.find((f) => f.id === changedFolderId)) return;
|
||||
folderRunStateStorage
|
||||
.getFolderRunState(changedFolderId)
|
||||
.then((runs) => {
|
||||
setStatuses((prev) => ({
|
||||
...prev,
|
||||
[changedFolderId]: deriveStatus(runs),
|
||||
}));
|
||||
})
|
||||
.catch((err) => console.error("Failed to update run status:", err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = doneTimersRef.current;
|
||||
Object.entries(statuses).forEach(([folderId, status]) => {
|
||||
if (status === "done" && !timers.has(folderId)) {
|
||||
const timer = setTimeout(() => {
|
||||
setStatuses((prev) => ({ ...prev, [folderId]: "idle" }));
|
||||
timers.delete(folderId);
|
||||
}, DONE_TTL_MS);
|
||||
timers.set(folderId, timer);
|
||||
} else if (status !== "done" && timers.has(folderId)) {
|
||||
clearTimeout(timers.get(folderId));
|
||||
timers.delete(folderId);
|
||||
}
|
||||
});
|
||||
}, [statuses]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = doneTimersRef.current;
|
||||
return () => {
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return statuses;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Polls local input directories for Watched Folders with inputSource === 'local-folder'.
|
||||
* On each cycle it scans the chosen directory, skips already-seen files,
|
||||
* registers new ones in watchedFolderFileStorage, 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 { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
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: WatchedFolder,
|
||||
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: WatchedFolder[];
|
||||
try {
|
||||
folders = await watchedFolderStorage.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 watchedFolderFileStorage.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 unknown as {
|
||||
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
|
||||
}
|
||||
).entries()) {
|
||||
if (cancelled) return;
|
||||
if (entry.kind !== "file") continue;
|
||||
|
||||
let file: File;
|
||||
try {
|
||||
file = await (entry as FileSystemFileHandle).getFile();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".pdf")) continue;
|
||||
|
||||
if (processingNames.has(file.name)) continue;
|
||||
|
||||
const seenKey = makeSeenKey(folder.id, file);
|
||||
const alreadySeen = await folderSeenFilesStorage.isSeen(seenKey);
|
||||
if (alreadySeen) continue;
|
||||
|
||||
await folderSeenFilesStorage.markSeen(seenKey);
|
||||
|
||||
const { inputFileId, ownedByFolder } = await resolveInputFile(file);
|
||||
|
||||
await watchedFolderFileStorage.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
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* URL synchronization for Watched Folders workbench.
|
||||
* Manages /watch-folders and /watch-folders/:slug routes
|
||||
* where :slug is derived from the folder name (e.g. "My Invoices" → "my-invoices").
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
|
||||
// Inlined to avoid circular imports — must match WatchedFoldersRegistration.tsx
|
||||
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
||||
const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder";
|
||||
|
||||
const WATCHED_FOLDERS_BASE = "/watch-folders";
|
||||
|
||||
export function slugifyFolderName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "folder"
|
||||
);
|
||||
}
|
||||
|
||||
function parseWatchedFolderRoute(): {
|
||||
isWatchedFolder: boolean;
|
||||
slug: string | null;
|
||||
} {
|
||||
const fullPath = window.location.pathname;
|
||||
const path =
|
||||
BASE_PATH && fullPath.startsWith(BASE_PATH)
|
||||
? fullPath.slice(BASE_PATH.length) || "/"
|
||||
: fullPath;
|
||||
|
||||
if (path === WATCHED_FOLDERS_BASE || path === WATCHED_FOLDERS_BASE + "/") {
|
||||
return { isWatchedFolder: true, slug: null };
|
||||
}
|
||||
if (path.startsWith(WATCHED_FOLDERS_BASE + "/")) {
|
||||
const slug = path.slice(WATCHED_FOLDERS_BASE.length + 1);
|
||||
return { isWatchedFolder: true, slug: slug || null };
|
||||
}
|
||||
return { isWatchedFolder: false, slug: null };
|
||||
}
|
||||
|
||||
function isWatchedFolderUrl(): boolean {
|
||||
const fullPath = window.location.pathname;
|
||||
const path =
|
||||
BASE_PATH && fullPath.startsWith(BASE_PATH)
|
||||
? fullPath.slice(BASE_PATH.length) || "/"
|
||||
: fullPath;
|
||||
return (
|
||||
path === WATCHED_FOLDERS_BASE || path.startsWith(WATCHED_FOLDERS_BASE + "/")
|
||||
);
|
||||
}
|
||||
|
||||
export function useWatchedFolderUrlSync() {
|
||||
const folders = useAllWatchedFolders();
|
||||
const navigationState = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
const { setCustomWorkbenchViewData, customWorkbenchViews } =
|
||||
useToolWorkflow();
|
||||
|
||||
const isWatchedFolderWorkbench =
|
||||
navigationState.workbench === WATCHED_FOLDER_WORKBENCH_ID;
|
||||
|
||||
const viewData = customWorkbenchViews.find(
|
||||
(v) => v.id === WATCHED_FOLDER_VIEW_ID,
|
||||
)?.data as { folderId: string | null } | null | undefined;
|
||||
const folderId = viewData?.folderId ?? null;
|
||||
|
||||
const { slugToId, idToSlug } = useMemo(() => {
|
||||
const s2i = new Map<string, string>();
|
||||
const i2s = new Map<string, string>();
|
||||
for (const f of folders) {
|
||||
const slug = slugifyFolderName(f.name);
|
||||
if (!s2i.has(slug)) s2i.set(slug, f.id);
|
||||
i2s.set(f.id, slug);
|
||||
}
|
||||
return { slugToId: s2i, idToSlug: i2s };
|
||||
}, [folders]);
|
||||
|
||||
const setDataRef = useRef(setCustomWorkbenchViewData);
|
||||
const actionsRef = useRef(actions);
|
||||
const slugToIdRef = useRef(slugToId);
|
||||
const foldersRef = useRef(folders);
|
||||
useEffect(() => {
|
||||
setDataRef.current = setCustomWorkbenchViewData;
|
||||
});
|
||||
useEffect(() => {
|
||||
actionsRef.current = actions;
|
||||
});
|
||||
useEffect(() => {
|
||||
slugToIdRef.current = slugToId;
|
||||
});
|
||||
useEffect(() => {
|
||||
foldersRef.current = folders;
|
||||
});
|
||||
|
||||
const mountSlugRef = useRef<string | null | "none">("none");
|
||||
const hasMountNavigated = useRef(false);
|
||||
const pendingSlugRef = useRef<string | null>(null);
|
||||
|
||||
// Phase 1a: capture URL slug on mount
|
||||
useEffect(() => {
|
||||
const { isWatchedFolder, slug } = parseWatchedFolderRoute();
|
||||
if (isWatchedFolder) {
|
||||
mountSlugRef.current = slug;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Phase 1b: navigate once the view is registered
|
||||
useEffect(() => {
|
||||
if (hasMountNavigated.current) return;
|
||||
if (mountSlugRef.current === "none") return;
|
||||
|
||||
const isRegistered = customWorkbenchViews.some(
|
||||
(v) => v.id === WATCHED_FOLDER_VIEW_ID,
|
||||
);
|
||||
if (!isRegistered) return;
|
||||
|
||||
hasMountNavigated.current = true;
|
||||
const slug = mountSlugRef.current;
|
||||
|
||||
if (!slug) {
|
||||
setDataRef.current(WATCHED_FOLDER_VIEW_ID, { folderId: null });
|
||||
actionsRef.current.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
setDataRef.current(WATCHED_FOLDER_VIEW_ID, { folderId: null });
|
||||
actionsRef.current.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
|
||||
if (foldersRef.current.length > 0) {
|
||||
const id = slugToIdRef.current.get(slug) ?? null;
|
||||
setDataRef.current(WATCHED_FOLDER_VIEW_ID, { folderId: id });
|
||||
} else {
|
||||
pendingSlugRef.current = slug;
|
||||
}
|
||||
}, [customWorkbenchViews]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingSlugRef.current || folders.length === 0) return;
|
||||
const slug = pendingSlugRef.current;
|
||||
pendingSlugRef.current = null;
|
||||
const id = slugToId.get(slug) ?? null;
|
||||
setDataRef.current(WATCHED_FOLDER_VIEW_ID, { folderId: id });
|
||||
}, [folders, slugToId]);
|
||||
|
||||
// Phase 2: State → URL
|
||||
const prevIsWatchedFolder = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isWatchedFolderWorkbench) {
|
||||
const slug = folderId ? (idToSlug.get(folderId) ?? null) : null;
|
||||
const targetPath = slug
|
||||
? withBasePath(`${WATCHED_FOLDERS_BASE}/${slug}`)
|
||||
: withBasePath(WATCHED_FOLDERS_BASE);
|
||||
if (window.location.pathname !== targetPath) {
|
||||
window.history.pushState(null, "", targetPath);
|
||||
}
|
||||
} else if (prevIsWatchedFolder.current && isWatchedFolderUrl()) {
|
||||
window.history.pushState(null, "", withBasePath("/"));
|
||||
}
|
||||
prevIsWatchedFolder.current = isWatchedFolderWorkbench;
|
||||
}, [isWatchedFolderWorkbench, folderId, idToSlug]);
|
||||
|
||||
// Phase 3: popstate → State
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const { isWatchedFolder, slug } = parseWatchedFolderRoute();
|
||||
if (!isWatchedFolder) return;
|
||||
|
||||
if (!slug) {
|
||||
setDataRef.current(WATCHED_FOLDER_VIEW_ID, { folderId: null });
|
||||
actionsRef.current.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
setDataRef.current(WATCHED_FOLDER_VIEW_ID, { folderId: null });
|
||||
actionsRef.current.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
|
||||
|
||||
if (foldersRef.current.length > 0) {
|
||||
const id = slugToIdRef.current.get(slug) ?? null;
|
||||
setDataRef.current(WATCHED_FOLDER_VIEW_ID, { folderId: id });
|
||||
} else {
|
||||
pendingSlugRef.current = slug;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Hook for managing Watched Folders — load, create, update, delete
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import {
|
||||
watchedFolderStorage,
|
||||
WATCHED_FOLDER_STORAGE_CHANGE_EVENT,
|
||||
} from "@app/services/watchedFolderStorage";
|
||||
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
|
||||
import { folderRunStateStorage } from "@app/services/folderRunStateStorage";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { folderRetryScheduleStorage } from "@app/services/folderRetryScheduleStorage";
|
||||
import { folderSeenFilesStorage } from "@app/services/folderSeenFilesStorage";
|
||||
import { folderDirectoryHandleStorage } from "@app/services/folderDirectoryHandleStorage";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
|
||||
interface UseWatchedFoldersReturn {
|
||||
folders: WatchedFolder[];
|
||||
loading: boolean;
|
||||
createFolder: (
|
||||
data: Omit<WatchedFolder, "id" | "createdAt" | "updatedAt">,
|
||||
) => Promise<WatchedFolder>;
|
||||
updateFolder: (folder: WatchedFolder) => Promise<WatchedFolder>;
|
||||
deleteFolder: (id: string) => Promise<void>;
|
||||
refreshFolders: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWatchedFolders(): UseWatchedFoldersReturn {
|
||||
const [folders, setFolders] = useState<WatchedFolder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refreshFolders = useCallback(async () => {
|
||||
try {
|
||||
const all = await watchedFolderStorage.getAllFolders();
|
||||
setFolders(all);
|
||||
} catch (error) {
|
||||
console.error("Failed to load smart folders:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshFolders();
|
||||
}, [refreshFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
refreshFolders();
|
||||
};
|
||||
window.addEventListener(WATCHED_FOLDER_STORAGE_CHANGE_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(WATCHED_FOLDER_STORAGE_CHANGE_EVENT, handler);
|
||||
}, [refreshFolders]);
|
||||
|
||||
const createFolder = useCallback(
|
||||
async (
|
||||
data: Omit<WatchedFolder, "id" | "createdAt" | "updatedAt">,
|
||||
): Promise<WatchedFolder> => {
|
||||
return watchedFolderStorage.createFolder(data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateFolder = useCallback(
|
||||
async (folder: WatchedFolder): Promise<WatchedFolder> => {
|
||||
return watchedFolderStorage.updateFolder(folder);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteFolder = useCallback(async (id: string): Promise<void> => {
|
||||
const record = await watchedFolderFileStorage.getFolderData(id);
|
||||
if (record) {
|
||||
// Only delete input files the folder created from disk — never touch sidebar-sourced files.
|
||||
const ownedInputIds = Object.entries(record.files)
|
||||
.filter(([, meta]) => meta.ownedByFolder === true)
|
||||
.map(([fid]) => fid);
|
||||
// Always delete every output the folder produced (folder always owns those).
|
||||
const outputIds = Object.values(record.files).flatMap(
|
||||
(meta) =>
|
||||
meta.displayFileIds ??
|
||||
(meta.displayFileId ? [meta.displayFileId] : []),
|
||||
);
|
||||
const toDelete = [...new Set([...ownedInputIds, ...outputIds])];
|
||||
await Promise.all(
|
||||
toDelete.map((fid) =>
|
||||
fileStorage.deleteStirlingFile(fid as FileId).catch(() => {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
await watchedFolderFileStorage.clearFolder(id);
|
||||
await folderRunStateStorage.clearFolderRunState(id);
|
||||
await folderRetryScheduleStorage.clearFolder(id).catch(() => {});
|
||||
await folderSeenFilesStorage.clearFolder(id).catch(() => {});
|
||||
await folderDirectoryHandleStorage.remove(id).catch(() => {});
|
||||
await folderDirectoryHandleStorage.removeInput(id).catch(() => {});
|
||||
await watchedFolderStorage.deleteFolder(id);
|
||||
window.dispatchEvent(new CustomEvent("stirling:files-changed"));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
folders,
|
||||
loading,
|
||||
createFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
refreshFolders,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Stores FileSystemDirectoryHandle instances per folder in IndexedDB.
|
||||
* Handles are structured-cloneable so they survive page refresh.
|
||||
* Permission must be re-requested each session before writing.
|
||||
*/
|
||||
|
||||
const DB_NAME = "stirling-pdf-folder-directory-handles";
|
||||
const DB_VERSION = 1;
|
||||
const STORE = "handles";
|
||||
|
||||
/** Cached singleton DB connection — avoids opening a new connection per call. */
|
||||
let cachedDB: IDBDatabase | null = null;
|
||||
let initPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBDatabase> {
|
||||
if (cachedDB) return Promise.resolve(cachedDB);
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
req.onsuccess = () => {
|
||||
cachedDB = req.result;
|
||||
cachedDB.onclose = () => {
|
||||
cachedDB = null;
|
||||
initPromise = null;
|
||||
};
|
||||
resolve(cachedDB);
|
||||
};
|
||||
req.onerror = () => {
|
||||
initPromise = null;
|
||||
reject(req.error);
|
||||
};
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
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 getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(STORE).objectStore(STORE).get(folderId);
|
||||
req.onsuccess = () => resolve(req.result ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async set(
|
||||
folderId: string,
|
||||
handle: FileSystemDirectoryHandle,
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE, "readwrite")
|
||||
.objectStore(STORE)
|
||||
.put(handle, folderId);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async remove(folderId: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db
|
||||
.transaction(STORE, "readwrite")
|
||||
.objectStore(STORE)
|
||||
.delete(folderId);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies the handle still has readwrite permission, requesting it if needed.
|
||||
* Returns true if permission is granted, false if denied/dismissed.
|
||||
*/
|
||||
async ensurePermission(handle: FileSystemDirectoryHandle): Promise<boolean> {
|
||||
const opts = { mode: "readwrite" };
|
||||
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 getDB();
|
||||
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 getDB();
|
||||
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 getDB();
|
||||
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";
|
||||
},
|
||||
|
||||
/** Write a file blob into the directory, overwriting if it exists. */
|
||||
async writeFile(
|
||||
handle: FileSystemDirectoryHandle,
|
||||
name: string,
|
||||
blob: Blob,
|
||||
): Promise<void> {
|
||||
const fileHandle = await handle.getFileHandle(name, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Persistent retry schedule for Watched Folder automation.
|
||||
*
|
||||
* Stores pending retries in IndexedDB so they survive page close.
|
||||
* The service worker reads this store to schedule timers; the main thread
|
||||
* drains due entries on mount, on SW notification, and on visibilitychange.
|
||||
*
|
||||
* claimDue() is atomic (single readwrite IDB transaction) — safe across tabs.
|
||||
*/
|
||||
|
||||
export interface RetryEntry {
|
||||
/** `${folderId}:${fileId}` — natural composite key */
|
||||
id: string;
|
||||
folderId: string;
|
||||
fileId: string;
|
||||
/** Absolute ms timestamp when this retry should fire */
|
||||
dueAt: number;
|
||||
attempt: number;
|
||||
ownedByFolder: boolean;
|
||||
}
|
||||
|
||||
class FolderRetryScheduleStorage {
|
||||
private dbName = "stirling-pdf-retry-schedule";
|
||||
private dbVersion = 1;
|
||||
private storeName = "retries";
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to open retry schedule database"));
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.db.onclose = () => {
|
||||
this.db = null;
|
||||
this.initPromise = null;
|
||||
};
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
|
||||
// Index on dueAt lets the SW and claimDue() range-scan efficiently
|
||||
store.createIndex("dueAt", "dueAt", { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
this.initPromise ??= this.init();
|
||||
await this.initPromise;
|
||||
}
|
||||
if (!this.db) throw new Error("Retry schedule database not initialized");
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/** Upsert a retry entry. Calling again for the same file replaces the previous schedule. */
|
||||
async schedule(
|
||||
folderId: string,
|
||||
fileId: string,
|
||||
dueAt: number,
|
||||
attempt: number,
|
||||
ownedByFolder: boolean,
|
||||
): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
const entry: RetryEntry = {
|
||||
id: `${folderId}:${fileId}`,
|
||||
folderId,
|
||||
fileId,
|
||||
dueAt,
|
||||
attempt,
|
||||
ownedByFolder,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const request = tx.objectStore(this.storeName).put(entry);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(new Error("Failed to schedule retry"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a scheduled retry (e.g. when a folder is deleted or a file is manually retried). */
|
||||
async cancel(folderId: string, fileId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const request = tx
|
||||
.objectStore(this.storeName)
|
||||
.delete(`${folderId}:${fileId}`);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(new Error("Failed to cancel retry"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reads and deletes all entries whose dueAt is in the past.
|
||||
* Because the delete happens inside the same readwrite transaction as the
|
||||
* read, two concurrent tabs cannot both claim the same entry.
|
||||
*/
|
||||
async claimDue(): Promise<RetryEntry[]> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const now = Date.now();
|
||||
const claimed: RetryEntry[] = [];
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const index = tx.objectStore(this.storeName).index("dueAt");
|
||||
const cursorRequest = index.openCursor(IDBKeyRange.upperBound(now));
|
||||
cursorRequest.onsuccess = () => {
|
||||
const cursor = cursorRequest.result;
|
||||
if (cursor) {
|
||||
claimed.push(cursor.value as RetryEntry);
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
tx.oncomplete = () => resolve(claimed);
|
||||
tx.onerror = () => reject(new Error("Failed to claim due retries"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove all scheduled retries for a folder (called when the folder is deleted). */
|
||||
async clearFolder(folderId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readwrite");
|
||||
const store = tx.objectStore(this.storeName);
|
||||
const cursorRequest = store.openCursor();
|
||||
cursorRequest.onsuccess = () => {
|
||||
const cursor = cursorRequest.result;
|
||||
if (!cursor) return;
|
||||
const entry = cursor.value as RetryEntry;
|
||||
if (entry.folderId === folderId) cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(new Error("Failed to clear folder retries"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the earliest scheduled dueAt timestamp, or null if no entries exist. */
|
||||
async getEarliestDueAt(): Promise<number | null> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([this.storeName], "readonly");
|
||||
const cursorRequest = tx
|
||||
.objectStore(this.storeName)
|
||||
.index("dueAt")
|
||||
.openCursor();
|
||||
cursorRequest.onsuccess = () =>
|
||||
resolve(
|
||||
cursorRequest.result
|
||||
? (cursorRequest.result.value as RetryEntry).dueAt
|
||||
: null,
|
||||
);
|
||||
cursorRequest.onerror = () =>
|
||||
reject(new Error("Failed to get earliest due at"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const folderRetryScheduleStorage = new FolderRetryScheduleStorage();
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Service for managing Watched Folder run state in IndexedDB
|
||||
*/
|
||||
|
||||
import { WatchedFolderRunEntry } from "@app/types/watchedFolders";
|
||||
|
||||
const FOLDER_RUN_STATE_CHANGE_EVENT = "folder-run-state-changed";
|
||||
|
||||
interface RunStateRecord {
|
||||
folderId: string;
|
||||
runs: WatchedFolderRunEntry[];
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
class FolderRunStateStorage {
|
||||
private dbName = "stirling-pdf-folder-run-state";
|
||||
private dbVersion = 1;
|
||||
private storeName = "runStates";
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to open folder run state database"));
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.db.onclose = () => {
|
||||
this.db = null;
|
||||
this.initPromise = null;
|
||||
};
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
db.createObjectStore(this.storeName, { keyPath: "folderId" });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDB(): Promise<IDBDatabase> {
|
||||
if (!this.db) {
|
||||
this.initPromise ??= this.init();
|
||||
await this.initPromise;
|
||||
}
|
||||
if (!this.db) {
|
||||
throw new Error("Folder run state database not initialized");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async getFolderRunState(folderId: string): Promise<WatchedFolderRunEntry[]> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(folderId);
|
||||
request.onsuccess = () => {
|
||||
const record: RunStateRecord | undefined = request.result;
|
||||
resolve(record?.runs || []);
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to get folder run state"));
|
||||
});
|
||||
}
|
||||
|
||||
async setFolderRunState(
|
||||
folderId: string,
|
||||
runs: WatchedFolderRunEntry[],
|
||||
): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
const record: RunStateRecord = { folderId, runs, lastUpdated: Date.now() };
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, {
|
||||
detail: { folderId },
|
||||
}),
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to set folder run state"));
|
||||
});
|
||||
}
|
||||
|
||||
onRunStateChange(listener: (folderId: string) => void): () => void {
|
||||
const handler = (e: Event) => listener((e as CustomEvent).detail.folderId);
|
||||
window.addEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler);
|
||||
}
|
||||
|
||||
/** Atomically appends entries to a folder's run state within a single readwrite transaction,
|
||||
* preventing lost-update races when multiple files are processed concurrently. */
|
||||
async appendRunEntries(
|
||||
folderId: string,
|
||||
entries: WatchedFolderRunEntry[],
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(folderId);
|
||||
getRequest.onsuccess = () => {
|
||||
const existing: RunStateRecord | undefined = getRequest.result;
|
||||
const MAX_RUN_ENTRIES = 500;
|
||||
const combined = [...(existing?.runs ?? []), ...entries];
|
||||
const record: RunStateRecord = {
|
||||
folderId,
|
||||
runs:
|
||||
combined.length > MAX_RUN_ENTRIES
|
||||
? combined.slice(-MAX_RUN_ENTRIES)
|
||||
: combined,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
const putRequest = store.put(record);
|
||||
putRequest.onsuccess = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, {
|
||||
detail: { folderId },
|
||||
}),
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
putRequest.onerror = () =>
|
||||
reject(new Error("Failed to append run entries"));
|
||||
};
|
||||
getRequest.onerror = () =>
|
||||
reject(new Error("Failed to read run state for append"));
|
||||
});
|
||||
}
|
||||
|
||||
async clearFolderRunState(folderId: string): Promise<void> {
|
||||
const db = await this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(folderId);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () =>
|
||||
reject(new Error("Failed to clear folder run state"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const folderRunStateStorage = new FolderRunStateStorage();
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/** Cached singleton DB connection — avoids opening a new connection per call. */
|
||||
let cachedDB: IDBDatabase | null = null;
|
||||
let initPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBDatabase> {
|
||||
if (cachedDB) return Promise.resolve(cachedDB);
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
req.onsuccess = () => {
|
||||
cachedDB = req.result;
|
||||
cachedDB.onclose = () => {
|
||||
cachedDB = null;
|
||||
initPromise = null;
|
||||
};
|
||||
resolve(cachedDB);
|
||||
};
|
||||
req.onerror = () => {
|
||||
initPromise = null;
|
||||
reject(req.error);
|
||||
};
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
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 getDB();
|
||||
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 getDB();
|
||||
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 getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
const store = tx.objectStore(STORE);
|
||||
const prefix = `${folderId}|`;
|
||||
const range = IDBKeyRange.bound(prefix, prefix + "");
|
||||
const req = store.openCursor(range);
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user