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>
104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""Database models for folders and tags — editable taxonomy."""
|
|
|
|
import re
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Index, UniqueConstraint
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.database import Base
|
|
|
|
|
|
def new_id():
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
def slugify(text: str) -> str:
|
|
s = text.lower().strip()
|
|
s = re.sub(r'[^\w\s-]', '', s)
|
|
s = re.sub(r'[\s_]+', '-', s)
|
|
return s.strip('-')
|
|
|
|
|
|
class Folder(Base):
|
|
__tablename__ = "folders"
|
|
|
|
id = Column(UUID(as_uuid=False), primary_key=True, default=new_id)
|
|
user_id = Column(String(64), nullable=False, index=True)
|
|
name = Column(String(128), nullable=False)
|
|
slug = Column(String(128), nullable=False)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
sort_order = Column(Integer, default=0, nullable=False)
|
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint('user_id', 'slug', name='uq_folder_user_slug'),
|
|
)
|
|
|
|
|
|
class Tag(Base):
|
|
__tablename__ = "tags"
|
|
|
|
id = Column(UUID(as_uuid=False), primary_key=True, default=new_id)
|
|
user_id = Column(String(64), nullable=False, index=True)
|
|
name = Column(String(128), nullable=False)
|
|
slug = Column(String(128), nullable=False)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
sort_order = Column(Integer, default=0, nullable=False)
|
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint('user_id', 'slug', name='uq_tag_user_slug'),
|
|
)
|
|
|
|
|
|
class ItemTag(Base):
|
|
__tablename__ = "item_tags"
|
|
|
|
item_id = Column(UUID(as_uuid=False), ForeignKey("items.id", ondelete="CASCADE"), primary_key=True)
|
|
tag_id = Column(UUID(as_uuid=False), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
|
|
|
|
|
# Default folders to seed for new users
|
|
DEFAULT_FOLDERS = ["Home", "Family", "Work", "Travel", "Knowledge", "Faith", "Projects"]
|
|
|
|
# Default tags to seed for new users
|
|
DEFAULT_TAGS = [
|
|
"reference", "important", "legal", "financial", "insurance",
|
|
"research", "idea", "guide", "tutorial", "setup", "how-to",
|
|
"tools", "dev", "server", "selfhosted", "home-assistant",
|
|
"shopping", "compare", "buy", "product",
|
|
"family", "kids", "health", "travel", "faith",
|
|
"video", "read-later", "books",
|
|
]
|
|
|
|
|
|
async def ensure_user_taxonomy(db, user_id: str):
|
|
"""Seed default folders and tags for a new user if they have none."""
|
|
from sqlalchemy import select, func
|
|
|
|
folder_count = (await db.execute(
|
|
select(func.count()).where(Folder.user_id == user_id)
|
|
)).scalar() or 0
|
|
|
|
if folder_count == 0:
|
|
for i, name in enumerate(DEFAULT_FOLDERS):
|
|
db.add(Folder(id=new_id(), user_id=user_id, name=name, slug=slugify(name), sort_order=i))
|
|
await db.flush()
|
|
|
|
tag_count = (await db.execute(
|
|
select(func.count()).where(Tag.user_id == user_id)
|
|
)).scalar() or 0
|
|
|
|
if tag_count == 0:
|
|
for i, name in enumerate(DEFAULT_TAGS):
|
|
db.add(Tag(id=new_id(), user_id=user_id, name=name, slug=slugify(name), sort_order=i))
|
|
await db.flush()
|
|
|
|
await db.commit()
|