that initial commit

This commit is contained in:
2025-11-18 19:21:31 -07:00
commit a68fd570c7
69 changed files with 31070 additions and 0 deletions

866
starpunk/notes.py Normal file
View File

@@ -0,0 +1,866 @@
"""
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(f"Limit must be >= 1")
if offset < 0:
raise ValueError(f"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