mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +00:00
Adopt Commonlib storage path contracts
Use the shared Node adapter and retain one comparison policy throughout each CLI scan. Propagate permission and I/O failures instead of reporting absence or a successful partial scan. Use shared namespace and comparison helpers in Obsidian while preserving its Vault APIs and alias policy. Cover native spelling and host filename compatibility, and document the dependency and integration boundaries.
This commit is contained in:
1 parent
2716d68b3d
commit
d557298ceb
13 files changed
+298
-269
No files matched your search
@@ -264,9 +264,10 @@ Commonlib owns the typed English fallback for messages requested by its services
|
|||||||
|
|
||||||
### File Path Handling
|
### File Path Handling
|
||||||
|
|
||||||
- Use tagged types from `types.ts`: `FilePath`, `FilePathWithPrefix`, `DocumentID`
|
- New path boundaries use `FilePath`, `DocumentPath`, `StoragePath`, and domain-specific `PathComparisonKey` from `@vrtmrz/livesync-commonlib/storage`. Original paths retain spelling; comparison keys are never converted back into filenames.
|
||||||
- Prefix constants: `CHeader` (chunks), `ICHeader`/`ICHeaderEnd` (internal data)
|
- Parse original document input and route its namespace before storage access. `splitDocumentPath` and `joinDocumentPath` retain one recognised namespace; `DocumentPath` excludes legacy comparison keys. Existing `FilePathWithPrefix`, `FilePathWithPrefixLC`, and `DocumentID` remain at compatibility boundaries.
|
||||||
- Path utilities are supplied by the focused Commonlib compatibility path `@vrtmrz/livesync-commonlib/compat/string_and_binary/path`
|
- The CLI uses Commonlib's `UnicodeResolvingNodeStorageAdapter` and one strict resolver per NFC directory scan. Only absence becomes a missing file; permission and enumeration failures propagate. Obsidian keeps its own Vault APIs and alias guard, whose fixed case-insensitive NFC policy is independent of the selected database identity mode.
|
||||||
|
- The [Commonlib storage-path design](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/design_docs/storage_path_resolution.md) and [ADR](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/adr/2026_11_shared_storage_path_resolution.md) define this unreleased boundary. WebApp and Bridge NFC enablement remains separate work.
|
||||||
|
|
||||||
### Logging & Debugging
|
### Logging & Debugging
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ Obsidian hosts, moves the setting to General Settings, and defaults new
|
|||||||
synchronisation environments to NFC. The original dates
|
synchronisation environments to NFC. The original dates
|
||||||
and reviewed versions above remain the historical baseline.
|
and reviewed versions above remain the historical baseline.
|
||||||
|
|
||||||
|
## Unreleased shared storage boundary
|
||||||
|
|
||||||
|
The CLI adopts Commonlib's shared Unicode resolver and typed physical-I/O
|
||||||
|
ports. Each NFC scan retains one comparison-policy snapshot; directory and
|
||||||
|
permission errors propagate instead of producing a successful partial scan.
|
||||||
|
Obsidian uses the shared namespace and comparison helpers while retaining its
|
||||||
|
Vault APIs and existing alias-guard policy. The [developer guide](../../devs.md#file-path-handling)
|
||||||
|
links the Commonlib design and ADR. This extraction does not change Metadata
|
||||||
|
IDs, defaults, database migration, or the host support matrix below.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
| Area | Contract |
|
| Area | Contract |
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
|||||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||||
|
import {
|
||||||
|
createStoragePathResolver,
|
||||||
|
parseFilePath,
|
||||||
|
type StoragePath,
|
||||||
|
type StoragePathResolver,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/storage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete file system adapter implementation for Node.js
|
* Complete file system adapter implementation for Node.js
|
||||||
@@ -21,6 +27,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
readonly vault: NodeVaultAdapter;
|
readonly vault: NodeVaultAdapter;
|
||||||
|
|
||||||
private fileCache = new Map<string, NodeFile>();
|
private fileCache = new Map<string, NodeFile>();
|
||||||
|
private readonly isCaseInsensitiveSetting: () => boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private basePath: string,
|
private basePath: string,
|
||||||
@@ -31,6 +38,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
this.path = new NodePathAdapter();
|
this.path = new NodePathAdapter();
|
||||||
this.typeGuard = new NodeTypeGuardAdapter();
|
this.typeGuard = new NodeTypeGuardAdapter();
|
||||||
this.conversion = new NodeConversionAdapter();
|
this.conversion = new NodeConversionAdapter();
|
||||||
|
this.isCaseInsensitiveSetting = isCaseInsensitive;
|
||||||
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
|
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
|
||||||
this.vault = new NodeVaultAdapter(this.storage);
|
this.vault = new NodeVaultAdapter(this.storage);
|
||||||
}
|
}
|
||||||
@@ -39,8 +47,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
return path.join(this.basePath, p);
|
return path.join(this.basePath, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalisePath(p: FilePath | string): string {
|
private normalisePath(p: FilePath | string): FilePath {
|
||||||
return this.path.normalisePath(p);
|
return parseFilePath(this.path.normalisePath(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hasExactPathCase(pathStr: string): Promise<boolean> {
|
private async hasExactPathCase(pathStr: string): Promise<boolean> {
|
||||||
@@ -122,15 +130,28 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
|
|
||||||
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
|
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
|
||||||
const pathStr = this.normalisePath(p);
|
const pathStr = this.normalisePath(p);
|
||||||
|
if (this.storage.isUnicodePathNormalizationEnabled) {
|
||||||
try {
|
try {
|
||||||
const resolvedPath = this.storage.isUnicodePathNormalizationEnabled
|
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false);
|
||||||
? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false)
|
|
||||||
: pathStr;
|
|
||||||
if (resolvedPath === null) {
|
if (resolvedPath === null) {
|
||||||
this.fileCache.delete(pathStr);
|
this.fileCache.delete(pathStr);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await this.refreshResolvedFile(resolvedPath);
|
return await this.refreshResolvedFile(resolvedPath);
|
||||||
|
} catch (error) {
|
||||||
|
this.fileCache.delete(pathStr);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await this.storage.stat(pathStr, caseInsensitive);
|
||||||
|
if (stat?.type !== "file") {
|
||||||
|
this.fileCache.delete(pathStr);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const file: NodeFile = { path: pathStr, stat };
|
||||||
|
this.fileCache.set(pathStr, file);
|
||||||
|
return file;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||||
this.fileCache.delete(pathStr);
|
this.fileCache.delete(pathStr);
|
||||||
@@ -138,16 +159,16 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
|
private async refreshResolvedFile(resolvedPath: StoragePath): Promise<NodeFile | null> {
|
||||||
try {
|
try {
|
||||||
const stat = await this.storage.statExact(resolvedPath);
|
const stat = await this.storage.statResolved(resolvedPath);
|
||||||
if (stat?.type !== "file") {
|
if (stat?.type !== "file") {
|
||||||
this.fileCache.delete(resolvedPath);
|
this.fileCache.delete(resolvedPath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file: NodeFile = {
|
const file: NodeFile = {
|
||||||
path: resolvedPath as FilePath,
|
path: resolvedPath,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtime,
|
mtime: stat.mtime,
|
||||||
@@ -158,10 +179,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
this.fileCache.set(resolvedPath, file);
|
this.fileCache.set(resolvedPath, file);
|
||||||
return file;
|
return file;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
|
||||||
// Evict so a deleted file is not returned by subsequent cache scans.
|
|
||||||
this.fileCache.delete(resolvedPath);
|
this.fileCache.delete(resolvedPath);
|
||||||
return null;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,34 +188,83 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
* Helper method to recursively scan directory and populate file cache
|
* Helper method to recursively scan directory and populate file cache
|
||||||
*/
|
*/
|
||||||
async scanDirectory(relativePath: string = ""): Promise<void> {
|
async scanDirectory(relativePath: string = ""): Promise<void> {
|
||||||
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
|
const files = new Map<string, NodeFile>();
|
||||||
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
|
await this.scanDirectoryInto(relativePath, files);
|
||||||
|
for (const [path, file] of files) this.fileCache.set(path, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||||
|
const resolver = this.createScanResolver();
|
||||||
|
const filePath = parseFilePath(relativePath);
|
||||||
|
if (resolver) {
|
||||||
|
const resolvedPath =
|
||||||
|
filePath === "" ? await resolver.resolveForWrite(filePath) : await resolver.resolveExisting(filePath);
|
||||||
|
if (resolvedPath !== null) await this.scanResolvedDirectoryInto(resolver, resolvedPath, files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.scanLegacyDirectoryInto(filePath, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createScanResolver(): StoragePathResolver | null {
|
||||||
|
if (!this.storage.isUnicodePathNormalizationEnabled) return null;
|
||||||
|
const caseInsensitive = this.isCaseInsensitiveSetting();
|
||||||
|
return createStoragePathResolver({
|
||||||
|
readDirectory: async (physicalDirectory) => await this.storage.readDirectory(physicalDirectory),
|
||||||
|
policy: {
|
||||||
|
pathNormalizationVersion: 1,
|
||||||
|
handleFilenameCaseSensitive: !caseInsensitive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanResolvedDirectoryInto(
|
||||||
|
resolver: StoragePathResolver,
|
||||||
|
relativePath: StoragePath,
|
||||||
|
files: Map<string, NodeFile>
|
||||||
|
): Promise<void> {
|
||||||
|
const directoryStat = await this.storage.statResolved(relativePath);
|
||||||
|
if (directoryStat === null) return;
|
||||||
|
if (directoryStat.type !== "folder") {
|
||||||
|
throw new Error(`Path is not a directory: ${this.resolvePath(relativePath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await resolver.listResolvedDirectory(relativePath);
|
||||||
|
if (entries === null) return;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.kind === "directory") {
|
||||||
|
await this.scanResolvedDirectoryInto(resolver, entry.path, files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await this.storage.statResolved(entry.path);
|
||||||
|
if (stat?.type !== "file") continue;
|
||||||
|
const file: NodeFile = { path: entry.path, stat };
|
||||||
|
files.set(entry.path, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanLegacyDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||||
const fullPath = this.resolvePath(relativePath);
|
const fullPath = this.resolvePath(relativePath);
|
||||||
try {
|
try {
|
||||||
const directoryStat = this.storage.isUnicodePathNormalizationEnabled
|
const directoryStat = await this.storage.stat(relativePath, false);
|
||||||
? await this.storage.statExact(relativePath)
|
|
||||||
: await this.storage.stat(relativePath, false);
|
|
||||||
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
|
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
|
||||||
const entries = await this.storage.listExact(relativePath);
|
const entries = await this.storage.list(relativePath, false);
|
||||||
|
|
||||||
for (const entryPath of entries.files) {
|
for (const entryPath of entries.files) {
|
||||||
const stat = await this.storage.statExact(entryPath);
|
const filePath = parseFilePath(entryPath);
|
||||||
|
const stat = await this.storage.stat(filePath, false);
|
||||||
if (stat?.type !== "file") continue;
|
if (stat?.type !== "file") continue;
|
||||||
const file: NodeFile = {
|
const file: NodeFile = {
|
||||||
path: entryPath as FilePath,
|
path: filePath,
|
||||||
stat,
|
stat,
|
||||||
};
|
};
|
||||||
files.set(entryPath, file);
|
files.set(filePath, file);
|
||||||
}
|
}
|
||||||
for (const entryPath of entries.folders) {
|
for (const entryPath of entries.folders) {
|
||||||
await this.scanDirectoryInto(entryPath, files);
|
await this.scanLegacyDirectoryInto(entryPath, files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||||
// Directory doesn't exist or is not readable
|
|
||||||
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
|
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node";
|
import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import { DEFAULT_SETTINGS, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { parseFilePath, type StoragePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||||
import {
|
import {
|
||||||
ServiceRebuilder,
|
ServiceRebuilder,
|
||||||
type ServiceRebuilderDependencies,
|
type ServiceRebuilderDependencies,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||||
|
import { AmbiguousNormalisedPathError } from "./UnicodeResolvingNodeStorageAdapter";
|
||||||
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||||
|
|
||||||
describe("NodeFileSystemAdapter file enumeration", () => {
|
describe("NodeFileSystemAdapter file enumeration", () => {
|
||||||
@@ -219,6 +221,77 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("propagates path-resolution failures when refreshing a file", async () => {
|
||||||
|
const directory = await createVault();
|
||||||
|
const adapter = createNormalisingAdapter(directory);
|
||||||
|
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||||
|
vi.spyOn(adapter.storage, "resolveLogicalPath").mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(adapter.refreshFile("a.md")).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates strict metadata failures when refreshing a resolved file", async () => {
|
||||||
|
const directory = await createVault();
|
||||||
|
await fs.writeFile(path.join(directory, "a.md"), "content");
|
||||||
|
const adapter = createNormalisingAdapter(directory);
|
||||||
|
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||||
|
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps resolved metadata reads strict when the identity mode changes during lookup", async () => {
|
||||||
|
const directory = await createVault();
|
||||||
|
await fs.writeFile(path.join(directory, "a.md"), "content");
|
||||||
|
let version = 1;
|
||||||
|
const adapter = new NodeFileSystemAdapter(directory, undefined, () => version);
|
||||||
|
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||||
|
const resolve = adapter.storage.resolveLogicalPath.bind(adapter.storage);
|
||||||
|
vi.spyOn(adapter.storage, "resolveLogicalPath").mockImplementation(async (...args) => {
|
||||||
|
const result = await resolve(...args);
|
||||||
|
version = 0;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates strict directory-read failures without reporting an incomplete scan", async () => {
|
||||||
|
const directory = await createVault();
|
||||||
|
const report = vi.fn();
|
||||||
|
const adapter = new NodeFileSystemAdapter(directory, report, () => 1);
|
||||||
|
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||||
|
vi.spyOn(adapter.storage, "readDirectory").mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(adapter.getFiles()).rejects.toBe(error);
|
||||||
|
expect(report).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one comparison policy snapshot for the whole NFC scan", async () => {
|
||||||
|
const directory = await createVault();
|
||||||
|
await fs.mkdir(path.join(directory, "Folder"));
|
||||||
|
await fs.writeFile(path.join(directory, "Folder/Note.md"), "upper");
|
||||||
|
await fs.writeFile(path.join(directory, "Folder/note.md"), "lower");
|
||||||
|
let policyReads = 0;
|
||||||
|
let normalizationReads = 0;
|
||||||
|
const adapter = new NodeFileSystemAdapter(
|
||||||
|
directory,
|
||||||
|
undefined,
|
||||||
|
() => {
|
||||||
|
normalizationReads++;
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
() => ++policyReads > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = await adapter.getFiles();
|
||||||
|
|
||||||
|
expect(files.map((file) => file.path).sort()).toEqual(["Folder/Note.md", "Folder/note.md"]);
|
||||||
|
expect(normalizationReads).toBe(1);
|
||||||
|
expect(policyReads).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
@@ -253,13 +326,13 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
|||||||
);
|
);
|
||||||
for (const name of names) await fs.writeFile(path.join(directory, child, name), "content");
|
for (const name of names) await fs.writeFile(path.join(directory, child, name), "content");
|
||||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
|
||||||
try {
|
try {
|
||||||
const files = await adapter.getFiles();
|
const files = await adapter.getFiles();
|
||||||
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
|
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
|
||||||
expect(list).toHaveBeenCalledTimes(3);
|
expect(readDirectory).toHaveBeenCalledTimes(3);
|
||||||
} finally {
|
} finally {
|
||||||
list.mockRestore();
|
readDirectory.mockRestore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -306,12 +379,12 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
|||||||
await fs.writeFile(path.join(directory, `${index}.md`), "content");
|
await fs.writeFile(path.join(directory, `${index}.md`), "content");
|
||||||
}
|
}
|
||||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
|
||||||
try {
|
try {
|
||||||
expect(await adapter.getFiles()).toHaveLength(12);
|
expect(await adapter.getFiles()).toHaveLength(12);
|
||||||
expect(list).toHaveBeenCalledTimes(1);
|
expect(readDirectory).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
} finally {
|
||||||
list.mockRestore();
|
readDirectory.mockRestore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -379,11 +452,11 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
|||||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||||
const adapter = createNormalisingAdapter(directory);
|
const adapter = createNormalisingAdapter(directory);
|
||||||
const exactFile = {
|
const exactFile = {
|
||||||
path: nfcName as FilePath,
|
path: parseFilePath(nfcName),
|
||||||
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
|
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
|
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toBeInstanceOf(AmbiguousNormalisedPathError);
|
||||||
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
|
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
|
||||||
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
|
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
|
||||||
|
|
||||||
@@ -545,7 +618,8 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
|||||||
const adapter = createNormalisingAdapter(directory);
|
const adapter = createNormalisingAdapter(directory);
|
||||||
|
|
||||||
await expect(adapter.getFiles()).resolves.toEqual([]);
|
await expect(adapter.getFiles()).resolves.toEqual([]);
|
||||||
await expect(adapter.storage.statExact("linked/note.md")).rejects.toThrow(/symbolic links/i);
|
await expect(adapter.storage.resolveLogicalPath(parseFilePath("linked/note.md"))).rejects.toThrow(/symbolic links/i);
|
||||||
|
await expect(adapter.storage.statExact("linked/note.md" as StoragePath)).rejects.toThrow(/symbolic links/i);
|
||||||
await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
|
await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
|
||||||
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
|
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||||
import type { NodeFile, NodeFolder } from "./NodeTypes";
|
import type { NodeFile, NodeFolder } from "./NodeTypes";
|
||||||
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
import { NodeStorageAdapter, UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import { UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
|
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vault adapter implementation for Node.js
|
* Vault adapter implementation for Node.js
|
||||||
@@ -56,7 +56,7 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
|
|
||||||
async rename(file: NodeFile, newPath: string): Promise<void> {
|
async rename(file: NodeFile, newPath: string): Promise<void> {
|
||||||
await this.storage.rename(file.path, newPath);
|
await this.storage.rename(file.path, newPath);
|
||||||
file.path = newPath as FilePath;
|
file.path = parseFilePath(newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
|
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
|
||||||
@@ -74,16 +74,17 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async toNodeFile(path: string): Promise<NodeFile> {
|
private async toNodeFile(path: string): Promise<NodeFile> {
|
||||||
const resolvedPath =
|
const filePath = parseFilePath(path);
|
||||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
if (this.storage instanceof UnicodeResolvingNodeStorageAdapter) {
|
||||||
? await this.storage.resolveLogicalPath(path, undefined, false)
|
const resolvedPath = await this.storage.resolveLogicalPath(filePath, undefined, false);
|
||||||
: path;
|
|
||||||
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
|
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
|
||||||
const stat =
|
const stat = await this.storage.statExact(resolvedPath);
|
||||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
|
||||||
? await this.storage.statExact(resolvedPath)
|
|
||||||
: await this.storage.stat(resolvedPath);
|
|
||||||
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||||
return { path: resolvedPath as FilePath, stat };
|
return { path: resolvedPath, stat };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await this.storage.stat(filePath);
|
||||||
|
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||||
|
return { path: filePath, stat };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
|
import { fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||||
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||||
import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ describe("NodeVaultAdapter.rename", () => {
|
|||||||
await fsPromises.writeFile(path.join(directory, "Calculus.md"), "content", "utf8");
|
await fsPromises.writeFile(path.join(directory, "Calculus.md"), "content", "utf8");
|
||||||
const adapter = new NodeVaultAdapter(directory);
|
const adapter = new NodeVaultAdapter(directory);
|
||||||
const file = {
|
const file = {
|
||||||
path: "Calculus.md" as FilePath,
|
path: parseFilePath("Calculus.md"),
|
||||||
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ describe("NodeVaultAdapter.rename", () => {
|
|||||||
);
|
);
|
||||||
const adapter = new NodeVaultAdapter(directory);
|
const adapter = new NodeVaultAdapter(directory);
|
||||||
const file = {
|
const file = {
|
||||||
path: "source.md" as FilePath,
|
path: parseFilePath("source.md"),
|
||||||
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ describe("NodeVaultAdapter.rename", () => {
|
|||||||
);
|
);
|
||||||
const adapter = new NodeVaultAdapter(directory);
|
const adapter = new NodeVaultAdapter(directory);
|
||||||
const file = {
|
const file = {
|
||||||
path: "linked/victim.md" as FilePath,
|
path: parseFilePath("linked/victim.md"),
|
||||||
stat: { ctime: 1, mtime: 2, size: 6, type: "file" as const },
|
stat: { ctime: 1, mtime: 2, size: 6, type: "file" as const },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,205 +1,2 @@
|
|||||||
import { NodeStorageAdapter, validateStoragePath } from "@vrtmrz/livesync-commonlib/node";
|
export { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
export { AmbiguousNormalisedPathError } from "@vrtmrz/livesync-commonlib/storage";
|
||||||
export class AmbiguousNormalisedPathError extends Error {
|
|
||||||
constructor(path: string) {
|
|
||||||
super(`Ambiguous path after Unicode normalisation: ${path}`);
|
|
||||||
this.name = "AmbiguousNormalisedPathError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves NFC-equivalent logical paths against current Node storage listings. */
|
|
||||||
export class UnicodeResolvingNodeStorageAdapter extends NodeStorageAdapter {
|
|
||||||
constructor(
|
|
||||||
rootPath: string,
|
|
||||||
private readonly getPathNormalizationVersion: () => number = () => 0,
|
|
||||||
private readonly isCaseInsensitive: () => boolean = () => false
|
|
||||||
) {
|
|
||||||
super(rootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isUnicodePathNormalizationEnabled(): boolean {
|
|
||||||
return this.getPathNormalizationVersion() === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read metadata for an entry already resolved or obtained from a storage listing. */
|
|
||||||
async statExact(path: string) {
|
|
||||||
return await super.trystat(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inspect a physical directory once before descending into its enumerated entries. */
|
|
||||||
async listExact(path: string) {
|
|
||||||
const entries = await super.list(path);
|
|
||||||
if (this.isUnicodePathNormalizationEnabled) {
|
|
||||||
const caseInsensitive = this.isCaseInsensitive();
|
|
||||||
const names = new Map<string, string>();
|
|
||||||
for (const entry of [...entries.files, ...entries.folders]) {
|
|
||||||
const key = this.comparisonKey(entry, caseInsensitive);
|
|
||||||
if (names.has(key)) throw new AmbiguousNormalisedPathError(entry);
|
|
||||||
names.set(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolveLogicalPath(
|
|
||||||
path: string,
|
|
||||||
caseInsensitive = this.isCaseInsensitive(),
|
|
||||||
allowMissing = true
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!this.isUnicodePathNormalizationEnabled) return path;
|
|
||||||
|
|
||||||
validateStoragePath(path, true);
|
|
||||||
if (path === "") return "";
|
|
||||||
|
|
||||||
const segments = path.split("/");
|
|
||||||
let resolvedParent = "";
|
|
||||||
|
|
||||||
for (let index = 0; index < segments.length; index++) {
|
|
||||||
const segment = segments[index];
|
|
||||||
const entries = await super.list(resolvedParent);
|
|
||||||
const candidates = [...entries.files, ...entries.folders];
|
|
||||||
const requestedKey = this.comparisonKey(segment, caseInsensitive);
|
|
||||||
const matches = candidates.filter((entryPath) => {
|
|
||||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
|
||||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
|
||||||
if (matches.length === 1) {
|
|
||||||
resolvedParent = matches[0];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowMissing) return null;
|
|
||||||
const remainingPath = segments.slice(index).join("/");
|
|
||||||
return resolvedParent === "" ? remainingPath : `${resolvedParent}/${remainingPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedParent;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async exists(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<boolean> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
|
||||||
if (resolvedPath === null) return false;
|
|
||||||
return await super.exists(resolvedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async trystat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
|
||||||
if (resolvedPath === null) return null;
|
|
||||||
return await super.trystat(resolvedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async stat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
|
||||||
if (resolvedPath === null) return null;
|
|
||||||
return await super.trystat(resolvedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async mkdir(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
|
||||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
|
||||||
await super.mkdir(resolvedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async remove(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
|
||||||
await super.remove(resolvedPath ?? path);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<string> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
|
||||||
return await super.read(resolvedPath ?? path);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<ArrayBuffer> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
|
||||||
return await super.readBinary(resolvedPath ?? path);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async write(
|
|
||||||
path: string,
|
|
||||||
data: string,
|
|
||||||
options?: Parameters<NodeStorageAdapter["write"]>[2],
|
|
||||||
caseInsensitive = this.isCaseInsensitive()
|
|
||||||
): Promise<void> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
|
||||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
|
||||||
await super.write(resolvedPath, data, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async writeBinary(
|
|
||||||
path: string,
|
|
||||||
data: ArrayBuffer,
|
|
||||||
options?: Parameters<NodeStorageAdapter["writeBinary"]>[2],
|
|
||||||
caseInsensitive = this.isCaseInsensitive()
|
|
||||||
): Promise<void> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
|
||||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
|
||||||
await super.writeBinary(resolvedPath, data, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async append(
|
|
||||||
path: string,
|
|
||||||
data: string,
|
|
||||||
options?: Parameters<NodeStorageAdapter["append"]>[2],
|
|
||||||
caseInsensitive = this.isCaseInsensitive()
|
|
||||||
): Promise<void> {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
|
||||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
|
||||||
await super.append(resolvedPath, data, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async rename(
|
|
||||||
sourcePath: string,
|
|
||||||
targetPath: string,
|
|
||||||
caseInsensitive = this.isCaseInsensitive()
|
|
||||||
): Promise<void> {
|
|
||||||
const resolvedSource = await this.resolveLogicalPath(sourcePath, caseInsensitive, false);
|
|
||||||
if (resolvedSource === null) return await super.rename(sourcePath, targetPath);
|
|
||||||
|
|
||||||
const resolvedTarget = await this.resolveLogicalPath(targetPath, caseInsensitive, true);
|
|
||||||
if (resolvedTarget === null) throw new Error(`Could not resolve path: ${targetPath}`);
|
|
||||||
|
|
||||||
// Preserve explicit case-only or Unicode-spelling renames when the target resolves to the source.
|
|
||||||
const sourceKey = this.comparisonKey(resolvedSource, caseInsensitive);
|
|
||||||
const targetKey = this.comparisonKey(resolvedTarget, caseInsensitive);
|
|
||||||
const target =
|
|
||||||
sourceKey === targetKey && resolvedTarget === resolvedSource && targetPath !== resolvedSource
|
|
||||||
? await this.resolveRenameTargetSpelling(targetPath, caseInsensitive)
|
|
||||||
: resolvedTarget;
|
|
||||||
|
|
||||||
await super.rename(resolvedSource, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async list(basePath: string, caseInsensitive = this.isCaseInsensitive()) {
|
|
||||||
const resolvedPath = await this.resolveLogicalPath(basePath, caseInsensitive, false);
|
|
||||||
if (resolvedPath === null) return { files: [], folders: [] };
|
|
||||||
return await super.list(resolvedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveRenameTargetSpelling(path: string, caseInsensitive: boolean): Promise<string> {
|
|
||||||
validateStoragePath(path, false);
|
|
||||||
const segments = path.split("/");
|
|
||||||
const leaf = segments.pop();
|
|
||||||
if (leaf === undefined) throw new Error(`Could not resolve path: ${path}`);
|
|
||||||
const parentPath = segments.join("/");
|
|
||||||
const resolvedParent = await this.resolveLogicalPath(parentPath, caseInsensitive, true);
|
|
||||||
if (resolvedParent === null) throw new Error(`Could not resolve path: ${path}`);
|
|
||||||
|
|
||||||
const entries = await super.list(resolvedParent);
|
|
||||||
const requestedKey = this.comparisonKey(leaf, caseInsensitive);
|
|
||||||
const matches = [...entries.files, ...entries.folders].filter((entryPath) => {
|
|
||||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
|
||||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
|
||||||
});
|
|
||||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
|
||||||
|
|
||||||
return resolvedParent === "" ? leaf : `${resolvedParent}/${leaf}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private comparisonKey(segment: string, caseInsensitive: boolean): string {
|
|
||||||
const normalisedSegment = segment.normalize("NFC");
|
|
||||||
return caseInsensitive ? normalisedSegment.toLowerCase() : normalisedSegment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,9 +27,10 @@ import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
|||||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
|
||||||
import { isLockAcquired, withLock } from "octagonal-wheels/concurrency/lock";
|
import { isLockAcquired, withLock } from "octagonal-wheels/concurrency/lock";
|
||||||
import diff_match_patch from "diff-match-patch";
|
import diff_match_patch from "diff-match-patch";
|
||||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
|
|
||||||
export type ConflictResolutionSettings = Pick<
|
export type ConflictResolutionSettings = Pick<
|
||||||
ObsidianLiveSyncSettings,
|
ObsidianLiveSyncSettings,
|
||||||
@@ -108,7 +109,9 @@ export function createConflictResolutionOperations(
|
|||||||
return AUTO_MERGED;
|
return AUTO_MERGED;
|
||||||
}
|
}
|
||||||
// If no conflicts were found, write the resolved content to the storage.
|
// If no conflicts were found, write the resolved content to the storage.
|
||||||
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
const documentPath = parseDocumentPath(path);
|
||||||
|
const { path: filePath } = splitDocumentPath(documentPath);
|
||||||
|
if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) {
|
||||||
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||||
return MISSING_OR_ERROR;
|
return MISSING_OR_ERROR;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-
|
|||||||
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage";
|
||||||
|
|
||||||
export type DocumentHistoryRestorationCore = {
|
export type DocumentHistoryRestorationCore = {
|
||||||
databaseFileAccess: Pick<
|
databaseFileAccess: Pick<
|
||||||
@@ -54,8 +54,8 @@ export async function restoreDocumentHistoryRevision(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = readAsBlob(source);
|
const body = readAsBlob(source);
|
||||||
const storagePath = stripAllPrefixes(current.path);
|
const { prefix, path: storagePath } = splitDocumentPath(parseDocumentPath(current.path));
|
||||||
if (storagePath !== current.path || !isPathValid(storagePath)) {
|
if (prefix !== "" || !isPathValid(storagePath)) {
|
||||||
return { status: "unsupported-path" };
|
return { status: "unsupported-path" };
|
||||||
}
|
}
|
||||||
const file: UXFileInfo = {
|
const file: UXFileInfo = {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getStoragePathComparisonKey, type FilePath, type PathComparisonKey } from "@vrtmrz/livesync-commonlib/storage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps storage from being changed through a name which the file system resolves but no directory lists.
|
* Keeps storage from being changed through a name which the file system resolves but no directory lists.
|
||||||
*
|
*
|
||||||
@@ -20,7 +22,13 @@ export interface PathAliasGuardHost {
|
|||||||
/** How long the names which a directory listed are taken as still listed. A name which is missed is listed afresh. */
|
/** How long the names which a directory listed are taken as still listed. A name which is missed is listed afresh. */
|
||||||
const LISTING_LIFETIME_MS = 1_000;
|
const LISTING_LIFETIME_MS = 1_000;
|
||||||
|
|
||||||
const foldName = (name: string) => name.normalize("NFC").toLowerCase();
|
// Host alias checks retain their fixed NFC and case-insensitive policy in both identity modes.
|
||||||
|
// Original host names keep the host vocabulary; comparison does not apply rooted-storage input validation.
|
||||||
|
const foldName = (name: string) =>
|
||||||
|
getStoragePathComparisonKey(name as FilePath, {
|
||||||
|
pathNormalizationVersion: 1,
|
||||||
|
handleFilenameCaseSensitive: false,
|
||||||
|
});
|
||||||
|
|
||||||
const nameOf = (entry: string) => {
|
const nameOf = (entry: string) => {
|
||||||
const segments = entry.split("/").filter((segment) => segment !== "");
|
const segments = entry.split("/").filter((segment) => segment !== "");
|
||||||
@@ -28,14 +36,14 @@ const nameOf = (entry: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class PathAliasGuard {
|
export class PathAliasGuard {
|
||||||
private readonly listings = new Map<string, { names: Set<string>; listedAt: number }>();
|
private readonly listings = new Map<string, { names: Set<PathComparisonKey<"storage">>; listedAt: number }>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly host: PathAliasGuardHost,
|
private readonly host: PathAliasGuardHost,
|
||||||
private readonly now: () => number = Date.now
|
private readonly now: () => number = Date.now
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private recentListing(directory: string): Set<string> | undefined {
|
private recentListing(directory: string): Set<PathComparisonKey<"storage">> | undefined {
|
||||||
const listing = this.listings.get(directory);
|
const listing = this.listings.get(directory);
|
||||||
if (!listing) return undefined;
|
if (!listing) return undefined;
|
||||||
if (this.now() - listing.listedAt <= LISTING_LIFETIME_MS) return listing.names;
|
if (this.now() - listing.listedAt <= LISTING_LIFETIME_MS) return listing.names;
|
||||||
@@ -43,7 +51,7 @@ export class PathAliasGuard {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listAfresh(directory: string): Promise<Set<string>> {
|
private async listAfresh(directory: string): Promise<Set<PathComparisonKey<"storage">>> {
|
||||||
const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry))));
|
const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry))));
|
||||||
this.listings.set(directory, { names, listedAt: this.now() });
|
this.listings.set(directory, { names, listedAt: this.now() });
|
||||||
return names;
|
return names;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { App, TFile } from "obsidian";
|
|||||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||||
import { createPathAliasGuard } from "./pathAliasGuard";
|
import { createPathAliasGuard, PathAliasGuard } from "./pathAliasGuard";
|
||||||
|
|
||||||
const REFUSAL = "Refusing a path which reaches an existing entry by a name its folder does not list.";
|
const REFUSAL = "Refusing a path which reaches an existing entry by a name its folder does not list.";
|
||||||
const CONFIG_DIRECTORY = ".obsidian";
|
const CONFIG_DIRECTORY = ".obsidian";
|
||||||
@@ -141,6 +141,30 @@ describe("paths which resolve to an existing entry under an unlisted name", () =
|
|||||||
expect(calls.trashFile).not.toHaveBeenCalled();
|
expect(calls.trashFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows NFC and case equivalents of names exposed by the host listing", async () => {
|
||||||
|
const folder = "Café".normalize("NFD");
|
||||||
|
const guard = new PathAliasGuard({
|
||||||
|
isIndexed: () => false,
|
||||||
|
exists: async () => true,
|
||||||
|
list: async (path) => (path === "" ? [folder] : [`${folder}/Note.md`]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.assertNoAlias("CAFÉ/note.md")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["x:cache", "host\\name"])(
|
||||||
|
"allows a listed target beside the unrelated host-listed name %s",
|
||||||
|
async (unrelatedName) => {
|
||||||
|
const guard = new PathAliasGuard({
|
||||||
|
isIndexed: () => false,
|
||||||
|
exists: async () => true,
|
||||||
|
list: async () => [unrelatedName, "note.md"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.assertNoAlias("note.md")).resolves.toBeUndefined();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it("allows listed names, case-only differences, indexed paths, and new files", async () => {
|
it("allows listed names, case-only differences, indexed paths, and new files", async () => {
|
||||||
const { app, calls } = fakeApp();
|
const { app, calls } = fakeApp();
|
||||||
const adapter = new ObsidianStorageAdapter(app);
|
const adapter = new ObsidianStorageAdapter(app);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { copyFile, mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises";
|
import { copyFile, mkdir, readFile, readdir, stat, utimes, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
||||||
@@ -136,7 +136,10 @@ test("mirror: storage <-> DB synchronisation", async (t) => {
|
|||||||
|
|
||||||
// Reflect the shared base into the actual Vault before editing it.
|
// Reflect the shared base into the actual Vault before editing it.
|
||||||
await runMirror();
|
await runMirror();
|
||||||
assert.deepStrictEqual(await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"), "old content\n");
|
assert.deepStrictEqual(
|
||||||
|
await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"),
|
||||||
|
"old content\n"
|
||||||
|
);
|
||||||
|
|
||||||
// Write new content to storage with a timestamp 1 hour in the future
|
// Write new content to storage with a timestamp 1 hour in the future
|
||||||
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
|
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
|
||||||
@@ -249,3 +252,37 @@ async function exists(path: string): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("mirror: NFC identity retains physical Unicode spelling", async () => {
|
||||||
|
await using workDir = await TempDir.create("livesync-cli-nfc-paths");
|
||||||
|
const vault = workDir.join("vault");
|
||||||
|
const database = workDir.join("db");
|
||||||
|
const settings = workDir.join("settings.json");
|
||||||
|
const parent = "Café".normalize("NFD");
|
||||||
|
const leaf = "Résumé.md".normalize("NFD");
|
||||||
|
const logical = "Café/Résumé.md";
|
||||||
|
const physical = workDir.join("vault", parent, leaf);
|
||||||
|
await mkdir(workDir.join("vault", parent), { recursive: true });
|
||||||
|
await mkdir(database);
|
||||||
|
await initSettingsFile(settings);
|
||||||
|
await markSettingsConfigured(settings);
|
||||||
|
const data = JSON.parse(await readFile(settings, "utf8"));
|
||||||
|
data.pathNormalizationVersion = 1;
|
||||||
|
await writeFile(settings, JSON.stringify(data));
|
||||||
|
await writeFile(physical, "original Unicode content\n");
|
||||||
|
await utimes(physical, new Date(), new Date(Date.now() - 3600_000));
|
||||||
|
|
||||||
|
const run = (...args: string[]) => runCliOrFail(database, "--settings", settings, ...args);
|
||||||
|
await run("mirror", vault);
|
||||||
|
const received = workDir.join("received.md");
|
||||||
|
await run("pull", logical, received);
|
||||||
|
assert.equal(await readFile(received, "utf8"), "original Unicode content\n");
|
||||||
|
|
||||||
|
const replacement = workDir.join("replacement.md");
|
||||||
|
await writeFile(replacement, "updated Unicode content\n");
|
||||||
|
await run("push", replacement, logical);
|
||||||
|
await run("mirror", vault);
|
||||||
|
assert.equal(await readFile(physical, "utf8"), "updated Unicode content\n");
|
||||||
|
assert.deepEqual(await readdir(vault), [parent]);
|
||||||
|
assert.deepEqual(await readdir(workDir.join("vault", parent)), [leaf]);
|
||||||
|
});
|
||||||
@@ -23,6 +23,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
- The notice shown when a translation is available now displays the **Display Language** setting label correctly in English, French, Hebrew, Russian, and Simplified Chinese. (#1298) Thank you to @cwatanab for the contribution!
|
- The notice shown when a translation is available now displays the **Display Language** setting label correctly in English, French, Hebrew, Russian, and Simplified Chinese. (#1298) Thank you to @cwatanab for the contribution!
|
||||||
- Hebrew interface messages also display their referenced labels correctly.
|
- Hebrew interface messages also display their referenced labels correctly.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- NFC file lookups and directory scans report permission and I/O failures instead of treating unreadable files as absent or returning a successful partial scan. A scan retains the comparison settings with which it began.
|
||||||
|
|
||||||
## 1.0.44
|
## 1.0.44
|
||||||
|
|
||||||
1st November, 2026
|
1st November, 2026
|
||||||
|
|||||||
Reference in new issue
Block a user