From 90d9512f183dce6beb2670166da050343804917d Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sun, 29 Mar 2026 00:26:41 +0100 Subject: [PATCH] implement headless sync plugin --- build.js | 29 ++ plugin/src/settings/server-plugins-tab.js | 14 + server/plugins/headless-sync/plugin/main.js | 434 +++++++++++++++++- .../plugins/headless-sync/plugin/src/api.js | 68 +++ .../plugins/headless-sync/plugin/src/auth.js | 69 +++ .../plugins/headless-sync/plugin/src/main.js | 65 +++ .../headless-sync/plugin/src/settings-tab.js | 240 ++++++++++ .../headless-sync/plugin/src/ws-listener.js | 92 ++++ src/shims/fs/index.js | 16 + src/shims/fs/metadata-cache.js | 7 + 10 files changed, 1026 insertions(+), 8 deletions(-) create mode 100644 server/plugins/headless-sync/plugin/src/api.js create mode 100644 server/plugins/headless-sync/plugin/src/auth.js create mode 100644 server/plugins/headless-sync/plugin/src/main.js create mode 100644 server/plugins/headless-sync/plugin/src/settings-tab.js create mode 100644 server/plugins/headless-sync/plugin/src/ws-listener.js diff --git a/build.js b/build.js index 290f0ad..276c7ad 100644 --- a/build.js +++ b/build.js @@ -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", }), diff --git a/plugin/src/settings/server-plugins-tab.js b/plugin/src/settings/server-plugins-tab.js index 10c4ac9..d16b5a4 100644 --- a/plugin/src/settings/server-plugins-tab.js +++ b/plugin/src/settings/server-plugins-tab.js @@ -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, diff --git a/server/plugins/headless-sync/plugin/main.js b/server/plugins/headless-sync/plugin/main.js index 7a48b7b..9fa3f0a 100644 --- a/server/plugins/headless-sync/plugin/main.js +++ b/server/plugins/headless-sync/plugin/main.js @@ -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; diff --git a/server/plugins/headless-sync/plugin/src/api.js b/server/plugins/headless-sync/plugin/src/api.js new file mode 100644 index 0000000..a4e145c --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/api.js @@ -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, +}; diff --git a/server/plugins/headless-sync/plugin/src/auth.js b/server/plugins/headless-sync/plugin/src/auth.js new file mode 100644 index 0000000..a0d97ce --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/auth.js @@ -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, +}; diff --git a/server/plugins/headless-sync/plugin/src/main.js b/server/plugins/headless-sync/plugin/src/main.js new file mode 100644 index 0000000..2fc29f1 --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/main.js @@ -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; diff --git a/server/plugins/headless-sync/plugin/src/settings-tab.js b/server/plugins/headless-sync/plugin/src/settings-tab.js new file mode 100644 index 0000000..4b07e60 --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/settings-tab.js @@ -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 }; diff --git a/server/plugins/headless-sync/plugin/src/ws-listener.js b/server/plugins/headless-sync/plugin/src/ws-listener.js new file mode 100644 index 0000000..e7e102a --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/ws-listener.js @@ -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 }; diff --git a/src/shims/fs/index.js b/src/shims/fs/index.js index 3c333ac..966f411 100644 --- a/src/shims/fs/index.js +++ b/src/shims/fs/index.js @@ -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); + }, }; diff --git a/src/shims/fs/metadata-cache.js b/src/shims/fs/metadata-cache.js index 9514591..f933bc2 100644 --- a/src/shims/fs/metadata-cache.js +++ b/src/shims/fs/metadata-cache.js @@ -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; }