Spaces:
Runtime error
Runtime error
| from typing import List, Optional | |
| from fastapi import APIRouter, HTTPException, Query | |
| from loguru import logger | |
| from api.models import ( | |
| NotebookCreate, | |
| NotebookDeletePreview, | |
| NotebookDeleteResponse, | |
| NotebookResponse, | |
| NotebookUpdate, | |
| ) | |
| from open_notebook.database.repository import ensure_record_id, repo_query | |
| from open_notebook.domain.notebook import Notebook, Source | |
| from open_notebook.exceptions import InvalidInputError | |
| router = APIRouter() | |
| async def get_notebooks( | |
| archived: Optional[bool] = Query(None, description="Filter by archived status"), | |
| order_by: str = Query("updated desc", description="Order by field and direction"), | |
| ): | |
| """Get all notebooks with optional filtering and ordering.""" | |
| try: | |
| # Validate order_by against allowlist to prevent SurrealQL injection | |
| allowed_fields = {"name", "created", "updated"} | |
| allowed_directions = {"asc", "desc"} | |
| parts = order_by.strip().lower().split() | |
| if len(parts) == 1: | |
| if parts[0] not in allowed_fields: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Invalid order_by field: '{order_by}'. Allowed fields: {', '.join(sorted(allowed_fields))}", | |
| ) | |
| validated_order_by = parts[0] | |
| elif len(parts) == 2: | |
| if parts[0] not in allowed_fields or parts[1] not in allowed_directions: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Invalid order_by: '{order_by}'. Allowed fields: {', '.join(sorted(allowed_fields))}. Allowed directions: asc, desc", | |
| ) | |
| validated_order_by = f"{parts[0]} {parts[1]}" | |
| else: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Invalid order_by format: '{order_by}'. Expected 'field' or 'field direction'", | |
| ) | |
| # Build the query with counts | |
| query = f""" | |
| SELECT *, | |
| count(<-reference.in) as source_count, | |
| count(<-artifact.in) as note_count | |
| FROM notebook | |
| ORDER BY {validated_order_by} | |
| """ | |
| result = await repo_query(query) | |
| # Filter by archived status if specified | |
| if archived is not None: | |
| result = [nb for nb in result if nb.get("archived") == archived] | |
| return [ | |
| NotebookResponse( | |
| id=str(nb.get("id", "")), | |
| name=nb.get("name", ""), | |
| description=nb.get("description", ""), | |
| archived=nb.get("archived", False), | |
| created=str(nb.get("created", "")), | |
| updated=str(nb.get("updated", "")), | |
| source_count=nb.get("source_count", 0), | |
| note_count=nb.get("note_count", 0), | |
| ) | |
| for nb in result | |
| ] | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error fetching notebooks: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, detail=f"Error fetching notebooks: {str(e)}" | |
| ) | |
| async def create_notebook(notebook: NotebookCreate): | |
| """Create a new notebook.""" | |
| try: | |
| new_notebook = Notebook( | |
| name=notebook.name, | |
| description=notebook.description, | |
| ) | |
| await new_notebook.save() | |
| return NotebookResponse( | |
| id=new_notebook.id or "", | |
| name=new_notebook.name, | |
| description=new_notebook.description, | |
| archived=new_notebook.archived or False, | |
| created=str(new_notebook.created), | |
| updated=str(new_notebook.updated), | |
| source_count=0, # New notebook has no sources | |
| note_count=0, # New notebook has no notes | |
| ) | |
| except InvalidInputError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| logger.error(f"Error creating notebook: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, detail=f"Error creating notebook: {str(e)}" | |
| ) | |
| async def get_notebook_delete_preview(notebook_id: str): | |
| """Get a preview of what will be deleted when this notebook is deleted.""" | |
| try: | |
| notebook = await Notebook.get(notebook_id) | |
| if not notebook: | |
| raise HTTPException(status_code=404, detail="Notebook not found") | |
| preview = await notebook.get_delete_preview() | |
| return NotebookDeletePreview( | |
| notebook_id=str(notebook.id), | |
| notebook_name=notebook.name, | |
| note_count=preview["note_count"], | |
| exclusive_source_count=preview["exclusive_source_count"], | |
| shared_source_count=preview["shared_source_count"], | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error getting delete preview for notebook {notebook_id}: {e}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Error fetching notebook deletion preview: {str(e)}", | |
| ) | |
| async def get_notebook(notebook_id: str): | |
| """Get a specific notebook by ID.""" | |
| try: | |
| # Query with counts for single notebook | |
| query = """ | |
| SELECT *, | |
| count(<-reference.in) as source_count, | |
| count(<-artifact.in) as note_count | |
| FROM $notebook_id | |
| """ | |
| result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)}) | |
| if not result: | |
| raise HTTPException(status_code=404, detail="Notebook not found") | |
| nb = result[0] | |
| return NotebookResponse( | |
| id=str(nb.get("id", "")), | |
| name=nb.get("name", ""), | |
| description=nb.get("description", ""), | |
| archived=nb.get("archived", False), | |
| created=str(nb.get("created", "")), | |
| updated=str(nb.get("updated", "")), | |
| source_count=nb.get("source_count", 0), | |
| note_count=nb.get("note_count", 0), | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error fetching notebook {notebook_id}: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, detail=f"Error fetching notebook: {str(e)}" | |
| ) | |
| async def update_notebook(notebook_id: str, notebook_update: NotebookUpdate): | |
| """Update a notebook.""" | |
| try: | |
| notebook = await Notebook.get(notebook_id) | |
| if not notebook: | |
| raise HTTPException(status_code=404, detail="Notebook not found") | |
| # Update only provided fields | |
| if notebook_update.name is not None: | |
| notebook.name = notebook_update.name | |
| if notebook_update.description is not None: | |
| notebook.description = notebook_update.description | |
| if notebook_update.archived is not None: | |
| notebook.archived = notebook_update.archived | |
| await notebook.save() | |
| # Query with counts after update | |
| query = """ | |
| SELECT *, | |
| count(<-reference.in) as source_count, | |
| count(<-artifact.in) as note_count | |
| FROM $notebook_id | |
| """ | |
| result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)}) | |
| if result: | |
| nb = result[0] | |
| return NotebookResponse( | |
| id=str(nb.get("id", "")), | |
| name=nb.get("name", ""), | |
| description=nb.get("description", ""), | |
| archived=nb.get("archived", False), | |
| created=str(nb.get("created", "")), | |
| updated=str(nb.get("updated", "")), | |
| source_count=nb.get("source_count", 0), | |
| note_count=nb.get("note_count", 0), | |
| ) | |
| # Fallback if query fails | |
| return NotebookResponse( | |
| id=notebook.id or "", | |
| name=notebook.name, | |
| description=notebook.description, | |
| archived=notebook.archived or False, | |
| created=str(notebook.created), | |
| updated=str(notebook.updated), | |
| source_count=0, | |
| note_count=0, | |
| ) | |
| except HTTPException: | |
| raise | |
| except InvalidInputError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| logger.error(f"Error updating notebook {notebook_id}: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, detail=f"Error updating notebook: {str(e)}" | |
| ) | |
| async def add_source_to_notebook(notebook_id: str, source_id: str): | |
| """Add an existing source to a notebook (create the reference).""" | |
| try: | |
| # Check if notebook exists | |
| notebook = await Notebook.get(notebook_id) | |
| if not notebook: | |
| raise HTTPException(status_code=404, detail="Notebook not found") | |
| # Check if source exists | |
| source = await Source.get(source_id) | |
| if not source: | |
| raise HTTPException(status_code=404, detail="Source not found") | |
| # Check if reference already exists (idempotency) | |
| existing_ref = await repo_query( | |
| "SELECT * FROM reference WHERE out = $source_id AND in = $notebook_id", | |
| { | |
| "notebook_id": ensure_record_id(notebook_id), | |
| "source_id": ensure_record_id(source_id), | |
| }, | |
| ) | |
| # If reference doesn't exist, create it | |
| if not existing_ref: | |
| await repo_query( | |
| "RELATE $source_id->reference->$notebook_id", | |
| { | |
| "notebook_id": ensure_record_id(notebook_id), | |
| "source_id": ensure_record_id(source_id), | |
| }, | |
| ) | |
| return {"message": "Source linked to notebook successfully"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error( | |
| f"Error linking source {source_id} to notebook {notebook_id}: {str(e)}" | |
| ) | |
| raise HTTPException( | |
| status_code=500, detail=f"Error linking source to notebook: {str(e)}" | |
| ) | |
| async def remove_source_from_notebook(notebook_id: str, source_id: str): | |
| """Remove a source from a notebook (delete the reference).""" | |
| try: | |
| # Check if notebook exists | |
| notebook = await Notebook.get(notebook_id) | |
| if not notebook: | |
| raise HTTPException(status_code=404, detail="Notebook not found") | |
| # Delete the reference record linking source to notebook | |
| await repo_query( | |
| "DELETE FROM reference WHERE out = $notebook_id AND in = $source_id", | |
| { | |
| "notebook_id": ensure_record_id(notebook_id), | |
| "source_id": ensure_record_id(source_id), | |
| }, | |
| ) | |
| return {"message": "Source removed from notebook successfully"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error( | |
| f"Error removing source {source_id} from notebook {notebook_id}: {str(e)}" | |
| ) | |
| raise HTTPException( | |
| status_code=500, detail=f"Error removing source from notebook: {str(e)}" | |
| ) | |
| async def delete_notebook( | |
| notebook_id: str, | |
| delete_exclusive_sources: bool = Query( | |
| False, | |
| description="Whether to delete sources that belong only to this notebook", | |
| ), | |
| ): | |
| """ | |
| Delete a notebook with cascade deletion. | |
| Always deletes all notes associated with the notebook. | |
| If delete_exclusive_sources is True, also deletes sources that belong only | |
| to this notebook (not linked to any other notebooks). | |
| """ | |
| try: | |
| notebook = await Notebook.get(notebook_id) | |
| if not notebook: | |
| raise HTTPException(status_code=404, detail="Notebook not found") | |
| result = await notebook.delete(delete_exclusive_sources=delete_exclusive_sources) | |
| return NotebookDeleteResponse( | |
| message="Notebook deleted successfully", | |
| deleted_notes=result["deleted_notes"], | |
| deleted_sources=result["deleted_sources"], | |
| unlinked_sources=result["unlinked_sources"], | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error deleting notebook {notebook_id}: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, detail=f"Error deleting notebook: {str(e)}" | |
| ) | |