From 1ddba47b805c738e4264e7cd930b36eea10e5061 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 20 Oct 2025 20:24:35 +0100 Subject: [PATCH 1/2] Fix folder deletion (#140) --- .../src/obsidian-file-system.ts | 6 +- .../file-operations/file-operations.test.ts | 6 +- .../src/file-operations/file-operations.ts | 55 ++++++++++++++++--- .../file-operations/filesystem-operations.ts | 6 +- .../safe-filesystem-operations.ts | 6 +- .../sync-client/src/sync-operations/syncer.ts | 4 +- frontend/test-client/src/agent/mock-agent.ts | 2 +- frontend/test-client/src/agent/mock-client.ts | 5 +- sync-server/Dockerfile | 4 -- 9 files changed, 69 insertions(+), 25 deletions(-) diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 00a9acfb..44407890 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -14,10 +14,12 @@ export class ObsidianFileSystemOperations implements FileSystemOperations { private readonly workspace: Workspace ) {} - public async listAllFiles(): Promise { + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. const allFiles = []; - const remainingFolders = [this.vault.getRoot().path]; + const remainingFolders = [root ?? this.vault.getRoot().path]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 64c02655..675fdce1 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -29,8 +29,10 @@ class MockDatabase implements Partial { class FakeFileSystemOperations implements FileSystemOperations { public readonly names = new Set(); - public async listAllFiles(): Promise { - throw new Error("Method not implemented."); + public async listFilesRecursively( + _root: RelativePath | undefined + ): Promise { + return ["file.md"]; } public async read(_path: RelativePath): Promise { throw new Error("Method not implemented."); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 38f624e5..56ce0e51 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -5,6 +5,7 @@ import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import { isBinary, reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; + export class FileOperations { private static readonly PARENTHESES_REGEX = / \((\d+)\)$/; private readonly fs: SafeFileSystemOperations; @@ -18,8 +19,22 @@ export class FileOperations { this.fs = new SafeFileSystemOperations(fs, logger); } - public async listAllFiles(): Promise { - return this.fs.listAllFiles(); + private static getParentDirAndFile( + path: RelativePath + ): [RelativePath, RelativePath] { + const pathParts = path.split("/"); + const fileName = pathParts.pop(); + if (fileName == "" || fileName == null) { + throw new Error(`Path '${path}' cannot be empty`); + } + + return [pathParts.join("/"), fileName]; + } + + public async listFilesRecursively( + root: RelativePath | undefined = undefined + ): Promise { + return this.fs.listFilesRecursively(root); } public async read(path: RelativePath): Promise { @@ -120,7 +135,8 @@ export class FileOperations { public async delete(path: RelativePath): Promise { if (await this.exists(path)) { - return this.fs.delete(path); + await this.fs.delete(path); + await this.deletingEmptyParentDirectoriesOfDeletedFile(path); } else { this.logger.debug(`No need to delete '${path}', it doesn't exist`); } @@ -146,6 +162,31 @@ export class FileOperations { this.database.move(oldPath, newPath); await this.fs.rename(oldPath, newPath); + await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); + } + + private async deletingEmptyParentDirectoriesOfDeletedFile( + path: RelativePath + ): Promise { + let directory = path; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + [directory] = FileOperations.getParentDirAndFile(directory); + if (directory.length === 0) { + break; + } + + const remainingContent = + await this.fs.listFilesRecursively(directory); + if (remainingContent.length === 0) { + this.logger.debug( + `Folder (${directory}) is now empty, deleting` + ); + await this.fs.delete(directory); + } else { + break; + } + } } private fromNativeLineEndings(content: Uint8Array): Uint8Array { @@ -184,13 +225,9 @@ export class FileOperations { } private async deconflictPath(path: RelativePath): Promise { - const pathParts = path.split("/"); - const fileName = pathParts.pop(); - if (fileName == "" || fileName == null) { - throw new Error(`Path '${path}' cannot be empty`); - } + // eslint-disable-next-line prefer-const + let [directory, fileName] = FileOperations.getParentDirAndFile(path); - let directory = pathParts.join("/"); if (directory) { directory += "/"; } diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index d5d1eedc..9c7a8366 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -3,8 +3,10 @@ import type { RelativePath } from "../persistence/database"; import type { TextWithCursors } from "reconcile-text"; export interface FileSystemOperations { - // List all files that should be synced. - listAllFiles: () => Promise; + // List all files under root that should be synced. If root is undefined, return every file. + listFilesRecursively: ( + root: RelativePath | undefined + ) => Promise; // Read the content of a file. read: (path: RelativePath) => Promise; diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 2b1f908a..2c865c9f 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -20,9 +20,11 @@ export class SafeFileSystemOperations implements FileSystemOperations { this.locks = new Locks(logger); } - public async listAllFiles(): Promise { + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { this.logger.debug("Listing all files"); - const result = await this.fs.listAllFiles(); + const result = await this.fs.listFilesRecursively(root); this.logger.debug(`Listed ${result.length} files`); return result; } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 186b9a9b..03041a36 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -335,7 +335,7 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { await this.createFakeDocumentsFromRemoteState(); - const allLocalFiles = await this.operations.listAllFiles(); + const allLocalFiles = await this.operations.listFilesRecursively(); let locallyPossiblyDeletedFiles: DocumentRecord[] = []; @@ -431,7 +431,7 @@ export class Syncer { } const [allLocalFiles, remote] = await Promise.all([ - this.operations.listAllFiles(), + this.operations.listFilesRecursively(), this.syncQueue.add(async () => this.syncService.getAll()) ]); diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 9e7806ab..a6ced45d 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -94,7 +94,7 @@ export class MockAgent extends MockClient { options.push(this.enableSyncAction.bind(this)); } - const files = await this.listAllFiles(); + const files = await this.listFilesRecursively(); if (files.length > 0) { options.push( diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 3ef55c8f..2b384c24 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -7,6 +7,7 @@ import { SyncClient } from "sync-client"; import type { TextWithCursors } from "reconcile-text"; + export class MockClient implements FileSystemOperations { protected readonly localFiles = new Map(); protected client!: SyncClient; @@ -46,7 +47,9 @@ export class MockClient implements FileSystemOperations { await this.client.start(); } - public async listAllFiles(): Promise { + public async listFilesRecursively( + _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests + ): Promise { return Array.from(this.localFiles.keys()); } diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile index 9d157520..cfb76138 100644 --- a/sync-server/Dockerfile +++ b/sync-server/Dockerfile @@ -8,7 +8,6 @@ RUN apt update && \ pkg-config && \ cargo install sqlx-cli -# Build application COPY . . RUN sqlx database create --database-url sqlite://db.sqlite3 && \ @@ -28,9 +27,6 @@ RUN apt update && \ rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/src/backend/target/release/sync_server /app/sync_server -COPY test-entrypoint.sh /app/test-entrypoint.sh - -RUN chmod +x /app/test-entrypoint.sh VOLUME /data EXPOSE 3000/tcp From a31c2d87b5c3af195bfc93f39112a8034b1ae3d5 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 20 Oct 2025 20:26:14 +0100 Subject: [PATCH 2/2] Bump versions to 0.9.0 --- frontend/obsidian-plugin/manifest.json | 2 +- frontend/obsidian-plugin/package.json | 2 +- frontend/package-lock.json | 6 +++--- frontend/sync-client/package.json | 2 +- frontend/test-client/package.json | 2 +- manifest.json | 2 +- sync-server/Cargo.lock | 2 +- sync-server/Cargo.toml | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/obsidian-plugin/manifest.json b/frontend/obsidian-plugin/manifest.json index 78550642..a085cd9b 100644 --- a/frontend/obsidian-plugin/manifest.json +++ b/frontend/obsidian-plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "vault-link", "name": "VaultLink", - "version": "0.8.3", + "version": "0.9.0", "minAppVersion": "0.0.0", "description": "Self-hosted synchronization and collaboration for your Vault.", "author": "Andras Schmelczer", diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index 326ba2df..971947e5 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -1,6 +1,6 @@ { "name": "vault-link-obsidian-plugin", - "version": "0.8.3", + "version": "0.9.0", "description": "This is a sample plugin for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9fb471d8..4df70bd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4582,7 +4582,7 @@ }, "obsidian-plugin": { "name": "vault-link-obsidian-plugin", - "version": "0.8.3", + "version": "0.9.0", "license": "MIT", "devDependencies": { "@plausible-analytics/tracker": "^0.4.0", @@ -4611,7 +4611,7 @@ } }, "sync-client": { - "version": "0.8.3", + "version": "0.9.0", "dependencies": { "byte-base64": "^1.1.0", "minimatch": "^10.0.1", @@ -4652,7 +4652,7 @@ } }, "test-client": { - "version": "0.8.3", + "version": "0.9.0", "bin": { "test-client": "dist/cli.js" }, diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index 6405dd18..1bb522b1 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -1,6 +1,6 @@ { "name": "sync-client", - "version": "0.8.3", + "version": "0.9.0", "main": "dist/sync-client.node.js", "browser": "dist/sync-client.web.js", "types": "dist/types/index.d.ts", diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index 4ea6f7d6..fbcb509d 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -1,6 +1,6 @@ { "name": "test-client", - "version": "0.8.3", + "version": "0.9.0", "private": true, "bin": { "test-client": "./dist/cli.js" diff --git a/manifest.json b/manifest.json index 78550642..a085cd9b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "vault-link", "name": "VaultLink", - "version": "0.8.3", + "version": "0.9.0", "minAppVersion": "0.0.0", "description": "Self-hosted synchronization and collaboration for your Vault.", "author": "Andras Schmelczer", diff --git a/sync-server/Cargo.lock b/sync-server/Cargo.lock index 3597bdae..ddaf2b72 100644 --- a/sync-server/Cargo.lock +++ b/sync-server/Cargo.lock @@ -2302,7 +2302,7 @@ dependencies = [ [[package]] name = "sync_server" -version = "0.8.3" +version = "0.9.0" dependencies = [ "anyhow", "axum", diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml index d7f39198..f938eeee 100644 --- a/sync-server/Cargo.toml +++ b/sync-server/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Andras Schmelczer "] edition = "2024" license = "MIT" repository = "https://github.com/schmelczer/vault-link" -version = "0.8.3" +version = "0.9.0" [dependencies] serde = { version = "1.0.219", default-features = false, features = ["derive"] }