/*!
* 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
};
}());