mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-17 18:16:01 +00:00
fix: synchronise case-only file renames safely
This commit is contained in:
@@ -53,9 +53,11 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
|
||||
// Navigate to the parent directory
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
|
||||
if (currentHandle.name !== parts[i]) return null;
|
||||
}
|
||||
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName);
|
||||
if (fileHandle.name !== fileName) return null;
|
||||
this.handleCache.set(pathStr, fileHandle);
|
||||
return fileHandle;
|
||||
} catch {
|
||||
@@ -110,6 +112,14 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
|
||||
return Array.from(this.fileCache.values());
|
||||
}
|
||||
|
||||
async renameFile(file: FSAPIFile, newPath: string): Promise<FSAPIFile> {
|
||||
await this.vault.rename(file, newPath);
|
||||
this.clearCache();
|
||||
const renamedFile = await this.refreshFile(newPath);
|
||||
if (!renamedFile) throw new Error(`Could not find renamed file: ${newPath}`);
|
||||
return renamedFile;
|
||||
}
|
||||
|
||||
async statFromNative(file: FSAPIFile): Promise<UXStat> {
|
||||
// Refresh stat from the file handle
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||
import { FSAPIFileSystemAdapter } from "./FSAPIFileSystemAdapter";
|
||||
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
||||
import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
|
||||
|
||||
class MemoryFileHandle {
|
||||
readonly kind = "file";
|
||||
@@ -35,19 +37,32 @@ class MemoryDirectoryHandle {
|
||||
readonly kind = "directory";
|
||||
private readonly children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
constructor(
|
||||
readonly name: string,
|
||||
private readonly caseInsensitive = false
|
||||
) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
|
||||
const existing = this.children.get(name);
|
||||
private resolveName(name: string): string {
|
||||
if (!this.caseInsensitive || this.children.has(name)) return name;
|
||||
return [...this.children.keys()].find((childName) => childName.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
}
|
||||
|
||||
async getDirectoryHandle(
|
||||
name: string,
|
||||
options?: FileSystemGetDirectoryOptions
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
const resolvedName = this.resolveName(name);
|
||||
const existing = this.children.get(resolvedName);
|
||||
if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle;
|
||||
if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError");
|
||||
const directory = new MemoryDirectoryHandle(name);
|
||||
const directory = new MemoryDirectoryHandle(name, this.caseInsensitive);
|
||||
this.children.set(name, directory);
|
||||
return directory as unknown as FileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle> {
|
||||
const existing = this.children.get(name);
|
||||
const resolvedName = this.resolveName(name);
|
||||
const existing = this.children.get(resolvedName);
|
||||
if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle;
|
||||
if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError");
|
||||
const file = new MemoryFileHandle(name);
|
||||
@@ -56,12 +71,13 @@ class MemoryDirectoryHandle {
|
||||
}
|
||||
|
||||
async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<void> {
|
||||
const existing = this.children.get(name);
|
||||
const resolvedName = this.resolveName(name);
|
||||
const existing = this.children.get(resolvedName);
|
||||
if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError");
|
||||
if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) {
|
||||
throw new DOMException("Directory is not empty", "InvalidModificationError");
|
||||
}
|
||||
this.children.delete(name);
|
||||
this.children.delete(resolvedName);
|
||||
}
|
||||
|
||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||
@@ -69,6 +85,10 @@ class MemoryDirectoryHandle {
|
||||
yield [name, entry as unknown as FileSystemHandle];
|
||||
}
|
||||
}
|
||||
|
||||
names(): string[] {
|
||||
return [...this.children.keys()];
|
||||
}
|
||||
}
|
||||
|
||||
describe("FSAPIStorageAdapter", () => {
|
||||
@@ -79,3 +99,34 @@ describe("FSAPIStorageAdapter", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter.rename", () => {
|
||||
it("moves the file through a temporary copy while preserving content", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root");
|
||||
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const file = await adapter.create("Calculus.md", "content");
|
||||
|
||||
await adapter.rename(file, "calculus.md");
|
||||
|
||||
expect(memoryRoot.names()).toEqual(["calculus.md"]);
|
||||
const renamedHandle = await root.getFileHandle("calculus.md");
|
||||
expect(await (await renamedHandle.getFile()).text()).toBe("content");
|
||||
expect(file.path).toBe("calculus.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FSAPIFileSystemAdapter path case", () => {
|
||||
it("returns the stored path case from a case-insensitive file system", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
||||
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath("calculus.md")).resolves.toBeNull();
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toEqual(
|
||||
expect.objectContaining({ path: "Calculus.md" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,46 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
||||
};
|
||||
}
|
||||
|
||||
async rename(file: FSAPIFile, newPath: string): Promise<void> {
|
||||
const source = await file.handle.getFile();
|
||||
const data = await source.arrayBuffer();
|
||||
const oldPath = file.path;
|
||||
const oldPathParts = oldPath.split("/");
|
||||
const oldName = oldPathParts.pop() ?? "file";
|
||||
const temporaryName = `.${oldName}.livesync-rename-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
const temporaryPath = [...oldPathParts, temporaryName].join("/");
|
||||
const temporaryFile = await this.createBinary(temporaryPath, data);
|
||||
|
||||
try {
|
||||
await this.delete(file);
|
||||
} catch (error) {
|
||||
await this.delete(temporaryFile, true);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const renamedFile = await this.createBinary(newPath, data);
|
||||
file.path = renamedFile.path;
|
||||
file.stat = renamedFile.stat;
|
||||
file.handle = renamedFile.handle;
|
||||
} catch (error) {
|
||||
try {
|
||||
const restoredFile = await this.createBinary(oldPath, data);
|
||||
file.path = restoredFile.path;
|
||||
file.stat = restoredFile.stat;
|
||||
file.handle = restoredFile.handle;
|
||||
await this.delete(temporaryFile, true);
|
||||
} catch (restoreError) {
|
||||
throw new Error(
|
||||
`Could not rename ${oldPath} to ${newPath}, or restore it. A temporary copy remains at ${temporaryPath}. Rename error: ${String(error)}. Restore error: ${String(restoreError)}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.delete(temporaryFile, true);
|
||||
}
|
||||
|
||||
async delete(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
|
||||
const parts = file.path.split("/").filter((part) => part !== "");
|
||||
const name = parts[parts.length - 1];
|
||||
|
||||
Reference in New Issue
Block a user