mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
move server into apps/ignis-server
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
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 createRemoteVault(name, encryption, password, region) {
|
||||
return post("/create-remote-vault", { name, encryption, password, region });
|
||||
}
|
||||
|
||||
function startSync(vaultId) {
|
||||
return post("/start", { vaultId });
|
||||
}
|
||||
|
||||
function stopSync(vaultId) {
|
||||
return post("/stop", { vaultId });
|
||||
}
|
||||
|
||||
function unlinkVault(vaultId) {
|
||||
return post("/unlink", { 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,
|
||||
createRemoteVault,
|
||||
startSync,
|
||||
stopSync,
|
||||
unlinkVault,
|
||||
getVaults,
|
||||
getLogs,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
const { Notice } = require("obsidian");
|
||||
const fs = require("fs");
|
||||
|
||||
const CORE_PLUGINS_PATH = ".obsidian/core-plugins.json";
|
||||
|
||||
// Reads core-plugins.json via the fs shim. When headless sync is active,
|
||||
// the shim patches sync: false, so this returns false. When the flag is
|
||||
// cleared (user action), this returns the real value.
|
||||
function isCoreSyncEnabled() {
|
||||
try {
|
||||
const data = fs.readFileSync(CORE_PLUGINS_PATH, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.sync === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showConflictWarning(title, message) {
|
||||
if (!window.IgnisUI?.MessageDialog) {
|
||||
new Notice(`${title}: ${message}`, 10000);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = new window.IgnisUI.MessageDialog({
|
||||
target: document.body,
|
||||
props: { title, message },
|
||||
});
|
||||
|
||||
dialog.$on("confirm", () => {
|
||||
dialog.$destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function startCoreSyncGuard(plugin, api, wsListener) {
|
||||
const app = plugin.app;
|
||||
const vaultId = app.vault.getName();
|
||||
|
||||
// Monkey-patch syncPlugin.enable() to clear the shim flag before
|
||||
// Obsidian writes core-plugins.json. This ensures the read transform
|
||||
// doesn't block a user-initiated core sync enable.
|
||||
const syncPlugin = app.internalPlugins.getPluginById("sync");
|
||||
let origEnable = null;
|
||||
|
||||
if (syncPlugin) {
|
||||
origEnable = syncPlugin.enable.bind(syncPlugin);
|
||||
|
||||
syncPlugin.enable = function (...args) {
|
||||
window.__ignisHeadlessSyncActive = false;
|
||||
api.stopSync(vaultId).catch(() => {});
|
||||
return origEnable(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// Watch for core-plugins.json changes via WebSocket.
|
||||
let wasEnabled = isCoreSyncEnabled();
|
||||
|
||||
const rawHandler = (msg) => {
|
||||
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) {
|
||||
handleCoreSyncChange();
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.onRaw(rawHandler);
|
||||
|
||||
function handleCoreSyncChange() {
|
||||
const enabled = isCoreSyncEnabled();
|
||||
|
||||
if (enabled && !wasEnabled) {
|
||||
showConflictWarning(
|
||||
"Headless Sync Stopped",
|
||||
"Obsidian Sync has been enabled. Headless Sync has been automatically " +
|
||||
"stopped to avoid conflicts between the two sync methods.\n\n" +
|
||||
"To use Headless Sync again, disable Obsidian Sync in Core Plugins.",
|
||||
);
|
||||
}
|
||||
|
||||
wasEnabled = enabled;
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
wsListener.offRaw();
|
||||
|
||||
if (syncPlugin && origEnable) {
|
||||
syncPlugin.enable = origEnable;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isCoreSyncEnabled,
|
||||
startCoreSyncGuard,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
const api = require("./api");
|
||||
|
||||
async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||
const details = containerEl.createEl("details", {
|
||||
cls: "ignis-log-details",
|
||||
});
|
||||
|
||||
details.createEl("summary", { text: "Sync logs" });
|
||||
|
||||
const logBox = details.createEl("pre", { cls: "ignis-log-terminal" });
|
||||
const codeEl = logBox.createEl("code");
|
||||
|
||||
let logsData;
|
||||
|
||||
try {
|
||||
logsData = await api.getLogs(vaultId, 50);
|
||||
} catch (e) {
|
||||
codeEl.textContent = `Failed to load logs: ${e.message}`;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (logsData.logs.length === 0) {
|
||||
codeEl.textContent = "No log entries yet.";
|
||||
} else {
|
||||
const lines = logsData.logs.map((entry) => {
|
||||
const time = new Date(entry.timestamp).toLocaleTimeString();
|
||||
return `[${time}] ${entry.line}`;
|
||||
});
|
||||
|
||||
codeEl.textContent = lines.join("\n");
|
||||
}
|
||||
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
|
||||
if (!wsListener) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
wsListener.subscribeLogs(vaultId);
|
||||
} else {
|
||||
wsListener.unsubscribeLogs();
|
||||
}
|
||||
});
|
||||
|
||||
const onLog = (payload) => {
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const line = `[${time}] ${payload.line}`;
|
||||
|
||||
if (codeEl.textContent === "No log entries yet.") {
|
||||
codeEl.textContent = line;
|
||||
} else {
|
||||
codeEl.textContent += "\n" + line;
|
||||
}
|
||||
|
||||
const isNearBottom =
|
||||
logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 50;
|
||||
|
||||
if (isNearBottom) {
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("sync-log", onLog);
|
||||
|
||||
return () => {
|
||||
wsListener.off("sync-log", onLog);
|
||||
wsListener.unsubscribeLogs();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { renderLogViewer };
|
||||
@@ -0,0 +1,86 @@
|
||||
const { Plugin } = require("obsidian");
|
||||
const { HeadlessSyncSettingTab } = require("./settings-tab");
|
||||
const { WsListener } = require("./ws-listener");
|
||||
const { initSyncStatusBar } = require("./sync-status-bar");
|
||||
const { startCoreSyncGuard } = require("./core-sync-guard");
|
||||
const api = require("./api");
|
||||
|
||||
class IgnisHeadlessSyncPlugin extends Plugin {
|
||||
async onload() {
|
||||
if (!window.__ignis) {
|
||||
console.log(
|
||||
"[ignis-headless-sync] Not running in Ignis - plugin is a no-op.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsListener = new WsListener();
|
||||
this.wsListener.start();
|
||||
|
||||
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
|
||||
|
||||
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
|
||||
|
||||
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener);
|
||||
|
||||
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 (!window.__ignis) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__ignisHeadlessSyncActive = false;
|
||||
|
||||
if (this._coreSyncGuard) {
|
||||
this._coreSyncGuard.cleanup();
|
||||
this._coreSyncGuard = null;
|
||||
}
|
||||
|
||||
if (this._syncStatusBarCleanup) {
|
||||
this._syncStatusBarCleanup();
|
||||
this._syncStatusBarCleanup = null;
|
||||
}
|
||||
|
||||
if (this.wsListener) {
|
||||
this.wsListener.stop();
|
||||
this.wsListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = IgnisHeadlessSyncPlugin;
|
||||
@@ -0,0 +1,341 @@
|
||||
const { PluginSettingTab, Setting, Notice } = require("obsidian");
|
||||
const api = require("./api");
|
||||
const auth = require("./auth");
|
||||
const { isCoreSyncEnabled } = require("./core-sync-guard");
|
||||
const { renderLogViewer } = require("./log-viewer");
|
||||
|
||||
class HeadlessSyncSettingTab extends PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this._cancelWait = null;
|
||||
this._logCleanup = null;
|
||||
|
||||
// Persistent container refs
|
||||
this._authEl = null;
|
||||
this._syncEl = null;
|
||||
this._logsEl = null;
|
||||
this._logsRendered = false;
|
||||
}
|
||||
|
||||
async display() {
|
||||
// Clean up previous log listener before rebuilding
|
||||
if (this._logCleanup) {
|
||||
this._logCleanup();
|
||||
this._logCleanup = null;
|
||||
}
|
||||
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
this._logsRendered = false;
|
||||
|
||||
if (isCoreSyncEnabled()) {
|
||||
const syncWarningSetting = new Setting(containerEl)
|
||||
.setName("Obsidian Sync is active");
|
||||
|
||||
syncWarningSetting.descEl.createEl("span", {
|
||||
text: "Headless Sync cannot run alongside Obsidian's built-in sync to avoid conflicts. Disable Obsidian Sync in Core Plugins to use Headless Sync instead.",
|
||||
cls: "mod-warning",
|
||||
});
|
||||
|
||||
syncWarningSetting
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Open Core Plugins").onClick(() => {
|
||||
this.app.setting.openTabById("plugins");
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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._authEl = containerEl.createDiv();
|
||||
this._syncEl = containerEl.createDiv();
|
||||
this._logsEl = containerEl.createDiv();
|
||||
|
||||
this.renderAuthSection(serverStatus);
|
||||
await this.renderSyncSection(serverStatus.authenticated);
|
||||
}
|
||||
|
||||
renderAuthSection(serverStatus) {
|
||||
this._authEl.empty();
|
||||
|
||||
const localToken = auth.getObsidianSyncToken();
|
||||
|
||||
if (serverStatus.authenticated) {
|
||||
new Setting(this._authEl)
|
||||
.setName("Obsidian Sync account")
|
||||
.setDesc(
|
||||
`Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`,
|
||||
)
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Disconnect");
|
||||
btn.buttonEl.addClass("mod-destructive");
|
||||
btn.onClick(async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
new Notice("Disconnected from Headless Sync");
|
||||
const status = await api.getStatus();
|
||||
this.renderAuthSection(status);
|
||||
await this.renderSyncSection(status.authenticated);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to disconnect: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (localToken) {
|
||||
new Setting(this._authEl)
|
||||
.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");
|
||||
const status = await api.getStatus();
|
||||
this.renderAuthSection(status);
|
||||
await this.renderSyncSection(status.authenticated);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to connect: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
new Setting(this._authEl)
|
||||
.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(async (token) => {
|
||||
this._cancelWait = null;
|
||||
|
||||
if (token) {
|
||||
new Notice(`Detected login: ${token.name}`);
|
||||
const status = await api.getStatus();
|
||||
this.renderAuthSection(status);
|
||||
await this.renderSyncSection(status.authenticated);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async renderSyncSection(authenticated) {
|
||||
this._syncEl.empty();
|
||||
|
||||
this._syncEl.createEl("h3", { text: "Vault sync" });
|
||||
|
||||
if (!authenticated) {
|
||||
new Setting(this._syncEl)
|
||||
.setName("Sync not configured")
|
||||
.setDesc("Sign in to your Obsidian Sync account to set up sync.")
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Set up sync");
|
||||
btn.buttonEl.disabled = true;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultId = this.app.vault.getName();
|
||||
|
||||
let vaultsData;
|
||||
|
||||
try {
|
||||
vaultsData = await api.getVaults();
|
||||
} catch (e) {
|
||||
this._syncEl.createEl("p", {
|
||||
text: `Failed to load sync state: ${e.message}`,
|
||||
cls: "mod-warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId);
|
||||
|
||||
if (!vaultState) {
|
||||
new Setting(this._syncEl)
|
||||
.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(() => {
|
||||
const scope = this.app.setting.scope;
|
||||
const prevFocusContainer = scope.tabFocusContainerEl;
|
||||
scope.tabFocusContainerEl = null;
|
||||
|
||||
const cleanup = () => {
|
||||
scope.tabFocusContainerEl = prevFocusContainer;
|
||||
};
|
||||
|
||||
const modal = new window.IgnisUI.SyncSetupModal({
|
||||
target: document.body,
|
||||
props: {
|
||||
vaultId,
|
||||
onSuccess: async () => {
|
||||
cleanup();
|
||||
modal.$destroy();
|
||||
await this.renderSyncSection(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
modal.$on("close", () => {
|
||||
cleanup();
|
||||
modal.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show current sync config
|
||||
new Setting(this._syncEl)
|
||||
.setName("Remote vault")
|
||||
.setDesc(
|
||||
vaultState.remoteVaultName || vaultState.remoteVault || "unknown",
|
||||
)
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Unlink");
|
||||
btn.buttonEl.addClass("mod-destructive");
|
||||
btn.onClick(async () => {
|
||||
try {
|
||||
await api.unlinkVault(vaultId);
|
||||
new Notice("Vault unlinked");
|
||||
await this.renderSyncSection(true);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to unlink: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this._syncEl)
|
||||
.setName("Sync mode")
|
||||
.setDesc(vaultState.config?.mode || "bidirectional");
|
||||
|
||||
// Sync controls
|
||||
const controlsEl = this._syncEl.createDiv();
|
||||
this.renderSyncControls(controlsEl, vaultId, vaultState);
|
||||
|
||||
// Log viewer - only render once, persists across sync section rebuilds
|
||||
if (!this._logsRendered) {
|
||||
await this.renderLogs(this._logsEl, vaultId);
|
||||
this._logsRendered = true;
|
||||
}
|
||||
}
|
||||
|
||||
async renderSyncControls(containerEl, vaultId, vaultState) {
|
||||
containerEl.empty();
|
||||
|
||||
if (!vaultState) {
|
||||
try {
|
||||
const data = await api.getVaults();
|
||||
vaultState = (data.vaults || []).find((v) => v.vaultId === vaultId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!vaultState) {
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
btn.buttonEl.addClass("mod-destructive");
|
||||
btn.onClick(async () => {
|
||||
try {
|
||||
await api.stopSync(vaultId);
|
||||
new Notice("Sync stopped");
|
||||
this.renderSyncControls(containerEl, vaultId);
|
||||
} 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.renderSyncControls(containerEl, vaultId);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to start: ${e.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async renderLogs(containerEl, vaultId) {
|
||||
this._logCleanup = await renderLogViewer(
|
||||
containerEl,
|
||||
vaultId,
|
||||
this.plugin.wsListener,
|
||||
);
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this._cancelWait) {
|
||||
this._cancelWait();
|
||||
this._cancelWait = null;
|
||||
}
|
||||
|
||||
if (this._logCleanup) {
|
||||
this._logCleanup();
|
||||
this._logCleanup = null;
|
||||
}
|
||||
|
||||
super.hide();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { HeadlessSyncSettingTab };
|
||||
@@ -0,0 +1,283 @@
|
||||
const { setIcon } = require("obsidian");
|
||||
const api = require("./api");
|
||||
|
||||
const TOOLTIP_MAP = {
|
||||
running: "Syncing...",
|
||||
synced: "Synced",
|
||||
stopped: "Sync stopped",
|
||||
error: "Sync error",
|
||||
};
|
||||
|
||||
function initSyncStatusBar(plugin, wsListener) {
|
||||
const vaultId = plugin.app.vault.getName();
|
||||
const item = plugin.addStatusBarItem();
|
||||
item.addClass("ignis-sync-statusbar");
|
||||
item.style.display = "none";
|
||||
|
||||
const iconEl = item.createEl("span", { cls: "ignis-sync-icon" });
|
||||
setIcon(iconEl, "refresh-cw");
|
||||
|
||||
let popoverEl = null;
|
||||
let popoverOpen = false;
|
||||
let currentStatus = "stopped";
|
||||
let outsideClickHandler = null;
|
||||
|
||||
function updateState(status, error) {
|
||||
currentStatus = status;
|
||||
|
||||
iconEl.className = "ignis-sync-icon";
|
||||
|
||||
if (status === "running") {
|
||||
iconEl.addClass("ignis-sync-syncing");
|
||||
iconEl.addClass("ignis-sync-spinning");
|
||||
} else if (status === "error") {
|
||||
iconEl.addClass("ignis-sync-error");
|
||||
} else if (status === "stopped") {
|
||||
iconEl.addClass("ignis-sync-stopped");
|
||||
} else {
|
||||
iconEl.addClass("ignis-sync-synced");
|
||||
}
|
||||
|
||||
const tooltip = error || TOOLTIP_MAP[status] || status;
|
||||
item.setAttribute("aria-label", tooltip);
|
||||
item.setAttribute("data-tooltip-position", "top");
|
||||
}
|
||||
|
||||
function showPopover(text) {
|
||||
if (popoverEl) {
|
||||
const span = popoverEl.querySelector(".ignis-sync-popover-filename");
|
||||
|
||||
if (span) {
|
||||
span.textContent = text;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
popoverEl = item.createEl("div", { cls: "ignis-sync-popover" });
|
||||
popoverEl.createEl("span", {
|
||||
text: text,
|
||||
cls: "ignis-sync-popover-filename",
|
||||
});
|
||||
|
||||
popoverOpen = true;
|
||||
|
||||
wsListener.subscribeLogs(vaultId);
|
||||
|
||||
outsideClickHandler = (e) => {
|
||||
if (!item.contains(e.target)) {
|
||||
hidePopover();
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", outsideClickHandler, true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
if (popoverEl) {
|
||||
popoverEl.remove();
|
||||
popoverEl = null;
|
||||
}
|
||||
|
||||
if (outsideClickHandler) {
|
||||
document.removeEventListener("click", outsideClickHandler, true);
|
||||
outsideClickHandler = null;
|
||||
}
|
||||
|
||||
wsListener.unsubscribeLogs();
|
||||
popoverOpen = false;
|
||||
}
|
||||
|
||||
function truncatePath(path, maxLen) {
|
||||
if (path.length <= maxLen) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return "\u2026" + path.slice(-(maxLen - 1));
|
||||
}
|
||||
|
||||
function formatPopoverText(prefix, path) {
|
||||
return `${prefix}: ${truncatePath(path, 46 - prefix.length)}`;
|
||||
}
|
||||
|
||||
function updatePopoverText(text) {
|
||||
if (!popoverOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = popoverEl?.querySelector(".ignis-sync-popover-filename");
|
||||
|
||||
if (span) {
|
||||
span.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractFileActivity(line) {
|
||||
// Downloading/Downloaded path
|
||||
let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Syncing", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Uploading file / Upload complete path
|
||||
match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Syncing", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Deleting path
|
||||
match = line.match(/^Deleting\s+(.+)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Deleting", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Push: path (updated)
|
||||
match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Syncing", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Push: path (deleted)
|
||||
match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Deleting", path: match[1].trim() };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFullySynced(line) {
|
||||
return /Fully synced/i.test(line);
|
||||
}
|
||||
|
||||
// Click toggles popover
|
||||
item.addEventListener("click", () => {
|
||||
if (popoverOpen) {
|
||||
hidePopover();
|
||||
} else {
|
||||
showPopover(TOOLTIP_MAP[currentStatus] || currentStatus);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for status updates
|
||||
const onStatus = (payload) => {
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.style.display = "";
|
||||
|
||||
// "running" from server means the process is alive, but we refine
|
||||
// the visual state based on log activity.
|
||||
if (payload.status === "running") {
|
||||
updateState("synced");
|
||||
} else {
|
||||
updateState(payload.status, payload.error);
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("sync-status", onStatus);
|
||||
|
||||
// Debounce the transition to "synced" state to avoid flickering
|
||||
// during rapid delete cycles (Fully synced -> Deleting -> Fully synced).
|
||||
let syncedTimer = null;
|
||||
|
||||
function deferSynced() {
|
||||
if (syncedTimer) {
|
||||
clearTimeout(syncedTimer);
|
||||
}
|
||||
|
||||
syncedTimer = setTimeout(() => {
|
||||
syncedTimer = null;
|
||||
updateState("synced");
|
||||
updatePopoverText("Synced");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function cancelDeferredSynced() {
|
||||
if (syncedTimer) {
|
||||
clearTimeout(syncedTimer);
|
||||
syncedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for log lines
|
||||
const onLog = (payload) => {
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullySynced(payload.line)) {
|
||||
deferSynced();
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = extractFileActivity(payload.line);
|
||||
|
||||
if (activity) {
|
||||
cancelDeferredSynced();
|
||||
updateState("running");
|
||||
updatePopoverText(formatPopoverText(activity.prefix, activity.path));
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("sync-log", onLog);
|
||||
|
||||
// Fetch initial state
|
||||
api
|
||||
.getVaults()
|
||||
.then((data) => {
|
||||
const vaults = data.vaults || [];
|
||||
const vault = vaults.find((v) => v.vaultId === vaultId);
|
||||
|
||||
if (vault) {
|
||||
item.style.display = "";
|
||||
updateState(vault.status, vault.error);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Poll WebSocket state to detect server disconnect/reconnect
|
||||
let wasDisconnected = false;
|
||||
|
||||
const wsCheckInterval = setInterval(() => {
|
||||
const disconnected = !wsListener.isConnected();
|
||||
|
||||
if (disconnected && currentStatus === "running") {
|
||||
updateState("error", "Server connection lost");
|
||||
wasDisconnected = true;
|
||||
} else if (!disconnected && wasDisconnected) {
|
||||
wasDisconnected = false;
|
||||
|
||||
api
|
||||
.getVaults()
|
||||
.then((data) => {
|
||||
const vaults = data.vaults || [];
|
||||
const vault = vaults.find((v) => v.vaultId === vaultId);
|
||||
|
||||
if (vault) {
|
||||
updateState(vault.status, vault.error);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
clearInterval(wsCheckInterval);
|
||||
cancelDeferredSynced();
|
||||
wsListener.off("sync-status", onStatus);
|
||||
wsListener.off("sync-log", onLog);
|
||||
hidePopover();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { initSyncStatusBar };
|
||||
@@ -0,0 +1,153 @@
|
||||
const CHANNEL = "plugin:headless-sync";
|
||||
const POLL_INTERVAL = 3000;
|
||||
const LOG_KEEPALIVE_INTERVAL = 7000;
|
||||
|
||||
class WsListener {
|
||||
constructor() {
|
||||
this._callbacks = new Map();
|
||||
this._handler = null;
|
||||
this._rawHandler = null;
|
||||
this._pollTimer = null;
|
||||
this._currentWs = null;
|
||||
this._logSubInterval = null;
|
||||
this._logSubVaultId = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._attachToWs();
|
||||
|
||||
this._pollTimer = setInterval(() => {
|
||||
this._attachToWs();
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
this.unsubscribeLogs();
|
||||
this._detachFromWs();
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
const ws = window.__ignisWs;
|
||||
return ws && ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for raw WebSocket messages (not channel-filtered).
|
||||
// Used by core-sync-guard to watch for file changes.
|
||||
onRaw(callback) {
|
||||
this._rawHandler = callback;
|
||||
}
|
||||
|
||||
offRaw() {
|
||||
this._rawHandler = null;
|
||||
}
|
||||
|
||||
send(type, payload) {
|
||||
const ws = window.__ignisWs;
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type, ...payload }));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to server log broadcasts for a vault.
|
||||
// Sends the initial subscribe message and keeps the subscription alive.
|
||||
subscribeLogs(vaultId) {
|
||||
// If already subscribed to this vault, no-op.
|
||||
if (this._logSubVaultId === vaultId && this._logSubInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubscribeLogs();
|
||||
this._logSubVaultId = vaultId;
|
||||
|
||||
this.send("subscribe-logs", { vaultId });
|
||||
|
||||
this._logSubInterval = setInterval(() => {
|
||||
this.send("subscribe-logs", { vaultId });
|
||||
}, LOG_KEEPALIVE_INTERVAL);
|
||||
}
|
||||
|
||||
// Stop the log subscription keepalive.
|
||||
unsubscribeLogs() {
|
||||
if (this._logSubInterval) {
|
||||
clearInterval(this._logSubInterval);
|
||||
this._logSubInterval = null;
|
||||
}
|
||||
|
||||
this._logSubVaultId = null;
|
||||
}
|
||||
|
||||
_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);
|
||||
|
||||
// Dispatch raw messages (for non-channel listeners like file watchers)
|
||||
if (this._rawHandler) {
|
||||
this._rawHandler(msg);
|
||||
}
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user