diff --git a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java index 480e80611..49ed068e2 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/RuntimePathConfig.java @@ -1,10 +1,14 @@ package stirling.software.common.configuration; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Configuration; @@ -41,6 +45,7 @@ public class RuntimePathConfig { // Pipeline paths private final String pipelineWatchedFoldersPath; + private final List pipelineWatchedFoldersPaths; private final String pipelineFinishedFoldersPath; private final String pipelineDefaultWebUiConfigs; private final String pipelinePath; @@ -49,20 +54,27 @@ public class RuntimePathConfig { this.properties = properties; this.basePath = InstallationPathConfig.getPath(); - this.pipelinePath = Path.of(basePath, "pipeline").toString(); - String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString(); - String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString(); - String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString(); - System system = properties.getSystem(); CustomPaths customPaths = system.getCustomPaths(); Pipeline pipeline = customPaths.getPipeline(); - this.pipelineWatchedFoldersPath = + this.pipelinePath = resolvePath( + Path.of(basePath, "pipeline").toString(), + pipeline != null ? pipeline.getPipelineDir() : null); + String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString(); + String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString(); + String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString(); + + List watchedFoldersDirs = + sanitizePathList(pipeline != null ? pipeline.getWatchedFoldersDirs() : null); + this.pipelineWatchedFoldersPaths = + resolveWatchedFolderPaths( defaultWatchedFolders, + watchedFoldersDirs, pipeline != null ? pipeline.getWatchedFoldersDir() : null); + this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.get(0); this.pipelineFinishedFoldersPath = resolvePath( defaultFinishedFolders, @@ -72,6 +84,9 @@ public class RuntimePathConfig { defaultWebUIConfigs, pipeline != null ? pipeline.getWebUIConfigsDir() : null); + // Validate path conflicts after all paths are resolved + validatePipelinePaths(); + boolean isDocker = isRunningInDocker(); // Initialize Operation paths @@ -129,6 +144,140 @@ public class RuntimePathConfig { return StringUtils.isNotBlank(customPath) ? customPath : defaultPath; } + private List resolveWatchedFolderPaths( + String defaultPath, List watchedFoldersDirs, String legacyWatchedFolder) { + List rawPaths = new ArrayList<>(); + + // Collect paths from new config + if (watchedFoldersDirs != null && !watchedFoldersDirs.isEmpty()) { + rawPaths.addAll(watchedFoldersDirs); + } + // Fall back to legacy config + else if (StringUtils.isNotBlank(legacyWatchedFolder)) { + rawPaths.add(legacyWatchedFolder); + } + // Fall back to default + else { + rawPaths.add(defaultPath); + } + + // Validate, normalize, and deduplicate paths + List validatedPaths = validateAndNormalizePaths(rawPaths); + + // Ensure we have at least one valid path (critical for system to function) + if (validatedPaths.isEmpty()) { + log.warn( + "No valid watched folder paths configured, falling back to default: {}", + defaultPath); + validatedPaths.add(defaultPath); + } + + // Detect overlapping paths (warning only, not blocking) + detectOverlappingPaths(validatedPaths); + + return validatedPaths; + } + + private List sanitizePathList(List paths) { + if (paths == null || paths.isEmpty()) { + return Collections.emptyList(); + } + List sanitized = new ArrayList<>(); + for (String path : paths) { + if (StringUtils.isNotBlank(path)) { + sanitized.add(path.trim()); + } + } + return sanitized; + } + + private List validateAndNormalizePaths(List paths) { + Set normalizedPaths = new LinkedHashSet<>(); // Preserves order, prevents duplicates + + for (String pathStr : paths) { + if (StringUtils.isBlank(pathStr)) { + continue; + } + + try { + // Normalize to absolute path + Path path = Paths.get(pathStr.trim()).toAbsolutePath().normalize(); + String normalizedPath = path.toString(); + + // Check for duplicates + if (normalizedPaths.contains(normalizedPath)) { + log.debug("Skipping duplicate watched folder path: {}", pathStr); + continue; + } + + normalizedPaths.add(normalizedPath); + log.info("Registered watched folder path: {}", normalizedPath); + + } catch (InvalidPathException e) { + log.error( + "Invalid watched folder path '{}' - skipping: {}", pathStr, e.getMessage()); + } + } + + return new ArrayList<>(normalizedPaths); + } + + private void detectOverlappingPaths(List paths) { + for (int i = 0; i < paths.size(); i++) { + Path path1 = Paths.get(paths.get(i)); + for (int j = i + 1; j < paths.size(); j++) { + Path path2 = Paths.get(paths.get(j)); + + // Check if one path is a parent of the other + if (path1.startsWith(path2)) { + log.warn( + "Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing", + path1, + path2); + } else if (path2.startsWith(path1)) { + log.warn( + "Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing", + path2, + path1); + } + } + } + } + + private void validatePipelinePaths() { + try { + Path finishedPath = Paths.get(pipelineFinishedFoldersPath).toAbsolutePath().normalize(); + + for (String watchedPathStr : pipelineWatchedFoldersPaths) { + Path watchedPath = Paths.get(watchedPathStr).toAbsolutePath().normalize(); + + // Check if watched folder is same as finished folder + if (watchedPath.equals(finishedPath)) { + log.error( + "CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!", + watchedPath, + finishedPath); + } + // Check if watched folder contains finished folder + else if (finishedPath.startsWith(watchedPath)) { + log.warn( + "Finished folder '{}' is nested inside watched folder '{}' - this may cause issues", + finishedPath, + watchedPath); + } + // Check if finished folder contains watched folder + else if (watchedPath.startsWith(finishedPath)) { + log.error( + "CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!", + watchedPath, + finishedPath); + } + } + } catch (Exception e) { + log.error("Error validating pipeline paths: {}", e.getMessage()); + } + } + private boolean isRunningInDocker() { return Files.exists(Path.of("/.dockerenv")); } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index cabced160..9ff046e04 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -459,7 +459,9 @@ public class ApplicationProperties { @Data public static class Pipeline { + private String pipelineDir; private String watchedFoldersDir; + private List watchedFoldersDirs = new ArrayList<>(); private String finishedFoldersDir; private String webUIConfigsDir; } diff --git a/app/common/src/main/java/stirling/software/common/util/FileMonitor.java b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java index 3d1fe4f58..fac9bf650 100644 --- a/app/common/src/main/java/stirling/software/common/util/FileMonitor.java +++ b/app/common/src/main/java/stirling/software/common/util/FileMonitor.java @@ -29,7 +29,7 @@ public class FileMonitor { private final ConcurrentHashMap.KeySetView readyForProcessingFiles; private final WatchService watchService; private final Predicate pathFilter; - private final Path rootDir; + private final List rootDirs; private Set stagingFiles; /** @@ -47,8 +47,28 @@ public class FileMonitor { this.pathFilter = pathFilter; this.readyForProcessingFiles = ConcurrentHashMap.newKeySet(); this.watchService = FileSystems.getDefault().newWatchService(); - log.info("Monitoring directory: {}", runtimePathConfig.getPipelineWatchedFoldersPath()); - this.rootDir = Path.of(runtimePathConfig.getPipelineWatchedFoldersPath()); + + List watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths(); + List validRootDirs = new ArrayList<>(); + + for (String pathStr : watchedFoldersDirs) { + try { + Path path = Path.of(pathStr); + validRootDirs.add(path); + log.info("Monitoring directory: {}", path); + } catch (Exception e) { + log.error( + "Failed to initialize monitoring for path '{}': {}", + pathStr, + e.getMessage()); + } + } + + this.rootDirs = Collections.unmodifiableList(validRootDirs); + + if (this.rootDirs.isEmpty()) { + log.error("No valid directories to monitor - FileMonitor will not function"); + } } private boolean shouldNotProcess(Path path) { @@ -85,13 +105,15 @@ public class FileMonitor { readyForProcessingFiles.clear(); if (path2KeyMapping.isEmpty()) { - log.warn("not monitoring any directory, even the root directory itself: {}", rootDir); - if (Files.exists( - rootDir)) { // if the root directory exists, re-register the root directory - try { - recursivelyRegisterEntry(rootDir); - } catch (IOException e) { - log.error("unable to register monitoring", e); + log.warn("Not monitoring any directories; attempting to re-register root paths."); + for (Path rootDir : rootDirs) { + if (Files.exists( + rootDir)) { // if the root directory exists, re-register the root directory + try { + recursivelyRegisterEntry(rootDir); + } catch (IOException e) { + log.error("unable to register monitoring for {}", rootDir, e); + } } } } diff --git a/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java b/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java index cd137723f..851c1c6cd 100644 --- a/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java @@ -9,6 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; import java.time.Instant; +import java.util.List; import java.util.function.Predicate; import org.junit.jupiter.api.BeforeEach; @@ -34,7 +35,8 @@ class FileMonitorTest { @BeforeEach void setUp() throws IOException { - when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString()); + when(runtimePathConfig.getPipelineWatchedFoldersPaths()) + .thenReturn(List.of(tempDir.toString())); // This mock is used in all tests except testPathFilter // We use lenient to avoid UnnecessaryStubbingException in that test diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java index 070bd4103..83450088e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java @@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api.pipeline; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystemException; +import java.nio.file.FileVisitOption; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; @@ -14,6 +15,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -41,14 +43,20 @@ import stirling.software.common.util.FileMonitor; @Slf4j public class PipelineDirectoryProcessor { + private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion + private final ObjectMapper objectMapper; private final ApiDocService apiDocService; private final PipelineProcessor processor; private final FileMonitor fileMonitor; private final PostHogService postHogService; - private final String watchedFoldersDir; + private final List watchedFoldersDirs; private final String finishedFoldersDir; + // Track processed directories in current scan to prevent duplicates + private final ThreadLocal> processedDirsInScan = + ThreadLocal.withInitial(java.util.HashSet::new); + public PipelineDirectoryProcessor( ObjectMapper objectMapper, ApiDocService apiDocService, @@ -61,13 +69,26 @@ public class PipelineDirectoryProcessor { this.processor = processor; this.fileMonitor = fileMonitor; this.postHogService = postHogService; - this.watchedFoldersDir = runtimePathConfig.getPipelineWatchedFoldersPath(); + this.watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths(); this.finishedFoldersDir = runtimePathConfig.getPipelineFinishedFoldersPath(); } @Scheduled(fixedRate = 60000) public void scanFolders() { - Path watchedFolderPath = Paths.get(watchedFoldersDir).toAbsolutePath(); + // Clear the processed directories set for this scan cycle + processedDirsInScan.get().clear(); + + try { + for (String watchedFoldersDir : watchedFoldersDirs) { + scanWatchedFolder(Paths.get(watchedFoldersDir).toAbsolutePath()); + } + } finally { + // Clean up ThreadLocal to prevent memory leaks + processedDirsInScan.remove(); + } + } + + private void scanWatchedFolder(Path watchedFolderPath) { if (!Files.exists(watchedFolderPath)) { try { Files.createDirectories(watchedFolderPath); @@ -78,16 +99,34 @@ public class PipelineDirectoryProcessor { } } + // Validate the path is a directory and readable + if (!Files.isDirectory(watchedFolderPath)) { + log.error("Path is not a directory: {}", watchedFolderPath); + return; + } + if (!Files.isReadable(watchedFolderPath)) { + log.error("Directory is not readable: {}", watchedFolderPath); + return; + } + try { + // Use FOLLOW_LINKS to follow symlinks, with max depth to prevent infinite loops Files.walkFileTree( watchedFolderPath, + EnumSet.of(FileVisitOption.FOLLOW_LINKS), + MAX_DIRECTORY_DEPTH, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory( Path dir, BasicFileAttributes attrs) { try { + String dirName = + dir.getFileName() != null + ? dir.getFileName().toString() + : ""; // Skip root directory and "processing" subdirectories - if (!dir.equals(watchedFolderPath) && !dir.endsWith("processing")) { + if (!dir.equals(watchedFolderPath) + && !"processing".equals(dirName)) { handleDirectory(dir); } } catch (Exception e) { @@ -98,8 +137,11 @@ public class PipelineDirectoryProcessor { @Override public FileVisitResult visitFileFailed(Path path, IOException exc) { - // Handle broken symlinks or inaccessible directories - log.error("Error accessing path: {}", path, exc); + // Handle broken symlinks, permission issues, or inaccessible + // directories + if (exc != null) { + log.debug("Cannot access path '{}': {}", path, exc.getMessage()); + } return FileVisitResult.CONTINUE; } }); @@ -109,6 +151,17 @@ public class PipelineDirectoryProcessor { } public void handleDirectory(Path dir) throws IOException { + // Normalize path to absolute to prevent duplicate processing from different path + // representations + Path normalizedDir = dir.toAbsolutePath().normalize(); + + // Check if we've already processed this directory in this scan cycle + java.util.Set processedDirs = processedDirsInScan.get(); + if (!processedDirs.add(normalizedDir)) { + log.debug("Directory already processed in this scan cycle: {}", normalizedDir); + return; + } + log.info("Handling directory: {}", dir); Path processingDir = createProcessingDirectory(dir); Optional jsonFileOptional = findJsonFile(dir); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 8a952fa97..8b1cbdeb8 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -203,7 +203,9 @@ system: name: postgres # set the name of your database. Should match the name of the database you create customPaths: pipeline: + pipelineDir: "" # Defaults to /pipeline watchedFoldersDir: "" # Defaults to /pipeline/watchedFolders + watchedFoldersDirs: [] # List of watched folder directories. Defaults to watchedFoldersDir or /pipeline/watchedFolders. finishedFoldersDir: "" # Defaults to /pipeline/finishedFolders operations: weasyprint: "" # Defaults to /opt/venv/bin/weasyprint diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 28a580c74..f92434fd9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -186,6 +186,13 @@ public class AdminSettingsController { + HtmlUtils.htmlEscape(key))); } + // Validate pipeline path settings + String validationError = validatePipelinePathSetting(key, value); + if (validationError != null) { + return ResponseEntity.badRequest() + .body(Map.of("error", HtmlUtils.htmlEscape(validationError))); + } + log.info("Admin updating setting: {} = {}", key, value); GeneralUtils.saveKeyToSettings(key, value); @@ -642,6 +649,54 @@ public class AdminSettingsController { return true; } + private String validatePipelinePathSetting(String key, Object value) { + // Validate pipeline path settings + if (key.startsWith("system.customPaths.pipeline.watchedFoldersDirs") + && value instanceof java.util.List) { + @SuppressWarnings("unchecked") + java.util.List paths = (java.util.List) value; + + // Check for empty or all-blank paths + if (paths.isEmpty()) { + return null; // Empty is OK, will use default + } + + // Validate each path + java.util.Set normalizedPaths = new java.util.HashSet<>(); + for (String path : paths) { + if (path != null && !path.trim().isEmpty()) { + try { + java.nio.file.Path normalized = + java.nio.file.Paths.get(path.trim()).toAbsolutePath().normalize(); + String normalizedStr = normalized.toString(); + + // Check for duplicates + if (normalizedPaths.contains(normalizedStr)) { + return "Duplicate path detected: " + path; + } + normalizedPaths.add(normalizedStr); + } catch (java.nio.file.InvalidPathException e) { + return "Invalid path: " + path + " - " + e.getMessage(); + } + } + } + + // Check for overlapping paths + java.util.List pathList = new java.util.ArrayList<>(normalizedPaths); + for (int i = 0; i < pathList.size(); i++) { + java.nio.file.Path path1 = java.nio.file.Paths.get(pathList.get(i)); + for (int j = i + 1; j < pathList.size(); j++) { + java.nio.file.Path path2 = java.nio.file.Paths.get(pathList.get(j)); + if (path1.startsWith(path2) || path2.startsWith(path1)) { + return "Overlapping paths detected: " + path1 + " and " + path2; + } + } + } + } + + return null; // Valid + } + private Object getSettingByKey(String key) { if (key == null || key.trim().isEmpty()) { return null; diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index a34fe6cb1..2bb1b12f9 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4514,10 +4514,18 @@ description = "Configure custom file system paths for pipeline processing and ex [admin.settings.general.customPaths.pipeline] label = "Pipeline Directories" +[admin.settings.general.customPaths.pipeline.pipelineDir] +label = "Pipeline Directory" +description = "Base directory for pipeline resources (leave empty for default: /pipeline)" + [admin.settings.general.customPaths.pipeline.watchedFoldersDir] label = "Watched Folders Directory" description = "Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)" +[admin.settings.general.customPaths.pipeline.watchedFoldersDirs] +label = "Watched Folders Directories" +description = "Directories where pipeline monitors for incoming PDFs (one per line or comma-separated; leave empty for default: /pipeline/watchedFolders)" + [admin.settings.general.customPaths.pipeline.finishedFoldersDir] label = "Finished Folders Directory" description = "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)" diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx index 85bc61e1b..3a50bd725 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; -import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core'; +import { TextInput, Textarea, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl, Select } from '@mantine/core'; import { alert } from '@app/components/toast'; import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; import { useRestartServer } from '@app/components/shared/config/useRestartServer'; @@ -31,7 +31,9 @@ interface GeneralSettingsData { }; customPaths?: { pipeline?: { + pipelineDir?: string; watchedFoldersDir?: string; + watchedFoldersDirs?: string[]; finishedFoldersDir?: string; }; operations?: { @@ -61,6 +63,17 @@ export default function AdminGeneralSection() { .sort((a, b) => a.label.localeCompare(b.label)), [] ); + const parseWatchedFoldersInput = useCallback((value: string) => { + const paths = value + .split(/[\n,;]+/) + .map((entry) => entry.trim()) + .filter(Boolean); + + // Deduplicate paths (case-sensitive, exact match) + const uniquePaths = Array.from(new Set(paths)); + + return uniquePaths; + }, []); // Track original settings for dirty detection const [originalSettingsSnapshot, setOriginalSettingsSnapshot] = useState(''); @@ -91,17 +104,31 @@ export default function AdminGeneralSection() { ui.languages = Array.isArray(ui.languages) ? toUnderscoreLanguages(ui.languages) : []; + const pipelinePaths = system.customPaths?.pipeline || {}; + const watchedFoldersDirs = Array.isArray(pipelinePaths.watchedFoldersDirs) + ? pipelinePaths.watchedFoldersDirs + : []; + const normalizedWatchedFoldersDirs = + watchedFoldersDirs.length > 0 + ? watchedFoldersDirs + : (pipelinePaths.watchedFoldersDir ? [pipelinePaths.watchedFoldersDir] : []); + const result: any = { ui, system, - customPaths: system.customPaths || { + customPaths: { + ...(system.customPaths || {}), pipeline: { - watchedFoldersDir: '', - finishedFoldersDir: '' + ...pipelinePaths, + pipelineDir: pipelinePaths.pipelineDir || '', + watchedFoldersDir: pipelinePaths.watchedFoldersDir || '', + watchedFoldersDirs: normalizedWatchedFoldersDirs, + finishedFoldersDir: pipelinePaths.finishedFoldersDir || '' }, operations: { - weasyprint: '', - unoconvert: '' + ...(system.customPaths?.operations || {}), + weasyprint: system.customPaths?.operations?.weasyprint || '', + unoconvert: system.customPaths?.operations?.unoconvert || '' } }, customMetadata: premium.proFeatures?.customMetadata || { @@ -154,7 +181,9 @@ export default function AdminGeneralSection() { }; if (settings.customPaths) { + deltaSettings['system.customPaths.pipeline.pipelineDir'] = settings.customPaths?.pipeline?.pipelineDir; deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths?.pipeline?.watchedFoldersDir; + deltaSettings['system.customPaths.pipeline.watchedFoldersDirs'] = settings.customPaths?.pipeline?.watchedFoldersDirs; deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths?.pipeline?.finishedFoldersDir; deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths?.operations?.weasyprint; deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths?.operations?.unoconvert; @@ -171,6 +200,54 @@ export default function AdminGeneralSection() { () => toUnderscoreLanguages(settings.ui?.languages || []), [settings.ui?.languages] ); + const watchedFoldersInput = useMemo(() => ( + (settings.customPaths?.pipeline?.watchedFoldersDirs || []).join('\n') + ), [settings.customPaths?.pipeline?.watchedFoldersDirs]); + + const watchedFoldersValidation = useMemo(() => { + const paths = settings.customPaths?.pipeline?.watchedFoldersDirs || []; + const finishedPath = settings.customPaths?.pipeline?.finishedFoldersDir || ''; + const warnings: string[] = []; + + // Normalize paths for comparison (handle both Windows and Unix paths) + const normalizePath = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, ''); + + // Check for overlapping watched folders + if (paths.length >= 2) { + for (let i = 0; i < paths.length; i++) { + for (let j = i + 1; j < paths.length; j++) { + const path1 = normalizePath(paths[i]); + const path2 = normalizePath(paths[j]); + + if (path1 === path2) { + warnings.push(`Duplicate path detected: '${paths[i]}'`); + } else if (path1.startsWith(path2 + '/')) { + warnings.push(`'${paths[i]}' is nested inside '${paths[j]}' - may cause duplicate processing`); + } else if (path2.startsWith(path1 + '/')) { + warnings.push(`'${paths[j]}' is nested inside '${paths[i]}' - may cause duplicate processing`); + } + } + } + } + + // Check for conflicts with finished folder + if (finishedPath && paths.length > 0) { + const normalizedFinished = normalizePath(finishedPath); + for (const watchedPath of paths) { + const normalizedWatched = normalizePath(watchedPath); + + if (normalizedWatched === normalizedFinished) { + warnings.push(`CRITICAL: Watched folder '${watchedPath}' is the same as finished folder - will cause processing loops!`); + } else if (normalizedFinished.startsWith(normalizedWatched + '/')) { + warnings.push(`Finished folder is nested inside watched folder '${watchedPath}' - may cause issues`); + } else if (normalizedWatched.startsWith(normalizedFinished + '/')) { + warnings.push(`CRITICAL: Watched folder '${watchedPath}' is nested inside finished folder - will cause processing loops!`); + } + } + } + + return warnings.length > 0 ? warnings : null; + }, [settings.customPaths?.pipeline?.watchedFoldersDirs, settings.customPaths?.pipeline?.finishedFoldersDir]); // Filter default locale options based on available languages setting const defaultLocaleOptions = useMemo(() => { @@ -651,27 +728,74 @@ export default function AdminGeneralSection() { - {t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.label', 'Watched Folders Directory')} - + {t('admin.settings.general.customPaths.pipeline.pipelineDir.label', 'Pipeline Directory')} + } - description={t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.description', 'Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)')} - value={settings.customPaths?.pipeline?.watchedFoldersDir || ''} + description={t('admin.settings.general.customPaths.pipeline.pipelineDir.description', 'Base directory for pipeline resources (leave empty for default: /pipeline)')} + value={settings.customPaths?.pipeline?.pipelineDir || ''} onChange={(e) => setSettings({ ...settings, customPaths: { ...settings.customPaths, pipeline: { ...settings.customPaths?.pipeline, - watchedFoldersDir: e.target.value + pipelineDir: e.target.value } } })} - placeholder="/pipeline/watchedFolders" + placeholder="/pipeline" disabled={!loginEnabled} /> +
+