Files
platform/services/brain/app/api/taxonomy.py
Yusuf Suleman 3531360827 feat: brain colored folders/tags — color + icon fields, mobile pills
Backend:
- Added color (hex) and icon (lucide name) columns to folders and tags
- Default folders seeded with colors: Home=green, Work=indigo, Travel=blue, etc.
- API returns color/icon in sidebar and CRUD responses
- Create/update endpoints accept color and icon

Frontend:
- Mobile: horizontal scrollable pill tabs with colored dots
- Desktop sidebar: colored dots next to folder names
- Active pill gets tinted border matching folder color

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:32:40 -05:00

349 lines
12 KiB
Python

"""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
color: Optional[str] = None
icon: Optional[str] = None
class FolderOut(BaseModel):
id: str
name: str
slug: str
color: Optional[str] = None
icon: Optional[str] = None
is_active: bool
sort_order: int
item_count: int = 0
model_config = {"from_attributes": True}
class TagIn(BaseModel):
name: str
color: Optional[str] = None
icon: Optional[str] = None
class TagOut(BaseModel):
id: str
name: str
slug: str
color: Optional[str] = None
icon: Optional[str] = None
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, color=f.color, icon=f.icon,
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, color=t.color, icon=t.icon,
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, color=f.color, icon=f.icon, 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,
color=body.color, icon=body.icon, 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)
if body.color is not None: folder.color = body.color
if body.icon is not None: folder.icon = body.icon
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, color=t.color, icon=t.icon, 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,
color=body.color, icon=body.icon, 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)
if body.color is not None: tag.color = body.color
if body.icon is not None: tag.icon = body.icon
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}