feat(tags): Add database schema and tags module (v1.3.0 Phase 1)

Implements tag/category system backend following microformats2 p-category specification.

Database changes:
- Migration 008: Add tags and note_tags tables
- Normalized tag storage (case-insensitive lookup, display name preserved)
- Indexes for performance

New module:
- starpunk/tags.py: Tag management functions
  - normalize_tag: Normalize tag strings
  - get_or_create_tag: Get or create tag records
  - add_tags_to_note: Associate tags with notes (replaces existing)
  - get_note_tags: Retrieve note tags (alphabetically ordered)
  - get_tag_by_name: Lookup tag by normalized name
  - get_notes_by_tag: Get all notes with specific tag
  - parse_tag_input: Parse comma-separated tag input

Model updates:
- Note.tags property (lazy-loaded, prefer pre-loading in routes)
- Note.to_dict() add include_tags parameter

CRUD updates:
- create_note() accepts tags parameter
- update_note() accepts tags parameter (None = no change, [] = remove all)

Micropub integration:
- Pass tags to create_note() (tags already extracted by extract_tags())
- Return tags in q=source response

Per design doc: docs/design/v1.3.0/microformats-tags-design.md

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-10 11:24:23 -07:00
parent 927db4aea0
commit f10d0679da
188 changed files with 601 additions and 945 deletions

View File

@@ -134,7 +134,11 @@ def _get_existing_slugs(db) -> set[str]:
def create_note(
content: str, published: bool = False, created_at: Optional[datetime] = None, custom_slug: Optional[str] = None
content: str,
published: bool = False,
created_at: Optional[datetime] = None,
custom_slug: Optional[str] = None,
tags: Optional[list[str]] = None
) -> Note:
"""
Create a new note
@@ -148,6 +152,7 @@ def create_note(
published: Whether the note should be published (default: False)
created_at: Creation timestamp (default: current UTC time)
custom_slug: Optional custom slug (from Micropub mp-slug property)
tags: Optional list of tag display names (v1.3.0)
Returns:
Note object with all metadata and content loaded
@@ -294,7 +299,16 @@ def create_note(
# Create Note object
note = Note.from_row(row, data_dir)
# 9. UPDATE FTS INDEX (if available)
# 9. ADD TAGS (v1.3.0)
if tags:
try:
from starpunk.tags import add_tags_to_note
add_tags_to_note(note_id, tags)
except Exception as e:
# Tag addition failure should not prevent note creation
current_app.logger.warning(f"Failed to add tags to note {slug}: {e}")
# 10. UPDATE FTS INDEX (if available)
try:
from starpunk.search import update_fts_index, has_fts_table
db_path = Path(current_app.config["DATABASE_PATH"])
@@ -540,6 +554,7 @@ def update_note(
id: Optional[int] = None,
content: Optional[str] = None,
published: Optional[bool] = None,
tags: Optional[list[str]] = None
) -> Note:
"""
Update a note's content and/or published status
@@ -553,6 +568,7 @@ def update_note(
id: Note ID to update (mutually exclusive with slug)
content: New markdown content (None = no change)
published: New published status (None = no change)
tags: New tags list (None = no change, [] = remove all tags) (v1.3.0)
Returns:
Updated Note object with new content and metadata
@@ -608,8 +624,8 @@ def update_note(
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")
if content is None and published is None and tags is None:
raise ValueError("Must provide at least one of content, published, or tags to update")
# Validate content if provided
if content is not None:
@@ -695,7 +711,16 @@ def update_note(
f"Failed to update note: {existing_note.slug}",
)
# 6. UPDATE FTS INDEX (if available and content changed)
# 6. UPDATE TAGS (v1.3.0)
if tags is not None:
try:
from starpunk.tags import add_tags_to_note
add_tags_to_note(existing_note.id, tags)
except Exception as e:
# Tag update failure should not prevent note update
current_app.logger.warning(f"Failed to update tags for note {existing_note.slug}: {e}")
# 7. UPDATE FTS INDEX (if available and content changed)
if content is not None:
try:
from starpunk.search import update_fts_index, has_fts_table
@@ -707,7 +732,7 @@ def update_note(
# FTS update failure should not prevent note update
current_app.logger.warning(f"Failed to update FTS index for note {existing_note.slug}: {e}")
# 7. RETURN UPDATED NOTE
# 8. RETURN UPDATED NOTE
updated_note = get_note(slug=existing_note.slug, load_content=True)
return updated_note