Add bulk operations, select all, and 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 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b86e139bdd
commit
d39f9a7813
1 changed files with 69 additions and 1 deletions
70
handlers.py
70
handlers.py
|
|
@ -174,6 +174,11 @@ def _set_page_tags(page_id, tag_string, db=None):
|
||||||
return_db(db)
|
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 ---
|
# --- 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)
|
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags)
|
||||||
tags_html = f' {tag_links}'
|
tags_html = f' {tag_links}'
|
||||||
items += (
|
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'<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="/edit/{r["id"]}">edit</a> '
|
||||||
f'<a href="/delete/{r["id"]}">remove</a></li>'
|
f'<a href="/delete/{r["id"]}">remove</a></li>'
|
||||||
|
|
@ -509,13 +515,71 @@ def handle_pages(query=None):
|
||||||
return _respond(
|
return _respond(
|
||||||
f"<h1>indexed pages ({total})</h1>"
|
f"<h1>indexed pages ({total})</h1>"
|
||||||
f"{msg_html}"
|
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"<ul>{items}</ul>"
|
||||||
f'{_page_nav(page, total, "/pages", BROWSE_PER_PAGE)}'
|
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'<p><a href="/export">export</a> | <a href="/import">import</a></p>'
|
||||||
f'<a href="/">back</a>'
|
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=""):
|
def handle_edit_form(page_id, msg=""):
|
||||||
db = get_db()
|
db = get_db()
|
||||||
try:
|
try:
|
||||||
|
|
@ -561,6 +625,7 @@ def handle_edit_submit(page_id, body):
|
||||||
)
|
)
|
||||||
|
|
||||||
_set_page_tags(page_id, tags, db)
|
_set_page_tags(page_id, tags, db)
|
||||||
|
_cleanup_orphaned_tags(db)
|
||||||
|
|
||||||
db.commit()
|
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 page_tags WHERE page_id = ?", (page_id,))
|
||||||
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
|
||||||
db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
|
db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
|
||||||
|
_cleanup_orphaned_tags(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
return_db(db)
|
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)
|
return _respond("<h1>403 Forbidden</h1><p>Invalid or missing CSRF token.</p>", status=403)
|
||||||
if path == "/add":
|
if path == "/add":
|
||||||
return handle_add_submit(body)
|
return handle_add_submit(body)
|
||||||
|
elif path == "/pages/bulk":
|
||||||
|
return handle_bulk_action(body)
|
||||||
elif path == "/add/manual":
|
elif path == "/add/manual":
|
||||||
return handle_add_manual_submit(body)
|
return handle_add_manual_submit(body)
|
||||||
elif path.startswith("/edit/"):
|
elif path.startswith("/edit/"):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue