feat: brain taxonomy — DB-backed folders/tags, sidebar, CRUD API

Backend:
- New Folder/Tag/ItemTag models with proper relational tables
- Taxonomy CRUD endpoints: list, create, rename, delete, merge tags
- Sidebar endpoint with folder/tag counts
- AI classification reads live folders/tags from DB, not hardcoded
- Default folders/tags seeded on first request per user
- folder_id FK on items for relational integrity

Frontend:
- Left sidebar with Folders/Tags tabs (like Karakeep)
- Click folder/tag to filter items
- "Manage" mode: add new folders/tags, delete existing
- Counts next to each folder/tag
- "All items" option to clear filter
- Replaces the old signal-strip cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 20:23:45 -05:00
parent 4805729f87
commit 68a8d4c228
7 changed files with 693 additions and 100 deletions

View File

@@ -0,0 +1,334 @@
"""Taxonomy API — folder and tag CRUD, sidebar data."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_user_id, get_db_session
from app.models.taxonomy import Folder, Tag, ItemTag, slugify, ensure_user_taxonomy
from app.models.item import Item
router = APIRouter(prefix="/api/taxonomy", tags=["taxonomy"])
# ── Schemas ──
class FolderIn(BaseModel):
name: str
class FolderOut(BaseModel):
id: str
name: str
slug: str
is_active: bool
sort_order: int
item_count: int = 0
model_config = {"from_attributes": True}
class TagIn(BaseModel):
name: str
class TagOut(BaseModel):
id: str
name: str
slug: str
is_active: bool
sort_order: int
item_count: int = 0
model_config = {"from_attributes": True}
class SidebarOut(BaseModel):
folders: list[FolderOut]
tags: list[TagOut]
total_items: int
class MergeTagIn(BaseModel):
source_tag_id: str
target_tag_id: str
# ── Sidebar data ──
@router.get("/sidebar", response_model=SidebarOut)
async def get_sidebar(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
await ensure_user_taxonomy(db, user_id)
# Folders with counts
folder_rows = (await db.execute(
select(Folder).where(Folder.user_id == user_id).order_by(Folder.sort_order, Folder.name)
)).scalars().all()
folder_counts = {}
for row in (await db.execute(
select(Item.folder_id, func.count()).where(
Item.user_id == user_id, Item.folder_id.isnot(None)
).group_by(Item.folder_id)
)).all():
folder_counts[row[0]] = row[1]
folders = [FolderOut(
id=f.id, name=f.name, slug=f.slug, is_active=f.is_active,
sort_order=f.sort_order, item_count=folder_counts.get(f.id, 0)
) for f in folder_rows]
# Tags with counts
tag_rows = (await db.execute(
select(Tag).where(Tag.user_id == user_id).order_by(Tag.sort_order, Tag.name)
)).scalars().all()
tag_counts = {}
for row in (await db.execute(
select(ItemTag.tag_id, func.count()).join(Item, Item.id == ItemTag.item_id).where(
Item.user_id == user_id
).group_by(ItemTag.tag_id)
)).all():
tag_counts[row[0]] = row[1]
tags = [TagOut(
id=t.id, name=t.name, slug=t.slug, is_active=t.is_active,
sort_order=t.sort_order, item_count=tag_counts.get(t.id, 0)
) for t in tag_rows]
# Total items
total = (await db.execute(
select(func.count()).where(Item.user_id == user_id)
)).scalar() or 0
return SidebarOut(folders=folders, tags=tags, total_items=total)
# ── Folder CRUD ──
@router.get("/folders", response_model=list[FolderOut])
async def list_folders(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
await ensure_user_taxonomy(db, user_id)
rows = (await db.execute(
select(Folder).where(Folder.user_id == user_id).order_by(Folder.sort_order, Folder.name)
)).scalars().all()
return [FolderOut(id=f.id, name=f.name, slug=f.slug, is_active=f.is_active, sort_order=f.sort_order) for f in rows]
@router.post("/folders", response_model=FolderOut, status_code=201)
async def create_folder(
body: FolderIn,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
slug = slugify(body.name)
existing = (await db.execute(
select(Folder).where(Folder.user_id == user_id, Folder.slug == slug)
)).scalar_one_or_none()
if existing:
raise HTTPException(400, "Folder already exists")
max_order = (await db.execute(
select(func.max(Folder.sort_order)).where(Folder.user_id == user_id)
)).scalar() or 0
folder = Folder(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug, sort_order=max_order + 1)
db.add(folder)
await db.commit()
return FolderOut(id=folder.id, name=folder.name, slug=folder.slug, is_active=folder.is_active, sort_order=folder.sort_order)
@router.patch("/folders/{folder_id}", response_model=FolderOut)
async def update_folder(
folder_id: str,
body: FolderIn,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
folder = (await db.execute(
select(Folder).where(Folder.id == folder_id, Folder.user_id == user_id)
)).scalar_one_or_none()
if not folder:
raise HTTPException(404, "Folder not found")
folder.name = body.name.strip()
folder.slug = slugify(body.name)
folder.updated_at = datetime.utcnow()
# Update denormalized folder name on items
await db.execute(
update(Item).where(Item.folder_id == folder_id).values(folder=folder.name)
)
await db.commit()
return FolderOut(id=folder.id, name=folder.name, slug=folder.slug, is_active=folder.is_active, sort_order=folder.sort_order)
@router.delete("/folders/{folder_id}")
async def delete_folder(
folder_id: str,
fallback_folder_id: Optional[str] = Query(None),
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
folder = (await db.execute(
select(Folder).where(Folder.id == folder_id, Folder.user_id == user_id)
)).scalar_one_or_none()
if not folder:
raise HTTPException(404, "Folder not found")
# Move items to fallback folder or first available folder
if fallback_folder_id:
fallback = (await db.execute(select(Folder).where(Folder.id == fallback_folder_id))).scalar_one_or_none()
else:
fallback = (await db.execute(
select(Folder).where(Folder.user_id == user_id, Folder.id != folder_id).order_by(Folder.sort_order).limit(1)
)).scalar_one_or_none()
if fallback:
await db.execute(
update(Item).where(Item.folder_id == folder_id).values(folder_id=fallback.id, folder=fallback.name)
)
await db.execute(delete(Folder).where(Folder.id == folder_id))
await db.commit()
return {"status": "deleted", "items_moved_to": fallback.name if fallback else None}
# ── Tag CRUD ──
@router.get("/tags", response_model=list[TagOut])
async def list_tags(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
await ensure_user_taxonomy(db, user_id)
rows = (await db.execute(
select(Tag).where(Tag.user_id == user_id).order_by(Tag.sort_order, Tag.name)
)).scalars().all()
return [TagOut(id=t.id, name=t.name, slug=t.slug, is_active=t.is_active, sort_order=t.sort_order) for t in rows]
@router.post("/tags", response_model=TagOut, status_code=201)
async def create_tag(
body: TagIn,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
slug = slugify(body.name)
existing = (await db.execute(
select(Tag).where(Tag.user_id == user_id, Tag.slug == slug)
)).scalar_one_or_none()
if existing:
raise HTTPException(400, "Tag already exists")
max_order = (await db.execute(
select(func.max(Tag.sort_order)).where(Tag.user_id == user_id)
)).scalar() or 0
tag = Tag(id=str(uuid.uuid4()), user_id=user_id, name=body.name.strip(), slug=slug, sort_order=max_order + 1)
db.add(tag)
await db.commit()
return TagOut(id=tag.id, name=tag.name, slug=tag.slug, is_active=tag.is_active, sort_order=tag.sort_order)
@router.patch("/tags/{tag_id}", response_model=TagOut)
async def update_tag(
tag_id: str,
body: TagIn,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
tag = (await db.execute(
select(Tag).where(Tag.id == tag_id, Tag.user_id == user_id)
)).scalar_one_or_none()
if not tag:
raise HTTPException(404, "Tag not found")
old_name = tag.name
tag.name = body.name.strip()
tag.slug = slugify(body.name)
tag.updated_at = datetime.utcnow()
# Update denormalized tags array on items that had the old name
items_with_tag = (await db.execute(
select(Item).join(ItemTag, ItemTag.item_id == Item.id).where(ItemTag.tag_id == tag_id)
)).scalars().all()
for item in items_with_tag:
if item.tags and old_name in item.tags:
item.tags = [tag.name if t == old_name else t for t in item.tags]
await db.commit()
return TagOut(id=tag.id, name=tag.name, slug=tag.slug, is_active=tag.is_active, sort_order=tag.sort_order)
@router.delete("/tags/{tag_id}")
async def delete_tag(
tag_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
tag = (await db.execute(
select(Tag).where(Tag.id == tag_id, Tag.user_id == user_id)
)).scalar_one_or_none()
if not tag:
raise HTTPException(404, "Tag not found")
# Remove tag from denormalized arrays
items_with_tag = (await db.execute(
select(Item).join(ItemTag, ItemTag.item_id == Item.id).where(ItemTag.tag_id == tag_id)
)).scalars().all()
for item in items_with_tag:
if item.tags and tag.name in item.tags:
item.tags = [t for t in item.tags if t != tag.name]
# Delete join table entries and tag
await db.execute(delete(ItemTag).where(ItemTag.tag_id == tag_id))
await db.execute(delete(Tag).where(Tag.id == tag_id))
await db.commit()
return {"status": "deleted"}
@router.post("/tags/merge")
async def merge_tags(
body: MergeTagIn,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Merge source tag into target tag. All items with source get target instead."""
source = (await db.execute(select(Tag).where(Tag.id == body.source_tag_id, Tag.user_id == user_id))).scalar_one_or_none()
target = (await db.execute(select(Tag).where(Tag.id == body.target_tag_id, Tag.user_id == user_id))).scalar_one_or_none()
if not source or not target:
raise HTTPException(404, "Tag not found")
# Move item_tags from source to target (skip duplicates)
source_items = (await db.execute(
select(ItemTag.item_id).where(ItemTag.tag_id == source.id)
)).scalars().all()
target_items = set((await db.execute(
select(ItemTag.item_id).where(ItemTag.tag_id == target.id)
)).scalars().all())
for item_id in source_items:
if item_id not in target_items:
db.add(ItemTag(item_id=item_id, tag_id=target.id))
# Update denormalized tags
items = (await db.execute(
select(Item).join(ItemTag, ItemTag.item_id == Item.id).where(ItemTag.tag_id == source.id)
)).scalars().all()
for item in items:
if item.tags:
new_tags = [target.name if t == source.name else t for t in item.tags]
item.tags = list(dict.fromkeys(new_tags)) # dedupe
# Delete source
await db.execute(delete(ItemTag).where(ItemTag.tag_id == source.id))
await db.execute(delete(Tag).where(Tag.id == source.id))
await db.commit()
return {"status": "merged", "source": source.name, "target": target.name}