merge: article bulk select-all matching + cornerstone filter
All checks were successful
CI / test (push) Successful in 59s
CI / tag (push) Successful in 3s

This commit is contained in:
Deeman
2026-03-06 23:48:26 +01:00
2 changed files with 309 additions and 96 deletions

View File

@@ -2275,15 +2275,17 @@ async def _sync_static_articles() -> None:
) )
async def _get_article_list( def _build_article_where(
status: str = None, status: str = None,
template_slug: str = None, template_slug: str = None,
language: str = None, language: str = None,
search: str = None, search: str = None,
page: int = 1, ) -> tuple[list[str], list]:
per_page: int = 50, """Build WHERE clauses and params for article queries.
) -> list[dict]:
"""Get articles with optional filters and pagination.""" template_slug='__manual__' filters for articles with template_slug IS NULL
(cornerstone / manually written articles, no pSEO template).
"""
wheres = ["1=1"] wheres = ["1=1"]
params: list = [] params: list = []
@@ -2293,7 +2295,9 @@ async def _get_article_list(
wheres.append("status = 'published' AND published_at > datetime('now')") wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft": elif status == "draft":
wheres.append("status = 'draft'") wheres.append("status = 'draft'")
if template_slug: if template_slug == "__manual__":
wheres.append("template_slug IS NULL")
elif template_slug:
wheres.append("template_slug = ?") wheres.append("template_slug = ?")
params.append(template_slug) params.append(template_slug)
if language: if language:
@@ -2303,6 +2307,20 @@ async def _get_article_list(
wheres.append("title LIKE ?") wheres.append("title LIKE ?")
params.append(f"%{search}%") params.append(f"%{search}%")
return wheres, params
async def _get_article_list(
status: str = None,
template_slug: str = None,
language: str = None,
search: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
"""Get articles with optional filters and pagination."""
wheres, params = _build_article_where(status=status, template_slug=template_slug,
language=language, search=search)
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
params.extend([per_page, offset]) params.extend([per_page, offset])
@@ -2332,22 +2350,8 @@ async def _get_article_list_grouped(
Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path. Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path.
Each returned item has a 'variants' list (one dict per language variant). Each returned item has a 'variants' list (one dict per language variant).
""" """
wheres = ["1=1"] wheres, params = _build_article_where(status=status, template_slug=template_slug,
params: list = [] search=search)
if status == "live":
wheres.append("status = 'published' AND published_at <= datetime('now')")
elif status == "scheduled":
wheres.append("status = 'published' AND published_at > datetime('now')")
elif status == "draft":
wheres.append("status = 'draft'")
if template_slug:
wheres.append("template_slug = ?")
params.append(template_slug)
if search:
wheres.append("title LIKE ?")
params.append(f"%{search}%")
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
@@ -2507,26 +2511,131 @@ async def article_results():
) )
@bp.route("/articles/matching-count")
@role_required("admin")
async def articles_matching_count():
"""Return count of articles matching current filters (for bulk select-all banner)."""
status_filter = request.args.get("status", "")
template_filter = request.args.get("template", "")
language_filter = request.args.get("language", "")
search = request.args.get("search", "").strip()
wheres, params = _build_article_where(
status=status_filter or None,
template_slug=template_filter or None,
language=language_filter or None,
search=search or None,
)
where = " AND ".join(wheres)
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
count = row["cnt"] if row else 0
return f"{count:,}"
@bp.route("/articles/bulk", methods=["POST"]) @bp.route("/articles/bulk", methods=["POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect
async def articles_bulk(): async def articles_bulk():
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete.""" """Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete.
Supports two modes:
- Explicit IDs: article_ids=1,2,3 (max 500)
- Apply to all matching: apply_to_all=true + filter params (rebuild capped at 2000, delete at 5000)
"""
form = await request.form form = await request.form
ids_raw = form.get("article_ids", "").strip()
action = form.get("action", "").strip() action = form.get("action", "").strip()
apply_to_all = form.get("apply_to_all", "").strip() == "true"
# Common filter params (used for action scope and re-render)
search = form.get("search", "").strip()
status_filter = form.get("status", "")
template_filter = form.get("template", "")
language_filter = form.get("language", "")
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete") valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
if action not in valid_actions or not ids_raw: if action not in valid_actions:
return "", 400 return "", 400
now = utcnow_iso()
if apply_to_all:
wheres, where_params = _build_article_where(
status=status_filter or None,
template_slug=template_filter or None,
language=language_filter or None,
search=search or None,
)
where = " AND ".join(wheres)
if action == "rebuild":
count_row = await fetch_one(
f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(where_params)
)
count = count_row["cnt"] if count_row else 0
if count > 2000:
return (
f"<p class='text-red-600 p-4'>Too many articles ({count:,}) for bulk rebuild"
f" — max 2,000. Narrow your filters first.</p>",
400,
)
if action == "publish":
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
elif action == "toggle_noindex":
await execute(
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END,"
f" updated_at = ? WHERE {where}",
(now, *where_params),
)
elif action == "rebuild":
rows = await fetch_all(
f"SELECT id FROM articles WHERE {where} LIMIT 2000", tuple(where_params)
)
for r in rows:
await _rebuild_article(r["id"])
elif action == "delete":
from ..content.routes import BUILD_DIR
rows = await fetch_all(
f"SELECT id, slug FROM articles WHERE {where} LIMIT 5000", tuple(where_params)
)
for a in rows:
build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists():
build_path.unlink()
md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute(f"DELETE FROM articles WHERE {where}", tuple(where_params))
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
else:
ids_raw = form.get("article_ids", "").strip()
if not ids_raw:
return "", 400
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()] article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
assert len(article_ids) <= 500, "too many article IDs in bulk action" assert len(article_ids) <= 500, "too many article IDs in bulk action"
if not article_ids: if not article_ids:
return "", 400 return "", 400
placeholders = ",".join("?" for _ in article_ids) placeholders = ",".join("?" for _ in article_ids)
now = utcnow_iso()
if action == "publish": if action == "publish":
await execute( await execute(
@@ -2557,18 +2666,17 @@ async def articles_bulk():
elif action == "delete": elif action == "delete":
from ..content.routes import BUILD_DIR from ..content.routes import BUILD_DIR
articles = await fetch_all( articles_rows = await fetch_all(
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})", f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
tuple(article_ids), tuple(article_ids),
) )
for a in articles: for a in articles_rows:
build_path = BUILD_DIR / f"{a['slug']}.html" build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists(): if build_path.exists():
build_path.unlink() build_path.unlink()
md_path = Path("data/content/articles") / f"{a['slug']}.md" md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists(): if md_path.exists():
md_path.unlink() md_path.unlink()
await execute( await execute(
f"DELETE FROM articles WHERE id IN ({placeholders})", f"DELETE FROM articles WHERE id IN ({placeholders})",
tuple(article_ids), tuple(article_ids),
@@ -2577,11 +2685,6 @@ async def articles_bulk():
invalidate_sitemap_cache() invalidate_sitemap_cache()
# Re-render results partial with current filters # Re-render results partial with current filters
search = form.get("search", "").strip()
status_filter = form.get("status", "")
template_filter = form.get("template", "")
language_filter = form.get("language", "")
grouped = not language_filter grouped = not language_filter
if grouped: if grouped:
article_list = await _get_article_list_grouped( article_list = await _get_article_list_grouped(

View File

@@ -48,6 +48,7 @@
<label class="text-xs font-semibold text-slate block mb-1">Template</label> <label class="text-xs font-semibold text-slate block mb-1">Template</label>
<select name="template" class="form-input" style="min-width:140px"> <select name="template" class="form-input" style="min-width:140px">
<option value="">All</option> <option value="">All</option>
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
{% for t in template_slugs %} {% for t in template_slugs %}
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option> <option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
{% endfor %} {% endfor %}
@@ -75,12 +76,13 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value=""> <input type="hidden" name="article_ids" id="article-bulk-ids" value="">
<input type="hidden" name="action" id="article-bulk-action" value=""> <input type="hidden" name="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}"> <input type="hidden" name="apply_to_all" id="article-bulk-apply-to-all" value="false">
<input type="hidden" name="status" value="{{ current_status }}"> <input type="hidden" name="search" id="article-bulk-search" value="{{ current_search }}">
<input type="hidden" name="template" value="{{ current_template }}"> <input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
<input type="hidden" name="language" value="{{ current_language }}"> <input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}">
<input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}">
</form> </form>
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;"> <div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;">
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span> <span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem"> <select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
<option value="">Action…</option> <option value="">Action…</option>
@@ -92,6 +94,20 @@
</select> </select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button> <button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button> <button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
<span id="article-select-all-banner" style="display:none;font-size:0.8125rem;color:#1E40AF;margin-left:0.5rem">
All <strong id="article-page-count"></strong> on this page selected.
<button type="button" onclick="enableApplyToAll()"
style="background:none;border:none;color:#1D4ED8;font-weight:600;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
Select all <span id="article-matching-count"></span> matching instead?
</button>
</span>
<span id="article-apply-to-all-banner" style="display:none;font-size:0.8125rem;color:#991B1B;font-weight:600;margin-left:0.5rem">
All matching articles selected (<span id="article-matching-count-confirm"></span> total).
<button type="button" onclick="disableApplyToAll()"
style="background:none;border:none;color:#1D4ED8;font-weight:400;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
Undo
</button>
</span>
</div> </div>
{# Results #} {# Results #}
@@ -101,10 +117,13 @@
<script> <script>
const articleSelectedIds = new Set(); const articleSelectedIds = new Set();
let articleApplyToAll = false;
let articleMatchingCount = 0;
function toggleArticleSelect(id, checked) { function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id); if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id); else articleSelectedIds.delete(id);
disableApplyToAll();
updateArticleBulkBar(); updateArticleBulkBar();
} }
@@ -114,30 +133,91 @@ function toggleArticleGroupSelect(checkbox) {
if (checkbox.checked) articleSelectedIds.add(id); if (checkbox.checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id); else articleSelectedIds.delete(id);
}); });
disableApplyToAll();
updateArticleBulkBar(); updateArticleBulkBar();
} }
function clearArticleSelection() { function clearArticleSelection() {
articleSelectedIds.clear(); articleSelectedIds.clear();
articleApplyToAll = false;
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; }); document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all'); var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false; if (selectAll) selectAll.checked = false;
updateArticleBulkBar(); updateArticleBulkBar();
} }
function enableApplyToAll() {
articleApplyToAll = true;
document.getElementById('article-bulk-apply-to-all').value = 'true';
document.getElementById('article-select-all-banner').style.display = 'none';
document.getElementById('article-apply-to-all-banner').style.display = 'inline';
var confirmEl = document.getElementById('article-matching-count-confirm');
if (confirmEl) confirmEl.textContent = articleMatchingCount.toLocaleString();
document.getElementById('article-bulk-count').textContent = 'All matching selected';
}
function disableApplyToAll() {
articleApplyToAll = false;
document.getElementById('article-bulk-apply-to-all').value = 'false';
document.getElementById('article-select-all-banner').style.display = 'none';
document.getElementById('article-apply-to-all-banner').style.display = 'none';
}
function updateArticleBulkBar() { function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar'); var bar = document.getElementById('article-bulk-bar');
var count = document.getElementById('article-bulk-count'); var countEl = document.getElementById('article-bulk-count');
var ids = document.getElementById('article-bulk-ids'); var ids = document.getElementById('article-bulk-ids');
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
count.textContent = articleSelectedIds.size + ' selected'; if (articleSelectedIds.size === 0 && !articleApplyToAll) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
if (!articleApplyToAll) {
countEl.textContent = articleSelectedIds.size + ' selected';
ids.value = Array.from(articleSelectedIds).join(','); ids.value = Array.from(articleSelectedIds).join(',');
}
// Check if select-all is checked → show "select all matching" banner
var selectAll = document.getElementById('article-select-all');
var allOnPage = document.querySelectorAll('.article-checkbox');
var pageCount = 0;
allOnPage.forEach(function(cb) {
if (cb.dataset.ids) {
pageCount += (cb.dataset.ids || '').split(',').filter(Boolean).length;
} else {
pageCount += 1;
}
});
var selectAllBanner = document.getElementById('article-select-all-banner');
if (!articleApplyToAll && selectAll && selectAll.checked && pageCount > 0) {
document.getElementById('article-page-count').textContent = pageCount;
selectAllBanner.style.display = 'inline';
// Fetch count of matching articles
var params = new URLSearchParams({
search: document.getElementById('article-bulk-search').value,
status: document.getElementById('article-bulk-status').value,
template: document.getElementById('article-bulk-template').value,
language: document.getElementById('article-bulk-language').value,
});
fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString())
.then(function(r) { return r.text(); })
.then(function(text) {
articleMatchingCount = parseInt(text.replace(/,/g, ''), 10) || 0;
var el = document.getElementById('article-matching-count');
if (el) el.textContent = text;
});
} else if (!articleApplyToAll) {
selectAllBanner.style.display = 'none';
}
} }
function submitArticleBulk() { function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value; var action = document.getElementById('article-bulk-action-select').value;
if (!action) return; if (!action) return;
if (articleSelectedIds.size === 0) return; if (!articleApplyToAll && articleSelectedIds.size === 0) return;
function doSubmit() { function doSubmit() {
document.getElementById('article-bulk-action').value = action; document.getElementById('article-bulk-action').value = action;
@@ -150,7 +230,13 @@ function submitArticleBulk() {
} }
if (action === 'delete') { if (action === 'delete') {
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) { var subject = articleApplyToAll
? 'Delete all ' + articleMatchingCount.toLocaleString() + ' matching articles? This cannot be undone.'
: 'Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.';
showConfirm(subject).then(function(ok) { if (ok) doSubmit(); });
} else if (articleApplyToAll) {
var verb = action.charAt(0).toUpperCase() + action.slice(1);
showConfirm(verb + ' all ' + articleMatchingCount.toLocaleString() + ' matching articles?').then(function(ok) {
if (ok) doSubmit(); if (ok) doSubmit();
}); });
} else { } else {
@@ -158,6 +244,30 @@ function submitArticleBulk() {
} }
} }
// Sync filter values into bulk form hidden inputs when filters change
document.addEventListener('DOMContentLoaded', function() {
var filterForm = document.querySelector('form[hx-get*="article_results"]');
if (!filterForm) return;
filterForm.addEventListener('change', syncBulkFilters);
filterForm.addEventListener('input', syncBulkFilters);
});
function syncBulkFilters() {
var filterForm = document.querySelector('form[hx-get*="article_results"]');
if (!filterForm) return;
var fd = new FormData(filterForm);
var searchEl = document.getElementById('article-bulk-search');
var statusEl = document.getElementById('article-bulk-status');
var templateEl = document.getElementById('article-bulk-template');
var languageEl = document.getElementById('article-bulk-language');
if (searchEl) searchEl.value = fd.get('search') || '';
if (statusEl) statusEl.value = fd.get('status') || '';
if (templateEl) templateEl.value = fd.get('template') || '';
if (languageEl) languageEl.value = fd.get('language') || '';
// Changing filters clears apply-to-all and resets selection
clearArticleSelection();
}
document.body.addEventListener('htmx:afterSwap', function(evt) { document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') { if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) { document.querySelectorAll('.article-checkbox').forEach(function(cb) {