Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
d1c4b319a5 Fix deletion logic 2025-10-19 20:20:43 +01:00
5956840217 Fix tests 2025-10-19 19:57:52 +01:00
b8e862cb67 Fix dockerfile 2025-10-19 16:11:13 +01:00
116661c674 Delete empty folders during syncing 2025-10-19 16:10:41 +01:00
9 changed files with 69 additions and 25 deletions

View file

@ -14,10 +14,12 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
private readonly workspace: Workspace private readonly workspace: Workspace
) {} ) {}
public async listAllFiles(): Promise<RelativePath[]> { public async listFilesRecursively(
root: RelativePath | undefined
): Promise<RelativePath[]> {
// Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files.
const allFiles = []; const allFiles = [];
const remainingFolders = [this.vault.getRoot().path]; const remainingFolders = [root ?? this.vault.getRoot().path];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) { while (true) {

View file

@ -29,8 +29,10 @@ class MockDatabase implements Partial<Database> {
class FakeFileSystemOperations implements FileSystemOperations { class FakeFileSystemOperations implements FileSystemOperations {
public readonly names = new Set<string>(); public readonly names = new Set<string>();
public async listAllFiles(): Promise<RelativePath[]> { public async listFilesRecursively(
throw new Error("Method not implemented."); _root: RelativePath | undefined
): Promise<RelativePath[]> {
return ["file.md"];
} }
public async read(_path: RelativePath): Promise<Uint8Array> { public async read(_path: RelativePath): Promise<Uint8Array> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");

View file

@ -5,6 +5,7 @@ import { SafeFileSystemOperations } from "./safe-filesystem-operations";
import type { TextWithCursors } from "reconcile-text"; import type { TextWithCursors } from "reconcile-text";
import { isBinary, reconcile } from "reconcile-text"; import { isBinary, reconcile } from "reconcile-text";
import { isFileTypeMergable } from "../utils/is-file-type-mergable"; import { isFileTypeMergable } from "../utils/is-file-type-mergable";
export class FileOperations { export class FileOperations {
private static readonly PARENTHESES_REGEX = / \((\d+)\)$/; private static readonly PARENTHESES_REGEX = / \((\d+)\)$/;
private readonly fs: SafeFileSystemOperations; private readonly fs: SafeFileSystemOperations;
@ -18,8 +19,22 @@ export class FileOperations {
this.fs = new SafeFileSystemOperations(fs, logger); this.fs = new SafeFileSystemOperations(fs, logger);
} }
public async listAllFiles(): Promise<RelativePath[]> { private static getParentDirAndFile(
return this.fs.listAllFiles(); 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<RelativePath[]> {
return this.fs.listFilesRecursively(root);
} }
public async read(path: RelativePath): Promise<Uint8Array> { public async read(path: RelativePath): Promise<Uint8Array> {
@ -120,7 +135,8 @@ export class FileOperations {
public async delete(path: RelativePath): Promise<void> { public async delete(path: RelativePath): Promise<void> {
if (await this.exists(path)) { if (await this.exists(path)) {
return this.fs.delete(path); await this.fs.delete(path);
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
} else { } else {
this.logger.debug(`No need to delete '${path}', it doesn't exist`); this.logger.debug(`No need to delete '${path}', it doesn't exist`);
} }
@ -146,6 +162,31 @@ export class FileOperations {
this.database.move(oldPath, newPath); this.database.move(oldPath, newPath);
await this.fs.rename(oldPath, newPath); await this.fs.rename(oldPath, newPath);
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
}
private async deletingEmptyParentDirectoriesOfDeletedFile(
path: RelativePath
): Promise<void> {
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 { private fromNativeLineEndings(content: Uint8Array): Uint8Array {
@ -184,13 +225,9 @@ export class FileOperations {
} }
private async deconflictPath(path: RelativePath): Promise<RelativePath> { private async deconflictPath(path: RelativePath): Promise<RelativePath> {
const pathParts = path.split("/"); // eslint-disable-next-line prefer-const
const fileName = pathParts.pop(); let [directory, fileName] = FileOperations.getParentDirAndFile(path);
if (fileName == "" || fileName == null) {
throw new Error(`Path '${path}' cannot be empty`);
}
let directory = pathParts.join("/");
if (directory) { if (directory) {
directory += "/"; directory += "/";
} }

View file

@ -3,8 +3,10 @@ import type { RelativePath } from "../persistence/database";
import type { TextWithCursors } from "reconcile-text"; import type { TextWithCursors } from "reconcile-text";
export interface FileSystemOperations { export interface FileSystemOperations {
// List all files that should be synced. // List all files under root that should be synced. If root is undefined, return every file.
listAllFiles: () => Promise<RelativePath[]>; listFilesRecursively: (
root: RelativePath | undefined
) => Promise<RelativePath[]>;
// Read the content of a file. // Read the content of a file.
read: (path: RelativePath) => Promise<Uint8Array>; read: (path: RelativePath) => Promise<Uint8Array>;

View file

@ -20,9 +20,11 @@ export class SafeFileSystemOperations implements FileSystemOperations {
this.locks = new Locks(logger); this.locks = new Locks(logger);
} }
public async listAllFiles(): Promise<RelativePath[]> { public async listFilesRecursively(
root: RelativePath | undefined
): Promise<RelativePath[]> {
this.logger.debug("Listing all files"); 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`); this.logger.debug(`Listed ${result.length} files`);
return result; return result;
} }

View file

@ -335,7 +335,7 @@ export class Syncer {
private async internalScheduleSyncForOfflineChanges(): Promise<void> { private async internalScheduleSyncForOfflineChanges(): Promise<void> {
await this.createFakeDocumentsFromRemoteState(); await this.createFakeDocumentsFromRemoteState();
const allLocalFiles = await this.operations.listAllFiles(); const allLocalFiles = await this.operations.listFilesRecursively();
let locallyPossiblyDeletedFiles: DocumentRecord[] = []; let locallyPossiblyDeletedFiles: DocumentRecord[] = [];
@ -431,7 +431,7 @@ export class Syncer {
} }
const [allLocalFiles, remote] = await Promise.all([ const [allLocalFiles, remote] = await Promise.all([
this.operations.listAllFiles(), this.operations.listFilesRecursively(),
this.syncQueue.add(async () => this.syncService.getAll()) this.syncQueue.add(async () => this.syncService.getAll())
]); ]);

View file

@ -94,7 +94,7 @@ export class MockAgent extends MockClient {
options.push(this.enableSyncAction.bind(this)); options.push(this.enableSyncAction.bind(this));
} }
const files = await this.listAllFiles(); const files = await this.listFilesRecursively();
if (files.length > 0) { if (files.length > 0) {
options.push( options.push(

View file

@ -7,6 +7,7 @@ import {
SyncClient SyncClient
} from "sync-client"; } from "sync-client";
import type { TextWithCursors } from "reconcile-text"; import type { TextWithCursors } from "reconcile-text";
export class MockClient implements FileSystemOperations { export class MockClient implements FileSystemOperations {
protected readonly localFiles = new Map<string, Uint8Array>(); protected readonly localFiles = new Map<string, Uint8Array>();
protected client!: SyncClient; protected client!: SyncClient;
@ -46,7 +47,9 @@ export class MockClient implements FileSystemOperations {
await this.client.start(); await this.client.start();
} }
public async listAllFiles(): Promise<RelativePath[]> { public async listFilesRecursively(
_root: RelativePath | undefined = undefined // we don't use multi-level paths during tests
): Promise<RelativePath[]> {
return Array.from(this.localFiles.keys()); return Array.from(this.localFiles.keys());
} }

View file

@ -8,7 +8,6 @@ RUN apt update && \
pkg-config && \ pkg-config && \
cargo install sqlx-cli cargo install sqlx-cli
# Build application
COPY . . COPY . .
RUN sqlx database create --database-url sqlite://db.sqlite3 && \ RUN sqlx database create --database-url sqlite://db.sqlite3 && \
@ -28,9 +27,6 @@ RUN apt update && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/src/backend/target/release/sync_server /app/sync_server 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 VOLUME /data
EXPOSE 3000/tcp EXPOSE 3000/tcp