""" Notes management for StarPunk This module provides CRUD operations for notes with atomic file+database synchronization. All write operations use database transactions to ensure files and database records stay in sync. Functions: create_note: Create new note with file and database entry get_note: Retrieve note by slug or ID list_notes: List notes with filtering and pagination update_note: Update note content and/or metadata delete_note: Delete note (soft or hard delete) Exceptions: NoteNotFoundError: Note does not exist InvalidNoteDataError: Invalid content or parameters NoteSyncError: File/database synchronization failure NoteError: Base exception for all note operations """ # Standard library imports from datetime import datetime from pathlib import Path from typing import Optional # Third-party imports from flask import current_app # Local imports from starpunk.database import get_db from starpunk.models import Note from starpunk.utils import ( generate_slug, make_slug_unique, generate_note_path, ensure_note_directory, write_note_file, delete_note_file, calculate_content_hash, validate_note_path, validate_slug, ) # Custom Exceptions class NoteError(Exception): """Base exception for note operations""" pass class NoteNotFoundError(NoteError): """ Raised when a note cannot be found This exception is raised when attempting to retrieve, update, or delete a note that doesn't exist in the database. Attributes: identifier: The slug or ID used to search for the note message: Human-readable error message """ def __init__(self, identifier: str | int, message: Optional[str] = None): self.identifier = identifier if message is None: message = f"Note not found: {identifier}" super().__init__(message) class InvalidNoteDataError(NoteError, ValueError): """ Raised when note data is invalid This exception is raised when attempting to create or update a note with invalid data (empty content, invalid slug, etc.) Attributes: field: The field that failed validation value: The invalid value message: Human-readable error message """ def __init__(self, field: str, value: any, message: Optional[str] = None): self.field = field self.value = value if message is None: message = f"Invalid {field}: {value}" super().__init__(message) class NoteSyncError(NoteError): """ Raised when file/database synchronization fails This exception is raised when a file operation and database operation cannot be kept in sync (e.g., file written but database insert failed). Attributes: operation: The operation that failed ('create', 'update', 'delete') details: Additional details about the failure message: Human-readable error message """ def __init__(self, operation: str, details: str, message: Optional[str] = None): self.operation = operation self.details = details if message is None: message = f"Sync error during {operation}: {details}" super().__init__(message) # Helper Functions def _get_existing_slugs(db) -> set[str]: """ Query all existing slugs from database Args: db: Database connection Returns: Set of existing slug strings """ rows = db.execute("SELECT slug FROM notes").fetchall() return {row["slug"] for row in rows} # Core CRUD Functions def create_note( content: str, published: bool = False, created_at: Optional[datetime] = None ) -> Note: """ Create a new note Creates a new note by generating a unique slug, writing the markdown content to a file, and inserting a database record. File and database operations are atomic - if either fails, both are rolled back. Args: content: Markdown content for the note (must not be empty) published: Whether the note should be published (default: False) created_at: Creation timestamp (default: current UTC time) Returns: Note object with all metadata and content loaded Raises: InvalidNoteDataError: If content is empty or whitespace-only NoteSyncError: If file write succeeds but database insert fails OSError: If file cannot be written (permissions, disk full, etc.) ValueError: If configuration is missing or invalid Examples: >>> # Create unpublished draft >>> note = create_note("# My First Note\\n\\nContent here.", published=False) >>> print(note.slug) 'my-first-note' >>> # Create published note >>> note = create_note( ... "Just published this!", ... published=True ... ) >>> print(note.published) True >>> # Create with specific timestamp >>> from datetime import datetime >>> note = create_note( ... "Backdated note", ... created_at=datetime(2024, 1, 1, 12, 0, 0) ... ) Transaction Safety: 1. Validates content (before any changes) 2. Generates unique slug (database query) 3. Writes file to disk 4. Begins database transaction 5. Inserts database record 6. If database fails: deletes file, raises NoteSyncError 7. If successful: commits transaction, returns Note Notes: - Slug is generated from first 5 words of content - Random suffix added if slug already exists - File path follows pattern: data/notes/YYYY/MM/slug.md - Content hash calculated and stored for integrity checking - created_at and updated_at set to same value initially """ # 1. VALIDATION (before any changes) if not content or not content.strip(): raise InvalidNoteDataError( "content", content, "Content cannot be empty or whitespace-only" ) # 2. SETUP if created_at is None: created_at = datetime.utcnow() updated_at = created_at # Same as created_at for new notes data_dir = Path(current_app.config["DATA_PATH"]) # 3. GENERATE UNIQUE SLUG # Query all existing slugs from database db = get_db(current_app) existing_slugs = _get_existing_slugs(db) # Generate base slug from content base_slug = generate_slug(content, created_at) # Make unique if collision slug = make_slug_unique(base_slug, existing_slugs) # Validate final slug (defensive check) if not validate_slug(slug): raise InvalidNoteDataError("slug", slug, f"Generated slug is invalid: {slug}") # 4. GENERATE FILE PATH note_path = generate_note_path(slug, created_at, data_dir) # Security: Validate path stays within data directory if not validate_note_path(note_path, data_dir): raise NoteSyncError( "create", f"Generated path outside data directory: {note_path}", "Path validation failed", ) # 5. CALCULATE CONTENT HASH content_hash = calculate_content_hash(content) # 6. WRITE FILE (before database to fail fast on disk issues) try: ensure_note_directory(note_path) write_note_file(note_path, content) except OSError as e: # File write failed, nothing to clean up raise NoteSyncError( "create", f"Failed to write file: {e}", f"Could not write note file: {note_path}", ) # 7. INSERT DATABASE RECORD (transaction starts here) file_path_rel = str(note_path.relative_to(data_dir)) try: db.execute( """ INSERT INTO notes (slug, file_path, published, created_at, updated_at, content_hash) VALUES (?, ?, ?, ?, ?, ?) """, (slug, file_path_rel, published, created_at, updated_at, content_hash), ) db.commit() except Exception as e: # Database insert failed, delete the file we created try: note_path.unlink() except OSError: # Log warning but don't fail - file cleanup is best effort current_app.logger.warning( f"Failed to clean up file after DB error: {note_path}" ) # Raise sync error raise NoteSyncError( "create", f"Database insert failed: {e}", f"Failed to create note: {slug}" ) # 8. RETRIEVE AND RETURN NOTE OBJECT # Get the auto-generated ID note_id = db.execute("SELECT last_insert_rowid()").fetchone()[0] # Fetch the complete record row = db.execute("SELECT * FROM notes WHERE id = ?", (note_id,)).fetchone() # Create Note object note = Note.from_row(row, data_dir) return note def get_note( slug: Optional[str] = None, id: Optional[int] = None, load_content: bool = True ) -> Optional[Note]: """ Get a note by slug or ID Retrieves note metadata from database and optionally loads content from file. Exactly one of slug or id must be provided. Args: slug: Note slug (unique identifier in URLs) id: Note database ID (primary key) load_content: Whether to load file content (default: True) Returns: Note object with metadata and optionally content, or None if not found Raises: ValueError: If both slug and id provided, or neither provided OSError: If file cannot be read (when load_content=True) FileNotFoundError: If note file doesn't exist (when load_content=True) Examples: >>> # Get by slug >>> note = get_note(slug="my-first-note") >>> if note: ... print(note.content) # Content loaded ... else: ... print("Note not found") >>> # Get by ID >>> note = get_note(id=42) >>> # Get metadata only (no file I/O) >>> note = get_note(slug="my-note", load_content=False) >>> print(note.slug) # Works >>> print(note.content) # Will trigger file load on access >>> # Check if note exists >>> if get_note(slug="maybe-exists"): ... print("Note exists") Performance: - Metadata retrieval: Single database query, <1ms - Content loading: File I/O, typically <5ms for normal notes - Use load_content=False for list operations to avoid file I/O Notes: - Returns None if note not found (does not raise exception) - Content hash verification is optional (logs warning if mismatch) - Note.content property will lazy-load if load_content=False - Soft-deleted notes (deleted_at != NULL) are excluded """ # 1. VALIDATE PARAMETERS if slug is None and id is None: raise ValueError("Must provide either slug or id") if slug is not None and id is not None: raise ValueError("Cannot provide both slug and id") # 2. QUERY DATABASE db = get_db(current_app) if slug is not None: # Query by slug row = db.execute( "SELECT * FROM notes WHERE slug = ? AND deleted_at IS NULL", (slug,) ).fetchone() else: # Query by ID row = db.execute( "SELECT * FROM notes WHERE id = ? AND deleted_at IS NULL", (id,) ).fetchone() # 3. CHECK IF FOUND if row is None: return None # 4. CREATE NOTE OBJECT data_dir = Path(current_app.config["DATA_PATH"]) note = Note.from_row(row, data_dir) # 5. OPTIONALLY LOAD CONTENT if load_content: # Access content property to trigger load try: _ = note.content except (FileNotFoundError, OSError) as e: current_app.logger.warning( f"Failed to load content for note {note.slug}: {e}" ) # 6. OPTIONALLY VERIFY INTEGRITY # This is a passive check - log warning but don't fail if load_content and note.content_hash: try: if not note.verify_integrity(): current_app.logger.warning( f"Content hash mismatch for note {note.slug}. " f"File may have been modified externally." ) except Exception as e: current_app.logger.warning( f"Failed to verify integrity for note {note.slug}: {e}" ) # 7. RETURN NOTE return note def list_notes( published_only: bool = False, limit: int = 50, offset: int = 0, order_by: str = "created_at", order_dir: str = "DESC", ) -> list[Note]: """ List notes with filtering and pagination Retrieves notes from database with optional filtering by published status, sorting, and pagination. Does not load file content for performance - use note.content to lazy-load when needed. Args: published_only: If True, only return published notes (default: False) limit: Maximum number of notes to return (default: 50, max: 1000) offset: Number of notes to skip for pagination (default: 0) order_by: Field to sort by (default: 'created_at') order_dir: Sort direction, 'ASC' or 'DESC' (default: 'DESC') Returns: List of Note objects with metadata only (content not loaded) Raises: ValueError: If order_by is not a valid column name (SQL injection prevention) ValueError: If order_dir is not 'ASC' or 'DESC' ValueError: If limit exceeds maximum allowed value Examples: >>> # List recent published notes >>> notes = list_notes(published_only=True, limit=10) >>> for note in notes: ... print(note.slug, note.created_at) >>> # List all notes, oldest first >>> notes = list_notes(order_dir='ASC') >>> # Pagination (page 2, 20 per page) >>> notes = list_notes(limit=20, offset=20) >>> # List by update time >>> notes = list_notes(order_by='updated_at') Performance: - Single database query - No file I/O (content not loaded) - Efficient for large result sets with pagination - Typical query time: <10ms for 1000s of notes Pagination Example: >>> page = 1 >>> per_page = 20 >>> notes = list_notes( ... published_only=True, ... limit=per_page, ... offset=(page - 1) * per_page ... ) Notes: - Excludes soft-deleted notes (deleted_at IS NULL) - Content is lazy-loaded when accessed via note.content - order_by values are validated to prevent SQL injection - Default sort is newest first (created_at DESC) """ # 1. VALIDATE PARAMETERS # Prevent SQL injection - validate order_by column ALLOWED_ORDER_FIELDS = ["id", "slug", "created_at", "updated_at", "published"] if order_by not in ALLOWED_ORDER_FIELDS: raise ValueError( f"Invalid order_by field: {order_by}. " f"Allowed: {', '.join(ALLOWED_ORDER_FIELDS)}" ) # Validate order direction order_dir = order_dir.upper() if order_dir not in ["ASC", "DESC"]: raise ValueError(f"Invalid order_dir: {order_dir}. Must be 'ASC' or 'DESC'") # Validate limit (prevent excessive queries) MAX_LIMIT = 1000 if limit > MAX_LIMIT: raise ValueError(f"Limit {limit} exceeds maximum {MAX_LIMIT}") if limit < 1: raise ValueError("Limit must be >= 1") if offset < 0: raise ValueError("Offset must be >= 0") # 2. BUILD QUERY # Start with base query query = "SELECT * FROM notes WHERE deleted_at IS NULL" # Add filters params = [] if published_only: query += " AND published = 1" # Add ordering (safe because order_by validated above) query += f" ORDER BY {order_by} {order_dir}" # Add pagination query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) # 3. EXECUTE QUERY db = get_db(current_app) rows = db.execute(query, params).fetchall() # 4. CREATE NOTE OBJECTS (without loading content) data_dir = Path(current_app.config["DATA_PATH"]) notes = [Note.from_row(row, data_dir) for row in rows] return notes def update_note( slug: Optional[str] = None, id: Optional[int] = None, content: Optional[str] = None, published: Optional[bool] = None, ) -> Note: """ Update a note's content and/or published status Updates note content and/or metadata, maintaining atomic synchronization between file and database. At least one of content or published must be provided. Args: slug: Note slug to update (mutually exclusive with id) id: Note ID to update (mutually exclusive with slug) content: New markdown content (None = no change) published: New published status (None = no change) Returns: Updated Note object with new content and metadata Raises: ValueError: If both slug and id provided, or neither provided ValueError: If neither content nor published provided (no changes) NoteNotFoundError: If note doesn't exist InvalidNoteDataError: If content is empty/whitespace (when provided) NoteSyncError: If file update succeeds but database update fails OSError: If file cannot be written Examples: >>> # Update content only >>> note = update_note( ... slug="my-note", ... content="# Updated content\\n\\nNew text here." ... ) >>> # Publish a draft >>> note = update_note(slug="draft-note", published=True) >>> # Update both content and status >>> note = update_note( ... id=42, ... content="New content", ... published=True ... ) >>> # Unpublish a note >>> note = update_note(slug="old-post", published=False) Transaction Safety: 1. Validates parameters 2. Retrieves existing note from database 3. If content changed: writes new file (old file preserved) 4. Begins database transaction 5. Updates database record 6. If database fails: log error, raise NoteSyncError 7. If successful: commits transaction, returns updated Note Notes: - Slug cannot be changed (use delete + create for that) - updated_at is automatically set to current time - Content hash recalculated if content changes - File is overwritten atomically (temp file + rename) - Old file content is lost (no backup by default) """ # 1. VALIDATE PARAMETERS if slug is None and id is None: raise ValueError("Must provide either slug or id") if slug is not None and id is not None: raise ValueError("Cannot provide both slug and id") if content is None and published is None: raise ValueError("Must provide at least one of content or published to update") # Validate content if provided if content is not None: if not content or not content.strip(): raise InvalidNoteDataError( "content", content, "Content cannot be empty or whitespace-only" ) # 2. GET EXISTING NOTE existing_note = get_note(slug=slug, id=id, load_content=False) if existing_note is None: identifier = slug if slug is not None else id raise NoteNotFoundError(identifier) # 3. SETUP updated_at = datetime.utcnow() data_dir = Path(current_app.config["DATA_PATH"]) note_path = data_dir / existing_note.file_path # Validate path (security check) if not validate_note_path(note_path, data_dir): raise NoteSyncError( "update", f"Note file path outside data directory: {note_path}", "Path validation failed", ) # 4. UPDATE FILE (if content changed) new_content_hash = existing_note.content_hash if content is not None: try: # Write new content atomically write_note_file(note_path, content) # Calculate new hash new_content_hash = calculate_content_hash(content) except OSError as e: raise NoteSyncError( "update", f"Failed to write file: {e}", f"Could not update note file: {note_path}", ) # 5. UPDATE DATABASE db = get_db(current_app) # Build update query based on what changed update_fields = ["updated_at = ?"] params = [updated_at] if content is not None: update_fields.append("content_hash = ?") params.append(new_content_hash) if published is not None: update_fields.append("published = ?") params.append(published) # Add WHERE clause parameter if slug is not None: where_clause = "slug = ?" params.append(slug) else: where_clause = "id = ?" params.append(id) query = f"UPDATE notes SET {', '.join(update_fields)} WHERE {where_clause}" try: db.execute(query, params) db.commit() except Exception as e: # Database update failed # File has been updated, but we can't roll that back easily # Log error and raise current_app.logger.error( f"Database update failed for note {existing_note.slug}: {e}" ) raise NoteSyncError( "update", f"Database update failed: {e}", f"Failed to update note: {existing_note.slug}", ) # 6. RETURN UPDATED NOTE updated_note = get_note(slug=existing_note.slug, load_content=True) return updated_note def delete_note( slug: Optional[str] = None, id: Optional[int] = None, soft: bool = True ) -> None: """ Delete a note (soft or hard delete) Deletes a note either by marking it as deleted (soft delete) or by permanently removing the file and database record (hard delete). Args: slug: Note slug to delete (mutually exclusive with id) id: Note ID to delete (mutually exclusive with id) soft: If True, soft delete (mark deleted_at); if False, hard delete (default: True) Returns: None Raises: ValueError: If both slug and id provided, or neither provided NoteSyncError: If file deletion succeeds but database update fails OSError: If file cannot be deleted Examples: >>> # Soft delete (default) >>> delete_note(slug="old-note") >>> # Note marked as deleted, file remains >>> # Hard delete >>> delete_note(slug="spam-note", soft=False) >>> # Note and file permanently removed >>> # Delete by ID >>> delete_note(id=42, soft=False) Soft Delete: - Sets deleted_at timestamp in database - File remains on disk (optionally moved to .trash/) - Note excluded from normal queries (deleted_at IS NULL) - Can be undeleted by clearing deleted_at (future feature) Hard Delete: - Removes database record permanently - Deletes file from disk - Cannot be recovered - Use for spam, test data, or confirmed deletions Transaction Safety: Soft delete: 1. Updates database (sets deleted_at) 2. Optionally moves file to .trash/ 3. If move fails: log warning but succeed (database is source of truth) Hard delete: 1. Deletes database record 2. Deletes file from disk 3. If file delete fails: log warning but succeed (record already gone) Notes: - Soft delete is default and recommended - Hard delete is permanent and cannot be undone - Missing files during hard delete are not errors (idempotent) - Deleting already-deleted note returns successfully (idempotent) """ # 1. VALIDATE PARAMETERS if slug is None and id is None: raise ValueError("Must provide either slug or id") if slug is not None and id is not None: raise ValueError("Cannot provide both slug and id") # 2. GET EXISTING NOTE # For soft delete, exclude already soft-deleted notes # For hard delete, get note even if soft-deleted if soft: existing_note = get_note(slug=slug, id=id, load_content=False) else: # Hard delete: query including soft-deleted notes db = get_db(current_app) if slug is not None: row = db.execute("SELECT * FROM notes WHERE slug = ?", (slug,)).fetchone() else: row = db.execute("SELECT * FROM notes WHERE id = ?", (id,)).fetchone() if row is None: existing_note = None else: data_dir = Path(current_app.config["DATA_PATH"]) existing_note = Note.from_row(row, data_dir) # 3. CHECK IF NOTE EXISTS if existing_note is None: # Note not found - could already be deleted # For idempotency, don't raise error - just return return # 4. SETUP data_dir = Path(current_app.config["DATA_PATH"]) note_path = data_dir / existing_note.file_path # Validate path (security check) if not validate_note_path(note_path, data_dir): raise NoteSyncError( "delete", f"Note file path outside data directory: {note_path}", "Path validation failed", ) # 5. PERFORM DELETION db = get_db(current_app) if soft: # SOFT DELETE: Mark as deleted in database deleted_at = datetime.utcnow() try: db.execute( "UPDATE notes SET deleted_at = ? WHERE id = ?", (deleted_at, existing_note.id), ) db.commit() except Exception as e: raise NoteSyncError( "delete", f"Database update failed: {e}", f"Failed to soft delete note: {existing_note.slug}", ) # Optionally move file to trash (best effort) # This is optional and failure is not critical try: delete_note_file(note_path, soft=True, data_dir=data_dir) except Exception as e: current_app.logger.warning( f"Failed to move file to trash for note {existing_note.slug}: {e}" ) # Don't fail - database update succeeded else: # HARD DELETE: Remove from database and filesystem try: db.execute("DELETE FROM notes WHERE id = ?", (existing_note.id,)) db.commit() except Exception as e: raise NoteSyncError( "delete", f"Database delete failed: {e}", f"Failed to delete note: {existing_note.slug}", ) # Delete file (best effort) try: delete_note_file(note_path, soft=False) except FileNotFoundError: # File already gone - that's fine current_app.logger.info( f"File already deleted for note {existing_note.slug}" ) except Exception as e: current_app.logger.warning( f"Failed to delete file for note {existing_note.slug}: {e}" ) # Don't fail - database record already deleted # 6. RETURN (no value) return None