true, 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'samesite' => 'Strict', ]); session_start(); // CSRF token if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } // ── Auth ── $method = $_SERVER['REQUEST_METHOD']; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $basePath = dirname($_SERVER['SCRIPT_NAME']); $route = ($basePath === '/' || $basePath === '\\') ? $path : str_replace($basePath, '', $path); $pwHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); $isSetup = !$pwHash; $isLoggedIn = !empty($_SESSION['hyprcrd_auth']); // Auth API endpoints (always accessible) if (str_starts_with($route, '/api/auth')) { error_reporting(0); header('Content-Type: application/json'); // GET /api/auth/status if ($route === '/api/auth/status' && $method === 'GET') { echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token']]); exit; } // POST /api/auth/setup — set initial password if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) { $input = json_decode(file_get_contents('php://input'), true); $pw = $input['password'] ?? ''; if (strlen($pw) < 4) { http_response_code(400); echo json_encode(['error' => 'password too short (min 4)']); exit; } $hash = password_hash($pw, PASSWORD_BCRYPT); $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_hash', :hash)")->execute(['hash' => $hash]); session_regenerate_id(true); $_SESSION['hyprcrd_auth'] = true; $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]); exit; } // POST /api/auth/login (rate limited) if ($route === '/api/auth/login' && $method === 'POST') { $attempts = $_SESSION['login_attempts'] ?? 0; $lastAttempt = $_SESSION['login_last'] ?? 0; if ($attempts >= 5 && time() - $lastAttempt < 60) { http_response_code(429); echo json_encode(['error' => 'too many attempts, wait 60s']); exit; } $input = json_decode(file_get_contents('php://input'), true); $pw = $input['password'] ?? ''; if ($pwHash && password_verify($pw, $pwHash)) { session_regenerate_id(true); $_SESSION['hyprcrd_auth'] = true; $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['login_attempts'] = 0; echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]); } else { $_SESSION['login_attempts'] = $attempts + 1; $_SESSION['login_last'] = time(); usleep(300000); // 300ms delay against timing attacks http_response_code(401); echo json_encode(['error' => 'wrong password']); } exit; } // POST /api/auth/logout if ($route === '/api/auth/logout' && $method === 'POST') { $_SESSION = []; session_destroy(); echo json_encode(['ok' => true]); exit; } http_response_code(404); echo json_encode(['error' => 'not found']); exit; } // All other API/page requests require auth (unless setup mode) if (!$isSetup && !$isLoggedIn) { if (str_starts_with($route, '/api/')) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['error' => 'unauthorized']); exit; } // For page requests, still serve the HTML shell — JS will show login dialog } if (str_starts_with($route, '/api/')) { error_reporting(0); header('Content-Type: application/json'); // CSRF check on all state-changing requests if (in_array($method, ['POST', 'PATCH', 'DELETE'])) { $csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) { http_response_code(403); echo json_encode(['error' => 'invalid csrf token']); exit; } } // Input sanitization function validColor($c) { return preg_match('/^#[0-9a-fA-F]{6}$/', $c ?? '') ? $c : '#ffffff'; } function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; } function extractTags($body) { preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches); return implode(',', array_unique(array_map('strtolower', $matches[1] ?? []))); } // Cards if ($route === '/api/cards' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $title = trim($input['title'] ?? ''); if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; } $pos = $input['position'] ?? 0; $body = $input['body'] ?? ''; $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->execute([ 'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'parent_id' => validInt($input['parent_id'] ?? null, null), 'position' => validInt($pos), 'tags' => extractTags($body), ]); echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch()); exit; } if ($route === '/api/cards' && $method === 'GET') { echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll()); exit; } if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') { // CalDAV tombstone for synced cards $card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id'); $card->execute(['id' => $m[1]]); $uid = $card->fetchColumn(); if ($uid) { $db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]); } $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); exit; } if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') { $input = json_decode(file_get_contents('php://input'), true); $fields = []; $params = ['id' => $m[1]]; foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } } if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); } if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); } $card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id'); $card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); 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; } if ($route === '/api/buckets' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $name = trim($input['name'] ?? ''); if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; } $db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]); echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit; } if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') { $input = json_decode(file_get_contents('php://input'), true); $fields = []; $params = ['id' => $m[1]]; foreach (['name', 'position', 'color'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } } if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); $b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit; } if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') { $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]); $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); exit; } // List all upload files if ($route === '/api/uploads' && $method === 'GET') { $dir = __DIR__ . '/uploads'; $files = []; if (is_dir($dir)) { foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) { $name = basename($f); $files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)]; } } usort($files, fn($a, $b) => $b['modified'] - $a['modified']); echo json_encode($files); exit; } // Delete an upload file if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') { $file = __DIR__ . '/uploads/' . basename($m[1]); if (is_file($file)) { unlink($file); echo json_encode(['ok' => true]); } else { http_response_code(404); echo json_encode(['error' => 'file not found']); } exit; } // Upload (max 10MB, real MIME check) if ($route === '/api/upload' && $method === 'POST') { $uploadDir = __DIR__ . '/uploads'; if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'no file', 'debug_files' => array_keys($_FILES), 'debug_method' => $method, 'debug_ct' => $_SERVER['CONTENT_TYPE'] ?? 'none']); exit; } $file = $_FILES['file']; if ($file['size'] > 10 * 1024 * 1024) { http_response_code(400); echo json_encode(['error' => 'file too large (max 10MB)']); exit; } // Check MIME — finfo first, fallback to extension for audio $mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg']; $extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a']; $finfo = new finfo(FILEINFO_MIME_TYPE); $realMime = $finfo->file($file['tmp_name']); $ext = $mimeMap[$realMime] ?? null; // Fallback: trust extension for audio when finfo returns octet-stream if (!$ext && ($realMime === 'application/octet-stream' || str_starts_with($realMime, 'audio/'))) { $origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $ext = $extMap[$origExt] ?? null; } if (!$ext) { http_response_code(400); echo json_encode(['error' => 'invalid file type', 'detected' => $realMime]); exit; } $name = bin2hex(random_bytes(16)) . '.' . $ext; $dest = $uploadDir . '/' . $name; if (!move_uploaded_file($file['tmp_name'], $dest)) { http_response_code(500); echo json_encode(['error' => 'upload failed']); exit; } http_response_code(201); echo json_encode(['url' => 'uploads/' . $name, 'name' => $name]); exit; } // Export — Markdown vault (Obsidian / Logseq compatible) if ($route === '/api/export-md' && $method === 'GET') { $ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null; if ($ids) { $ph = implode(',', array_fill(0, count($ids), '?')); $stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id"); $stmt->execute($ids); $cards = $stmt->fetchAll(); } else { $cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); } $allCards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); $buckets = $db->query('SELECT * FROM buckets ORDER BY position, id')->fetchAll(); $bucketMap = []; foreach ($buckets as $b) $bucketMap[$b['id']] = $b['name']; $cardMap = []; foreach ($allCards as $c) $cardMap[$c['id']] = $c; // Sanitise filename $safe = function ($name) { $name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $name); return trim($name) ?: 'untitled'; }; $tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_md_'); $zip = new ZipArchive(); $zip->open($tmp, ZipArchive::OVERWRITE); $usedNames = []; $allBodies = ''; foreach ($cards as $c) { // Build folder path: bucket/group/ (skip folders for single-card export) $folder = ''; if (!$ids || count($ids) > 1) { if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) { $folder = $safe($bucketMap[$c['bucket_id']]) . '/'; } if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) { $folder .= $safe($cardMap[$c['parent_id']]['title']) . '/'; } } $baseName = $safe($c['title']); $fileName = $folder . $baseName . '.md'; // Deduplicate $n = 1; while (isset($usedNames[$fileName])) { $fileName = $folder . $baseName . ' ' . (++$n) . '.md'; } $usedNames[$fileName] = true; // YAML frontmatter $fm = "---\n"; $fm .= 'id: ' . $c['id'] . "\n"; $fm .= 'color: "' . $c['color'] . "\"\n"; if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) { $fm .= 'bucket: "' . addcslashes($bucketMap[$c['bucket_id']], '"') . "\"\n"; } if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) { $fm .= 'parent: "' . addcslashes($cardMap[$c['parent_id']]['title'], '"') . "\"\n"; } $fm .= 'created: ' . $c['created_at'] . "\n"; $fm .= 'updated: ' . $c['updated_at'] . "\n"; if (!empty($c['tags'])) { $fm .= 'tags: [' . implode(', ', array_map(fn($t) => '"' . trim($t) . '"', explode(',', $c['tags']))) . "]\n"; } $fm .= "---\n\n"; // Body: rewrite upload paths to attachments/ $body = $c['body'] ?? ''; $allBodies .= "\n" . $body; $body = preg_replace('#uploads/#', 'attachments/', $body); $zip->addFromString($fileName, $fm . '# ' . $c['title'] . "\n\n" . $body); } // Copy referenced uploads → attachments/ $uploadDir = __DIR__ . '/uploads'; if (is_dir($uploadDir)) { foreach (glob($uploadDir . '/*') as $f) { $name = basename($f); if (is_file($f) && (!$ids || str_contains($allBodies, $name))) { $zip->addFile($f, 'attachments/' . $name); } } } $zip->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="hyprcrd-vault-' . date('Y-m-d') . '.zip"'); header('Content-Length: ' . filesize($tmp)); readfile($tmp); unlink($tmp); exit; } // Export — full or selective if ($route === '/api/export' && $method === 'GET') { $ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null; if ($ids) { $ph = implode(',', array_fill(0, count($ids), '?')); $cards = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id")->execute($ids) ? $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id") : null; // Re-query properly $stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id"); $stmt->execute($ids); $cards = $stmt->fetchAll(); $bucketIds = array_unique(array_filter(array_column($cards, 'bucket_id'))); $buckets = []; if ($bucketIds) { $bph = implode(',', array_fill(0, count($bucketIds), '?')); $bstmt = $db->prepare("SELECT * FROM buckets WHERE id IN ($bph)"); $bstmt->execute(array_values($bucketIds)); $buckets = $bstmt->fetchAll(); } } else { $cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); $buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll(); } $tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_'); $zip = new ZipArchive(); $zip->open($tmp, ZipArchive::OVERWRITE); $zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); // Only include referenced uploads $uploadDir = __DIR__ . '/uploads'; $allBodies = implode("\n", array_column($cards, 'body')); if (is_dir($uploadDir)) { foreach (glob($uploadDir . '/*') as $f) { $name = basename($f); if (is_file($f) && (!$ids || str_contains($allBodies, $name))) { $zip->addFile($f, 'uploads/' . $name); } } } $zip->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="hyprcrd-export-' . date('Y-m-d') . '.zip"'); header('Content-Length: ' . filesize($tmp)); readfile($tmp); unlink($tmp); exit; } // Import — ZIP with JSON dump + uploads if ($route === '/api/import' && $method === 'POST') { if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'no file']); exit; } $zip = new ZipArchive(); if ($zip->open($_FILES['file']['tmp_name']) !== true) { http_response_code(400); echo json_encode(['error' => 'invalid zip']); exit; } $json = $zip->getFromName('data.json'); if (!$json) { $zip->close(); http_response_code(400); echo json_encode(['error' => 'no data.json in archive']); exit; } $data = json_decode($json, true); if (!$data || !isset($data['buckets']) || !isset($data['cards'])) { $zip->close(); http_response_code(400); echo json_encode(['error' => 'invalid data.json']); exit; } $merge = !empty($data['selective']) || !empty($_GET['merge']); if (!$merge) { $db->exec('DELETE FROM cards'); $db->exec('DELETE FROM buckets'); } // Import buckets (merge: match by name, create if missing) $bucketMap = []; foreach ($data['buckets'] as $b) { $oldId = $b['id']; if ($merge) { $existing = $db->prepare('SELECT id FROM buckets WHERE name = :name'); $existing->execute(['name' => $b['name']]); $found = $existing->fetchColumn(); if ($found) { $bucketMap[$oldId] = $found; continue; } } $stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)'); $stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]); $bucketMap[$oldId] = $db->lastInsertId(); } // Import cards foreach ($data['cards'] as $c) { $bucketId = ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) ? $bucketMap[$c['bucket_id']] : null; $stmt = $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :created_at, :updated_at)'); $stmt->execute([ 'title' => $c['title'], 'body' => $c['body'], 'color' => $c['color'], 'bucket_id' => $bucketId, 'position' => $c['position'] ?? 0, 'created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'), 'updated_at' => $c['updated_at'] ?? date('Y-m-d H:i:s'), ]); } // Extract uploads $uploadDir = __DIR__ . '/uploads'; if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true); for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') { $content = $zip->getFromIndex($i); if ($content !== false) { file_put_contents($uploadDir . '/' . basename($name), $content); } } } $zip->close(); echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]); exit; } // CalDAV endpoints $caldav = new CalDavSync($db); if ($route === '/api/caldav/settings' && $method === 'GET') { echo json_encode($caldav->getSettings()); exit; } if ($route === '/api/caldav/settings' && $method === 'POST') { $caldav->saveSettings(json_decode(file_get_contents('php://input'), true) ?? []); echo json_encode(['ok' => true]); exit; } if ($route === '/api/caldav/test' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?? []; echo json_encode($caldav->testConnection($input['url'] ?? null, $input['username'] ?? null, $input['password'] ?? null)); exit; } if ($route === '/api/caldav/sync' && $method === 'POST') { echo json_encode($caldav->sync()); exit; } if ($route === '/api/caldav/log' && $method === 'GET') { echo json_encode($db->query('SELECT * FROM caldav_sync_log ORDER BY id DESC LIMIT 50')->fetchAll()); exit; } http_response_code(404); echo json_encode(['error' => 'not found']); exit; } // ── HTML Shell ── ?> hyprcrd

hyprcrd

v0.1

help

concepts

cards are the basic unit — each card has a title, a markdown body, a color, and optional tags. Cards appear as tiles on the board. Click to preview, double-click to edit, right-click for actions. Cards can contain images, checklists, links to other cards, and any markdown content.

buckets organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: all (everything), current (recently touched), favorites (starred), journal (date-titled cards), unsorted (no bucket). Drag a card onto a bucket tab to move it. Drag to the + button to create a new bucket from a card.

groups let you nest cards. Drag a card to the center of another card to group them. The parent card shows a badge with the member count. Click the badge or use "open group" in the context menu to see all members. Inside a group you can reorder, drag cards out to ungroup, or drag the first card to promote a new parent. Use "dissolve group" to ungroup all at once.

tags are written as #tagname in the card body and extracted automatically. They support hierarchy: #project/frontend. Click a tag on any card to filter the board — Ctrl+click to combine multiple tags (AND). Tags can also be assigned via right-click > tags without editing the card body. Each segment of a hierarchical tag is independently clickable.

links connect cards. Write [[Card Title]] in the body to create a link. Linked cards are clickable in preview and show up as backlinks on the target card. The split view shows related cards based on shared tags, links, and content overlap.

shortcuts

Ctrl+Kopen / close search
Ctrl+Zundo last action
F1open / close help
Escapeclose any overlay

board

click cardopen preview
double-clickopen editor
right-clickcontext menu (color, bucket, tags, ...)
Ctrl+clickmulti-select cards
drag cardreorder, move to bucket, or group
click tagfilter by tag
Ctrl+click tagadd/remove tag filter (AND)
group badgeexpand group

preview

double-click imagefullscreen (scroll to zoom, drag to pan)
Arrow Left/Rightprev/next image in gallery
click [[link]]open linked card
click checkboxtoggle task completion
zen modefull-screen reading (drag edges to resize)
split viewshow related / tagged cards side-by-side

editor

Ctrl+Ssave
Ctrl+Shift+Ssave and close
Alt+Arrow Up/Downmove line
Shift+Alt+Arrowduplicate line
Tabindent (4 spaces)
paste/drag imageupload and insert
drag dividerresize editor / preview split

autocomplete

[[link to card (search by title)
#tag autocomplete (existing tags)
?snippet menu (see below)
Arrow Up/Downnavigate suggestions
Enter / Tabaccept suggestion

snippets (type ? + keyword + space)

?datcurrent date (2026-04-07)
?timecurrent time (22:53)
?nowdate + time
?weekcalendar week (KW15)
?todocheckbox - [ ]
?hrhorizontal rule ---
?h1 ?h2 ?h3headings
?tablemarkdown table template
?codefenced code block

inline calculator

?3+5 Spacereplaces with 8
34+56? Spacebecomes 34+56=90
operators+ - * / () % ^

tags

#tagnamewrite tags in card body
#parent/childhierarchical tags
right-click > tagsassign tags via context menu
click tag segmenteach path level is clickable

search

textsearch titles + body
#tagfilter by tag
#tag wordtag filter + text search
#tag1 #tag2multiple tags (AND)
bucket filterdropdown to restrict scope

markdown

[[title]]card link
```galleryimage gallery grid (click to fullscreen)
```treetree-style list
```bulletsbullet-style list
- [ ] / - [x]interactive checkboxes
external linksopen in new tab automatically

import / export

exportJSON + media ZIP (re-importable)
vaultObsidian / Logseq markdown vault
right-click > save .mdsingle card as markdown + images
importrestore from exported ZIP

global_tasks

CalDAV Sync