/*! * JDI Justice42 Portal - Download button client * ----------------------------------------------------------------------------- * Binds click handlers to any element with class `.jdi-download-document`, * creates a `jdi_downloadrequest` row via the Power Pages Web API, polls the * row until a sync plug-in flips `jdi_status` off Pending, then downloads the * file using one of two delivery modes: * * - `jdi_deliverymode = File` (SharePoint-backed, up to ~50 MB / 50768 KB): * GET /_api/jdi_downloadrequests({id})/jdi_file/$value * -> the browser streams the bytes straight out of Dataverse. * * - `jdi_deliverymode = SasUrl` (blob-backed, any size): * The plug-in writes a short-lived read SAS (10-60 min, tiered on file * size) to `jdi_sasurl`. Bulk downloads auto-start as each SAS becomes * Ready, at most 3 concurrent starts (spaced) so the browser can register * each file. Chrome/Edge may prompt once to Allow multiple downloads. * * After the download is initiated, the staging row is DELETEd so content never * persists on the trigger table. * * Why a trigger record? * Power Pages' Web API (/_api) does not support invoking bound Custom API * actions on custom activity tables. The trigger-record pattern keeps the * same server-side pipeline (whitelist + Graph/Blob) while using only portal- * supported CRUD verbs from the browser. * * Portal configuration prereqs (see docs/PortalDownload_TriggerTable_Setup.md * and docs/PortalDownload_AuditLog_Setup.md): * - Site Settings `Webapi/jdi_downloadrequest/enabled = true` * - Site Settings `Webapi/jdi_downloadrequest/fields` = `jdi_documentid,` * `jdi_contactid,jdi_status,jdi_filename,jdi_mimetype,jdi_filesize,` * `jdi_deliverymode,jdi_sasurl,jdi_sasexpiresat,jdi_file,` * `jdi_errormessage,jdi_ipaddress,jdi_useragent` * - Table Permission (Contact scope) granting Create/Read/Write/Delete on * `jdi_downloadrequest` to the caller's web role. * - Plug-in `CreatePortalDownloadRequestPlugin` registered Sync/PostOperation * on Create of `jdi_downloadrequest`. * - Plug-in `LogPortalDownloadRequestDeletePlugin` registered Sync/ * PostOperation on Delete of `jdi_downloadrequest` with a `PreImage` image * that exposes the columns listed in PortalDownload_AuditLog_Setup.md. * * Global lookups: * - Contact id - window.jdiPortalUser.contactId (required). * * Toast host: * - The script creates a
on first use. * - Errors also render into any element with id="jdi-download-error". * =========================================================================== */ (function () { "use strict"; /** Public constants (kept on window for debugging). */ var PORTAL_DOWNLOAD_VERSION = "6.7.4-page200"; var PORTAL_DOWNLOAD_NAMESPACE = "jdi.portal.download"; /** Max concurrent Prepare (requestAndPoll) workers in the bulk queue. */ var PREPARE_CONCURRENCY = 3; /** Max concurrent blob SAS download starts during auto-wave. */ var BLOB_AUTO_CONCURRENCY = 3; /** Spacing before a start-slot is released so the browser registers each file. */ var BLOB_AUTO_SPACING_MS = 2000; /** * Safety-cap remove for iframe/anchor handoff nodes. Must stay well above Chromium's * connection-stall window — removing at 60s aborted stalled SAS starts (last-N failures). * Also cleaned on pagehide/beforeunload. */ var SAS_HANDOFF_TEARDOWN_MS = 30 * 60 * 1000; var DOWNLOAD_BUTTON_SELECTOR = ".jdi-download-document"; var INLINE_ERROR_HOST_ID = "jdi-download-error"; var TOAST_REGION_ID = "jdi-toast-region"; var TOAST_AUTO_DISMISS_MS = 5000; /** Multi-select + ZIP download (Documents grid). */ var SELECT_ALL_ID = "jdi-select-all"; var ROW_SELECT_SELECTOR = ".jdi-row-select"; var DOWNLOAD_SELECTED_ID = "jdi-download-selected"; var DOWNLOAD_ALL_ID = "jdi-download-all"; var PROGRESS_PANEL_ID = "jdi-progress-panel"; var DOCUMENTS_SECTION_SELECTOR = ".jdi-portal-documents[data-defendant-id]"; var DOCUMENTS_TABLE_BODY_ID = "jdi-documents-table-body"; var DOCUMENTS_PAGER_ID = "jdi-documents-pager"; var DOCUMENTS_STATUS_ID = "jdi-documents-status"; var DOCUMENTS_COLLECTION_URL = "/_api/jdi_defendantdocuments"; var SELECTION_STORAGE_PREFIX = "jdi.portal.download.selection."; /** In-memory selection (documentId -> fileName). Select-all fills this for all N. */ var _selection = {}; var _selectAllMode = false; var _selectionFetchPromise = null; /** Download status side pane. */ var DOWNLOAD_PANE_ID = "jdi-download-pane"; var DOWNLOAD_PANE_TOGGLE_ID = "jdi-download-pane-toggle"; /** Trigger-record endpoints. */ var REQUEST_COLLECTION_URL = "/_api/jdi_downloadrequests"; var REQUEST_ITEM_URL_TEMPLATE = "/_api/jdi_downloadrequests({id})"; var REQUEST_FILE_VALUE_URL_TEMPLATE = "/_api/jdi_downloadrequests({id})/jdi_file/$value"; var REQUEST_SELECT_COLUMNS = "jdi_status,jdi_filename,jdi_mimetype,jdi_filesize,jdi_deliverymode," + "jdi_sasurl,jdi_sasexpiresat,jdi_errormessage"; /** jdi_status option-set values (keep in sync with Dataverse/Schema.cs). */ var STATUS_PENDING = 100000000; var STATUS_SUCCEEDED = 100000001; var STATUS_FAILED = 100000002; /** jdi_deliverymode option-set values (keep in sync with Dataverse/Schema.cs). */ var DELIVERY_MODE_FILE = 100000000; var DELIVERY_MODE_SASURL = 100000001; /** Poll configuration: ~30 s total with gentle backoff. */ var POLL_INITIAL_DELAY_MS = 400; var POLL_MAX_DELAY_MS = 2500; var POLL_MAX_ELAPSED_MS = 30000; /* --------------------------------------------------------------------- * Small DOM + compatibility helpers * ------------------------------------------------------------------- */ function qs(selector, root) { return (root || document).querySelector(selector); } function createEl(tag, attrs, children) { var el = document.createElement(tag); if (attrs) { Object.keys(attrs).forEach(function (key) { if (key === "class") { el.className = attrs[key]; } else if (key === "text") { el.textContent = attrs[key]; } else if (key === "html") { el.innerHTML = attrs[key]; } else { el.setAttribute(key, attrs[key]); } }); } if (children) { children.forEach(function (child) { if (child) { el.appendChild(child); } }); } return el; } function closest(element, selector) { if (!element) { return null; } if (element.closest) { return element.closest(selector); } var node = element; while (node && node.nodeType === 1) { if (node.matches && node.matches(selector)) { return node; } node = node.parentNode; } return null; } function delay(ms) { return new Promise(function (resolve) { window.setTimeout(resolve, ms); }); } /** * Requirement 5: present filenames with real spaces, not %20. Some stored names * arrive percent-encoded (e.g. derived from a URL path). We only decode when the * name looks encoded (contains "%" and no spaces) and decoding succeeds, so names * that legitimately contain "%" are left untouched. */ function prettifyFileName(name) { var value = (name == null ? "" : String(name)).trim(); if (!value) { return "download"; } if (value.indexOf("%") !== -1 && value.indexOf(" ") === -1) { try { var decoded = decodeURIComponent(value.replace(/\+/g, " ")); if (decoded) { value = decoded; } } catch (_) { // Leave as-is: not a valid percent-encoded string. } } return value; } /* --------------------------------------------------------------------- * Audit capture (client IP + user agent) * * These fields ride through on the Create payload and are never read by the * server-side download pipeline. LogPortalDownloadRequestDeletePlugin copies * them onto the jdi_downloadlog row when the trigger record is DELETEd. * * IP is fetched best-effort from an external echo. We cache per session * (including the empty string) so blocked or slow echoes are only retried * once per page load, and we never let the capture block the download more * than 2 seconds. * ------------------------------------------------------------------- */ var IP_ECHO_URL = "https://api.ipify.org?format=json"; var IP_ECHO_TIMEOUT_MS = 2000; var IP_MAX_LENGTH = 45; var USER_AGENT_MAX_LENGTH = 500; var _cachedClientIp = null; function fetchClientIp() { if (_cachedClientIp !== null) { return Promise.resolve(_cachedClientIp); } var controller = ("AbortController" in window) ? new AbortController() : null; var timer = null; if (controller) { timer = window.setTimeout(function () { controller.abort(); }, IP_ECHO_TIMEOUT_MS); } return fetch(IP_ECHO_URL, { signal: controller ? controller.signal : undefined, credentials: "omit", cache: "no-store" }) .then(function (response) { return response && response.ok ? response.json() : null; }) .then(function (json) { var ip = json && json.ip ? String(json.ip).trim() : ""; _cachedClientIp = ip ? ip.slice(0, IP_MAX_LENGTH) : ""; return _cachedClientIp; }) .catch(function () { // Aborted / offline / blocked host / JSON parse error - all best-effort misses. _cachedClientIp = ""; return _cachedClientIp; }) .then(function (value) { if (timer) { window.clearTimeout(timer); } return value; }); } function captureUserAgent() { try { var ua = navigator && navigator.userAgent ? String(navigator.userAgent) : ""; return ua.length > USER_AGENT_MAX_LENGTH ? ua.slice(0, USER_AGENT_MAX_LENGTH) : ua; } catch (_) { return ""; } } /* --------------------------------------------------------------------- * Caller identity resolution * ------------------------------------------------------------------- */ function resolveContactId() { if (window.jdiPortalUser && window.jdiPortalUser.contactId) { return String(window.jdiPortalUser.contactId).trim(); } return ""; } function buildItemUrl(requestId) { return REQUEST_ITEM_URL_TEMPLATE.replace("{id}", encodeURIComponent(requestId)); } function buildFileValueUrl(requestId) { return REQUEST_FILE_VALUE_URL_TEMPLATE.replace("{id}", encodeURIComponent(requestId)); } /* --------------------------------------------------------------------- * Error surface (inline alert + toast) * ------------------------------------------------------------------- */ function ensureToastRegion() { var region = qs("#" + TOAST_REGION_ID); if (region) { return region; } region = createEl("div", { id: TOAST_REGION_ID, "class": "jdi-toast-region", role: "region", "aria-live": "polite" }); document.body.appendChild(region); return region; } function pushToast(type, title, message) { var typeClass = "jdi-toast-" + (type || "info"); var region = ensureToastRegion(); var titleEl = createEl("div", { "class": "jdi-toast-title", text: title || "" }); var messageEl = message ? createEl("div", { "class": "jdi-toast-message", text: message }) : null; var body = createEl("div", { "class": "jdi-toast-body" }, [titleEl, messageEl]); var dismiss = createEl("button", { type: "button", "class": "jdi-toast-dismiss", "aria-label": "Dismiss" }); dismiss.innerHTML = "×"; var toast = createEl("div", { "class": "jdi-toast " + typeClass, role: type === "danger" ? "alert" : "status" }, [body, dismiss]); function remove() { if (toast.parentNode) { toast.parentNode.removeChild(toast); } } dismiss.addEventListener("click", remove); window.setTimeout(remove, TOAST_AUTO_DISMISS_MS); region.appendChild(toast); } function showInlineError(message) { var host = document.getElementById(INLINE_ERROR_HOST_ID); if (host) { host.textContent = message; host.hidden = false; } } function clearInlineError() { var host = document.getElementById(INLINE_ERROR_HOST_ID); if (host) { host.textContent = ""; host.hidden = true; } } function raiseError(message) { var text = message || "Download failed."; showInlineError(text); pushToast("danger", "Download failed", text); } function raiseSuccess(fileName) { pushToast("success", "Download started", fileName || ""); } /* --------------------------------------------------------------------- * Progress panel (Requirement 3) * * A single shared, accessible panel that beats the button-only spinner. * Determinate for multi-file zips ("Preparing 2 of 5 - file.pdf"), and an * indeterminate variant for a single ~10s download. * ------------------------------------------------------------------- */ var _progressEls = null; function ensureProgressPanel() { if (_progressEls && _progressEls.panel && _progressEls.panel.parentNode) { return _progressEls; } var bar = createEl("div", { "class": "jdi-progress-bar" }); var track = createEl("div", { "class": "jdi-progress" }, [bar]); var titleEl = createEl("div", { "class": "jdi-progress-title", text: "Preparing download" }); var messageEl = createEl("div", { "class": "jdi-progress-message", text: "" }); var panel = createEl("div", { id: PROGRESS_PANEL_ID, "class": "jdi-progress-panel", role: "status", "aria-live": "polite" }, [titleEl, messageEl, track]); document.body.appendChild(panel); _progressEls = { panel: panel, bar: bar, track: track, title: titleEl, message: messageEl }; return _progressEls; } function removeProgressPanel() { if (_progressEls && _progressEls.panel && _progressEls.panel.parentNode) { _progressEls.panel.parentNode.removeChild(_progressEls.panel); } _progressEls = null; } var progress = { startDeterminate: function (total, title) { var els = ensureProgressPanel(); els.total = total; els.title.textContent = title || "Preparing download"; els.track.classList.remove("is-indeterminate"); els.bar.style.width = "0%"; els.message.textContent = "Starting..."; }, startIndeterminate: function (title, message) { var els = ensureProgressPanel(); els.title.textContent = title || "Preparing your download"; els.track.classList.add("is-indeterminate"); els.bar.style.width = ""; els.message.textContent = message || "This may take a few seconds..."; }, step: function (index, total, label) { var els = ensureProgressPanel(); var count = total || els.total || 1; var pct = Math.max(0, Math.min(100, Math.round((index / count) * 100))); els.track.classList.remove("is-indeterminate"); els.bar.style.width = pct + "%"; els.message.textContent = "Preparing " + Math.min(index + 1, count) + " of " + count + (label ? " - " + label : ""); }, zipping: function () { var els = ensureProgressPanel(); els.track.classList.remove("is-indeterminate"); els.bar.style.width = "100%"; els.title.textContent = "Packaging files"; els.message.textContent = "Building your zip..."; }, done: function () { removeProgressPanel(); }, fail: function () { removeProgressPanel(); } }; /* --------------------------------------------------------------------- * Download status side pane (queue + per-file progress) * * The pane is the primary UX for multi-file ("Download Selected") * downloads. SharePoint file-column docs are fetched same-origin so we can * show live bytes/speed/ETA; blob (SAS) videos become Ready and wait for a * auto-wave of at most 3 starts (no CORS, no memory blow-up). Errors * surface per-row with a Retry. * ------------------------------------------------------------------- */ var QUEUE_STATE = { QUEUED: "queued", PREPARING: "preparing", DOWNLOADING: "downloading", READY: "ready", ZIPPED: "zipped", HANDED: "handed", DONE: "done", ERROR: "error" }; var STATE_INFO = { queued: { label: "Queued", tone: "neutral" }, preparing: { label: "Preparing", tone: "info" }, downloading: { label: "Downloading", tone: "active" }, ready: { label: "Waiting", tone: "active" }, zipped: { label: "Saved", tone: "success" }, handed: { label: "Starting", tone: "active" }, done: { label: "Started", tone: "success" }, error: { label: "Failed", tone: "danger" } }; var _queue = []; var _queueSeq = 0; var _queueContactId = ""; var _queueRunning = false; var _queueRunningPromise = null; var _paneEls = null; var _fabEl = null; /** Auto blob download wave: Ready items wait here; at most BLOB_AUTO_CONCURRENCY start slots. */ var _blobAutoQueue = []; var _blobAutoActive = 0; var _blobAutoWarnedAllow = false; /** Live iframe/anchor nodes used to hand off SAS downloads; cleaned on unload or safety cap. */ var _sasHandoffNodes = []; var _sasHandoffUnloadBound = false; function isFinishedState(state) { return state === QUEUE_STATE.DONE || state === QUEUE_STATE.ZIPPED || state === QUEUE_STATE.ERROR; } /* ----- formatting helpers ----- */ function formatBytes(n) { if (!n || n < 0) { return "0 B"; } var units = ["B", "KB", "MB", "GB", "TB"]; var i = 0; var v = n; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return (v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)) + " " + units[i]; } function formatSpeed(bps) { return formatBytes(bps) + "/s"; } function formatEta(sec) { sec = Math.round(sec); if (sec < 60) { return sec + "s"; } var m = Math.floor(sec / 60); var s = sec % 60; if (m < 60) { return m + ":" + (s < 10 ? "0" : "") + s; } var h = Math.floor(m / 60); m = m % 60; return h + ":" + (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s; } function buildMetaText(item) { switch (item.state) { case QUEUE_STATE.QUEUED: return "Waiting\u2026"; case QUEUE_STATE.PREPARING: return "Preparing\u2026"; case QUEUE_STATE.DOWNLOADING: var parts = []; if (item.speedBps > 0) { parts.push(formatSpeed(item.speedBps)); } if (item.etaSec > 0) { parts.push(formatEta(item.etaSec) + " left"); } if (item.totalBytes > 0) { parts.push(formatBytes(item.receivedBytes) + " / " + formatBytes(item.totalBytes)); } else if (item.receivedBytes > 0) { parts.push(formatBytes(item.receivedBytes)); } return parts.join(" \u2022 ") || "Downloading\u2026"; case QUEUE_STATE.READY: if (item.kind === "blob") { return item.totalBytes > 0 ? (formatBytes(item.totalBytes) + " \u2022 waiting to start") : "Waiting to start"; } return item.totalBytes > 0 ? (formatBytes(item.totalBytes) + " \u2022 ready") : "Ready"; case QUEUE_STATE.ZIPPED: return item.zipName ? ("Saved in " + item.zipName) : "Saved in zip"; case QUEUE_STATE.HANDED: return "Starting in your browser\u2026"; case QUEUE_STATE.DONE: return "Started in your browser"; default: return ""; } } /* ----- pane DOM ----- */ function ensureDownloadPane() { if (_paneEls && _paneEls.pane && _paneEls.pane.parentNode) { return _paneEls; } var summary = createEl("div", { "class": "jdi-dl-summary", "aria-live": "polite", text: "No downloads yet" }); var speed = createEl("div", { "class": "jdi-dl-aggspeed", text: "" }); var headText = createEl("div", { "class": "jdi-dl-headtext" }, [ createEl("div", { "class": "jdi-dl-title", text: "Downloads" }), summary, speed ]); var clearBtn = createEl("button", { type: "button", "class": "jdi-dl-clear", text: "Clear completed" }); var closeBtn = createEl("button", { type: "button", "class": "jdi-dl-close", "aria-label": "Close downloads panel" }); closeBtn.innerHTML = "×"; var headBtns = createEl("div", { "class": "jdi-dl-headbtns" }, [clearBtn, closeBtn]); var header = createEl("div", { "class": "jdi-dl-header" }, [headText, headBtns]); var list = createEl("div", { "class": "jdi-dl-list", role: "list" }); var pane = createEl("aside", { id: DOWNLOAD_PANE_ID, "class": "jdi-download-pane", role: "complementary", "aria-label": "Downloads", tabindex: "-1" }, [header, list]); document.body.appendChild(pane); clearBtn.addEventListener("click", clearCompletedQueue); closeBtn.addEventListener("click", closePane); pane.addEventListener("keydown", function (event) { if (event.key === "Escape" || event.keyCode === 27) { closePane(); } }); _paneEls = { pane: pane, list: list, summary: summary, speed: speed }; return _paneEls; } function ensureToggleFab() { if (_fabEl && _fabEl.parentNode) { return _fabEl; } var fab = createEl("button", { type: "button", "class": "jdi-dl-fab", "aria-label": "Show downloads" }); fab.innerHTML = '' + ''; fab.addEventListener("click", togglePane); document.body.appendChild(fab); _fabEl = fab; return fab; } function setPaneOpen(open) { var els = ensureDownloadPane(); if (open) { els.pane.classList.add("is-open"); try { els.pane.focus(); } catch (_) { /* ignore */ } } else { els.pane.classList.remove("is-open"); } updateAggregate(); } function openPane() { setPaneOpen(true); } function closePane() { setPaneOpen(false); } function togglePane() { var els = ensureDownloadPane(); setPaneOpen(!els.pane.classList.contains("is-open")); } function renderItemRow(item) { var els = ensureDownloadPane(); var icon = createEl("span", { "class": "jdi-dl-icon", "aria-hidden": "true" }); var name = createEl("div", { "class": "jdi-dl-name" }); var chip = createEl("span", { "class": "jdi-dl-chip" }); var top = createEl("div", { "class": "jdi-dl-top" }, [icon, name, chip]); var barFill = createEl("div", { "class": "jdi-dl-bar-fill" }); var bar = createEl("div", { "class": "jdi-dl-bar" }, [barFill]); var meta = createEl("div", { "class": "jdi-dl-meta" }); var errText = createEl("div", { "class": "jdi-dl-error", role: "alert" }); var retry = createEl("button", { type: "button", "class": "jdi-dl-retry", text: "Retry" }); retry.addEventListener("click", function () { retryItem(item); }); var errWrap = createEl("div", { "class": "jdi-dl-errwrap" }, [errText, retry]); var row = createEl("div", { "class": "jdi-dl-item", role: "listitem" }, [top, bar, meta, errWrap]); els.list.appendChild(row); item._row = row; item._icon = icon; item._name = name; item._chip = chip; item._bar = bar; item._barFill = barFill; item._meta = meta; item._err = errText; item._retry = retry; updateItemRow(item); } function updateItemRow(item) { if (!item._row) { return; } var info = STATE_INFO[item.state] || STATE_INFO.queued; item._chip.textContent = info.label; item._chip.className = "jdi-dl-chip jdi-dl-chip-" + info.tone; item._name.textContent = item.name; item._name.title = item.name; item._icon.innerHTML = item.kind === "blob" ? "🎥" : "📄"; var pct = 0; var indeterminate = false; if (item.kind === "file") { if (item.state === QUEUE_STATE.READY || item.state === QUEUE_STATE.ZIPPED) { pct = 100; } else if (item.totalBytes > 0) { pct = Math.max(0, Math.min(100, (item.receivedBytes / item.totalBytes) * 100)); } else if (item.state === QUEUE_STATE.DOWNLOADING) { indeterminate = true; } } else if (item.kind === "blob") { if (item.state === QUEUE_STATE.READY || item.state === QUEUE_STATE.DONE) { pct = 100; } else if (item.state === QUEUE_STATE.HANDED || item.state === QUEUE_STATE.PREPARING) { indeterminate = true; } } else if (item.state === QUEUE_STATE.PREPARING) { indeterminate = true; } var isError = item.state === QUEUE_STATE.ERROR; item._bar.classList.toggle("is-indeterminate", indeterminate && !isError); item._bar.classList.toggle("is-error", isError); item._barFill.style.width = indeterminate ? "" : (pct + "%"); item._meta.textContent = buildMetaText(item); if (isError) { item._err.textContent = item.error || "Download failed."; item._row.classList.add("is-error"); item._err.style.display = ""; item._retry.style.display = ""; } else { item._row.classList.remove("is-error"); item._err.style.display = "none"; item._retry.style.display = "none"; } } function updateAggregate() { if (!_paneEls) { return; } var total = _queue.length; var doneCount = 0; var failed = 0; var waitingBlob = 0; var handingBlob = 0; var preparing = 0; var aggSpeed = 0; _queue.forEach(function (it) { if (it.state === QUEUE_STATE.DONE || it.state === QUEUE_STATE.ZIPPED) { doneCount++; } else if (it.state === QUEUE_STATE.ERROR) { failed++; } else if (it.kind === "blob" && it.state === QUEUE_STATE.READY) { waitingBlob++; } else if (it.kind === "blob" && it.state === QUEUE_STATE.HANDED) { handingBlob++; } else if (it.state === QUEUE_STATE.QUEUED || it.state === QUEUE_STATE.PREPARING || it.state === QUEUE_STATE.DOWNLOADING || (it.kind === "file" && it.state === QUEUE_STATE.READY)) { preparing++; } if (it.state === QUEUE_STATE.DOWNLOADING && it.speedBps > 0) { aggSpeed += it.speedBps; } }); var txt; if (total === 0) { txt = "No downloads yet"; } else if (preparing > 0 || waitingBlob > 0 || handingBlob > 0 || _blobAutoActive > 0) { txt = doneCount + " of " + total + " complete"; if (handingBlob > 0 || _blobAutoActive > 0) { txt += " \u2022 " + Math.max(handingBlob, _blobAutoActive) + " starting"; } if (waitingBlob > 0) { txt += " \u2022 " + waitingBlob + " waiting"; } if (preparing > 0) { txt += " \u2022 Preparing\u2026"; } } else { txt = doneCount + " of " + total + " complete"; } if (failed > 0) { txt += " \u2022 " + failed + " failed"; } _paneEls.summary.textContent = txt; _paneEls.speed.textContent = aggSpeed > 0 ? formatSpeed(aggSpeed) : ""; updateToggleFab(); } function updateToggleFab() { var fab = ensureToggleFab(); var active = 0; _queue.forEach(function (it) { if (!isFinishedState(it.state)) { active++; } }); var badge = fab.querySelector(".jdi-dl-fab-badge"); if (badge) { badge.textContent = active > 0 ? String(active) : ""; badge.style.display = active > 0 ? "" : "none"; } var open = _paneEls && _paneEls.pane.classList.contains("is-open"); fab.style.display = (_queue.length > 0 && !open) ? "" : "none"; } function setItemState(item, state) { item.state = state; if (state !== QUEUE_STATE.ERROR) { item.error = null; } updateItemRow(item); updateAggregate(); } function enqueueItem(documentId, fileName) { var item = { key: "q" + (++_queueSeq), documentId: documentId, name: prettifyFileName(fileName || "download"), kind: "unknown", state: QUEUE_STATE.QUEUED, receivedBytes: 0, totalBytes: 0, speedBps: 0, etaSec: 0, error: null, requestId: null, sasUrl: "", _blobAutoQueued: false }; _queue.push(item); renderItemRow(item); updateAggregate(); return item; } function clearCompletedQueue() { _queue = _queue.filter(function (it) { if (isFinishedState(it.state)) { if (it._row && it._row.parentNode) { it._row.parentNode.removeChild(it._row); } return false; } return true; }); updateAggregate(); } function retryItem(item) { if (!isFinishedState(item.state)) { return; } item.error = null; item.kind = "unknown"; item.receivedBytes = 0; item.totalBytes = 0; item.speedBps = 0; item.etaSec = 0; item.requestId = null; item.sasUrl = ""; item._blobAutoQueued = false; setItemState(item, QUEUE_STATE.QUEUED); if (_queueContactId) { startQueue(_queueContactId); } } /* ----- auto blob download waves (max BLOB_AUTO_CONCURRENCY) ----- */ function enqueueBlobAutoDownload(item) { if (!item || !item.sasUrl) { return; } if (item._blobAutoQueued) { return; } if (item.state === QUEUE_STATE.DONE || item.state === QUEUE_STATE.ERROR) { return; } item._blobAutoQueued = true; _blobAutoQueue.push(item); if (!_blobAutoWarnedAllow && _blobAutoQueue.length + _blobAutoActive >= 2) { _blobAutoWarnedAllow = true; pushToast("info", "Multiple downloads", "If the browser asks, choose Allow multiple downloads so the remaining files can start."); } pumpBlobAutoDownloads(); } function pumpBlobAutoDownloads() { while (_blobAutoActive < BLOB_AUTO_CONCURRENCY && _blobAutoQueue.length > 0) { var item = _blobAutoQueue.shift(); if (!item || !item.sasUrl) { continue; } if (item.state === QUEUE_STATE.DONE || item.state === QUEUE_STATE.ERROR) { continue; } _blobAutoActive++; setItemState(item, QUEUE_STATE.HANDED); try { downloadViaSasUrl(item.sasUrl, item.name); setItemState(item, QUEUE_STATE.DONE); } catch (err) { item.error = mapDownloadError(err); item._blobAutoQueued = false; setItemState(item, QUEUE_STATE.ERROR); } delay(BLOB_AUTO_SPACING_MS).then(function () { _blobAutoActive = Math.max(0, _blobAutoActive - 1); updateAggregate(); pumpBlobAutoDownloads(); }); } updateAggregate(); } function waitForBlobAutoIdle() { if (_blobAutoActive === 0 && _blobAutoQueue.length === 0) { return Promise.resolve(); } return delay(200).then(waitForBlobAutoIdle); } /* ----- queue execution ----- */ function processQueueItem(item, contactId, entries, usedNames) { setItemState(item, QUEUE_STATE.PREPARING); var requestId = null; return requestAndPoll(item.documentId, contactId) .then(function (res) { requestId = res.requestId; item.requestId = requestId; var row = res.row; item.name = prettifyFileName(row.jdi_filename || item.name || "download"); if (typeof row.jdi_filesize === "number" && row.jdi_filesize > 0) { item.totalBytes = row.jdi_filesize; } if (row.jdi_deliverymode === DELIVERY_MODE_SASURL) { item.kind = "blob"; if (!row.jdi_sasurl) { throw new Error("Download URL is missing."); } // Auto-start via the concurrency wave (max 3). SAS URL only — no file bytes // are held in memory for large blob videos. item.sasUrl = row.jdi_sasurl; setItemState(item, QUEUE_STATE.READY); enqueueBlobAutoDownload(item); return null; } // SharePoint file column: stream same-origin so we can show progress, then zip. item.kind = "file"; setItemState(item, QUEUE_STATE.DOWNLOADING); return fetchFileColumnStreamed(requestId, function (p) { item.receivedBytes = p.received; if (p.total) { item.totalBytes = p.total; } item.speedBps = p.speedBps; item.etaSec = p.etaSec; updateItemRow(item); updateAggregate(); }).then(function (blob) { return blobToUint8(blob).then(function (bytes) { var zipName = ensureUniqueZipName(usedNames, item.name); entries.push({ name: zipName, data: bytes }); setItemState(item, QUEUE_STATE.READY); }); }); }) .catch(function (err) { requestId = requestId || (err && err.requestId) || null; item.error = mapDownloadError(err); setItemState(item, QUEUE_STATE.ERROR); }) .then(function () { if (requestId) { deleteDownloadRequest(requestId); } }); } function runPool(items, concurrency, worker) { var index = 0; function next() { if (index >= items.length) { return Promise.resolve(); } var item = items[index++]; return Promise.resolve() .then(function () { return worker(item); }) .then(next, next); } var starters = []; var n = Math.min(concurrency, items.length); for (var i = 0; i < n; i++) { starters.push(next()); } return Promise.all(starters); } function runQueue(contactId) { var pending = _queue.filter(function (it) { return it.state === QUEUE_STATE.QUEUED; }); if (pending.length === 0) { return Promise.resolve(); } var entries = []; var usedNames = {}; return runPool(pending, PREPARE_CONCURRENCY, function (item) { return processQueueItem(item, contactId, entries, usedNames); }).then(function () { var zipName = ""; if (entries.length > 0) { zipName = "documents-" + entries.length + "-files.zip"; var zipBlob = buildZip(entries); downloadBlob(zipBlob, zipName); _queue.forEach(function (it) { if (it.kind === "file" && it.state === QUEUE_STATE.READY) { it.zipName = zipName; setItemState(it, QUEUE_STATE.ZIPPED); } }); } updateAggregate(); return waitForBlobAutoIdle().then(function () { summarizeQueue(zipName, entries.length); }); }); } function startQueue(contactId) { if (_queueRunning) { return _queueRunningPromise; } _queueRunning = true; _queueRunningPromise = runQueue(contactId).then(function () { _queueRunning = false; // Pick up anything re-queued (Retry) while we were running. if (_queue.some(function (it) { return it.state === QUEUE_STATE.QUEUED; })) { return startQueue(contactId); } }).catch(function () { _queueRunning = false; }); return _queueRunningPromise; } function summarizeQueue(zipName, zipEntryCount) { var zipped = 0; var browser = 0; var waiting = 0; var failed = 0; _queue.forEach(function (it) { if (it.state === QUEUE_STATE.ZIPPED) { zipped++; } else if (it.state === QUEUE_STATE.DONE) { browser++; } else if (it.kind === "blob" && (it.state === QUEUE_STATE.READY || it.state === QUEUE_STATE.HANDED)) { waiting++; } else if (it.state === QUEUE_STATE.ERROR) { failed++; } }); var zipCount = typeof zipEntryCount === "number" ? zipEntryCount : zipped; var parts = []; if (zipCount > 0) { parts.push(zipCount + " SharePoint file(s) saved in " + (zipName || ("documents-" + zipCount + "-files.zip"))); } if (browser > 0) { parts.push(browser + " browser download(s) started"); } if (waiting > 0) { parts.push(waiting + " still starting — if downloads stopped, Allow multiple downloads in the browser and Retry"); } if (failed > 0) { parts.push(failed + " failed (see Downloads panel to retry)"); } if (parts.length === 0) { return; } if (failed > 0 && (zipCount > 0 || browser > 0 || waiting > 0)) { pushToast("warning", "Some downloads need attention", parts.join(" · ")); } else if (failed > 0) { pushToast("danger", "Downloads failed", parts.join(" · ")); } else if (waiting > 0) { pushToast("info", "Downloads in progress", parts.join(" · ")); } else { pushToast("success", "Downloads complete", parts.join(" · ")); } } /* --------------------------------------------------------------------- * Download triggers * ------------------------------------------------------------------- */ function downloadBlob(blob, fileName) { var url = window.URL.createObjectURL(blob); var anchor = createEl("a", { href: url, download: fileName || "download", style: "display:none;" }); document.body.appendChild(anchor); anchor.click(); window.setTimeout(function () { if (anchor.parentNode) { anchor.parentNode.removeChild(anchor); } window.URL.revokeObjectURL(url); }, 0); } /** * Triggers a direct download from an absolute URL (the Azure blob SAS). * Prefer a hidden iframe for bulk/auto-wave (more reliable cross-origin * attachment starts). Fall back to an click. Keep the handoff * node alive until page unload (or a long safety cap) — removing at 60s * aborted requests that were still Stalled behind Chromium's connection limit. */ function downloadViaSasUrl(absoluteUrl, fileName) { if (!absoluteUrl) { throw new Error("Download URL is missing."); } try { downloadViaIframe(absoluteUrl); } catch (_) { downloadViaAnchor(absoluteUrl, fileName); } } function ensureSasHandoffUnloadBound() { if (_sasHandoffUnloadBound) { return; } _sasHandoffUnloadBound = true; var cleanup = function () { clearSasHandoffNodes(); }; window.addEventListener("pagehide", cleanup); window.addEventListener("beforeunload", cleanup); } function trackSasHandoffNode(node) { if (!node) { return; } ensureSasHandoffUnloadBound(); _sasHandoffNodes.push(node); window.setTimeout(function () { removeSasHandoffNode(node); }, SAS_HANDOFF_TEARDOWN_MS); } function removeSasHandoffNode(node) { if (!node) { return; } var idx = _sasHandoffNodes.indexOf(node); if (idx >= 0) { _sasHandoffNodes.splice(idx, 1); } if (node.parentNode) { node.parentNode.removeChild(node); } } function clearSasHandoffNodes() { var nodes = _sasHandoffNodes.slice(); _sasHandoffNodes.length = 0; nodes.forEach(function (node) { if (node && node.parentNode) { node.parentNode.removeChild(node); } }); } function downloadViaIframe(absoluteUrl) { var iframe = createEl("iframe", { src: absoluteUrl, style: "display:none;width:0;height:0;border:0;", "aria-hidden": "true", title: "Download" }); document.body.appendChild(iframe); trackSasHandoffNode(iframe); } function downloadViaAnchor(absoluteUrl, fileName) { var anchor = createEl("a", { href: absoluteUrl, download: fileName || "download", style: "display:none;", rel: "noopener" }); document.body.appendChild(anchor); anchor.click(); trackSasHandoffNode(anchor); } /* --------------------------------------------------------------------- * Minimal store-only ZIP writer (Requirement 2 - Download Selected) * * No third-party library (avoids portal CSP/CDN friction). "Store" mode * (no compression) is ideal for already-compressed media and is trivial to * emit: local file header + raw bytes per entry, then a central directory * and end-of-central-directory record. Filenames are UTF-8 with the general * purpose bit 11 set so spaces/Unicode are preserved. * ------------------------------------------------------------------- */ var _crcTable = null; function crc32Table() { if (_crcTable) { return _crcTable; } var table = new Uint32Array(256); for (var n = 0; n < 256; n++) { var c = n; for (var k = 0; k < 8; k++) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } table[n] = c >>> 0; } _crcTable = table; return table; } function crc32(bytes) { var table = crc32Table(); var crc = 0xFFFFFFFF; for (var i = 0; i < bytes.length; i++) { crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xFF]; } return (crc ^ 0xFFFFFFFF) >>> 0; } function utf8Bytes(str) { if (window.TextEncoder) { return new TextEncoder().encode(str); } // Fallback for very old engines. var encoded = unescape(encodeURIComponent(str)); var out = new Uint8Array(encoded.length); for (var i = 0; i < encoded.length; i++) { out[i] = encoded.charCodeAt(i) & 0xFF; } return out; } function blobToUint8(blob) { if (blob.arrayBuffer) { return blob.arrayBuffer().then(function (buf) { return new Uint8Array(buf); }); } return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve(new Uint8Array(reader.result)); }; reader.onerror = function () { reject(reader.error || new Error("Could not read file bytes.")); }; reader.readAsArrayBuffer(blob); }); } function ensureUniqueZipName(usedNames, name) { var candidate = name; var dot = name.lastIndexOf("."); var base = dot > 0 ? name.slice(0, dot) : name; var ext = dot > 0 ? name.slice(dot) : ""; var counter = 1; while (usedNames[candidate.toLowerCase()]) { candidate = base + " (" + counter + ")" + ext; counter++; } usedNames[candidate.toLowerCase()] = true; return candidate; } /** * Builds a ZIP (store, no compression) Blob from [{ name, data: Uint8Array }]. */ function buildZip(entries) { var fileParts = []; var central = []; var offset = 0; function u16(v) { return new Uint8Array([v & 0xFF, (v >>> 8) & 0xFF]); } function u32(v) { return new Uint8Array([v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF]); } function concat(arrays) { var len = 0, i; for (i = 0; i < arrays.length; i++) { len += arrays[i].length; } var out = new Uint8Array(len); var pos = 0; for (i = 0; i < arrays.length; i++) { out.set(arrays[i], pos); pos += arrays[i].length; } return out; } // DOS time/date are not meaningful here; emit a fixed valid value. var dosTime = u16(0); var dosDate = u16(0x21); // 1980-01-01 var flags = u16(0x0800); // bit 11: filename is UTF-8 entries.forEach(function (entry) { var nameBytes = utf8Bytes(entry.name); var data = entry.data; var crc = crc32(data); var localHeader = concat([ u32(0x04034b50), // local file header signature u16(20), // version needed flags, u16(0), // compression method 0 = store dosTime, dosDate, u32(crc), u32(data.length), // compressed size u32(data.length), // uncompressed size u16(nameBytes.length), u16(0), // extra field length nameBytes ]); fileParts.push(localHeader, data); var centralHeader = concat([ u32(0x02014b50), // central directory header signature u16(20), // version made by u16(20), // version needed flags, u16(0), // compression method dosTime, dosDate, u32(crc), u32(data.length), u32(data.length), u16(nameBytes.length), u16(0), // extra field length u16(0), // comment length u16(0), // disk number start u16(0), // internal attributes u32(0), // external attributes u32(offset), // local header offset nameBytes ]); central.push(centralHeader); offset += localHeader.length + data.length; }); var centralBytes = concat(central); var centralOffset = offset; var end = concat([ u32(0x06054b50), // end of central directory signature u16(0), // disk number u16(0), // disk with central directory u16(entries.length), // entries on this disk u16(entries.length), // total entries u32(centralBytes.length), u32(centralOffset), u16(0) // comment length ]); return new Blob(fileParts.concat([centralBytes, end]), { type: "application/zip" }); } /* --------------------------------------------------------------------- * Network helpers: CSRF token + portal fetch wrappers * ------------------------------------------------------------------- */ function fetchCsrfToken() { return fetch("/_layout/tokenhtml", { credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { var doc = new DOMParser().parseFromString(html, "text/html"); var input = doc.querySelector('input[name="__RequestVerificationToken"]'); return input ? input.value : ""; }); } function readEntityIdFromHeader(response) { var header = response.headers.get("OData-EntityId") || response.headers.get("odata-entityid"); if (!header) { return ""; } // Shape: https://{host}/_api/jdi_downloadrequests() var match = /\(([^)]+)\)\s*$/.exec(header); return match ? match[1] : ""; } function extractErrorMessage(response, fallback) { return response.text().then(function (text) { if (!text) { return fallback || ("HTTP " + response.status); } try { var json = JSON.parse(text); if (json && json.error && json.error.message) { return json.error.message; } return text; } catch (e) { return text; } }); } /* --------------------------------------------------------------------- * Trigger-record operations (create / get / fetch file / delete) * ------------------------------------------------------------------- */ function createDownloadRequest(documentId, contactId) { // Fetch the CSRF token and the client IP in parallel so the audit capture // never serializes against the actual POST. captureUserAgent is synchronous. return Promise.all([fetchCsrfToken(), fetchClientIp()]).then(function (pair) { var token = pair[0]; var ipAddress = pair[1]; var userAgent = captureUserAgent(); var body = {}; // jdi_DocumentId is the single-valued navigation property name for the // document lookup on jdi_downloadrequest (PascalCase schema name, // verified via Dataverse REST Builder). Using the lowercase attribute // logical name instead returns 400 / 0x80048d19 from the Power Pages // Web API because the payload bind can't be resolved to a nav property. body["jdi_DocumentId@odata.bind"] = "/jdi_documents(" + documentId + ")"; body["jdi_contactid@odata.bind"] = "/contacts(" + contactId + ")"; // Audit fields. Server-side pipeline ignores them; the Delete plug-in // copies them into jdi_downloadlog when the trigger row is cleaned up. // Only include when populated so Dataverse never receives empty strings. if (ipAddress) { body["jdi_ipaddress"] = ipAddress; } if (userAgent) { body["jdi_useragent"] = userAgent; } return fetch(REQUEST_COLLECTION_URL, { method: "POST", credentials: "same-origin", headers: { "__RequestVerificationToken": token, "Content-Type": "application/json", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }, body: JSON.stringify(body) }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to create download request.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } var requestId = readEntityIdFromHeader(response); if (!requestId) { throw new Error("Download request created but its id could not be determined."); } return requestId; }); }); } function getDownloadRequest(requestId) { var url = buildItemUrl(requestId) + "?$select=" + REQUEST_SELECT_COLUMNS; return fetch(url, { method: "GET", credentials: "same-origin", headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" } }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to read download request.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } return response.json(); }); } function fetchFileColumn(requestId) { return fetch(buildFileValueUrl(requestId), { method: "GET", credentials: "same-origin", headers: { "X-Requested-With": "XMLHttpRequest" } }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to download file bytes.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } return response.blob(); }); } /** * Same same-origin file-column fetch as fetchFileColumn, but reads the response as a * stream so we can report live bytes/speed/ETA into the download pane. Resolves a Blob. * Falls back to response.blob() when ReadableStream is unavailable. */ function fetchFileColumnStreamed(requestId, onProgress) { return fetch(buildFileValueUrl(requestId), { method: "GET", credentials: "same-origin", headers: { "X-Requested-With": "XMLHttpRequest" } }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to download file bytes.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } var lenHeader = response.headers.get("Content-Length"); var total = lenHeader ? parseInt(lenHeader, 10) : 0; if (isNaN(total)) { total = 0; } if (!response.body || !response.body.getReader) { // No streaming support: resolve the blob in one shot. return response.blob().then(function (blob) { if (onProgress) { onProgress({ received: blob.size, total: blob.size || total, speedBps: 0, etaSec: 0, done: true }); } return blob; }); } function nowMs() { return (window.performance && window.performance.now) ? window.performance.now() : Date.now(); } var reader = response.body.getReader(); var chunks = []; var received = 0; var lastT = nowMs(); var lastBytes = 0; var emaBps = 0; var lastEmit = 0; function pump() { return reader.read().then(function (res) { if (res.done) { if (onProgress) { onProgress({ received: received, total: total || received, speedBps: emaBps, etaSec: 0, done: true }); } var blob = new Blob(chunks); chunks = null; return blob; } var chunk = res.value; chunks.push(chunk); received += chunk.length; var t = nowMs(); var dt = (t - lastT) / 1000; if (dt >= 0.25) { var instBps = (received - lastBytes) / dt; emaBps = emaBps > 0 ? (0.3 * instBps + 0.7 * emaBps) : instBps; lastT = t; lastBytes = received; if (onProgress && (t - lastEmit) >= 250) { lastEmit = t; var etaSec = (total > 0 && emaBps > 0) ? Math.max(0, (total - received) / emaBps) : 0; onProgress({ received: received, total: total, speedBps: emaBps, etaSec: etaSec, done: false }); } } return pump(); }); } return pump(); }); } function deleteDownloadRequest(requestId) { if (!requestId) { return Promise.resolve(); } return fetchCsrfToken().then(function (token) { return fetch(buildItemUrl(requestId), { method: "DELETE", credentials: "same-origin", headers: { "__RequestVerificationToken": token, "X-Requested-With": "XMLHttpRequest" } }); // We intentionally ignore the result: DELETE is best-effort cleanup. }).catch(function () { /* swallow */ }); } /* --------------------------------------------------------------------- * Polling loop * ------------------------------------------------------------------- */ function pollDownloadRequest(requestId) { var deadline = Date.now() + POLL_MAX_ELAPSED_MS; var nextDelay = POLL_INITIAL_DELAY_MS; function attempt() { return getDownloadRequest(requestId).then(function (row) { var status = row && row.jdi_status; if (status === STATUS_SUCCEEDED) { return row; } if (status === STATUS_FAILED) { var err = new Error(row.jdi_errormessage || "Download failed."); err.isDownloadFailure = true; throw err; } if (Date.now() > deadline) { throw new Error("Download request timed out. Please try again."); } return delay(nextDelay).then(function () { nextDelay = Math.min(Math.floor(nextDelay * 1.5), POLL_MAX_DELAY_MS); return attempt(); }); }); } return attempt(); } /* --------------------------------------------------------------------- * Shared request pipeline (single + multi) * ------------------------------------------------------------------- */ /** Create a staging row and poll it to a terminal state. Resolves {requestId, row}. */ function requestAndPoll(documentId, contactId) { var createdRequestId = null; return createDownloadRequest(documentId, contactId) .then(function (requestId) { createdRequestId = requestId; return pollDownloadRequest(requestId); }) .then(function (row) { return { requestId: createdRequestId, row: row }; }) .catch(function (err) { err.requestId = createdRequestId; throw err; }); } /** * Materializes the file bytes for a succeeded row as a Blob. Blob-backed rows are * fetched from the cross-origin Azure SAS (requires storage CORS); SharePoint rows * come back same-origin via the jdi_file column. */ function fetchRowBytes(requestId, row) { if (row.jdi_deliverymode === DELIVERY_MODE_SASURL) { if (!row.jdi_sasurl) { return Promise.reject(new Error("Download URL is missing.")); } return fetch(row.jdi_sasurl, { credentials: "omit", cache: "no-store" }) .then(function (response) { if (!response.ok) { throw new Error("Blob fetch failed (HTTP " + response.status + ")."); } return response.blob(); }); } return fetchFileColumn(requestId); } /** Single-file direct download (no zip). */ function deliverSingle(requestId, row, fileNameHint) { var effectiveFileName = prettifyFileName(row.jdi_filename || fileNameHint || "download"); if (row.jdi_deliverymode === DELIVERY_MODE_SASURL) { if (!row.jdi_sasurl) { throw new Error("Download URL is missing."); } downloadViaSasUrl(row.jdi_sasurl, effectiveFileName); return Promise.resolve(effectiveFileName); } return fetchFileColumn(requestId).then(function (blob) { if (!blob || (typeof blob.size === "number" && blob.size === 0)) { throw new Error("The download response did not include file content."); } downloadBlob(blob, effectiveFileName); return effectiveFileName; }); } function mapDownloadError(err) { var message = (err && err.message) || "Download request failed."; if (err && err.status === 403) { return "You do not have permission to download this document."; } if (err && err.status === 404) { return "Download endpoint not found. Verify Webapi/jdi_downloadrequest/enabled is configured."; } return message; } /* --------------------------------------------------------------------- * Button busy-state management * ------------------------------------------------------------------- */ function setBusy(button, busy) { if (!button) { return; } button.disabled = busy; button.setAttribute("aria-busy", busy ? "true" : "false"); if (busy) { button.classList.add("is-loading"); } else { button.classList.remove("is-loading"); } } /* --------------------------------------------------------------------- * Main click handler * ------------------------------------------------------------------- */ function handleDownloadClick(event) { var button = closest(event.target, DOWNLOAD_BUTTON_SELECTOR); if (!button) { return; } event.preventDefault(); clearInlineError(); var documentId = button.getAttribute("data-document-id"); var fileNameHint = button.getAttribute("data-file-name"); var contactId = resolveContactId(); if (!documentId) { raiseError("Document id is missing."); return; } if (!contactId) { raiseError("You must be signed in to download documents."); return; } setBusy(button, true); progress.startIndeterminate("Preparing your download", prettifyFileName(fileNameHint)); var createdRequestId = null; requestAndPoll(documentId, contactId) .then(function (res) { createdRequestId = res.requestId; return deliverSingle(res.requestId, res.row, fileNameHint).then(function (name) { progress.done(); raiseSuccess(name); }); }) .catch(function (err) { createdRequestId = createdRequestId || (err && err.requestId) || null; progress.fail(); raiseError(mapDownloadError(err)); }) .then(function () { // Clean up the staging row regardless of outcome; keeps the file column out of // long-term storage and avoids growing the table indefinitely. The file fetch // above is fully resolved (blob materialized in memory) before this runs, so // deleting the row does not race the SharePoint-path download. if (createdRequestId) { deleteDownloadRequest(createdRequestId); } setBusy(button, false); }); } /* --------------------------------------------------------------------- * Selection set + Download Selected / Download all * ------------------------------------------------------------------- */ function getRowCheckboxes() { return Array.prototype.slice.call(document.querySelectorAll(ROW_SELECT_SELECTOR)); } function normalizeGuid(value) { return String(value || "").replace(/[{}]/g, "").toLowerCase(); } function getDocumentsTotal() { var section = qs(DOCUMENTS_SECTION_SELECTOR); var pager = document.getElementById(DOCUMENTS_PAGER_ID); var fromSection = section ? parseInt(section.getAttribute("data-documents-total"), 10) : NaN; var fromPager = pager ? parseInt(pager.getAttribute("data-total"), 10) : NaN; var pageCount = getRowCheckboxes().length; var selected = getSelectedCount(); var total = 0; if (!isNaN(fromSection) && fromSection > 0) { total = fromSection; } if (!isNaN(fromPager) && fromPager > total) { total = fromPager; } if (pageCount > total) { total = pageCount; } if (selected > total) { total = selected; } return total; } function getPageRange() { var section = qs(DOCUMENTS_SECTION_SELECTOR); var pager = document.getElementById(DOCUMENTS_PAGER_ID); var from = section ? parseInt(section.getAttribute("data-page-from"), 10) : NaN; var to = section ? parseInt(section.getAttribute("data-page-to"), 10) : NaN; if ((isNaN(from) || isNaN(to)) && pager) { from = parseInt(pager.getAttribute("data-page-from"), 10); to = parseInt(pager.getAttribute("data-page-to"), 10); } if (!isNaN(from) && !isNaN(to) && from > 0 && to >= from) { return { from: from, to: to }; } var count = getRowCheckboxes().length; return count > 0 ? { from: 1, to: count } : { from: 0, to: 0 }; } function getSelectedCount() { return Object.keys(_selection).length; } function getSelectedItems() { return Object.keys(_selection).map(function (documentId) { return { documentId: documentId, fileName: _selection[documentId] || "download" }; }); } function selectionStorageKey(defendantId) { var id = normalizeGuid(defendantId || getDefendantId()); return id ? SELECTION_STORAGE_PREFIX + id : ""; } function persistSelection() { var key = selectionStorageKey(); if (!key) { return; } try { sessionStorage.setItem(key, JSON.stringify(_selection || {})); } catch (_) { /* private mode / quota */ } } function restoreSelection() { var key = selectionStorageKey(); if (!key) { return; } try { var raw = sessionStorage.getItem(key); if (!raw) { return; } var parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { return; } _selection = parsed; } catch (_) { /* ignore corrupt storage */ } } function clearSelection() { _selection = {}; _selectAllMode = false; getRowCheckboxes().forEach(function (cb) { cb.checked = false; }); var selectAll = document.getElementById(SELECT_ALL_ID); if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } persistSelection(); } function syncRowCheckboxesFromSelection() { getRowCheckboxes().forEach(function (cb) { var id = normalizeGuid(cb.getAttribute("data-document-id")); if (id) { cb.setAttribute("data-document-id", id); } cb.checked = !!(id && Object.prototype.hasOwnProperty.call(_selection, id)); }); } function updateDocumentsStatusBar() { var status = document.getElementById(DOCUMENTS_STATUS_ID); if (!status) { return; } var total = getDocumentsTotal(); var selected = getSelectedCount(); var range = getPageRange(); var showing = range.from > 0 ? (range.from + "\u2013" + range.to + " of " + total) : ("0 of " + total); status.textContent = total + " documents · " + selected + " selected · Showing " + showing; } function updateSelectionUi() { var total = getDocumentsTotal(); var selectedCount = getSelectedCount(); var selectAll = document.getElementById(SELECT_ALL_ID); if (selectAll) { selectAll.checked = total > 0 && selectedCount === total; selectAll.indeterminate = selectedCount > 0 && selectedCount < total; selectAll.setAttribute("aria-label", "Select all " + total + " documents"); } var button = document.getElementById(DOWNLOAD_SELECTED_ID); if (button) { button.disabled = selectedCount === 0; var label = button.querySelector(".jdi-btn-label"); if (label) { label.textContent = selectedCount > 0 ? "Download Selected (" + selectedCount + ")" : "Download Selected"; } } var downloadAll = document.getElementById(DOWNLOAD_ALL_ID); if (downloadAll) { downloadAll.disabled = total === 0; var allLabel = downloadAll.querySelector(".jdi-btn-label"); if (allLabel) { allLabel.textContent = "Download all " + total; } } updateDocumentsStatusBar(); } function getDefendantId() { var section = qs(DOCUMENTS_SECTION_SELECTOR); var fromAttr = String((section && section.getAttribute("data-defendant-id")) || "") .replace(/[{}]/g, ""); if (/^[0-9a-f-]{36}$/i.test(fromAttr)) { return fromAttr; } try { var params = new URLSearchParams(window.location.search || ""); var fromQuery = String(params.get("id") || "").replace(/[{}]/g, ""); if (/^[0-9a-f-]{36}$/i.test(fromQuery)) { return fromQuery; } } catch (_) { /* ignore */ } var match = /[?&]id=([{0-9a-f-]{36}}?)/i.exec(window.location.href || ""); return match ? String(match[1] || "").replace(/[{}]/g, "") : ""; } function selectLoadedRowsIntoSelection() { var count = 0; getRowCheckboxes().forEach(function (cb) { var documentId = normalizeGuid(cb.getAttribute("data-document-id")); var fileName = cb.getAttribute("data-file-name") || "download"; if (!documentId) { return; } _selection[documentId] = prettifyFileName(fileName); cb.setAttribute("data-document-id", documentId); cb.checked = true; count++; }); return count; } /** * Server-driven paging for Power Pages / Dataverse list GETs. * Do NOT use $top (suppresses @odata.nextLink) or $skip (unsupported, 9004010B). * Prefer: odata.maxpagesize=N returns pages + nextLink until exhausted. */ var DOCUMENTS_LIST_PAGE_SIZE = 200; function documentsListFetchHeaders() { return { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Prefer": "odata.maxpagesize=" + DOCUMENTS_LIST_PAGE_SIZE }; } function buildDocumentsIdPageUrl(defendantId) { var select = "jdi_defendantdocumentid,_jdi_documentid_value"; var expand = "jdi_DocumentId($select=activityid,subject)"; var filter = "statecode eq 0 and _jdi_documentid_value ne null" + " and _jdi_defendantid_value eq " + defendantId; return DOCUMENTS_COLLECTION_URL + "?$select=" + encodeURIComponent(select) + "&$expand=" + encodeURIComponent(expand) + "&$filter=" + encodeURIComponent(filter) + "&$orderby=" + encodeURIComponent("createdon desc"); } function fetchAllDocumentIds() { var defendantId = getDefendantId(); if (!/^[0-9a-f-]{36}$/i.test(defendantId)) { return Promise.reject(new Error("Unable to resolve the defendant for this page.")); } var collected = {}; function page(url) { return fetch(url, { method: "GET", credentials: "same-origin", headers: documentsListFetchHeaders() }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to load the full document list.") .then(function (message) { throw new Error(message); }); } return response.json(); }).then(function (payload) { (payload.value || []).forEach(function (record) { var document = record.jdi_DocumentId || record.jdi_documentid || {}; var documentId = normalizeGuid( record._jdi_documentid_value || document.activityid || "" ); if (!documentId) { return; } collected[documentId] = prettifyFileName(document.subject || "Untitled document"); }); var next = payload["@odata.nextLink"]; if (next) { return page(next); } return collected; }); } return page(buildDocumentsIdPageUrl(defendantId)); } /* --------------------------------------------------------------------- * Full scrollable grid (Phase B) * * The template renders the first Liquid page (newest 50, with real per-row * "Last Downloaded" history) for fast first paint. When the defendant has * more rows than one page, we progressively fetch via Prefer: * odata.maxpagesize + @odata.nextLink (no $top / $skip — both break paging * on Power Pages). * ------------------------------------------------------------------- */ var _fullGridLoading = false; function buildDocumentsRowPageUrl(defendantId) { var select = "jdi_defendantdocumentid,_jdi_documentid_value,createdon"; // Do NOT select the jdi_documentcategoryid lookup inside the expand: the // Power Pages Web API returns 400 (9004010A) for a lookup in an expanded // $select (confirmed via browser ladder). Category shows "-" for these // API-appended rows; the Liquid first page still renders category badges. var expand = "jdi_DocumentId($select=activityid,subject,jdi_extension,createdon)"; var filter = "statecode eq 0 and _jdi_documentid_value ne null" + " and _jdi_defendantid_value eq " + defendantId; return DOCUMENTS_COLLECTION_URL + "?$select=" + encodeURIComponent(select) + "&$expand=" + encodeURIComponent(expand) + "&$filter=" + encodeURIComponent(filter) + "&$orderby=" + encodeURIComponent("createdon desc"); } function buildCreatedOnCell(iso) { var td = createEl("td"); var when = iso ? new Date(iso) : null; if (!when || isNaN(when.getTime())) { td.appendChild(createEl("span", { "class": "jdi-text-muted", text: "-" })); return td; } // Match the template's Pacific (DST-aware) rendering. The template's own // converter is a private IIFE that already ran, so format directly here. var TZ = "America/Los_Angeles"; var dateStr = when.toLocaleDateString("en-US", { timeZone: TZ, year: "numeric", month: "numeric", day: "numeric" }); var timeStr = when.toLocaleTimeString("en-US", { timeZone: TZ, hour: "numeric", minute: "2-digit" }); td.appendChild(createEl("span", { "class": "jdi-text-body", text: dateStr })); td.appendChild(createEl("span", { "class": "jdi-text-muted", text: " at " + timeStr })); return td; } function buildDocumentGridRow(record) { var doc = record.jdi_DocumentId || {}; var documentId = normalizeGuid(record._jdi_documentid_value || doc.activityid || ""); if (!documentId) { return null; } var subject = prettifyFileName(doc.subject || "Untitled document"); var category = doc["_jdi_documentcategoryid_value@OData.Community.Display.V1.FormattedValue"] || ""; var ext = String(doc.jdi_extension || "").toUpperCase().replace(/\./g, ""); var createdIso = doc.createdon || record.createdon || ""; var tr = createEl("tr", { "data-row-id": record.jdi_defendantdocumentid || "" }); tr.appendChild(createEl("td", { "class": "jdi-col-select" }, [ createEl("input", { type: "checkbox", "class": "jdi-checkbox jdi-row-select", "data-document-id": documentId, "data-file-name": subject, "aria-label": "Select " + subject }) ])); tr.appendChild(createEl("td", null, [ createEl("div", { "class": "jdi-row" }, [ createEl("span", { "class": "jdi-download-icon", "aria-hidden": "true", html: "📄" }), createEl("span", { "class": "jdi-text-body-strong", text: subject }) ]) ])); tr.appendChild(createEl("td", null, [ category ? createEl("span", { "class": "jdi-badge", text: category }) : createEl("span", { "class": "jdi-text-muted", text: "-" }) ])); tr.appendChild(createEl("td", null, [ createEl("span", { "class": "jdi-text-muted", text: ext || "-" }) ])); tr.appendChild(buildCreatedOnCell(createdIso)); // Last Downloaded By / When: real history is only computed in Liquid for // the first page. API-appended rows show placeholders in this phase. tr.appendChild(createEl("td", null, [ createEl("span", { "class": "jdi-text-muted", text: "-" }) ])); tr.appendChild(createEl("td", null, [ createEl("span", { "class": "jdi-text-muted", text: "Never" }) ])); tr.appendChild(createEl("td", { "class": "jdi-col-actions" }, [ createEl("button", { type: "button", "class": "jdi-btn jdi-btn-primary jdi-btn-small jdi-download-document", "data-document-id": documentId, "data-file-name": subject, "aria-label": "Download " + subject, "aria-describedby": "jdi-download-error" }, [ createEl("span", { "class": "jdi-btn-icon-glyph", "aria-hidden": "true", html: "⬇" }), createEl("span", { "class": "jdi-btn-label", text: "Download" }) ]) ])); return tr; } function setDocumentsLoadingStatus() { var status = document.getElementById(DOCUMENTS_STATUS_ID); if (!status) { return; } status.textContent = getDocumentsTotal() + " documents \u00b7 " + getSelectedCount() + " selected \u00b7 Loading " + getRowCheckboxes().length + " of " + getDocumentsTotal() + "\u2026"; } function loadRemainingDocumentRows() { if (_fullGridLoading) { return Promise.resolve(); } var tbody = document.getElementById(DOCUMENTS_TABLE_BODY_ID); var section = qs(DOCUMENTS_SECTION_SELECTOR); if (!tbody || !section) { return Promise.resolve(); } // Only auto-load the full grid from page 1. If a user deep-links to // ?docpage=N (N>1) we leave the Liquid page + Previous/Next pager as the // fallback, rather than redirecting (which would break the pager and, if // the Web API were unavailable, bounce them between pages). var pageNum = parseInt(section.getAttribute("data-page"), 10); if (!isNaN(pageNum) && pageNum > 1) { return Promise.resolve(); } var total = getDocumentsTotal(); if (total <= 0 || getRowCheckboxes().length >= total) { return Promise.resolve(); } var defendantId = getDefendantId(); if (!/^[0-9a-f-]{36}$/i.test(defendantId)) { return Promise.resolve(); } _fullGridLoading = true; var seen = {}; getRowCheckboxes().forEach(function (cb) { var id = normalizeGuid(cb.getAttribute("data-document-id")); if (id) { seen[id] = true; } }); var pager = document.getElementById(DOCUMENTS_PAGER_ID); function appendPayload(payload) { var added = 0; (payload.value || []).forEach(function (record) { var doc = record.jdi_DocumentId || {}; var id = normalizeGuid(record._jdi_documentid_value || doc.activityid || ""); if (!id || seen[id]) { return; } var tr = buildDocumentGridRow(record); if (tr) { tbody.appendChild(tr); seen[id] = true; added++; } }); return added; } function page(url) { return fetch(url, { method: "GET", credentials: "same-origin", headers: documentsListFetchHeaders() }).then(function (response) { if (!response.ok) { return extractErrorMessage(response, "Unable to load the full document list.") .then(function (message) { var err = new Error(message); err.status = response.status; throw err; }); } return response.json(); }).then(function (payload) { appendPayload(payload); setDocumentsLoadingStatus(); var count = getRowCheckboxes().length; // Hide Previous/Next only once the scrollable grid is actually full. if (pager && count >= total) { pager.style.display = "none"; return; } var next = payload["@odata.nextLink"]; if (next) { return page(next); } }); } // First API page overlaps Liquid rows; seen[] skips duplicates. return page(buildDocumentsRowPageUrl(defendantId)) .then(function () { syncRowCheckboxesFromSelection(); var count = getRowCheckboxes().length; section.setAttribute("data-page-from", count > 0 ? "1" : "0"); section.setAttribute("data-page-to", String(count)); if (count >= total) { if (pager) { pager.style.display = "none"; } } else { // Incomplete load: keep Liquid Previous/Next as the fallback. if (pager) { pager.style.display = ""; } pushToast("info", "Showing a partial list", "Loaded " + count + " of " + total + " documents. " + "Use Previous / Next to browse the rest."); } updateSelectionUi(); }) .catch(function (error) { // Web API unavailable: keep the Liquid page + pager as the fallback. if (pager) { pager.style.display = ""; } if (isWebApiUnavailableError(error)) { pushToast("info", "Showing the first page", "The full document grid needs the document Web API enabled. " + "Use Previous / Next to browse the rest."); } else { pushToast("warning", "Could not load all documents", mapDownloadError(error)); } updateSelectionUi(); }) .then(function () { _fullGridLoading = false; }); } function selectAllDocuments() { if (_selectionFetchPromise) { return _selectionFetchPromise; } var selectAll = document.getElementById(SELECT_ALL_ID); if (selectAll) { selectAll.disabled = true; } // Immediate feedback: select every row already on the page, then expand // to the full authorized set via Web API when possible. var localCount = selectLoadedRowsIntoSelection(); _selectAllMode = true; persistSelection(); updateSelectionUi(); var knownTotal = getDocumentsTotal(); if (localCount > 0 && localCount >= knownTotal && knownTotal > 0) { if (selectAll) { selectAll.disabled = false; } pushToast("success", "All documents selected", localCount + " of " + knownTotal + " selected."); return Promise.resolve(); } pushToast("info", "Selecting all documents", "Loading the full list of " + (knownTotal || "all") + " documents\u2026"); _selectionFetchPromise = fetchAllDocumentIds() .then(function (map) { var merged = map || {}; Object.keys(_selection).forEach(function (key) { if (!Object.prototype.hasOwnProperty.call(merged, key)) { merged[key] = _selection[key]; } }); _selection = merged; _selectAllMode = true; persistSelection(); syncRowCheckboxesFromSelection(); updateSelectionUi(); pushToast("success", "All documents selected", getSelectedCount() + " of " + getDocumentsTotal() + " selected."); }) .catch(function (error) { // Keep whatever was selected from loaded rows. _selectAllMode = getSelectedCount() > 0 && getSelectedCount() >= getDocumentsTotal() && getDocumentsTotal() > 0; persistSelection(); syncRowCheckboxesFromSelection(); updateSelectionUi(); if (localCount > 0) { pushToast("warning", "Selected loaded documents only", localCount + " on-screen document(s) selected. Full list unavailable: " + mapDownloadError(error)); } else { pushToast("danger", "Select all failed", mapDownloadError(error)); } }) .then(function () { _selectionFetchPromise = null; if (selectAll) { selectAll.disabled = false; } }); return _selectionFetchPromise; } function handleSelectionChange(event) { var target = event.target; if (!target) { return; } if (target.id === SELECT_ALL_ID) { if (target.checked) { selectAllDocuments(); } else { clearSelection(); updateSelectionUi(); } return; } if (target.classList && target.classList.contains("jdi-row-select")) { var documentId = normalizeGuid(target.getAttribute("data-document-id")); var fileName = target.getAttribute("data-file-name") || "download"; if (!documentId) { return; } target.setAttribute("data-document-id", documentId); if (target.checked) { _selection[documentId] = prettifyFileName(fileName); } else { delete _selection[documentId]; _selectAllMode = false; } persistSelection(); updateSelectionUi(); } } function handleSelectAllClick(event) { // Toggle semantics: any existing selection (full, or partial/indeterminate) // clears; only an empty selection triggers select-all. This fixes the // Chromium quirk where clicking an indeterminate checkbox re-checks it // (firing change with checked=true), which made it impossible to unselect // from a partial selection (e.g. 50 of 321 selected). if (getSelectedCount() === 0) { // Nothing selected yet: let the native toggle + change handler run // selectAllDocuments(). return; } event.preventDefault(); clearSelection(); updateSelectionUi(); } function startBulkDownload(selected, busyButton) { var contactId = resolveContactId(); if (!contactId) { setBusy(busyButton, false); raiseError("You must be signed in to download documents."); return; } selected = (selected || []).filter(function (item) { return item.documentId; }); if (selected.length === 0) { setBusy(busyButton, false); return; } // A single selected file: just do the normal direct download, no zip. if (selected.length === 1) { setBusy(busyButton, true); progress.startIndeterminate("Preparing your download", prettifyFileName(selected[0].fileName)); var soloRequestId = null; requestAndPoll(selected[0].documentId, contactId) .then(function (res) { soloRequestId = res.requestId; return deliverSingle(res.requestId, res.row, selected[0].fileName).then(function (name) { progress.done(); raiseSuccess(name); }); }) .catch(function (err) { soloRequestId = soloRequestId || (err && err.requestId) || null; progress.fail(); raiseError(mapDownloadError(err)); }) .then(function () { if (soloRequestId) { deleteDownloadRequest(soloRequestId); } setBusy(busyButton, false); clearSelection(); updateSelectionUi(); }); return; } // Multiple files: SharePoint -> zip; blob -> auto-start (max 3 at a time). _queueContactId = contactId; selected.forEach(function (item) { enqueueItem(item.documentId, item.fileName); }); openPane(); clearSelection(); updateSelectionUi(); setBusy(busyButton, true); startQueue(contactId).then(function () { setBusy(busyButton, false); updateSelectionUi(); }); } /** * Prepare multiple selected documents. SharePoint bytes are stored in one ZIP; * blob-backed files auto-start via SAS as each becomes Ready (max 3 at a time). */ function handleDownloadSelected(event) { if (event) { event.preventDefault(); } clearInlineError(); startBulkDownload(getSelectedItems(), document.getElementById(DOWNLOAD_SELECTED_ID)); } function isWebApiUnavailableError(error) { var message = String((error && error.message) || "").toLowerCase(); return (error && error.status === 404) || message.indexOf("not found for the segment") !== -1 || message.indexOf("resource not found") !== -1; } function handleDownloadAll(event) { if (event) { event.preventDefault(); } clearInlineError(); var button = document.getElementById(DOWNLOAD_ALL_ID); var total = getDocumentsTotal(); if (total === 0) { return; } // Fast path: when every authorized row is already rendered on this page // (single-page defendants), there is no need to call the Web API - select // the loaded rows and download them directly. This avoids the // /_api/jdi_defendantdocuments 404 that otherwise breaks Download all // whenever that table's Web API is not enabled for the portal. var loaded = getRowCheckboxes().length; if (loaded > 0 && loaded >= total) { _selection = {}; selectLoadedRowsIntoSelection(); _selectAllMode = true; persistSelection(); updateSelectionUi(); startBulkDownload(getSelectedItems(), button); return; } setBusy(button, true); pushToast("info", "Preparing download all", "Loading all " + total + " document ids\u2026"); fetchAllDocumentIds() .then(function (map) { _selection = map || {}; _selectAllMode = true; persistSelection(); syncRowCheckboxesFromSelection(); updateSelectionUi(); startBulkDownload(getSelectedItems(), button); }) .catch(function (error) { setBusy(button, false); // Web API not enabled for this table: gracefully fall back to the // rows on the current page and let the user page through the rest // with Previous / Next, instead of a raw OData error. if (isWebApiUnavailableError(error)) { _selection = {}; var localCount = selectLoadedRowsIntoSelection(); _selectAllMode = false; persistSelection(); updateSelectionUi(); if (localCount > 0) { pushToast("warning", "Downloading this page only", "Downloading the " + localCount + " document(s) on this page. To " + "download all " + total + " at once, the document Web API must be " + "enabled - contact support. Use Previous / Next to reach the rest."); startBulkDownload(getSelectedItems(), button); return; } } raiseError(mapDownloadError(error)); }); } /* --------------------------------------------------------------------- * Bootstrap * ------------------------------------------------------------------- */ function bootstrap() { if (window[PORTAL_DOWNLOAD_NAMESPACE + ".bound"]) { try { console.warn( "[jdi-portal-download] skipped bootstrap; already bound. Loaded version would be", PORTAL_DOWNLOAD_VERSION, "| active:", window.jdiPortalDownload && window.jdiPortalDownload.version ); } catch (_) { /* ignore */ } return; } window[PORTAL_DOWNLOAD_NAMESPACE + ".bound"] = true; try { console.log("[jdi-portal-download] version", PORTAL_DOWNLOAD_VERSION); } catch (_) { /* ignore */ } document.addEventListener("click", handleDownloadClick); // Selection + Download Selected wiring (delegated so it survives partial re-renders). document.addEventListener("change", handleSelectionChange); document.addEventListener("click", function (event) { if (event.target && event.target.id === SELECT_ALL_ID) { handleSelectAllClick(event); } else if (closest(event.target, "#" + DOWNLOAD_SELECTED_ID)) { handleDownloadSelected(event); } else if (closest(event.target, "#" + DOWNLOAD_ALL_ID)) { handleDownloadAll(event); } else if (closest(event.target, "#" + DOWNLOAD_PANE_TOGGLE_ID)) { event.preventDefault(); togglePane(); } }); // Normalize Liquid-rendered document ids so selection keys match API ids. getRowCheckboxes().forEach(function (cb) { var id = normalizeGuid(cb.getAttribute("data-document-id")); if (id) { cb.setAttribute("data-document-id", id); } }); restoreSelection(); syncRowCheckboxesFromSelection(); updateSelectionUi(); // Phase B: progressively load any rows beyond the first Liquid page into // one scrollable grid (no-op for single-page defendants / when on ?docpage>1). loadRemainingDocumentRows(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", bootstrap); } else { bootstrap(); } /* Expose a handful of helpers for diagnostics or bespoke pages. */ window.jdiPortalDownload = { trigger: function (documentId, fileName) { var fakeButton = createEl("button", { "class": "jdi-download-document jdi-btn", "data-document-id": documentId, "data-file-name": fileName || "" }); document.body.appendChild(fakeButton); fakeButton.click(); document.body.removeChild(fakeButton); }, toast: pushToast, downloadSelected: handleDownloadSelected, downloadAll: handleDownloadAll, buildZip: buildZip, prettifyFileName: prettifyFileName, openDownloadPane: openPane, closeDownloadPane: closePane, toggleDownloadPane: togglePane, version: PORTAL_DOWNLOAD_VERSION }; }());