feat(tags): Add tag archive route and admin interface integration
Implement Phase 3 of v1.3.0 tags feature per microformats-tags-design.md: Routes (starpunk/routes/public.py): - Add /tag/<tag> archive route with normalization and 404 handling - Pre-load tags in index route for all notes - Pre-load tags in note route for individual notes Admin (starpunk/routes/admin.py): - Parse comma-separated tag input in create route - Parse tag input in update route - Pre-load tags when displaying edit form - Empty tag field removes all tags Templates: - Add tag input field to templates/admin/edit.html - Add tag input field to templates/admin/new.html - Use Jinja2 map filter to display existing tags Implementation details: - Tag URL parameter normalized to lowercase before lookup - Tags pre-loaded using object.__setattr__ pattern (like media) - parse_tag_input() handles trim, dedupe, normalization - All existing tests pass (micropub categories, admin routes) Per architect design: - No pagination on tag archives (acceptable for v1.3.0) - No autocomplete in admin (out of scope) - Follows existing media loading patterns Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,7 @@ def create_note_submit():
|
||||
custom_slug: Optional custom slug (v1.2.0 Phase 1)
|
||||
media_files: Multiple file upload (v1.2.0 Phase 3)
|
||||
captions[]: Captions for each media file (v1.2.0 Phase 3)
|
||||
tags: Comma-separated tag list (v1.3.0 Phase 3)
|
||||
|
||||
Returns:
|
||||
Redirect to dashboard on success, back to form on error
|
||||
@@ -85,21 +86,27 @@ def create_note_submit():
|
||||
Decorator: @require_auth
|
||||
"""
|
||||
from starpunk.media import save_media, attach_media_to_note
|
||||
from starpunk.tags import parse_tag_input
|
||||
|
||||
content = request.form.get("content", "").strip()
|
||||
published = "published" in request.form
|
||||
custom_slug = request.form.get("custom_slug", "").strip()
|
||||
tags_input = request.form.get("tags", "")
|
||||
|
||||
if not content:
|
||||
flash("Content cannot be empty", "error")
|
||||
return redirect(url_for("admin.new_note_form"))
|
||||
|
||||
# Parse tags (v1.3.0 Phase 3)
|
||||
tags = parse_tag_input(tags_input)
|
||||
|
||||
try:
|
||||
# Create note first (per Q4)
|
||||
note = create_note(
|
||||
content,
|
||||
published=published,
|
||||
custom_slug=custom_slug if custom_slug else None
|
||||
custom_slug=custom_slug if custom_slug else None,
|
||||
tags=tags if tags else None
|
||||
)
|
||||
|
||||
# Handle media uploads (v1.2.0 Phase 3)
|
||||
@@ -167,12 +174,18 @@ def edit_note_form(note_id: int):
|
||||
Decorator: @require_auth
|
||||
Template: templates/admin/edit.html
|
||||
"""
|
||||
from starpunk.tags import get_note_tags
|
||||
|
||||
note = get_note(id=note_id)
|
||||
|
||||
if not note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
|
||||
# Pre-load tags for the edit form (v1.3.0 Phase 3)
|
||||
tags = get_note_tags(note.id)
|
||||
object.__setattr__(note, '_cached_tags', tags)
|
||||
|
||||
return render_template("admin/edit.html", note=note)
|
||||
|
||||
|
||||
@@ -191,12 +204,15 @@ def update_note_submit(note_id: int):
|
||||
Form data:
|
||||
content: Updated markdown content (required)
|
||||
published: Checkbox for published status (optional)
|
||||
tags: Comma-separated tag list (v1.3.0 Phase 3)
|
||||
|
||||
Returns:
|
||||
Redirect to dashboard on success, back to form on error
|
||||
|
||||
Decorator: @require_auth
|
||||
"""
|
||||
from starpunk.tags import parse_tag_input
|
||||
|
||||
# Check if note exists first
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
if not existing_note:
|
||||
@@ -205,13 +221,22 @@ def update_note_submit(note_id: int):
|
||||
|
||||
content = request.form.get("content", "").strip()
|
||||
published = "published" in request.form
|
||||
tags_input = request.form.get("tags", "")
|
||||
|
||||
if not content:
|
||||
flash("Content cannot be empty", "error")
|
||||
return redirect(url_for("admin.edit_note_form", note_id=note_id))
|
||||
|
||||
# Parse tags (v1.3.0 Phase 3)
|
||||
tags = parse_tag_input(tags_input)
|
||||
|
||||
try:
|
||||
note = update_note(id=note_id, content=content, published=published)
|
||||
note = update_note(
|
||||
id=note_id,
|
||||
content=content,
|
||||
published=published,
|
||||
tags=tags if tags else None
|
||||
)
|
||||
flash(f"Note updated: {note.slug}", "success")
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
except ValueError as e:
|
||||
|
||||
@@ -228,16 +228,21 @@ def index():
|
||||
Microformats: h-feed containing h-entry items with u-photo
|
||||
"""
|
||||
from starpunk.media import get_note_media
|
||||
from starpunk.tags import get_note_tags
|
||||
|
||||
# Get recent published notes (limit 20)
|
||||
notes = list_notes(published_only=True, limit=20)
|
||||
|
||||
# Attach media to each note for display
|
||||
# Attach media and tags to each note for display
|
||||
for note in notes:
|
||||
media = get_note_media(note.id)
|
||||
# Use object.__setattr__ since Note is frozen dataclass
|
||||
object.__setattr__(note, 'media', media)
|
||||
|
||||
# Attach tags (v1.3.0 Phase 3)
|
||||
tags = get_note_tags(note.id)
|
||||
object.__setattr__(note, '_cached_tags', tags)
|
||||
|
||||
return render_template("index.html", notes=notes)
|
||||
|
||||
|
||||
@@ -259,6 +264,7 @@ def note(slug: str):
|
||||
Microformats: h-entry
|
||||
"""
|
||||
from starpunk.media import get_note_media
|
||||
from starpunk.tags import get_note_tags
|
||||
|
||||
# Get note by slug
|
||||
note_obj = get_note(slug=slug)
|
||||
@@ -274,9 +280,60 @@ def note(slug: str):
|
||||
# Use object.__setattr__ since Note is frozen dataclass
|
||||
object.__setattr__(note_obj, 'media', media)
|
||||
|
||||
# Attach tags to note (v1.3.0 Phase 3)
|
||||
tags = get_note_tags(note_obj.id)
|
||||
object.__setattr__(note_obj, '_cached_tags', tags)
|
||||
|
||||
return render_template("note.html", note=note_obj)
|
||||
|
||||
|
||||
@bp.route("/tag/<tag>")
|
||||
def tag(tag: str):
|
||||
"""
|
||||
Tag archive page
|
||||
|
||||
Lists all notes with a specific tag.
|
||||
|
||||
Args:
|
||||
tag: Tag name (will be normalized before lookup)
|
||||
|
||||
Returns:
|
||||
Rendered tag archive template
|
||||
|
||||
Raises:
|
||||
404: If tag doesn't exist
|
||||
|
||||
Note:
|
||||
URL accepts any format - normalized before lookup.
|
||||
/tag/IndieWeb and /tag/indieweb resolve to same tag.
|
||||
|
||||
Template: templates/tag.html
|
||||
Microformats: h-feed containing h-entry items
|
||||
"""
|
||||
from starpunk.tags import get_notes_by_tag, get_tag_by_name, normalize_tag
|
||||
from starpunk.media import get_note_media
|
||||
|
||||
# Normalize the tag name before lookup
|
||||
normalized_name, _ = normalize_tag(tag)
|
||||
|
||||
tag_info = get_tag_by_name(normalized_name)
|
||||
if not tag_info:
|
||||
abort(404)
|
||||
|
||||
notes = get_notes_by_tag(normalized_name)
|
||||
|
||||
# Attach media to each note (tags already pre-loaded by get_notes_by_tag)
|
||||
for note in notes:
|
||||
media = get_note_media(note.id)
|
||||
object.__setattr__(note, 'media', media)
|
||||
|
||||
return render_template(
|
||||
"tag.html",
|
||||
tag=tag_info,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/feed")
|
||||
def feed():
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user