Move file handling logic inside of client

This commit is contained in:
Andras Schmelczer 2025-02-22 11:09:28 +00:00
parent db8e4bc2e7
commit fde1fecbb6
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
7 changed files with 151 additions and 137 deletions

View file

@ -0,0 +1,69 @@
import { normalizePath, Stat, Vault } from "obsidian";
import { FileSystemOperations, RelativePath } from "sync-client";
export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(private readonly vault: Vault) {}
public async listAllFiles(): Promise<RelativePath[]> {
return this.vault.getFiles().map((file) => file.path);
}
public async read(path: RelativePath): Promise<Uint8Array> {
return new Uint8Array(
await this.vault.adapter.readBinary(normalizePath(path))
);
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
return this.vault.adapter.writeBinary(
normalizePath(path),
content.buffer as ArrayBuffer
);
}
public async atomicUpdateText(
path: RelativePath,
updater: (currentContent: string) => string
): Promise<string> {
return this.vault.adapter.process(normalizePath(path), updater);
}
public async getFileSize(path: RelativePath): Promise<number> {
return (await this.statFile(path)).size;
}
public async getModificationTime(path: RelativePath): Promise<Date> {
return new Date((await this.statFile(path)).mtime);
}
public async exists(path: RelativePath): Promise<boolean> {
return this.vault.adapter.exists(normalizePath(path));
}
public async createDirectory(path: RelativePath): Promise<void> {
return this.vault.adapter.mkdir(normalizePath(path));
}
public async delete(path: RelativePath): Promise<void> {
if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) {
return this.vault.adapter.remove(normalizePath(path));
}
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
return this.vault.adapter.rename(oldPath, newPath);
}
private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path));
if (!file) {
throw new Error(`File not found: ${path}`);
}
return file;
}
}

View file

@ -6,12 +6,12 @@ import "../manifest.json";
import { SyncSettingsTab } from "./views/settings-tab"; import { SyncSettingsTab } from "./views/settings-tab";
import { HistoryView } from "./views/history-view"; import { HistoryView } from "./views/history-view";
import { ObsidianFileEventHandler } from "./obisidan-event-handler"; import { ObsidianFileEventHandler } from "./obisidan-event-handler";
import { ObsidianFileOperations } from "./obsidian-file-operations";
import { StatusBar } from "./views/status-bar"; import { StatusBar } from "./views/status-bar";
import { LogsView } from "./views/logs-view"; import { LogsView } from "./views/logs-view";
import { StatusDescription } from "./views/status-description"; import { StatusDescription } from "./views/status-description";
import { Logger, SyncClient } from "sync-client"; import { Logger, SyncClient } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
export default class VaultLinkPlugin extends Plugin { export default class VaultLinkPlugin extends Plugin {
private settingsTab: SyncSettingsTab | undefined; private settingsTab: SyncSettingsTab | undefined;
@ -21,7 +21,7 @@ export default class VaultLinkPlugin extends Plugin {
Logger.getInstance().info("Starting plugin"); Logger.getInstance().info("Starting plugin");
this.client = await SyncClient.create( this.client = await SyncClient.create(
new ObsidianFileOperations(this.app.vault), new ObsidianFileSystemOperations(this.app.vault),
{ {
load: this.loadData.bind(this), load: this.loadData.bind(this),
save: this.saveData.bind(this) save: this.saveData.bind(this)

View file

@ -1,32 +0,0 @@
import type { RelativePath } from "src/persistence/database";
export interface FileOperations {
listAllFiles: () => Promise<RelativePath[]>;
read: (path: RelativePath) => Promise<Uint8Array>;
getFileSize: (path: RelativePath) => Promise<number>;
exists: (path: RelativePath) => Promise<boolean>;
getModificationTime: (path: RelativePath) => Promise<Date>;
// Create and write the file if it doesn't exist. Otherwise, it has the same behavior as write.
// All parent directories are created if they don't exist.
create: (path: RelativePath, newContent: Uint8Array) => Promise<void>;
// Update the file at the given path.
// If the file's content is different from `expectedContent`, the a 3-way merge is performed before writing.
// If the file no longer exists, the file is not recreated and an empty array is returned.
write: (
path: RelativePath,
expectedContent: Uint8Array,
newContent: Uint8Array
) => Promise<Uint8Array>;
remove: (path: RelativePath) => Promise<void>;
move: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
isFileEligibleForSync: (path: RelativePath) => boolean;
}

View file

@ -1,53 +1,56 @@
import type { Stat, Vault } from "obsidian"; import { Logger } from "src/tracing/logger";
import { normalizePath } from "obsidian"; import { FileSystemOperations } from "./filesystem-operations";
import { Platform } from "obsidian"; import { RelativePath } from "src/persistence/database";
import type { FileOperations, RelativePath } from "sync-client"; import { isBinary, isFileTypeMergable, mergeText } from "sync_lib";
import { Logger, isFileTypeMergable, mergeText } from "sync-client";
export class ObsidianFileOperations implements FileOperations { export class FileOperations {
public constructor(private readonly vault: Vault) {} public constructor(private readonly fs: FileSystemOperations) {}
public async listAllFiles(): Promise<RelativePath[]> { public async listAllFiles(): Promise<RelativePath[]> {
const files = this.vault.getFiles(); const files = await this.fs.listAllFiles();
Logger.getInstance().debug(`Listing all files, found ${files.length}`); Logger.getInstance().debug(`Listing all files, found ${files.length}`);
return files.map((file) => file.path); return files;
} }
public async read(path: RelativePath): Promise<Uint8Array> { public async read(path: RelativePath): Promise<Uint8Array> {
Logger.getInstance().debug(`Reading file: ${path}`); Logger.getInstance().debug(`Reading file: ${path}`);
if (isFileTypeMergable(path)) { const content = await this.fs.read(path);
let text = await this.vault.adapter.read(normalizePath(path));
text = text.replace(/\r\n/g, "\n"); if (isBinary(content)) {
return content;
return new TextEncoder().encode(text);
} }
return new Uint8Array(
await this.vault.adapter.readBinary(normalizePath(path)) const decoder = new TextDecoder("utf-8");
);
let text = decoder.decode(content);
text = text.replace(/\r\n/g, "\n");
return new TextEncoder().encode(text);
} }
public async getFileSize(path: RelativePath): Promise<number> { public async getFileSize(path: RelativePath): Promise<number> {
Logger.getInstance().debug(`Getting file size: ${path}`); Logger.getInstance().debug(`Getting file size: ${path}`);
return (await this.statFile(path)).size; return this.fs.getFileSize(path);
} }
public async getModificationTime(path: RelativePath): Promise<Date> { public async getModificationTime(path: RelativePath): Promise<Date> {
Logger.getInstance().debug(`Getting modification time: ${path}`); Logger.getInstance().debug(`Getting modification time: ${path}`);
return new Date((await this.statFile(path)).mtime); return this.fs.getModificationTime(path);
} }
public async exists(path: RelativePath): Promise<boolean> { public async exists(path: RelativePath): Promise<boolean> {
Logger.getInstance().debug(`Checking existance of ${path}`); Logger.getInstance().debug(`Checking existance of ${path}`);
return this.vault.adapter.exists(normalizePath(path)); return this.fs.exists(path);
} }
// Create and write the file if it doesn't exist. Otherwise, it has the same behavior as write.
// All parent directories are created if they don't exist.
public async create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array newContent: Uint8Array
): Promise<void> { ): Promise<void> {
Logger.getInstance().debug(`Creating file: ${path}`); Logger.getInstance().debug(`Creating file: ${path}`);
if (await this.vault.adapter.exists(normalizePath(path))) { if (await this.fs.exists(path)) {
Logger.getInstance().debug( Logger.getInstance().debug(
`Didn't expect ${path} to exist, when trying to create it, merging instead` `Didn't expect ${path} to exist, when trying to create it, merging instead`
); );
@ -55,50 +58,51 @@ export class ObsidianFileOperations implements FileOperations {
return; return;
} }
await this.createParentDirectories(normalizePath(path)); await this.createParentDirectories(path);
await this.vault.adapter.writeBinary( await this.fs.write(path, newContent);
normalizePath(path),
newContent.buffer as ArrayBuffer
);
} }
// Update the file at the given path.
// If the file's content is different from `expectedContent`, the a 3-way merge is performed before writing.
// If the file no longer exists, the file is not recreated and an empty array is returned.
public async write( public async write(
path: RelativePath, path: RelativePath,
expectedContent: Uint8Array, expectedContent: Uint8Array,
newContent: Uint8Array newContent: Uint8Array
): Promise<Uint8Array> { ): Promise<Uint8Array> {
Logger.getInstance().debug(`Writing file: ${path}`); Logger.getInstance().debug(`Writing file: ${path}`);
if (!(await this.vault.adapter.exists(normalizePath(path)))) { if (!(await this.fs.exists(path))) {
Logger.getInstance().debug( Logger.getInstance().debug(
`The caller assumed ${path} exists, but it no longer, so we wont recreate it` `The caller assumed ${path} exists, but it no longer, so we wont recreate it`
); );
return new Uint8Array(0); return new Uint8Array(0);
} }
if (!isFileTypeMergable(path)) { if (
!isFileTypeMergable(path) ||
isBinary(expectedContent) ||
isBinary(newContent)
) {
Logger.getInstance().debug( Logger.getInstance().debug(
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
); );
await this.vault.adapter.writeBinary( await this.fs.write(path, newContent);
normalizePath(path),
newContent.buffer as ArrayBuffer
);
return newContent; return newContent;
} }
const expetedText = new TextDecoder().decode(expectedContent); const expectedText = new TextDecoder().decode(expectedContent);
const newText = new TextDecoder().decode(newContent); const newText = new TextDecoder().decode(newContent);
const resultText = await this.vault.adapter.process( const resultText = await this.fs.atomicUpdateText(
normalizePath(path), path,
(currentText) => { (currentText) => {
currentText = currentText.replace(/\r\n/g, "\n"); currentText = currentText.replace(/\r\n/g, "\n");
if (currentText !== expetedText) { if (currentText !== expectedText) {
Logger.getInstance().debug( Logger.getInstance().debug(
`Performing a 3-way merge for ${path} with the expected content` `Performing a 3-way merge for ${path} with the expected content`
); );
return mergeText(expetedText, currentText, newText); return mergeText(expectedText, currentText, newText);
} }
Logger.getInstance().debug( Logger.getInstance().debug(
@ -113,18 +117,13 @@ export class ObsidianFileOperations implements FileOperations {
public async remove(path: RelativePath): Promise<void> { public async remove(path: RelativePath): Promise<void> {
Logger.getInstance().debug(`Removing file: ${path}`); Logger.getInstance().debug(`Removing file: ${path}`);
if (await this.vault.adapter.exists(normalizePath(path))) { return this.fs.delete(path);
await this.vault.adapter.trashSystem(normalizePath(path));
}
} }
public async move( public async move(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<void> {
oldPath = normalizePath(oldPath);
newPath = normalizePath(newPath);
Logger.getInstance().debug(`Moving file: ${oldPath} -> ${newPath}`); Logger.getInstance().debug(`Moving file: ${oldPath} -> ${newPath}`);
if (oldPath === newPath) { if (oldPath === newPath) {
@ -132,25 +131,16 @@ export class ObsidianFileOperations implements FileOperations {
} }
await this.createParentDirectories(newPath); await this.createParentDirectories(newPath);
await this.vault.adapter.rename(oldPath, newPath); await this.fs.rename(oldPath, newPath);
} }
public isFileEligibleForSync(path: RelativePath): boolean { public isFileEligibleForSync(path: RelativePath): boolean {
if (Platform.isDesktopApp) { return true;
return true; // if (Platform.isDesktopApp) {
} // return true;
// }
return isFileTypeMergable(path); // return isFileTypeMergable(path);
}
private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path));
if (!file) {
throw new Error(`File not found: ${path}`);
}
return file;
} }
private async createParentDirectories(path: string): Promise<void> { private async createParentDirectories(path: string): Promise<void> {
@ -160,8 +150,8 @@ export class ObsidianFileOperations implements FileOperations {
} }
for (let i = 1; i < components.length; i++) { for (let i = 1; i < components.length; i++) {
const parentDir = components.slice(0, i).join("/"); const parentDir = components.slice(0, i).join("/");
if (!(await this.vault.adapter.exists(parentDir))) { if (!(await this.fs.exists(parentDir))) {
await this.vault.adapter.mkdir(parentDir); await this.fs.createDirectory(parentDir);
} }
} }
} }

View file

@ -0,0 +1,17 @@
import { RelativePath } from "src/persistence/database";
export interface FileSystemOperations {
listAllFiles(): Promise<RelativePath[]>;
read(path: RelativePath): Promise<Uint8Array>;
write(path: RelativePath, content: Uint8Array): Promise<void>;
atomicUpdateText(
path: RelativePath,
updater: (currentContent: string) => string
): Promise<string>;
getFileSize(path: RelativePath): Promise<number>;
getModificationTime(path: RelativePath): Promise<Date>;
exists(path: RelativePath): Promise<boolean>;
createDirectory(path: RelativePath): Promise<void>;
delete(path: RelativePath): Promise<void>;
rename(oldPath: RelativePath, newPath: RelativePath): Promise<void>;
}

View file

@ -1,9 +1,3 @@
export { Settings, type SyncSettings } from "./persistence/settings";
export { type CheckConnectionResult } from "./services/sync-service";
export { Syncer } from "./sync-operations/syncer";
export { export {
SyncHistory, SyncHistory,
SyncType, SyncType,
@ -12,17 +6,14 @@ export {
type HistoryStats, type HistoryStats,
type HistoryEntry type HistoryEntry
} from "./tracing/sync-history"; } from "./tracing/sync-history";
export { Logger, LogLevel } from "./tracing/logger"; export { Logger, LogLevel } from "./tracing/logger";
export { SyncClient } from "./sync-client"; export { SyncClient } from "./sync-client";
export { type FileOperations } from "./file-operations"; export { Syncer } from "./sync-operations/syncer";
export { type RelativePath } from "./persistence/database"; export type { CheckConnectionResult } from "./services/sync-service";
export type { PersistenceProvider } from "./persistence/persistence"; export { Settings, type SyncSettings } from "./persistence/settings";
export { export type { RelativePath } from "./persistence/database";
isFileTypeMergable, export type { FileSystemOperations } from "./file-operations/filesystem-operations";
mergeText, export type { PersistenceProvider } from "./persistence/persistence";
bytesToBase64,
base64ToBytes,
merge
} from "sync_lib";

View file

@ -2,14 +2,14 @@ import init from "sync_lib";
import wasmBin from "sync_lib/sync_lib_bg.wasm"; import wasmBin from "sync_lib/sync_lib_bg.wasm";
import type { PersistenceProvider } from "./persistence/persistence"; import type { PersistenceProvider } from "./persistence/persistence";
import { SyncHistory } from "./tracing/sync-history"; import { SyncHistory } from "./tracing/sync-history";
import type { FileOperations } from "./file-operations";
import { Logger } from "./tracing/logger"; import { Logger } from "./tracing/logger";
import { Database } from "./persistence/database"; import { Database } from "./persistence/database";
import { Settings } from "./persistence/settings"; import { Settings } from "./persistence/settings";
import type { CheckConnectionResult } from "./services/sync-service"; import type { CheckConnectionResult } from "./services/sync-service";
import { SyncService } from "./services/sync-service"; import { SyncService } from "./services/sync-service";
import { Syncer } from "./sync-operations/syncer"; import { Syncer } from "./sync-operations/syncer";
import { applyRemoteChangesLocally } from "./sync-operations/apply-remote-changes-locally"; import { FileSystemOperations } from "./file-operations/filesystem-operations";
import { FileOperations } from "./file-operations/file-operations";
export class SyncClient { export class SyncClient {
private remoteListenerIntervalId: number | null = null; private remoteListenerIntervalId: number | null = null;
@ -35,7 +35,7 @@ export class SyncClient {
} }
public static async create( public static async create(
operations: FileOperations, fs: FileSystemOperations,
persistence: PersistenceProvider persistence: PersistenceProvider
): Promise<SyncClient> { ): Promise<SyncClient> {
const history = new SyncHistory(); const history = new SyncHistory();
@ -75,7 +75,7 @@ export class SyncClient {
database, database,
settings, settings,
syncService, syncService,
operations, new FileOperations(fs),
history history
); );
@ -90,19 +90,11 @@ export class SyncClient {
void syncer.scheduleSyncForOfflineChanges(); void syncer.scheduleSyncForOfflineChanges();
client.registerRemoteEventListener( client.registerRemoteEventListener(
settings,
database,
syncService,
syncer,
settings.getSettings().fetchChangesUpdateIntervalMs settings.getSettings().fetchChangesUpdateIntervalMs
); );
settings.addOnSettingsChangeHandlers((newSettings, oldSettings) => { settings.addOnSettingsChangeHandlers((newSettings, oldSettings) => {
client.registerRemoteEventListener( client.registerRemoteEventListener(
settings,
database,
syncService,
syncer,
newSettings.fetchChangesUpdateIntervalMs newSettings.fetchChangesUpdateIntervalMs
); );
@ -142,26 +134,13 @@ export class SyncClient {
} }
} }
private registerRemoteEventListener( private registerRemoteEventListener(intervalMs: number): void {
settings: Settings,
database: Database,
syncService: SyncService,
syncer: Syncer,
intervalMs: number
): void {
if (this.remoteListenerIntervalId !== null) { if (this.remoteListenerIntervalId !== null) {
window.clearInterval(this.remoteListenerIntervalId); window.clearInterval(this.remoteListenerIntervalId);
} }
this.remoteListenerIntervalId = window.setInterval( this.remoteListenerIntervalId = window.setInterval(
// eslint-disable-next-line @typescript-eslint/no-misused-promises () => void this._syncer.applyRemoteChangesLocally(),
async () =>
applyRemoteChangesLocally({
settings,
database,
syncService,
syncer
}),
intervalMs intervalMs
); );
} }