Wothmag07's picture
Doc-MCP Application
1e6a9db
"""HTTP API routes for note operations."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query
from ...models.note import Note, NoteSummary, NoteUpdate, NoteCreate
from ...services.database import DatabaseService
from ...services.indexer import IndexerService
from ...services.vault import VaultService
from ..middleware import AuthContext, get_auth_context
router = APIRouter()
class ConflictError(Exception):
"""Raised when optimistic concurrency check fails."""
def __init__(self, message: str = "Version conflict detected"):
self.message = message
super().__init__(self.message)
@router.get("/api/notes", response_model=list[NoteSummary])
async def list_notes(
folder: Optional[str] = Query(None, description="Optional folder filter"),
auth: AuthContext = Depends(get_auth_context),
):
"""List all notes in the vault."""
user_id = auth.user_id
vault_service = VaultService()
try:
notes = vault_service.list_notes(user_id, folder=folder)
summaries = []
for note in notes:
# list_notes returns {path, title, last_modified}
updated = note.get("last_modified")
if not isinstance(updated, datetime):
updated = datetime.now()
summaries.append(
NoteSummary(
note_path=note["path"],
title=note["title"],
updated=updated,
)
)
return summaries
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list notes: {str(e)}")
@router.post("/api/notes", response_model=Note, status_code=201)
async def create_note(create: NoteCreate, auth: AuthContext = Depends(get_auth_context)):
"""Create a new note."""
user_id = auth.user_id
vault_service = VaultService()
indexer_service = IndexerService()
db_service = DatabaseService()
try:
note_path = create.note_path
# Check if note already exists
try:
vault_service.read_note(user_id, note_path)
raise HTTPException(status_code=409, detail=f"Note already exists: {note_path}")
except FileNotFoundError:
pass # Good, note doesn't exist
# Prepare metadata
metadata = create.metadata.model_dump() if create.metadata else {}
if create.title:
metadata["title"] = create.title
# Write note to vault
written_note = vault_service.write_note(
user_id,
note_path,
body=create.body,
metadata=metadata,
title=create.title
)
# Index the note
new_version = indexer_service.index_note(user_id, written_note)
# Update index health
conn = db_service.connect()
try:
with conn:
indexer_service.update_index_health(conn, user_id)
finally:
conn.close()
# Return created note
created = written_note["metadata"].get("created")
updated_ts = written_note["metadata"].get("updated")
if isinstance(created, str):
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
elif not isinstance(created, datetime):
created = datetime.now()
if isinstance(updated_ts, str):
updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00"))
elif not isinstance(updated_ts, datetime):
updated_ts = created
return Note(
user_id=user_id,
note_path=note_path,
version=new_version,
title=written_note["title"],
metadata=written_note["metadata"],
body=written_note["body"],
created=created,
updated=updated_ts,
size_bytes=written_note.get("size_bytes", len(written_note["body"].encode("utf-8"))),
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create note: {str(e)}")
@router.get("/api/notes/{path:path}", response_model=Note)
async def get_note(path: str, auth: AuthContext = Depends(get_auth_context)):
"""Get a specific note by path."""
user_id = auth.user_id
vault_service = VaultService()
db_service = DatabaseService()
try:
# URL decode the path
note_path = unquote(path)
# Read note from vault
note_data = vault_service.read_note(user_id, note_path)
# Get version from index
conn = db_service.connect()
try:
cursor = conn.execute(
"SELECT version FROM note_metadata WHERE user_id = ? AND note_path = ?",
(user_id, note_path),
)
row = cursor.fetchone()
version = row["version"] if row else 1
finally:
conn.close()
# Parse metadata
metadata = note_data.get("metadata", {})
created = metadata.get("created")
updated = metadata.get("updated")
if isinstance(created, str):
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
elif not isinstance(created, datetime):
created = datetime.now()
if isinstance(updated, str):
updated = datetime.fromisoformat(updated.replace("Z", "+00:00"))
elif not isinstance(updated, datetime):
updated = created
return Note(
user_id=user_id,
note_path=note_path,
version=version,
title=note_data["title"],
metadata=metadata,
body=note_data["body"],
created=created,
updated=updated,
size_bytes=note_data.get("size_bytes", len(note_data["body"].encode("utf-8"))),
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Note not found: {path}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read note: {str(e)}")
@router.put("/api/notes/{path:path}", response_model=Note)
async def update_note(
path: str,
update: NoteUpdate,
auth: AuthContext = Depends(get_auth_context),
):
"""Update a note with optimistic concurrency control."""
user_id = auth.user_id
vault_service = VaultService()
indexer_service = IndexerService()
db_service = DatabaseService()
try:
# URL decode the path
note_path = unquote(path)
# Check version if provided
if update.if_version is not None:
conn = db_service.connect()
try:
cursor = conn.execute(
"SELECT version FROM note_metadata WHERE user_id = ? AND note_path = ?",
(user_id, note_path),
)
row = cursor.fetchone()
current_version = row["version"] if row else 0
if current_version != update.if_version:
raise ConflictError(
f"Version conflict: expected {update.if_version}, got {current_version}"
)
finally:
conn.close()
# Prepare metadata
metadata = update.metadata.model_dump() if update.metadata else {}
if update.title:
metadata["title"] = update.title
# Write note to vault
written_note = vault_service.write_note(
user_id,
note_path,
body=update.body,
metadata=metadata,
title=update.title
)
# Index the note
new_version = indexer_service.index_note(user_id, written_note)
# Update index health
conn = db_service.connect()
try:
with conn:
indexer_service.update_index_health(conn, user_id)
finally:
conn.close()
# Return updated note
created = written_note["metadata"].get("created")
updated_ts = written_note["metadata"].get("updated")
if isinstance(created, str):
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
elif not isinstance(created, datetime):
created = datetime.now()
if isinstance(updated_ts, str):
updated_ts = datetime.fromisoformat(updated_ts.replace("Z", "+00:00"))
elif not isinstance(updated_ts, datetime):
updated_ts = created
return Note(
user_id=user_id,
note_path=note_path,
version=new_version,
title=written_note["title"],
metadata=written_note["metadata"],
body=written_note["body"],
created=created,
updated=updated_ts,
size_bytes=written_note.get("size_bytes", len(written_note["body"].encode("utf-8"))),
)
except ConflictError as e:
raise HTTPException(status_code=409, detail=str(e))
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Note not found: {path}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update note: {str(e)}")
__all__ = ["router", "ConflictError"]