Store selected items between pages
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled

This commit is contained in:
dgtlmoon
2026-06-16 14:41:22 +02:00
parent 82da4a2996
commit f0ec578f5e
6 changed files with 258 additions and 67 deletions
@@ -13,7 +13,50 @@ from changedetectionio.auth_decorator import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
# --- Watchlist filtering, shared by the rendered list (index) and the
# "/uuids" endpoint (used by the client "select all matching" feature) so the
# two can never drift out of sync. ---
def _resolve_active_tag_uuid(active_tag_req):
if not active_tag_req:
return None
for uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
if active_tag_req == tag.get('title', '').lower().strip() or active_tag_req == uuid:
return uuid
return None
def _list_filters_from_args(args, active_tag_uuid):
return {
'with_errors': args.get('with_errors') == "1",
'unread_only': args.get('unread') == "1",
'processor': args.get('processor', '').strip(),
'tag_uuid': active_tag_uuid,
'search_q': args.get('q').strip().lower() if args.get('q') else False,
}
# Status/tag/processor filters (everything except the text search).
def _watch_passes_prefilter(watch, f):
if f['with_errors'] and not watch.get('last_error'):
return False
if f['unread_only'] and (watch.viewed or watch.last_changed == 0):
return False
if f['tag_uuid'] and f['tag_uuid'] not in watch['tags']:
return False
if f['processor'] and watch.get('processor') != f['processor']:
return False
return True
def _watch_passes_search(watch, f):
search_q = f['search_q']
if not search_q:
return True
if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
return True
if watch.get('last_error') and search_q in watch.get('last_error').lower():
return True
return False
@watchlist_blueprint.route("/", methods=['GET'])
@login_optionally_required
def index():
@@ -45,32 +88,16 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Sort by last_changed and add the uuid which is usually the key..
sorted_watches = []
with_errors = request.args.get('with_errors') == "1"
unread_only = request.args.get('unread') == "1"
active_processor = request.args.get('processor', '').strip()
errored_count = 0
search_q = request.args.get('q').strip().lower() if request.args.get('q') else False
list_filters = _list_filters_from_args(request.args, active_tag_uuid)
for uuid, watch in datastore.data['watching'].items():
if with_errors and not watch.get('last_error'):
if not _watch_passes_prefilter(watch, list_filters):
continue
if unread_only and (watch.viewed or watch.last_changed == 0) :
continue
if active_tag_uuid and not active_tag_uuid in watch['tags']:
continue
if active_processor and watch.get('processor') != active_processor:
continue
# errored_count reflects the tag/processor/status-filtered set (pre-search)
if watch.get('last_error'):
errored_count += 1
if search_q:
if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
sorted_watches.append(watch)
elif watch.get('last_error') and search_q in watch.get('last_error').lower():
sorted_watches.append(watch)
else:
if _watch_passes_search(watch, list_filters):
sorted_watches.append(watch)
form = forms.quickWatchForm(request.form)
@@ -149,5 +176,23 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
resp.set_cookie('order', request.args.get('order'))
return resp
@watchlist_blueprint.route("/uuids", methods=['GET'])
@login_optionally_required
def uuids():
"""All watch UUIDs matching the current filter (tag/search/status/processor).
Backs the client "select all matching" feature: the watchlist page only
renders one page of rows, so to select across pages the browser fetches the
full matching id list from here and holds it in its selection store.
"""
from flask import jsonify
active_tag_uuid = _resolve_active_tag_uuid(request.args.get('tag', '').lower().strip())
list_filters = _list_filters_from_args(request.args, active_tag_uuid)
matching = [
uuid for uuid, watch in datastore.data['watching'].items()
if _watch_passes_prefilter(watch, list_filters) and _watch_passes_search(watch, list_filters)
]
return jsonify({'uuids': matching})
return watchlist_blueprint
@@ -11,6 +11,14 @@
<script>let nowtimeserver={{ now_time_server }};</script>
<script>let favicon_baseURL="{{ url_for('static_content', group='favicon', filename="PLACEHOLDER")}}";</script>
<script>
// Cross-page selection: total rows matching the current filter, and the endpoint
// that lists all matching UUIDs for the "select all matching" action.
window.watchListSelection = {
total: {{ pagination.total }},
uuidsUrl: "{{ url_for('watchlist.uuids') }}"
};
</script>
<script>
// Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() {
feather.replace();
@@ -151,8 +159,15 @@ window.watchOverviewI18n = {
data-confirm-title="{{ _('Delete Watches?') }}"
data-confirm-message="{{ _('<p><strong>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}"
data-confirm-button="{{ _('Delete') }}"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Delete') }}</button>
<button type="button" class="pure-button button-secondary button-xsmall" id="check-cancel"><i data-feather="x" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Cancel') }}</button>
<button type="button" class="pure-button button-xsmall" id="check-cancel" style="background: var(--color-background-button-cancel); color: #333;"><i data-feather="x" style="width: 14px; height: 14px; stroke: #333; margin-right: 4px;"></i>{{ _('Cancel') }}</button>
</div>
{# "Select all matching" banner — shown by watch-overview.js when the whole
visible page is selected but more matching rows exist on other pages. #}
<div id="select-all-banner" class="select-all-banner" style="display: none;"
data-select-all-tmpl="{{ _('All %(page)s on this page are selected.') }}"
data-select-all-action="{{ _('Select all %(total)s matching') }}"
data-all-selected-tmpl="{{ _('All %(total)s matching are selected.') }}"
data-clear-action="{{ _('Clear selection') }}"></div>
<div id="stats_row">
<div class="left">{%- if watches|length >= pagination.per_page -%}{{ pagination.info }}{%- endif -%}
+17 -6
View File
@@ -33,9 +33,15 @@ $(document).ready(function () {
e.preventDefault();
const $button = $(this);
const op = $button.val();
const checkedUuids = $('input[name="uuids"]:checked').map(function () {
return this.value.trim();
}).get();
// The cross-page selection store (watch-overview.js) is the source of
// truth when present — it includes rows selected on other pages, not
// just the visible checked boxes. Fall back to the DOM if absent.
const watchSel = window.cdioWatchSelection;
const checkedUuids = watchSel
? watchSel.all()
: $('input[name="uuids"]:checked').map(function () {
return this.value.trim();
}).get();
// Check if this button requires confirmation
console.log('Button clicked, op:', op, 'requires-confirm:', $button.is('[data-requires-confirm]'));
@@ -55,7 +61,12 @@ $(document).ready(function () {
extra_data: $('#op_extradata').val()
});
// Keep the rows selected after the operation so further
// actions can be applied to the same selection.
// actions can be applied to the same selection — except
// delete, where the rows (and their UUIDs) are now gone.
if (op === 'delete' && watchSel) {
watchSel.clear();
if (watchSel.refreshUI) watchSel.refreshUI();
}
}
};
ModalDialog.confirm(config);
@@ -66,8 +77,8 @@ $(document).ready(function () {
uuids: checkedUuids,
extra_data: $('#op_extradata').val()
});
// Keep the rows selected after the operation so further
// actions can be applied to the same selection.
// Keep the rows selected after the operation so further actions
// can be applied to the same selection.
}
return false;
+141 -37
View File
@@ -66,65 +66,169 @@ $(function () {
}
});
// Update the "N records selected" line under the stats row. The translatable
// template ("%(count)s records selected") is carried on the element's
// data-template attribute; we substitute a locale-formatted count here.
// ---- Cross-page selection store -------------------------------------------
// Selection is a Set of watch UUIDs (the source of truth) rather than the DOM,
// so it spans paginated pages. Persisted in sessionStorage scoped to the
// current filter (URL minus the page param) so it auto-clears when the
// search/filter changes. The watchlist "/uuids" endpoint supplies the full
// matching id list for "select all matching".
const sel = window.cdioWatchSelection = (function () {
const KEY = 'cdio-watch-selection';
function scopeKey() {
const p = new URLSearchParams(location.search);
p.delete('page');
return location.pathname + '?' + p.toString();
}
let uuids = new Set();
try {
const obj = JSON.parse(sessionStorage.getItem(KEY) || 'null');
if (obj && obj.scope === scopeKey() && Array.isArray(obj.uuids)) {
uuids = new Set(obj.uuids);
}
} catch (e) { /* private mode / corrupt — best effort */ }
function persist() {
try {
sessionStorage.setItem(KEY, JSON.stringify({ scope: scopeKey(), uuids: Array.from(uuids) }));
} catch (e) { /* quota / private mode */ }
}
return {
has: (u) => uuids.has(u),
add: (u) => { uuids.add(u); persist(); },
remove: (u) => { uuids.delete(u); persist(); },
toggle: (u) => { if (uuids.has(u)) { uuids.delete(u); } else { uuids.add(u); } persist(); },
addMany: (arr) => { arr.forEach((u) => uuids.add(u)); persist(); },
clear: () => { uuids.clear(); persist(); },
all: () => Array.from(uuids),
size: () => uuids.size
};
})();
const selCfg = window.watchListSelection || {};
const totalMatching = parseInt(selCfg.total, 10) || 0;
// Row checkbox values carry a trailing space in the template — always trim.
const cbUuid = (el) => (el.value || '').trim();
const $rowCbs = () => $('input[name="uuids"][type=checkbox]');
const fmtNum = (n) => new Intl.NumberFormat(navigator.language).format(n);
// Reflect the store onto this page's checkboxes (load + after bulk changes).
function applySelectionToDom() {
$rowCbs().each(function () { this.checked = sel.has(cbUuid(this)); });
const visible = $rowCbs().get();
$('#check-all').prop('checked', visible.length > 0 && visible.every((el) => el.checked));
}
function updateRecordsSelected() {
const $el = $('#records-selected');
if (!$el.length) return;
const n = $('input[name="uuids"][type=checkbox]:checked').length;
const n = sel.size();
if (n > 0) {
const tpl = $el.attr('data-template') || '%(count)s records selected';
// Only the count is emphasised. The number is locale-formatted digits/
// separators (no HTML-special chars) so injecting it as markup is safe.
const numHtml = '<strong>' + new Intl.NumberFormat(navigator.language).format(n) + '</strong>';
$el.html(tpl.replace('%(count)s', numHtml)).show();
// Only the count is emphasised; the number is locale-formatted digits.
$el.html(tpl.replace('%(count)s', '<strong>' + fmtNum(n) + '</strong>')).show();
} else {
$el.hide();
}
}
// checkboxes - check all
$("#check-all").click(function (e) {
$('input[type=checkbox]').not(this).prop('checked', this.checked);
updateRecordsSelected();
});
function updateSelectAllBanner() {
const $b = $('#select-all-banner');
if (!$b.length) return;
const visible = $rowCbs().map(function () { return cbUuid(this); }).get();
const allVisibleSelected = visible.length > 0 && visible.every((u) => sel.has(u));
// checkboxes - invert the current selection
$("#check-invert").click(function (e) {
$('input[name="uuids"][type=checkbox]').prop('checked', function (i, val) {
return !val;
});
// Programmatic changes don't fire 'click', so re-evaluate the operations bar.
if ($('input[name="uuids"][type=checkbox]:checked').length) {
$('#checkbox-operations').slideDown();
if (allVisibleSelected && totalMatching > 0 && sel.size() >= totalMatching) {
const tpl = $b.attr('data-all-selected-tmpl') || 'All %(total)s matching are selected.';
$b.html(
tpl.replace('%(total)s', '<strong>' + fmtNum(totalMatching) + '</strong>') +
' <button type="button" class="pure-button button-xsmall" id="select-all-clear">' +
($b.attr('data-clear-action') || 'Clear selection') + '</button>'
).show();
} else if (allVisibleSelected && totalMatching > visible.length) {
const tpl = $b.attr('data-select-all-tmpl') || 'All %(page)s on this page are selected.';
const action = ($b.attr('data-select-all-action') || 'Select all %(total)s matching')
.replace('%(total)s', fmtNum(totalMatching));
$b.html(
tpl.replace('%(page)s', '<strong>' + fmtNum(visible.length) + '</strong>') +
' <button type="button" class="pure-button button-xsmall" id="select-all-matching">' + action + '</button>'
).show();
} else {
$('#checkbox-operations').slideUp();
$b.hide().empty();
}
}
function refreshSelectionUI() {
if (sel.size() > 0) { $('#checkbox-operations').slideDown(); }
else { $('#checkbox-operations').slideUp(); }
updateRecordsSelected();
updateSelectAllBanner();
}
// Exposed so realtime.js can refresh the UI after it mutates the selection
// (e.g. clearing it once a delete operation removes the rows).
sel.refreshUI = refreshSelectionUI;
// Individual row checkbox toggled (direct click, or via the row-click handler).
$(document).on('change', 'input[name="uuids"][type=checkbox]', function () {
if (this.checked) { sel.add(cbUuid(this)); } else { sel.remove(cbUuid(this)); }
const visible = $rowCbs().get();
$('#check-all').prop('checked', visible.length > 0 && visible.every((el) => el.checked));
refreshSelectionUI();
});
// checkboxes - cancel: clear the whole selection and dismiss the operations bar
$("#check-cancel").click(function (e) {
$('input[type=checkbox]').prop('checked', false);
// Check-all (this page only).
$("#check-all").click(function () {
const on = this.checked;
$rowCbs().each(function () {
this.checked = on;
if (on) { sel.add(cbUuid(this)); } else { sel.remove(cbUuid(this)); }
});
refreshSelectionUI();
});
// Invert this page's selection.
$("#check-invert").click(function () {
$rowCbs().each(function () { sel.toggle(cbUuid(this)); });
applySelectionToDom();
refreshSelectionUI();
});
// Cancel: clear the whole cross-page selection (incl. the stored browser data)
// and dismiss the operations bar.
$("#check-cancel").click(function () {
sel.clear();
applySelectionToDom();
$('#checkbox-operations').slideUp();
updateRecordsSelected();
updateSelectAllBanner();
});
// "Select all N matching" — pull the full matching id list from the server.
$(document).on('click', '#select-all-matching', function () {
if (!selCfg.uuidsUrl) return;
const $btn = $(this).prop('disabled', true);
$.getJSON(selCfg.uuidsUrl + location.search)
.done(function (data) {
if (data && Array.isArray(data.uuids)) {
sel.addMany(data.uuids.map((u) => ('' + u).trim()));
applySelectionToDom();
refreshSelectionUI();
}
})
.always(function () { $btn.prop('disabled', false); });
});
// "Clear selection" link inside the banner.
$(document).on('click', '#select-all-clear', function () {
sel.clear();
applySelectionToDom();
refreshSelectionUI();
});
const time_check_step_size_seconds=1;
// checkboxes - show/hide buttons
$("input[type=checkbox]").click(function (e) {
if ($('input[type=checkbox]:checked').length) {
$('#checkbox-operations').slideDown();
} else {
$('#checkbox-operations').slideUp();
}
updateRecordsSelected();
});
// Initial state (e.g. browser restored some checkbox states on reload)
updateRecordsSelected();
// On load: reflect any persisted selection onto this page, then refresh the UI.
applySelectionToDom();
refreshSelectionUI();
setInterval(function () {
// Background ETA completion for 'checking now'
@@ -31,6 +31,22 @@ $title-col-stack-breakpoint: 1200px;
overflow: hidden;
}
// "Select all matching" banner shown under the bulk-operations bar when the
// whole visible page is selected but more matching rows exist on other pages.
.select-all-banner {
margin: 0.4rem 0;
padding: 0.5rem 0.75rem;
border-radius: var(--common-round-border);
background: var(--watchlist-row-selected);
color: var(--color-watch-table-row-text);
font-size: var(--body-main-text-size);
button {
margin-left: 0.5rem;
vertical-align: baseline;
}
}
/* table related */
#stats_row {
display: flex;
File diff suppressed because one or more lines are too long