$(function () { function normalizeUrl(el) { const val = el.value.trim(); if (val && !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(val)) { el.value = 'https://' + val; } } $('#url').on('blur keydown', function (e) { if (e.type === 'blur' || e.key === 'Enter') { normalizeUrl(this); } }); $('form').on('submit', function () { normalizeUrl($('#url')[0]); }); // Remove unviewed status when normally clicked $('.diff-link').click(function () { $(this).closest('.unviewed').removeClass('unviewed'); }); $('td[data-timestamp]').each(function () { $(this).prop('title', new Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format($(this).data('timestamp') * 1000)); }) $("#checkbox-assign-tag").click(function (e) { $('#op_extradata').val(prompt("Enter a tag name")); }); // Bulk "Browser" / "Proxy" actions: open a modal with the same radio choices as the // edit page, then set #op_extradata and fire the (hidden) real operation button so the // existing socket.io / form submit path applies the choice to the selected watches. // Title / button text come from the button's data-* attributes so they pass through // server-side i18n (the static JS can't call gettext itself). function showBulkChoiceModal($btn, templateId, radioName, hiddenBtnId) { const tmpl = document.getElementById(templateId); if (!tmpl || typeof ModalDialog === 'undefined') { return; } ModalDialog.confirm({ type: 'info', title: $btn.data('modal-title'), message: '
' + tmpl.innerHTML + '
', confirmText: $btn.data('modal-apply'), onConfirm: function () { const checked = document.querySelector('.modal-dialog input[name="' + radioName + '"]:checked'); $('#op_extradata').val(checked ? checked.value : ''); $('#' + hiddenBtnId).trigger('click'); // Feedback is server-driven: the backend emits a 'toast' event with the real // count/result, handled in realtime.js (and the form fallback flashes on redirect). } }); } $("#checkbox-set-browser").click(function (e) { showBulkChoiceModal($(this), 'bulk-browser-options', 'bulk-fetch-backend', 'op-set-fetch-backend'); }); $("#checkbox-set-proxy").click(function (e) { showBulkChoiceModal($(this), 'bulk-proxy-options', 'bulk-proxy', 'op-set-proxy'); }); $('.history-link').click(function (e) { // Incase they click 'back' in the browser, it should be removed. $(this).closest('tr').removeClass('unviewed'); }); // Inline restock price/stock graph: for restock_diff rows the History button rolls down a // price/stock graph (no LLM needed), styled like the AI summary roll-down. Registered before // the LLM handler and stops propagation so AI mode never turns these into LLM summaries. $(document).on('click', 'tr.processor-restock_diff .ai-history-btn', function (e) { e.preventDefault(); e.stopImmediatePropagation(); var i18n = window.watchOverviewI18n || {}; var $btn = $(this); var uuid = $btn.data('uuid'); var dataUrl = $btn.data('processor-data-url'); var historyUrl = $btn.attr('href'); var $row = $btn.closest('tr'); var rowId = 'restock-graph-row-' + uuid; var cols = $row.find('td').length; var $tbody = $row.closest('tbody'); $row.removeClass('unviewed'); // Toggle off if already open if ($('#' + rowId).length) { $('#' + rowId).remove(); $tbody.find('tr:not(.restock-inline-row) td').css('background-color', ''); return; } // Freeze row backgrounds so inserting a doesn't shift nth-child striping var $dataRows = $tbody.find('tr:not(.restock-inline-row)'); var bgMap = []; $dataRows.each(function () { bgMap.push($(this).find('td:first').css('background-color')); }); var $r = $( '' + '' + '
' + '' + '' ); $r.find('.restock-inline-graph').text(i18n.loadingPriceHistory || 'Loading price history…'); $r.find('.restock-inline-history-link').attr('href', historyUrl).text(i18n.gotoHistory || 'Goto full history'); $row.after($r); // Re-apply frozen backgrounds so the nth-child parity shift is invisible $dataRows.each(function (i) { $(this).find('td').css('background-color', bgMap[i]); }); function showError() { $r.find('.restock-inline-graph').html( '' + $('').text(i18n.priceHistoryError || 'Could not load price history.').html() + '' ); } if (!dataUrl) { showError(); return; } $.getJSON(dataUrl).done(function (data) { var series = (data && data.series) || []; var graphI18n = { in_stock: i18n.inStock, out_of_stock: i18n.outOfStock, no_data: i18n.noPriceData, load_error: i18n.priceHistoryError, changes: i18n.changes, avg_price: i18n.avgPrice, price_low: i18n.priceLow, price_typical: i18n.priceTypical, price_high: i18n.priceHigh, cheaper_than: i18n.cheaperThan, pricier_than: i18n.pricierThan, typical_note: i18n.typicalNote, avg_label: i18n.avgLabel }; if (window.renderRestockGraph) { // Shorter graph inline in the watchlist roll-down than on the full /diff page. window.renderRestockGraph($r.find('.restock-inline-graph')[0], series, (data && data.currency) || '', graphI18n, (data && data.summary) || null, 230); } }).fail(showError); }); $('.with-share-link > *').click(function () { $("#copied-clipboard").remove(); var range = document.createRange(); var n = $("#share-link")[0]; range.selectNode(n); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); document.execCommand("copy"); window.getSelection().removeAllRanges(); $('.with-share-link').append('Copied to clipboard'); $("#copied-clipboard").fadeOut(2500, function () { $(this).remove(); }); }); $(".watch-table tr").click(function (event) { var tagName = event.target.tagName.toLowerCase(); if (tagName === 'tr' || tagName === 'td') { var cb = $('input[type=checkbox]', this)[0]; if (cb) { // Forward the shiftKey so shift-clicking anywhere on a row does a // range-select (a plain $(cb).click() would drop the modifier). cb.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: event.shiftKey })); } } }); // ---- 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 // watch-list page + the active tag (only): a selection survives switching the // status/processor/search/pagination views, but each tag group (and the // untagged "all" view) gets its own selection bucket. The "/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 tag = new URLSearchParams(location.search).get('tag') || ''; return location.pathname + (tag ? '?tag=' + tag : ''); } 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 = 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. $el.html(tpl.replace('%(count)s', '' + fmtNum(n) + '')).show(); } else { $el.hide(); } } 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)); 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', '' + fmtNum(totalMatching) + '') + ' ' ).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', '' + fmtNum(visible.length) + '') + ' ' ).show(); } else { $b.hide().empty(); } } function refreshSelectionUI() { // Toggle a single body class - everything (operations bar visibility, hiding the whole-list // buttons) is driven off body.watch-selection-active in CSS (see _watch_table.scss). // No jQuery slideUp/slideDown. // Base it on a checkbox actually being checked on THIS page, not sel.size(): the store is a // cross-page set persisted in sessionStorage, so a leftover/off-page selection would // otherwise light up the class with nothing visibly selected. const anyVisibleChecked = $rowCbs().get().some((el) => el.checked); $('body').toggleClass('watch-selection-active', anyVisibleChecked); 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; // Shift-click to (de)select a contiguous range, anchored on the last checkbox // clicked — the usual "select many at once" gesture. Runs on 'click' (which // carries shiftKey) before the 'change' handler below. let lastCheckedIndex = null; $(document).on('click', 'input[name="uuids"][type=checkbox]', function (e) { const boxes = $rowCbs().get(); const idx = boxes.indexOf(this); if (e.shiftKey && lastCheckedIndex !== null && lastCheckedIndex < boxes.length) { const lo = Math.min(idx, lastCheckedIndex); const hi = Math.max(idx, lastCheckedIndex); for (let i = lo; i <= hi; i++) { boxes[i].checked = this.checked; if (this.checked) { sel.add(cbUuid(boxes[i])); } else { sel.remove(cbUuid(boxes[i])); } } refreshSelectionUI(); } lastCheckedIndex = idx; }); // 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(); }); // 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(); refreshSelectionUI(); }); // "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; // On load: reflect any persisted selection onto this page, then refresh the UI. applySelectionToDom(); refreshSelectionUI(); setInterval(function () { // Background ETA completion for 'checking now' $(".watch-table .checking-now .last-checked").each(function () { const eta_complete = parseFloat($(this).data('eta_complete')); const fetch_duration = parseInt($(this).data('fetchduration')); if (eta_complete + 2 > nowtimeserver && fetch_duration > 3) { const remaining_seconds = Math.abs(eta_complete) - nowtimeserver - 1; let r = Math.round((1.0 - (remaining_seconds / fetch_duration)) * 100); if (r < 10) { r = 10; } if (r >= 90) { r = 100; } $(this).css('background-size', `${r}% 100%`); } else { // Snap to full complete $(this).css('background-size', `100% 100%`); } }); nowtimeserver = nowtimeserver + time_check_step_size_seconds; }, time_check_step_size_seconds * 1000); // LLM / AI features — only active when the server has LLM configured if ($('body').hasClass('llm-configured')) { var i18n = window.watchOverviewI18n || {}; var msgGenerating = i18n.generatingSummary || 'Generating change summary…'; var msgHistory = i18n.gotoHistory || 'Goto full history'; // Reveal intent textarea on first keydown in the quick-add URL field var $intentWrap = $('#quick-watch-llm-intent'); if ($intentWrap.length) { $('#new-watch-form input[name="url"]').one('keydown', function () { $intentWrap.slideDown(200); }); } // Inline AI summary — clicking the Summary button inserts a row below with AJAX content $(document).on('click', '.ai-history-btn', function (e) { // restock_diff rows have their own graph roll-down handler (above) - never LLM here. if ($(this).closest('tr').hasClass('processor-restock_diff')) return; if ($('html').attr('data-ai-mode') !== 'true') return; // normal navigation when AI mode is off e.preventDefault(); var $btn = $(this); var uuid = $btn.data('uuid'); var url = $btn.data('summary-url'); var $row = $btn.closest('tr'); var rowId = 'ai-summary-row-' + uuid; var cols = $row.find('td').length; var $tbody = $row.closest('tbody'); // Toggle: remove existing row if already open if ($('#' + rowId).length) { $('#' + rowId).remove(); $tbody.find('tr:not(.ai-inline-summary-row) td').css('background-color', ''); return; } // Snapshot row backgrounds BEFORE DOM mutation — inserting a shifts nth-child parity var $dataRows = $tbody.find('tr:not(.ai-inline-summary-row)'); var bgMap = []; $dataRows.each(function () { bgMap.push($(this).find('td:first').css('background-color')); }); var $summaryRow = $( '' + '' + '
' + '✨' + '
' + '' + $('').text(msgGenerating).html() + '' + '
' + '
' + '' ); $row.after($summaryRow); // Re-apply frozen backgrounds so the nth-child parity shift is invisible $dataRows.each(function (i) { $(this).find('td').css('background-color', bgMap[i]); }); function formatSummary(text) { var sectionRe = /^(Added|Changed|Removed|Updated|New|Deleted)\s*:/i; return text.split('\n').map(function (line) { var safe = $('').text(line).html(); return sectionRe.test(line.trim()) ? safe.replace(/^(\w[\w\s]*)(\s*:)/i, '$1$2') : safe; }).join('
'); } var promptUrl = url + '/prompt'; // Fire both requests simultaneously — prompt returns immediately, summary after LLM $.getJSON(promptUrl) .done(function (data) { if (data.prompt && $summaryRow.find('.ai-inline-summary-content:not(.loaded)').length) { $summaryRow.find('.ai-inline-body').append( '' + $('').text(data.prompt).html() + '' ); } }); $.getJSON(url) .done(function (data) { var $content = $summaryRow.find('.ai-inline-summary-content'); var historyUrl = $btn.attr('href'); if (data.summary) { $content.addClass('loaded'); $content.find('.ai-inline-text').html(formatSummary(data.summary)); $content.find('.ai-inline-prompt').remove(); } else if (data.error) { $summaryRow.find('td').html( '' + $('').text(data.error).html() + '' ); } $content.find('.ai-inline-body').append( '' + $('').text(msgHistory).html() + '' ); }) .fail(function (xhr) { var msg = (xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : 'AI summary request failed (HTTP ' + xhr.status + ').'; $summaryRow.find('td').html( '' + $('').text(msg).html() + '' ); }); }); } });