rewrite transforms, implement unified transform layer
This commit is contained in:
parent
d8c43c20f4
commit
47d39098cd
10 changed files with 289 additions and 206 deletions
|
|
@ -51,6 +51,16 @@ Writes go through a server-side write coalescer (`server/write-coalescer.js`) de
|
||||||
|
|
||||||
Sync calls use synchronous XHR to ensure blocking behavior. Async calls use fetch. Everything goes through a transport layer that handles vault ID injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values.
|
Sync calls use synchronous XHR to ensure blocking behavior. Async calls use fetch. Everything goes through a transport layer that handles vault ID injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values.
|
||||||
|
|
||||||
|
### Translation registry
|
||||||
|
|
||||||
|
The shim has a registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
|
||||||
|
|
||||||
|
- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout.
|
||||||
|
- **Read transforms** post-process bytes returned by a read (cache hit or transport miss). Used to mask the Obsidian Sync setting in `core-plugins.json` when headless-sync is active for the vault, and to override the `active` field on reads of `workspaces.json` so each tab sees its own workspace as selected.
|
||||||
|
- **Write transforms** pre-process bytes before a write hits the cache or transport. Used to override the `active` field on writes to `workspaces.json` so cross-tab disk state stays canonical.
|
||||||
|
|
||||||
|
All hooks are synchronous and registered at module load. Translation happens once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
|
||||||
|
|
||||||
### IPC
|
### IPC
|
||||||
|
|
||||||
IPC is implemented as a synchronous dispatcher that maps channel names to handlers.
|
IPC is implemented as a synchronous dispatcher that maps channel names to handlers.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// around files without loading them via readFileSync upfront.
|
// around files without loading them via readFileSync upfront.
|
||||||
|
|
||||||
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
||||||
|
import { resolvePath } from "./transforms.js";
|
||||||
|
|
||||||
let nextFd = 100;
|
let nextFd = 100;
|
||||||
const openFiles = new Map();
|
const openFiles = new Map();
|
||||||
|
|
@ -22,7 +23,8 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = contentCache.get(path);
|
const resolved = resolvePath(path);
|
||||||
|
const cached = contentCache.get(resolved);
|
||||||
|
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
if (typeof cached === "string") {
|
if (typeof cached === "string") {
|
||||||
|
|
@ -33,9 +35,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronous fetch fallback
|
// Synchronous fetch fallback
|
||||||
console.warn("[shim:fs] fd open cache miss, using sync XHR:", path);
|
console.warn("[shim:fs] fd open cache miss, using sync XHR:", resolved);
|
||||||
const data = transport.readFileSync(path);
|
const data = transport.readFileSync(resolved);
|
||||||
contentCache.set(path, data);
|
contentCache.set(resolved, data);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
@ -56,8 +58,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
||||||
|
|
||||||
function openSync(path, flags, mode) {
|
function openSync(path, flags, mode) {
|
||||||
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
||||||
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
if (!hasInCache && !metadataCache.has(path)) {
|
if (!hasInCache && !metadataCache.has(resolved)) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
`ENOENT: no such file or directory, open '${path}'`,
|
`ENOENT: no such file or directory, open '${path}'`,
|
||||||
);
|
);
|
||||||
|
|
@ -67,7 +70,7 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
||||||
|
|
||||||
const data = ensureData(path);
|
const data = ensureData(path);
|
||||||
const fd = nextFd++;
|
const fd = nextFd++;
|
||||||
openFiles.set(fd, { path, data });
|
openFiles.set(fd, { path: resolved, data });
|
||||||
|
|
||||||
return fd;
|
return fd;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { createFsWatch } from "./watch.js";
|
||||||
import { createWatcherClient } from "./watcher-client.js";
|
import { createWatcherClient } from "./watcher-client.js";
|
||||||
import { createFdOps } from "./fd.js";
|
import { createFdOps } from "./fd.js";
|
||||||
import { constants } from "./constants.js";
|
import { constants } from "./constants.js";
|
||||||
import { registerReadTransform, removeReadTransform } from "./read-transforms.js";
|
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
|
||||||
|
|
||||||
const metadataCache = new MetadataCache();
|
const metadataCache = new MetadataCache();
|
||||||
const contentCache = new ContentCache();
|
const contentCache = new ContentCache();
|
||||||
|
|
@ -41,6 +41,10 @@ export const fsShim = {
|
||||||
watch: fsWatch.watch,
|
watch: fsWatch.watch,
|
||||||
constants,
|
constants,
|
||||||
|
|
||||||
|
invalidate(path) {
|
||||||
|
contentCache.invalidate(resolvePath(path));
|
||||||
|
},
|
||||||
|
|
||||||
_metadataCache: metadataCache,
|
_metadataCache: metadataCache,
|
||||||
_contentCache: contentCache,
|
_contentCache: contentCache,
|
||||||
_watcherClient: watcherClient,
|
_watcherClient: watcherClient,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import { markLocalOp } from "./echo-guard.js";
|
import { markLocalOp } from "./echo-guard.js";
|
||||||
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
||||||
import { applyReadTransform } from "./read-transforms.js";
|
import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js";
|
||||||
|
|
||||||
export function createFsPromises(metadataCache, contentCache, transport) {
|
export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
async stat(path) {
|
async stat(path) {
|
||||||
const cached = metadataCache.toStat(path);
|
const resolved = resolvePath(path);
|
||||||
|
const cached = metadataCache.toStat(resolved);
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = await transport.stat(path);
|
const meta = await transport.stat(resolved);
|
||||||
metadataCache.set(path, meta);
|
metadataCache.set(resolved, meta);
|
||||||
return metadataCache.toStat(path);
|
return metadataCache.toStat(resolved);
|
||||||
},
|
},
|
||||||
|
|
||||||
async lstat(path) {
|
async lstat(path) {
|
||||||
|
|
@ -46,6 +47,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||||
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
let result = null;
|
let result = null;
|
||||||
|
|
||||||
|
|
@ -55,7 +57,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
const meta = metadataCache.get(path);
|
const meta = metadataCache.get(resolved);
|
||||||
|
|
||||||
if (meta && meta.type === "directory") {
|
if (meta && meta.type === "directory") {
|
||||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||||
|
|
@ -63,7 +65,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!meta && path) {
|
if (!meta && resolved && resolved === path) {
|
||||||
|
// Throw ENOENT only when not redirected; redirected paths fall through to the transport's fallback.
|
||||||
const e = new Error(
|
const e = new Error(
|
||||||
`ENOENT: no such file or directory, open '${path}'`,
|
`ENOENT: no such file or directory, open '${path}'`,
|
||||||
);
|
);
|
||||||
|
|
@ -71,16 +74,25 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = contentCache.get(path);
|
result = contentCache.get(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
result = await transport.readFile(path, encoding);
|
try {
|
||||||
contentCache.set(path, result);
|
result = await transport.readFile(resolved, encoding);
|
||||||
|
} catch (e) {
|
||||||
|
if (resolved !== path && e.code === "ENOENT") {
|
||||||
|
result = await transport.readFile(path, encoding);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentCache.set(resolved, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply registered read transforms (e.g., patching synced config files).
|
// Apply registered read transforms (e.g., patching synced config files).
|
||||||
result = applyReadTransform(path, result);
|
result = applyReadTransform(resolved, result);
|
||||||
|
|
||||||
if (wantText) {
|
if (wantText) {
|
||||||
return typeof result === "string"
|
return typeof result === "string"
|
||||||
|
|
@ -100,62 +112,74 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
encoding = encoding?.encoding;
|
encoding = encoding?.encoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
contentCache.set(path, data);
|
const transformed = applyWriteTransform(resolved, data);
|
||||||
|
|
||||||
|
markLocalOp(resolved);
|
||||||
|
contentCache.set(resolved, transformed);
|
||||||
|
|
||||||
const size =
|
const size =
|
||||||
typeof data === "string" ? data.length : data.byteLength || 0;
|
typeof transformed === "string"
|
||||||
|
? transformed.length
|
||||||
|
: transformed.byteLength || 0;
|
||||||
|
|
||||||
metadataCache.set(path, {
|
metadataCache.set(resolved, {
|
||||||
type: "file",
|
type: "file",
|
||||||
size,
|
size,
|
||||||
mtime: Date.now(),
|
mtime: Date.now(),
|
||||||
ctime: metadataCache.get(path)?.ctime || Date.now(),
|
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await transport.writeFile(path, data, encoding);
|
const result = await transport.writeFile(resolved, transformed, encoding);
|
||||||
|
|
||||||
if (result.mtime) {
|
if (result.mtime) {
|
||||||
metadataCache.set(path, {
|
metadataCache.set(resolved, {
|
||||||
type: "file",
|
type: "file",
|
||||||
size: result.size || size,
|
size: result.size || size,
|
||||||
mtime: result.mtime,
|
mtime: result.mtime,
|
||||||
ctime: metadataCache.get(path)?.ctime || Date.now(),
|
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async appendFile(path, data, encoding) {
|
async appendFile(path, data, encoding) {
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
contentCache.invalidate(path);
|
|
||||||
|
|
||||||
await transport.appendFile(path, data);
|
markLocalOp(resolved);
|
||||||
|
contentCache.invalidate(resolved);
|
||||||
|
|
||||||
const meta = await transport.stat(path);
|
await transport.appendFile(resolved, data);
|
||||||
metadataCache.set(path, meta);
|
|
||||||
|
const meta = await transport.stat(resolved);
|
||||||
|
metadataCache.set(resolved, meta);
|
||||||
},
|
},
|
||||||
|
|
||||||
async unlink(path) {
|
async unlink(path) {
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
contentCache.delete(path);
|
|
||||||
metadataCache.delete(path);
|
|
||||||
|
|
||||||
await transport.unlink(path);
|
markLocalOp(resolved);
|
||||||
|
contentCache.delete(resolved);
|
||||||
|
metadataCache.delete(resolved);
|
||||||
|
|
||||||
|
await transport.unlink(resolved);
|
||||||
},
|
},
|
||||||
|
|
||||||
async rename(oldPath, newPath) {
|
async rename(oldPath, newPath) {
|
||||||
markLocalOp(oldPath);
|
const resolvedOld = resolvePath(oldPath);
|
||||||
markLocalOp(newPath);
|
const resolvedNew = resolvePath(newPath);
|
||||||
const content = contentCache.get(oldPath);
|
|
||||||
|
markLocalOp(resolvedOld);
|
||||||
|
markLocalOp(resolvedNew);
|
||||||
|
const content = contentCache.get(resolvedOld);
|
||||||
|
|
||||||
if (content !== null) {
|
if (content !== null) {
|
||||||
contentCache.set(newPath, content);
|
contentCache.set(resolvedNew, content);
|
||||||
contentCache.delete(oldPath);
|
contentCache.delete(resolvedOld);
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataCache.rename(oldPath, newPath);
|
metadataCache.rename(resolvedOld, resolvedNew);
|
||||||
|
|
||||||
await transport.rename(oldPath, newPath);
|
await transport.rename(resolvedOld, resolvedNew);
|
||||||
},
|
},
|
||||||
|
|
||||||
async mkdir(path, options) {
|
async mkdir(path, options) {
|
||||||
|
|
@ -178,23 +202,29 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
const recursive =
|
const recursive =
|
||||||
typeof options === "object" ? !!options.recursive : false;
|
typeof options === "object" ? !!options.recursive : false;
|
||||||
|
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
metadataCache.delete(path);
|
|
||||||
contentCache.delete(path);
|
|
||||||
|
|
||||||
await transport.rm(path, recursive);
|
markLocalOp(resolved);
|
||||||
|
metadataCache.delete(resolved);
|
||||||
|
contentCache.delete(resolved);
|
||||||
|
|
||||||
|
await transport.rm(resolved, recursive);
|
||||||
},
|
},
|
||||||
|
|
||||||
async copyFile(src, dest) {
|
async copyFile(src, dest) {
|
||||||
markLocalOp(dest);
|
const resolvedDest = resolvePath(dest);
|
||||||
await transport.copyFile(src, dest);
|
|
||||||
|
|
||||||
const meta = await transport.stat(dest);
|
markLocalOp(resolvedDest);
|
||||||
metadataCache.set(dest, meta);
|
await transport.copyFile(src, resolvedDest);
|
||||||
|
|
||||||
|
const meta = await transport.stat(resolvedDest);
|
||||||
|
metadataCache.set(resolvedDest, meta);
|
||||||
},
|
},
|
||||||
|
|
||||||
async access(path) {
|
async access(path) {
|
||||||
if (metadataCache.has(path)) {
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
|
if (metadataCache.has(resolved)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,18 +244,21 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async utimes(path, atime, mtime) {
|
async utimes(path, atime, mtime) {
|
||||||
await transport.utimes(path, atime, mtime);
|
const resolved = resolvePath(path);
|
||||||
const meta = metadataCache.get(path);
|
|
||||||
|
await transport.utimes(resolved, atime, mtime);
|
||||||
|
const meta = metadataCache.get(resolved);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
|
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
|
||||||
metadataCache.set(path, meta);
|
metadataCache.set(resolved, meta);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async open(path, flags) {
|
async open(path, flags) {
|
||||||
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
||||||
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
if (!hasInCache && !metadataCache.has(path)) {
|
if (!hasInCache && !metadataCache.has(resolved)) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
`ENOENT: no such file or directory, open '${path}'`,
|
`ENOENT: no such file or directory, open '${path}'`,
|
||||||
);
|
);
|
||||||
|
|
@ -237,7 +270,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
const fileData =
|
const fileData =
|
||||||
typeof data === "string" ? new TextEncoder().encode(data) : data;
|
typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||||
|
|
||||||
const fileStat = metadataCache.toStat(path) || {
|
const fileStat = metadataCache.toStat(resolved) || {
|
||||||
size: fileData.length,
|
size: fileData.length,
|
||||||
isFile: () => true,
|
isFile: () => true,
|
||||||
isDirectory: () => false,
|
isDirectory: () => false,
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
// Post-read transforms for specific file paths.
|
|
||||||
// Allows patching file content after reading but before returning to the caller.
|
|
||||||
// Used to prevent synced config files from activating conflicting features.
|
|
||||||
|
|
||||||
const transforms = new Map();
|
|
||||||
|
|
||||||
function normalize(p) {
|
|
||||||
return (p || "")
|
|
||||||
.replace(/\\/g, "/")
|
|
||||||
.replace(/^\/+/, "")
|
|
||||||
.replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerReadTransform(path, fn) {
|
|
||||||
transforms.set(normalize(path), fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeReadTransform(path) {
|
|
||||||
transforms.delete(normalize(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyReadTransform(path, data) {
|
|
||||||
const norm = normalize(path);
|
|
||||||
const fn = transforms.get(norm);
|
|
||||||
|
|
||||||
if (!fn) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return fn(data);
|
|
||||||
} catch {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasReadTransform(path) {
|
|
||||||
return transforms.has(normalize(path));
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { markLocalOp } from "./echo-guard.js";
|
import { markLocalOp } from "./echo-guard.js";
|
||||||
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
||||||
import { applyReadTransform } from "./read-transforms.js";
|
import {
|
||||||
|
applyReadTransform,
|
||||||
|
applyWriteTransform,
|
||||||
|
resolvePath,
|
||||||
|
} from "./transforms.js";
|
||||||
|
|
||||||
export function createFsSync(metadataCache, contentCache, transport) {
|
export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -9,7 +13,8 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadataCache.has(path);
|
const resolved = resolvePath(path);
|
||||||
|
return metadataCache.has(resolved);
|
||||||
},
|
},
|
||||||
|
|
||||||
statSync(path) {
|
statSync(path) {
|
||||||
|
|
@ -27,7 +32,8 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const stat = metadataCache.toStat(path);
|
const resolved = resolvePath(path);
|
||||||
|
const stat = metadataCache.toStat(resolved);
|
||||||
|
|
||||||
if (!stat) {
|
if (!stat) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
|
|
@ -45,7 +51,9 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadataCache.has(path)) {
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
|
if (!metadataCache.has(resolved)) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
`ENOENT: no such file or directory, access '${path}'`,
|
`ENOENT: no such file or directory, access '${path}'`,
|
||||||
);
|
);
|
||||||
|
|
@ -60,8 +68,9 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||||
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
const meta = metadataCache.get(path);
|
const meta = metadataCache.get(resolved);
|
||||||
if (meta && meta.type === "directory") {
|
if (meta && meta.type === "directory") {
|
||||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||||
e.code = "EISDIR";
|
e.code = "EISDIR";
|
||||||
|
|
@ -80,17 +89,31 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
result = contentCache.get(path);
|
result = contentCache.get(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
|
// ENOENT fallback: if the resolved path doesn't exist, try the original.
|
||||||
result = transport.readFileSync(path, encoding);
|
// Covers per-name workspace files that haven't been saved yet.
|
||||||
contentCache.set(path, result);
|
try {
|
||||||
|
result = transport.readFileSync(resolved, encoding);
|
||||||
|
} catch (e) {
|
||||||
|
if (resolved !== path && e.code === "ENOENT") {
|
||||||
|
console.warn(
|
||||||
|
"[shim:fs] readFileSync cache miss, using sync XHR:",
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
result = transport.readFileSync(path, encoding);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentCache.set(resolved, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply registered read transforms (e.g., patching synced config files).
|
// Apply registered read transforms (e.g., patching synced config files).
|
||||||
result = applyReadTransform(path, result);
|
result = applyReadTransform(resolved, result);
|
||||||
|
|
||||||
if (wantText) {
|
if (wantText) {
|
||||||
return typeof result === "string"
|
return typeof result === "string"
|
||||||
|
|
@ -106,40 +129,47 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
encoding = encoding?.encoding;
|
encoding = encoding?.encoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
contentCache.set(path, data);
|
const transformed = applyWriteTransform(resolved, data);
|
||||||
|
|
||||||
|
markLocalOp(resolved);
|
||||||
|
contentCache.set(resolved, transformed);
|
||||||
|
|
||||||
const size =
|
const size =
|
||||||
typeof data === "string" ? data.length : data.byteLength || 0;
|
typeof transformed === "string"
|
||||||
|
? transformed.length
|
||||||
|
: transformed.byteLength || 0;
|
||||||
|
|
||||||
metadataCache.set(path, {
|
metadataCache.set(resolved, {
|
||||||
type: "file",
|
type: "file",
|
||||||
size,
|
size,
|
||||||
mtime: Date.now(),
|
mtime: Date.now(),
|
||||||
ctime: metadataCache.get(path)?.ctime || Date.now(),
|
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fire-and-forget async send to server
|
// Fire-and-forget async send to server
|
||||||
transport.writeFile(path, data, encoding).catch((e) => {
|
transport.writeFile(resolved, transformed, encoding).catch((e) => {
|
||||||
console.error(
|
console.error(
|
||||||
"[shim:fs] writeFileSync background save failed:",
|
"[shim:fs] writeFileSync background save failed:",
|
||||||
path,
|
resolved,
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
unlinkSync(path) {
|
unlinkSync(path) {
|
||||||
markLocalOp(path);
|
const resolved = resolvePath(path);
|
||||||
contentCache.delete(path);
|
|
||||||
metadataCache.delete(path);
|
markLocalOp(resolved);
|
||||||
|
contentCache.delete(resolved);
|
||||||
|
metadataCache.delete(resolved);
|
||||||
|
|
||||||
// Fire-and-forget. suppress ENOENT (file already gone)
|
// Fire-and-forget. suppress ENOENT (file already gone)
|
||||||
transport.unlink(path).catch((e) => {
|
transport.unlink(resolved).catch((e) => {
|
||||||
if (e.code !== "ENOENT") {
|
if (e.code !== "ENOENT") {
|
||||||
console.error(
|
console.error(
|
||||||
"[shim:fs] unlinkSync background delete failed:",
|
"[shim:fs] unlinkSync background delete failed:",
|
||||||
path,
|
resolved,
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
src/shims/fs/transforms.js
Normal file
89
src/shims/fs/transforms.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// FS shim translation registry.
|
||||||
|
// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write.
|
||||||
|
// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
|
||||||
|
|
||||||
|
function normalize(p) {
|
||||||
|
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Path resolvers ---
|
||||||
|
|
||||||
|
const pathResolvers = [];
|
||||||
|
|
||||||
|
export function registerPathResolver(matcher, resolver) {
|
||||||
|
pathResolvers.push({ matcher, resolver });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePath(path) {
|
||||||
|
const norm = normalize(path);
|
||||||
|
|
||||||
|
for (const { matcher, resolver } of pathResolvers) {
|
||||||
|
try {
|
||||||
|
if (matcher(norm)) {
|
||||||
|
const resolved = resolver(norm);
|
||||||
|
|
||||||
|
if (typeof resolved === "string" && resolved.length > 0) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Read transforms ---
|
||||||
|
|
||||||
|
const readTransforms = new Map();
|
||||||
|
|
||||||
|
export function registerReadTransform(path, fn) {
|
||||||
|
readTransforms.set(normalize(path), fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeReadTransform(path) {
|
||||||
|
readTransforms.delete(normalize(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReadTransform(path, data) {
|
||||||
|
const fn = readTransforms.get(normalize(path));
|
||||||
|
|
||||||
|
if (!fn) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return fn(data);
|
||||||
|
} catch {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasReadTransform(path) {
|
||||||
|
return readTransforms.has(normalize(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Write transforms ---
|
||||||
|
|
||||||
|
const writeTransforms = new Map();
|
||||||
|
|
||||||
|
export function registerWriteTransform(path, fn) {
|
||||||
|
writeTransforms.set(normalize(path), fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWriteTransform(path) {
|
||||||
|
writeTransforms.delete(normalize(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyWriteTransform(path, data) {
|
||||||
|
const fn = writeTransforms.get(normalize(path));
|
||||||
|
|
||||||
|
if (!fn) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return fn(data);
|
||||||
|
} catch {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
import {
|
|
||||||
rewriteWorkspacePath,
|
|
||||||
rewriteWorkspacesContent,
|
|
||||||
} from "../workspace.js";
|
|
||||||
|
|
||||||
const API_BASE = "/api/fs";
|
const API_BASE = "/api/fs";
|
||||||
const WORKSPACES_PATH = ".obsidian/workspaces.json";
|
|
||||||
|
|
||||||
function normPath(p) {
|
function normPath(p) {
|
||||||
return (p || "").replace(/^\/+/, "");
|
return (p || "").replace(/^\/+/, "");
|
||||||
|
|
@ -116,26 +110,10 @@ export const transport = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async readFile(path, encoding) {
|
async readFile(path, encoding) {
|
||||||
const norm = normPath(path);
|
const res = await request("GET", "/readFile", {
|
||||||
const rewritten = rewriteWorkspacePath(norm);
|
path: normPath(path),
|
||||||
|
encoding: encoding || "",
|
||||||
let res;
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
res = await request("GET", "/readFile", {
|
|
||||||
path: rewritten,
|
|
||||||
encoding: encoding || "",
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (rewritten !== norm && e.code === "ENOENT") {
|
|
||||||
res = await request("GET", "/readFile", {
|
|
||||||
path: norm,
|
|
||||||
encoding: encoding || "",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encoding === "utf8" || encoding === "utf-8") {
|
if (encoding === "utf8" || encoding === "utf-8") {
|
||||||
return res.text();
|
return res.text();
|
||||||
|
|
@ -146,17 +124,10 @@ export const transport = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async writeFile(path, content, encoding) {
|
async writeFile(path, content, encoding) {
|
||||||
const norm = normPath(path);
|
const isText = typeof content === "string";
|
||||||
let data = content;
|
|
||||||
|
|
||||||
if (norm === WORKSPACES_PATH && typeof data === "string") {
|
|
||||||
data = rewriteWorkspacesContent(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isText = typeof data === "string";
|
|
||||||
return requestJson("POST", "/writeFile", {
|
return requestJson("POST", "/writeFile", {
|
||||||
path: rewriteWorkspacePath(norm),
|
path: normPath(path),
|
||||||
content: isText ? data : uint8ToBase64(data),
|
content: isText ? content : uint8ToBase64(content),
|
||||||
encoding: encoding || (isText ? "utf-8" : "binary"),
|
encoding: encoding || (isText ? "utf-8" : "binary"),
|
||||||
base64: !isText,
|
base64: !isText,
|
||||||
});
|
});
|
||||||
|
|
@ -222,26 +193,10 @@ export const transport = {
|
||||||
},
|
},
|
||||||
|
|
||||||
readFileSync(path, encoding) {
|
readFileSync(path, encoding) {
|
||||||
const norm = normPath(path);
|
const xhr = requestSync("GET", "/readFile", {
|
||||||
const rewritten = rewriteWorkspacePath(norm);
|
path: normPath(path),
|
||||||
|
encoding: encoding || "",
|
||||||
let xhr;
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
xhr = requestSync("GET", "/readFile", {
|
|
||||||
path: rewritten,
|
|
||||||
encoding: encoding || "",
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (rewritten !== norm && e.code === "ENOENT") {
|
|
||||||
xhr = requestSync("GET", "/readFile", {
|
|
||||||
path: norm,
|
|
||||||
encoding: encoding || "",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encoding === "utf8" || encoding === "utf-8") {
|
if (encoding === "utf8" || encoding === "utf-8") {
|
||||||
return xhr.responseText;
|
return xhr.responseText;
|
||||||
|
|
@ -258,17 +213,10 @@ export const transport = {
|
||||||
},
|
},
|
||||||
|
|
||||||
writeFileSync(path, content, encoding) {
|
writeFileSync(path, content, encoding) {
|
||||||
const norm = normPath(path);
|
const isText = typeof content === "string";
|
||||||
let data = content;
|
|
||||||
|
|
||||||
if (norm === WORKSPACES_PATH && typeof data === "string") {
|
|
||||||
data = rewriteWorkspacesContent(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isText = typeof data === "string";
|
|
||||||
requestSync("POST", "/writeFile", {
|
requestSync("POST", "/writeFile", {
|
||||||
path: rewriteWorkspacePath(norm),
|
path: normPath(path),
|
||||||
content: isText ? data : uint8ToBase64(data),
|
content: isText ? content : uint8ToBase64(content),
|
||||||
encoding: encoding || (isText ? "utf-8" : "binary"),
|
encoding: encoding || (isText ? "utf-8" : "binary"),
|
||||||
base64: !isText,
|
base64: !isText,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { fsShim } from "./fs/index.js";
|
||||||
import { installRequestUrlShim } from "./request-url.js";
|
import { installRequestUrlShim } from "./request-url.js";
|
||||||
import { vaultService } from "../services/vault-service.js";
|
import { vaultService } from "../services/vault-service.js";
|
||||||
import { showPluginInstallDialog } from "../ui/bootstrap.js";
|
import { showPluginInstallDialog } from "../ui/bootstrap.js";
|
||||||
import { registerReadTransform } from "./fs/read-transforms.js";
|
import { registerReadTransform } from "./fs/transforms.js";
|
||||||
import { resolveWorkspaceName, initWorkspacePatch } from "./workspace.js";
|
import { resolveWorkspaceName, initWorkspacePatch } from "./workspace.js";
|
||||||
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,31 @@
|
||||||
import { fsShim } from "./fs/index.js";
|
import { fsShim } from "./fs/index.js";
|
||||||
import { registerReadTransform } from "./fs/read-transforms.js";
|
import {
|
||||||
|
registerPathResolver,
|
||||||
|
registerReadTransform,
|
||||||
|
registerWriteTransform,
|
||||||
|
} from "./fs/transforms.js";
|
||||||
|
|
||||||
const WORKSPACE_PATH = ".obsidian/workspace.json";
|
const WORKSPACE_PATH = ".obsidian/workspace.json";
|
||||||
const WORKSPACES_PATH = ".obsidian/workspaces.json";
|
const WORKSPACES_PATH = ".obsidian/workspaces.json";
|
||||||
|
|
||||||
export function rewriteWorkspacePath(normalizedPath) {
|
// Redirect workspace.json to a per-name file when a workspace is active in this tab.
|
||||||
const name = window.__workspaceName;
|
registerPathResolver(
|
||||||
|
(path) => path === WORKSPACE_PATH && !!window.__workspaceName,
|
||||||
|
() => `.obsidian/workspace.${window.__workspaceName}.json`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!name) {
|
// Keep workspaces.json's active field at the canonical value on disk so other tabs see a stable state.
|
||||||
return normalizedPath;
|
registerWriteTransform(WORKSPACES_PATH, (content) => {
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedPath === WORKSPACE_PATH) {
|
|
||||||
return `.obsidian/workspace.${name}.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rewriteWorkspacesContent(content) {
|
|
||||||
const original = window.__originalActiveWorkspace;
|
const original = window.__originalActiveWorkspace;
|
||||||
|
|
||||||
if (!original || !window.__workspaceName) {
|
if (!original || !window.__workspaceName) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
|
|
||||||
|
|
@ -35,7 +36,7 @@ export function rewriteWorkspacesContent(content) {
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
});
|
||||||
|
|
||||||
function setWorkspaceParam(name) {
|
function setWorkspaceParam(name) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
|
@ -136,29 +137,33 @@ export function initWorkspacePatch() {
|
||||||
instance.loadWorkspace = function (name) {
|
instance.loadWorkspace = function (name) {
|
||||||
window.__workspaceName = name;
|
window.__workspaceName = name;
|
||||||
setWorkspaceParam(name);
|
setWorkspaceParam(name);
|
||||||
fsShim._contentCache.invalidate(".obsidian/workspace.json");
|
fsShim.invalidate(WORKSPACE_PATH);
|
||||||
return origLoad(name);
|
return origLoad(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
instance.saveWorkspace = function (name) {
|
instance.saveWorkspace = function (name) {
|
||||||
// Grab the current layout before switching the transport target.
|
// Grab the current layout before changing __workspaceName.
|
||||||
const currentLayout = fsShim._contentCache.get(".obsidian/workspace.json");
|
let currentLayout = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentLayout = fsShim.readFileSync(WORKSPACE_PATH, "utf-8");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
window.__workspaceName = name;
|
window.__workspaceName = name;
|
||||||
setWorkspaceParam(name);
|
setWorkspaceParam(name);
|
||||||
fsShim._contentCache.invalidate(".obsidian/workspace.json");
|
fsShim.invalidate(WORKSPACE_PATH);
|
||||||
const result = origSave(name);
|
const result = origSave(name);
|
||||||
|
|
||||||
// Write the layout to the new workspace file so it exists on disk immediately.
|
// Write the layout to the new workspace file so it exists on disk immediately.
|
||||||
if (currentLayout) {
|
if (currentLayout) {
|
||||||
fsShim.writeFileSync(".obsidian/workspace.json", currentLayout, "utf-8");
|
fsShim.writeFileSync(WORKSPACE_PATH, currentLayout, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Override the active field on reads so the menu matches this tab's workspace.
|
// Override the active field on reads so the menu matches this tab's workspace.
|
||||||
registerReadTransform(".obsidian/workspaces.json", (data) => {
|
registerReadTransform(WORKSPACES_PATH, (data) => {
|
||||||
if (!window.__workspaceName) {
|
if (!window.__workspaceName) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue