implement headless sync plugin

This commit is contained in:
Nystik
2026-03-29 00:26:41 +01:00
parent acb700a82b
commit 90d9512f18
10 changed files with 1026 additions and 8 deletions

View File

@@ -40,6 +40,35 @@ Promise.all([
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"],
logLevel: "info",
}),
// Build headless-sync bundled plugin
esbuild.build({
entryPoints: [
path.join(
__dirname,
"server",
"plugins",
"headless-sync",
"plugin",
"src",
"main.js",
),
],
bundle: true,
outfile: path.join(
__dirname,
"server",
"plugins",
"headless-sync",
"plugin",
"main.js",
),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian"],
logLevel: "info",
}),

View File

@@ -4,6 +4,15 @@ function getVaultId() {
return window.__currentVaultId || "";
}
async function refreshPluginCache(bundledPluginId) {
const pluginPath = `.obsidian/plugins/${bundledPluginId}`;
const fs = require("fs");
if (fs._refreshSubtree) {
await fs._refreshSubtree(pluginPath);
}
}
async function fetchPlugins() {
const res = await fetch("/api/plugins");
@@ -84,6 +93,11 @@ function display(containerEl, app) {
toggle.onChange(async (value) => {
try {
await togglePlugin(plugin.id, value, app);
if (value && plugin.bundledPluginId) {
await refreshPluginCache(plugin.bundledPluginId);
}
await activateBundledPlugin(
plugin.bundledPluginId,
value,

View File

@@ -1,14 +1,432 @@
// Stub - will be replaced with real implementation in Phase 4
const { Plugin } = require("obsidian");
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
class IgnisHeadlessSyncPlugin extends Plugin {
// server/plugins/headless-sync/plugin/src/api.js
var require_api = __commonJS({
"server/plugins/headless-sync/plugin/src/api.js"(exports2, module2) {
var BASE = "/api/ext/headless-sync";
async function fetchJson(path, opts = {}) {
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Request failed: ${res.status}`);
}
return res.json();
}
function post(path, body) {
return fetchJson(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
}
function getStatus() {
return fetchJson("/status");
}
function login(token, email, name) {
return post("/login", { token, email, name });
}
function logout() {
return post("/logout", {});
}
function getRemoteVaults() {
return fetchJson("/remote-vaults");
}
function setupSync(vaultId, remoteVault, opts = {}) {
return post("/setup", { vaultId, remoteVault, ...opts });
}
function startSync(vaultId) {
return post("/start", { vaultId });
}
function stopSync(vaultId) {
return post("/stop", { vaultId });
}
function getVaults() {
return fetchJson("/vaults");
}
function getLogs(vaultId, limit = 100) {
return fetchJson(`/logs?vaultId=${encodeURIComponent(vaultId)}&limit=${limit}`);
}
module2.exports = {
getStatus,
login,
logout,
getRemoteVaults,
setupSync,
startSync,
stopSync,
getVaults,
getLogs
};
}
});
// server/plugins/headless-sync/plugin/src/auth.js
var require_auth = __commonJS({
"server/plugins/headless-sync/plugin/src/auth.js"(exports2, module2) {
var api2 = require_api();
function getObsidianSyncToken() {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
try {
const val = JSON.parse(localStorage.getItem(key));
if ((val == null ? void 0 : val.token) && (val == null ? void 0 : val.email) && (val == null ? void 0 : val.name)) {
return val;
}
} catch {
}
}
return null;
}
function triggerLogin(app) {
const aboutTab = app.setting.settingTabs.find((t) => t.id === "about");
if (!aboutTab || !aboutTab.accountSetting) {
return false;
}
const loginBtn = aboutTab.accountSetting.controlEl.querySelector("button");
if (!loginBtn) {
return false;
}
loginBtn.click();
return true;
}
async function sendTokenToServer(tokenData) {
return api2.login(tokenData.token, tokenData.email, tokenData.name);
}
function waitForLogin(callback, timeoutMs = 6e4) {
const interval = 2e3;
let elapsed = 0;
const timer = setInterval(() => {
elapsed += interval;
const token = getObsidianSyncToken();
if (token) {
clearInterval(timer);
callback(token);
return;
}
if (elapsed >= timeoutMs) {
clearInterval(timer);
callback(null);
}
}, interval);
return () => clearInterval(timer);
}
module2.exports = {
getObsidianSyncToken,
triggerLogin,
sendTokenToServer,
waitForLogin
};
}
});
// server/plugins/headless-sync/plugin/src/settings-tab.js
var require_settings_tab = __commonJS({
"server/plugins/headless-sync/plugin/src/settings-tab.js"(exports2, module2) {
var { PluginSettingTab, Setting, Notice } = require("obsidian");
var api2 = require_api();
var auth = require_auth();
var HeadlessSyncSettingTab2 = class extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this._cancelWait = null;
}
async display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Headless Sync" });
let serverStatus;
try {
serverStatus = await api2.getStatus();
} catch (e) {
containerEl.createEl("p", {
text: "Failed to connect to Headless Sync server plugin.",
cls: "mod-warning"
});
return;
}
if (!serverStatus.installed) {
containerEl.createEl("p", {
text: "obsidian-headless (ob CLI) is not installed on the server. Install it to enable sync.",
cls: "mod-warning"
});
return;
}
this.renderAuthSection(containerEl, serverStatus);
if (serverStatus.authenticated) {
await this.renderSyncSection(containerEl);
}
}
renderAuthSection(containerEl, serverStatus) {
const localToken = auth.getObsidianSyncToken();
if (serverStatus.authenticated) {
new Setting(containerEl).setName("Obsidian Sync account").setDesc(
`Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`
).addButton((btn) => {
btn.setButtonText("Disconnect").setWarning().onClick(async () => {
try {
await api2.logout();
new Notice("Disconnected from Headless Sync");
this.display();
} catch (e) {
new Notice(`Failed to disconnect: ${e.message}`);
}
});
});
} else if (localToken) {
new Setting(containerEl).setName("Obsidian Sync account detected").setDesc(`${localToken.name} (${localToken.email})`).addButton((btn) => {
btn.setButtonText("Use this account for Headless Sync").setCta().onClick(async () => {
try {
await auth.sendTokenToServer(localToken);
new Notice("Connected to Headless Sync");
this.display();
} catch (e) {
new Notice(`Failed to connect: ${e.message}`);
}
});
});
} else {
new Setting(containerEl).setName("Obsidian Sync account").setDesc("Sign in to your Obsidian account to enable sync.").addButton((btn) => {
btn.setButtonText("Log in to Obsidian Sync").onClick(() => {
const triggered = auth.triggerLogin(this.app);
if (!triggered) {
new Notice("Could not open login dialog. Try logging in from Settings > General.");
return;
}
this._cancelWait = auth.waitForLogin((token) => {
this._cancelWait = null;
if (token) {
new Notice(`Detected login: ${token.name}`);
this.display();
}
});
});
});
}
}
async renderSyncSection(containerEl) {
var _a;
const vaultId = this.app.vault.getName();
let vaultsData;
try {
vaultsData = await api2.getVaults();
} catch (e) {
containerEl.createEl("p", {
text: `Failed to load sync state: ${e.message}`,
cls: "mod-warning"
});
return;
}
const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId);
containerEl.createEl("h3", { text: "Vault sync" });
if (!vaultState) {
new Setting(containerEl).setName("Sync not configured").setDesc("This vault has not been linked to a remote vault yet.").addButton((btn) => {
btn.setButtonText("Set up sync").setCta().onClick(() => {
new Notice("Vault picker coming soon.");
});
});
return;
}
new Setting(containerEl).setName("Remote vault").setDesc(vaultState.remoteVault || "unknown");
new Setting(containerEl).setName("Sync mode").setDesc(((_a = vaultState.config) == null ? void 0 : _a.mode) || "bidirectional");
const statusText = vaultState.status === "running" ? "Sync is running" : vaultState.status === "error" ? `Error: ${vaultState.error}` : "Sync is stopped";
new Setting(containerEl).setName("Status").setDesc(statusText).addButton((btn) => {
if (vaultState.status === "running") {
btn.setButtonText("Stop sync").setWarning().onClick(async () => {
try {
await api2.stopSync(vaultId);
new Notice("Sync stopped");
this.display();
} catch (e) {
new Notice(`Failed to stop: ${e.message}`);
}
});
} else {
btn.setButtonText("Start sync").setCta().onClick(async () => {
try {
await api2.startSync(vaultId);
new Notice("Sync started");
this.display();
} catch (e) {
new Notice(`Failed to start: ${e.message}`);
}
});
}
});
await this.renderLogs(containerEl, vaultId);
}
async renderLogs(containerEl, vaultId) {
containerEl.createEl("h3", { text: "Recent logs" });
let logsData;
try {
logsData = await api2.getLogs(vaultId, 50);
} catch (e) {
containerEl.createEl("p", {
text: `Failed to load logs: ${e.message}`,
cls: "mod-warning"
});
return;
}
const logContainer = containerEl.createDiv("ignis-log-viewer");
if (logsData.logs.length === 0) {
logContainer.createEl("p", {
text: "No log entries yet.",
cls: "setting-item-description"
});
} else {
for (const entry of logsData.logs) {
const time = new Date(entry.timestamp).toLocaleTimeString();
logContainer.createEl("div", {
text: `[${time}] ${entry.line}`,
cls: "ignis-log-entry"
});
}
}
}
hide() {
if (this._cancelWait) {
this._cancelWait();
this._cancelWait = null;
}
super.hide();
}
};
module2.exports = { HeadlessSyncSettingTab: HeadlessSyncSettingTab2 };
}
});
// server/plugins/headless-sync/plugin/src/ws-listener.js
var require_ws_listener = __commonJS({
"server/plugins/headless-sync/plugin/src/ws-listener.js"(exports2, module2) {
var CHANNEL = "plugin:headless-sync";
var POLL_INTERVAL = 3e3;
var WsListener2 = class {
constructor() {
this._callbacks = /* @__PURE__ */ new Map();
this._handler = null;
this._pollTimer = null;
this._currentWs = null;
}
start() {
this._attachToWs();
this._pollTimer = setInterval(() => {
this._attachToWs();
}, POLL_INTERVAL);
}
stop() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
this._detachFromWs();
}
on(type, callback) {
if (!this._callbacks.has(type)) {
this._callbacks.set(type, []);
}
this._callbacks.get(type).push(callback);
}
off(type, callback) {
const list = this._callbacks.get(type);
if (!list) {
return;
}
const idx = list.indexOf(callback);
if (idx !== -1) {
list.splice(idx, 1);
}
}
_attachToWs() {
const ws = window.__ignisWs;
if (!ws || ws === this._currentWs) {
return;
}
this._detachFromWs();
this._currentWs = ws;
this._handler = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.channel !== CHANNEL) {
return;
}
const listeners = this._callbacks.get(msg.type);
if (listeners) {
for (const cb of listeners) {
cb(msg.payload);
}
}
} catch {
}
};
ws.addEventListener("message", this._handler);
}
_detachFromWs() {
if (this._currentWs && this._handler) {
this._currentWs.removeEventListener("message", this._handler);
}
this._currentWs = null;
this._handler = null;
}
};
module2.exports = { WsListener: WsListener2 };
}
});
// server/plugins/headless-sync/plugin/src/main.js
var { Plugin } = require("obsidian");
var { HeadlessSyncSettingTab } = require_settings_tab();
var { WsListener } = require_ws_listener();
var api = require_api();
var IgnisHeadlessSyncPlugin = class extends Plugin {
async onload() {
console.log("[ignis-headless-sync] Loaded (stub)");
this.wsListener = new WsListener();
this.wsListener.start();
this.wsListener.on("sync-status", (payload) => {
if (payload.vaultId === this.app.vault.getName()) {
console.log("[ignis-headless-sync] Status update:", payload.status);
}
});
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
this.addCommand({
id: "start-sync",
name: "Start server-side sync",
callback: async () => {
try {
await api.startSync(this.app.vault.getName());
} catch (e) {
console.error("[ignis-headless-sync] Start failed:", e.message);
}
}
});
this.addCommand({
id: "stop-sync",
name: "Stop server-side sync",
callback: async () => {
try {
await api.stopSync(this.app.vault.getName());
} catch (e) {
console.error("[ignis-headless-sync] Stop failed:", e.message);
}
}
});
this.addCommand({
id: "show-status",
name: "Show sync status",
callback: () => {
this.app.setting.open();
this.app.setting.openTabById("ignis-headless-sync");
}
});
console.log("[ignis-headless-sync] Loaded");
}
onunload() {
console.log("[ignis-headless-sync] Unloaded (stub)");
if (this.wsListener) {
this.wsListener.stop();
this.wsListener = null;
}
console.log("[ignis-headless-sync] Unloaded");
}
}
};
module.exports = IgnisHeadlessSyncPlugin;

View File

@@ -0,0 +1,68 @@
const BASE = "/api/ext/headless-sync";
async function fetchJson(path, opts = {}) {
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Request failed: ${res.status}`);
}
return res.json();
}
function post(path, body) {
return fetchJson(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function getStatus() {
return fetchJson("/status");
}
function login(token, email, name) {
return post("/login", { token, email, name });
}
function logout() {
return post("/logout", {});
}
function getRemoteVaults() {
return fetchJson("/remote-vaults");
}
function setupSync(vaultId, remoteVault, opts = {}) {
return post("/setup", { vaultId, remoteVault, ...opts });
}
function startSync(vaultId) {
return post("/start", { vaultId });
}
function stopSync(vaultId) {
return post("/stop", { vaultId });
}
function getVaults() {
return fetchJson("/vaults");
}
function getLogs(vaultId, limit = 100) {
return fetchJson(`/logs?vaultId=${encodeURIComponent(vaultId)}&limit=${limit}`);
}
module.exports = {
getStatus,
login,
logout,
getRemoteVaults,
setupSync,
startSync,
stopSync,
getVaults,
getLogs,
};

View File

@@ -0,0 +1,69 @@
const api = require("./api");
function getObsidianSyncToken() {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
try {
const val = JSON.parse(localStorage.getItem(key));
if (val?.token && val?.email && val?.name) {
return val;
}
} catch {}
}
return null;
}
function triggerLogin(app) {
const aboutTab = app.setting.settingTabs.find((t) => t.id === "about");
if (!aboutTab || !aboutTab.accountSetting) {
return false;
}
const loginBtn = aboutTab.accountSetting.controlEl.querySelector("button");
if (!loginBtn) {
return false;
}
loginBtn.click();
return true;
}
async function sendTokenToServer(tokenData) {
return api.login(tokenData.token, tokenData.email, tokenData.name);
}
function waitForLogin(callback, timeoutMs = 60000) {
const interval = 2000;
let elapsed = 0;
const timer = setInterval(() => {
elapsed += interval;
const token = getObsidianSyncToken();
if (token) {
clearInterval(timer);
callback(token);
return;
}
if (elapsed >= timeoutMs) {
clearInterval(timer);
callback(null);
}
}, interval);
return () => clearInterval(timer);
}
module.exports = {
getObsidianSyncToken,
triggerLogin,
sendTokenToServer,
waitForLogin,
};

View File

@@ -0,0 +1,65 @@
const { Plugin } = require("obsidian");
const { HeadlessSyncSettingTab } = require("./settings-tab");
const { WsListener } = require("./ws-listener");
const api = require("./api");
class IgnisHeadlessSyncPlugin extends Plugin {
async onload() {
this.wsListener = new WsListener();
this.wsListener.start();
this.wsListener.on("sync-status", (payload) => {
if (payload.vaultId === this.app.vault.getName()) {
console.log("[ignis-headless-sync] Status update:", payload.status);
}
});
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
this.addCommand({
id: "start-sync",
name: "Start server-side sync",
callback: async () => {
try {
await api.startSync(this.app.vault.getName());
} catch (e) {
console.error("[ignis-headless-sync] Start failed:", e.message);
}
},
});
this.addCommand({
id: "stop-sync",
name: "Stop server-side sync",
callback: async () => {
try {
await api.stopSync(this.app.vault.getName());
} catch (e) {
console.error("[ignis-headless-sync] Stop failed:", e.message);
}
},
});
this.addCommand({
id: "show-status",
name: "Show sync status",
callback: () => {
this.app.setting.open();
this.app.setting.openTabById("ignis-headless-sync");
},
});
console.log("[ignis-headless-sync] Loaded");
}
onunload() {
if (this.wsListener) {
this.wsListener.stop();
this.wsListener = null;
}
console.log("[ignis-headless-sync] Unloaded");
}
}
module.exports = IgnisHeadlessSyncPlugin;

View File

@@ -0,0 +1,240 @@
const { PluginSettingTab, Setting, Notice } = require("obsidian");
const api = require("./api");
const auth = require("./auth");
class HeadlessSyncSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this._cancelWait = null;
}
async display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Headless Sync" });
let serverStatus;
try {
serverStatus = await api.getStatus();
} catch (e) {
containerEl.createEl("p", {
text: "Failed to connect to Headless Sync server plugin.",
cls: "mod-warning",
});
return;
}
if (!serverStatus.installed) {
containerEl.createEl("p", {
text: "obsidian-headless (ob CLI) is not installed on the server. Install it to enable sync.",
cls: "mod-warning",
});
return;
}
this.renderAuthSection(containerEl, serverStatus);
if (serverStatus.authenticated) {
await this.renderSyncSection(containerEl);
}
}
renderAuthSection(containerEl, serverStatus) {
const localToken = auth.getObsidianSyncToken();
if (serverStatus.authenticated) {
// State C: connected to server
new Setting(containerEl)
.setName("Obsidian Sync account")
.setDesc(
`Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`,
)
.addButton((btn) => {
btn
.setButtonText("Disconnect")
.setWarning()
.onClick(async () => {
try {
await api.logout();
new Notice("Disconnected from Headless Sync");
this.display();
} catch (e) {
new Notice(`Failed to disconnect: ${e.message}`);
}
});
});
} else if (localToken) {
// State B: signed into Obsidian, not connected to server
new Setting(containerEl)
.setName("Obsidian Sync account detected")
.setDesc(`${localToken.name} (${localToken.email})`)
.addButton((btn) => {
btn
.setButtonText("Use this account for Headless Sync")
.setCta()
.onClick(async () => {
try {
await auth.sendTokenToServer(localToken);
new Notice("Connected to Headless Sync");
this.display();
} catch (e) {
new Notice(`Failed to connect: ${e.message}`);
}
});
});
} else {
// State A: not signed into Obsidian
new Setting(containerEl)
.setName("Obsidian Sync account")
.setDesc("Sign in to your Obsidian account to enable sync.")
.addButton((btn) => {
btn.setButtonText("Log in to Obsidian Sync").onClick(() => {
const triggered = auth.triggerLogin(this.app);
if (!triggered) {
new Notice("Could not open login dialog. Try logging in from Settings > General.");
return;
}
this._cancelWait = auth.waitForLogin((token) => {
this._cancelWait = null;
if (token) {
new Notice(`Detected login: ${token.name}`);
this.display();
}
});
});
});
}
}
async renderSyncSection(containerEl) {
const vaultId = this.app.vault.getName();
let vaultsData;
try {
vaultsData = await api.getVaults();
} catch (e) {
containerEl.createEl("p", {
text: `Failed to load sync state: ${e.message}`,
cls: "mod-warning",
});
return;
}
const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId);
containerEl.createEl("h3", { text: "Vault sync" });
if (!vaultState) {
new Setting(containerEl)
.setName("Sync not configured")
.setDesc("This vault has not been linked to a remote vault yet.")
.addButton((btn) => {
btn
.setButtonText("Set up sync")
.setCta()
.onClick(() => {
new Notice("Vault picker coming soon.");
});
});
return;
}
// Show current sync config
new Setting(containerEl)
.setName("Remote vault")
.setDesc(vaultState.remoteVault || "unknown");
new Setting(containerEl)
.setName("Sync mode")
.setDesc(vaultState.config?.mode || "bidirectional");
// Sync controls
const statusText =
vaultState.status === "running"
? "Sync is running"
: vaultState.status === "error"
? `Error: ${vaultState.error}`
: "Sync is stopped";
new Setting(containerEl)
.setName("Status")
.setDesc(statusText)
.addButton((btn) => {
if (vaultState.status === "running") {
btn.setButtonText("Stop sync").setWarning().onClick(async () => {
try {
await api.stopSync(vaultId);
new Notice("Sync stopped");
this.display();
} catch (e) {
new Notice(`Failed to stop: ${e.message}`);
}
});
} else {
btn.setButtonText("Start sync").setCta().onClick(async () => {
try {
await api.startSync(vaultId);
new Notice("Sync started");
this.display();
} catch (e) {
new Notice(`Failed to start: ${e.message}`);
}
});
}
});
// Log viewer
await this.renderLogs(containerEl, vaultId);
}
async renderLogs(containerEl, vaultId) {
containerEl.createEl("h3", { text: "Recent logs" });
let logsData;
try {
logsData = await api.getLogs(vaultId, 50);
} catch (e) {
containerEl.createEl("p", {
text: `Failed to load logs: ${e.message}`,
cls: "mod-warning",
});
return;
}
const logContainer = containerEl.createDiv("ignis-log-viewer");
if (logsData.logs.length === 0) {
logContainer.createEl("p", {
text: "No log entries yet.",
cls: "setting-item-description",
});
} else {
for (const entry of logsData.logs) {
const time = new Date(entry.timestamp).toLocaleTimeString();
logContainer.createEl("div", {
text: `[${time}] ${entry.line}`,
cls: "ignis-log-entry",
});
}
}
}
hide() {
if (this._cancelWait) {
this._cancelWait();
this._cancelWait = null;
}
super.hide();
}
}
module.exports = { HeadlessSyncSettingTab };

View File

@@ -0,0 +1,92 @@
const CHANNEL = "plugin:headless-sync";
const POLL_INTERVAL = 3000;
class WsListener {
constructor() {
this._callbacks = new Map();
this._handler = null;
this._pollTimer = null;
this._currentWs = null;
}
start() {
this._attachToWs();
this._pollTimer = setInterval(() => {
this._attachToWs();
}, POLL_INTERVAL);
}
stop() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
this._detachFromWs();
}
on(type, callback) {
if (!this._callbacks.has(type)) {
this._callbacks.set(type, []);
}
this._callbacks.get(type).push(callback);
}
off(type, callback) {
const list = this._callbacks.get(type);
if (!list) {
return;
}
const idx = list.indexOf(callback);
if (idx !== -1) {
list.splice(idx, 1);
}
}
_attachToWs() {
const ws = window.__ignisWs;
if (!ws || ws === this._currentWs) {
return;
}
this._detachFromWs();
this._currentWs = ws;
this._handler = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.channel !== CHANNEL) {
return;
}
const listeners = this._callbacks.get(msg.type);
if (listeners) {
for (const cb of listeners) {
cb(msg.payload);
}
}
} catch {}
};
ws.addEventListener("message", this._handler);
}
_detachFromWs() {
if (this._currentWs && this._handler) {
this._currentWs.removeEventListener("message", this._handler);
}
this._currentWs = null;
this._handler = null;
}
}
module.exports = { WsListener };

View File

@@ -49,4 +49,20 @@ export const fsShim = {
metadataCache.populate(tree);
console.log(`[shim:fs] Initialized with ${metadataCache.size} entries`);
},
async _refreshSubtree(subPath) {
const tree = await transport.fetchTree(subPath);
const prefix = subPath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
// Tree keys are relative to subPath, so prefix them to make vault-relative
const prefixed = {};
prefixed[prefix] = { type: "directory" };
for (const [key, meta] of Object.entries(tree)) {
prefixed[prefix + "/" + key] = meta;
}
metadataCache.merge(prefixed);
},
};

View File

@@ -82,6 +82,13 @@ export class MetadataCache {
return results;
}
// Merge entries from a subtree without clearing existing data
merge(tree) {
for (const [path, meta] of Object.entries(tree)) {
this._entries.set(this._normalize(path), meta);
}
}
get size() {
return this._entries.size;
}