mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
implement headless sync plugin
This commit is contained in:
29
build.js
29
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",
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
68
server/plugins/headless-sync/plugin/src/api.js
Normal file
68
server/plugins/headless-sync/plugin/src/api.js
Normal 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,
|
||||
};
|
||||
69
server/plugins/headless-sync/plugin/src/auth.js
Normal file
69
server/plugins/headless-sync/plugin/src/auth.js
Normal 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,
|
||||
};
|
||||
65
server/plugins/headless-sync/plugin/src/main.js
Normal file
65
server/plugins/headless-sync/plugin/src/main.js
Normal 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;
|
||||
240
server/plugins/headless-sync/plugin/src/settings-tab.js
Normal file
240
server/plugins/headless-sync/plugin/src/settings-tab.js
Normal 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 };
|
||||
92
server/plugins/headless-sync/plugin/src/ws-listener.js
Normal file
92
server/plugins/headless-sync/plugin/src/ws-listener.js
Normal 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 };
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user