diff --git a/app/api/export/obsidian/route.ts b/app/api/export/obsidian/route.ts new file mode 100644 index 0000000..8a04b8c --- /dev/null +++ b/app/api/export/obsidian/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' +import { exportToObsidian, validateVaultPath } from '@/lib/obsidian-exporter' +import prisma from '@/lib/db' + +export async function POST(request: NextRequest): Promise { + let body: { category?: string; overwrite?: boolean } = {} + try { + body = await request.json() + } catch { + // body stays as defaults + } + + const { category, overwrite = false } = body + + const setting = await prisma.setting.findUnique({ where: { key: 'obsidianVaultPath' } }) + if (!setting?.value) { + return NextResponse.json( + { error: 'Obsidian vault path not configured. Add it in Settings.' }, + { status: 400 } + ) + } + + const validation = await validateVaultPath(setting.value) + if (!validation.valid) { + return NextResponse.json( + { error: `Invalid vault path: ${validation.error}` }, + { status: 400 } + ) + } + + try { + const result = await exportToObsidian({ + vaultPath: setting.value, + subfolder: 'Twitter Bookmarks', + overwrite, + categoryFilter: category, + }) + return NextResponse.json(result) + } catch (err: unknown) { + console.error('Obsidian export error:', err) + return NextResponse.json( + { error: 'Export failed. Check server logs for details.' }, + { status: 500 } + ) + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 9e7f315..6debf8d 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/db' import { invalidateSettingsCache } from '@/lib/settings' +import { validateVaultPath } from '@/lib/obsidian-exporter' function maskKey(raw: string | null): string | null { if (!raw) return null @@ -30,7 +31,7 @@ const ALLOWED_MINIMAX_MODELS = [ export async function GET(): Promise { try { - const [anthropic, anthropicModel, provider, openai, openaiModel, minimax, minimaxModel, xClientId, xClientSecret] = await Promise.all([ + const [anthropic, anthropicModel, provider, openai, openaiModel, minimax, minimaxModel, xClientId, xClientSecret, obsidianVault] = await Promise.all([ prisma.setting.findUnique({ where: { key: 'anthropicApiKey' } }), prisma.setting.findUnique({ where: { key: 'anthropicModel' } }), prisma.setting.findUnique({ where: { key: 'aiProvider' } }), @@ -40,6 +41,7 @@ export async function GET(): Promise { prisma.setting.findUnique({ where: { key: 'minimaxModel' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }), prisma.setting.findUnique({ where: { key: 'x_oauth_client_secret' } }), + prisma.setting.findUnique({ where: { key: 'obsidianVaultPath' } }), ]) return NextResponse.json({ @@ -56,6 +58,7 @@ export async function GET(): Promise { xOAuthClientId: maskKey(xClientId?.value ?? null), xOAuthClientSecret: maskKey(xClientSecret?.value ?? null), hasXOAuth: !!xClientId?.value, + obsidianVaultPath: obsidianVault?.value ?? null, }) } catch (err) { console.error('Settings GET error:', err) @@ -77,6 +80,7 @@ export async function POST(request: NextRequest): Promise { minimaxModel?: string xOAuthClientId?: string xOAuthClientSecret?: string + obsidianVaultPath?: string } = {} try { body = await request.json() @@ -211,6 +215,26 @@ export async function POST(request: NextRequest): Promise { } } + // Save Obsidian vault path if provided + if (body.obsidianVaultPath !== undefined) { + const trimmed = body.obsidianVaultPath.trim() + if (!trimmed) { + // Allow clearing the path + await prisma.setting.deleteMany({ where: { key: 'obsidianVaultPath' } }) + return NextResponse.json({ saved: true }) + } + const validation = await validateVaultPath(trimmed) + if (!validation.valid) { + return NextResponse.json({ error: `Invalid vault path: ${validation.error}` }, { status: 400 }) + } + await prisma.setting.upsert({ + where: { key: 'obsidianVaultPath' }, + update: { value: trimmed }, + create: { key: 'obsidianVaultPath', value: trimmed }, + }) + return NextResponse.json({ saved: true }) + } + // Save X OAuth credentials if provided const { xOAuthClientId, xOAuthClientSecret } = body const xKeys: { key: string; value: string | undefined }[] = [ diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 46a19ed..7b07ff9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -20,6 +20,8 @@ import { Terminal, Loader2, X, + BookOpen, + FolderOpen, } from 'lucide-react' const ANTHROPIC_MODELS = [ @@ -693,6 +695,151 @@ function ExportButton({ ) } +interface ObsidianResult { + written: number + skipped: number + errors: Array<{ tweetId: string; error: string }> + indexesWritten: number +} + +function ObsidianExportBlock({ onToast }: { onToast: (t: Toast) => void }) { + const [vaultPath, setVaultPath] = useState('') + const [savedPath, setSavedPath] = useState(null) + const [savingPath, setSavingPath] = useState(false) + const [exporting, setExporting] = useState(false) + const [result, setResult] = useState(null) + const [overwrite, setOverwrite] = useState(false) + + useEffect(() => { + fetch('/api/settings') + .then((r) => r.json()) + .then((d: Record) => { + if (d.obsidianVaultPath) setSavedPath(d.obsidianVaultPath as string) + }) + .catch(() => {}) + }, []) + + async function handleSavePath() { + if (!vaultPath.trim()) { + onToast({ type: 'error', message: 'Enter a vault path first' }) + return + } + setSavingPath(true) + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ obsidianVaultPath: vaultPath.trim() }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'Failed to save') + setSavedPath(vaultPath.trim()) + onToast({ type: 'success', message: 'Vault path saved' }) + } catch (err) { + onToast({ type: 'error', message: err instanceof Error ? err.message : 'Failed to save path' }) + } finally { + setSavingPath(false) + } + } + + async function handleExport() { + setExporting(true) + setResult(null) + try { + const res = await fetch('/api/export/obsidian', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwrite }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'Export failed') + setResult(data) + onToast({ type: 'success', message: `Exported ${data.written} notes to Obsidian` }) + } catch (err) { + onToast({ type: 'error', message: err instanceof Error ? err.message : 'Export failed' }) + } finally { + setExporting(false) + } + } + + return ( +
+
+
+ +
+
+ + setVaultPath(e.target.value)} + placeholder={savedPath ?? '/Users/you/ObsidianVault'} + className="w-full pl-9 pr-3 py-2.5 bg-zinc-800 border border-zinc-700 rounded-xl text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 font-mono" + /> +
+ +
+ {savedPath && ( +

+ Current: {savedPath} +

+ )} +
+ +
+ +
+ + + + {result && ( +
+

{result.written} notes written

+ {result.skipped > 0 &&

{result.skipped} skipped (already exist)

} + {result.indexesWritten > 0 &&

{result.indexesWritten} index files created

} + {result.errors.length > 0 &&

{result.errors.length} errors

} +
+ )} +
+
+ ) +} + function DataSection() { return (
+ diff --git a/lib/obsidian-exporter.ts b/lib/obsidian-exporter.ts new file mode 100644 index 0000000..5e87533 --- /dev/null +++ b/lib/obsidian-exporter.ts @@ -0,0 +1,294 @@ +import fs from 'fs/promises' +import path from 'path' +import prisma from '@/lib/db' + +export interface ObsidianExportResult { + written: number + skipped: number + errors: Array<{ tweetId: string; error: string }> + indexesWritten: number +} + +interface ObsidianExportOptions { + vaultPath: string + subfolder?: string + overwrite?: boolean + categoryFilter?: string +} + +interface MediaItemRow { + type: string + url: string + thumbnailUrl: string | null +} + +interface CategoryJoin { + category: { + name: string + slug: string + color: string + } +} + +interface BookmarkRow { + id: string + tweetId: string + text: string + authorHandle: string + authorName: string + tweetCreatedAt: Date | null + importedAt: Date + semanticTags: string | null + entities: string | null + mediaItems: MediaItemRow[] + categories: CategoryJoin[] +} + +/** + * Validate that vaultPath is safe: + * - Must be an absolute path + * - Must be an existing directory + * - Must be under the user's home directory (not system paths) + */ +export async function validateVaultPath(vaultPath: string): Promise<{ valid: boolean; error?: string }> { + const resolved = path.resolve(vaultPath) + + if (!path.isAbsolute(resolved)) { + return { valid: false, error: 'Vault path must be absolute' } + } + + // Block system paths + const blocked = ['/etc', '/usr', '/bin', '/sbin', '/var', '/tmp', '/dev', '/proc', '/sys'] + if (blocked.some(p => resolved === p || resolved.startsWith(p + '/'))) { + return { valid: false, error: 'Cannot write to system directories' } + } + + try { + const stat = await fs.stat(resolved) + if (!stat.isDirectory()) { + return { valid: false, error: 'Path is not a directory' } + } + } catch { + return { valid: false, error: 'Directory does not exist' } + } + + return { valid: true } +} + +function sanitizeTag(tag: string): string { + return tag + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9\-_/]/g, '') + .replace(/-+/g, '-') + .trim() +} + +function sanitizeFilename(str: string): string { + return str.replace(/[<>:"/\\|?*\n\r]/g, '').trim() +} + +function noteFilename(bookmark: BookmarkRow): string { + const date = bookmark.tweetCreatedAt + ? new Date(bookmark.tweetCreatedAt).toISOString().split('T')[0] + : 'unknown' + const author = sanitizeFilename(bookmark.authorHandle || 'unknown') + return `${date} - @${author} - ${bookmark.tweetId}.md` +} + +function buildNoteMarkdown(bookmark: BookmarkRow): string { + const tags: string[] = ['twitter/bookmark'] + + if (bookmark.authorHandle) { + tags.push(`author/${sanitizeTag(bookmark.authorHandle)}`) + } + + let semanticTags: string[] = [] + try { semanticTags = JSON.parse(bookmark.semanticTags || '[]') } catch {} + semanticTags.forEach(t => { const c = sanitizeTag(t); if (c) tags.push(c) }) + + const categories = bookmark.categories.map((bc) => bc.category.name) + categories.forEach((c) => tags.push(`category/${sanitizeTag(c)}`)) + + const date = bookmark.tweetCreatedAt + ? new Date(bookmark.tweetCreatedAt).toISOString().split('T')[0] + : null + const sourceUrl = `https://x.com/${bookmark.authorHandle}/status/${bookmark.tweetId}` + + const frontmatter = [ + '---', + `tweet_id: "${bookmark.tweetId}"`, + `author: "${bookmark.authorHandle || ''}"`, + `author_name: "${(bookmark.authorName || '').replace(/"/g, "'")}"`, + date ? `date: ${date}` : null, + `source: "${sourceUrl}"`, + `categories: [${categories.map((c) => `"${c}"`).join(', ')}]`, + `tags:`, + ...tags.map(t => ` - ${t}`), + '---', + ].filter(Boolean).join('\n') + + const lines: string[] = [frontmatter, '', bookmark.text || ''] + + if (bookmark.mediaItems.length > 0) { + lines.push('', '## Media', '') + for (const item of bookmark.mediaItems) { + if (item.type === 'photo') { + lines.push(`![](${item.url})`) + } else { + lines.push(`[${item.type.toUpperCase()}](${item.url})`) + } + } + } + + lines.push('', '## Source', '', `[View on X](${sourceUrl})`) + return lines.join('\n') +} + +function buildCategoryIndex( + categoryName: string, + bookmarks: BookmarkRow[] +): string { + const tag = sanitizeTag(categoryName) + const links = bookmarks + .map(b => `- [[${noteFilename(b).replace(/\.md$/, '')}]]`) + .join('\n') + + return [ + '---', + `type: index`, + `category: "${categoryName}"`, + `tags:`, + ` - index/category`, + ` - category/${tag}`, + '---', + '', + `# ${categoryName}`, + '', + `${bookmarks.length} bookmarks`, + '', + '## Bookmarks', + '', + links, + ].join('\n') +} + +function buildAuthorIndex( + handle: string, + displayName: string, + bookmarks: BookmarkRow[] +): string { + const links = bookmarks + .map(b => `- [[${noteFilename(b).replace(/\.md$/, '')}]]`) + .join('\n') + + return [ + '---', + `type: index`, + `author: "${handle}"`, + `author_name: "${displayName.replace(/"/g, "'")}"`, + `tags:`, + ` - index/author`, + ` - author/${sanitizeTag(handle)}`, + '---', + '', + `# @${handle}`, + '', + `${bookmarks.length} bookmarks`, + '', + '## Bookmarks', + '', + links, + ].join('\n') +} + +export async function exportToObsidian(options: ObsidianExportOptions): Promise { + const { vaultPath, subfolder = 'Twitter Bookmarks', overwrite = false, categoryFilter } = options + + // Validate path before writing + const validation = await validateVaultPath(vaultPath) + if (!validation.valid) { + throw new Error(`Invalid vault path: ${validation.error}`) + } + + // Sanitize subfolder to prevent path traversal via subfolder param + const safeSubfolder = sanitizeFilename(subfolder) + const notesDir = path.join(vaultPath, safeSubfolder) + const indexDir = path.join(notesDir, '_index') + + // Verify the resolved paths are still under vaultPath + const resolvedNotesDir = path.resolve(notesDir) + const resolvedVaultPath = path.resolve(vaultPath) + if (!resolvedNotesDir.startsWith(resolvedVaultPath + '/') && resolvedNotesDir !== resolvedVaultPath) { + throw new Error('Subfolder path escapes vault directory') + } + + await fs.mkdir(notesDir, { recursive: true }) + await fs.mkdir(indexDir, { recursive: true }) + + const where = categoryFilter + ? { categories: { some: { category: { slug: categoryFilter } } } } + : {} + + const bookmarks = await prisma.bookmark.findMany({ + where, + include: { + mediaItems: true, + categories: { include: { category: true } }, + }, + orderBy: { tweetCreatedAt: 'desc' }, + }) as BookmarkRow[] + + const result: ObsidianExportResult = { written: 0, skipped: 0, errors: [], indexesWritten: 0 } + + for (const bookmark of bookmarks) { + const filename = noteFilename(bookmark) + const filePath = path.join(notesDir, filename) + + if (!overwrite) { + try { await fs.access(filePath); result.skipped++; continue } catch {} + } + + try { + await fs.writeFile(filePath, buildNoteMarkdown(bookmark), 'utf-8') + result.written++ + } catch (err: unknown) { + result.errors.push({ + tweetId: bookmark.tweetId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + + const byCategory = new Map() + for (const bookmark of bookmarks) { + for (const bc of bookmark.categories) { + const name = bc.category.name + if (!byCategory.has(name)) byCategory.set(name, []) + byCategory.get(name)!.push(bookmark) + } + } + for (const [categoryName, categoryBookmarks] of byCategory) { + const filename = `_${sanitizeFilename(categoryName)}.md` + const filePath = path.join(indexDir, filename) + await fs.writeFile(filePath, buildCategoryIndex(categoryName, categoryBookmarks), 'utf-8') + result.indexesWritten++ + } + + const byAuthor = new Map() + for (const bookmark of bookmarks) { + const handle = bookmark.authorHandle || 'unknown' + if (!byAuthor.has(handle)) { + byAuthor.set(handle, { displayName: bookmark.authorName || handle, bookmarks: [] }) + } + byAuthor.get(handle)!.bookmarks.push(bookmark) + } + for (const [handle, { displayName, bookmarks: authorBookmarks }] of byAuthor) { + const filename = `@${sanitizeFilename(handle)}.md` + const filePath = path.join(indexDir, filename) + await fs.writeFile(filePath, buildAuthorIndex(handle, displayName, authorBookmarks), 'utf-8') + result.indexesWritten++ + } + + return result +}