Add self-hosted-livesync-cli to src/apps/cli as a headless, and a dedicated version.

This commit is contained in:
vorotamoroz
2026-03-11 14:51:01 +01:00
parent 2f8bc4fef2
commit 0742773e1e
26 changed files with 2839 additions and 143 deletions

View File

@@ -0,0 +1,28 @@
import * as path from "path";
import type { UXFileInfoStub, UXFolderInfo } from "@lib/common/types";
import type { IConversionAdapter } from "@lib/serviceModules/adapters";
import type { NodeFile, NodeFolder } from "./NodeTypes";
/**
* Conversion adapter implementation for Node.js
*/
export class NodeConversionAdapter implements IConversionAdapter<NodeFile, NodeFolder> {
nativeFileToUXFileInfoStub(file: NodeFile): UXFileInfoStub {
return {
name: path.basename(file.path),
path: file.path,
stat: file.stat,
isFolder: false,
};
}
nativeFolderToUXFolder(folder: NodeFolder): UXFolderInfo {
return {
name: path.basename(folder.path),
path: folder.path,
isFolder: true,
children: [],
parent: path.dirname(folder.path) as any,
};
}
}

View File

@@ -0,0 +1,153 @@
import * as fs from "fs/promises";
import * as path from "path";
import type { FilePath, UXStat } from "@lib/common/types";
import type { IFileSystemAdapter } from "@lib/serviceModules/adapters";
import { NodePathAdapter } from "./NodePathAdapter";
import { NodeTypeGuardAdapter } from "./NodeTypeGuardAdapter";
import { NodeConversionAdapter } from "./NodeConversionAdapter";
import { NodeStorageAdapter } from "./NodeStorageAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
/**
* Complete file system adapter implementation for Node.js
*/
export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeFile, NodeFolder, NodeStat> {
readonly path: NodePathAdapter;
readonly typeGuard: NodeTypeGuardAdapter;
readonly conversion: NodeConversionAdapter;
readonly storage: NodeStorageAdapter;
readonly vault: NodeVaultAdapter;
private fileCache = new Map<string, NodeFile>();
constructor(private basePath: string) {
this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter();
this.storage = new NodeStorageAdapter(basePath);
this.vault = new NodeVaultAdapter(basePath);
}
private resolvePath(p: FilePath | string): string {
return path.join(this.basePath, p);
}
private normalisePath(p: FilePath | string): string {
return this.path.normalisePath(p as string);
}
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
const cached = this.fileCache.get(pathStr);
if (cached) {
return cached;
}
return await this.refreshFile(pathStr);
}
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
const exact = await this.getAbstractFileByPath(pathStr);
if (exact) {
return exact;
}
const lowerPath = pathStr.toLowerCase();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
await this.scanDirectory();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
return null;
}
async getFiles(): Promise<NodeFile[]> {
if (this.fileCache.size === 0) {
await this.scanDirectory();
}
return Array.from(this.fileCache.values());
}
async statFromNative(file: NodeFile): Promise<UXStat> {
return file.stat;
}
async reconcileInternalFile(p: string): Promise<void> {
// No-op in Node.js version
// This is used by Obsidian to sync internal file metadata
}
async refreshFile(p: string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
try {
const fullPath = this.resolvePath(pathStr);
const stat = await fs.stat(fullPath);
if (!stat.isFile()) {
this.fileCache.delete(pathStr);
return null;
}
const file: NodeFile = {
path: pathStr as FilePath,
stat: {
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
type: "file",
},
};
this.fileCache.set(pathStr, file);
return file;
} catch {
this.fileCache.delete(pathStr);
return null;
}
}
/**
* Helper method to recursively scan directory and populate file cache
*/
async scanDirectory(relativePath: string = ""): Promise<void> {
const fullPath = this.resolvePath(relativePath);
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
const entryRelativePath = path.join(relativePath, entry.name).replace(/\\/g, "/");
if (entry.isDirectory()) {
await this.scanDirectory(entryRelativePath);
} else if (entry.isFile()) {
const entryFullPath = this.resolvePath(entryRelativePath);
const stat = await fs.stat(entryFullPath);
const file: NodeFile = {
path: entryRelativePath as FilePath,
stat: {
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
type: "file",
},
};
this.fileCache.set(entryRelativePath, file);
}
}
} catch (error) {
// Directory doesn't exist or is not readable
console.error(`Error scanning directory ${fullPath}:`, error);
}
}
}

View File

@@ -0,0 +1,18 @@
import * as path from "path";
import type { FilePath } from "@lib/common/types";
import type { IPathAdapter } from "@lib/serviceModules/adapters";
import type { NodeFile } from "./NodeTypes";
/**
* Path adapter implementation for Node.js
*/
export class NodePathAdapter implements IPathAdapter<NodeFile> {
getPath(file: string | NodeFile): FilePath {
return (typeof file === "string" ? file : file.path) as FilePath;
}
normalisePath(p: string): string {
// Normalize path separators to forward slashes (like Obsidian)
return path.normalize(p).replace(/\\/g, "/");
}
}

View File

@@ -0,0 +1,124 @@
import * as fs from "fs/promises";
import * as path from "path";
import type { UXDataWriteOptions } from "@lib/common/types";
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
import type { NodeStat } from "./NodeTypes";
/**
* Storage adapter implementation for Node.js
*/
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
constructor(private basePath: string) {}
private resolvePath(p: string): string {
return path.join(this.basePath, p);
}
async exists(p: string): Promise<boolean> {
try {
await fs.access(this.resolvePath(p));
return true;
} catch {
return false;
}
}
async trystat(p: string): Promise<NodeStat | null> {
try {
const stat = await fs.stat(this.resolvePath(p));
return {
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
type: stat.isDirectory() ? "folder" : "file",
};
} catch {
return null;
}
}
async stat(p: string): Promise<NodeStat | null> {
return await this.trystat(p);
}
async mkdir(p: string): Promise<void> {
await fs.mkdir(this.resolvePath(p), { recursive: true });
}
async remove(p: string): Promise<void> {
const fullPath = this.resolvePath(p);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force: true });
} else {
await fs.unlink(fullPath);
}
}
async read(p: string): Promise<string> {
return await fs.readFile(this.resolvePath(p), "utf-8");
}
async readBinary(p: string): Promise<ArrayBuffer> {
const buffer = await fs.readFile(this.resolvePath(p));
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
}
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, data, "utf-8");
if (options?.mtime || options?.ctime) {
const atime = options.mtime ? new Date(options.mtime) : new Date();
const mtime = options.mtime ? new Date(options.mtime) : new Date();
await fs.utimes(fullPath, atime, mtime);
}
}
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, new Uint8Array(data));
if (options?.mtime || options?.ctime) {
const atime = options.mtime ? new Date(options.mtime) : new Date();
const mtime = options.mtime ? new Date(options.mtime) : new Date();
await fs.utimes(fullPath, atime, mtime);
}
}
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.appendFile(fullPath, data, "utf-8");
if (options?.mtime || options?.ctime) {
const atime = options.mtime ? new Date(options.mtime) : new Date();
const mtime = options.mtime ? new Date(options.mtime) : new Date();
await fs.utimes(fullPath, atime, mtime);
}
}
async list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
const fullPath = this.resolvePath(basePath);
try {
const entries = await fs.readdir(fullPath, { withFileTypes: true });
const files: string[] = [];
const folders: string[] = [];
for (const entry of entries) {
const entryPath = path.join(basePath, entry.name).replace(/\\/g, "/");
if (entry.isDirectory()) {
folders.push(entryPath);
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return { files, folders };
} catch {
return { files: [], folders: [] };
}
}
}

View File

@@ -0,0 +1,15 @@
import type { ITypeGuardAdapter } from "@lib/serviceModules/adapters";
import type { NodeFile, NodeFolder } from "./NodeTypes";
/**
* Type guard adapter implementation for Node.js
*/
export class NodeTypeGuardAdapter implements ITypeGuardAdapter<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;
}
}

View File

@@ -0,0 +1,22 @@
import type { FilePath, UXStat } from "@lib/common/types";
/**
* Node.js file representation
*/
export type NodeFile = {
path: FilePath;
stat: UXStat;
};
/**
* Node.js folder representation
*/
export type NodeFolder = {
path: FilePath;
isFolder: true;
};
/**
* Node.js stat type (compatible with UXStat)
*/
export type NodeStat = UXStat;

View File

@@ -0,0 +1,118 @@
import * as fs from "fs/promises";
import * as path from "path";
import type { UXDataWriteOptions } from "@lib/common/types";
import type { IVaultAdapter } from "@lib/serviceModules/adapters";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
/**
* Vault adapter implementation for Node.js
*/
export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
constructor(private basePath: string) {}
private resolvePath(p: string): string {
return path.join(this.basePath, p);
}
async read(file: NodeFile): Promise<string> {
return await fs.readFile(this.resolvePath(file.path), "utf-8");
}
async cachedRead(file: NodeFile): Promise<string> {
// No caching in CLI version, just read directly
return await this.read(file);
}
async readBinary(file: NodeFile): Promise<ArrayBuffer> {
const buffer = await fs.readFile(this.resolvePath(file.path));
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
}
async modify(file: NodeFile, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(file.path);
await fs.writeFile(fullPath, data, "utf-8");
if (options?.mtime || options?.ctime) {
const atime = options.mtime ? new Date(options.mtime) : new Date();
const mtime = options.mtime ? new Date(options.mtime) : new Date();
await fs.utimes(fullPath, atime, mtime);
}
}
async modifyBinary(file: NodeFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(file.path);
await fs.writeFile(fullPath, new Uint8Array(data));
if (options?.mtime || options?.ctime) {
const atime = options.mtime ? new Date(options.mtime) : new Date();
const mtime = options.mtime ? new Date(options.mtime) : new Date();
await fs.utimes(fullPath, atime, mtime);
}
}
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<NodeFile> {
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, data, "utf-8");
if (options?.mtime || options?.ctime) {
const atime = options.mtime ? new Date(options.mtime) : new Date();
const mtime = options.mtime ? new Date(options.mtime) : new Date();
await fs.utimes(fullPath, atime, mtime);
}
const stat = await fs.stat(fullPath);
return {
path: p as any,
stat: {
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
type: "file",
},
};
}
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<NodeFile> {
const fullPath = this.resolvePath(p);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, new Uint8Array(data));
if (options?.mtime || options?.ctime) {
const atime = options.mtime ? new Date(options.mtime) : new Date();
const mtime = options.mtime ? new Date(options.mtime) : new Date();
await fs.utimes(fullPath, atime, mtime);
}
const stat = await fs.stat(fullPath);
return {
path: p as any,
stat: {
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
type: "file",
},
};
}
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
const fullPath = this.resolvePath(file.path);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force });
} else {
await fs.unlink(fullPath);
}
}
async trash(file: NodeFile | NodeFolder, force = false): Promise<void> {
// In CLI, trash is the same as delete (no recycle bin)
await this.delete(file, force);
}
trigger(name: string, ...data: any[]): any {
// No-op in CLI version (no event system)
return undefined;
}
}