logseq import plugin

This commit is contained in:
th committed 2026-04-11 01:45:36 +02:00
1 parent 3ae84ccdd7
commit 5af49655d6
11 files changed
+301 -193

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
<?php
// Logseq Import Plugin — backend route handler.
//
// Registers POST /api/import-logseq which accepts a Logseq vault ZIP upload and:
// 1. Copies vault assets into public/uploads/ with randomized filenames
// 2. Rewrites markdown image references to point at the new upload paths
// 3. Strips Logseq-specific bullet outline prefixes and property blocks
// 4. Creates a card per journal entry (date-titled) and per page
// 5. Places everything in an "import" bucket (created if missing)
PluginManager::instance()->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;
});
@@ -0,0 +1,74 @@
// Logseq Import Plugin — frontend.
//
// Injects a footer button + hidden file input, posts the selected ZIP to
// /api/import-logseq, and refreshes the board when the import completes.
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[logseq-import] window.hyprcrd not found');
function injectFooterButton() {
const footer = document.querySelector('footer');
if (!footer) return;
if (footer.querySelector('#logseq-import-btn')) return;
// Anchor before the settings button so the order matches the previous core layout.
const settingsBtn = footer.querySelector('#settings-btn');
const group = settingsBtn?.parentElement || footer.querySelector('.flex.items-center.gap-2');
if (!group) return;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'logseq-import-file';
fileInput.accept = '.zip,application/zip';
fileInput.className = 'hidden';
group.insertBefore(fileInput, settingsBtn || null);
const btn = document.createElement('button');
btn.id = 'logseq-import-btn';
btn.className = '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';
btn.innerHTML = '<i class="ph ph-graph text-sm"></i><span class="font-pixel text-[10px]">logseq import</span>';
group.insertBefore(btn, settingsBtn || null);
btn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('api/import-logseq', {
method: 'POST',
body: form,
headers: { 'X-CSRF-Token': hyprcrd.util.csrf() },
});
const data = await res.json();
if (data.error) throw new Error(data.error);
await window._reloadAll?.();
await hyprcrd.dialog.show({
title: 'logseq import',
message: `${data.imported} cards, ${data.assets} images imported`,
okLabel: 'ok',
});
} catch (err) {
await hyprcrd.dialog.show({
title: 'logseq import failed',
message: String(err?.message || err) || 'unknown error',
danger: true,
okLabel: 'ok',
});
}
e.target.value = '';
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectFooterButton);
} else {
injectFooterButton();
}
hyprcrd.registerPlugin({
name: 'logseq-import',
version: '1.0.0',
description: 'Import a Logseq vault ZIP as dated journal cards and pages.',
});
+9
View File
@@ -0,0 +1,9 @@
{
"name": "logseq-import",
"version": "1.0.0",
"description": "Import a Logseq vault ZIP — journals become dated cards, pages become cards with the page name as title, and assets are copied into uploads/ with rewritten references.",
"frontend": {
"js": ["logseq-import.js"]
},
"backend": "backend.php"
}