mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-13 19:11:15 +00:00
IgnoreRules (src/apps/cli/serviceModules/IgnoreRules.ts): - Reads .livesync/ignore for user-defined glob patterns - Applies gitignore matchBase semantics: patterns without / get **/ prefix, patterns ending with / get ** appended for directory contents - Supports `import: .gitignore` directive to merge gitignore patterns - Rejects negation patterns with a warning (not fully supportable) - Integrated into both daemon and mirror commands via isTargetFile handler Wiring: - IgnoreRules loaded before LiveSyncBaseCore construction so beginWatch() receives rules when it fires during onLoad/onFirstInitialise - Passed through initialiseServiceModulesCLI -> StorageEventManagerCLI -> CLIStorageEventManagerAdapter -> CLIWatchAdapter Deployment: - src/apps/cli/deploy/livesync-cli.service - systemd unit template - src/apps/cli/deploy/install.sh - user/system install script Testing: - src/apps/cli/test/test-daemon-linux.sh - e2e tests for ignore rules - src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts - 15 unit tests - src/apps/cli/commands/daemonCommand.unit.spec.ts - 7 unit tests
212 lines
7.0 KiB
TypeScript
212 lines
7.0 KiB
TypeScript
import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/types";
|
|
import type { FileEventItem } from "@lib/common/types";
|
|
import type { IStorageEventManagerAdapter } from "@lib/managers/adapters";
|
|
import type {
|
|
IStorageEventTypeGuardAdapter,
|
|
IStorageEventPersistenceAdapter,
|
|
IStorageEventWatchAdapter,
|
|
IStorageEventStatusAdapter,
|
|
IStorageEventConverterAdapter,
|
|
IStorageEventWatchHandlers,
|
|
} from "@lib/managers/adapters";
|
|
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
|
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
|
import type { Stats } from "fs";
|
|
import * as fs from "fs/promises";
|
|
import * as path from "path";
|
|
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
|
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
|
|
|
/**
|
|
* CLI-specific type guard adapter
|
|
*/
|
|
class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, NodeFolder> {
|
|
isFile(file: any): file is NodeFile {
|
|
return file && typeof file === "object" && "path" in file && "stat" in file && !file.isFolder;
|
|
}
|
|
|
|
isFolder(item: any): item is NodeFolder {
|
|
return item && typeof item === "object" && "path" in item && item.isFolder === true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CLI-specific persistence adapter (file-based snapshot)
|
|
*/
|
|
class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
|
private snapshotPath: string;
|
|
|
|
constructor(basePath: string) {
|
|
this.snapshotPath = path.join(basePath, ".livesync-snapshot.json");
|
|
}
|
|
|
|
async saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise<void> {
|
|
try {
|
|
await fs.writeFile(this.snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
|
|
} catch (error) {
|
|
console.error("Failed to save snapshot:", error);
|
|
}
|
|
}
|
|
|
|
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
|
|
try {
|
|
const content = await fs.readFile(this.snapshotPath, "utf-8");
|
|
return JSON.parse(content);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CLI-specific status adapter (no-op — daemon uses journald for status)
|
|
*/
|
|
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
|
updateStatus(_status: { batched: number; processing: number; totalQueued: number }): void {
|
|
// intentional no-op
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CLI-specific converter adapter
|
|
*/
|
|
class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
|
toFileInfo(file: NodeFile, deleted?: boolean): UXFileInfoStub {
|
|
return {
|
|
name: path.basename(file.path),
|
|
path: file.path,
|
|
stat: file.stat,
|
|
deleted: deleted,
|
|
isFolder: false,
|
|
};
|
|
}
|
|
|
|
toInternalFileInfo(p: FilePath): UXInternalFileInfoStub {
|
|
return {
|
|
name: path.basename(p),
|
|
path: p,
|
|
isInternal: true,
|
|
stat: undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CLI-specific watch adapter using chokidar for real-time filesystem monitoring.
|
|
*/
|
|
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
|
private _watcher: FSWatcher | undefined;
|
|
|
|
constructor(private basePath: string, private ignoreRules?: IgnoreRules, private watchEnabled: boolean = false) {}
|
|
|
|
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
|
return {
|
|
path: path.relative(this.basePath, filePath) as FilePath,
|
|
stat: {
|
|
ctime: stats?.ctimeMs ?? Date.now(),
|
|
mtime: stats?.mtimeMs ?? Date.now(),
|
|
size: stats?.size ?? 0,
|
|
type: "file",
|
|
},
|
|
};
|
|
}
|
|
|
|
private _toNodeFolder(dirPath: string): NodeFolder {
|
|
return {
|
|
path: path.relative(this.basePath, dirPath) as FilePath,
|
|
isFolder: true,
|
|
};
|
|
}
|
|
|
|
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
|
if (!this.watchEnabled) return;
|
|
const baseIgnored: Array<RegExp | string | ((p: string) => boolean)> = [
|
|
/(^|[/\\])\./,
|
|
/(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/,
|
|
];
|
|
// Bind rules to a local const before the closure — chokidar v4 requires a
|
|
// MatchFunction, not glob strings, for custom patterns.
|
|
const rules = this.ignoreRules;
|
|
const ignored = rules
|
|
? [...baseIgnored, (p: string) => rules.shouldIgnore(path.relative(this.basePath, p))]
|
|
: baseIgnored;
|
|
|
|
const watcher = chokidarWatch(this.basePath, {
|
|
ignored,
|
|
ignoreInitial: true,
|
|
persistent: true,
|
|
awaitWriteFinish: {
|
|
stabilityThreshold: 500,
|
|
pollInterval: 100,
|
|
},
|
|
});
|
|
|
|
watcher.on("add", (filePath, stats) => {
|
|
const nodeFile = this._toNodeFile(filePath, stats);
|
|
handlers.onCreate(nodeFile);
|
|
});
|
|
|
|
watcher.on("change", (filePath, stats) => {
|
|
const nodeFile = this._toNodeFile(filePath, stats);
|
|
handlers.onChange(nodeFile);
|
|
});
|
|
|
|
watcher.on("unlink", (filePath) => {
|
|
const nodeFile = this._toNodeFile(filePath, undefined);
|
|
handlers.onDelete(nodeFile);
|
|
});
|
|
|
|
watcher.on("addDir", (dirPath) => {
|
|
const nodeFolder = this._toNodeFolder(dirPath);
|
|
handlers.onCreate(nodeFolder);
|
|
});
|
|
|
|
watcher.on("unlinkDir", (dirPath) => {
|
|
const nodeFolder = this._toNodeFolder(dirPath);
|
|
handlers.onDelete(nodeFolder);
|
|
});
|
|
|
|
watcher.on("error", (err) => {
|
|
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
|
|
console.error("[CLIWatchAdapter] Exiting for systemd restart.");
|
|
void watcher.close();
|
|
this._watcher = undefined;
|
|
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages.
|
|
process.exit(1);
|
|
});
|
|
|
|
await new Promise<void>((resolve) => watcher.once("ready", resolve));
|
|
this._watcher = watcher;
|
|
}
|
|
|
|
close(): Promise<void> {
|
|
if (this._watcher) {
|
|
return this._watcher.close();
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Composite adapter for CLI StorageEventManager
|
|
*/
|
|
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
|
readonly typeGuard: CLITypeGuardAdapter;
|
|
readonly persistence: CLIPersistenceAdapter;
|
|
readonly watch: CLIWatchAdapter;
|
|
readonly status: CLIStatusAdapter;
|
|
readonly converter: CLIConverterAdapter;
|
|
|
|
constructor(basePath: string, ignoreRules?: IgnoreRules, watchEnabled: boolean = false) {
|
|
this.typeGuard = new CLITypeGuardAdapter();
|
|
this.persistence = new CLIPersistenceAdapter(basePath);
|
|
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled);
|
|
this.status = new CLIStatusAdapter();
|
|
this.converter = new CLIConverterAdapter();
|
|
}
|
|
|
|
close(): Promise<void> {
|
|
return this.watch.close();
|
|
}
|
|
}
|