Project Files
src / documents / fileWatcher.ts
/**
* File Watcher for Find Image
* Watches all source directories for changes and invalidates cache
*/
import { watch, type FSWatcher } from "fs";
import { existsSync, readFileSync } from "fs";
import path from "path";
import { invalidateCache } from "../indexer.js";
import { SUPPORTED_IMAGE_EXTENSION_PATTERN, SUPPORTED_IMAGE_WATCH_EXTENSIONS } from "./imageFormats.js";
// ═══════════════════════════════════════════════════════════════
// Multi-Directory Watcher
// ═══════════════════════════════════════════════════════════════
interface WatcherEntry {
watcher: FSWatcher;
extensions: string[];
}
const watchers = new Map<string, WatcherEntry>();
/**
* File extensions for each source type
*/
const SOURCE_EXTENSIONS = {
images: SUPPORTED_IMAGE_WATCH_EXTENSIONS,
projects: ['.sqlite3', '.sqlite3-wal'],
};
export const PROJECT_FILE_EXTENSIONS = SOURCE_EXTENSIONS.projects;
const HARVESTED_PROJECT_RESULT_RE = new RegExp(`^(?:preview-)?.+\\.sqlite3#\\d+\\.${SUPPORTED_IMAGE_EXTENSION_PATTERN}$`, "i");
export function isHarvestedProjectResultPath(filename: string): boolean {
const normalized = filename.toString().replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
if (parts.length < 2 || !/^\d+$/.test(parts[0])) return false;
return HARVESTED_PROJECT_RESULT_RE.test(parts[parts.length - 1] ?? "");
}
export function shouldInvalidateForFile(
filename: string,
extensions: string[],
watchRoot?: string,
): boolean {
const normalized = filename.toString();
if (isHarvestedProjectResultPath(normalized)) return false;
if (watchRoot && isMaterializedFindImageResultPath(watchRoot, normalized)) return false;
const matchesExt = extensions.some((ext) => normalized.toLowerCase().endsWith(ext));
if (!matchesExt) return false;
return true;
}
/**
* Start watching a directory for specific file types
*/
export function startWatching(
dirPath: string,
extensions: string[] = SOURCE_EXTENSIONS.images,
): void {
// Already watching this path?
if (watchers.has(dirPath)) {
return;
}
if (!existsSync(dirPath)) {
console.warn(`[FileWatcher] Directory does not exist: ${dirPath}`);
return;
}
try {
const fsWatcher = watch(dirPath, { recursive: true }, (eventType, filename) => {
if (!filename) {
console.log(`[FileWatcher] ${eventType}: ${dirPath}`);
invalidateCache();
return;
}
const normalized = filename.toString();
if (!shouldInvalidateForFile(normalized, extensions, dirPath)) return;
console.log(`[FileWatcher] ${eventType}: ${normalized}`);
invalidateCache();
});
watchers.set(dirPath, { watcher: fsWatcher, extensions });
console.log(`[FileWatcher] Watching: ${dirPath} (${extensions.join(', ')})`);
fsWatcher.on("error", (err) => {
console.warn(`[FileWatcher] Error on ${dirPath}: ${err.message}`);
stopWatching(dirPath);
});
} catch (e) {
console.warn(`[FileWatcher] Failed to start on ${dirPath}: ${e}`);
}
}
/**
* Convenience functions for each source type
*/
export function watchImages(dirPath: string): void {
startWatching(dirPath, SOURCE_EXTENSIONS.images);
}
export function watchProjects(dirPath: string): void {
startWatching(dirPath, PROJECT_FILE_EXTENSIONS);
}
/**
* Stop watching a specific directory
*/
export function stopWatching(dirPath?: string): void {
if (dirPath) {
const entry = watchers.get(dirPath);
if (entry) {
entry.watcher.close();
watchers.delete(dirPath);
console.log(`[FileWatcher] Stopped: ${dirPath}`);
}
} else {
// Stop all watchers
for (const [path, entry] of watchers) {
entry.watcher.close();
console.log(`[FileWatcher] Stopped: ${path}`);
}
watchers.clear();
}
}
/**
* Check if watching a specific path
*/
export function isWatching(dirPath?: string): boolean {
if (dirPath) {
return watchers.has(dirPath);
}
return watchers.size > 0;
}
/**
* Get all watched paths
*/
export function getWatchedPaths(): string[] {
return Array.from(watchers.keys());
}
function isMaterializedFindImageResultPath(watchRoot: string, relativePath: string): boolean {
const normalized = relativePath.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
if (parts.length < 2 || !/^\d+$/.test(parts[0])) return false;
const workingDir = path.join(watchRoot, parts[0]);
const statePath = path.join(workingDir, "chat_media_state.json");
let state: any;
try {
state = JSON.parse(readFileSync(statePath, "utf-8"));
} catch {
return false;
}
const absolutePath = path.resolve(watchRoot, relativePath);
const records = [
...(Array.isArray(state.attachments) ? state.attachments : []),
...(Array.isArray(state.variants) ? state.variants : []),
...(Array.isArray(state.images) ? state.images : []),
...(Array.isArray(state.pictures) ? state.pictures : []),
];
return records.some((record: any) => {
if (!isFindImageRecord(record)) return false;
return [record.filename, record.preview].some((value) => {
if (typeof value !== "string" || !value.trim()) return false;
const candidate = path.resolve(workingDir, value.trim());
return candidate === absolutePath;
});
});
}
function isFindImageRecord(record: any): boolean {
const pluginId = typeof record?.pluginId === "string" ? record.pluginId.toLowerCase() : "";
const sourceTool = typeof record?.sourceTool === "string" ? record.sourceTool.toLowerCase() : "";
return pluginId === "ceveyne/find-image" || sourceTool.includes("find_image") || sourceTool.includes("find-image");
}