mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(wiki): support multi-path and glob patterns for KB source directory
Allow knowledge base source paths to be configured as a newline-separated
list of absolute paths or glob patterns rather than a single directory.
- Each non-blank, non-# line is treated as one path or glob pattern
- Plain paths (no wildcards) retain the existing recursive-scan behaviour
filtered by SUPPORTED_EXTENSIONS
- Glob patterns (e.g. /data/ocr/**/*.txt) walk from the fixed-prefix base
and apply Java's PathMatcher against each candidate's absolute path
- Patterns whose filename segment explicitly specifies an extension
(*.txt, *.{xlsx,csv}) skip the SUPPORTED_EXTENSIONS secondary filter,
respecting the user's explicit choice (key for OCR pipelines that
produce .txt output and should ignore the original PDF scans)
- Candidates collected across multiple patterns are deduplicated by
resolved absolute path so overlapping patterns don't double-count
- Symlink-escape check uses each pattern's own validated scan root
- parseSourcePatterns / extractBasePath moved into WikiSourcePathValidator
to eliminate a static circular reference between the two services
- Frontend watcher panel: single-line <input> replaced with <textarea>
supporting multiline editing; i18n updated with example patterns
- No DB schema change; fully backward-compatible with existing single-path
configs stored in sourceDirectory
Closes #(pending issue)
This commit is contained in:
parent
9baef57871
commit
d2e1f9eb9c
@ -285,7 +285,7 @@ public class WikiController {
|
|||||||
String path = body.get("path");
|
String path = body.get("path");
|
||||||
if (path != null && !path.isBlank()) {
|
if (path != null && !path.isBlank()) {
|
||||||
try {
|
try {
|
||||||
pathValidator.validateDirectory(path);
|
pathValidator.validateSourcePatterns(path);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return R.fail(400, e.getMessage());
|
return R.fail(400, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.wiki.WikiProperties;
|
import vip.mate.wiki.WikiProperties;
|
||||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@ -18,6 +17,13 @@ import java.util.*;
|
|||||||
* <p>
|
* <p>
|
||||||
* 扫描本地目录中的文档文件,为每个文件创建原始材料。
|
* 扫描本地目录中的文档文件,为每个文件创建原始材料。
|
||||||
* 基于 sourcePath 去重,避免重复导入。
|
* 基于 sourcePath 去重,避免重复导入。
|
||||||
|
* <p>
|
||||||
|
* sourceDirectory 支持换行分隔的多条记录,每条可以是:
|
||||||
|
* <ul>
|
||||||
|
* <li>普通目录路径(如 {@code /data/docs})——递归扫描,按 SUPPORTED_EXTENSIONS 过滤</li>
|
||||||
|
* <li>Glob 模式(如 {@code /data/ocr/**}{@code /*.txt})——从固定前缀出发,用 PathMatcher 过滤</li>
|
||||||
|
* </ul>
|
||||||
|
* 以 {@code #} 开头的行视为注释,忽略。路径解析与验证委托给 {@link WikiSourcePathValidator}。
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -38,13 +44,16 @@ public class WikiDirectoryScanService {
|
|||||||
|
|
||||||
private static final Set<String> TEXT_EXTENSIONS = Set.of("txt", "md", "csv");
|
private static final Set<String> TEXT_EXTENSIONS = Set.of("txt", "md", "csv");
|
||||||
|
|
||||||
|
/** 一个待处理候选文件及其所属的扫描根(用于符号链接逃逸检测)。 */
|
||||||
|
private record FileCandidate(Path file, Path scanRoot) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描结果
|
* 扫描结果
|
||||||
*/
|
*/
|
||||||
public record ScanResult(int scanned, int added, int skipped, List<String> errors) {}
|
public record ScanResult(int scanned, int added, int skipped, List<String> errors) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描指定知识库关联的目录
|
* 扫描指定知识库关联的目录(支持多路径 + glob)
|
||||||
*/
|
*/
|
||||||
public ScanResult scan(Long kbId) {
|
public ScanResult scan(Long kbId) {
|
||||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||||
@ -59,79 +68,42 @@ public class WikiDirectoryScanService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描指定目录,为每个支持的文件创建原始材料
|
* 扫描指定路径配置,支持换行分隔的多条路径/Glob 模式。
|
||||||
|
* 单条普通路径时与旧行为完全兼容。
|
||||||
*/
|
*/
|
||||||
public ScanResult scanDirectory(Long kbId, String directoryPath) {
|
public ScanResult scanDirectory(Long kbId, String directoryPath) {
|
||||||
Path dir;
|
List<String> patterns = WikiSourcePathValidator.parseSourcePatterns(directoryPath);
|
||||||
try {
|
if (patterns.isEmpty()) {
|
||||||
// Canonicalize (resolving symlinks) and enforce allowed-roots so a
|
return new ScanResult(0, 0, 0, List.of("No source directory configured"));
|
||||||
// scan cannot read outside the authorized area.
|
|
||||||
dir = pathValidator.validateDirectory(directoryPath);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return new ScanResult(0, 0, 0, List.of(e.getMessage()));
|
|
||||||
}
|
}
|
||||||
|
return scanWithPatterns(kbId, patterns);
|
||||||
|
}
|
||||||
|
|
||||||
if (!Files.exists(dir)) {
|
// ==================== private ====================
|
||||||
return new ScanResult(0, 0, 0, List.of("Directory does not exist: " + dir));
|
|
||||||
}
|
|
||||||
if (!Files.isDirectory(dir)) {
|
|
||||||
return new ScanResult(0, 0, 0, List.of("Path is not a directory: " + dir));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Path> files = new ArrayList<>();
|
private ScanResult scanWithPatterns(Long kbId, List<String> patterns) {
|
||||||
|
List<FileCandidate> candidates = new ArrayList<>();
|
||||||
List<String> errors = new ArrayList<>();
|
List<String> errors = new ArrayList<>();
|
||||||
int maxFiles = properties.getMaxScanFiles();
|
int maxFiles = properties.getMaxScanFiles();
|
||||||
long maxFileSize = properties.getMaxScanFileSize();
|
long maxFileSize = properties.getMaxScanFileSize();
|
||||||
|
|
||||||
// 递归遍历目录
|
for (String pattern : patterns) {
|
||||||
try {
|
if (candidates.size() >= maxFiles) break;
|
||||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
collectCandidates(pattern, candidates, errors, maxFiles, maxFileSize);
|
||||||
@Override
|
|
||||||
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) {
|
|
||||||
// 跳过隐藏目录
|
|
||||||
String name = d.getFileName().toString();
|
|
||||||
if (name.startsWith(".") && !d.equals(dir)) {
|
|
||||||
return FileVisitResult.SKIP_SUBTREE;
|
|
||||||
}
|
|
||||||
return FileVisitResult.CONTINUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
|
||||||
if (files.size() >= maxFiles) {
|
|
||||||
return FileVisitResult.TERMINATE;
|
|
||||||
}
|
|
||||||
String fileName = file.getFileName().toString();
|
|
||||||
// 跳过隐藏文件
|
|
||||||
if (fileName.startsWith(".")) return FileVisitResult.CONTINUE;
|
|
||||||
// 跳过过大文件
|
|
||||||
if (attrs.size() > maxFileSize) {
|
|
||||||
log.debug("[Wiki] Skipping large file: {} ({} bytes)", file, attrs.size());
|
|
||||||
return FileVisitResult.CONTINUE;
|
|
||||||
}
|
|
||||||
// 检查扩展名
|
|
||||||
String ext = getExtension(fileName);
|
|
||||||
if (SUPPORTED_EXTENSIONS.contains(ext)) {
|
|
||||||
files.add(file);
|
|
||||||
}
|
|
||||||
return FileVisitResult.CONTINUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public FileVisitResult visitFileFailed(Path file, IOException exc) {
|
|
||||||
errors.add("Cannot read: " + file.getFileName() + " (" + exc.getMessage() + ")");
|
|
||||||
return FileVisitResult.CONTINUE;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (IOException e) {
|
|
||||||
return new ScanResult(0, 0, 0, List.of("Failed to scan directory: " + e.getMessage()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int scanned = files.size();
|
// Deduplicate: the same file can be matched by multiple overlapping patterns.
|
||||||
|
// Keep first-match order; first-match scanRoot wins for the symlink escape check.
|
||||||
|
Set<Path> seen = new LinkedHashSet<>();
|
||||||
|
candidates.removeIf(c -> !seen.add(c.file().toAbsolutePath().normalize()));
|
||||||
|
|
||||||
|
int scanned = candidates.size();
|
||||||
int added = 0;
|
int added = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
|
|
||||||
for (Path file : files) {
|
for (FileCandidate candidate : candidates) {
|
||||||
|
Path file = candidate.file();
|
||||||
|
Path scanRoot = candidate.scanRoot();
|
||||||
try {
|
try {
|
||||||
// Per-file symlink guard: a symlinked file inside an allowed
|
// Per-file symlink guard: a symlinked file inside an allowed
|
||||||
// directory could point outside it (e.g. secret.md ->
|
// directory could point outside it (e.g. secret.md ->
|
||||||
@ -143,7 +115,7 @@ public class WikiDirectoryScanService {
|
|||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
realFile = file.toAbsolutePath().normalize();
|
realFile = file.toAbsolutePath().normalize();
|
||||||
}
|
}
|
||||||
if (!realFile.startsWith(dir)) {
|
if (!realFile.startsWith(scanRoot)) {
|
||||||
errors.add("Skipped symlink escaping the scan root: " + file.getFileName());
|
errors.add("Skipped symlink escaping the scan root: " + file.getFileName());
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
@ -210,17 +182,127 @@ public class WikiDirectoryScanService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.size() >= maxFiles) {
|
if (candidates.size() >= maxFiles) {
|
||||||
errors.add("Scan limit reached (" + maxFiles + " files). Some files may have been skipped.");
|
errors.add("Scan limit reached (" + maxFiles + " files). Some files may have been skipped.");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("[Wiki] Directory scan completed: dir={}, scanned={}, added={}, skipped={}, errors={}",
|
log.info("[Wiki] Scan completed: patterns={}, scanned={}, added={}, skipped={}, errors={}",
|
||||||
directoryPath, scanned, added, skipped, errors.size());
|
patterns, scanned, added, skipped, errors.size());
|
||||||
|
|
||||||
return new ScanResult(scanned, added, skipped, errors);
|
return new ScanResult(scanned, added, skipped, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getExtension(String fileName) {
|
private void collectCandidates(String pattern, List<FileCandidate> candidates,
|
||||||
|
List<String> errors, int maxFiles, long maxFileSize) {
|
||||||
|
boolean hasWildcard = containsWildcard(pattern);
|
||||||
|
Path scanRoot;
|
||||||
|
PathMatcher matcher;
|
||||||
|
boolean requireSupportedExt;
|
||||||
|
|
||||||
|
if (!hasWildcard) {
|
||||||
|
// Plain directory: walk recursively, filter by SUPPORTED_EXTENSIONS.
|
||||||
|
try {
|
||||||
|
scanRoot = pathValidator.validateDirectory(pattern);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
errors.add(e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Files.exists(scanRoot) || !Files.isDirectory(scanRoot)) {
|
||||||
|
errors.add("Not a directory: " + scanRoot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matcher = null;
|
||||||
|
requireSupportedExt = true;
|
||||||
|
} else {
|
||||||
|
// Glob pattern: validate the fixed-prefix base, then apply PathMatcher.
|
||||||
|
String basePath = WikiSourcePathValidator.extractBasePath(pattern);
|
||||||
|
try {
|
||||||
|
scanRoot = pathValidator.validateDirectory(basePath);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
errors.add(e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Files.exists(scanRoot)) {
|
||||||
|
errors.add("Base directory does not exist: " + scanRoot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
errors.add("Invalid glob pattern '" + pattern + "': " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the filename segment already specifies an extension (e.g. *.txt),
|
||||||
|
// skip the secondary SUPPORTED_EXTENSIONS filter to respect the explicit choice.
|
||||||
|
requireSupportedExt = !patternSpecifiesExtension(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Path finalScanRoot = scanRoot;
|
||||||
|
final PathMatcher finalMatcher = matcher;
|
||||||
|
final boolean finalRequireExt = requireSupportedExt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.walkFileTree(scanRoot, new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) {
|
||||||
|
String name = d.getFileName().toString();
|
||||||
|
if (name.startsWith(".") && !d.equals(finalScanRoot)) {
|
||||||
|
return FileVisitResult.SKIP_SUBTREE;
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||||
|
if (candidates.size() >= maxFiles) return FileVisitResult.TERMINATE;
|
||||||
|
String fileName = file.getFileName().toString();
|
||||||
|
if (fileName.startsWith(".")) return FileVisitResult.CONTINUE;
|
||||||
|
if (attrs.size() > maxFileSize) {
|
||||||
|
log.debug("[Wiki] Skipping large file: {} ({} bytes)", file, attrs.size());
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
String ext = getExtension(fileName);
|
||||||
|
boolean accept;
|
||||||
|
if (finalMatcher != null) {
|
||||||
|
accept = finalMatcher.matches(file.toAbsolutePath());
|
||||||
|
if (accept && finalRequireExt) {
|
||||||
|
accept = SUPPORTED_EXTENSIONS.contains(ext);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accept = SUPPORTED_EXTENSIONS.contains(ext);
|
||||||
|
}
|
||||||
|
if (accept) {
|
||||||
|
candidates.add(new FileCandidate(file, finalScanRoot));
|
||||||
|
}
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFileFailed(Path file, IOException exc) {
|
||||||
|
errors.add("Cannot read: " + file.getFileName() + " (" + exc.getMessage() + ")");
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to scan '" + pattern + "': " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 glob 模式的文件名段是否已显式指定扩展名(如 {@code *.txt}、{@code *.{txt,md}}),
|
||||||
|
* 是则不再叠加 SUPPORTED_EXTENSIONS 过滤,以尊重用户的明确选择。
|
||||||
|
*/
|
||||||
|
private static boolean patternSpecifiesExtension(String pattern) {
|
||||||
|
int lastSlash = pattern.lastIndexOf('/');
|
||||||
|
String lastSeg = lastSlash >= 0 ? pattern.substring(lastSlash + 1) : pattern;
|
||||||
|
return lastSeg.contains(".") && containsWildcard(lastSeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsWildcard(String s) {
|
||||||
|
return s.contains("*") || s.contains("?") || s.contains("{") || s.contains("[");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getExtension(String fileName) {
|
||||||
int dot = fileName.lastIndexOf('.');
|
int dot = fileName.lastIndexOf('.');
|
||||||
return dot > 0 ? fileName.substring(dot + 1).toLowerCase() : "";
|
return dot > 0 ? fileName.substring(dot + 1).toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single point of truth for validating a KB source directory path, shared by
|
* Single point of truth for validating a KB source directory path, shared by
|
||||||
@ -22,6 +25,10 @@ import java.util.List;
|
|||||||
* canonicalized (opt-in enforcement — existing single-tenant / desktop setups
|
* canonicalized (opt-in enforcement — existing single-tenant / desktop setups
|
||||||
* keep working, server operators can lock it down).
|
* keep working, server operators can lock it down).
|
||||||
*
|
*
|
||||||
|
* <p>Also owns the parsing helpers for the multi-line source-paths config
|
||||||
|
* format so that both the validation endpoint and the scan service share a
|
||||||
|
* single implementation.
|
||||||
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -34,6 +41,48 @@ public class WikiSourcePathValidator {
|
|||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== parsing helpers (stateless, no Spring context) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the {@code sourceDirectory} field: split by newline, strip blank
|
||||||
|
* lines and lines starting with {@code #}.
|
||||||
|
*/
|
||||||
|
public static List<String> parseSourcePatterns(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return List.of();
|
||||||
|
return Arrays.stream(raw.split("\n"))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isBlank() && !s.startsWith("#"))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the fixed-prefix base directory from a glob pattern — the
|
||||||
|
* leading path segments before the first wildcard segment.
|
||||||
|
* <p>
|
||||||
|
* Examples:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code /data/ocr/**}{@code /*.txt} → {@code /data/ocr}</li>
|
||||||
|
* <li>{@code /data/*.txt} → {@code /data}</li>
|
||||||
|
* <li>{@code /data/docs} → {@code /data/docs} (no wildcard)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public static String extractBasePath(String pattern) {
|
||||||
|
if (!containsWildcard(pattern)) {
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
String[] segments = pattern.split("/", -1);
|
||||||
|
List<String> baseSegments = new ArrayList<>();
|
||||||
|
for (String seg : segments) {
|
||||||
|
if (containsWildcard(seg)) break;
|
||||||
|
baseSegments.add(seg);
|
||||||
|
}
|
||||||
|
if (baseSegments.isEmpty()) return "/";
|
||||||
|
String joined = String.join("/", baseSegments);
|
||||||
|
return joined.isEmpty() ? "/" : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== validation ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonicalize and authorize a source directory path.
|
* Canonicalize and authorize a source directory path.
|
||||||
*
|
*
|
||||||
@ -69,6 +118,40 @@ public class WikiSourcePathValidator {
|
|||||||
"Path is outside the allowed source roots: " + resolved);
|
"Path is outside the allowed source roots: " + resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate all patterns in a multi-line source-directory config. Each
|
||||||
|
* non-blank, non-comment line is validated; the first violation is thrown.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException describing which line failed and why
|
||||||
|
*/
|
||||||
|
public void validateSourcePatterns(String raw) {
|
||||||
|
List<String> patterns = parseSourcePatterns(raw);
|
||||||
|
for (String pattern : patterns) {
|
||||||
|
validatePatternBase(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single path-or-glob-pattern: for glob patterns the
|
||||||
|
* fixed-prefix base directory is extracted and validated; for plain paths
|
||||||
|
* the path itself is validated.
|
||||||
|
*
|
||||||
|
* @return the resolved base directory path
|
||||||
|
* @throws IllegalArgumentException when the base is outside the allowed roots
|
||||||
|
*/
|
||||||
|
public Path validatePatternBase(String pattern) {
|
||||||
|
if (pattern == null || pattern.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Pattern is blank");
|
||||||
|
}
|
||||||
|
String basePath = extractBasePath(pattern);
|
||||||
|
try {
|
||||||
|
return validateDirectory(basePath);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Pattern '" + pattern + "' has an invalid base path: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a path passes validation, without throwing. */
|
/** Whether a path passes validation, without throwing. */
|
||||||
public boolean isAllowed(String rawPath) {
|
public boolean isAllowed(String rawPath) {
|
||||||
try {
|
try {
|
||||||
@ -79,6 +162,8 @@ public class WikiSourcePathValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== private ====================
|
||||||
|
|
||||||
private Path canonicalize(Path path) {
|
private Path canonicalize(Path path) {
|
||||||
Path abs = path.toAbsolutePath().normalize();
|
Path abs = path.toAbsolutePath().normalize();
|
||||||
if (Files.exists(abs)) {
|
if (Files.exists(abs)) {
|
||||||
@ -90,4 +175,8 @@ public class WikiSourcePathValidator {
|
|||||||
}
|
}
|
||||||
return abs;
|
return abs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean containsWildcard(String s) {
|
||||||
|
return s.contains("*") || s.contains("?") || s.contains("{") || s.contains("[");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2203,13 +2203,13 @@ export default {
|
|||||||
watcher: {
|
watcher: {
|
||||||
tab: 'Watcher',
|
tab: 'Watcher',
|
||||||
title: 'Source Directory Watcher',
|
title: 'Source Directory Watcher',
|
||||||
desc: 'Bind a local directory as a knowledge source; the watcher scans for new/changed files at an interval and ingests them automatically. You can also trigger a one-off scan.',
|
desc: 'Configure local scan paths (multiple paths and glob patterns supported); the watcher scans for new/changed files at an interval and ingests them automatically. You can also trigger a one-off scan.',
|
||||||
enabled: 'Watcher enabled',
|
enabled: 'Watcher enabled',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
interval: 'Scan interval',
|
interval: 'Scan interval',
|
||||||
sourceType: 'Source type',
|
sourceType: 'Source type',
|
||||||
directory: 'Source directory',
|
directory: 'Scan paths',
|
||||||
dirHint: 'absolute path, e.g. /data/docs',
|
dirHint: 'One path or glob pattern per line; lines starting with # are comments. Examples:\n/data/docs\n/data/ocr/**/*.txt\n/data/reports/*.{xlsx,csv}',
|
||||||
availableTypes: 'Available source types',
|
availableTypes: 'Available source types',
|
||||||
scanNow: 'Scan now',
|
scanNow: 'Scan now',
|
||||||
scanDone: 'Scan complete',
|
scanDone: 'Scan complete',
|
||||||
|
|||||||
@ -2215,13 +2215,13 @@ export default {
|
|||||||
watcher: {
|
watcher: {
|
||||||
tab: '变更监测',
|
tab: '变更监测',
|
||||||
title: '源目录变更监测',
|
title: '源目录变更监测',
|
||||||
desc: '关联一个本地目录作为知识来源,监测器会按间隔扫描新增/变更文件并自动摄取。也可手动触发一次扫描。',
|
desc: '配置本地扫描路径(支持多路径、Glob 通配符),监测器会按间隔扫描新增/变更文件并自动摄取。也可手动触发一次扫描。',
|
||||||
enabled: '监测开关',
|
enabled: '监测开关',
|
||||||
active: '已激活',
|
active: '已激活',
|
||||||
interval: '扫描间隔',
|
interval: '扫描间隔',
|
||||||
sourceType: '源类型',
|
sourceType: '源类型',
|
||||||
directory: '源目录',
|
directory: '扫描路径',
|
||||||
dirHint: '绝对路径,例如 /data/docs',
|
dirHint: '每行一条路径或 Glob 模式,# 开头为注释。示例:\n/data/docs\n/data/ocr/**/*.txt\n/data/reports/*.{xlsx,csv}',
|
||||||
availableTypes: '可用源类型',
|
availableTypes: '可用源类型',
|
||||||
scanNow: '立即扫描',
|
scanNow: '立即扫描',
|
||||||
scanDone: '扫描完成',
|
scanDone: '扫描完成',
|
||||||
|
|||||||
@ -140,8 +140,13 @@
|
|||||||
<div class="kv"><span>{{ t('wiki.adv.watcher.sourceType') }}</span><b>{{ watcher.data.sourceType || '—' }}</b></div>
|
<div class="kv"><span>{{ t('wiki.adv.watcher.sourceType') }}</span><b>{{ watcher.data.sourceType || '—' }}</b></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="field-label">{{ t('wiki.adv.watcher.directory') }}</label>
|
<label class="field-label">{{ t('wiki.adv.watcher.directory') }}</label>
|
||||||
<div class="dir-row">
|
<textarea
|
||||||
<input v-model.trim="watcher.dir" class="form-input" :placeholder="t('wiki.adv.watcher.dirHint')" />
|
v-model="watcher.dir"
|
||||||
|
class="code-editor dir-editor"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="t('wiki.adv.watcher.dirHint')"
|
||||||
|
></textarea>
|
||||||
|
<div class="adv-actions">
|
||||||
<button class="btn-ghost" @click="saveDirectory" :disabled="watcher.busy">{{ t('common.save') }}</button>
|
<button class="btn-ghost" @click="saveDirectory" :disabled="watcher.busy">{{ t('common.save') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="watcher.data.availableSourceTypes?.length" class="adv-desc">
|
<p v-if="watcher.data.availableSourceTypes?.length" class="adv-desc">
|
||||||
@ -505,6 +510,7 @@ onMounted(() => { loaded.profile = true; loadProfile() })
|
|||||||
.kv b { font-size: 14px; color: var(--mc-text-primary); }
|
.kv b { font-size: 14px; color: var(--mc-text-primary); }
|
||||||
.dir-row { display: flex; gap: 8px; }
|
.dir-row { display: flex; gap: 8px; }
|
||||||
.dir-row .form-input { flex: 1; }
|
.dir-row .form-input { flex: 1; }
|
||||||
|
.dir-editor { min-height: 100px; font-size: 12.5px; }
|
||||||
|
|
||||||
.runs-box { border: 1px solid var(--mc-border-light); border-radius: 12px; padding: 12px; background: var(--mc-bg-muted); }
|
.runs-box { border: 1px solid var(--mc-border-light); border-radius: 12px; padding: 12px; background: var(--mc-bg-muted); }
|
||||||
.runs-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
.runs-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user