274 lines
8.3 KiB
TypeScript
274 lines
8.3 KiB
TypeScript
/**
|
|
* Logseq graph ↔ rNotes converter.
|
|
*
|
|
* Import: ZIP of pages/ + journals/ dirs, property:: value syntax, bullet outliner blocks
|
|
* Export: ZIP with Logseq-compatible page files + properties
|
|
*/
|
|
|
|
import JSZip from 'jszip';
|
|
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
|
|
import { registerConverter } from './index';
|
|
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
|
|
import type { NoteItem } from '../schemas';
|
|
|
|
/** Hash content for conflict detection. */
|
|
function hashContent(content: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < content.length; i++) {
|
|
const char = content.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash |= 0;
|
|
}
|
|
return Math.abs(hash).toString(36);
|
|
}
|
|
|
|
/** Parse Logseq property:: value lines from the top of a page. */
|
|
function parseLogseqProperties(content: string): { properties: Record<string, string>; body: string } {
|
|
const lines = content.split('\n');
|
|
const properties: Record<string, string> = {};
|
|
let bodyStart = 0;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const match = lines[i].match(/^([a-zA-Z_-]+)::\s*(.*)$/);
|
|
if (match) {
|
|
properties[match[1].toLowerCase()] = match[2].trim();
|
|
bodyStart = i + 1;
|
|
} else if (lines[i].trim() === '') {
|
|
bodyStart = i + 1;
|
|
continue;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { properties, body: lines.slice(bodyStart).join('\n') };
|
|
}
|
|
|
|
/**
|
|
* Convert Logseq outliner bullet format to regular markdown.
|
|
* Logseq uses `- content` for all blocks with indentation for nesting.
|
|
*/
|
|
function convertOutlinerToMarkdown(content: string): string {
|
|
const lines = content.split('\n');
|
|
const result: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
// Detect indented bullets: tabs or spaces followed by -
|
|
const match = line.match(/^(\t*|\s*)- (.*)$/);
|
|
if (match) {
|
|
const indent = match[1];
|
|
const text = match[2];
|
|
|
|
// Calculate nesting level
|
|
const level = indent.replace(/ /g, '\t').split('\t').length - 1;
|
|
|
|
// Check if this looks like a heading (common Logseq pattern)
|
|
if (level === 0 && text.startsWith('# ')) {
|
|
result.push(text);
|
|
} else if (level === 0 && !text.startsWith('- ')) {
|
|
// Top-level bullet → paragraph or list item
|
|
result.push(`- ${text}`);
|
|
} else {
|
|
// Nested bullet → indented list item
|
|
const indentation = ' '.repeat(level);
|
|
result.push(`${indentation}- ${text}`);
|
|
}
|
|
} else {
|
|
result.push(line);
|
|
}
|
|
}
|
|
|
|
return result.join('\n');
|
|
}
|
|
|
|
/** Convert [[page references]] to standard links. */
|
|
function convertPageRefs(md: string): string {
|
|
return md.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
|
|
}
|
|
|
|
/** Convert Logseq tags (#tag or #[[multi word tag]]). */
|
|
function extractLogseqTags(content: string): string[] {
|
|
const tags: string[] = [];
|
|
// #tag
|
|
const singleTags = content.match(/#([a-zA-Z0-9_-]+)/g);
|
|
if (singleTags) tags.push(...singleTags.map(t => t.slice(1).toLowerCase()));
|
|
// #[[multi word tag]]
|
|
const multiTags = content.match(/#\[\[([^\]]+)\]\]/g);
|
|
if (multiTags) tags.push(...multiTags.map(t => t.slice(3, -2).toLowerCase().replace(/\s+/g, '-')));
|
|
return [...new Set(tags)];
|
|
}
|
|
|
|
/** Parse Logseq journal filename to date. */
|
|
function parseJournalDate(filename: string): string | null {
|
|
// Common Logseq journal formats: 2026_03_01.md, 2026-03-01.md
|
|
const match = filename.match(/(\d{4})[_-](\d{2})[_-](\d{2})\.md$/);
|
|
if (match) return `${match[1]}-${match[2]}-${match[3]}`;
|
|
return null;
|
|
}
|
|
|
|
/** Extract title from filename. */
|
|
function titleFromPath(filePath: string): string {
|
|
const filename = filePath.split('/').pop() || 'Untitled';
|
|
return filename.replace(/\.md$/i, '').replace(/%2F/g, '/').replace(/_/g, ' ');
|
|
}
|
|
|
|
const logseqConverter: NoteConverter = {
|
|
id: 'logseq',
|
|
name: 'Logseq',
|
|
requiresAuth: false,
|
|
|
|
async import(input: ImportInput): Promise<ImportResult> {
|
|
if (!input.fileData) {
|
|
throw new Error('Logseq import requires a ZIP file');
|
|
}
|
|
|
|
const zip = await JSZip.loadAsync(input.fileData);
|
|
const notes: ConvertedNote[] = [];
|
|
const warnings: string[] = [];
|
|
let graphName = 'Logseq Import';
|
|
|
|
// Collect all .md files
|
|
const mdFiles: { path: string; file: JSZip.JSZipObject; isJournal: boolean }[] = [];
|
|
zip.forEach((path, file) => {
|
|
if (file.dir) return;
|
|
if (!path.endsWith('.md')) return;
|
|
// Skip config/hidden files
|
|
if (path.includes('logseq/') && !path.includes('pages/') && !path.includes('journals/')) return;
|
|
if (path.includes('.recycle/')) return;
|
|
|
|
const isJournal = path.includes('journals/');
|
|
mdFiles.push({ path, file, isJournal });
|
|
});
|
|
|
|
if (mdFiles.length === 0) {
|
|
warnings.push('No .md files found in pages/ or journals/ directories');
|
|
return { notes, notebookTitle: graphName, warnings };
|
|
}
|
|
|
|
// Detect graph name from common root
|
|
const firstPath = mdFiles[0].path;
|
|
const rootFolder = firstPath.split('/')[0];
|
|
if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) {
|
|
graphName = rootFolder;
|
|
for (const f of mdFiles) {
|
|
f.path = f.path.slice(rootFolder.length + 1);
|
|
}
|
|
}
|
|
|
|
for (const { path, file, isJournal } of mdFiles) {
|
|
try {
|
|
const raw = await file.async('string');
|
|
const { properties, body } = parseLogseqProperties(raw);
|
|
|
|
// Convert Logseq format to standard markdown
|
|
let md = convertOutlinerToMarkdown(body);
|
|
md = convertPageRefs(md);
|
|
|
|
const filename = path.split('/').pop() || '';
|
|
let title: string;
|
|
|
|
if (isJournal) {
|
|
const date = parseJournalDate(filename);
|
|
title = date ? `Journal: ${date}` : titleFromPath(path);
|
|
} else {
|
|
title = properties.title || titleFromPath(path);
|
|
}
|
|
|
|
const tiptapJson = markdownToTiptap(md);
|
|
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
|
|
|
|
// Collect tags
|
|
const tags: string[] = [];
|
|
if (properties.tags) {
|
|
const tagStr = properties.tags.replace(/\[\[|\]\]/g, '');
|
|
tags.push(...tagStr.split(',').map(t => t.trim().toLowerCase()).filter(Boolean));
|
|
}
|
|
tags.push(...extractLogseqTags(raw));
|
|
if (isJournal) tags.push('journal');
|
|
|
|
notes.push({
|
|
title,
|
|
content: tiptapJson,
|
|
contentPlain,
|
|
markdown: md,
|
|
tags: [...new Set(tags)],
|
|
sourceRef: {
|
|
source: 'logseq',
|
|
externalId: path,
|
|
lastSyncedAt: Date.now(),
|
|
contentHash: hashContent(raw),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
warnings.push(`Failed to parse ${path}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
return { notes, notebookTitle: graphName, warnings };
|
|
},
|
|
|
|
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
|
|
const zip = new JSZip();
|
|
const graphName = opts.notebookTitle || 'rNotes Export';
|
|
const pagesDir = zip.folder('pages')!;
|
|
|
|
for (const note of notes) {
|
|
// Convert content to markdown
|
|
let md: string;
|
|
if (note.contentFormat === 'tiptap-json' && note.content) {
|
|
md = tiptapToMarkdown(note.content);
|
|
} else if (note.content) {
|
|
md = note.content.replace(/<[^>]*>/g, '').trim();
|
|
} else {
|
|
md = '';
|
|
}
|
|
|
|
// Build Logseq properties block
|
|
const props: string[] = [];
|
|
if (note.tags.length > 0) {
|
|
props.push(`tags:: ${note.tags.map(t => `[[${t}]]`).join(', ')}`);
|
|
}
|
|
if (note.type !== 'NOTE') {
|
|
props.push(`type:: ${note.type.toLowerCase()}`);
|
|
}
|
|
props.push(`created:: ${new Date(note.createdAt).toISOString().split('T')[0]}`);
|
|
|
|
// Convert markdown paragraphs to Logseq outliner bullets
|
|
const mdLines = md.split('\n');
|
|
const outliner: string[] = [];
|
|
for (const line of mdLines) {
|
|
if (line.trim() === '') continue;
|
|
if (line.startsWith('#')) {
|
|
outliner.push(`- ${line}`);
|
|
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
|
outliner.push(`- ${line.slice(2)}`);
|
|
} else if (line.match(/^\d+\.\s/)) {
|
|
outliner.push(`- ${line.replace(/^\d+\.\s/, '')}`);
|
|
} else {
|
|
outliner.push(`- ${line}`);
|
|
}
|
|
}
|
|
|
|
const propsBlock = props.length > 0 ? props.join('\n') + '\n\n' : '';
|
|
const fileContent = `${propsBlock}${outliner.join('\n')}\n`;
|
|
|
|
// Sanitize filename for Logseq (uses %2F for namespaced pages)
|
|
const filename = note.title
|
|
.replace(/[<>:"/\\|?*]/g, '')
|
|
.replace(/\//g, '%2F')
|
|
.trim() || 'Untitled';
|
|
|
|
pagesDir.file(`${filename}.md`, fileContent);
|
|
}
|
|
|
|
const data = await zip.generateAsync({ type: 'uint8array' });
|
|
return {
|
|
data,
|
|
filename: `${graphName.replace(/\s+/g, '-').toLowerCase()}-logseq.zip`,
|
|
mimeType: 'application/zip',
|
|
};
|
|
},
|
|
};
|
|
|
|
registerConverter(logseqConverter);
|