Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions app/api/export/obsidian/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 }
)
}
}
26 changes: 25 additions & 1 deletion app/api/settings/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -30,7 +31,7 @@ const ALLOWED_MINIMAX_MODELS = [

export async function GET(): Promise<NextResponse> {
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' } }),
Expand All @@ -40,6 +41,7 @@ export async function GET(): Promise<NextResponse> {
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({
Expand All @@ -56,6 +58,7 @@ export async function GET(): Promise<NextResponse> {
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)
Expand All @@ -77,6 +80,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
minimaxModel?: string
xOAuthClientId?: string
xOAuthClientSecret?: string
obsidianVaultPath?: string
} = {}
try {
body = await request.json()
Expand Down Expand Up @@ -211,6 +215,26 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
}
}

// 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 }[] = [
Expand Down
148 changes: 148 additions & 0 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
Terminal,
Loader2,
X,
BookOpen,
FolderOpen,
} from 'lucide-react'

const ANTHROPIC_MODELS = [
Expand Down Expand Up @@ -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<string | null>(null)
const [savingPath, setSavingPath] = useState(false)
const [exporting, setExporting] = useState(false)
const [result, setResult] = useState<ObsidianResult | null>(null)
const [overwrite, setOverwrite] = useState(false)

useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((d: Record<string, unknown>) => {
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 (
<Section
icon={BookOpen}
title="Obsidian Export"
description="Export bookmarks as Markdown notes with YAML frontmatter, wikilinks, and index files."
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1.5">Vault path</label>
<div className="flex gap-2">
<div className="relative flex-1">
<FolderOpen size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={vaultPath}
onChange={(e) => 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"
/>
</div>
<button
onClick={handleSavePath}
disabled={savingPath || !vaultPath.trim()}
className="px-4 py-2.5 rounded-xl bg-zinc-700 hover:bg-zinc-600 text-sm font-medium text-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{savingPath ? 'Saving...' : 'Save'}
</button>
</div>
{savedPath && (
<p className="text-xs text-zinc-500 mt-1.5">
Current: <code className="font-mono text-zinc-400">{savedPath}</code>
</p>
)}
</div>

<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer">
<input
type="checkbox"
checked={overwrite}
onChange={(e) => setOverwrite(e.target.checked)}
className="rounded border-zinc-600 bg-zinc-800 text-indigo-500 focus:ring-indigo-500/50"
/>
Overwrite existing notes
</label>
</div>

<button
onClick={handleExport}
disabled={exporting || !savedPath}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{exporting ? (
<>
<Loader2 size={14} className="animate-spin" />
Exporting...
</>
) : (
<>
<Download size={14} />
Export to Obsidian
</>
)}
</button>

{result && (
<div className="bg-zinc-800/50 border border-zinc-700 rounded-xl p-3 text-sm">
<p className="text-green-400">{result.written} notes written</p>
{result.skipped > 0 && <p className="text-zinc-400">{result.skipped} skipped (already exist)</p>}
{result.indexesWritten > 0 && <p className="text-zinc-400">{result.indexesWritten} index files created</p>}
{result.errors.length > 0 && <p className="text-red-400">{result.errors.length} errors</p>}
</div>
)}
</div>
</Section>
)
}

function DataSection() {
return (
<Section
Expand Down Expand Up @@ -1045,6 +1192,7 @@ export default function SettingsPage() {
<ApiKeySection onToast={showToast} />
<XOAuthSection onToast={showToast} />
<DataSection />
<ObsidianExportBlock onToast={showToast} />
<DangerZoneSection onToast={showToast} />
<AboutSection />
</div>
Expand Down
Loading
Loading