mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-08-23 22:57:00 +00:00
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (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
504 lines
22 KiB
JavaScript
504 lines
22 KiB
JavaScript
$(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: '<div class="bulk-choice-list">' + tmpl.innerHTML + '</div>',
|
|
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 <tr> 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 = $(
|
|
'<tr class="restock-inline-row" id="' + rowId + '">' +
|
|
'<td colspan="' + cols + '">' +
|
|
'<div class="restock-inline-graph"></div>' +
|
|
'<a class="restock-inline-history-link"></a>' +
|
|
'</td></tr>'
|
|
);
|
|
$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(
|
|
'<span class="restock-inline-error">' +
|
|
$('<span>').text(i18n.priceHistoryError || 'Could not load price history.').html() +
|
|
'</span>'
|
|
);
|
|
}
|
|
|
|
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('<span style="font-size: 80%; color: #fff;" id="copied-clipboard">Copied to clipboard</span>');
|
|
$("#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', '<strong>' + fmtNum(n) + '</strong>')).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', '<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 {
|
|
$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 <tr> 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 = $(
|
|
'<tr class="ai-inline-summary-row" id="' + rowId + '">' +
|
|
'<td colspan="' + cols + '">' +
|
|
'<div class="ai-inline-summary-content">' +
|
|
'<span class="ai-inline-spinner">✨</span>' +
|
|
'<div class="ai-inline-body">' +
|
|
'<span class="ai-inline-text">' + $('<span>').text(msgGenerating).html() + '</span>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</td></tr>'
|
|
);
|
|
$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 = $('<span>').text(line).html();
|
|
return sectionRe.test(line.trim())
|
|
? safe.replace(/^(\w[\w\s]*)(\s*:)/i, '<strong>$1$2</strong>')
|
|
: safe;
|
|
}).join('<br>');
|
|
}
|
|
|
|
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(
|
|
'<span class="ai-inline-prompt">' + $('<span>').text(data.prompt).html() + '</span>'
|
|
);
|
|
}
|
|
});
|
|
|
|
$.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(
|
|
'<span class="ai-inline-error">' + $('<span>').text(data.error).html() + '</span>'
|
|
);
|
|
}
|
|
$content.find('.ai-inline-body').append(
|
|
'<a href="' + historyUrl + '" class="ai-inline-history-link">' +
|
|
$('<span>').text(msgHistory).html() + '</a>'
|
|
);
|
|
|
|
})
|
|
.fail(function (xhr) {
|
|
var msg = (xhr.responseJSON && xhr.responseJSON.error)
|
|
? xhr.responseJSON.error
|
|
: 'AI summary request failed (HTTP ' + xhr.status + ').';
|
|
$summaryRow.find('td').html(
|
|
'<span class="ai-inline-error">' + $('<span>').text(msg).html() + '</span>'
|
|
);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|