/** * Obsidian vault ↔ rNotes converter. * * Import: ZIP of .md files with YAML frontmatter, [[wikilinks]], callouts, nested folders → tags * Export: ZIP of .md files with YAML frontmatter, organized by notebook */ import JSZip from 'jszip'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; 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 YAML frontmatter from an Obsidian markdown file. */ function parseFrontmatter(content: string): { frontmatter: Record; body: string } { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) return { frontmatter: {}, body: content }; try { const frontmatter = parseYaml(match[1]) || {}; return { frontmatter, body: match[2] }; } catch { return { frontmatter: {}, body: content }; } } /** Convert Obsidian [[wikilinks]] to standard markdown links. */ function convertWikilinks(md: string): string { // [[Page Name|Display Text]] → [Display Text](Page Name) // [[Page Name]] → [Page Name](Page Name) return md.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '[$2]($1)') .replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)'); } /** Convert Obsidian callouts to blockquotes. */ function convertCallouts(md: string): string { // > [!type] Title → > **Type:** Title return md.replace(/^> \[!(\w+)\]\s*(.*)/gm, (_, type, title) => { const label = type.charAt(0).toUpperCase() + type.slice(1); return title ? `> **${label}:** ${title}` : `> **${label}**`; }); } /** Extract folder path as tag prefix from file path. */ function pathToTags(filePath: string): string[] { const parts = filePath.split('/').slice(0, -1); // Remove filename // Filter out common vault root folders const filtered = parts.filter(p => !['attachments', 'assets', 'templates', '.obsidian'].includes(p.toLowerCase())); if (filtered.length === 0) return []; return filtered.map(p => p.toLowerCase().replace(/\s+/g, '-')); } /** Extract title from filename (without .md extension). */ function titleFromPath(filePath: string): string { const filename = filePath.split('/').pop() || 'Untitled'; return filename.replace(/\.md$/i, ''); } const obsidianConverter: NoteConverter = { id: 'obsidian', name: 'Obsidian', requiresAuth: false, async import(input: ImportInput): Promise { if (!input.fileData) { throw new Error('Obsidian import requires a ZIP file'); } const zip = await JSZip.loadAsync(input.fileData); const notes: ConvertedNote[] = []; const warnings: string[] = []; let vaultName = 'Obsidian Import'; // Find markdown files in the ZIP const mdFiles: { path: string; file: JSZip.JSZipObject }[] = []; zip.forEach((path, file) => { if (file.dir) return; if (!path.endsWith('.md')) return; // Skip hidden/config files if (path.includes('.obsidian/') || path.includes('.trash/')) return; mdFiles.push({ path, file }); }); if (mdFiles.length === 0) { warnings.push('No .md files found in the ZIP archive'); return { notes, notebookTitle: vaultName, warnings }; } // Try to detect vault name from common root folder const firstPath = mdFiles[0].path; const rootFolder = firstPath.split('/')[0]; if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) { vaultName = rootFolder; // Strip root folder prefix from all paths for (const f of mdFiles) { f.path = f.path.slice(rootFolder.length + 1); } } for (const { path, file } of mdFiles) { try { const raw = await file.async('string'); const { frontmatter, body } = parseFrontmatter(raw); // Process markdown let md = convertWikilinks(body); md = convertCallouts(md); const title = frontmatter.title || titleFromPath(path); const tiptapJson = markdownToTiptap(md); const contentPlain = extractPlainTextFromTiptap(tiptapJson); // Collect tags from frontmatter + folder path const tags: string[] = []; if (frontmatter.tags) { const fmTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags]; tags.push(...fmTags.map((t: string) => String(t).toLowerCase().replace(/^#/, ''))); } tags.push(...pathToTags(path)); notes.push({ title, content: tiptapJson, contentPlain, markdown: md, tags: [...new Set(tags)], sourceRef: { source: 'obsidian', externalId: path, lastSyncedAt: Date.now(), contentHash: hashContent(raw), }, }); } catch (err) { warnings.push(`Failed to parse ${path}: ${(err as Error).message}`); } } return { notes, notebookTitle: vaultName, warnings }; }, async export(notes: NoteItem[], opts: ExportOptions): Promise { const zip = new JSZip(); const notebookTitle = opts.notebookTitle || 'rNotes Export'; 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) { // Legacy HTML — strip tags for basic markdown md = note.content.replace(/<[^>]*>/g, '').trim(); } else { md = ''; } // Build YAML frontmatter const frontmatter: Record = {}; if (note.tags.length > 0) frontmatter.tags = note.tags; frontmatter.created = new Date(note.createdAt).toISOString(); frontmatter.updated = new Date(note.updatedAt).toISOString(); if (note.type !== 'NOTE') frontmatter.type = note.type.toLowerCase(); if (note.sourceRef) { frontmatter['rnotes-id'] = note.id; } const yamlStr = stringifyYaml(frontmatter).trim(); const fileContent = `---\n${yamlStr}\n---\n\n${md}\n`; // Sanitize filename const filename = note.title .replace(/[<>:"/\\|?*]/g, '') .replace(/\s+/g, ' ') .trim() || 'Untitled'; zip.file(`${notebookTitle}/${filename}.md`, fileContent); } const data = await zip.generateAsync({ type: 'uint8array' }); return { data, filename: `${notebookTitle.replace(/\s+/g, '-').toLowerCase()}-obsidian.zip`, mimeType: 'application/zip', }; }, }; registerConverter(obsidianConverter);