"""Folder management.""" from __future__ import annotations from fastapi import APIRouter, Depends, Form, HTTPException, Response, status from sqlalchemy.orm import Session as DBSession from lembas.api.deps import Db, RequiredUser, require_permission from lembas.db.models import Folder # Every route here manages folders, so the guard belongs on the router. router = APIRouter( prefix="/api/folders", tags=["folders"], dependencies=[Depends(require_permission("folder.manage"))], ) MAX_DEPTH = 8 def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder: folder = db.get(Folder, folder_id) if folder is None or folder.user_id != user_id: raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.") return folder def _depth_of(db: DBSession, folder: Folder | None) -> int: depth = 0 seen: set[str] = set() while folder is not None and folder.id not in seen: seen.add(folder.id) depth += 1 folder = db.get(Folder, folder.parent_id) if folder.parent_id else None return depth def _refresh_sidebar() -> Response: """Tell the browser to reload so the tree re-renders. The folder tree is recursive and a change can move any part of it, so re-rendering the whole sidebar server-side is both simpler and less error-prone than trying to patch individual nodes over the wire. """ response = Response(status_code=status.HTTP_204_NO_CONTENT) response.headers["HX-Refresh"] = "true" return response @router.post("") async def create_folder( db: Db, user: RequiredUser, name: str = Form("New folder"), parent_id: str = Form(""), ) -> Response: parent = _owned_folder(db, parent_id, user.id) if parent_id else None # A cap on nesting, so a runaway client cannot build a tree deep enough to # blow the recursion limit in the template. if parent is not None and _depth_of(db, parent) >= MAX_DEPTH: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Folders cannot be nested more than {MAX_DEPTH} deep.", ) db.add( Folder( user_id=user.id, name=name.strip()[:200] or "New folder", parent_id=parent.id if parent else None, ) ) db.commit() return _refresh_sidebar() @router.patch("/{folder_id}") async def update_folder( db: Db, user: RequiredUser, folder_id: str, name: str | None = Form(None), parent_id: str | None = Form(None), collapsed: bool | None = Form(None), ) -> Response: folder = _owned_folder(db, folder_id, user.id) if name is not None and name.strip(): folder.name = name.strip()[:200] if parent_id is not None: new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None # Reparenting a folder into its own subtree would detach that subtree # from the root and make it unreachable. cursor = new_parent while cursor is not None: if cursor.id == folder.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, "A folder cannot be moved inside itself.", ) cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None folder.parent_id = new_parent.id if new_parent else None if collapsed is not None: folder.collapsed = collapsed db.commit() return _refresh_sidebar() @router.delete("/{folder_id}") async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response: """Delete a folder. Child folders go with it; chats do not. Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because losing a conversation to a mis-clicked folder delete is unforgivable. """ folder = _owned_folder(db, folder_id, user.id) db.delete(folder) db.commit() return _refresh_sidebar()