logseq import plugin
This commit is contained in:
1 parent
3ae84ccdd7
commit
5af49655d6
11 files changed
+301
-193
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"disabled": []
|
||||||
|
}
|
||||||
@@ -674,132 +674,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import Logseq vault
|
|
||||||
if ($route === '/api/import-logseq' && $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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
|
||||||
|
|
||||||
// Find the vault root (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 name mapping
|
|
||||||
$assetMap = []; // original filename → new hex filename
|
|
||||||
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
|
|
||||||
$rewriteImages = function($body) use ($assetMap) {
|
|
||||||
// Logseq patterns: , , 
|
|
||||||
$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);
|
|
||||||
// Logseq block-level image embed: {{embed ((image))}} or just bare refs
|
|
||||||
// Also handle:  with URL-encoded filenames
|
|
||||||
$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: clean Logseq markdown (remove bullet prefixes, properties)
|
|
||||||
$cleanBody = function($body) {
|
|
||||||
$lines = explode("\n", $body);
|
|
||||||
$cleaned = [];
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
// Skip Logseq properties (key:: value at start)
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get or create "import" bucket
|
|
||||||
$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 → cards with date title
|
|
||||||
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));
|
|
||||||
// Extract tags
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CalDAV endpoints
|
// CalDAV endpoints
|
||||||
$caldav = new CalDavSync($db);
|
$caldav = new CalDavSync($db);
|
||||||
|
|
||||||
@@ -1251,7 +1125,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<footer class="shrink-0 z-40 border-t border-t-border bg-t-bg shadow-[0_-4px_20px_-4px_rgba(0,0,0,0.3)] px-6 py-2 flex items-center justify-between gap-4">
|
<footer class="shrink-0 z-40 border-t border-t-border bg-t-bg shadow-[0_-4px_20px_-4px_rgba(0,0,0,0.3)] px-6 py-2 flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
|
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
|
||||||
<input type="file" id="import-logseq-file" accept=".zip,application/zip" class="hidden">
|
|
||||||
<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">
|
<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>
|
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1261,9 +1134,6 @@ 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">
|
<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>
|
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="import-logseq-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-graph text-sm"></i><span class="font-pixel text-[10px]">logseq import</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">
|
<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]">export vault</span>
|
<i class="ph ph-vault text-sm"></i><span class="font-pixel text-[10px]">export vault</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -142,22 +142,6 @@ importFile?.addEventListener('change', async e => {
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const importLogseq = document.getElementById('import-logseq-file');
|
|
||||||
document.getElementById('import-logseq-btn')?.addEventListener('click', () => importLogseq?.click());
|
|
||||||
importLogseq?.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': getCsrf() } });
|
|
||||||
const data = JSON.parse(await res.text());
|
|
||||||
if (data.error) throw new Error(data.error);
|
|
||||||
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
|
|
||||||
await showDialog({ title: 'logseq import', message: `${data.imported} cards, ${data.assets} images imported`, okLabel: 'ok' });
|
|
||||||
} catch {}
|
|
||||||
e.target.value = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Auth ──
|
// ── Auth ──
|
||||||
|
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ function render(list) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reloadTimer = null;
|
||||||
|
|
||||||
async function onToggle(e) {
|
async function onToggle(e) {
|
||||||
const input = e.currentTarget;
|
const input = e.currentTarget;
|
||||||
const name = input.dataset.name;
|
const name = input.dataset.name;
|
||||||
@@ -58,6 +60,10 @@ async function onToggle(e) {
|
|||||||
pluginsCache = res.plugins;
|
pluginsCache = res.plugins;
|
||||||
dirtyAfterToggle = true;
|
dirtyAfterToggle = true;
|
||||||
$('plugins-reload-notice')?.classList.remove('hidden');
|
$('plugins-reload-notice')?.classList.remove('hidden');
|
||||||
|
// Auto-reload so the effect of the toggle is visible immediately.
|
||||||
|
// Debounced so rapid successive toggles only trigger one reload.
|
||||||
|
if (reloadTimer) clearTimeout(reloadTimer);
|
||||||
|
reloadTimer = setTimeout(() => location.reload(), 400);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Revert UI on failure
|
// Revert UI on failure
|
||||||
input.checked = !enabled;
|
input.checked = !enabled;
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ hyprcrd plugin system
|
|||||||
│ │ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
|
│ │ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
|
||||||
│ │ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
|
│ │ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
|
||||||
│ │ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
|
│ │ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
|
||||||
|
│ ├── logseq-import/ (~200 lines)
|
||||||
|
│ │ ├── Backend: reads ZIP, copies assets → uploads/, imports journals + pages
|
||||||
|
│ │ ├── Route: POST /api/import-logseq (multipart form, file field)
|
||||||
|
│ │ ├── Creates "import" bucket if missing; journals → blue, pages → violet
|
||||||
|
│ │ ├── Strips Logseq bullet prefixes and `key:: value` property lines
|
||||||
|
│ │ ├── Rewrites asset refs via filename → random-hex map
|
||||||
|
│ │ └── Frontend injects button + file input into footer before #settings-btn,
|
||||||
|
│ │ posts to /api/import-logseq, calls window._reloadAll() on success
|
||||||
│ └── slideshow/ (~500 lines)
|
│ └── slideshow/ (~500 lines)
|
||||||
│ ├── No backend — data stored as ```slide-image fenced block in card body
|
│ ├── No backend — data stored as ```slide-image fenced block in card body
|
||||||
│ ├── Slideshow = parent card (start slide) + children (in position order)
|
│ ├── Slideshow = parent card (start slide) + children (in position order)
|
||||||
|
|||||||
@@ -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: , , 
|
||||||
|
$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.',
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -9,17 +9,22 @@
|
|||||||
|
|
||||||
const hyprcrd = window.hyprcrd;
|
const hyprcrd = window.hyprcrd;
|
||||||
|
|
||||||
|
// Cache-bust the test modules so edits to test files are picked up without
|
||||||
|
// a hard refresh. The browser treats each unique URL as a distinct module,
|
||||||
|
// so a fresh query string on each page load forces re-import.
|
||||||
|
const V = '?v=' + Date.now();
|
||||||
|
|
||||||
// Auto-load if ?test in query string
|
// Auto-load if ?test in query string
|
||||||
if (new URLSearchParams(location.search).has('test')) {
|
if (new URLSearchParams(location.search).has('test')) {
|
||||||
import('./tests.js');
|
import('./tests.js' + V);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+Shift+T → open runner
|
// Keyboard shortcut: Ctrl+Shift+T → open runner
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
import('./tests.js').then(() =>
|
import('./tests.js' + V).then(() =>
|
||||||
import('./runner.js').then(m => m.open?.())
|
import('./runner.js' + V).then(m => m.open?.())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ describe('App Shell', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('footer buttons', () => {
|
it('footer buttons', () => {
|
||||||
for (const id of ['settings-btn', 'export-btn', 'import-btn', 'logout-btn', 'export-md-btn', 'import-logseq-btn']) {
|
for (const id of ['settings-btn', 'export-btn', 'import-btn', 'logout-btn', 'export-md-btn']) {
|
||||||
expect($(`#${id}`)).toExist();
|
expect($(`#${id}`)).toExist();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -38,6 +38,5 @@ describe('App Shell', () => {
|
|||||||
|
|
||||||
it('hidden file inputs', () => {
|
it('hidden file inputs', () => {
|
||||||
expect($('#import-file')).toExist();
|
expect($('#import-file')).toExist();
|
||||||
expect($('#import-logseq-file')).toExist();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,44 +1,54 @@
|
|||||||
// ── hyprcrd Test Index ──
|
// ── hyprcrd Test Index ──
|
||||||
// Imports all test suites. Add new test files here.
|
//
|
||||||
|
// Loads all test suites via dynamic imports with a shared cache-busting
|
||||||
|
// query string. This ensures edits to any test file are picked up without
|
||||||
|
// requiring a hard refresh — each module URL becomes unique per page load.
|
||||||
|
|
||||||
import './shell.test.js';
|
const V = '?v=' + Date.now();
|
||||||
import './settings.test.js';
|
|
||||||
import './board.test.js';
|
const FILES = [
|
||||||
import './toolbar.test.js';
|
'./shell.test.js',
|
||||||
import './buckets.test.js';
|
'./settings.test.js',
|
||||||
import './bucket-context.test.js';
|
'./board.test.js',
|
||||||
import './context-menu.test.js';
|
'./toolbar.test.js',
|
||||||
import './card-interactions.test.js';
|
'./buckets.test.js',
|
||||||
import './filtering.test.js';
|
'./bucket-context.test.js',
|
||||||
import './sorting.test.js';
|
'./context-menu.test.js',
|
||||||
import './state-functions.test.js';
|
'./card-interactions.test.js',
|
||||||
import './favorites.test.js';
|
'./filtering.test.js',
|
||||||
import './search.test.js';
|
'./sorting.test.js',
|
||||||
import './search-logic.test.js';
|
'./state-functions.test.js',
|
||||||
import './calendar.test.js';
|
'./favorites.test.js',
|
||||||
import './calendar-popup.test.js';
|
'./search.test.js',
|
||||||
import './rrule.test.js';
|
'./search-logic.test.js',
|
||||||
import './datepicker.test.js';
|
'./calendar.test.js',
|
||||||
import './gallery.test.js';
|
'./calendar-popup.test.js',
|
||||||
import './help.test.js';
|
'./rrule.test.js',
|
||||||
import './multiselect.test.js';
|
'./datepicker.test.js',
|
||||||
import './detail.test.js';
|
'./gallery.test.js',
|
||||||
import './detail-interactions.test.js';
|
'./help.test.js',
|
||||||
import './editor.test.js';
|
'./multiselect.test.js',
|
||||||
import './editor-features.test.js';
|
'./detail.test.js',
|
||||||
import './editor-save.test.js';
|
'./detail-interactions.test.js',
|
||||||
import './editor-toolbar.test.js';
|
'./editor.test.js',
|
||||||
import './line-numbers.test.js';
|
'./editor-features.test.js',
|
||||||
import './canvas.test.js';
|
'./editor-save.test.js',
|
||||||
import './snippets.test.js';
|
'./editor-toolbar.test.js',
|
||||||
import './image-fullscreen.test.js';
|
'./line-numbers.test.js',
|
||||||
import './markdown.test.js';
|
'./canvas.test.js',
|
||||||
import './markdown-edge.test.js';
|
'./snippets.test.js',
|
||||||
import './undo.test.js';
|
'./image-fullscreen.test.js',
|
||||||
import './api.test.js';
|
'./markdown.test.js',
|
||||||
import './api-edge.test.js';
|
'./markdown-edge.test.js',
|
||||||
import './export.test.js';
|
'./undo.test.js',
|
||||||
import './relay.test.js';
|
'./api.test.js',
|
||||||
import './notifications.test.js';
|
'./api-edge.test.js',
|
||||||
import './quick-capture.test.js';
|
'./export.test.js',
|
||||||
import './theme.test.js';
|
'./relay.test.js',
|
||||||
|
'./notifications.test.js',
|
||||||
|
'./quick-capture.test.js',
|
||||||
|
'./theme.test.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Await all imports so the runner can start in a deterministic state.
|
||||||
|
await Promise.all(FILES.map(f => import(f + V)));
|
||||||
Reference in new issue
Block a user