Skip to content
Open
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
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,20 @@ It runs a **4-stage AI pipeline** on your bookmarks:
🏷️ Entity Extraction — mines hashtags, URLs, mentions, and 100+ known tools from raw tweet data (free, zero API calls)
👁️ Vision Analysis — reads text, objects, and context from every image/GIF/video thumbnail (30–40 visual tags per image)
👁️ Vision Analysis — reads text, objects, and context from every image/GIF/video thumbnail
🧠 Semantic Tagging — generates 25–35 searchable tags per bookmark for AI-powered search
🧠 Semantic Tagging — generates broad, reusable search tags per bookmark for AI-powered search
📂 Categorization — assigns each bookmark to 1–3 categories with confidence scores
📖 Obsidian Export — writes your knowledge base as Markdown notes with YAML frontmatter, wikilinks, and index files
```

After the pipeline runs, you get:
- **AI search** — find bookmarks by meaning, not just keywords (*"funny meme about crypto crashing"*)
- **Interactive mindmap** — explore your entire bookmark graph visually
- **Filtered browsing** — grid or list view, filter by category, media type, and date
- **Export tools** — download media, export as CSV / JSON / ZIP
- **Export tools** — download media, export as CSV / JSON / ZIP / Obsidian vault

---

Expand Down Expand Up @@ -224,6 +226,45 @@ Create custom categories with a name, color, and optional description. The descr
- **CSV** — spreadsheet-compatible with all fields
- **JSON** — full structured data export
- **ZIP** — exports a category's bookmarks + all media files with a `manifest.csv`
- **Obsidian** — exports your entire knowledge base as a Markdown vault (see below)

### 📖 Obsidian Export

Export all processed bookmarks directly into an [Obsidian](https://obsidian.md) vault as structured Markdown notes.

**Each bookmark becomes a note** with YAML frontmatter:

```yaml
---
tweet_id: "1933508347334177246"
author: "alex_prompter"
author_name: "Alex Prompter"
date: 2025-06-13
source: "https://x.com/alex_prompter/status/1933508347334177246"
categories: ["AI & Agents", "PKM & Workflows"]
tags:
- twitter/bookmark
- author/alex_prompter
- prompt-engineering
- llm
- automation
- category/ai-agents
---
```

**Index notes** are generated automatically in a `_index/` subfolder:
- One note per **category** — lists all bookmarks in that category as `[[wikilinks]]`
- One note per **author** — lists every bookmark from that person

This creates a dense backlink graph in Obsidian's graph view, where bookmarks cluster naturally by topic and author.

**To set up:**
1. Go to **Settings** → find the **Obsidian Export** section
2. Enter the full path to your Obsidian vault folder (e.g. `/Users/you/Obsidian/Personal`)
3. Click **Save**, then **Export to Obsidian**
4. Open your vault in Obsidian — notes appear in a `Twitter Bookmarks/` folder

Re-export anytime. By default, existing notes are skipped — enable **Overwrite** to replace them.

### ⌨️ Command Palette

Expand All @@ -241,6 +282,7 @@ All settings are manageable in the **Settings** page at `/settings` or via envir
| API Base URL | `ANTHROPIC_BASE_URL` | Custom endpoint for proxies or local Anthropic-compatible models |
| AI Model | Settings page only | Haiku 4.5 (default, fastest/cheapest), Sonnet 4.6, Opus 4.6 |
| OpenAI Key | Settings page only | Alternative provider if no Anthropic key is set |
| Obsidian Vault Path | Settings page only | Absolute path to your Obsidian vault folder for Markdown export |
| Database | `DATABASE_URL` | SQLite file path (default: `file:./prisma/dev.db`) |

### Custom API Endpoint
Expand All @@ -266,6 +308,7 @@ siftly/
│ │ │ └── [slug]/ # Individual category operations
│ │ ├── categorize/ # 4-stage AI pipeline (start, status, stop)
│ │ ├── export/ # CSV, JSON, ZIP export
│ │ │ └── obsidian/ # Obsidian vault export endpoint
│ │ ├── import/ # JSON file import with dedup + auto-pipeline trigger
│ │ │ ├── bookmarklet/ # Bookmarklet-specific import endpoint
│ │ │ └── twitter/ # Twitter-specific import endpoint
Expand Down Expand Up @@ -308,6 +351,7 @@ siftly/
│ ├── rawjson-extractor.ts # Entity extraction from raw tweet JSON
│ ├── parser.ts # Multi-format JSON parser
│ ├── exporter.ts # CSV, JSON, ZIP export
│ ├── obsidian-exporter.ts # Obsidian vault export (Markdown + YAML frontmatter + indexes)
│ ├── types.ts # Shared TypeScript types
│ └── db.ts # Prisma client singleton
Expand Down
38 changes: 38 additions & 0 deletions app/api/export/obsidian/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { exportToObsidian } 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 }
)
}

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: ${err instanceof Error ? err.message : String(err)}` },
{ status: 500 }
)
}
}
18 changes: 17 additions & 1 deletion app/api/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ const ALLOWED_OPENAI_MODELS = [

export async function GET(): Promise<NextResponse> {
try {
const [anthropic, anthropicModel, provider, openai, openaiModel, xClientId, xClientSecret] = await Promise.all([
const [anthropic, anthropicModel, provider, openai, openaiModel, xClientId, xClientSecret, obsidianVaultPath] = await Promise.all([
prisma.setting.findUnique({ where: { key: 'anthropicApiKey' } }),
prisma.setting.findUnique({ where: { key: 'anthropicModel' } }),
prisma.setting.findUnique({ where: { key: 'aiProvider' } }),
prisma.setting.findUnique({ where: { key: 'openaiApiKey' } }),
prisma.setting.findUnique({ where: { key: 'openaiModel' } }),
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 @@ -45,6 +46,7 @@ export async function GET(): Promise<NextResponse> {
xOAuthClientId: maskKey(xClientId?.value ?? null),
xOAuthClientSecret: maskKey(xClientSecret?.value ?? null),
hasXOAuth: !!xClientId?.value,
obsidianVaultPath: obsidianVaultPath?.value ?? null,
})
} catch (err) {
console.error('Settings GET error:', err)
Expand Down Expand Up @@ -161,6 +163,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
}
}

// Save Obsidian vault path if provided
if ('obsidianVaultPath' in body) {
const vaultPath = (body as { obsidianVaultPath?: string }).obsidianVaultPath
if (typeof vaultPath !== 'string' || vaultPath.trim() === '') {
return NextResponse.json({ error: 'Invalid obsidianVaultPath value' }, { status: 400 })
}
await prisma.setting.upsert({
where: { key: 'obsidianVaultPath' },
update: { value: vaultPath.trim() },
create: { key: 'obsidianVaultPath', value: vaultPath.trim() },
})
return NextResponse.json({ saved: true })
}

// Save X OAuth credentials if provided
const { xOAuthClientId, xOAuthClientSecret } = body
const xKeys: { key: string; value: string | undefined }[] = [
Expand Down
2 changes: 1 addition & 1 deletion app/api/settings/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {

let client
try {
client = resolveOpenAIClient({ dbKey })
client = await resolveOpenAIClient({ dbKey })
} catch {
return NextResponse.json({ working: false, error: 'No OpenAI API key found. Add one in Settings or set up Codex CLI.' })
}
Expand Down
36 changes: 28 additions & 8 deletions app/import/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,16 @@ const BOOKMARKLET_SCRIPT = `(async function(){
function addTweet(t){
if(!t||!t.rest_id||seen.has(t.rest_id))return;
seen.add(t.rest_id);
var leg=t.legacy||{},usr=(t.core&&t.core.user_results&&t.core.user_results.result&&t.core.user_results.result.legacy)||{};
var leg=t.legacy||{};
var res=t.core&&t.core.user_results&&t.core.user_results.result;
var usrLeg=(res&&res.legacy)||{};
var usrCore=(res&&res.core)||{};
var usrAvatar=(res&&res.avatar)||{};
var usr={
name:usrCore.name||usrLeg.name||'Unknown',
screen_name:usrCore.screen_name||usrLeg.screen_name||'unknown',
profile_image_url_https:usrAvatar.image_url||usrLeg.profile_image_url_https||''
};
var rawMedia=(leg.extended_entities&&leg.extended_entities.media)||(leg.entities&&leg.entities.media)||[];
var media=rawMedia.map(function(m){
var thumb=m.media_url_https||'';
Expand Down Expand Up @@ -191,19 +200,20 @@ const BOOKMARKLET_SCRIPT = `(async function(){
document.body.appendChild(btn);
document.body.appendChild(autoBtn);
var origFetch=window.fetch;
function isRelevantUrl(u){return u.includes('/graphql/')&&(isLikes?u.toLowerCase().includes('like'):u.includes('Bookmark'));}
window.fetch=async function(){
var r=await origFetch.apply(this,arguments);
try{
var u=arguments[0] instanceof Request?arguments[0].url:String(arguments[0]);
if(u.includes('/graphql/')){var d=await r.clone().json();processData(d);}
if(isRelevantUrl(u)){var d=await r.clone().json();processData(d);}
}catch(ex){}
return r;
};
var origOpen=XMLHttpRequest.prototype.open,origSend=XMLHttpRequest.prototype.send,xhrUrls=new WeakMap();
XMLHttpRequest.prototype.open=function(){xhrUrls.set(this,String(arguments[1]||''));return origOpen.apply(this,arguments);};
XMLHttpRequest.prototype.send=function(){
var xhr=this,u=xhrUrls.get(xhr)||'';
if(u.includes('/graphql/')){xhr.addEventListener('load',function(){try{processData(JSON.parse(xhr.responseText));}catch(ex){}});}
if(isRelevantUrl(u)){xhr.addEventListener('load',function(){try{processData(JSON.parse(xhr.responseText));}catch(ex){}});}
return origSend.apply(this,arguments);
};
showToast('\u2705 Active! Scroll your '+label+' \u2014 counter updates above.','#1e1b4b');
Expand All @@ -222,7 +232,16 @@ const CONSOLE_SCRIPT = `(async function() {
function addTweet(t) {
if (!t?.rest_id || seen.has(t.rest_id)) return;
seen.add(t.rest_id);
const leg = t.legacy ?? {}, usr = t.core?.user_results?.result?.legacy ?? {};
const leg = t.legacy ?? {};
const res = t.core?.user_results?.result;
const usrLeg = res?.legacy ?? {};
const usrCore = res?.core ?? {};
const usrAvatar = res?.avatar ?? {};
const usr = {
name: usrCore.name ?? usrLeg.name ?? 'Unknown',
screen_name: usrCore.screen_name ?? usrLeg.screen_name ?? 'unknown',
profile_image_url_https: usrAvatar.image_url ?? usrLeg.profile_image_url_https ?? '',
};
const media = (leg.extended_entities?.media ?? leg.entities?.media ?? []).map(m => {
const thumb = m.media_url_https ?? '';
if (m.type === 'video' || m.type === 'animated_gif') {
Expand Down Expand Up @@ -336,14 +355,13 @@ const CONSOLE_SCRIPT = `(async function() {
autoBtn.style.background = '#4f46e5'; autoBtn.style.color = '#fff'; autoBtn.style.border = 'none';
runAutoScroll();
};
document.body.appendChild(btn);
document.body.appendChild(autoBtn);
const isRelevantUrl = (u) => u.includes('/graphql/') && (isLikes ? u.toLowerCase().includes('like') : u.includes('Bookmark'));
const origFetch = window.fetch;
window.fetch = async function(...args) {
const r = await origFetch.apply(this, args);
try {
const u = args[0] instanceof Request ? args[0].url : String(args[0]);
if (u.includes('/graphql/')) {
if (isRelevantUrl(u)) {
const d = await r.clone().json();
processData(d);
}
Expand All @@ -359,13 +377,15 @@ const CONSOLE_SCRIPT = `(async function() {
};
XMLHttpRequest.prototype.send = function(...args) {
const xhr = this, u = xhrUrls.get(xhr) ?? '';
if (u.includes('/graphql/')) {
if (isRelevantUrl(u)) {
xhr.addEventListener('load', function() {
try { processData(JSON.parse(xhr.responseText)); } catch(e) {}
});
}
return origSend.apply(this, args);
};
document.body.appendChild(btn);
document.body.appendChild(autoBtn);
console.log(\`✅ Script active. Scroll through your \${label}, then click the purple button.\`);
})();`

Expand Down
Loading