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

@@ -121,6 +121,11 @@ class Note:
default=None, repr=False, compare=False, init=False
)
# Cached tags (loaded separately, not from database row)
_cached_tags: Optional[list[dict]] = field(
default=None, repr=False, compare=False, init=False
)
@classmethod
def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note":
"""
@@ -358,8 +363,27 @@ class Note:
"""
return self.published
@property
def tags(self) -> list[dict]:
"""
Get note tags (lazy-loaded, but prefer pre-loading in routes)
Routes should pre-load tags using:
object.__setattr__(note, '_cached_tags', tags)
This property exists as a fallback for lazy loading.
Returns:
List of tag dicts with 'name' and 'display_name'
"""
if self._cached_tags is None:
from starpunk.tags import get_note_tags
tags = get_note_tags(self.id)
object.__setattr__(self, "_cached_tags", tags)
return self._cached_tags
def to_dict(
self, include_content: bool = False, include_html: bool = False
self, include_content: bool = False, include_html: bool = False, include_tags: bool = False
) -> dict[str, Any]:
"""
Serialize note to dictionary
@@ -370,6 +394,7 @@ class Note:
Args:
include_content: Include markdown content in output
include_html: Include rendered HTML in output
include_tags: Include tags in output (v1.3.0)
Returns:
Dictionary with note data
@@ -410,6 +435,9 @@ class Note:
if include_html:
data["html"] = self.html
if include_tags:
data["tags"] = [tag["display_name"] for tag in self.tags]
return data
def verify_integrity(self) -> bool: