2026-04-02 11:17:49 +05:30
|
|
|
import { BrowserWindow, dialog } from 'electron';
|
2026-04-02 11:55:29 +05:30
|
|
|
import chokidar, { type FSWatcher } from 'chokidar';
|
2026-04-03 00:40:49 +05:30
|
|
|
import { randomUUID } from 'crypto';
|
2026-04-02 11:17:49 +05:30
|
|
|
import * as path from 'path';
|
|
|
|
|
import * as fs from 'fs';
|
|
|
|
|
import { IPC_CHANNELS } from '../ipc/channels';
|
2026-04-18 14:35:14 -07:00
|
|
|
import { trackEvent } from './analytics';
|
2026-04-02 11:17:49 +05:30
|
|
|
|
|
|
|
|
export interface WatchedFolderConfig {
|
|
|
|
|
path: string;
|
|
|
|
|
name: string;
|
|
|
|
|
excludePatterns: string[];
|
|
|
|
|
fileExtensions: string[] | null;
|
2026-04-02 22:20:11 +05:30
|
|
|
rootFolderId: number | null;
|
2026-04-02 11:17:49 +05:30
|
|
|
searchSpaceId: number;
|
|
|
|
|
active: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WatcherEntry {
|
|
|
|
|
config: WatchedFolderConfig;
|
2026-04-02 11:55:29 +05:30
|
|
|
watcher: FSWatcher | null;
|
2026-04-02 11:17:49 +05:30
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
type MtimeMap = Record<string, number>;
|
2026-04-03 00:40:49 +05:30
|
|
|
type FolderSyncAction = 'add' | 'change' | 'unlink';
|
|
|
|
|
|
|
|
|
|
export interface FolderSyncFileChangedEvent {
|
|
|
|
|
id: string;
|
|
|
|
|
rootFolderId: number | null;
|
|
|
|
|
searchSpaceId: number;
|
|
|
|
|
folderPath: string;
|
|
|
|
|
folderName: string;
|
|
|
|
|
relativePath: string;
|
|
|
|
|
fullPath: string;
|
|
|
|
|
action: FolderSyncAction;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
}
|
2026-04-02 11:55:29 +05:30
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
const STORE_KEY = 'watchedFolders';
|
2026-04-03 00:40:49 +05:30
|
|
|
const OUTBOX_STORE_KEY = 'events';
|
2026-04-02 11:55:29 +05:30
|
|
|
const MTIME_TOLERANCE_S = 1.0;
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
let store: any = null;
|
2026-04-02 11:55:29 +05:30
|
|
|
let mtimeStore: any = null;
|
2026-04-03 00:40:49 +05:30
|
|
|
let outboxStore: any = null;
|
2026-04-02 11:17:49 +05:30
|
|
|
let watchers: Map<string, WatcherEntry> = new Map();
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
/**
|
|
|
|
|
* In-memory cache of mtime maps, keyed by folder path.
|
|
|
|
|
* Persisted to electron-store on mutation.
|
|
|
|
|
*/
|
|
|
|
|
const mtimeMaps: Map<string, MtimeMap> = new Map();
|
|
|
|
|
|
2026-04-02 22:20:11 +05:30
|
|
|
let rendererReady = false;
|
2026-04-03 00:40:49 +05:30
|
|
|
const outboxEvents: Map<string, FolderSyncFileChangedEvent> = new Map();
|
|
|
|
|
let outboxLoaded = false;
|
2026-04-02 22:20:11 +05:30
|
|
|
|
|
|
|
|
export function markRendererReady() {
|
|
|
|
|
rendererReady = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
async function getStore() {
|
|
|
|
|
if (!store) {
|
|
|
|
|
const { default: Store } = await import('electron-store');
|
|
|
|
|
store = new Store({
|
|
|
|
|
name: 'folder-watcher',
|
|
|
|
|
defaults: {
|
|
|
|
|
[STORE_KEY]: [] as WatchedFolderConfig[],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return store;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
async function getMtimeStore() {
|
|
|
|
|
if (!mtimeStore) {
|
|
|
|
|
const { default: Store } = await import('electron-store');
|
|
|
|
|
mtimeStore = new Store({
|
|
|
|
|
name: 'folder-mtime-maps',
|
|
|
|
|
defaults: {} as Record<string, MtimeMap>,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return mtimeStore;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:40:49 +05:30
|
|
|
async function getOutboxStore() {
|
|
|
|
|
if (!outboxStore) {
|
|
|
|
|
const { default: Store } = await import('electron-store');
|
|
|
|
|
outboxStore = new Store({
|
|
|
|
|
name: 'folder-sync-outbox',
|
|
|
|
|
defaults: {
|
|
|
|
|
[OUTBOX_STORE_KEY]: [] as FolderSyncFileChangedEvent[],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return outboxStore;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeEventKey(event: Pick<FolderSyncFileChangedEvent, 'folderPath' | 'relativePath'>): string {
|
|
|
|
|
return `${event.folderPath}:${event.relativePath}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persistOutbox() {
|
|
|
|
|
getOutboxStore().then((s) => {
|
|
|
|
|
s.set(OUTBOX_STORE_KEY, Array.from(outboxEvents.values()));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOutbox() {
|
|
|
|
|
if (outboxLoaded) return;
|
|
|
|
|
const s = await getOutboxStore();
|
|
|
|
|
const stored: FolderSyncFileChangedEvent[] = s.get(OUTBOX_STORE_KEY, []);
|
|
|
|
|
outboxEvents.clear();
|
|
|
|
|
for (const event of stored) {
|
|
|
|
|
if (!event?.id || !event.folderPath || !event.relativePath) continue;
|
|
|
|
|
outboxEvents.set(makeEventKey(event), event);
|
|
|
|
|
}
|
|
|
|
|
outboxLoaded = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendFileChangedEvent(
|
|
|
|
|
data: Omit<FolderSyncFileChangedEvent, 'id'>
|
|
|
|
|
) {
|
|
|
|
|
const event: FolderSyncFileChangedEvent = {
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
...data,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
outboxEvents.set(makeEventKey(event), event);
|
|
|
|
|
persistOutbox();
|
|
|
|
|
|
|
|
|
|
if (rendererReady) {
|
|
|
|
|
sendToRenderer(IPC_CHANNELS.FOLDER_SYNC_FILE_CHANGED, event);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
function loadMtimeMap(folderPath: string): MtimeMap {
|
|
|
|
|
return mtimeMaps.get(folderPath) ?? {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persistMtimeMap(folderPath: string) {
|
|
|
|
|
const map = mtimeMaps.get(folderPath) ?? {};
|
|
|
|
|
getMtimeStore().then((s) => s.set(folderPath, map));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function walkFolderMtimes(config: WatchedFolderConfig): MtimeMap {
|
|
|
|
|
const root = config.path;
|
|
|
|
|
const result: MtimeMap = {};
|
|
|
|
|
const excludes = new Set(config.excludePatterns);
|
|
|
|
|
|
|
|
|
|
function walk(dir: string) {
|
|
|
|
|
let entries: fs.Dirent[];
|
|
|
|
|
try {
|
|
|
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
const name = entry.name;
|
|
|
|
|
|
|
|
|
|
if (name.startsWith('.') || excludes.has(name)) continue;
|
|
|
|
|
|
|
|
|
|
const full = path.join(dir, name);
|
|
|
|
|
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
walk(full);
|
|
|
|
|
} else if (entry.isFile()) {
|
|
|
|
|
if (
|
|
|
|
|
config.fileExtensions &&
|
|
|
|
|
config.fileExtensions.length > 0
|
|
|
|
|
) {
|
|
|
|
|
const ext = path.extname(name).toLowerCase();
|
|
|
|
|
if (!config.fileExtensions.includes(ext)) continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const stat = fs.statSync(full);
|
|
|
|
|
const rel = path.relative(root, full);
|
|
|
|
|
result[rel] = stat.mtimeMs;
|
|
|
|
|
} catch {
|
|
|
|
|
// File may have been removed between readdir and stat
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
walk(root);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 15:46:52 +05:30
|
|
|
export interface FolderFileEntry {
|
|
|
|
|
relativePath: string;
|
|
|
|
|
fullPath: string;
|
|
|
|
|
size: number;
|
|
|
|
|
mtimeMs: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listFolderFiles(config: WatchedFolderConfig): FolderFileEntry[] {
|
|
|
|
|
const root = config.path;
|
|
|
|
|
const mtimeMap = walkFolderMtimes(config);
|
|
|
|
|
const entries: FolderFileEntry[] = [];
|
|
|
|
|
|
|
|
|
|
for (const [relativePath, mtimeMs] of Object.entries(mtimeMap)) {
|
|
|
|
|
const fullPath = path.join(root, relativePath);
|
|
|
|
|
try {
|
|
|
|
|
const stat = fs.statSync(fullPath);
|
|
|
|
|
entries.push({ relativePath, fullPath, size: stat.size, mtimeMs });
|
|
|
|
|
} catch {
|
|
|
|
|
// File may have been removed between walk and stat
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entries;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
function getMainWindow(): BrowserWindow | null {
|
|
|
|
|
const windows = BrowserWindow.getAllWindows();
|
|
|
|
|
return windows.length > 0 ? windows[0] : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendToRenderer(channel: string, data: any) {
|
|
|
|
|
const win = getMainWindow();
|
|
|
|
|
if (win && !win.isDestroyed()) {
|
|
|
|
|
win.webContents.send(channel, data);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
async function startWatcher(config: WatchedFolderConfig) {
|
2026-04-02 11:17:49 +05:30
|
|
|
if (watchers.has(config.path)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
const ms = await getMtimeStore();
|
|
|
|
|
const storedMap: MtimeMap = ms.get(config.path) ?? {};
|
|
|
|
|
mtimeMaps.set(config.path, { ...storedMap });
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
const ignored = [
|
|
|
|
|
/(^|[/\\])\../, // dotfiles by default
|
|
|
|
|
...config.excludePatterns.map((p) => `**/${p}/**`),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const watcher = chokidar.watch(config.path, {
|
|
|
|
|
persistent: true,
|
2026-04-02 11:55:29 +05:30
|
|
|
ignoreInitial: true,
|
2026-04-02 11:17:49 +05:30
|
|
|
awaitWriteFinish: {
|
|
|
|
|
stabilityThreshold: 500,
|
|
|
|
|
pollInterval: 100,
|
|
|
|
|
},
|
|
|
|
|
ignored,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let ready = false;
|
|
|
|
|
|
|
|
|
|
watcher.on('ready', () => {
|
|
|
|
|
ready = true;
|
2026-04-02 11:55:29 +05:30
|
|
|
|
|
|
|
|
const currentMap = walkFolderMtimes(config);
|
|
|
|
|
const storedSnapshot = loadMtimeMap(config.path);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
2026-04-02 22:20:11 +05:30
|
|
|
// Track which files are unchanged so we can selectively update the mtime map
|
|
|
|
|
const unchangedMap: MtimeMap = {};
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
for (const [rel, currentMtime] of Object.entries(currentMap)) {
|
|
|
|
|
const storedMtime = storedSnapshot[rel];
|
|
|
|
|
if (storedMtime === undefined) {
|
2026-04-02 22:20:11 +05:30
|
|
|
sendFileChangedEvent({
|
|
|
|
|
rootFolderId: config.rootFolderId,
|
2026-04-02 11:55:29 +05:30
|
|
|
searchSpaceId: config.searchSpaceId,
|
|
|
|
|
folderPath: config.path,
|
2026-04-02 22:20:11 +05:30
|
|
|
folderName: config.name,
|
2026-04-02 11:55:29 +05:30
|
|
|
relativePath: rel,
|
|
|
|
|
fullPath: path.join(config.path, rel),
|
|
|
|
|
action: 'add',
|
|
|
|
|
timestamp: now,
|
|
|
|
|
});
|
|
|
|
|
} else if (Math.abs(currentMtime - storedMtime) >= MTIME_TOLERANCE_S * 1000) {
|
2026-04-02 22:20:11 +05:30
|
|
|
sendFileChangedEvent({
|
|
|
|
|
rootFolderId: config.rootFolderId,
|
2026-04-02 11:55:29 +05:30
|
|
|
searchSpaceId: config.searchSpaceId,
|
|
|
|
|
folderPath: config.path,
|
2026-04-02 22:20:11 +05:30
|
|
|
folderName: config.name,
|
2026-04-02 11:55:29 +05:30
|
|
|
relativePath: rel,
|
|
|
|
|
fullPath: path.join(config.path, rel),
|
|
|
|
|
action: 'change',
|
|
|
|
|
timestamp: now,
|
|
|
|
|
});
|
2026-04-02 22:20:11 +05:30
|
|
|
} else {
|
|
|
|
|
unchangedMap[rel] = currentMtime;
|
2026-04-02 11:55:29 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const rel of Object.keys(storedSnapshot)) {
|
|
|
|
|
if (!(rel in currentMap)) {
|
2026-04-02 22:20:11 +05:30
|
|
|
sendFileChangedEvent({
|
|
|
|
|
rootFolderId: config.rootFolderId,
|
2026-04-02 11:55:29 +05:30
|
|
|
searchSpaceId: config.searchSpaceId,
|
|
|
|
|
folderPath: config.path,
|
2026-04-02 22:20:11 +05:30
|
|
|
folderName: config.name,
|
2026-04-02 11:55:29 +05:30
|
|
|
relativePath: rel,
|
|
|
|
|
fullPath: path.join(config.path, rel),
|
|
|
|
|
action: 'unlink',
|
|
|
|
|
timestamp: now,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:20:11 +05:30
|
|
|
// Only update the mtime map for unchanged files; changed files keep their
|
|
|
|
|
// stored mtime so they'll be re-detected if the app crashes before indexing.
|
|
|
|
|
mtimeMaps.set(config.path, unchangedMap);
|
2026-04-02 11:55:29 +05:30
|
|
|
persistMtimeMap(config.path);
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
sendToRenderer(IPC_CHANNELS.FOLDER_SYNC_WATCHER_READY, {
|
2026-04-02 22:20:11 +05:30
|
|
|
rootFolderId: config.rootFolderId,
|
2026-04-02 11:17:49 +05:30
|
|
|
folderPath: config.path,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-03 00:40:49 +05:30
|
|
|
const handleFileEvent = (filePath: string, action: FolderSyncAction) => {
|
2026-04-02 11:17:49 +05:30
|
|
|
if (!ready) return;
|
|
|
|
|
|
|
|
|
|
const relativePath = path.relative(config.path, filePath);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
config.fileExtensions &&
|
|
|
|
|
config.fileExtensions.length > 0
|
|
|
|
|
) {
|
|
|
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
|
|
|
if (!config.fileExtensions.includes(ext)) return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
const map = mtimeMaps.get(config.path);
|
|
|
|
|
if (map) {
|
|
|
|
|
if (action === 'unlink') {
|
|
|
|
|
delete map[relativePath];
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map[relativePath] = fs.statSync(filePath).mtimeMs;
|
|
|
|
|
} catch {
|
|
|
|
|
// File may have been removed between event and stat
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
persistMtimeMap(config.path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 22:20:11 +05:30
|
|
|
sendFileChangedEvent({
|
|
|
|
|
rootFolderId: config.rootFolderId,
|
2026-04-02 11:17:49 +05:30
|
|
|
searchSpaceId: config.searchSpaceId,
|
|
|
|
|
folderPath: config.path,
|
2026-04-02 22:20:11 +05:30
|
|
|
folderName: config.name,
|
2026-04-02 11:17:49 +05:30
|
|
|
relativePath,
|
|
|
|
|
fullPath: filePath,
|
|
|
|
|
action,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
watcher.on('add', (fp) => handleFileEvent(fp, 'add'));
|
|
|
|
|
watcher.on('change', (fp) => handleFileEvent(fp, 'change'));
|
|
|
|
|
watcher.on('unlink', (fp) => handleFileEvent(fp, 'unlink'));
|
|
|
|
|
|
|
|
|
|
watchers.set(config.path, { config, watcher });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopWatcher(folderPath: string) {
|
2026-04-02 11:55:29 +05:30
|
|
|
persistMtimeMap(folderPath);
|
2026-04-02 11:17:49 +05:30
|
|
|
const entry = watchers.get(folderPath);
|
|
|
|
|
if (entry?.watcher) {
|
|
|
|
|
entry.watcher.close();
|
|
|
|
|
}
|
|
|
|
|
watchers.delete(folderPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function selectFolder(): Promise<string | null> {
|
|
|
|
|
const result = await dialog.showOpenDialog({
|
|
|
|
|
properties: ['openDirectory'],
|
|
|
|
|
title: 'Select a folder to watch',
|
|
|
|
|
});
|
|
|
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return result.filePaths[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function addWatchedFolder(
|
|
|
|
|
config: WatchedFolderConfig
|
|
|
|
|
): Promise<WatchedFolderConfig[]> {
|
|
|
|
|
const s = await getStore();
|
|
|
|
|
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
|
|
|
|
|
|
|
|
|
const existing = folders.findIndex((f: WatchedFolderConfig) => f.path === config.path);
|
|
|
|
|
if (existing >= 0) {
|
|
|
|
|
folders[existing] = config;
|
|
|
|
|
} else {
|
|
|
|
|
folders.push(config);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.set(STORE_KEY, folders);
|
|
|
|
|
|
|
|
|
|
if (config.active) {
|
2026-04-02 11:55:29 +05:30
|
|
|
await startWatcher(config);
|
2026-04-02 11:17:49 +05:30
|
|
|
}
|
|
|
|
|
|
2026-04-18 14:35:14 -07:00
|
|
|
trackEvent('desktop_folder_watch_added', {
|
|
|
|
|
search_space_id: config.searchSpaceId,
|
|
|
|
|
root_folder_id: config.rootFolderId,
|
|
|
|
|
active: config.active,
|
|
|
|
|
has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0,
|
|
|
|
|
has_extension_filter: !!config.fileExtensions && config.fileExtensions.length > 0,
|
|
|
|
|
is_update: existing >= 0,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
return folders;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function removeWatchedFolder(
|
|
|
|
|
folderPath: string
|
|
|
|
|
): Promise<WatchedFolderConfig[]> {
|
|
|
|
|
const s = await getStore();
|
|
|
|
|
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
2026-04-18 14:35:14 -07:00
|
|
|
const removed = folders.find((f: WatchedFolderConfig) => f.path === folderPath);
|
2026-04-02 11:17:49 +05:30
|
|
|
const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath);
|
|
|
|
|
s.set(STORE_KEY, updated);
|
|
|
|
|
|
|
|
|
|
stopWatcher(folderPath);
|
|
|
|
|
|
2026-04-02 11:55:29 +05:30
|
|
|
mtimeMaps.delete(folderPath);
|
|
|
|
|
const ms = await getMtimeStore();
|
|
|
|
|
ms.delete(folderPath);
|
|
|
|
|
|
2026-04-18 14:35:14 -07:00
|
|
|
if (removed) {
|
|
|
|
|
trackEvent('desktop_folder_watch_removed', {
|
|
|
|
|
search_space_id: removed.searchSpaceId,
|
|
|
|
|
root_folder_id: removed.rootFolderId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getWatchedFolders(): Promise<WatchedFolderConfig[]> {
|
|
|
|
|
const s = await getStore();
|
|
|
|
|
return s.get(STORE_KEY, []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getWatcherStatus(): Promise<
|
|
|
|
|
{ path: string; active: boolean; watching: boolean }[]
|
|
|
|
|
> {
|
|
|
|
|
const s = await getStore();
|
|
|
|
|
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
|
|
|
|
return folders.map((f: WatchedFolderConfig) => ({
|
|
|
|
|
path: f.path,
|
|
|
|
|
active: f.active,
|
|
|
|
|
watching: watchers.has(f.path),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:40:49 +05:30
|
|
|
export async function getPendingFileEvents(): Promise<FolderSyncFileChangedEvent[]> {
|
|
|
|
|
await loadOutbox();
|
|
|
|
|
return Array.from(outboxEvents.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function acknowledgeFileEvents(eventIds: string[]): Promise<{ acknowledged: number }> {
|
|
|
|
|
if (!eventIds || eventIds.length === 0) return { acknowledged: 0 };
|
|
|
|
|
await loadOutbox();
|
|
|
|
|
|
|
|
|
|
const ackSet = new Set(eventIds);
|
|
|
|
|
let acknowledged = 0;
|
2026-04-08 16:07:25 +05:30
|
|
|
const foldersToUpdate = new Set<string>();
|
2026-04-03 00:40:49 +05:30
|
|
|
|
|
|
|
|
for (const [key, event] of outboxEvents.entries()) {
|
|
|
|
|
if (ackSet.has(event.id)) {
|
2026-04-08 16:07:25 +05:30
|
|
|
if (event.action !== 'unlink') {
|
|
|
|
|
const map = mtimeMaps.get(event.folderPath);
|
|
|
|
|
if (map) {
|
|
|
|
|
try {
|
|
|
|
|
map[event.relativePath] = fs.statSync(event.fullPath).mtimeMs;
|
|
|
|
|
foldersToUpdate.add(event.folderPath);
|
|
|
|
|
} catch {
|
|
|
|
|
// File may have been removed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 00:40:49 +05:30
|
|
|
outboxEvents.delete(key);
|
|
|
|
|
acknowledged += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 16:07:25 +05:30
|
|
|
for (const fp of foldersToUpdate) {
|
|
|
|
|
persistMtimeMap(fp);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:40:49 +05:30
|
|
|
if (acknowledged > 0) {
|
|
|
|
|
persistOutbox();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { acknowledged };
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 16:07:25 +05:30
|
|
|
export async function seedFolderMtimes(
|
|
|
|
|
folderPath: string,
|
|
|
|
|
mtimes: Record<string, number>,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const ms = await getMtimeStore();
|
|
|
|
|
const existing: MtimeMap = ms.get(folderPath) ?? {};
|
|
|
|
|
const merged = { ...existing, ...mtimes };
|
|
|
|
|
mtimeMaps.set(folderPath, merged);
|
|
|
|
|
ms.set(folderPath, merged);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:17:49 +05:30
|
|
|
export async function pauseWatcher(): Promise<void> {
|
|
|
|
|
for (const [, entry] of watchers) {
|
|
|
|
|
if (entry.watcher) {
|
|
|
|
|
await entry.watcher.close();
|
|
|
|
|
entry.watcher = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function resumeWatcher(): Promise<void> {
|
2026-04-02 11:55:29 +05:30
|
|
|
for (const [, entry] of watchers) {
|
2026-04-02 11:17:49 +05:30
|
|
|
if (!entry.watcher && entry.config.active) {
|
2026-04-02 11:55:29 +05:30
|
|
|
await startWatcher(entry.config);
|
2026-04-02 11:17:49 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function registerFolderWatcher(): Promise<void> {
|
2026-04-03 00:40:49 +05:30
|
|
|
await loadOutbox();
|
2026-04-02 11:17:49 +05:30
|
|
|
const s = await getStore();
|
|
|
|
|
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
|
|
|
|
|
|
|
|
|
|
for (const config of folders) {
|
|
|
|
|
if (config.active && fs.existsSync(config.path)) {
|
2026-04-02 11:55:29 +05:30
|
|
|
await startWatcher(config);
|
2026-04-02 11:17:49 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function unregisterFolderWatcher(): Promise<void> {
|
|
|
|
|
for (const [folderPath] of watchers) {
|
|
|
|
|
stopWatcher(folderPath);
|
|
|
|
|
}
|
|
|
|
|
watchers.clear();
|
|
|
|
|
}
|
2026-04-03 00:28:24 +05:30
|
|
|
|
2026-04-03 02:56:24 +05:30
|
|
|
export async function browseFiles(): Promise<string[] | null> {
|
2026-04-03 00:28:24 +05:30
|
|
|
const result = await dialog.showOpenDialog({
|
2026-04-03 02:56:24 +05:30
|
|
|
properties: ['openFile', 'multiSelections'],
|
|
|
|
|
title: 'Select files',
|
2026-04-03 00:28:24 +05:30
|
|
|
});
|
|
|
|
|
if (result.canceled || result.filePaths.length === 0) return null;
|
2026-04-03 02:56:24 +05:30
|
|
|
return result.filePaths;
|
2026-04-03 00:28:24 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MIME_MAP: Record<string, string> = {
|
|
|
|
|
'.pdf': 'application/pdf',
|
|
|
|
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
|
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
|
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
|
|
|
'.html': 'text/html', '.htm': 'text/html',
|
|
|
|
|
'.csv': 'text/csv',
|
|
|
|
|
'.txt': 'text/plain',
|
|
|
|
|
'.md': 'text/markdown', '.markdown': 'text/markdown',
|
|
|
|
|
'.mp3': 'audio/mpeg', '.mpeg': 'audio/mpeg', '.mpga': 'audio/mpeg',
|
|
|
|
|
'.mp4': 'audio/mp4', '.m4a': 'audio/mp4',
|
|
|
|
|
'.wav': 'audio/wav',
|
|
|
|
|
'.webm': 'audio/webm',
|
|
|
|
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
|
|
|
'.png': 'image/png',
|
|
|
|
|
'.bmp': 'image/bmp',
|
|
|
|
|
'.webp': 'image/webp',
|
|
|
|
|
'.tiff': 'image/tiff',
|
|
|
|
|
'.doc': 'application/msword',
|
|
|
|
|
'.rtf': 'application/rtf',
|
|
|
|
|
'.xml': 'application/xml',
|
|
|
|
|
'.epub': 'application/epub+zip',
|
|
|
|
|
'.xls': 'application/vnd.ms-excel',
|
|
|
|
|
'.ppt': 'application/vnd.ms-powerpoint',
|
|
|
|
|
'.eml': 'message/rfc822',
|
|
|
|
|
'.odt': 'application/vnd.oasis.opendocument.text',
|
|
|
|
|
'.msg': 'application/vnd.ms-outlook',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface LocalFileData {
|
|
|
|
|
name: string;
|
|
|
|
|
data: ArrayBuffer;
|
|
|
|
|
mimeType: string;
|
|
|
|
|
size: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readLocalFiles(filePaths: string[]): LocalFileData[] {
|
|
|
|
|
return filePaths.map((p) => {
|
|
|
|
|
const buf = fs.readFileSync(p);
|
|
|
|
|
const ext = path.extname(p).toLowerCase();
|
|
|
|
|
return {
|
|
|
|
|
name: path.basename(p),
|
|
|
|
|
data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
|
|
|
|
|
mimeType: MIME_MAP[ext] || 'application/octet-stream',
|
|
|
|
|
size: buf.byteLength,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|