mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-07-08 08:21:14 +00:00
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
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:
@@ -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 -%}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user