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>
349 lines
12 KiB
Python
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}
|