tags
This commit is contained in:
1 parent
a9603aa0e8
commit
719f9d51ff
13 files changed
+625
-70
No files matched your search
+119
-5
@@ -134,6 +134,10 @@ if (str_starts_with($route, '/api/')) {
|
||||
// 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') {
|
||||
@@ -141,11 +145,13 @@ if (str_starts_with($route, '/api/')) {
|
||||
$title = trim($input['title'] ?? '');
|
||||
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
||||
$pos = $input['position'] ?? 0;
|
||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position)')->execute([
|
||||
'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null),
|
||||
$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;
|
||||
@@ -167,6 +173,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
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;
|
||||
@@ -275,6 +282,103 @@ if (str_starts_with($route, '/api/')) {
|
||||
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;
|
||||
@@ -452,7 +556,6 @@ if (str_starts_with($route, '/api/')) {
|
||||
<body class="bg-t-bg text-t-text min-h-screen transition-colors duration-200 flex flex-col">
|
||||
|
||||
<header id="main-header" class="sticky top-0 z-40 bg-t-bg border-b border-t-border shadow-[0_4px_20px_-4px_rgba(0,0,0,0.3)]" style="overflow:hidden;">
|
||||
<div id="header-tint" class="absolute inset-0 pointer-events-none" style="opacity:0;transition:opacity 0.3s,background 0.3s;z-index:0;"></div>
|
||||
<div class="flex items-center justify-between px-6 py-3 relative z-[1]">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
|
||||
@@ -476,7 +579,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center px-6 pb-2 relative z-[1]">
|
||||
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
||||
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
|
||||
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2">
|
||||
<i class="ph ph-sort-ascending text-xs"></i>
|
||||
@@ -496,6 +599,9 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button id="export-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
|
||||
</button>
|
||||
<button id="export-md-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-vault text-sm"></i><span class="font-pixel text-[10px]">vault</span>
|
||||
</button>
|
||||
<button id="logout-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
|
||||
</button>
|
||||
@@ -555,6 +661,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div class="card-bar w-16 mb-4 rounded-sm" id="search-preview-color"></div>
|
||||
<h2 id="search-preview-title" class="font-pixel text-t-bright text-sm mb-4 leading-relaxed"></h2>
|
||||
<div id="search-preview-body" class="prose text-xs text-t-text"></div>
|
||||
<div id="search-preview-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
|
||||
<div id="search-preview-meta" class="mt-6 pt-3 border-t border-t-border text-[10px] text-t-dim font-pixel"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,6 +740,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div id="detail-scroll" class="p-6 pt-5 flex-1 overflow-y-auto">
|
||||
<h2 id="detail-title" class="font-pixel text-t-bright text-lg mb-4 leading-relaxed"></h2>
|
||||
<div id="detail-body" class="prose text-sm text-t-text"></div>
|
||||
<div id="detail-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
|
||||
<div id="detail-backlinks" class="hidden mt-6 pt-4 border-t border-t-border">
|
||||
<div class="font-pixel text-[10px] text-t-dim mb-2 flex items-center gap-1.5"><i class="ph ph-arrow-bend-left-up text-xs"></i> referenced_by</div>
|
||||
<div id="detail-backlinks-list" class="flex flex-wrap gap-2"></div>
|
||||
@@ -654,6 +762,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
|
||||
<div id="link-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:220px;max-width:360px;"></div>
|
||||
<div id="tag-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:180px;max-width:300px;"></div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
||||
@@ -690,11 +799,16 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
<div id="ctx-buckets" class="hidden border-t border-t-border py-1"></div>
|
||||
<div id="ctx-members" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
|
||||
<div id="ctx-tags" class="hidden border-t border-t-border py-1">
|
||||
<div class="px-3 py-1.5"><input id="ctx-tag-input" type="text" placeholder="#tag..." class="w-full bg-t-bg border border-t-border text-t-text text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none"></div>
|
||||
<div id="ctx-tag-list" class="max-h-36 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
|
||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
||||
<div class="card-bar w-full shrink-0" id="editor-color"></div>
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
|
||||
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -729,7 +843,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button data-md="cardlink" title="Card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">preview</span></button>
|
||||
|
||||
Reference in new issue
Block a user