From 3f4ba3c0f4707b98e7bc845cdcb4f1f05a375afe Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 10 Apr 2026 07:58:07 +0200
Subject: [PATCH] plugin infrastruktur
---
README.md | 110 ++++-
public/css/style.css | 11 -
public/index.php | 96 ++---
public/js/app.js | 13 +-
public/js/hyprcrd.js | 135 +++++++
public/js/modules/editor.js | 142 +++----
public/js/modules/markdown.js | 80 +++-
public/js/modules/plugin-settings.js | 90 +++++
public/js/modules/theme.js | 1 +
public/plugins/canvas/canvas.css | 43 ++
public/plugins/canvas/canvas.html | 45 +++
.../{js/modules => plugins/canvas}/canvas.js | 85 +++-
public/plugins/canvas/plugin.json | 10 +
public/plugins/tests/buckets.test.js | 2 +-
public/plugins/tests/canvas.test.js | 377 ++++++++++++++++++
.../plugins/tests/card-interactions.test.js | 2 +-
public/plugins/tests/datepicker.test.js | 2 +-
public/plugins/tests/favorites.test.js | 2 +-
public/plugins/tests/filtering.test.js | 2 +-
public/plugins/tests/image-fullscreen.test.js | 2 +-
public/plugins/tests/loader.js | 31 ++
public/plugins/tests/markdown-edge.test.js | 2 +-
public/plugins/tests/markdown.test.js | 4 +-
public/plugins/tests/multiselect.test.js | 2 +-
public/plugins/tests/plugin.json | 10 +
public/plugins/tests/rrule.test.js | 2 +-
public/plugins/tests/runner.js | 4 +-
public/plugins/tests/settings.test.js | 6 +-
public/plugins/tests/sorting.test.js | 2 +-
public/plugins/tests/state-functions.test.js | 2 +-
public/plugins/tests/tests.js | 1 +
public/plugins/tests/toolbar.test.js | 2 +-
public/plugins/tests/undo.test.js | 4 +-
src/PluginManager.php | 169 ++++++++
src/bootstrap.php | 7 +
35 files changed, 1303 insertions(+), 195 deletions(-)
create mode 100644 public/js/hyprcrd.js
create mode 100644 public/js/modules/plugin-settings.js
create mode 100644 public/plugins/canvas/canvas.css
create mode 100644 public/plugins/canvas/canvas.html
rename public/{js/modules => plugins/canvas}/canvas.js (83%)
create mode 100644 public/plugins/canvas/plugin.json
create mode 100644 public/plugins/tests/canvas.test.js
create mode 100644 public/plugins/tests/loader.js
create mode 100644 public/plugins/tests/plugin.json
create mode 100644 src/PluginManager.php
diff --git a/README.md b/README.md
index b9352ee..8c7216f 100644
--- a/README.md
+++ b/README.md
@@ -89,8 +89,9 @@ hyprcrd/
├── docker-entrypoint.sh
├── dist.sh # Build distribution ZIP
├── src/
-│ ├── bootstrap.php # Initialization
+│ ├── bootstrap.php # Initialization + plugin discovery
│ ├── Database.php # SQLite schema & migrations
+│ ├── PluginManager.php # Plugin scanner & asset/slot renderer
│ ├── CalDavClient.php # CalDAV HTTP client + iCal parser
│ └── CalDavSync.php # Bidirectional CalDAV sync engine
├── public/ # Document root
@@ -103,9 +104,13 @@ hyprcrd/
│ │ └── vendor/ # Phosphor Icons CSS + fonts
│ ├── fonts/ # JetBrains Mono & Silkscreen
│ ├── js/
+│ │ ├── hyprcrd.js # Plugin API (window.hyprcrd, sync-loaded)
│ │ ├── app.js # Orchestrator
-│ │ ├── modules/ # Feature modules
+│ │ ├── modules/ # Core feature modules
│ │ └── vendor/ # marked.js, DOMPurify
+│ ├── plugins/ # Optional, drop-in plugin folders
+│ │ ├── canvas/ # ASCII art canvas editor
+│ │ └── tests/ # UI + state test runner
│ └── uploads/ # User media (writable)
├── relay/ # Relay service (mobile inbox + card sharing)
│ ├── index.php # Single-file API + Mobile UI
@@ -121,7 +126,7 @@ hyprcrd/
- **Cards** with markdown body, color, tags, and position
- **Buckets** for organizing cards into groups
- **Tags** written as `#tagname` or hierarchical `#parent/child` in card body, extracted automatically
-- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views
+- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views, ```` ```ascii ```` art canvas (via canvas plugin)
- **Search** combining text and `#tag` filters (e.g. `#project meeting notes`)
- **Image gallery** with orphan detection and fullscreen viewer
- **Split view** showing tag-related and content-related cards
@@ -138,6 +143,101 @@ hyprcrd/
- **Superview** compact multi-column list with hover cards
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `Shift+scroll` zoom, arrow keys in autocomplete
+## Plugins
+
+hyprcrd has a drop-in plugin system. A plugin is just a folder under `public/plugins/` with a `plugin.json` manifest. The core scans the directory at startup, loads each plugin's CSS/JS/HTML, and the plugin self-registers against `window.hyprcrd`. Plugins are entirely optional — removing a plugin folder removes the feature, with no code changes anywhere else.
+
+### Bundled plugins
+
+- **`canvas/`** — ASCII art canvas editor. Adds a toolbar button + ` ```ascii ` fenced block. Draw with brush, line, rectangle, ellipse, fill, and text tools using a monospace character grid.
+- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `?test` is in the URL or `Ctrl+Shift+T` is pressed, so production users pay no cost for it.
+
+### Plugin layout
+
+```
+public/plugins/myplugin/
+├── plugin.json # Manifest (required)
+├── myplugin.js # Entry point (loaded as ES module)
+├── myplugin.css # Optional styles
+├── myplugin.html # Optional HTML fragment (overlays, modals, etc.)
+└── backend.php # Optional PHP hooks
+```
+
+**`plugin.json`:**
+
+```json
+{
+ "name": "myplugin",
+ "version": "1.0.0",
+ "description": "What this plugin does",
+ "frontend": {
+ "js": ["myplugin.js"],
+ "css": ["myplugin.css"],
+ "html": ["myplugin.html"]
+ },
+ "backend": "backend.php"
+}
+```
+
+### Plugin API (`window.hyprcrd`)
+
+Loaded synchronously before any module, so plugins can register handlers immediately:
+
+| Namespace | Methods | Purpose |
+|---|---|---|
+| `hyprcrd.editor` | `registerCommand(name, fn)`, `getCommand(name)`, `api.{body, insertMd, updatePreview}` | Add toolbar commands. The `data-md=""` button dispatches to the registered handler. `api` is set after the editor initializes. |
+| `hyprcrd.markdown` | `registerFencedBlock(lang, fn)`, `registerPreProcessor(fn)`, `registerPostProcessor(fn)` | Add ` ``` ` block renderers (returns HTML, passes through DOMPurify). Pre/post processors run on the source/HTML around `marked.parse()`. |
+| `hyprcrd.hooks` | `on(name, fn)`, `off(name, fn)`, `emit(name, data)` | Generic event bus. Use for cross-plugin or core→plugin signaling. |
+| `hyprcrd.util` | `$(idOrSel)`, `$$(sel)`, `esc(s)` | Lightweight DOM/escape helpers so plugins don't need to import core modules. |
+| `hyprcrd.registerPlugin(meta)` | — | Register the plugin's metadata for introspection. |
+
+### Minimal plugin example
+
+`public/plugins/hello/plugin.json`:
+
+```json
+{
+ "name": "hello",
+ "version": "1.0.0",
+ "description": "Inserts a greeting",
+ "frontend": { "js": ["hello.js"] }
+}
+```
+
+`public/plugins/hello/hello.js`:
+
+```js
+const h = window.hyprcrd;
+
+h.editor.registerCommand('hello', () => {
+ h.editor.api.insertMd('Hello, world! ', '', '');
+});
+
+// Inject a toolbar button
+const btn = document.createElement('button');
+btn.dataset.md = 'hello';
+btn.className = 'toolbar-btn';
+btn.title = 'Hello';
+btn.textContent = '👋';
+document.getElementById('md-toolbar').appendChild(btn);
+
+h.registerPlugin({ name: 'hello', version: '1.0.0' });
+```
+
+That's the entire plugin. Drop the folder in, reload, the button appears.
+
+### Backend hooks (PHP)
+
+If a plugin needs server-side code, set `"backend": "backend.php"` in the manifest. The file is `require_once`'d during bootstrap. Backend plugins can use `PluginManager::instance()->addToSlot('slot-name', '')` to inject HTML into named slots in `index.php` (e.g. `md-toolbar`).
+
+### Removing a plugin
+
+```bash
+rm -rf public/plugins/canvas
+```
+
+That's it. The toolbar button, overlay, CSS, and markdown handler all disappear together. The core has no awareness of any specific plugin.
+
## Data & Backup
All data lives in two places:
@@ -318,8 +418,8 @@ After changing Tailwind utility classes in HTML or JS files:
# Download standalone CLI (once)
curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o tailwindcss && chmod +x tailwindcss
-# Build
-./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js" --minify
+# Build (includes plugins so plugin HTML/JS contributes utility classes)
+./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js,public/plugins/**/*.{js,html}" --minify
```
## Security
diff --git a/public/css/style.css b/public/css/style.css
index 1c5b7fc..5d24de1 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -499,16 +499,6 @@ body.detail-image-fs-open { overflow: hidden; }
#editor-gutter > div { padding-right: 2px; box-sizing: border-box; }
.gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); }
-/* ASCII Canvas */
-#ascii-canvas-overlay { background: rgba(0,0,0,0.5); backdrop-filter: blur(2px); }
-.ascii-grid { white-space: pre; user-select: none; -webkit-user-select: none; display: inline-block; width: max-content; }
-.ascii-cursor { background: var(--t-accent); color: var(--t-bg); }
-.ascii-char-btn { font-family: var(--t-font-mono, monospace); font-size: 13px; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--t-border); border-radius: 3px; background: var(--t-bg); color: var(--t-text); cursor: pointer; transition: all 0.1s; padding: 0; line-height: 1; }
-.ascii-char-btn:hover { background: var(--t-raised); color: var(--t-bright); }
-.ascii-char-btn.active { background: var(--t-accent); color: var(--t-bg); border-color: var(--t-accent); }
-.ascii-char-group { display: inline-flex; gap: 2px; margin-right: 6px; }
-#ascii-canvas-toolbar .toolbar-btn.active { background: var(--t-accent); color: var(--t-bg); }
-
/* Search */
#search { backdrop-filter: blur(4px); }
#search-input { caret-color: var(--t-accent); }
@@ -563,7 +553,6 @@ body.detail-image-fs-open { overflow: hidden; }
.prose code { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.88em; }
.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; position: relative; }
.prose pre code { background: none; border: none; padding: 0; font-size: 0.85em; }
-.prose pre.ascii-art { line-height: 1.35; letter-spacing: 0; white-space: pre; font-size: 0.85em; }
.prose pre .code-copy {
position: absolute; top: 4px; right: 4px;
background: var(--t-surface); border: 1px solid var(--t-border);
diff --git a/public/index.php b/public/index.php
index 6b13394..954bd80 100644
--- a/public/index.php
+++ b/public/index.php
@@ -301,6 +301,33 @@ if (str_starts_with($route, '/api/')) {
$card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit;
}
+ // Plugins
+ if ($route === '/api/plugins' && $method === 'GET') {
+ echo json_encode(PluginManager::instance()->pluginsForApi()); exit;
+ }
+
+ if ($route === '/api/plugins/toggle' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $name = trim((string)($input['name'] ?? ''));
+ $enabled = !empty($input['enabled']);
+ if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
+ $pm = PluginManager::instance();
+ $known = array_keys($pm->plugins());
+ if (!in_array($name, $known, true)) {
+ http_response_code(404); echo json_encode(['error' => 'plugin not found']); exit;
+ }
+ $disabled = $pm->disabledList();
+ if ($enabled) {
+ $disabled = array_values(array_diff($disabled, [$name]));
+ } else if (!in_array($name, $disabled, true)) {
+ $disabled[] = $name;
+ }
+ if (!$pm->setDisabled($disabled)) {
+ http_response_code(500); echo json_encode(['error' => 'failed to write state']); exit;
+ }
+ echo json_encode(['ok' => true, 'plugins' => $pm->pluginsForApi()]); exit;
+ }
+
// Buckets
if ($route === '/api/buckets' && $method === 'GET') {
echo json_encode($db->query('SELECT * FROM buckets ORDER BY position ASC, created_at ASC')->fetchAll()); exit;
@@ -1012,6 +1039,7 @@ if (str_starts_with($route, '/api/')) {
+ = PluginManager::instance()->cssLinks() ?>
+
+
+ = PluginManager::instance()->scriptTags() ?>
diff --git a/public/js/app.js b/public/js/app.js
index dd4f892..577b3f0 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -17,6 +17,7 @@ import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
+import { openPluginSettings } from './modules/plugin-settings.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -28,6 +29,7 @@ window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
window._openCalendar = openCalendar;
window._triggerSync = triggerSync;
+window._openPluginSettings = openPluginSettings;
window._loadCaldavSettings = loadCaldavSettings;
window._loadRelaySettings = loadRelaySettings;
window._renderTagBar = renderTagBar;
@@ -277,13 +279,4 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
checkAuth();
-// ── Test runner (lazy-loaded, no cost in production) ──
-if (new URLSearchParams(location.search).has('test') || false) {
- import('./test/tests.js');
-}
-document.addEventListener('keydown', e => {
- if (e.ctrlKey && e.shiftKey && e.key === 'T') {
- e.preventDefault();
- import('./test/tests.js').then(() => import('./test/runner.js').then(m => m.open()));
- }
-});
+// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
diff --git a/public/js/hyprcrd.js b/public/js/hyprcrd.js
new file mode 100644
index 0000000..c2fbc3f
--- /dev/null
+++ b/public/js/hyprcrd.js
@@ -0,0 +1,135 @@
+// ── 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 ';
+ }
+ }
+ return implode("\n ", $out);
+ }
+}
diff --git a/src/bootstrap.php b/src/bootstrap.php
index 79d644e..e21fe80 100644
--- a/src/bootstrap.php
+++ b/src/bootstrap.php
@@ -8,6 +8,7 @@ if (function_exists('opcache_invalidate')) {
require_once $configPath;
require_once __DIR__ . '/Database.php';
+require_once __DIR__ . '/PluginManager.php';
// Ensure data directory is writable
$dataDir = dirname(DB_PATH);
@@ -16,3 +17,9 @@ if (!is_writable($dataDir) && is_dir($dataDir)) {
echo 'Data directory is not writable';
exit(1);
}
+
+// Discover plugins (each subdir of public/plugins/ with a plugin.json)
+$pluginManager = PluginManager::instance();
+$pluginManager->scan(__DIR__ . '/../public/plugins');
+$pluginManager->loadDisabled(dirname(DB_PATH) . '/plugins.json');
+$pluginManager->loadBackends(); // require_once enabled backend.php files
|