mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
rewrite transforms, implement unified transform layer
This commit is contained in:
@@ -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.
|
||||
|
||||
### 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 is implemented as a synchronous dispatcher that maps channel names to handlers.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// around files without loading them via readFileSync upfront.
|
||||
|
||||
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
||||
import { resolvePath } from "./transforms.js";
|
||||
|
||||
let nextFd = 100;
|
||||
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 (typeof cached === "string") {
|
||||
@@ -33,9 +35,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
||||
}
|
||||
|
||||
// Synchronous fetch fallback
|
||||
console.warn("[shim:fs] fd open cache miss, using sync XHR:", path);
|
||||
const data = transport.readFileSync(path);
|
||||
contentCache.set(path, data);
|
||||
console.warn("[shim:fs] fd open cache miss, using sync XHR:", resolved);
|
||||
const data = transport.readFileSync(resolved);
|
||||
contentCache.set(resolved, data);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -56,8 +58,9 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
||||
|
||||
function openSync(path, flags, mode) {
|
||||
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(
|
||||
`ENOENT: no such file or directory, open '${path}'`,
|
||||
);
|
||||
@@ -67,7 +70,7 @@ export function createFdOps(metadataCache, contentCache, transport) {
|
||||
|
||||
const data = ensureData(path);
|
||||
const fd = nextFd++;
|
||||
openFiles.set(fd, { path, data });
|
||||
openFiles.set(fd, { path: resolved, data });
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createFsWatch } from "./watch.js";
|
||||
import { createWatcherClient } from "./watcher-client.js";
|
||||
import { createFdOps } from "./fd.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 contentCache = new ContentCache();
|
||||
@@ -41,6 +41,10 @@ export const fsShim = {
|
||||
watch: fsWatch.watch,
|
||||
constants,
|
||||
|
||||
invalidate(path) {
|
||||
contentCache.invalidate(resolvePath(path));
|
||||
},
|
||||
|
||||
_metadataCache: metadataCache,
|
||||
_contentCache: contentCache,
|
||||
_watcherClient: watcherClient,
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { markLocalOp } from "./echo-guard.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) {
|
||||
return {
|
||||
async stat(path) {
|
||||
const cached = metadataCache.toStat(path);
|
||||
const resolved = resolvePath(path);
|
||||
const cached = metadataCache.toStat(resolved);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const meta = await transport.stat(path);
|
||||
metadataCache.set(path, meta);
|
||||
return metadataCache.toStat(path);
|
||||
const meta = await transport.stat(resolved);
|
||||
metadataCache.set(resolved, meta);
|
||||
return metadataCache.toStat(resolved);
|
||||
},
|
||||
|
||||
async lstat(path) {
|
||||
@@ -46,6 +47,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
}
|
||||
|
||||
const wantText = encoding === "utf8" || encoding === "utf-8";
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
let result = null;
|
||||
|
||||
@@ -55,7 +57,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
const meta = metadataCache.get(path);
|
||||
const meta = metadataCache.get(resolved);
|
||||
|
||||
if (meta && meta.type === "directory") {
|
||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||
@@ -63,7 +65,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
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(
|
||||
`ENOENT: no such file or directory, open '${path}'`,
|
||||
);
|
||||
@@ -71,16 +74,25 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
result = contentCache.get(path);
|
||||
result = contentCache.get(resolved);
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
result = await transport.readFile(path, encoding);
|
||||
contentCache.set(path, result);
|
||||
try {
|
||||
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).
|
||||
result = applyReadTransform(path, result);
|
||||
result = applyReadTransform(resolved, result);
|
||||
|
||||
if (wantText) {
|
||||
return typeof result === "string"
|
||||
@@ -100,62 +112,74 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
encoding = encoding?.encoding;
|
||||
}
|
||||
|
||||
markLocalOp(path);
|
||||
contentCache.set(path, data);
|
||||
const resolved = resolvePath(path);
|
||||
const transformed = applyWriteTransform(resolved, data);
|
||||
|
||||
markLocalOp(resolved);
|
||||
contentCache.set(resolved, transformed);
|
||||
|
||||
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",
|
||||
size,
|
||||
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) {
|
||||
metadataCache.set(path, {
|
||||
metadataCache.set(resolved, {
|
||||
type: "file",
|
||||
size: result.size || size,
|
||||
mtime: result.mtime,
|
||||
ctime: metadataCache.get(path)?.ctime || Date.now(),
|
||||
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async appendFile(path, data, encoding) {
|
||||
markLocalOp(path);
|
||||
contentCache.invalidate(path);
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
await transport.appendFile(path, data);
|
||||
markLocalOp(resolved);
|
||||
contentCache.invalidate(resolved);
|
||||
|
||||
const meta = await transport.stat(path);
|
||||
metadataCache.set(path, meta);
|
||||
await transport.appendFile(resolved, data);
|
||||
|
||||
const meta = await transport.stat(resolved);
|
||||
metadataCache.set(resolved, meta);
|
||||
},
|
||||
|
||||
async unlink(path) {
|
||||
markLocalOp(path);
|
||||
contentCache.delete(path);
|
||||
metadataCache.delete(path);
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
await transport.unlink(path);
|
||||
markLocalOp(resolved);
|
||||
contentCache.delete(resolved);
|
||||
metadataCache.delete(resolved);
|
||||
|
||||
await transport.unlink(resolved);
|
||||
},
|
||||
|
||||
async rename(oldPath, newPath) {
|
||||
markLocalOp(oldPath);
|
||||
markLocalOp(newPath);
|
||||
const content = contentCache.get(oldPath);
|
||||
const resolvedOld = resolvePath(oldPath);
|
||||
const resolvedNew = resolvePath(newPath);
|
||||
|
||||
markLocalOp(resolvedOld);
|
||||
markLocalOp(resolvedNew);
|
||||
const content = contentCache.get(resolvedOld);
|
||||
|
||||
if (content !== null) {
|
||||
contentCache.set(newPath, content);
|
||||
contentCache.delete(oldPath);
|
||||
contentCache.set(resolvedNew, content);
|
||||
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) {
|
||||
@@ -178,23 +202,29 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
const recursive =
|
||||
typeof options === "object" ? !!options.recursive : false;
|
||||
|
||||
markLocalOp(path);
|
||||
metadataCache.delete(path);
|
||||
contentCache.delete(path);
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
await transport.rm(path, recursive);
|
||||
markLocalOp(resolved);
|
||||
metadataCache.delete(resolved);
|
||||
contentCache.delete(resolved);
|
||||
|
||||
await transport.rm(resolved, recursive);
|
||||
},
|
||||
|
||||
async copyFile(src, dest) {
|
||||
markLocalOp(dest);
|
||||
await transport.copyFile(src, dest);
|
||||
const resolvedDest = resolvePath(dest);
|
||||
|
||||
const meta = await transport.stat(dest);
|
||||
metadataCache.set(dest, meta);
|
||||
markLocalOp(resolvedDest);
|
||||
await transport.copyFile(src, resolvedDest);
|
||||
|
||||
const meta = await transport.stat(resolvedDest);
|
||||
metadataCache.set(resolvedDest, meta);
|
||||
},
|
||||
|
||||
async access(path) {
|
||||
if (metadataCache.has(path)) {
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
if (metadataCache.has(resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,18 +244,21 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
},
|
||||
|
||||
async utimes(path, atime, mtime) {
|
||||
await transport.utimes(path, atime, mtime);
|
||||
const meta = metadataCache.get(path);
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
await transport.utimes(resolved, atime, mtime);
|
||||
const meta = metadataCache.get(resolved);
|
||||
if (meta) {
|
||||
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
|
||||
metadataCache.set(path, meta);
|
||||
metadataCache.set(resolved, meta);
|
||||
}
|
||||
},
|
||||
|
||||
async open(path, flags) {
|
||||
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(
|
||||
`ENOENT: no such file or directory, open '${path}'`,
|
||||
);
|
||||
@@ -237,7 +270,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||
const fileData =
|
||||
typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||
|
||||
const fileStat = metadataCache.toStat(path) || {
|
||||
const fileStat = metadataCache.toStat(resolved) || {
|
||||
size: fileData.length,
|
||||
isFile: () => true,
|
||||
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 { 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) {
|
||||
return {
|
||||
@@ -9,7 +13,8 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return metadataCache.has(path);
|
||||
const resolved = resolvePath(path);
|
||||
return metadataCache.has(resolved);
|
||||
},
|
||||
|
||||
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) {
|
||||
const err = new Error(
|
||||
@@ -45,7 +51,9 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadataCache.has(path)) {
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
if (!metadataCache.has(resolved)) {
|
||||
const err = new Error(
|
||||
`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 resolved = resolvePath(path);
|
||||
|
||||
const meta = metadataCache.get(path);
|
||||
const meta = metadataCache.get(resolved);
|
||||
if (meta && meta.type === "directory") {
|
||||
const e = new Error("EISDIR: illegal operation on a directory, read");
|
||||
e.code = "EISDIR";
|
||||
@@ -80,17 +89,31 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
result = contentCache.get(path);
|
||||
result = contentCache.get(resolved);
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path);
|
||||
result = transport.readFileSync(path, encoding);
|
||||
contentCache.set(path, result);
|
||||
// ENOENT fallback: if the resolved path doesn't exist, try the original.
|
||||
// Covers per-name workspace files that haven't been saved yet.
|
||||
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).
|
||||
result = applyReadTransform(path, result);
|
||||
result = applyReadTransform(resolved, result);
|
||||
|
||||
if (wantText) {
|
||||
return typeof result === "string"
|
||||
@@ -106,40 +129,47 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||
encoding = encoding?.encoding;
|
||||
}
|
||||
|
||||
markLocalOp(path);
|
||||
contentCache.set(path, data);
|
||||
const resolved = resolvePath(path);
|
||||
const transformed = applyWriteTransform(resolved, data);
|
||||
|
||||
markLocalOp(resolved);
|
||||
contentCache.set(resolved, transformed);
|
||||
|
||||
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",
|
||||
size,
|
||||
mtime: Date.now(),
|
||||
ctime: metadataCache.get(path)?.ctime || Date.now(),
|
||||
ctime: metadataCache.get(resolved)?.ctime || Date.now(),
|
||||
});
|
||||
|
||||
// Fire-and-forget async send to server
|
||||
transport.writeFile(path, data, encoding).catch((e) => {
|
||||
transport.writeFile(resolved, transformed, encoding).catch((e) => {
|
||||
console.error(
|
||||
"[shim:fs] writeFileSync background save failed:",
|
||||
path,
|
||||
resolved,
|
||||
e,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
unlinkSync(path) {
|
||||
markLocalOp(path);
|
||||
contentCache.delete(path);
|
||||
metadataCache.delete(path);
|
||||
const resolved = resolvePath(path);
|
||||
|
||||
markLocalOp(resolved);
|
||||
contentCache.delete(resolved);
|
||||
metadataCache.delete(resolved);
|
||||
|
||||
// Fire-and-forget. suppress ENOENT (file already gone)
|
||||
transport.unlink(path).catch((e) => {
|
||||
transport.unlink(resolved).catch((e) => {
|
||||
if (e.code !== "ENOENT") {
|
||||
console.error(
|
||||
"[shim:fs] unlinkSync background delete failed:",
|
||||
path,
|
||||
resolved,
|
||||
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 WORKSPACES_PATH = ".obsidian/workspaces.json";
|
||||
|
||||
function normPath(p) {
|
||||
return (p || "").replace(/^\/+/, "");
|
||||
@@ -116,26 +110,10 @@ export const transport = {
|
||||
},
|
||||
|
||||
async readFile(path, encoding) {
|
||||
const norm = normPath(path);
|
||||
const rewritten = rewriteWorkspacePath(norm);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
const res = await request("GET", "/readFile", {
|
||||
path: normPath(path),
|
||||
encoding: encoding || "",
|
||||
});
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
return res.text();
|
||||
@@ -146,17 +124,10 @@ export const transport = {
|
||||
},
|
||||
|
||||
async writeFile(path, content, encoding) {
|
||||
const norm = normPath(path);
|
||||
let data = content;
|
||||
|
||||
if (norm === WORKSPACES_PATH && typeof data === "string") {
|
||||
data = rewriteWorkspacesContent(data);
|
||||
}
|
||||
|
||||
const isText = typeof data === "string";
|
||||
const isText = typeof content === "string";
|
||||
return requestJson("POST", "/writeFile", {
|
||||
path: rewriteWorkspacePath(norm),
|
||||
content: isText ? data : uint8ToBase64(data),
|
||||
path: normPath(path),
|
||||
content: isText ? content : uint8ToBase64(content),
|
||||
encoding: encoding || (isText ? "utf-8" : "binary"),
|
||||
base64: !isText,
|
||||
});
|
||||
@@ -222,26 +193,10 @@ export const transport = {
|
||||
},
|
||||
|
||||
readFileSync(path, encoding) {
|
||||
const norm = normPath(path);
|
||||
const rewritten = rewriteWorkspacePath(norm);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
const xhr = requestSync("GET", "/readFile", {
|
||||
path: normPath(path),
|
||||
encoding: encoding || "",
|
||||
});
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
return xhr.responseText;
|
||||
@@ -258,17 +213,10 @@ export const transport = {
|
||||
},
|
||||
|
||||
writeFileSync(path, content, encoding) {
|
||||
const norm = normPath(path);
|
||||
let data = content;
|
||||
|
||||
if (norm === WORKSPACES_PATH && typeof data === "string") {
|
||||
data = rewriteWorkspacesContent(data);
|
||||
}
|
||||
|
||||
const isText = typeof data === "string";
|
||||
const isText = typeof content === "string";
|
||||
requestSync("POST", "/writeFile", {
|
||||
path: rewriteWorkspacePath(norm),
|
||||
content: isText ? data : uint8ToBase64(data),
|
||||
path: normPath(path),
|
||||
content: isText ? content : uint8ToBase64(content),
|
||||
encoding: encoding || (isText ? "utf-8" : "binary"),
|
||||
base64: !isText,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fsShim } from "./fs/index.js";
|
||||
import { installRequestUrlShim } from "./request-url.js";
|
||||
import { vaultService } from "../services/vault-service.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 { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
||||
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
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 WORKSPACES_PATH = ".obsidian/workspaces.json";
|
||||
|
||||
export function rewriteWorkspacePath(normalizedPath) {
|
||||
const name = window.__workspaceName;
|
||||
// Redirect workspace.json to a per-name file when a workspace is active in this tab.
|
||||
registerPathResolver(
|
||||
(path) => path === WORKSPACE_PATH && !!window.__workspaceName,
|
||||
() => `.obsidian/workspace.${window.__workspaceName}.json`,
|
||||
);
|
||||
|
||||
if (!name) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
if (normalizedPath === WORKSPACE_PATH) {
|
||||
return `.obsidian/workspace.${name}.json`;
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
export function rewriteWorkspacesContent(content) {
|
||||
// Keep workspaces.json's active field at the canonical value on disk so other tabs see a stable state.
|
||||
registerWriteTransform(WORKSPACES_PATH, (content) => {
|
||||
const original = window.__originalActiveWorkspace;
|
||||
|
||||
if (!original || !window.__workspaceName) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (typeof content !== "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
@@ -35,7 +36,7 @@ export function rewriteWorkspacesContent(content) {
|
||||
} catch {}
|
||||
|
||||
return content;
|
||||
}
|
||||
});
|
||||
|
||||
function setWorkspaceParam(name) {
|
||||
const url = new URL(window.location.href);
|
||||
@@ -136,29 +137,33 @@ export function initWorkspacePatch() {
|
||||
instance.loadWorkspace = function (name) {
|
||||
window.__workspaceName = name;
|
||||
setWorkspaceParam(name);
|
||||
fsShim._contentCache.invalidate(".obsidian/workspace.json");
|
||||
fsShim.invalidate(WORKSPACE_PATH);
|
||||
return origLoad(name);
|
||||
};
|
||||
|
||||
instance.saveWorkspace = function (name) {
|
||||
// Grab the current layout before switching the transport target.
|
||||
const currentLayout = fsShim._contentCache.get(".obsidian/workspace.json");
|
||||
// Grab the current layout before changing __workspaceName.
|
||||
let currentLayout = null;
|
||||
|
||||
try {
|
||||
currentLayout = fsShim.readFileSync(WORKSPACE_PATH, "utf-8");
|
||||
} catch {}
|
||||
|
||||
window.__workspaceName = name;
|
||||
setWorkspaceParam(name);
|
||||
fsShim._contentCache.invalidate(".obsidian/workspace.json");
|
||||
fsShim.invalidate(WORKSPACE_PATH);
|
||||
const result = origSave(name);
|
||||
|
||||
// Write the layout to the new workspace file so it exists on disk immediately.
|
||||
if (currentLayout) {
|
||||
fsShim.writeFileSync(".obsidian/workspace.json", currentLayout, "utf-8");
|
||||
fsShim.writeFileSync(WORKSPACE_PATH, currentLayout, "utf-8");
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 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) {
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user