diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts new file mode 100644 index 00000000..68642c15 --- /dev/null +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -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 { + return this.vault.getFiles().map((file) => file.path); + } + + public async read(path: RelativePath): Promise { + return new Uint8Array( + await this.vault.adapter.readBinary(normalizePath(path)) + ); + } + + public async write(path: RelativePath, content: Uint8Array): Promise { + return this.vault.adapter.writeBinary( + normalizePath(path), + content.buffer as ArrayBuffer + ); + } + + public async atomicUpdateText( + path: RelativePath, + updater: (currentContent: string) => string + ): Promise { + return this.vault.adapter.process(normalizePath(path), updater); + } + + public async getFileSize(path: RelativePath): Promise { + return (await this.statFile(path)).size; + } + + public async getModificationTime(path: RelativePath): Promise { + return new Date((await this.statFile(path)).mtime); + } + + public async exists(path: RelativePath): Promise { + return this.vault.adapter.exists(normalizePath(path)); + } + + public async createDirectory(path: RelativePath): Promise { + return this.vault.adapter.mkdir(normalizePath(path)); + } + + public async delete(path: RelativePath): Promise { + if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) { + return this.vault.adapter.remove(normalizePath(path)); + } + } + + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + return this.vault.adapter.rename(oldPath, newPath); + } + + private async statFile(path: string): Promise { + const file = await this.vault.adapter.stat(normalizePath(path)); + + if (!file) { + throw new Error(`File not found: ${path}`); + } + + return file; + } +} diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 53a17106..a3cb4a07 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -6,12 +6,12 @@ import "../manifest.json"; import { SyncSettingsTab } from "./views/settings-tab"; import { HistoryView } from "./views/history-view"; import { ObsidianFileEventHandler } from "./obisidan-event-handler"; -import { ObsidianFileOperations } from "./obsidian-file-operations"; import { StatusBar } from "./views/status-bar"; import { LogsView } from "./views/logs-view"; import { StatusDescription } from "./views/status-description"; import { Logger, SyncClient } from "sync-client"; +import { ObsidianFileSystemOperations } from "./obsidian-file-system"; export default class VaultLinkPlugin extends Plugin { private settingsTab: SyncSettingsTab | undefined; @@ -21,7 +21,7 @@ export default class VaultLinkPlugin extends Plugin { Logger.getInstance().info("Starting plugin"); this.client = await SyncClient.create( - new ObsidianFileOperations(this.app.vault), + new ObsidianFileSystemOperations(this.app.vault), { load: this.loadData.bind(this), save: this.saveData.bind(this) diff --git a/frontend/sync-client/src/file-operations.ts b/frontend/sync-client/src/file-operations.ts deleted file mode 100644 index 5fce5242..00000000 --- a/frontend/sync-client/src/file-operations.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { RelativePath } from "src/persistence/database"; - -export interface FileOperations { - listAllFiles: () => Promise; - - read: (path: RelativePath) => Promise; - - getFileSize: (path: RelativePath) => Promise; - - exists: (path: RelativePath) => Promise; - - getModificationTime: (path: RelativePath) => Promise; - - // 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; - - // 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; - - remove: (path: RelativePath) => Promise; - - move: (oldPath: RelativePath, newPath: RelativePath) => Promise; - - isFileEligibleForSync: (path: RelativePath) => boolean; -} diff --git a/frontend/obsidian-plugin/src/obsidian-file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts similarity index 55% rename from frontend/obsidian-plugin/src/obsidian-file-operations.ts rename to frontend/sync-client/src/file-operations/file-operations.ts index b124322d..7fb03be6 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -1,53 +1,56 @@ -import type { Stat, Vault } from "obsidian"; -import { normalizePath } from "obsidian"; -import { Platform } from "obsidian"; -import type { FileOperations, RelativePath } from "sync-client"; -import { Logger, isFileTypeMergable, mergeText } from "sync-client"; +import { Logger } from "src/tracing/logger"; +import { FileSystemOperations } from "./filesystem-operations"; +import { RelativePath } from "src/persistence/database"; +import { isBinary, isFileTypeMergable, mergeText } from "sync_lib"; -export class ObsidianFileOperations implements FileOperations { - public constructor(private readonly vault: Vault) {} +export class FileOperations { + public constructor(private readonly fs: FileSystemOperations) {} public async listAllFiles(): Promise { - const files = this.vault.getFiles(); + const files = await this.fs.listAllFiles(); Logger.getInstance().debug(`Listing all files, found ${files.length}`); - return files.map((file) => file.path); + return files; } public async read(path: RelativePath): Promise { Logger.getInstance().debug(`Reading file: ${path}`); - if (isFileTypeMergable(path)) { - let text = await this.vault.adapter.read(normalizePath(path)); + const content = await this.fs.read(path); - text = text.replace(/\r\n/g, "\n"); - - return new TextEncoder().encode(text); + if (isBinary(content)) { + return content; } - 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 { Logger.getInstance().debug(`Getting file size: ${path}`); - return (await this.statFile(path)).size; + return this.fs.getFileSize(path); } public async getModificationTime(path: RelativePath): Promise { 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 { 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( path: RelativePath, newContent: Uint8Array ): Promise { Logger.getInstance().debug(`Creating file: ${path}`); - if (await this.vault.adapter.exists(normalizePath(path))) { + if (await this.fs.exists(path)) { Logger.getInstance().debug( `Didn't expect ${path} to exist, when trying to create it, merging instead` ); @@ -55,50 +58,51 @@ export class ObsidianFileOperations implements FileOperations { return; } - await this.createParentDirectories(normalizePath(path)); - await this.vault.adapter.writeBinary( - normalizePath(path), - newContent.buffer as ArrayBuffer - ); + await this.createParentDirectories(path); + await this.fs.write(path, newContent); } + // 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( path: RelativePath, expectedContent: Uint8Array, newContent: Uint8Array ): Promise { Logger.getInstance().debug(`Writing file: ${path}`); - if (!(await this.vault.adapter.exists(normalizePath(path)))) { + if (!(await this.fs.exists(path))) { Logger.getInstance().debug( `The caller assumed ${path} exists, but it no longer, so we wont recreate it` ); return new Uint8Array(0); } - if (!isFileTypeMergable(path)) { + if ( + !isFileTypeMergable(path) || + isBinary(expectedContent) || + isBinary(newContent) + ) { Logger.getInstance().debug( `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` ); - await this.vault.adapter.writeBinary( - normalizePath(path), - newContent.buffer as ArrayBuffer - ); + await this.fs.write(path, newContent); return newContent; } - const expetedText = new TextDecoder().decode(expectedContent); + const expectedText = new TextDecoder().decode(expectedContent); const newText = new TextDecoder().decode(newContent); - const resultText = await this.vault.adapter.process( - normalizePath(path), + const resultText = await this.fs.atomicUpdateText( + path, (currentText) => { currentText = currentText.replace(/\r\n/g, "\n"); - if (currentText !== expetedText) { + if (currentText !== expectedText) { Logger.getInstance().debug( `Performing a 3-way merge for ${path} with the expected content` ); - return mergeText(expetedText, currentText, newText); + return mergeText(expectedText, currentText, newText); } Logger.getInstance().debug( @@ -113,18 +117,13 @@ export class ObsidianFileOperations implements FileOperations { public async remove(path: RelativePath): Promise { Logger.getInstance().debug(`Removing file: ${path}`); - if (await this.vault.adapter.exists(normalizePath(path))) { - await this.vault.adapter.trashSystem(normalizePath(path)); - } + return this.fs.delete(path); } public async move( oldPath: RelativePath, newPath: RelativePath ): Promise { - oldPath = normalizePath(oldPath); - newPath = normalizePath(newPath); - Logger.getInstance().debug(`Moving file: ${oldPath} -> ${newPath}`); if (oldPath === newPath) { @@ -132,25 +131,16 @@ export class ObsidianFileOperations implements FileOperations { } await this.createParentDirectories(newPath); - await this.vault.adapter.rename(oldPath, newPath); + await this.fs.rename(oldPath, newPath); } public isFileEligibleForSync(path: RelativePath): boolean { - if (Platform.isDesktopApp) { - return true; - } + return true; + // if (Platform.isDesktopApp) { + // return true; + // } - return isFileTypeMergable(path); - } - - private async statFile(path: string): Promise { - const file = await this.vault.adapter.stat(normalizePath(path)); - - if (!file) { - throw new Error(`File not found: ${path}`); - } - - return file; + // return isFileTypeMergable(path); } private async createParentDirectories(path: string): Promise { @@ -160,8 +150,8 @@ export class ObsidianFileOperations implements FileOperations { } for (let i = 1; i < components.length; i++) { const parentDir = components.slice(0, i).join("/"); - if (!(await this.vault.adapter.exists(parentDir))) { - await this.vault.adapter.mkdir(parentDir); + if (!(await this.fs.exists(parentDir))) { + await this.fs.createDirectory(parentDir); } } } diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts new file mode 100644 index 00000000..32bdcdfe --- /dev/null +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -0,0 +1,17 @@ +import { RelativePath } from "src/persistence/database"; + +export interface FileSystemOperations { + listAllFiles(): Promise; + read(path: RelativePath): Promise; + write(path: RelativePath, content: Uint8Array): Promise; + atomicUpdateText( + path: RelativePath, + updater: (currentContent: string) => string + ): Promise; + getFileSize(path: RelativePath): Promise; + getModificationTime(path: RelativePath): Promise; + exists(path: RelativePath): Promise; + createDirectory(path: RelativePath): Promise; + delete(path: RelativePath): Promise; + rename(oldPath: RelativePath, newPath: RelativePath): Promise; +} diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index 49272e23..e1625963 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -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 { SyncHistory, SyncType, @@ -12,17 +6,14 @@ export { type HistoryStats, type HistoryEntry } from "./tracing/sync-history"; + export { Logger, LogLevel } from "./tracing/logger"; export { SyncClient } from "./sync-client"; -export { type FileOperations } from "./file-operations"; -export { type RelativePath } from "./persistence/database"; -export type { PersistenceProvider } from "./persistence/persistence"; +export { Syncer } from "./sync-operations/syncer"; +export type { CheckConnectionResult } from "./services/sync-service"; +export { Settings, type SyncSettings } from "./persistence/settings"; -export { - isFileTypeMergable, - mergeText, - bytesToBase64, - base64ToBytes, - merge -} from "sync_lib"; +export type { RelativePath } from "./persistence/database"; +export type { FileSystemOperations } from "./file-operations/filesystem-operations"; +export type { PersistenceProvider } from "./persistence/persistence"; diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 6e35148c..8d735da7 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -2,14 +2,14 @@ import init from "sync_lib"; import wasmBin from "sync_lib/sync_lib_bg.wasm"; import type { PersistenceProvider } from "./persistence/persistence"; import { SyncHistory } from "./tracing/sync-history"; -import type { FileOperations } from "./file-operations"; import { Logger } from "./tracing/logger"; import { Database } from "./persistence/database"; import { Settings } from "./persistence/settings"; import type { CheckConnectionResult } from "./services/sync-service"; import { SyncService } from "./services/sync-service"; 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 { private remoteListenerIntervalId: number | null = null; @@ -35,7 +35,7 @@ export class SyncClient { } public static async create( - operations: FileOperations, + fs: FileSystemOperations, persistence: PersistenceProvider ): Promise { const history = new SyncHistory(); @@ -75,7 +75,7 @@ export class SyncClient { database, settings, syncService, - operations, + new FileOperations(fs), history ); @@ -90,19 +90,11 @@ export class SyncClient { void syncer.scheduleSyncForOfflineChanges(); client.registerRemoteEventListener( - settings, - database, - syncService, - syncer, settings.getSettings().fetchChangesUpdateIntervalMs ); settings.addOnSettingsChangeHandlers((newSettings, oldSettings) => { client.registerRemoteEventListener( - settings, - database, - syncService, - syncer, newSettings.fetchChangesUpdateIntervalMs ); @@ -142,26 +134,13 @@ export class SyncClient { } } - private registerRemoteEventListener( - settings: Settings, - database: Database, - syncService: SyncService, - syncer: Syncer, - intervalMs: number - ): void { + private registerRemoteEventListener(intervalMs: number): void { if (this.remoteListenerIntervalId !== null) { window.clearInterval(this.remoteListenerIntervalId); } this.remoteListenerIntervalId = window.setInterval( - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async () => - applyRemoteChangesLocally({ - settings, - database, - syncService, - syncer - }), + () => void this._syncer.applyRemoteChangesLocally(), intervalMs ); }