236 lines
8.3 KiB
TypeScript
236 lines
8.3 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, hashContent } from './index';
|
|
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
|
|
import type { NoteItem } from '../schemas';
|
|
|
|
/** 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';
|
|
|
|
// Build image map from non-.md files in the ZIP for embedded image resolution
|
|
const imageMap = new Map<string, { file: JSZip.JSZipObject; mimeType: string }>();
|
|
const imageExts: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp' };
|
|
zip.forEach((path, file) => {
|
|
if (file.dir) return;
|
|
const ext = path.substring(path.lastIndexOf('.')).toLowerCase();
|
|
if (imageExts[ext]) {
|
|
// Index by basename for ![[filename.png]] lookup
|
|
const basename = path.split('/').pop()!;
|
|
imageMap.set(basename, { file, mimeType: imageExts[ext] });
|
|
// Also index by full path
|
|
imageMap.set(path, { file, mimeType: imageExts[ext] });
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
|
|
// Resolve embedded images: ![[image.png]] was converted to 
|
|
// Also handle original ![[image.png]] syntax in case wikilinks missed it
|
|
const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
|
|
const imageRefPattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
const resolvedImages = new Set<string>();
|
|
|
|
let match: RegExpExecArray | null;
|
|
while ((match = imageRefPattern.exec(md)) !== null) {
|
|
const ref = match[2];
|
|
const basename = ref.split('/').pop()!;
|
|
const imgEntry = imageMap.get(basename) || imageMap.get(ref);
|
|
if (imgEntry && !resolvedImages.has(basename)) {
|
|
resolvedImages.add(basename);
|
|
const data = await imgEntry.file.async('uint8array');
|
|
attachments.push({ filename: basename, data, mimeType: imgEntry.mimeType });
|
|
}
|
|
}
|
|
|
|
// Replace image URLs to point to uploaded files
|
|
if (attachments.length > 0) {
|
|
md = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (full, alt, ref) => {
|
|
const basename = ref.split('/').pop()!;
|
|
if (imageMap.has(basename) || imageMap.has(ref)) {
|
|
return ``;
|
|
}
|
|
return full;
|
|
});
|
|
}
|
|
|
|
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)],
|
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
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);
|