Add tree organization system with categories, tags, and folders

Features:
- Categories: Global and team-specific tree categorization (admin-managed)
- Tags: Flexible tree tagging with autocomplete (author + admin)
- User folders: Personal tree collections with subfolder support
  - Hierarchical structure (max 3 levels deep)
  - Right-click context menu for folder management
  - Cascade delete for subfolders
- Filter trees by category, tags, and folder in library view

Backend:
- New models: Category, Tag, UserFolder with relationships
- New API endpoints for categories, tags, and folders
- Tree organization migrations (005, 006)

Frontend:
- FolderSidebar with hierarchical folder tree
- FolderEditModal for create/edit with color picker
- AddToFolderMenu for quick tree organization
- TagInput with autocomplete and TagBadges display
- Updated TreeMetadataForm and TreeLibraryPage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-02 01:31:13 -05:00
parent 2d99c52025
commit fafdaa50a5
41 changed files with 5006 additions and 221 deletions

View File

@@ -2,10 +2,23 @@ from .user import UserCreate, UserUpdate, UserResponse, UserLogin
from .token import Token, TokenPayload
from .tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
from .session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, DecisionRecord
from .category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse
from .tag import TagCreate, TagResponse, TagListResponse, TagAssignment
from .folder import FolderCreate, FolderUpdate, FolderResponse, FolderListResponse, FolderReorderRequest, FolderTreeRequest
__all__ = [
# User
"UserCreate", "UserUpdate", "UserResponse", "UserLogin",
# Token
"Token", "TokenPayload",
# Tree
"TreeCreate", "TreeUpdate", "TreeResponse", "TreeListResponse",
"SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord"
# Session
"SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord",
# Category
"CategoryCreate", "CategoryUpdate", "CategoryResponse", "CategoryListResponse",
# Tag
"TagCreate", "TagResponse", "TagListResponse", "TagAssignment",
# Folder
"FolderCreate", "FolderUpdate", "FolderResponse", "FolderListResponse", "FolderReorderRequest", "FolderTreeRequest",
]

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
import re
def slugify(name: str) -> str:
"""Convert a name to a URL-safe slug."""
# Remove non-alphanumeric chars except spaces, convert to lowercase
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
# Replace spaces with hyphens
slug = re.sub(r' +', '-', slug.strip())
return slug
class CategoryBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
class CategoryCreate(CategoryBase):
team_id: Optional[UUID] = Field(None, description="Team ID for team-specific category. NULL for global.")
class CategoryUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
display_order: Optional[int] = None
is_active: Optional[bool] = None
class CategoryResponse(CategoryBase):
id: UUID
slug: str
team_id: Optional[UUID] = None
display_order: int
is_active: bool
created_at: datetime
updated_at: datetime
tree_count: int = 0 # Computed field
class Config:
from_attributes = True
class CategoryListResponse(BaseModel):
id: UUID
name: str
slug: str
description: Optional[str] = None
team_id: Optional[UUID] = None
display_order: int
is_active: bool
tree_count: int = 0
class Config:
from_attributes = True

View File

@@ -0,0 +1,62 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
import re
# Valid hex color pattern
HEX_COLOR_PATTERN = r'^#[0-9A-Fa-f]{6}$'
class FolderBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
color: str = Field("#6366f1", pattern=HEX_COLOR_PATTERN)
icon: str = Field("folder", max_length=50)
class FolderCreate(FolderBase):
parent_id: Optional[UUID] = Field(None, description="Parent folder ID for creating subfolders")
class FolderUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, pattern=HEX_COLOR_PATTERN)
icon: Optional[str] = Field(None, max_length=50)
display_order: Optional[int] = None
parent_id: Optional[UUID] = Field(None, description="Parent folder ID to move folder")
class FolderResponse(FolderBase):
id: UUID
parent_id: Optional[UUID] = None
display_order: int
tree_count: int = 0 # Computed field
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class FolderListResponse(BaseModel):
id: UUID
name: str
color: str
icon: str
parent_id: Optional[UUID] = None
display_order: int
tree_count: int = 0
class Config:
from_attributes = True
class FolderReorderRequest(BaseModel):
"""Request body for reordering folders."""
folder_ids: list[UUID] = Field(..., min_length=1, description="Folder IDs in desired order")
class FolderTreeRequest(BaseModel):
"""Request body for adding a tree to a folder."""
tree_id: UUID

View File

@@ -0,0 +1,56 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
import re
def slugify(name: str) -> str:
"""Convert a tag name to a URL-safe slug."""
# Remove non-alphanumeric chars except spaces, convert to lowercase
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
# Replace spaces with hyphens
slug = re.sub(r' +', '-', slug.strip())
return slug
class TagBase(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
class TagCreate(TagBase):
team_id: Optional[UUID] = Field(None, description="Team ID for team-specific tag. NULL for global.")
class TagResponse(TagBase):
id: UUID
slug: str
team_id: Optional[UUID] = None
usage_count: int
created_at: datetime
class Config:
from_attributes = True
class TagListResponse(BaseModel):
id: UUID
name: str
slug: str
team_id: Optional[UUID] = None
usage_count: int
class Config:
from_attributes = True
class TagAssignment(BaseModel):
"""Request body for adding/removing tags from a tree."""
tags: list[str] = Field(..., min_length=1, max_length=10, description="List of tag names to assign")
class TagSearchParams(BaseModel):
"""Query parameters for tag search/autocomplete."""
q: str = Field(..., min_length=1, description="Search query")
limit: int = Field(10, ge=1, le=50)
include_team: bool = Field(True, description="Include team-specific tags")

View File

@@ -4,9 +4,20 @@ from uuid import UUID
from pydantic import BaseModel, Field
class CategoryInfo(BaseModel):
"""Embedded category info for tree responses."""
id: UUID
name: str
slug: str
class Config:
from_attributes = True
class TreeBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
# Legacy category field - kept for backward compatibility
category: Optional[str] = Field(None, max_length=100)
@@ -14,15 +25,19 @@ class TreeCreate(TreeBase):
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
is_public: bool = Field(False, description="Make tree visible to all users")
is_default: bool = Field(False, description="Mark as a default/system tree (admin only)")
category_id: Optional[UUID] = Field(None, description="Category ID from tree_categories table")
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign")
class TreeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
category_id: Optional[UUID] = None
tree_structure: Optional[dict[str, Any]] = None
is_public: Optional[bool] = None
is_active: Optional[bool] = None
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign (replaces existing)")
class TreeResponse(TreeBase):
@@ -30,6 +45,9 @@ class TreeResponse(TreeBase):
tree_structure: dict[str, Any]
author_id: Optional[UUID] = None
team_id: Optional[UUID] = None
category_id: Optional[UUID] = None
category_info: Optional[CategoryInfo] = None
tags: list[str] = [] # List of tag names
is_active: bool
is_public: bool
is_default: bool
@@ -47,6 +65,10 @@ class TreeListResponse(BaseModel):
name: str
description: Optional[str] = None
category: Optional[str] = None
category_id: Optional[UUID] = None
category_info: Optional[CategoryInfo] = None
tags: list[str] = [] # List of tag names
author_id: Optional[UUID] = None
is_active: bool
is_public: bool
is_default: bool

View File

@@ -28,6 +28,7 @@ class UserLogin(BaseModel):
class UserResponse(UserBase):
id: UUID
role: str
is_team_admin: bool = False
team_id: Optional[UUID] = None
created_at: datetime
last_login: Optional[datetime] = None