From 2b165d347ee300b8a459da21060d2ee1b11e14ea Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Wed, 29 Apr 2026 10:00:43 +0100 Subject: [PATCH] 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 Signed-off-by: Jacob O'Mara --- .../prog/en/includes/str/viewlog.inc | 47 ++++ koha-tmpl/intranet-tmpl/prog/js/viewlog.js | 203 +++++++++++++++++- 2 files changed, 248 insertions(+), 2 deletions(-) diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/str/viewlog.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/str/viewlog.inc index 53e7a124ad..b7e449f03a 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/str/viewlog.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/str/viewlog.inc @@ -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"), + }; diff --git a/koha-tmpl/intranet-tmpl/prog/js/viewlog.js b/koha-tmpl/intranet-tmpl/prog/js/viewlog.js index 7e8296b758..e7bac21dda 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/viewlog.js +++ b/koha-tmpl/intranet-tmpl/prog/js/viewlog.js @@ -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
    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 $("").text(String(v)).html(); + if (Array.isArray(v)) { + var items = v + .map(function (item) { + return "
  • " + fmtJsonValue(item) + "
  • "; + }) + .join(""); + return '
      ' + items + "
    "; + } + var items = Object.keys(v) + .map(function (k) { + return ( + "
  • " + + $("").text(k).html() + + ": " + + fmtJsonValue(v[k]) + + "
  • " + ); + }) + .join(""); + return '
      ' + items + "
    "; +} + +/** + * Render the info column for a CATALOGUING or AUTHORITIES log entry. + * The payload is "biblio " or "authority " 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 '
    ' + fmtJsonValue(parsed) + "
    "; +} + +/* 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 '
    ' + translate(c) + "
    "; + }) + .join(""); + } + if (confirmations) { + html += confirmations + .map(function (c) { + return '
    ' + translate(c) + "
    "; + }) + .join(""); + } + return html; +} + +/** + * Render Struct::Diff JSON as a human-readable before/after table. + * Falls back to a
     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 $("
    ").text(raw)[0].outerHTML;
    +    }
    +    if (!diff || !diff.D || typeof diff.D !== "object") {
    +        return $("
    ").text(raw)[0].outerHTML;
    +    }
    +
    +    var entries = collectDiffEntries(diff.D, "");
    +    if (!entries.length) return "";
    +
    +    var rows = entries
    +        .map(function (e) {
    +            var before =
    +                e.before !== undefined
    +                    ? "" + fmtJsonValue(e.before) + ""
    +                    : "";
    +            var after =
    +                e.after !== undefined
    +                    ? "" + fmtJsonValue(e.after) + ""
    +                    : "";
    +            return (
    +                "" +
    +                '' +
    +                $("").text(e.key).html() +
    +                "" +
    +                '' +
    +                before +
    +                "" +
    +                '' +
    +                after +
    +                "" +
    +                ""
    +            );
    +        })
    +        .join("");
    +
    +    return (
    +        '' +
    +        "" +
    +        "" +
    +        "" +
    +        "" +
    +        "" +
    +        "" +
    +        rows +
    +        "" +
    +        "
    " + + __("Field") + + "" + + __("Before") + + "" + + __("After") + + "
    " + ); +} + 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 ( '