Files
hyprcrd/public/plugins/MINDMAP.md
T
2026-04-11 01:22:52 +02:00

17 KiB

hyprcrd plugin system — mindmap

A complete architectural reference. Don't lose state on this — every detail here was learned the hard way during the canvas/tests/email plugin builds.

hyprcrd plugin system
│
├── Discovery (PHP)
│   ├── src/PluginManager.php (singleton)
│   │   ├── scan(dir)               → reads each subdir's plugin.json
│   │   ├── loadDisabled(stateFile) → reads data/plugins.json {disabled: [...]}
│   │   ├── loadBackends()          → require_once enabled backend.php files
│   │   └── isEnabled(name)         → checked by cssLinks/htmlFragments/scriptTags
│   └── src/bootstrap.php (call order matters!)
│       1. config.php
│       2. Database.php
│       3. PluginManager.php
│       4. PluginManager::scan(public/plugins)
│       5. PluginManager::loadDisabled(dirname(DB_PATH).'/plugins.json')
│       6. PluginManager::loadBackends()  ← AFTER loadDisabled, so disabled plugins skip backend.php too
│
├── plugin.json manifest format
│   ├── name              (string, unique)
│   ├── version           (string)
│   ├── description       (string)
│   ├── frontend
│   │   ├── js: [...]     (loaded as <script type="module">)
│   │   ├── css: [...]    (loaded as <link>)
│   │   └── html: [...]   (file contents inlined into body via htmlFragments())
│   └── backend           (string, optional path to backend.php)
│
├── Core injection points (PHP, in index.php)
│   ├── In <head> after style.css:    <?= PluginManager::instance()->cssLinks() ?>
│   ├── In #md-toolbar after extract: <?= PluginManager::instance()->slot('md-toolbar') ?>
│   ├── Before </body>:               <?= PluginManager::instance()->htmlFragments() ?>
│   ├── Then synchronously:           <script src="js/hyprcrd.js"></script>
│   ├── Then plugin entries:          <?= PluginManager::instance()->scriptTags() ?>
│   ├── Then core ES module:          <script type="module" src="js/app.js">
│   └── Inside auth-protected API block, BEFORE first /api/cards check:
│                                     PluginManager::instance()->dispatchRoute($route, $method);
│
├── Frontend API: window.hyprcrd  (defined in public/js/hyprcrd.js, sync-loaded)
│   │
│   ├── hooks                          (generic event bus)
│   │   ├── on(name, fn)               register listener
│   │   ├── off(name, fn)              remove
│   │   └── emit(name, data) → data    listeners can mutate via return value
│   │
│   ├── editor                         (toolbar command registry)
│   │   ├── registerCommand(name, fn)  buttons with data-md="<name>" dispatch here
│   │   ├── unregisterCommand(name)
│   │   ├── getCommand(name)
│   │   └── api                        ← set by editor.js AFTER its init
│   │       ├── body                   the textarea element
│   │       ├── insertMd(b, a, ph)     inserts markdown around selection
│   │       └── updatePreview()        re-renders the preview pane
│   │
│   ├── markdown                       (registry, applied in order)
│   │   ├── registerFencedBlock(lang, fn)   ```<lang> blocks → handler returns HTML
│   │   ├── registerPreProcessor(fn)        runs on source text before marked.parse()
│   │   ├── registerPostProcessor(fn)       runs on HTML after marked.parse(), before DOMPurify
│   │   ├── getFencedBlocks()
│   │   ├── getPreProcessors()
│   │   └── getPostProcessors()
│   │   └── handler signature: fn(content, { esc, normalizeListBlock }) → htmlString
│   │
│   ├── settings                       (settings overlay tab registry)
│   │   ├── registerTab({id, label, icon, onShow})
│   │   │   ├── injects button into #settings-nav
│   │   │   ├── creates empty <div id="settings-panel-{id}"> in panels container
│   │   │   ├── attaches click handler (active state + emit hook + onShow)
│   │   │   └── icon = phosphor class like 'ph-envelope', defaults to ph-puzzle-piece
│   │   ├── open()                     clicks #settings-btn
│   │   └── openTab(id)                opens settings + switches to that tab
│   │
│   ├── util
│   │   ├── $(idOrSel)                 querySelector or getElementById
│   │   ├── $$(sel)                    querySelectorAll → Array
│   │   ├── esc(s)                     HTML escape
│   │   ├── setCsrf(token)             called by app.js after auth
│   │   └── csrf()                     read by plugins for API calls
│   │
│   ├── api(path, opts)                fetch wrapper, auto X-CSRF-Token header
│   │
│   ├── dialog                         (themed confirm/prompt — NEVER use native confirm())
│   │   ├── show(opts)                 → core dialog.js fills this in after load
│   │   │                                 opts: {title, message, input, danger, okLabel, cancelLabel}
│   │   │                                 returns Promise<value|null> (null = cancelled)
│   │   └── confirm(msg, opts)         convenience: returns Promise<true|null>
│   │                                   opts: {title, okLabel, cancelLabel, danger}
│   │
│   └── registerPlugin({name, version, description})
│       └── pushes to hyprcrd.plugins[] for the settings → plugins UI
│
├── Backend API extension (PHP, from plugin's backend.php)
│   └── PluginManager::instance()->onApiRoute(function($route, $method) {
│           if ($route === '/api/foo' && $method === 'GET') {
│               // ... must call exit() on match
│               echo json_encode([...]);
│               exit;
│           }
│       });
│       │
│       ├── handlers run in registration order, before core route handling
│       ├── pass-through if none exit → core routes get a chance
│       ├── CSRF middleware in core runs BEFORE dispatchRoute, so plugin
│       │   routes are automatically CSRF-protected for mutations
│       └── auth check also runs BEFORE → all plugin routes require login
│
├── Lifecycle / load order (CRITICAL for timing bugs)
│   1. Browser parses HTML
│   2. <link rel="stylesheet"> tags loaded (core + plugin CSS)
│   3. <?= htmlFragments() ?> → plugin overlay HTML now in DOM
│   4. <script src="js/hyprcrd.js"> → SYNCHRONOUS, window.hyprcrd defined
│   5. <script type="module" src="plugins/.../*.js"> → DEFERRED, run after parse
│      ├── Plugin modules execute in document order
│      ├── At this point: DOM ready, window.hyprcrd ready, core editor.js NOT loaded yet
│      ├── So hyprcrd.editor.api is null until app.js runs
│      └── Plugin commands that USE editor.api should defer (only fire on user click)
│   6. <script type="module" src="js/app.js"> → orchestrator
│      ├── imports core modules (state, theme, editor, markdown, ...)
│      ├── markdown.js top-level: registers gallery/tree/bullets via the SAME registry
│      ├── editor.js top-level: registers heading/bold/italic/etc + sets editor.api
│      └── checkAuth() → setCsrf() → window.hyprcrd.util.setCsrf() (mirrored)
│
├── Settings tab race (avoided by registerTab design)
│   ├── theme.js attaches click handlers to all .settings-tab at module load
│   ├── If plugin's tab is added BEFORE theme.js runs (default), theme.js handler ALSO attaches
│   ├── Result: both core handler AND plugin click handler fire on click
│   ├── Both manage active states identically — harmless duplicate work
│   └── Plugin's onShow is called by the plugin handler, NOT by core
│
├── Disable mechanism
│   ├── State file: data/plugins.json → {"disabled": ["canvas", ...]}
│   ├── Settings UI: settings → plugins → toggle switches
│   ├── API: GET /api/plugins, POST /api/plugins/toggle {name, enabled}
│   ├── Disabled plugins are skipped in cssLinks, htmlFragments, scriptTags, AND loadBackends
│   ├── Toggling requires page reload (already-loaded code can't unload)
│   └── Reload notice in plugins panel; dirtyAfterToggle flag persists until reload
│
├── Conventions
│   ├── Plugins NEVER import from core modules. Use window.hyprcrd globals only.
│   ├── Plugin JS is ES module (loaded as type="module") so import/export works WITHIN the plugin
│   ├── Plugin self-registration happens at module top level (no init function needed)
│   ├── Plugin DOM injection should idempotently check if element already exists
│   │   (canvas: btn.querySelector('[data-md="asciiart"]') guard)
│   ├── If a plugin needs DOM that may not exist yet (e.g. injectToolbarButton),
│   │   wrap in DOMContentLoaded check OR run immediately if document.readyState !== 'loading'
│   ├── Plugin schema migrations live in backend.php as IIFE that runs once per request
│   │   (cheap CREATE TABLE IF NOT EXISTS)
│   └── Plugin credentials → reuse vault key encryption (sha256(vault_key) substr 0..32 + AES-256-CBC)
│
├── Existing plugins
│   ├── canvas/  (~500 lines)
│   │   ├── No backend
│   │   ├── Registers ascii fenced block + asciiart editor command + toolbar btn (JS injected)
│   │   ├── Has its own overlay (canvas.html), tools, palette
│   │   └── Edit-mode: detects cursor inside ```ascii block, loads existing content
│   ├── tests/  (~40 test files + runner.js + loader.js)
│   │   ├── No backend
│   │   ├── loader.js conditionally imports tests.js on ?test or Ctrl+Shift+T
│   │   ├── Test files import from '../../js/modules/...' (NOT '../modules/...')
│   │   └── Both static AND dynamic imports use that path — sed both when moving files
│   ├── email/  (~1300 lines, biggest)
│   │   ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes)
│   │   ├── Schema: email_accounts table, passwords AES-256-CBC with vault key
│   │   ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}}
│   │   ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'})
│   │   │   └── onShow rebuilds form on first show, refreshes accounts list every show
│   │   ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
│   │   ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
│   │   └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
│   └── slideshow/  (~500 lines)
│       ├── No backend — data stored as ```slide-image fenced block in card body
│       ├── Slideshow = parent card (start slide) + children (in position order)
│       ├── Text slides: normal markdown rendered fullscreen via hyprcrd.markdown.render
│       ├── Image slides: fenced block with `src:` + `x,y,w,h -> target` lines (coords in %)
│       │   └── Targets: card:<id>, slide:next, slide:prev, slide:first, slide:<index>
│       ├── Viewer: fullscreen overlay, ArrowLeft/Right + Esc, HUD with counter
│       │   ├── Image slides use object-fit:contain; hotspots positioned relative to img box
│       │   ├── Arrow keys always work as fallback, even when hotspots are present
│       │   └── 'h' key toggles debug-hotspots class to visualize rects during presentation
│       ├── Hotspot editor: drag on image to create rect, picker assigns target, dbl-click rect to re-pick
│       ├── Group-bar button: mutation-observed injection; reads parent id from #group-bar[data-group-id]
│       │   └── Requires board.js renderGroupBar to write bar.dataset.groupId — small core patch
│       └── Toolbar button "slideHotspots" injected into #md-toolbar, detects existing block at cursor
│
├── Gotchas / lessons learned
│   ├── statusHeight vs scrollHeight — textarea.scrollHeight includes padding, mind it
│   ├── inline-block vs block — pre with display:block stretches to container width,
│   │                            making mouse-cell math wrong. Use width:max-content.
│   ├── auto-fit canvas — measure ONE char (set textContent='M'), then floor(container/charW)
│   ├── DOMPurify strips HTML comments — list:tree/bullets uses comments as markers, then
│   │                                     converts to classes BEFORE sanitize
│   ├── ALLOWED_TAGS in markdown.js — must include any tag plugins emit (pre, span, div ok)
│   ├── ALLOWED_ATTR — class, style, data-* are allowed; check before adding novel attrs
│   ├── theme.js handles tab switching for ALL .settings-tab, including dynamically added ones,
│   │                because forEach runs at module load AFTER plugins have injected their tabs
│   ├── CSRF token sync — app.js stores it via setCsrf() in util.js (module-private),
│   │                      also calls window.hyprcrd.util.setCsrf() so plugins can read it
│   ├── Plugin tests can break core tab counts — don't hardcode toBe(N), use toContain checks
│   └── Header button injection — querySelector('#main-header .flex.items-center.gap-3:last-of-type')
│                                  to grab the right-side button group (calendar, gallery, etc.)
│
└── Adding a new plugin (checklist)
    1. mkdir public/plugins/<name>
    2. Write plugin.json (name, version, description, frontend.{js,css,html}, backend?)
    3. If JS: import nothing from core. Use window.hyprcrd.* exclusively.
    4. If HTML: provide overlay/modal markup. Use IDs prefixed with plugin name to avoid clashes.
    5. If CSS: scope selectors to plugin-specific classes/IDs.
    6. If backend: require_once your classes, define helpers in named functions (no closures
       at top level — they pollute the global scope), register routes via onApiRoute().
    7. If schema: CREATE TABLE IF NOT EXISTS in an IIFE at top of backend.php.
    8. If credentials: reuse the vault key encryption pattern (see email/backend.php).
    9. If settings: hyprcrd.settings.registerTab({...}) at the end of init().
    10. Test by reloading; check window.hyprcrd.plugins includes your meta entry.
    11. Verify it can be disabled via settings → plugins (assets must vanish from page source).
    12. If adding a new core hook is unavoidable, document it HERE before merging.

API quick reference (copy-paste safe)

Register a markdown fenced block

window.hyprcrd.markdown.registerFencedBlock('mylang', (content, { esc }) => {
    return `<div class="my-block">${esc(content)}</div>`;
});

Register an editor toolbar command

window.hyprcrd.editor.registerCommand('mycmd', () => {
    const ed = window.hyprcrd.editor.api;
    if (!ed) return;  // editor not initialized yet (shouldn't happen on user click)
    ed.insertMd('<<', '>>', 'placeholder');
});

// Add a button with data-md="mycmd" to #md-toolbar via JS:
const btn = document.createElement('button');
btn.dataset.md = 'mycmd';
btn.className = 'toolbar-btn';
btn.innerHTML = '<i class="ph ph-star text-sm"></i>';
document.getElementById('md-toolbar').appendChild(btn);

Register a settings tab

window.hyprcrd.settings.registerTab({
    id: 'mything',
    label: 'my thing',
    icon: 'ph-star',
    onShow: (panelEl) => {
        panelEl.innerHTML = `<p>Settings UI here</p>`;
    },
});

// Open it programmatically:
window.hyprcrd.settings.openTab('mything');

Register a backend API route (in backend.php)

PluginManager::instance()->onApiRoute(function (string $route, string $method) {
    if ($route === '/api/myplugin/things' && $method === 'GET') {
        header('Content-Type: application/json');
        echo json_encode(['ok' => true]);
        exit;
    }
});

Make a CSRF-protected API call (frontend)

const result = await window.hyprcrd.api('api/myplugin/things', {
    method: 'POST',
    body: { name: 'foo' },
});

Encrypt credentials (in backend.php, reusing the vault key)

function myEncKey(): string {
    $db = Database::connect();
    $vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
    if ($vk) return substr(hash('sha256', $vk), 0, 32);
    $hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
    return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
function myEncrypt(string $plain): string {
    $key = myEncKey();
    $iv = random_bytes(16);
    $cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
    return base64_encode($iv . $cipher);
}
function myDecrypt(string $encoded): string {
    $key = myEncKey();
    $raw = base64_decode($encoded);
    if (!$raw || strlen($raw) < 17) return '';
    return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
}