Bug 29016 [25.11.x]: (QA follow-up) Translate circulation overrides in info column

CIRCULATION rows whose info payload is a JSON object can carry `forced`
and `confirmations` arrays listing the override codes a librarian
acknowledged when checking out, renewing or otherwise circulating an
item (e.g. DEBT, AGE_RESTRICTION, BIBLIO_ALREADY_ISSUED). The old
server-rendered template ran these through the translate_log_override
SWITCH block in action-logs.inc; in the new AJAX-driven viewer the JS
default branch was just escapeHtml()-ing the raw JSON, leaving the
codes unlocalised and unreadable.

Port the override -> human-readable label map into str/viewlog.inc as
override_labels, mirroring the module/action/interface label maps
already there. Add a renderCircInfo() helper that recognises the JSON
shape and emits the same div.forced / div.confirmed blocks the old
template did, so the existing CSS continues to apply.

renderInfo() now falls through CIRCULATION -> renderCircInfo, the
generic record-info renderer (CATALOGUING / AUTHORITIES from bug 42032)
and finally escapeHtml(), so non-JSON payloads (item barcodes,
ID-only info, ...) keep displaying as before.

Test plan:
  1. Enable IssueLog.
  2. Trigger an override checkout (e.g. patron with debt, item not for
     loan) and confirm.
  3. Tools -> Log viewer, filter by module=Circulation.
  4. The override row's Info column should show one or more localised
     "Patron had debt", "Item was not for loan", ... lines instead of
     a raw JSON blob.
  5. Plain ISSUE / RETURN rows (no overrides) keep showing the raw
     itemnumber / barcode payload as before.

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
Signed-off-by: Jacob O'Mara <Jacob.omara@openfifth.co.uk>
This commit is contained in:
Martin Renvoize 2026-04-29 10:00:43 +01:00 committed by Jacob O'Mara
parent 78a9da8e33
commit 2b165d347e
No known key found for this signature in database
2 changed files with 248 additions and 2 deletions

View File

@ -94,5 +94,52 @@
CRON: _("Cron job"),
UPGRADE: _("Upgrade"),
};
/*
* Translation map for circulation override codes that appear in the
* `forced` and `confirmations` arrays of a CIRCULATION info column
* JSON payload (see C4::Circulation::CanBookBeIssued and friends).
*/
var override_labels = {
HOLD_POLICY_OVERRIDE: _("Hold policy override"),
INVALID_DATE: _("Invalid date confirmed"),
DEBT_GUARANTEES: _("Guarantees had debts"),
DEBT_GUARANTORS: _("Guarantors had debts"),
DEBT: _("Patron had debt"),
USERBLOCKEDOVERDUE: _("Patron had overdues"),
USERBLOCKEDNOENDDATE: _("Patron had an indefinite restriction"),
ADDITIONAL_MATERIALS: _("Additional materials were confirmed"),
RENEW_ISSUE: _("Renewal"),
ISSUED_TO_ANOTHER: _("Item was issued to another patron prior to this checkout"),
PATRON_CANT: _("Max renewals reached"),
TOO_MANY: _("Too many checkouts"),
PREVISSUE: _("Patron was notified of previous issues"),
NOT_FOR_LOAN: _("Item was not for loan"),
NOT_FOR_LOAN_FORCING: _("Item was not for loan"),
ITEM_LOST: _("Item was reported lost"),
BORRNOTSAMEBRANCH: _("Patron does not belong to this library"),
RENTALCHARGE: _("Item had a rental charge"),
RECALLED: _("Item was recalled"),
RESERVE_WAITING: _("Hold waiting on item"),
RESERVED: _("Hold placed on item"),
TRANSFERRED: _("Item was transferred"),
PROCESSING: _("Item was in processing"),
BOOKED_EARLY: _("Item was booked and checked out early"),
BOOKED_TO_ANOTHER: _("Item was booked to another patron"),
AGE_RESTRICTION: _("Item has an age restriction"),
HIGHHOLDS: _("Item had high holds"),
BIBLIO_ALREADY_ISSUED: _("An item from this bibliographic record is already checked out"),
DEBARRED: _("Patron is restricted"),
RENEWAL_LIMIT: _("Renewal limit override"),
UNSEEN_LIMIT: _("Override unseen item restriction"),
AUTO_TOO_SOON: _("Override auto-renew too soon restriction"),
AUTO_TOO_LATE: _("Override auto-renew too late restriction"),
ACCOUNT_EXPIRED: _("Account expired override"),
ON_RESERVE: _("Override renew hold for another"),
TOO_MUCH: _("Override auto-renew too much owing restriction"),
CURRENTISSUE: _("Item is currently checked out to this patron"),
UNSEEN: _("Unseen renewal"),
SEEN: _("Seen renewal"),
};
</script>
<!-- / str/viewlog.inc -->

View File

@ -1,3 +1,194 @@
/**
* Recursively collect changed entries from a Struct::Diff node.
* Returns an array of { key, before, after } objects.
* Nested diffs are flattened with dot-separated keys.
*/
function collectDiffEntries(node, prefix) {
var entries = [];
Object.keys(node).forEach(function (key) {
var change = node[key];
var fullKey = prefix ? prefix + "." + key : key;
if ("O" in change || "N" in change) {
entries.push({ key: fullKey, before: change.O, after: change.N });
} else if ("A" in change) {
entries.push({ key: fullKey, before: undefined, after: change.A });
} else if ("R" in change) {
entries.push({ key: fullKey, before: change.R, after: undefined });
} else if ("D" in change) {
entries = entries.concat(collectDiffEntries(change.D, fullKey));
}
});
return entries;
}
/**
* Pretty-print a parsed JSON value as nested <ul> markup. Arrays become
* ordered lists preserving MARC-in-JSON field order; objects render their
* keys. Used by both the struct-diff renderer and the catalog info renderer.
*/
function fmtJsonValue(v) {
if (v === undefined || v === null) return "";
if (typeof v !== "object") return $("<span>").text(String(v)).html();
if (Array.isArray(v)) {
var items = v
.map(function (item) {
return "<li>" + fmtJsonValue(item) + "</li>";
})
.join("");
return '<ul class="diff-obj">' + items + "</ul>";
}
var items = Object.keys(v)
.map(function (k) {
return (
"<li>" +
$("<span>").text(k).html() +
": " +
fmtJsonValue(v[k]) +
"</li>"
);
})
.join("");
return '<ul class="diff-obj">' + items + "</ul>";
}
/**
* Render the info column for a CATALOGUING or AUTHORITIES log entry.
* The payload is "biblio <JSON>" or "authority <JSON>" where the JSON
* is the pre-change (MODIFY) or final (DELETE) state of the record,
* including a MARC-in-JSON `_marc` key. Returns rendered HTML, or null
* if the text doesn't look like a record JSON payload (e.g. the bare
* "biblio" / "authority" prefix logged on ADD).
*/
function renderRecordInfo(raw) {
if (!raw) return null;
var match = raw.match(/^(?:biblio|authority)\s+(\{[\s\S]*\})\s*$/);
if (!match) return null;
var parsed;
try {
parsed = JSON.parse(match[1]);
} catch (e) {
return null;
}
return '<div class="loginfo-struct">' + fmtJsonValue(parsed) + "</div>";
}
/* global override_labels */
/**
* Render the info column for a CIRCULATION log entry whose payload is a
* JSON object containing `forced` and/or `confirmations` arrays of
* override codes. Returns rendered HTML, or null if the payload doesn't
* look like a circulation JSON object.
*/
function renderCircInfo(raw) {
if (!raw) return null;
var trimmed = raw.replace(/^\s+|\s+$/g, "");
if (trimmed.charAt(0) !== "{") return null;
var parsed;
try {
parsed = JSON.parse(trimmed);
} catch (e) {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
var forced =
Array.isArray(parsed.forced) && parsed.forced.length
? parsed.forced
: null;
var confirmations =
Array.isArray(parsed.confirmations) && parsed.confirmations.length
? parsed.confirmations
: null;
if (!forced && !confirmations) return null;
function translate(code) {
return override_labels[code] || escapeHtml(code);
}
var html = "";
if (forced) {
html += forced
.map(function (c) {
return '<div class="forced">' + translate(c) + "</div>";
})
.join("");
}
if (confirmations) {
html += confirmations
.map(function (c) {
return '<div class="confirmed">' + translate(c) + "</div>";
})
.join("");
}
return html;
}
/**
* Render Struct::Diff JSON as a human-readable before/after table.
* Falls back to a <pre> block for non-Struct::Diff JSON or plain text.
*/
function renderStructDiff(raw) {
if (!raw) return "";
var diff;
try {
diff = JSON.parse(raw);
} catch (e) {
return $("<pre>").text(raw)[0].outerHTML;
}
if (!diff || !diff.D || typeof diff.D !== "object") {
return $("<pre>").text(raw)[0].outerHTML;
}
var entries = collectDiffEntries(diff.D, "");
if (!entries.length) return "";
var rows = entries
.map(function (e) {
var before =
e.before !== undefined
? "<del>" + fmtJsonValue(e.before) + "</del>"
: "";
var after =
e.after !== undefined
? "<ins>" + fmtJsonValue(e.after) + "</ins>"
: "";
return (
"<tr>" +
'<td class="diff-key">' +
$("<span>").text(e.key).html() +
"</td>" +
'<td class="diff-before">' +
before +
"</td>" +
'<td class="diff-after">' +
after +
"</td>" +
"</tr>"
);
})
.join("");
return (
'<table class="struct-diff">' +
"<thead><tr>" +
"<th>" +
__("Field") +
"</th>" +
"<th>" +
__("Before") +
"</th>" +
"<th>" +
__("After") +
"</th>" +
"</tr></thead>" +
"<tbody>" +
rows +
"</tbody>" +
"</table>"
);
}
function tickAll(section) {
$("input[type='checkbox'][name='" + section + "']").prop("checked", true);
$("#" + section.slice(0, -1) + "ALL").prop("checked", true);
@ -202,8 +393,16 @@ function renderInfo(data, type, row) {
);
}
var rendered = renderRecordInfo(info);
var body = rendered !== null ? rendered : escapeHtml(info);
var body = null;
if (mod == "CIRCULATION") {
body = renderCircInfo(info);
}
if (body === null) {
body = renderRecordInfo(info);
}
if (body === null) {
body = escapeHtml(info);
}
return (
'<div class="loginfo" id="loginfo' +
escapeHtml(action_id) +