mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
Share application adapter and diagnostic helpers
This commit is contained in:
1 parent
4027417bf5
commit
b9dcb0d4c7
26 files changed
+937
-305
No files matched your search
@@ -8,6 +8,7 @@ import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
|
||||
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for FileSystem API
|
||||
@@ -85,28 +86,12 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
|
||||
*/
|
||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
// TODO: Refactor: Very, Very heavy.
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<FSAPIFile[]> {
|
||||
|
||||
@@ -110,6 +110,83 @@ describe("FileSystemAccessStorageAdapter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter creation", () => {
|
||||
it.each(["text", "binary"] as const)(
|
||||
"creates nested %s files with exact content and stored metadata",
|
||||
async (kind) => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255, 1, 128, 42]).buffer;
|
||||
const path = "/nested//folder/file.dat";
|
||||
const file =
|
||||
kind === "text" ? await adapter.create(path, "日本語😀") : await adapter.createBinary(path, data);
|
||||
const expected = kind === "text" ? new TextEncoder().encode("日本語😀") : new Uint8Array(data);
|
||||
const nested = await root.getDirectoryHandle("nested");
|
||||
const folder = await nested.getDirectoryHandle("folder");
|
||||
const stored = await folder.getFileHandle("file.dat");
|
||||
expect(file.path).toBe(path);
|
||||
expect(file.handle).toBe(stored);
|
||||
expect(new Uint8Array(await adapter.readBinary(file))).toEqual(expected);
|
||||
expect(file.stat).toEqual({ size: expected.byteLength, mtime: 1, ctime: 1, type: "file" });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["text", "binary"] as const)("waits for the %s writer to close before reading metadata", async (kind) => {
|
||||
let releaseClose!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
const events: string[] = [];
|
||||
const getFile = vi.fn(async () => {
|
||||
events.push("metadata");
|
||||
return new File(["result"], "file.dat", { lastModified: 123 });
|
||||
});
|
||||
const close = vi.fn(async () => {
|
||||
events.push("close-start");
|
||||
await closed;
|
||||
events.push("closed");
|
||||
});
|
||||
const write = vi.fn(async () => {
|
||||
events.push("write");
|
||||
});
|
||||
const fileHandle = { createWritable: vi.fn(async () => ({ write, close })), getFile };
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255]).buffer;
|
||||
const pending = kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", data);
|
||||
try {
|
||||
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseClose();
|
||||
}
|
||||
const file = await pending;
|
||||
expect(events).toEqual(["write", "close-start", "closed", "metadata"]);
|
||||
expect(write).toHaveBeenCalledWith(kind === "text" ? "text" : data);
|
||||
expect(file.stat).toEqual({ size: 6, ctime: 123, mtime: 123, type: "file" });
|
||||
});
|
||||
|
||||
it.each(["text", "binary"] as const)("propagates a %s write failure without returning metadata", async (kind) => {
|
||||
const failure = new Error("fixture write failed");
|
||||
const getFile = vi.fn();
|
||||
const fileHandle = {
|
||||
createWritable: vi.fn(async () => ({
|
||||
write: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
getFile,
|
||||
};
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const pending =
|
||||
kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", new ArrayBuffer(2));
|
||||
await expect(pending).rejects.toBe(failure);
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter.rename", () => {
|
||||
it("moves the file through a temporary copy while preserving content", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root");
|
||||
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
|
||||
});
|
||||
|
||||
describe("FSAPIFileSystemAdapter path case", () => {
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -155,6 +246,48 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("other.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports scan failures through the injected Webapp log", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and reports scan failure during case-insensitive lookup", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||
throw new Error("scan failed");
|
||||
},
|
||||
} as unknown as FileSystemDirectoryHandle;
|
||||
const addLog = vi.fn();
|
||||
const adapter = new FSAPIFileSystemAdapter(root, addLog);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(addLog).toHaveBeenCalledWith(
|
||||
"Error scanning directory '.': Error: scan failed",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
||||
}
|
||||
|
||||
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to parent directory, creating as needed
|
||||
let currentHandle = this.rootHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
|
||||
}
|
||||
|
||||
// Create the file
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
|
||||
// Get file metadata
|
||||
const fileObject = await fileHandle.getFile();
|
||||
|
||||
return {
|
||||
path: p as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: fileHandle,
|
||||
};
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
private async createFile(p: string, data: string | ArrayBuffer): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
|
||||
Reference in new issue
Block a user