rspace-online/modules/rnotes/converters/obsidian.ts

202 lines
6.5 KiB
TypeScript

/**
* 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<string, any>; 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<ImportResult> {
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<ExportResult> {
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<string, any> = {};
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);