mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-13 00:03:11 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50247a80ea | |||
| 42a333a571 | |||
| a6a5f7af53 | |||
| 4df87cc96a | |||
| d16e66c67a | |||
| 57e26f1079 | |||
| dbb8d2be22 |
@@ -0,0 +1,67 @@
|
|||||||
|
name: Deploy GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'aggregator.html'
|
||||||
|
- '.github/workflows/deploy-pages.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'aggregator.html'
|
||||||
|
- '.github/workflows/deploy-pages.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Validate and package Pages site
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate aggregator
|
||||||
|
run: |
|
||||||
|
test -s aggregator.html
|
||||||
|
grep -Fq '<!DOCTYPE html>' aggregator.html
|
||||||
|
grep -Fq 'obsidian://setuplivesync?settingsQR=' aggregator.html
|
||||||
|
sed -n '/<script>/,/<\/script>/p' aggregator.html | sed '1d;$d' > aggregator.js
|
||||||
|
node --check aggregator.js
|
||||||
|
rm aggregator.js
|
||||||
|
|
||||||
|
- name: Prepare Pages site
|
||||||
|
run: |
|
||||||
|
mkdir -p _site
|
||||||
|
cp aggregator.html _site/aggregator.html
|
||||||
|
touch _site/.nojekyll
|
||||||
|
|
||||||
|
- name: Configure GitHub Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Upload GitHub Pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: _site
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy GitHub Pages
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -47,6 +47,12 @@ npm test # Run Harness based vitest tests (requires Docker services)
|
|||||||
|
|
||||||
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
|
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness.
|
||||||
|
|
||||||
|
### Unreleased change notes
|
||||||
|
|
||||||
|
Keep changes that may belong in a future release under `## Unreleased` at the top of `updates.md` when they do not justify an immediate release. Do not add a date to this virtual version. Move relevant entries under the real version and ordinal release date when preparing that release, then leave an empty `## Unreleased` section for subsequent work.
|
||||||
|
|
||||||
|
Use this section for durable release-note candidates, including compatibility-relevant internal maintenance, rather than tasks, local diagnostics, or implementation journals. Categorise user-visible behaviour separately from internal changes and testing.
|
||||||
|
|
||||||
### Auto-copy to test vaults
|
### Auto-copy to test vaults
|
||||||
|
|
||||||
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
|
To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
|
|
||||||
|
/** One platform-neutral storage adapter contract case. */
|
||||||
|
export interface StorageAdapterContractCase {
|
||||||
|
readonly name: string;
|
||||||
|
run(adapter: IStorageAdapter): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRejects(operation: () => Promise<unknown>, message: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passing baseline shared by Node, FSAPI, and future storage adapters. */
|
||||||
|
export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [
|
||||||
|
{
|
||||||
|
name: "reports missing paths consistently",
|
||||||
|
async run(adapter) {
|
||||||
|
assertEqual(await adapter.exists("missing.txt"), false, "missing path should not exist");
|
||||||
|
assertEqual(await adapter.stat("missing.txt"), null, "missing stat should be null");
|
||||||
|
assertEqual(await adapter.trystat("missing.txt"), null, "missing trystat should be null");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "creates parent directories for nested text writes",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.write("notes/nested/note.md", "hello");
|
||||||
|
assertEqual(await adapter.read("notes/nested/note.md"), "hello", "text should round-trip");
|
||||||
|
assert(await adapter.exists("notes/nested/note.md"), "written text path should exist");
|
||||||
|
assertEqual((await adapter.stat("notes/nested/note.md"))?.type, "file", "written path should be a file");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "round-trips exact binary bytes",
|
||||||
|
async run(adapter) {
|
||||||
|
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
||||||
|
await adapter.writeBinary("binary/blob.bin", expected.buffer.slice(0));
|
||||||
|
const result = await adapter.readBinary("binary/blob.bin");
|
||||||
|
assertEqual([...new Uint8Array(result)], [...expected], "binary data should round-trip exactly");
|
||||||
|
assertEqual(result.byteLength, expected.byteLength, "binary result should have the exact visible length");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "creates and extends text through append",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.append("logs/events.log", "first");
|
||||||
|
await adapter.append("logs/events.log", ":second");
|
||||||
|
assertEqual(await adapter.read("logs/events.log"), "first:second", "append should create then extend text");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lists direct files and folders",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.mkdir("listing/folder");
|
||||||
|
await adapter.write("listing/file.txt", "content");
|
||||||
|
const listed = await adapter.list("listing");
|
||||||
|
assertEqual([...listed.files].sort(), ["listing/file.txt"], "list should contain the direct file");
|
||||||
|
assertEqual([...listed.folders].sort(), ["listing/folder"], "list should contain the direct folder");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removes files and directory trees",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.write("remove/file.txt", "content");
|
||||||
|
await adapter.write("remove/folder/nested.txt", "content");
|
||||||
|
await adapter.remove("remove/file.txt");
|
||||||
|
assertEqual(await adapter.exists("remove/file.txt"), false, "file should be removed");
|
||||||
|
await adapter.remove("remove/folder");
|
||||||
|
assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keeps operations inside the configured root",
|
||||||
|
async run(adapter) {
|
||||||
|
await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected");
|
||||||
|
await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected");
|
||||||
|
await assertRejects(() => adapter.read("/absolute"), "absolute paths should be rejected");
|
||||||
|
await assertRejects(() => adapter.read("C:\\absolute"), "drive-qualified paths should be rejected");
|
||||||
|
await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected");
|
||||||
|
await assertRejects(() => adapter.remove(""), "removing the configured root should be rejected");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uses the empty path only for root-safe operations",
|
||||||
|
async run(adapter) {
|
||||||
|
await adapter.mkdir("");
|
||||||
|
assertEqual(await adapter.exists(""), true, "the configured root should exist");
|
||||||
|
assertEqual((await adapter.stat(""))?.type, "folder", "the configured root should be a folder");
|
||||||
|
assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable");
|
||||||
|
await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected");
|
||||||
|
await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -2,20 +2,22 @@ import type { UXDataWriteOptions } from "@lib/common/types";
|
|||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { NodeStat } from "./NodeTypes";
|
import type { NodeStat } from "./NodeTypes";
|
||||||
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
||||||
|
import { validateStoragePath } from "@/apps/storagePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for Node.js
|
* Storage adapter implementation for Node.js
|
||||||
*/
|
*/
|
||||||
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||||
constructor(private basePath: string) {}
|
constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
private resolvePath(p: string): string {
|
private resolvePath(p: string, allowRoot: boolean = true): string {
|
||||||
return path.join(this.basePath, p);
|
return path.join(this.basePath, validateStoragePath(p, allowRoot));
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(p: string): Promise<boolean> {
|
async exists(p: string): Promise<boolean> {
|
||||||
|
const fullPath = this.resolvePath(p);
|
||||||
try {
|
try {
|
||||||
await fs.access(this.resolvePath(p));
|
await fs.access(fullPath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -23,8 +25,9 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async trystat(p: string): Promise<NodeStat | null> {
|
async trystat(p: string): Promise<NodeStat | null> {
|
||||||
|
const fullPath = this.resolvePath(p);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(this.resolvePath(p));
|
const stat = await fs.stat(fullPath);
|
||||||
return {
|
return {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: Math.floor(stat.mtimeMs),
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
@@ -45,7 +48,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(p: string): Promise<void> {
|
async remove(p: string): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
const stat = await fs.stat(fullPath);
|
const stat = await fs.stat(fullPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
await fs.rm(fullPath, { recursive: true, force: true });
|
await fs.rm(fullPath, { recursive: true, force: true });
|
||||||
@@ -55,17 +58,17 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(p: string): Promise<string> {
|
async read(p: string): Promise<string> {
|
||||||
return await fs.readFile(this.resolvePath(p), "utf-8");
|
return await fs.readFile(this.resolvePath(p, false), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async readBinary(p: string): Promise<ArrayBuffer> {
|
async readBinary(p: string): Promise<ArrayBuffer> {
|
||||||
const buffer = await fs.readFile(this.resolvePath(p));
|
const buffer = await fs.readFile(this.resolvePath(p, false));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
|
||||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, data, "utf-8");
|
await fs.writeFile(fullPath, data, "utf-8");
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, new Uint8Array(data));
|
await fs.writeFile(fullPath, new Uint8Array(data));
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.appendFile(fullPath, data, "utf-8");
|
await fs.appendFile(fullPath, data, "utf-8");
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import * as fs from "node:fs/promises";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||||
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
||||||
|
|
||||||
describe("NodeStorageAdapter binary I/O", () => {
|
describe("NodeStorageAdapter", () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
async function createAdapter() {
|
async function createAdapter() {
|
||||||
@@ -17,6 +18,12 @@ describe("NodeStorageAdapter binary I/O", () => {
|
|||||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const contractCase of storageAdapterContractCases) {
|
||||||
|
it(contractCase.name, async () => {
|
||||||
|
await contractCase.run(await createAdapter());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("writes and reads binary data without corruption", async () => {
|
it("writes and reads binary data without corruption", async () => {
|
||||||
const adapter = await createAdapter();
|
const adapter = await createAdapter();
|
||||||
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Validate the platform-neutral path vocabulary used by rooted storage adapters.
|
||||||
|
*
|
||||||
|
* Paths are slash-separated and relative to the root bound to the adapter. Root
|
||||||
|
* selection and authorisation happen before the adapter is constructed.
|
||||||
|
*/
|
||||||
|
export function validateStoragePath(storagePath: string, allowRoot: boolean = true): string {
|
||||||
|
if (storagePath === "") {
|
||||||
|
if (allowRoot) return storagePath;
|
||||||
|
throw new Error("The storage root is not a valid entry path");
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveSync normally rejects ':' in portable filenames before paths reach storage. A leading letter and colon
|
||||||
|
// therefore represents drive-qualified input or a leaked non-storage namespace, not a supported physical path.
|
||||||
|
if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) {
|
||||||
|
throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`);
|
||||||
|
}
|
||||||
|
if (storagePath.includes("\\")) {
|
||||||
|
throw new Error(`Storage paths must use forward slashes: ${storagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = storagePath.split("/");
|
||||||
|
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||||
|
throw new Error(`Storage paths must not contain traversal segments: ${storagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return storagePath;
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { UXDataWriteOptions } from "@lib/common/types";
|
import type { UXDataWriteOptions } from "@lib/common/types";
|
||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { FSAPIStat } from "./FSAPITypes";
|
import type { FSAPIStat } from "./FSAPITypes";
|
||||||
|
import { validateStoragePath } from "@/apps/storagePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for FileSystem API
|
* Storage adapter implementation for FileSystem API
|
||||||
*/
|
*/
|
||||||
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||||
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
constructor(private readonly rootHandle: FileSystemDirectoryHandle) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a path to directory and file handles
|
* Resolve a path to directory and file handles
|
||||||
@@ -15,11 +16,9 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
dirHandle: FileSystemDirectoryHandle;
|
dirHandle: FileSystemDirectoryHandle;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
|
validateStoragePath(p, false);
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
const fileName = parts[parts.length - 1];
|
const fileName = parts[parts.length - 1];
|
||||||
@@ -39,6 +38,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get file handle for a given path
|
* Get file handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
||||||
|
validateStoragePath(p);
|
||||||
|
if (p === "") return null;
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolvePath(p);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get directory handle for a given path
|
* Get directory handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
||||||
|
validateStoragePath(p);
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
@@ -70,6 +72,20 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a writable file path after creating its parent directories. */
|
||||||
|
private async resolveWritablePath(p: string): Promise<{
|
||||||
|
dirHandle: FileSystemDirectoryHandle;
|
||||||
|
fileName: string;
|
||||||
|
} | null> {
|
||||||
|
validateStoragePath(p, false);
|
||||||
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
|
const fileName = parts.pop()!;
|
||||||
|
const parentPath = parts.join("/");
|
||||||
|
await this.mkdir(parentPath);
|
||||||
|
const dirHandle = await this.getDirectoryHandle(parentPath);
|
||||||
|
return dirHandle ? { dirHandle, fileName } : null;
|
||||||
|
}
|
||||||
|
|
||||||
async exists(p: string): Promise<boolean> {
|
async exists(p: string): Promise<boolean> {
|
||||||
const fileHandle = await this.getFileHandle(p);
|
const fileHandle = await this.getFileHandle(p);
|
||||||
if (fileHandle) return true;
|
if (fileHandle) return true;
|
||||||
@@ -110,6 +126,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mkdir(p: string): Promise<void> {
|
async mkdir(p: string): Promise<void> {
|
||||||
|
validateStoragePath(p);
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
|
|
||||||
@@ -146,14 +163,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolveWritablePath(p);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`Invalid path: ${p}`);
|
throw new Error(`Invalid path: ${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
|
||||||
|
|
||||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(data);
|
await writable.write(data);
|
||||||
@@ -161,14 +175,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolveWritablePath(p);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`Invalid path: ${p}`);
|
throw new Error(`Invalid path: ${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
|
||||||
|
|
||||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(data);
|
await writable.write(data);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||||
|
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
||||||
|
|
||||||
|
class MemoryFileHandle {
|
||||||
|
readonly kind = "file";
|
||||||
|
private data = new Uint8Array();
|
||||||
|
|
||||||
|
constructor(readonly name: string) {}
|
||||||
|
|
||||||
|
async getFile(): Promise<File> {
|
||||||
|
return new File([this.data], this.name, { lastModified: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWritable(): Promise<FileSystemWritableFileStream> {
|
||||||
|
const handle = this;
|
||||||
|
return {
|
||||||
|
async write(data: FileSystemWriteChunkType) {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
handle.data = new TextEncoder().encode(data);
|
||||||
|
} else if (data instanceof ArrayBuffer) {
|
||||||
|
handle.data = new Uint8Array(data.slice(0));
|
||||||
|
} else if (ArrayBuffer.isView(data)) {
|
||||||
|
handle.data = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
|
||||||
|
} else {
|
||||||
|
throw new TypeError("Unsupported in-memory write type");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {},
|
||||||
|
} as FileSystemWritableFileStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryDirectoryHandle {
|
||||||
|
readonly kind = "directory";
|
||||||
|
private readonly children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
|
||||||
|
|
||||||
|
constructor(readonly name: string) {}
|
||||||
|
|
||||||
|
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
|
||||||
|
const existing = this.children.get(name);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
this.children.set(name, file);
|
||||||
|
return file as unknown as FileSystemFileHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<void> {
|
||||||
|
const existing = this.children.get(name);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||||
|
for (const [name, entry] of this.children) {
|
||||||
|
yield [name, entry as unknown as FileSystemHandle];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FSAPIStorageAdapter", () => {
|
||||||
|
for (const contractCase of storageAdapterContractCases) {
|
||||||
|
it(contractCase.name, async () => {
|
||||||
|
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||||
|
await contractCase.run(new FSAPIStorageAdapter(root));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+1
-1
Submodule src/lib updated: a0efb7274e...1cb156d463
+16
@@ -3,6 +3,22 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
|||||||
|
|
||||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Improved (CLI and Webapp)
|
||||||
|
|
||||||
|
- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself.
|
||||||
|
- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling.
|
||||||
|
- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations.
|
||||||
|
|
||||||
|
### Miscellaneous
|
||||||
|
|
||||||
|
- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour.
|
||||||
|
|
||||||
## 0.25.80
|
## 0.25.80
|
||||||
|
|
||||||
7th July, 2026
|
7th July, 2026
|
||||||
|
|||||||
Reference in New Issue
Block a user