onApiRoute(function (string $route, string $method): void { if ($route !== '/api/import-logseq' || $method !== 'POST') return; 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; } $db = Database::connect(); $uploadDir = dirname(__DIR__, 2) . '/uploads'; if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true); // Find the vault root (the directory containing journals/ or pages/) $prefix = ''; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (preg_match('#^(.*/)?journals/#', $name, $m)) { $prefix = $m[1] ?? ''; break; } if (preg_match('#^(.*/)?pages/#', $name, $m)) { $prefix = $m[1] ?? ''; break; } } // 1. Copy assets → uploads/ with randomized filenames and build a rename map. $assetMap = []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (!str_starts_with($name, $prefix . 'assets/')) continue; if (str_ends_with($name, '/')) continue; $content = $zip->getFromIndex($i); if ($content === false) continue; $origName = basename($name); $ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin'; $newName = bin2hex(random_bytes(16)) . '.' . strtolower($ext); file_put_contents($uploadDir . '/' . $newName, $content); $assetMap[$origName] = $newName; } // Helper: rewrite image references in markdown body to the new upload paths. $rewriteImages = function ($body) use ($assetMap) { // Logseq patterns: ![desc](../assets/file.png), ![desc](/assets/file.png), ![desc](assets/file.png) $body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?(?:assets/)?([^)]+)\)#', function ($m) use ($assetMap) { $file = basename($m[2]); if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')'; return $m[0]; }, $body); // Also handle URL-encoded filenames in the assets path. $body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?assets/([^)]+)\)#', function ($m) use ($assetMap) { $file = urldecode(basename($m[2])); if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')'; return $m[0]; }, $body); return $body; }; // Helper: strip Logseq-specific syntax (property blocks + bullet outline prefixes). $cleanBody = function ($body) { $lines = explode("\n", $body); $cleaned = []; foreach ($lines as $line) { // Skip Logseq properties (key:: value at start of line) if (preg_match('/^[a-z_-]+::\s/', $line)) continue; // Remove leading "- " bullet outline format (Logseq default) $line = preg_replace('/^(\t*)- /', '$1', $line); $cleaned[] = $line; } return implode("\n", $cleaned); }; // Ensure the "import" bucket exists. $importBucket = $db->query("SELECT id FROM buckets WHERE name = 'import'")->fetchColumn(); if (!$importBucket) { $db->prepare("INSERT INTO buckets (name, position) VALUES ('import', 999)")->execute(); $importBucket = $db->lastInsertId(); } $imported = 0; // 2. Import journals → date-titled cards. for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (!str_starts_with($name, $prefix . 'journals/')) continue; if (!str_ends_with($name, '.md')) continue; $content = $zip->getFromIndex($i); if ($content === false || !trim($content)) continue; $basename = pathinfo(basename($name), PATHINFO_FILENAME); // 2025_06_18 → 2025-06-18 $title = str_replace('_', '-', $basename); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $title)) continue; $body = $rewriteImages($cleanBody($content)); preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); $db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id, created_at) VALUES (?, ?, ?, ?, -1, ?, ?)') ->execute([$title, trim($body), '#38bdf8', $tags, $importBucket, $title . ' 00:00:00']); $imported++; } // 3. Import pages → cards with the page name as title. for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (!str_starts_with($name, $prefix . 'pages/')) continue; if (!str_ends_with($name, '.md')) continue; $content = $zip->getFromIndex($i); if ($content === false || !trim($content)) continue; $title = pathinfo(basename($name), PATHINFO_FILENAME); // Skip Logseq internal pages. if (in_array(strtolower($title), ['contents', 'favorites'])) continue; $body = $rewriteImages($cleanBody($content)); preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); $db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id) VALUES (?, ?, ?, ?, -1, ?)') ->execute([$title, trim($body), '#8b5cf6', $tags, $importBucket]); $imported++; } $zip->close(); echo json_encode(['ok' => true, 'imported' => $imported, 'assets' => count($assetMap)]); exit; });