mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
move bridge plugin to package
This commit is contained in:
13
packages/bridge-plugin/build.js
Normal file
13
packages/bridge-plugin/build.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const esbuild = require("esbuild");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "src", "main.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "main.js"),
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
external: ["obsidian", "fs"],
|
||||
logLevel: "info",
|
||||
});
|
||||
10
packages/bridge-plugin/manifest.json
Normal file
10
packages/bridge-plugin/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "ignis-bridge",
|
||||
"name": "Ignis Bridge",
|
||||
"version": "0.8.1",
|
||||
"minAppVersion": "1.12.4",
|
||||
"description": "Additional Ignis specific functionality and ignis plugin management.",
|
||||
"author": "Nystik",
|
||||
"authorUrl": "https://github.com/Nystik-gh/ignis",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"name": "@ignis/bridge-plugin",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node build.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
54
packages/bridge-plugin/src/demo-guards.js
Normal file
54
packages/bridge-plugin/src/demo-guards.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Demo-mode UX guards that run at the document level.
|
||||
//
|
||||
// Disable any email/password inputs to prevent users from entering credentials into a server they don't control.
|
||||
|
||||
const PLACEHOLDER =
|
||||
"Disabled in demo. Don't enter credentials on a server you don't control.";
|
||||
|
||||
function isDemoMode() {
|
||||
return document.body && document.body.dataset.demoMode === "true";
|
||||
}
|
||||
|
||||
function disableInputs(root) {
|
||||
const inputs = root.querySelectorAll(
|
||||
'input[type="email"], input[type="password"]',
|
||||
);
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.dataset.ignisDemoDisabled === "1") {
|
||||
continue;
|
||||
}
|
||||
|
||||
input.disabled = true;
|
||||
input.value = "";
|
||||
input.placeholder = PLACEHOLDER;
|
||||
input.dataset.ignisDemoDisabled = "1";
|
||||
}
|
||||
}
|
||||
|
||||
let observer = null;
|
||||
|
||||
function startDemoGuards() {
|
||||
if (!isDemoMode() || observer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk what's already there.
|
||||
disableInputs(document.body);
|
||||
|
||||
// And watch for anything added later (login modals, plugin dialogs, etc.).
|
||||
observer = new MutationObserver(() => {
|
||||
disableInputs(document.body);
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function stopDemoGuards() {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startDemoGuards, stopDemoGuards };
|
||||
95
packages/bridge-plugin/src/file-actions.js
Normal file
95
packages/bridge-plugin/src/file-actions.js
Normal file
@@ -0,0 +1,95 @@
|
||||
const { Notice, TFile, TFolder } = require("obsidian");
|
||||
|
||||
function getVaultId() {
|
||||
return window.__currentVaultId || "";
|
||||
}
|
||||
|
||||
function triggerDownload(endpoint, filePath, downloadName) {
|
||||
const vaultId = getVaultId();
|
||||
const url =
|
||||
`/api/fs/${endpoint}` +
|
||||
`?vault=${encodeURIComponent(vaultId)}` +
|
||||
`&path=${encodeURIComponent(filePath)}`;
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = downloadName;
|
||||
a.click();
|
||||
}
|
||||
|
||||
function showFilePicker(app, targetFolder = null) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
|
||||
input.addEventListener("change", async () => {
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const folder = targetFolder || app.vault.getRoot();
|
||||
const folderPath = folder.path;
|
||||
|
||||
new Notice(`Uploading ${files.length} file(s)...`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const targetPath = folderPath
|
||||
? `${folderPath}/${file.name}`
|
||||
: file.name;
|
||||
|
||||
await app.vault.createBinary(targetPath, arrayBuffer);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.error("[ignis-bridge] Upload failed:", file.name, e);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
new Notice(`Uploaded ${successCount} file(s) successfully`);
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
new Notice(`Failed to upload ${errorCount} file(s)`, 5000);
|
||||
}
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
}
|
||||
|
||||
function addFileMenuItems(menu, file) {
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("Download")
|
||||
.setIcon("download")
|
||||
.onClick(() => triggerDownload("download", file.path, file.name));
|
||||
});
|
||||
}
|
||||
|
||||
function addFolderMenuItems(menu, folder, app) {
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("Download as ZIP")
|
||||
.setIcon("download")
|
||||
.onClick(() =>
|
||||
triggerDownload("download-zip", folder.path, `${folder.name}.zip`),
|
||||
);
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("Upload file")
|
||||
.setIcon("upload")
|
||||
.onClick(() => showFilePicker(app, folder));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { showFilePicker, addFileMenuItems, addFolderMenuItems };
|
||||
70
packages/bridge-plugin/src/main.js
Normal file
70
packages/bridge-plugin/src/main.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const { Plugin, TFile, TFolder } = require("obsidian");
|
||||
const {
|
||||
showFilePicker,
|
||||
addFileMenuItems,
|
||||
addFolderMenuItems,
|
||||
} = require("./file-actions");
|
||||
const {
|
||||
patchSettingsModal,
|
||||
unpatchSettingsModal,
|
||||
} = require("./settings/inject");
|
||||
const pluginRegistry = require("./plugin-registry");
|
||||
const { initStatusBar } = require("./status-bar");
|
||||
const { WorkspacePickerModal } = require("./workspace-picker");
|
||||
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
||||
|
||||
window.__obsidianAPI = require("obsidian");
|
||||
|
||||
class IgnisBridgePlugin extends Plugin {
|
||||
async onload() {
|
||||
if (!window.__ignis) {
|
||||
console.log("[ignis-bridge] Not running in Ignis - plugin is a no-op.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[ignis-bridge] Plugin loaded");
|
||||
|
||||
await pluginRegistry.refresh();
|
||||
patchSettingsModal(this);
|
||||
startDemoGuards();
|
||||
this._statusBarInterval = initStatusBar(this);
|
||||
|
||||
this.addRibbonIcon("upload", "Upload file", () => {
|
||||
showFilePicker(this.app);
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-workspace-in-new-tab",
|
||||
name: "Open workspace in new tab",
|
||||
callback: () => {
|
||||
new WorkspacePickerModal(this.app).open();
|
||||
},
|
||||
});
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-menu", (menu, file) => {
|
||||
if (file instanceof TFile) {
|
||||
addFileMenuItems(menu, file);
|
||||
} else if (file instanceof TFolder) {
|
||||
addFolderMenuItems(menu, file, this.app);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onunload() {
|
||||
if (!window.__ignis) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._statusBarInterval) {
|
||||
clearInterval(this._statusBarInterval);
|
||||
}
|
||||
|
||||
unpatchSettingsModal(this);
|
||||
stopDemoGuards();
|
||||
console.log("[ignis-bridge] Plugin unloaded");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = IgnisBridgePlugin;
|
||||
37
packages/bridge-plugin/src/plugin-registry.js
Normal file
37
packages/bridge-plugin/src/plugin-registry.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Maintains a set of known ignis plugin IDs for filtering.
|
||||
// Populated on bridge plugin load and updated when plugins are enabled/disabled.
|
||||
|
||||
const knownIds = new Set(["ignis-bridge"]);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch("/api/plugins");
|
||||
const plugins = await res.json();
|
||||
|
||||
// Keep ignis-bridge, add all bundled plugin IDs.
|
||||
knownIds.clear();
|
||||
knownIds.add("ignis-bridge");
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.bundledPluginId) {
|
||||
knownIds.add(plugin.bundledPluginId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep whatever we had.
|
||||
}
|
||||
}
|
||||
|
||||
function isIgnisPlugin(pluginId) {
|
||||
return knownIds.has(pluginId);
|
||||
}
|
||||
|
||||
function addId(pluginId) {
|
||||
knownIds.add(pluginId);
|
||||
}
|
||||
|
||||
function getKnownIds() {
|
||||
return knownIds;
|
||||
}
|
||||
|
||||
module.exports = { refresh, isIgnisPlugin, addId, getKnownIds };
|
||||
166
packages/bridge-plugin/src/settings/general-tab.js
Normal file
166
packages/bridge-plugin/src/settings/general-tab.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const { Setting } = require("obsidian");
|
||||
|
||||
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
|
||||
const GITHUB_API_LATEST =
|
||||
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
|
||||
|
||||
function getVersion(app) {
|
||||
try {
|
||||
const manifest = app.plugins.getPlugin("ignis-bridge")?.manifest;
|
||||
return manifest?.version || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
|
||||
function stripBuildMetadata(version) {
|
||||
return (version || "").split("+")[0];
|
||||
}
|
||||
|
||||
async function checkForUpdate(currentVersion) {
|
||||
try {
|
||||
const res = await fetch(GITHUB_API_LATEST);
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
||||
const current = stripBuildMetadata(currentVersion);
|
||||
|
||||
if (latest && latest !== current) {
|
||||
return { version: latest, url: data.html_url };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function display(containerEl, app) {
|
||||
const version = getVersion(app);
|
||||
|
||||
const header = containerEl.createDiv("ignis-header");
|
||||
|
||||
const logo = header.createEl("img", {
|
||||
cls: "ignis-header-logo",
|
||||
attr: { src: "/assets/ignis.webp", alt: "Ignis" },
|
||||
});
|
||||
|
||||
const info = header.createDiv("ignis-header-info");
|
||||
info.createEl("div", { text: "Ignis", cls: "ignis-header-title" });
|
||||
info.createEl("div", {
|
||||
text: "Obsidian server bridge",
|
||||
cls: "ignis-header-subtitle",
|
||||
});
|
||||
|
||||
const right = header.createDiv("ignis-header-right");
|
||||
|
||||
const versionCol = right.createDiv("ignis-header-version-col");
|
||||
versionCol.createEl("span", {
|
||||
text: `Version ${version}`,
|
||||
cls: "ignis-header-version",
|
||||
});
|
||||
|
||||
const updateIndicator = versionCol.createEl("a", {
|
||||
text: "Checking...",
|
||||
cls: "ignis-update-indicator",
|
||||
attr: { target: "_blank", rel: "noopener noreferrer" },
|
||||
});
|
||||
|
||||
const githubLink = right.createEl("a", {
|
||||
cls: "ignis-github-link",
|
||||
href: GITHUB_URL,
|
||||
attr: { target: "_blank", "aria-label": "GitHub" },
|
||||
});
|
||||
|
||||
const githubIcon = githubLink.createEl("img", {
|
||||
cls: "ignis-github-icon",
|
||||
attr: { src: "/assets/github.svg", alt: "GitHub" },
|
||||
});
|
||||
|
||||
checkForUpdate(version).then((latest) => {
|
||||
if (latest) {
|
||||
updateIndicator.textContent = `v${latest.version} available`;
|
||||
updateIndicator.addClass("ignis-update-available");
|
||||
updateIndicator.href = latest.url;
|
||||
} else {
|
||||
updateIndicator.textContent = "Up to date";
|
||||
}
|
||||
});
|
||||
|
||||
addServerStatus(containerEl);
|
||||
}
|
||||
|
||||
function getWsStatus() {
|
||||
const ws = window.__ignisWs;
|
||||
|
||||
if (!ws) {
|
||||
return "disconnected";
|
||||
}
|
||||
|
||||
switch (ws.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return "connecting";
|
||||
case WebSocket.OPEN:
|
||||
return "connected";
|
||||
case WebSocket.CLOSING:
|
||||
case WebSocket.CLOSED:
|
||||
return "disconnected";
|
||||
default:
|
||||
return "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "connecting":
|
||||
return "Connecting...";
|
||||
case "disconnected":
|
||||
return "Disconnected";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function addServerStatus(containerEl) {
|
||||
const status = getWsStatus();
|
||||
|
||||
const setting = new Setting(containerEl).setName("Server status");
|
||||
|
||||
const dotEl = setting.controlEl.createEl("span", {
|
||||
cls: `ignis-status-dot ignis-status-${status}`,
|
||||
});
|
||||
|
||||
const labelEl = setting.controlEl.createEl("span", {
|
||||
text: statusLabel(status),
|
||||
cls: "ignis-status-label",
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
const s = getWsStatus();
|
||||
dotEl.className = `ignis-status-dot ignis-status-${s}`;
|
||||
labelEl.textContent = statusLabel(s);
|
||||
};
|
||||
|
||||
const pollInterval = setInterval(update, 3000);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!containerEl.isConnected) {
|
||||
clearInterval(pollInterval);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(containerEl.parentElement || document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { display };
|
||||
148
packages/bridge-plugin/src/settings/inject.js
Normal file
148
packages/bridge-plugin/src/settings/inject.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const generalTab = require("./general-tab");
|
||||
const serverPluginsTab = require("./server-plugins-tab");
|
||||
const { createNavEl, createTab, createGroup } = require("./settings-ui");
|
||||
const {
|
||||
setupPluginTabs,
|
||||
reconcilePluginTabs,
|
||||
hideIgnisFromCommunityPlugins,
|
||||
restoreCommunityPlugins,
|
||||
clearOwnedPluginIds,
|
||||
} = require("./plugin-tabs");
|
||||
|
||||
function removeExistingIgnisGroups(tabHeadersEl) {
|
||||
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
|
||||
|
||||
for (const g of groups) {
|
||||
const title = g.querySelector(".vertical-tab-header-group-title");
|
||||
|
||||
if (
|
||||
title?.textContent === "Ignis" ||
|
||||
title?.textContent === "Ignis Core Plugins"
|
||||
) {
|
||||
g.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
|
||||
// Collected here so the openTab patch can manage is-active across all of them.
|
||||
const allIgnisNavEls = new Map(); // tab id -> nav element
|
||||
|
||||
function replaceInstallerVersionRow(setting, ignisVersion) {
|
||||
const container = setting.tabContentContainer || setting.contentEl;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = container.querySelectorAll(".setting-item");
|
||||
|
||||
for (const row of rows) {
|
||||
const desc = row.querySelector(".setting-item-description");
|
||||
|
||||
if (!desc || !desc.textContent.startsWith("Installer version:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
desc.empty();
|
||||
desc.createEl("strong", { text: `Running in Ignis v${ignisVersion}` });
|
||||
desc.createEl("br");
|
||||
desc.appendText(
|
||||
"Obsidian is served through Ignis. There's no installer to update.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function patchOpenTab(setting, plugin) {
|
||||
if (setting._ignisOpenTabPatched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const original = setting.openTab.bind(setting);
|
||||
|
||||
setting.openTab = function (tab) {
|
||||
// Clear is-active from all ignis nav items.
|
||||
for (const [, el] of allIgnisNavEls) {
|
||||
el.removeClass("is-active");
|
||||
}
|
||||
|
||||
original(tab);
|
||||
|
||||
// If the opened tab is one of ours, highlight it.
|
||||
const navEl = allIgnisNavEls.get(tab.id);
|
||||
|
||||
if (navEl) {
|
||||
navEl.addClass("is-active");
|
||||
}
|
||||
|
||||
if (tab && tab.id === "about") {
|
||||
replaceInstallerVersionRow(setting, plugin.manifest.version);
|
||||
}
|
||||
};
|
||||
|
||||
setting._ignisOpenTabPatched = true;
|
||||
}
|
||||
|
||||
function injectIgnisSettings(setting, app, plugin) {
|
||||
removeExistingIgnisGroups(setting.tabHeadersEl);
|
||||
clearOwnedPluginIds();
|
||||
allIgnisNavEls.clear();
|
||||
|
||||
patchOpenTab(setting, plugin);
|
||||
replaceInstallerVersionRow(setting, plugin.manifest.version);
|
||||
|
||||
const ignis = createGroup("Ignis");
|
||||
|
||||
const tabs = [
|
||||
createTab("ignis-general", "General", generalTab.display, app, "flame"),
|
||||
createTab(
|
||||
"ignis-core-plugins",
|
||||
"Core plugins",
|
||||
serverPluginsTab.display,
|
||||
app,
|
||||
"blocks",
|
||||
),
|
||||
];
|
||||
|
||||
for (const tab of tabs) {
|
||||
tab.navEl = createNavEl(tab, setting);
|
||||
ignis.items.appendChild(tab.navEl);
|
||||
allIgnisNavEls.set(tab.id, tab.navEl);
|
||||
}
|
||||
|
||||
setting.tabHeadersEl.appendChild(ignis.group);
|
||||
|
||||
const corePlugins = createGroup("Ignis Core Plugins");
|
||||
setting.tabHeadersEl.appendChild(corePlugins.group);
|
||||
|
||||
hideIgnisFromCommunityPlugins(setting);
|
||||
setupPluginTabs(setting, corePlugins.items, allIgnisNavEls);
|
||||
}
|
||||
|
||||
function patchSettingsModal(plugin) {
|
||||
const original = plugin.app.setting.onOpen;
|
||||
const app = plugin.app;
|
||||
plugin._originalOnOpen = original;
|
||||
|
||||
plugin.app.setting.onOpen = function () {
|
||||
original.call(this);
|
||||
injectIgnisSettings(this, app, plugin);
|
||||
};
|
||||
}
|
||||
|
||||
function unpatchSettingsModal(plugin) {
|
||||
if (plugin._originalOnOpen) {
|
||||
plugin.app.setting.onOpen = plugin._originalOnOpen;
|
||||
}
|
||||
|
||||
delete plugin.app.setting._ignisOpenTabPatched;
|
||||
|
||||
restoreCommunityPlugins(plugin.app.setting);
|
||||
clearOwnedPluginIds();
|
||||
}
|
||||
|
||||
window.__ignisReconcilePluginTabs = (setting) =>
|
||||
reconcilePluginTabs(setting, allIgnisNavEls);
|
||||
|
||||
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
||||
237
packages/bridge-plugin/src/settings/plugin-tabs.js
Normal file
237
packages/bridge-plugin/src/settings/plugin-tabs.js
Normal file
@@ -0,0 +1,237 @@
|
||||
const { setIcon } = require("obsidian");
|
||||
const { findGroupByTitle } = require("./settings-ui");
|
||||
const { isIgnisPlugin } = require("../plugin-registry");
|
||||
|
||||
// Tracks which plugin IDs have nav items we created.
|
||||
const ownedPluginIds = new Set();
|
||||
|
||||
function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
|
||||
const tab = setting.pluginTabs.find((t) => t.id === pluginId);
|
||||
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ownedPluginIds.has(pluginId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "vertical-tab-nav-item tappable";
|
||||
|
||||
if (tab.icon) {
|
||||
const iconEl = document.createElement("div");
|
||||
iconEl.className = "vertical-tab-nav-item-icon";
|
||||
setIcon(iconEl, tab.icon);
|
||||
nav.appendChild(iconEl);
|
||||
}
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "vertical-tab-nav-item-title";
|
||||
title.textContent = tab.name;
|
||||
nav.appendChild(title);
|
||||
|
||||
const chevron = document.createElement("div");
|
||||
chevron.className = "vertical-tab-nav-item-chevron";
|
||||
nav.appendChild(chevron);
|
||||
|
||||
nav.addEventListener("click", () => {
|
||||
setting.openTab(tab);
|
||||
});
|
||||
|
||||
corePluginsItems.appendChild(nav);
|
||||
ownedPluginIds.add(pluginId);
|
||||
ignisNavEls.set(pluginId, nav);
|
||||
}
|
||||
|
||||
function removePluginNavItem(pluginId, ignisNavEls) {
|
||||
const nav = ignisNavEls.get(pluginId);
|
||||
|
||||
if (nav && ownedPluginIds.has(pluginId)) {
|
||||
nav.remove();
|
||||
ownedPluginIds.delete(pluginId);
|
||||
ignisNavEls.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
function hideIgnisFromCommunityPlugins(setting) {
|
||||
const cpTab = setting.settingTabs.find((t) => t.id === "community-plugins");
|
||||
|
||||
if (!cpTab || cpTab._ignisPatched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const origRender = cpTab.renderInstalledPlugin;
|
||||
|
||||
cpTab.renderInstalledPlugin = function (manifest, ...rest) {
|
||||
if (isIgnisPlugin(manifest.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return origRender.call(this, manifest, ...rest);
|
||||
};
|
||||
|
||||
cpTab._ignisPatched = true;
|
||||
cpTab._origRenderInstalledPlugin = origRender;
|
||||
}
|
||||
|
||||
function restoreCommunityPlugins(setting) {
|
||||
const cpTab = setting.settingTabs.find(
|
||||
(t) => t.id === "community-plugins",
|
||||
);
|
||||
|
||||
if (cpTab?._origRenderInstalledPlugin) {
|
||||
cpTab.renderInstalledPlugin = cpTab._origRenderInstalledPlugin;
|
||||
delete cpTab._origRenderInstalledPlugin;
|
||||
delete cpTab._ignisPatched;
|
||||
}
|
||||
}
|
||||
|
||||
function hideIgnisNavFromCommunityGroup(setting) {
|
||||
const communityGroup = findGroupByTitle(
|
||||
setting.tabHeadersEl,
|
||||
"Community plugins",
|
||||
);
|
||||
|
||||
if (!communityGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = communityGroup.querySelector(".vertical-tab-header-group-items");
|
||||
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tab of setting.pluginTabs) {
|
||||
if (isIgnisPlugin(tab.id) && tab.navEl?.parentElement === items) {
|
||||
tab.navEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const hasVisible = Array.from(items.children).some(
|
||||
(el) => el.style.display !== "none",
|
||||
);
|
||||
|
||||
communityGroup.style.display = hasVisible ? "" : "none";
|
||||
}
|
||||
|
||||
function hideCorePluginsGroupIfEmpty(ignisNavEls) {
|
||||
let hasConnected = false;
|
||||
|
||||
for (const id of ownedPluginIds) {
|
||||
const nav = ignisNavEls.get(id);
|
||||
|
||||
if (nav?.isConnected) {
|
||||
hasConnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const groups = document.querySelectorAll(".vertical-tab-header-group");
|
||||
|
||||
for (const g of groups) {
|
||||
const title = g.querySelector(".vertical-tab-header-group-title");
|
||||
|
||||
if (title?.textContent === "Ignis Core Plugins") {
|
||||
g.style.display = hasConnected ? "" : "none";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
||||
for (const tab of setting.pluginTabs) {
|
||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
||||
}
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
|
||||
const communityGroup = findGroupByTitle(
|
||||
setting.tabHeadersEl,
|
||||
"Community plugins",
|
||||
);
|
||||
|
||||
if (communityGroup) {
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const tab of setting.pluginTabs) {
|
||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
||||
}
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
});
|
||||
|
||||
observer.observe(communityGroup, { childList: true, subtree: true });
|
||||
|
||||
const modalEl = setting.tabHeadersEl.closest(".modal");
|
||||
|
||||
if (modalEl && modalEl.parentElement) {
|
||||
const cleanupObserver = new MutationObserver(() => {
|
||||
if (!setting.tabHeadersEl.isConnected) {
|
||||
observer.disconnect();
|
||||
cleanupObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
cleanupObserver.observe(modalEl.parentElement, {
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reconcilePluginTabs(setting, ignisNavEls) {
|
||||
const corePluginsGroup = findGroupByTitle(
|
||||
setting.tabHeadersEl,
|
||||
"Ignis Core Plugins",
|
||||
);
|
||||
|
||||
if (!corePluginsGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const corePluginsItems = corePluginsGroup.querySelector(
|
||||
".vertical-tab-header-group-items",
|
||||
);
|
||||
|
||||
if (!corePluginsItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIds = new Set(
|
||||
setting.pluginTabs
|
||||
.filter((t) => isIgnisPlugin(t.id) && t.id !== "ignis-bridge")
|
||||
.map((t) => t.id),
|
||||
);
|
||||
|
||||
for (const id of ownedPluginIds) {
|
||||
if (!activeIds.has(id)) {
|
||||
removePluginNavItem(id, ignisNavEls);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of activeIds) {
|
||||
addPluginNavItem(id, setting, corePluginsItems, ignisNavEls);
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
}
|
||||
|
||||
function clearOwnedPluginIds() {
|
||||
ownedPluginIds.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setupPluginTabs,
|
||||
reconcilePluginTabs,
|
||||
hideIgnisFromCommunityPlugins,
|
||||
restoreCommunityPlugins,
|
||||
clearOwnedPluginIds,
|
||||
};
|
||||
132
packages/bridge-plugin/src/settings/server-plugins-tab.js
Normal file
132
packages/bridge-plugin/src/settings/server-plugins-tab.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const { Setting, Notice } = require("obsidian");
|
||||
|
||||
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");
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch plugins");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function togglePlugin(pluginId, enable, app) {
|
||||
const action = enable ? "enable" : "disable";
|
||||
const vaultId = getVaultId();
|
||||
|
||||
const res = await fetch(`/api/plugins/${pluginId}/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ vault: vaultId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Failed to ${action} plugin`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function activateBundledPlugin(bundledPluginId, enable, app) {
|
||||
if (!bundledPluginId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = app.plugins;
|
||||
|
||||
if (enable) {
|
||||
await plugins.loadManifests();
|
||||
await plugins.enablePluginAndSave(bundledPluginId);
|
||||
} else {
|
||||
await plugins.disablePluginAndSave(bundledPluginId);
|
||||
}
|
||||
}
|
||||
|
||||
function display(containerEl, app) {
|
||||
containerEl.createEl("h2", { text: "Ignis Core Plugins" });
|
||||
|
||||
const descEl = containerEl.createEl("p", {
|
||||
text:
|
||||
"Ignis plugins extend server functionality and run alongside your vaults. " +
|
||||
"They are separate from Obsidian's built-in plugins.",
|
||||
cls: "ignis-plugins-description",
|
||||
});
|
||||
|
||||
const loadingEl = containerEl.createEl("p", { text: "Loading plugins..." });
|
||||
|
||||
fetchPlugins()
|
||||
.then((plugins) => {
|
||||
loadingEl.remove();
|
||||
|
||||
if (plugins.length === 0) {
|
||||
containerEl.createEl("p", {
|
||||
text: "No server plugins available.",
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultId = getVaultId();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const enabled = plugin.enabledVaults.includes(vaultId);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(plugin.name)
|
||||
.setDesc(plugin.description || "")
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(enabled);
|
||||
toggle.onChange(async (value) => {
|
||||
try {
|
||||
await togglePlugin(plugin.id, value, app);
|
||||
|
||||
if (value && plugin.bundledPluginId) {
|
||||
await refreshPluginCache(plugin.bundledPluginId);
|
||||
}
|
||||
|
||||
await activateBundledPlugin(
|
||||
plugin.bundledPluginId,
|
||||
value,
|
||||
app,
|
||||
);
|
||||
|
||||
new Notice(
|
||||
`${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`,
|
||||
);
|
||||
|
||||
// Give Obsidian a moment to update its plugin tabs,
|
||||
// then reconcile our sidebar groups.
|
||||
setTimeout(() => {
|
||||
if (typeof window.__ignisReconcilePluginTabs === "function") {
|
||||
window.__ignisReconcilePluginTabs(app.setting);
|
||||
}
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
new Notice(`Failed: ${e.message}`);
|
||||
toggle.setValue(!value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
loadingEl.setText("Failed to load plugins.");
|
||||
console.error("[ignis-bridge] Server plugins error:", e);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { display };
|
||||
89
packages/bridge-plugin/src/settings/settings-ui.js
Normal file
89
packages/bridge-plugin/src/settings/settings-ui.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const { setIcon } = require("obsidian");
|
||||
|
||||
function createNavEl(tab, setting) {
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "vertical-tab-nav-item tappable";
|
||||
|
||||
if (tab.icon) {
|
||||
const iconEl = document.createElement("div");
|
||||
iconEl.className = "vertical-tab-nav-item-icon";
|
||||
|
||||
if (tab.icon.startsWith("<svg") || tab.icon.startsWith("<img")) {
|
||||
iconEl.innerHTML = tab.icon;
|
||||
} else if (tab.icon.endsWith(".svg") || tab.icon.endsWith(".webp") || tab.icon.endsWith(".png")) {
|
||||
iconEl.innerHTML = `<img src="${tab.icon}" class="svg-icon" width="24" height="24" />`;
|
||||
} else {
|
||||
setIcon(iconEl, tab.icon);
|
||||
}
|
||||
|
||||
nav.appendChild(iconEl);
|
||||
}
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "vertical-tab-nav-item-title";
|
||||
title.textContent = tab.name;
|
||||
nav.appendChild(title);
|
||||
|
||||
const chevron = document.createElement("div");
|
||||
chevron.className = "vertical-tab-nav-item-chevron";
|
||||
nav.appendChild(chevron);
|
||||
|
||||
nav.addEventListener("click", () => {
|
||||
setting.openTab(tab);
|
||||
});
|
||||
|
||||
return nav;
|
||||
}
|
||||
|
||||
function createTab(id, name, displayFn, app, icon) {
|
||||
const tab = {
|
||||
id,
|
||||
name,
|
||||
icon: icon || null,
|
||||
containerEl: createDiv("vertical-tab-content"),
|
||||
navEl: null,
|
||||
|
||||
display() {
|
||||
this.containerEl.empty();
|
||||
displayFn(this.containerEl, app);
|
||||
},
|
||||
|
||||
hide() {
|
||||
this.containerEl.empty();
|
||||
},
|
||||
};
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
function createGroup(name) {
|
||||
const group = document.createElement("div");
|
||||
group.className = "vertical-tab-header-group";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "vertical-tab-header-group-title";
|
||||
title.textContent = name;
|
||||
group.appendChild(title);
|
||||
|
||||
const items = document.createElement("div");
|
||||
items.className = "vertical-tab-header-group-items";
|
||||
group.appendChild(items);
|
||||
|
||||
return { group, items };
|
||||
}
|
||||
|
||||
function findGroupByTitle(tabHeadersEl, title) {
|
||||
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
|
||||
|
||||
for (const g of groups) {
|
||||
const t = g.querySelector(".vertical-tab-header-group-title");
|
||||
|
||||
if (t?.textContent === title) {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { createNavEl, createTab, createGroup, findGroupByTitle };
|
||||
48
packages/bridge-plugin/src/status-bar.js
Normal file
48
packages/bridge-plugin/src/status-bar.js
Normal file
@@ -0,0 +1,48 @@
|
||||
function getWsStatus() {
|
||||
const ws = window.__ignisWs;
|
||||
|
||||
if (!ws) {
|
||||
return "disconnected";
|
||||
}
|
||||
|
||||
switch (ws.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return "connecting";
|
||||
case WebSocket.OPEN:
|
||||
return "connected";
|
||||
default:
|
||||
return "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
connected: "Ignis server: Connected",
|
||||
connecting: "Ignis server: Connecting...",
|
||||
disconnected: "Ignis server: Disconnected",
|
||||
};
|
||||
|
||||
function initStatusBar(plugin) {
|
||||
const item = plugin.addStatusBarItem();
|
||||
item.addClass("ignis-statusbar-item");
|
||||
|
||||
const dot = item.createEl("span", {
|
||||
cls: "ignis-statusbar-dot",
|
||||
});
|
||||
|
||||
item.setAttribute("aria-label", "Ignis: Checking...");
|
||||
item.setAttribute("data-tooltip-position", "top");
|
||||
|
||||
const update = () => {
|
||||
const status = getWsStatus();
|
||||
dot.className = `ignis-statusbar-dot ignis-statusbar-${status}`;
|
||||
item.setAttribute("aria-label", STATUS_LABELS[status] || "Ignis: Unknown");
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
const interval = setInterval(update, 3000);
|
||||
|
||||
return interval;
|
||||
}
|
||||
|
||||
module.exports = { initStatusBar };
|
||||
32
packages/bridge-plugin/src/workspace-picker.js
Normal file
32
packages/bridge-plugin/src/workspace-picker.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { FuzzySuggestModal } = require("obsidian");
|
||||
|
||||
class WorkspacePickerModal extends FuzzySuggestModal {
|
||||
constructor(app) {
|
||||
super(app);
|
||||
this.setPlaceholder("Open workspace in new tab");
|
||||
}
|
||||
|
||||
getItems() {
|
||||
const plugin = this.app.internalPlugins.plugins.workspaces;
|
||||
|
||||
if (!plugin || !plugin.enabled || !plugin.instance) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(plugin.instance.workspaces);
|
||||
}
|
||||
|
||||
getItemText(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
onChooseItem(item) {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.searchParams.set("workspace", item);
|
||||
url.searchParams.set("load", "preset");
|
||||
window.open(url.toString(), "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WorkspacePickerModal };
|
||||
143
packages/bridge-plugin/styles.css
Normal file
143
packages/bridge-plugin/styles.css
Normal file
@@ -0,0 +1,143 @@
|
||||
.ignis-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.ignis-header-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ignis-header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ignis-header-title {
|
||||
font-size: var(--font-ui-large);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ignis-header-subtitle {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.2;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.ignis-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ignis-header-version-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ignis-header-version {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ignis-update-indicator {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ignis-update-indicator.ignis-update-available {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.ignis-update-indicator.ignis-update-available:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ignis-github-link {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ignis-github-link:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.ignis-github-link:hover .ignis-github-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ignis-github-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ignis-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.ignis-status-connected {
|
||||
background-color: var(--color-green);
|
||||
}
|
||||
|
||||
.ignis-status-connecting {
|
||||
background-color: var(--color-yellow);
|
||||
}
|
||||
|
||||
.ignis-status-disconnected {
|
||||
background-color: var(--color-red);
|
||||
}
|
||||
|
||||
.ignis-status-label {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ignis-statusbar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ignis-statusbar-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ignis-statusbar-connected {
|
||||
background-color: var(--color-green);
|
||||
}
|
||||
|
||||
.ignis-statusbar-connecting {
|
||||
background-color: var(--color-yellow);
|
||||
}
|
||||
|
||||
.ignis-statusbar-disconnected {
|
||||
background-color: var(--color-red);
|
||||
}
|
||||
|
||||
.ignis-plugins-description {
|
||||
padding: 0 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
Reference in New Issue
Block a user