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/')) { + cssLinks() ?> + + + 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