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:
334
services/brain/app/api/taxonomy.py
Normal file
334
services/brain/app/api/taxonomy.py
Normal 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}
|
||||
Reference in New Issue
Block a user