added bulk ops + orphaned tag cleanup

- Bulk delete and retag from browse page with checkboxes
- Select all / deselect all toggle
- Delete confirmation shows count of selected pages
- Auto-cleanup orphaned tags on delete, edit, and bulk actions
This commit is contained in:
lichenblankie 2026-04-08 10:33:57 -07:00
parent a9f426132e
commit 7655748e8e

View file

@ -174,6 +174,11 @@ def _set_page_tags(page_id, tag_string, db=None):
return_db(db)
def _cleanup_orphaned_tags(db):
"""Delete tags that have no page associations."""
db.execute("DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM page_tags)")
# --- Route handlers ---
@ -499,7 +504,8 @@ def handle_pages(query=None):
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags)
tags_html = f' {tag_links}'
items += (
f'<li>{esc(r["title"])}{note_html}{tags_html} '
f'<li><label><input type="checkbox" name="ids" value="{r["id"]}"> '
f'{esc(r["title"])}</label>{note_html}{tags_html} '
f'<small>(<a href="{esc(r["url"])}" rel="noreferrer noopener">{esc(r["url"])}</a>)</small> '
f'<a href="/edit/{r["id"]}">edit</a> '
f'<a href="/delete/{r["id"]}">remove</a></li>'
@ -509,13 +515,71 @@ def handle_pages(query=None):
return _respond(
f"<h1>indexed pages ({total})</h1>"
f"{msg_html}"
f'<form method="post" action="/pages/bulk">'
f'{_csrf_field()}'
f'<p><label><input type="checkbox" id="select-all"> select all</label></p>'
f"<ul>{items}</ul>"
f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
f'<details><summary>bulk actions</summary>'
f'<p><button type="submit" name="action" value="delete" id="bulk-delete">delete selected</button></p>'
f'<p><input name="bulk_tags" placeholder="tags (comma-separated)" size="40"> '
f'<select name="tag_mode"><option value="add">add tags</option><option value="replace">replace tags</option></select> '
f'<button type="submit" name="action" value="retag">retag selected</button></p>'
f'</details>'
f'</form>'
f'<script>'
f'document.getElementById("select-all").addEventListener("change",function(){{'
f'document.querySelectorAll("input[name=ids]").forEach(function(c){{c.checked=this.checked}}.bind(this))'
f'}});'
f'document.getElementById("bulk-delete").addEventListener("click",function(e){{'
f'var n=document.querySelectorAll("input[name=ids]:checked").length;'
f'if(!n){{e.preventDefault();return}}'
f'if(!confirm("Delete "+n+" selected page"+(n===1?"":"s")+"?"))e.preventDefault()'
f'}});'
f'</script>'
f'<p><a href="/export">export</a> | <a href="/import">import</a></p>'
f'<a href="/">back</a>'
)
def handle_bulk_action(body):
ids = body.get("ids", [])
action = body.get("action", [""])[0]
if not ids:
return _redirect("/pages")
# Validate all ids are integers
try:
page_ids = [int(i) for i in ids]
except ValueError:
return _error(400)
db = get_db()
try:
if action == "delete":
for pid in page_ids:
db.execute("DELETE FROM page_tags WHERE page_id = ?", (pid,))
db.execute("DELETE FROM links WHERE page_id = ?", (pid,))
db.execute("DELETE FROM pages WHERE id = ?", (pid,))
_cleanup_orphaned_tags(db)
db.commit()
elif action == "retag":
bulk_tags = body.get("bulk_tags", [""])[0].strip()
tag_mode = body.get("tag_mode", ["add"])[0]
if bulk_tags:
for pid in page_ids:
if tag_mode == "add":
existing = _get_page_tags(pid, db)
new_tags = [t.strip().lower() for t in bulk_tags.split(",") if t.strip()]
merged = ", ".join(sorted(set(existing + new_tags)))
_set_page_tags(pid, merged, db)
else:
_set_page_tags(pid, bulk_tags, db)
_cleanup_orphaned_tags(db)
db.commit()
finally:
return_db(db)
return _redirect("/pages")
def handle_edit_form(page_id, msg=""):
db = get_db()
try:
@ -561,6 +625,7 @@ def handle_edit_submit(page_id, body):
)
_set_page_tags(page_id, tags, db)
_cleanup_orphaned_tags(db)
db.commit()
@ -596,6 +661,7 @@ def handle_delete(page_id):
db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
_cleanup_orphaned_tags(db)
db.commit()
finally:
return_db(db)
@ -1344,6 +1410,8 @@ def _dispatch_inner(data):
return _respond("<h1>403 Forbidden</h1><p>Invalid or missing CSRF token.</p>", status=403)
if path == "/add":
return handle_add_submit(body)
elif path == "/pages/bulk":
return handle_bulk_action(body)
elif path == "/add/manual":
return handle_add_manual_submit(body)
elif path.startswith("/edit/"):