- auth: when a background poll or save returns 401 (session expired while the UI is still open), the api() helpers now prompt for the password instead of letting the save fail silently. Concurrent 401s collapse onto a single dialog via a shared reauthPromise, and each caller retries its original request once after login. /api/auth/* is excluded from the retry path. Extracts promptLogin() in app.js and exposes it as window._reauth so both the core util.js api() and the plugin-SDK hyprcrd.js api() can share the flow. - tests: the "open-switches-bucket" detail test assumed buckets[0] was a plain top-level bucket, but the bar renders super-bucket parents with data-bucket-super and hides child buckets entirely, so the .bucket-tab.active[data-bucket] selector found nothing on setups with nested layouts. Test now picks two plain top-level buckets or skips. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 lines
10 KiB
JavaScript
257 lines
10 KiB
JavaScript
// ── hyprcrd Plugin API ──
|
|
//
|
|
// Loaded synchronously BEFORE any module script. Plugins and core both
|
|
// register against window.hyprcrd. Plugins MUST NOT import from core
|
|
// modules — use this global API instead so they remain decoupled.
|
|
//
|
|
// Available namespaces:
|
|
// hyprcrd.hooks — generic event bus (on/emit)
|
|
// hyprcrd.editor — toolbar command registry
|
|
// hyprcrd.markdown — fenced block + post-processing registry
|
|
// hyprcrd.util — DOM helpers exposed for plugin use
|
|
//
|
|
// Plugins are loaded as <script type="module"> tags after this script
|
|
// but before app.js. They self-register on module evaluation; the core
|
|
// then queries the registries when needed.
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── Generic event bus ───────────────────────────────────────────
|
|
const hookListeners = Object.create(null);
|
|
const hooks = {
|
|
on(name, fn) {
|
|
if (typeof fn !== 'function') return;
|
|
(hookListeners[name] ||= []).push(fn);
|
|
},
|
|
off(name, fn) {
|
|
const arr = hookListeners[name];
|
|
if (!arr) return;
|
|
const idx = arr.indexOf(fn);
|
|
if (idx >= 0) arr.splice(idx, 1);
|
|
},
|
|
emit(name, data) {
|
|
const arr = hookListeners[name];
|
|
if (!arr) return data;
|
|
for (const fn of arr.slice()) {
|
|
try {
|
|
const r = fn(data);
|
|
if (r !== undefined) data = r;
|
|
} catch (err) {
|
|
console.error(`[hyprcrd hook ${name}]`, err);
|
|
}
|
|
}
|
|
return data;
|
|
},
|
|
};
|
|
|
|
// ── Editor command registry ─────────────────────────────────────
|
|
// Toolbar buttons with `data-md="<name>"` dispatch to handlers
|
|
// registered here. Plugins inject buttons + register handlers.
|
|
const editorCommands = Object.create(null);
|
|
const editor = {
|
|
registerCommand(name, fn) {
|
|
if (typeof fn !== 'function') return;
|
|
editorCommands[name] = fn;
|
|
},
|
|
unregisterCommand(name) {
|
|
delete editorCommands[name];
|
|
},
|
|
getCommand(name) {
|
|
return editorCommands[name];
|
|
},
|
|
// Set by editor.js after init — gives plugins access to
|
|
// insertMd(before, after, placeholder) and the textarea body
|
|
// without forcing an import dependency.
|
|
api: null,
|
|
};
|
|
|
|
// ── Markdown fenced block registry ──────────────────────────────
|
|
// Each entry: { lang: string, handler: (content) => string }
|
|
// The handler receives the raw text inside ```<lang> ... ``` and
|
|
// must return safe HTML (it will pass through DOMPurify after).
|
|
const fencedBlocks = [];
|
|
// Pre/post processors run on the full source text, before/after
|
|
// marked.parse(). Useful for non-fenced syntax extensions.
|
|
const preProcessors = [];
|
|
const postProcessors = [];
|
|
const markdown = {
|
|
registerFencedBlock(lang, handler) {
|
|
if (typeof handler !== 'function') return;
|
|
fencedBlocks.push({ lang, handler });
|
|
},
|
|
getFencedBlocks() {
|
|
return fencedBlocks.slice();
|
|
},
|
|
registerPreProcessor(fn) {
|
|
if (typeof fn === 'function') preProcessors.push(fn);
|
|
},
|
|
getPreProcessors() {
|
|
return preProcessors.slice();
|
|
},
|
|
registerPostProcessor(fn) {
|
|
if (typeof fn === 'function') postProcessors.push(fn);
|
|
},
|
|
getPostProcessors() {
|
|
return postProcessors.slice();
|
|
},
|
|
// Filled in by core markdown.js after load
|
|
render: null,
|
|
};
|
|
|
|
// ── Util helpers (mirror of internal util.js) ───────────────────
|
|
let csrfToken = '';
|
|
const util = {
|
|
$(idOrSel) {
|
|
if (typeof idOrSel !== 'string') return null;
|
|
if (idOrSel.startsWith('#') || idOrSel.includes(' ') || idOrSel.includes('.') || idOrSel.includes('[')) {
|
|
return document.querySelector(idOrSel);
|
|
}
|
|
return document.getElementById(idOrSel);
|
|
},
|
|
$$(sel) {
|
|
return Array.from(document.querySelectorAll(sel));
|
|
},
|
|
esc(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[c]));
|
|
},
|
|
// CSRF: core calls setCsrf() after auth so plugins can read it.
|
|
// Emits 'csrf.ready' so plugins that need to make state-changing
|
|
// requests on init can defer until auth has happened.
|
|
setCsrf(token) {
|
|
csrfToken = String(token || '');
|
|
if (csrfToken) hooks.emit('csrf.ready', csrfToken);
|
|
},
|
|
csrf() { return csrfToken; },
|
|
};
|
|
|
|
/** Generic JSON API call with automatic CSRF header.
|
|
* On 401 (session expired while the UI is open) we call the app's
|
|
* re-auth prompt once, pick up the refreshed CSRF token, and retry
|
|
* the original request so the caller's save / poll succeeds instead
|
|
* of silently returning an unauthorized error payload. */
|
|
async function api(path, opts = {}) {
|
|
const doFetch = () => {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
|
return fetch(path, {
|
|
method: opts.method || 'GET',
|
|
headers,
|
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
};
|
|
let res = await doFetch();
|
|
if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') {
|
|
await window._reauth();
|
|
res = await doFetch();
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// ── Settings registry ───────────────────────────────────────────
|
|
// Plugins can inject their own tabs into the main settings overlay.
|
|
// The plugin's onShow callback is called whenever the tab becomes visible.
|
|
const settings = {
|
|
registerTab({ id, label, icon, onShow }) {
|
|
if (!id) return;
|
|
const nav = document.getElementById('settings-nav');
|
|
const panelsContainer = document.querySelector('#settings-overlay .overflow-y-auto.p-5');
|
|
if (!nav || !panelsContainer) {
|
|
// DOM not ready — defer to next tick
|
|
requestAnimationFrame(() => settings.registerTab({ id, label, icon, onShow }));
|
|
return;
|
|
}
|
|
if (nav.querySelector(`[data-tab="${id}"]`)) return; // already registered
|
|
|
|
// Create tab button
|
|
const btn = document.createElement('button');
|
|
btn.className = 'settings-tab';
|
|
btn.dataset.tab = id;
|
|
const iconClass = icon || 'ph-puzzle-piece';
|
|
btn.innerHTML = `<i class="ph ${iconClass} text-sm"></i><span class="font-pixel text-[10px]">${label || id}</span>`;
|
|
nav.appendChild(btn);
|
|
|
|
// Create empty panel container
|
|
let panel = document.getElementById(`settings-panel-${id}`);
|
|
if (!panel) {
|
|
panel = document.createElement('div');
|
|
panel.id = `settings-panel-${id}`;
|
|
panel.className = 'hidden';
|
|
panelsContainer.appendChild(panel);
|
|
}
|
|
|
|
// Click handler — duplicates core tab logic (theme.js may also
|
|
// attach its own handler later, but identical work is harmless).
|
|
btn.addEventListener('click', () => {
|
|
nav.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
|
|
panel.classList.remove('hidden');
|
|
if (typeof onShow === 'function') {
|
|
try { onShow(panel); } catch (err) { console.error(`[settings tab ${id}]`, err); }
|
|
}
|
|
hooks.emit('settings.tab.shown', { tab: id, panel });
|
|
});
|
|
},
|
|
|
|
/** Open the main settings overlay. */
|
|
open() {
|
|
document.getElementById('settings-btn')?.click();
|
|
},
|
|
|
|
/** Open the settings overlay and switch to a specific tab. */
|
|
openTab(id) {
|
|
this.open();
|
|
// Settings opens via click; the tab might need a frame to render
|
|
requestAnimationFrame(() => {
|
|
document.querySelector(`#settings-nav [data-tab="${id}"]`)?.click();
|
|
});
|
|
},
|
|
};
|
|
|
|
// ── Dialog API (filled in by core dialog.js after load) ─────────
|
|
// Plugins call hyprcrd.dialog.confirm(...) / hyprcrd.dialog.show(...)
|
|
// instead of native browser confirm()/prompt() so the UI stays themed
|
|
// and consistent.
|
|
const dialog = {
|
|
show: null, // (opts) => Promise<value|null> — set by core dialog.js
|
|
/** Convenience confirm wrapper. Returns Promise<true|null>. */
|
|
async confirm(message, opts = {}) {
|
|
if (typeof dialog.show !== 'function') {
|
|
// Core dialog not loaded yet — fall back to native
|
|
return window.confirm(message) ? true : null;
|
|
}
|
|
return dialog.show({
|
|
title: opts.title || 'confirm',
|
|
message,
|
|
input: false,
|
|
danger: opts.danger ?? true,
|
|
okLabel: opts.okLabel || 'Ok',
|
|
cancelLabel: opts.cancelLabel || 'Cancel',
|
|
});
|
|
},
|
|
};
|
|
|
|
// ── Plugin metadata registry ────────────────────────────────────
|
|
// Plugins can register themselves here for introspection / debug.
|
|
const plugins = [];
|
|
const registerPlugin = (meta) => {
|
|
if (meta && meta.name) plugins.push(meta);
|
|
};
|
|
|
|
window.hyprcrd = {
|
|
hooks,
|
|
editor,
|
|
markdown,
|
|
util,
|
|
api,
|
|
settings,
|
|
dialog,
|
|
plugins,
|
|
registerPlugin,
|
|
version: '1.0',
|
|
};
|
|
})();
|