617 lines
15 KiB
Markdown
617 lines
15 KiB
Markdown
# Phase 2.1: Notes Management - Quick Reference
|
|
|
|
## Overview
|
|
|
|
Quick reference guide for implementing Phase 2.1: Notes Management (CRUD operations) in StarPunk.
|
|
|
|
**File**: `starpunk/notes.py`
|
|
**Estimated Time**: 6-8 hours
|
|
**Dependencies**: `utils.py`, `models.py`, `database.py`
|
|
|
|
---
|
|
|
|
## Function Checklist
|
|
|
|
### Required Functions
|
|
|
|
- [ ] **create_note(content, published=False, created_at=None) -> Note**
|
|
- Generate unique slug
|
|
- Write file atomically
|
|
- Insert database record
|
|
- Return Note object
|
|
|
|
- [ ] **get_note(slug=None, id=None, load_content=True) -> Optional[Note]**
|
|
- Query database by slug or id
|
|
- Load content from file if requested
|
|
- Return Note or None
|
|
|
|
- [ ] **list_notes(published_only=False, limit=50, offset=0, order_by='created_at', order_dir='DESC') -> list[Note]**
|
|
- Query database with filters
|
|
- Support pagination
|
|
- No file I/O (metadata only)
|
|
- Return list of Notes
|
|
|
|
- [ ] **update_note(slug=None, id=None, content=None, published=None) -> Note**
|
|
- Update file if content changed
|
|
- Update database record
|
|
- Return updated Note
|
|
|
|
- [ ] **delete_note(slug=None, id=None, soft=True) -> None**
|
|
- Soft delete: mark deleted_at in database
|
|
- Hard delete: remove file and database record
|
|
- Return None
|
|
|
|
### Custom Exceptions
|
|
|
|
- [ ] **NoteNotFoundError(Exception)**
|
|
- Raised when note doesn't exist
|
|
|
|
- [ ] **InvalidNoteDataError(Exception)**
|
|
- Raised for invalid content/parameters
|
|
|
|
- [ ] **NoteSyncError(Exception)**
|
|
- Raised when file/database sync fails
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
### Step 1: Module Setup (15 minutes)
|
|
|
|
```python
|
|
# starpunk/notes.py
|
|
"""Notes management for StarPunk"""
|
|
|
|
# Imports
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from flask import current_app
|
|
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, read_note_file,
|
|
delete_note_file, calculate_content_hash,
|
|
validate_note_path, validate_slug
|
|
)
|
|
|
|
# Exception classes (define all 3)
|
|
```
|
|
|
|
**Time**: 15 minutes
|
|
|
|
### Step 2: create_note() (90 minutes)
|
|
|
|
**Algorithm**:
|
|
1. Validate content not empty
|
|
2. Set created_at to now if not provided
|
|
3. Query existing slugs from database
|
|
4. Generate unique slug
|
|
5. Generate file path
|
|
6. Validate path (security)
|
|
7. Calculate content hash
|
|
8. Write file (ensure_note_directory + write_note_file)
|
|
9. Insert database record
|
|
10. If DB fails: delete file, raise NoteSyncError
|
|
11. If success: commit, return Note object
|
|
|
|
**Testing**:
|
|
- Create basic note
|
|
- Create with empty content (should fail)
|
|
- Create with duplicate slug (should add suffix)
|
|
- Create with specific timestamp
|
|
- File write fails (should not create DB record)
|
|
|
|
**Time**: 90 minutes (45 min implementation + 45 min testing)
|
|
|
|
### Step 3: get_note() (45 minutes)
|
|
|
|
**Algorithm**:
|
|
1. Validate parameters (exactly one of slug or id)
|
|
2. Query database
|
|
3. Return None if not found
|
|
4. Create Note.from_row()
|
|
5. Optionally verify integrity (log warning if mismatch)
|
|
6. Return Note
|
|
|
|
**Testing**:
|
|
- Get by slug
|
|
- Get by id
|
|
- Get nonexistent (returns None)
|
|
- Get with both parameters (should fail)
|
|
- Get without loading content
|
|
|
|
**Time**: 45 minutes (25 min implementation + 20 min testing)
|
|
|
|
### Step 4: list_notes() (60 minutes)
|
|
|
|
**Algorithm**:
|
|
1. Validate order_by (whitelist check)
|
|
2. Validate order_dir (ASC/DESC)
|
|
3. Validate limit (max 1000)
|
|
4. Build SQL query with filters
|
|
5. Add ORDER BY and LIMIT/OFFSET
|
|
6. Execute query
|
|
7. Create Note objects (don't load content)
|
|
8. Return list
|
|
|
|
**Testing**:
|
|
- List all notes
|
|
- List published only
|
|
- List with pagination
|
|
- List with different ordering
|
|
- Invalid order_by (should fail - SQL injection test)
|
|
|
|
**Time**: 60 minutes (35 min implementation + 25 min testing)
|
|
|
|
### Step 5: update_note() (90 minutes)
|
|
|
|
**Algorithm**:
|
|
1. Validate parameters
|
|
2. Get existing note (raises NoteNotFoundError if missing)
|
|
3. Validate content if provided
|
|
4. Setup paths and timestamps
|
|
5. If content changed: write new file, calculate new hash
|
|
6. Build UPDATE query for changed fields
|
|
7. Execute database update
|
|
8. If DB fails: log error, raise NoteSyncError
|
|
9. If success: commit, return updated Note
|
|
|
|
**Testing**:
|
|
- Update content only
|
|
- Update published only
|
|
- Update both
|
|
- Update nonexistent (should fail)
|
|
- Update with empty content (should fail)
|
|
- Update with no changes (should fail)
|
|
|
|
**Time**: 90 minutes (50 min implementation + 40 min testing)
|
|
|
|
### Step 6: delete_note() (60 minutes)
|
|
|
|
**Algorithm**:
|
|
1. Validate parameters
|
|
2. Get existing note (return if None - idempotent)
|
|
3. Validate path
|
|
4. If soft delete:
|
|
- UPDATE notes SET deleted_at = now WHERE id = ?
|
|
- Optionally move file to trash (best effort)
|
|
5. If hard delete:
|
|
- DELETE FROM notes WHERE id = ?
|
|
- Delete file (best effort)
|
|
6. Return None
|
|
|
|
**Testing**:
|
|
- Soft delete
|
|
- Hard delete
|
|
- Delete nonexistent (should succeed)
|
|
- Delete already deleted (should succeed)
|
|
|
|
**Time**: 60 minutes (35 min implementation + 25 min testing)
|
|
|
|
### Step 7: Integration Tests (60 minutes)
|
|
|
|
**Full CRUD cycle test**:
|
|
1. Create note
|
|
2. Retrieve note
|
|
3. Update content
|
|
4. Update published status
|
|
5. List notes (verify appears)
|
|
6. Delete note
|
|
7. Verify gone
|
|
|
|
**Sync tests**:
|
|
- Verify file exists after create
|
|
- Verify DB record exists after create
|
|
- Verify file updated after update
|
|
- Verify file deleted after hard delete
|
|
- Verify DB record deleted after hard delete
|
|
|
|
**Time**: 60 minutes
|
|
|
|
### Step 8: Documentation and Cleanup (30 minutes)
|
|
|
|
- Review all docstrings
|
|
- Format with Black
|
|
- Run flake8
|
|
- Check type hints
|
|
- Review error messages
|
|
|
|
**Time**: 30 minutes
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
### 1. Forgetting to Commit Transactions
|
|
|
|
```python
|
|
# BAD - no commit
|
|
db.execute("INSERT INTO notes ...")
|
|
|
|
# GOOD - explicit commit
|
|
db.execute("INSERT INTO notes ...")
|
|
db.commit()
|
|
```
|
|
|
|
### 2. Not Cleaning Up on Failure
|
|
|
|
```python
|
|
# BAD - orphaned file if DB fails
|
|
write_note_file(path, content)
|
|
db.execute("INSERT ...") # What if this fails?
|
|
|
|
# GOOD - cleanup on failure
|
|
write_note_file(path, content)
|
|
try:
|
|
db.execute("INSERT ...")
|
|
db.commit()
|
|
except Exception as e:
|
|
path.unlink() # Delete file we created
|
|
raise NoteSyncError(...)
|
|
```
|
|
|
|
### 3. SQL Injection in ORDER BY
|
|
|
|
```python
|
|
# BAD - SQL injection risk
|
|
order_by = request.args.get('order')
|
|
query = f"SELECT * FROM notes ORDER BY {order_by}"
|
|
|
|
# GOOD - whitelist validation
|
|
ALLOWED = ['id', 'slug', 'created_at', 'updated_at']
|
|
if order_by not in ALLOWED:
|
|
raise ValueError(f"Invalid order_by: {order_by}")
|
|
query = f"SELECT * FROM notes ORDER BY {order_by}"
|
|
```
|
|
|
|
### 4. Not Using Parameterized Queries
|
|
|
|
```python
|
|
# BAD - SQL injection risk
|
|
slug = request.args.get('slug')
|
|
query = f"SELECT * FROM notes WHERE slug = '{slug}'"
|
|
|
|
# GOOD - parameterized query
|
|
query = "SELECT * FROM notes WHERE slug = ?"
|
|
db.execute(query, (slug,))
|
|
```
|
|
|
|
### 5. Forgetting Path Validation
|
|
|
|
```python
|
|
# BAD - directory traversal risk
|
|
note_path = data_dir / file_path
|
|
write_note_file(note_path, content)
|
|
|
|
# GOOD - validate path
|
|
note_path = data_dir / file_path
|
|
if not validate_note_path(note_path, data_dir):
|
|
raise NoteSyncError(...)
|
|
write_note_file(note_path, content)
|
|
```
|
|
|
|
### 6. Not Handling None in Optional Parameters
|
|
|
|
```python
|
|
# BAD - will crash on None
|
|
if slug and id:
|
|
raise ValueError(...)
|
|
|
|
# GOOD - explicit None checks
|
|
if slug is None and id is None:
|
|
raise ValueError("Must provide slug or id")
|
|
if slug is not None and id is not None:
|
|
raise ValueError("Cannot provide both")
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Checklist
|
|
|
|
### Unit Tests
|
|
|
|
- [ ] create_note with valid content
|
|
- [ ] create_note with empty content (fail)
|
|
- [ ] create_note with duplicate slug (unique suffix)
|
|
- [ ] create_note with specific timestamp
|
|
- [ ] create_note with unicode content
|
|
- [ ] create_note file write failure (no DB record)
|
|
- [ ] get_note by slug
|
|
- [ ] get_note by id
|
|
- [ ] get_note nonexistent (returns None)
|
|
- [ ] get_note with invalid parameters
|
|
- [ ] get_note without loading content
|
|
- [ ] list_notes all
|
|
- [ ] list_notes published only
|
|
- [ ] list_notes with pagination
|
|
- [ ] list_notes with ordering
|
|
- [ ] list_notes with invalid order_by (fail)
|
|
- [ ] update_note content
|
|
- [ ] update_note published
|
|
- [ ] update_note both
|
|
- [ ] update_note nonexistent (fail)
|
|
- [ ] update_note empty content (fail)
|
|
- [ ] delete_note soft
|
|
- [ ] delete_note hard
|
|
- [ ] delete_note nonexistent (succeed)
|
|
|
|
### Integration Tests
|
|
|
|
- [ ] Full CRUD cycle (create → read → update → delete)
|
|
- [ ] File-database sync maintained throughout lifecycle
|
|
- [ ] Orphaned files cleaned up on DB failure
|
|
- [ ] Soft-deleted notes excluded from queries
|
|
- [ ] Hard-deleted notes removed from DB and filesystem
|
|
|
|
### Performance Tests
|
|
|
|
- [ ] list_notes with 1000 notes (< 10ms)
|
|
- [ ] get_note (< 10ms)
|
|
- [ ] create_note (< 20ms)
|
|
|
|
---
|
|
|
|
## Time Estimates
|
|
|
|
| Task | Time |
|
|
|------|------|
|
|
| Module setup | 15 min |
|
|
| create_note() | 90 min |
|
|
| get_note() | 45 min |
|
|
| list_notes() | 60 min |
|
|
| update_note() | 90 min |
|
|
| delete_note() | 60 min |
|
|
| Integration tests | 60 min |
|
|
| Documentation/cleanup | 30 min |
|
|
| **Total** | **7.5 hours** |
|
|
|
|
Add 30-60 minutes for unexpected issues and debugging.
|
|
|
|
---
|
|
|
|
## Key Design Decisions
|
|
|
|
### 1. File Operations Before Database
|
|
|
|
**Rationale**: Fail fast on disk issues before database changes.
|
|
|
|
**Pattern**:
|
|
```python
|
|
# Write file first
|
|
write_note_file(path, content)
|
|
|
|
# Then update database
|
|
db.execute("INSERT ...")
|
|
db.commit()
|
|
|
|
# If DB fails, cleanup file
|
|
```
|
|
|
|
### 2. Best-Effort File Cleanup
|
|
|
|
**Rationale**: Database is source of truth. Missing files can be recreated or cleaned up later.
|
|
|
|
**Pattern**:
|
|
```python
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
logger.warning("Cleanup failed")
|
|
# Don't fail - log and continue
|
|
```
|
|
|
|
### 3. Idempotent Delete
|
|
|
|
**Rationale**: DELETE operations should succeed even if already deleted.
|
|
|
|
**Pattern**:
|
|
```python
|
|
note = get_note(slug=slug)
|
|
if note is None:
|
|
return # Already deleted, that's fine
|
|
# ... proceed with delete
|
|
```
|
|
|
|
### 4. Lazy Content Loading
|
|
|
|
**Rationale**: list_notes() should not trigger file I/O for every note.
|
|
|
|
**Pattern**:
|
|
```python
|
|
# list_notes creates Notes without loading content
|
|
notes = [Note.from_row(row, data_dir) for row in rows]
|
|
|
|
# Content loaded on access
|
|
for note in notes:
|
|
print(note.slug) # Fast (metadata)
|
|
print(note.content) # Triggers file I/O
|
|
```
|
|
|
|
### 5. Parameterized Queries Only
|
|
|
|
**Rationale**: Prevent SQL injection.
|
|
|
|
**Pattern**:
|
|
```python
|
|
# Always use parameter binding
|
|
db.execute("SELECT * FROM notes WHERE slug = ?", (slug,))
|
|
|
|
# Never use string interpolation
|
|
db.execute(f"SELECT * FROM notes WHERE slug = '{slug}'") # NO!
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencies Reference
|
|
|
|
### From utils.py
|
|
|
|
```python
|
|
generate_slug(content, created_at) -> str
|
|
make_slug_unique(base_slug, existing_slugs) -> str
|
|
validate_slug(slug) -> bool
|
|
generate_note_path(slug, created_at, data_dir) -> Path
|
|
ensure_note_directory(note_path) -> Path
|
|
write_note_file(file_path, content) -> None
|
|
read_note_file(file_path) -> str
|
|
delete_note_file(file_path, soft=False, data_dir=None) -> None
|
|
calculate_content_hash(content) -> str
|
|
validate_note_path(file_path, data_dir) -> bool
|
|
```
|
|
|
|
### From models.py
|
|
|
|
```python
|
|
Note.from_row(row, data_dir) -> Note
|
|
Note.content -> str (property, lazy-loaded)
|
|
Note.to_dict(include_content=False) -> dict
|
|
Note.verify_integrity() -> bool
|
|
```
|
|
|
|
### From database.py
|
|
|
|
```python
|
|
get_db() -> sqlite3.Connection
|
|
db.execute(query, params) -> Cursor
|
|
db.commit() -> None
|
|
db.rollback() -> None
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling Quick Reference
|
|
|
|
### When to Raise vs Return None
|
|
|
|
| Scenario | Action |
|
|
|----------|--------|
|
|
| Note not found in get_note() | Return None |
|
|
| Note not found in update_note() | Raise NoteNotFoundError |
|
|
| Note not found in delete_note() | Return None (idempotent) |
|
|
| Empty content | Raise InvalidNoteDataError |
|
|
| File write fails | Raise NoteSyncError |
|
|
| Database fails | Raise NoteSyncError |
|
|
| Invalid parameters | Raise ValueError |
|
|
|
|
### Error Message Examples
|
|
|
|
```python
|
|
# NoteNotFoundError
|
|
raise NoteNotFoundError(
|
|
slug,
|
|
f"Note '{slug}' does not exist or has been deleted"
|
|
)
|
|
|
|
# InvalidNoteDataError
|
|
raise InvalidNoteDataError(
|
|
'content',
|
|
content,
|
|
"Note content cannot be empty. Please provide markdown content."
|
|
)
|
|
|
|
# NoteSyncError
|
|
raise NoteSyncError(
|
|
'create',
|
|
f"Database insert failed: {str(e)}",
|
|
f"Failed to create note. File written but database update failed."
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Security Checklist
|
|
|
|
- [ ] All SQL queries use parameterized binding (no string interpolation)
|
|
- [ ] order_by field validated against whitelist
|
|
- [ ] All file paths validated with validate_note_path()
|
|
- [ ] No symlinks followed in path operations
|
|
- [ ] Content validated (not empty)
|
|
- [ ] Slug validated before use in file paths
|
|
- [ ] No code execution from user content
|
|
|
|
---
|
|
|
|
## Final Checks
|
|
|
|
Before submitting Phase 2.1 as complete:
|
|
|
|
- [ ] All 5 functions implemented
|
|
- [ ] All 3 exceptions implemented
|
|
- [ ] Full type hints on all functions
|
|
- [ ] Comprehensive docstrings with examples
|
|
- [ ] Test coverage > 90%
|
|
- [ ] All tests passing
|
|
- [ ] Black formatting applied
|
|
- [ ] flake8 linting passes (no errors)
|
|
- [ ] Integration test passes (full CRUD cycle)
|
|
- [ ] No orphaned files in test runs
|
|
- [ ] No orphaned database records in test runs
|
|
- [ ] Error messages are clear and actionable
|
|
- [ ] Performance targets met:
|
|
- [ ] create_note < 20ms
|
|
- [ ] get_note < 10ms
|
|
- [ ] list_notes < 10ms
|
|
- [ ] update_note < 20ms
|
|
- [ ] delete_note < 10ms
|
|
|
|
---
|
|
|
|
## Quick Command Reference
|
|
|
|
```bash
|
|
# Run tests
|
|
pytest tests/test_notes.py -v
|
|
|
|
# Check coverage
|
|
pytest tests/test_notes.py --cov=starpunk.notes --cov-report=term-missing
|
|
|
|
# Format code
|
|
black starpunk/notes.py tests/test_notes.py
|
|
|
|
# Lint code
|
|
flake8 starpunk/notes.py --max-line-length=100
|
|
|
|
# Type check (optional)
|
|
mypy starpunk/notes.py
|
|
```
|
|
|
|
---
|
|
|
|
## What's Next?
|
|
|
|
After completing Phase 2.1:
|
|
|
|
**Phase 3: Authentication**
|
|
- IndieLogin OAuth flow
|
|
- Session management
|
|
- Admin access control
|
|
|
|
**Phase 4: Web Routes**
|
|
- Admin interface (create/edit/delete notes)
|
|
- Public note views
|
|
- Template rendering
|
|
|
|
**Phase 5: Micropub**
|
|
- Micropub endpoint
|
|
- Token validation
|
|
- IndieWeb API compliance
|
|
|
|
**Phase 6: RSS Feed**
|
|
- Feed generation
|
|
- RFC-822 date formatting
|
|
- Published notes only
|
|
|
|
---
|
|
|
|
## Help & Resources
|
|
|
|
- **Full Design Doc**: [phase-2.1-notes-management.md](/home/phil/Projects/starpunk/docs/design/phase-2.1-notes-management.md)
|
|
- **Utilities Design**: [phase-1.1-core-utilities.md](/home/phil/Projects/starpunk/docs/design/phase-1.1-core-utilities.md)
|
|
- **Models Design**: [phase-1.2-data-models.md](/home/phil/Projects/starpunk/docs/design/phase-1.2-data-models.md)
|
|
- **Coding Standards**: [python-coding-standards.md](/home/phil/Projects/starpunk/docs/standards/python-coding-standards.md)
|
|
- **Implementation Plan**: [implementation-plan.md](/home/phil/Projects/starpunk/docs/projectplan/v1/implementation-plan.md)
|
|
|
|
Remember: "Every line of code must justify its existence. When in doubt, leave it out."
|