1"use strict"; 2 3/** 4 * Microfiche viewer (vanilla JS) 5 * 6 * Responsibilities 7 * - Fetch issue documents from the Microfiche API 8 * - Provide a simple thumbnail grid + active-page viewer for microfiche issues 9 * - Expose a small public API on window.HarpersPaywallDocuments 10 * 11 * The viewer does not depend on jQuery. If Bootstrap's modal is present, it 12 * will be used; otherwise a minimal class/attribute toggle is applied. 13 */ 14(function (window, document) { 15 'use strict'; 16 17 /** 18 * Remove trailing slashes from a URL or path string. 19 * @param {string} value 20 * @returns {string} 21 */ 22 function trimTrailingSlash(value) { 23 if (!value) { 24 return ''; 25 } 26 return String(value).replace(/\/+$/, ''); 27 } 28 29 /** 30 * Coerce a year value to a 4-digit string or null when invalid. 31 * @param {string|number|null|undefined} value 32 * @returns {string|null} 33 */ 34 function normalizeYear(value) { 35 if (value === undefined || value === null) { 36 return null; 37 } 38 var yearInt = parseInt(value, 10); 39 if (isNaN(yearInt)) { 40 return null; 41 } 42 return String(yearInt); 43 } 44 45 /** 46 * Coerce a month value to a 2-digit string (01â12) or null when invalid. 47 * @param {string|number|null|undefined} value 48 * @returns {string|null} 49 */ 50 function normalizeMonth(value) { 51 if (value === undefined || value === null) { 52 return null; 53 } 54 var monthInt = parseInt(value, 10); 55 if (isNaN(monthInt)) { 56 return null; 57 } 58 var monthString = String(monthInt); 59 return monthString.length === 1 ? '0' + monthString : monthString; 60 } 61 62 /** 63 * Normalize a star/page-like identifier by stripping non-digits. 64 * @param {string|number|null|undefined} value 65 * @returns {string|null} 66 */ 67 function parseStarNumber(value) { 68 if (value === undefined || value === null) { 69 return null; 70 } 71 var digits = String(value).replace(/\D+/g, ''); 72 return digits.length ? digits : null; 73 } 74 75 /** 76 * Find an attachment by star number across structured and filename hints. 77 * @param {Array<{id:string, filename?:string, starNumber?:string|number}>} attachments 78 * @param {string|number} star 79 * @returns {any|null} 80 */ 81 function findAttachmentByStar(attachments, star) { 82 if (!star || !attachments) { 83 return null; 84 } 85 var normalizedStar = parseStarNumber(star); 86 if (!normalizedStar) { 87 return null; 88 } 89 for (var i = 0; i < attachments.length; i += 1) { 90 var attachmentStar = parseStarNumber(attachments[i].starNumber); 91 if (attachmentStar && attachmentStar === normalizedStar) { 92 return attachments[i]; 93 } 94 } 95 for (var j = 0; j < attachments.length; j += 1) { 96 var filename = attachments[j].filename || ''; 97 if (!filename) { 98 continue; 99 } 100 var parts = filename.split('-'); 101 var lastSegment = parts.length ? parts[parts.length - 1].replace(/\.[^.]+$/, '') : ''; 102 var filenameStar = parseStarNumber(lastSegment); 103 if (filenameStar && filenameStar === normalizedStar) { 104 return attachments[j]; 105 } 106 } 107 return null; 108 } 109 110 /** 111 * Lightweight client for the Microfiche API with simple memoization. 112 * @returns {{ 113 * hydratePdfLinks: () => void, 114 * fetchIssue: (year: string|number, month: string|number, options?: { apiBase?: string, starNumber?: string|number }) => Promise<{issue:any, subscribed:boolean, match:any, apiBase:string}>, 115 * getApiBase: () => string, 116 * hydrateElement: (el: Element) => Promise<void> 117 * }} 118 */ 119 function createDocumentsClient() { 120 var DEFAULT_API_BASE = 'https://api.harpers.org/microfiche'; 121 var globalBase = typeof window.HARPERS_PAYWALL_API_BASE === 'string' && window.HARPERS_PAYWALL_API_BASE ? window.HARPERS_PAYWALL_API_BASE : DEFAULT_API_BASE; 122 var resolvedGlobalBase = trimTrailingSlash(globalBase); 123 var cache = {}; 124 125 /** Build a stable cache key for a year-month request. */ 126 function buildCacheKey(base, year, month) { 127 return base + '|' + year + '-' + month; 128 } 129 130 /** Get or initialize a memoized cache bucket for an issue. */ 131 function getCacheEntry(key) { 132 if (!cache[key]) { 133 cache[key] = { 134 data: null, 135 matches: {}, 136 promise: null 137 }; 138 } 139 return cache[key]; 140 } 141 142 /** 143 * Request a specific issue; when starNumber is provided, the API 144 * may preselect the corresponding attachment as a match. 145 */ 146 function requestIssue(base, year, month, star) {
147 var endpoint = trimTrailingSlash(base) + '/issue/' + year + '/' + month; 148 if (star) { 149 endpoint += '?starNumber=' + encodeURIComponent(star); 150 } 151 return fetch(endpoint, { 152 credentials: 'include' 153 }).then(function (response) { 154 if (!response.ok) { 155 var error = new Error('Microfiche request failed: ' + response.status); 156 error.status = response.status; 157 throw error; 158 } 159 return response.json(); 160 }).then(function (payload) { 161 payload.apiBase = base; 162 return payload; 163 }); 164 } 165 166 // Note: PDFs are resolved server-side via the single /issue route. 167 168 /** 169 * Fetch an issue with local memoization and star matching. 170 * @returns {Promise<{data:any, match:any}>} 171 */ 172 function getIssue(base, year, month, star) { 173 var normalizedYear = normalizeYear(year); 174 var normalizedMonth = normalizeMonth(month); 175 if (!normalizedYear || !normalizedMonth) { 176 return Promise.reject(new Error('Invalid issue parameters')); 177 } 178 var normalizedStar = star ? parseStarNumber(star) : null; 179 var key = buildCacheKey(base, normalizedYear, normalizedMonth); 180 var entry = getCacheEntry(key); 181 if (entry.data) { 182 if (!normalizedStar) { 183 return Promise.resolve({ 184 data: entry.data, 185 match: entry.data.match || null 186 }); 187 } 188 if (Object.prototype.hasOwnProperty.call(entry.matches, normalizedStar)) { 189 return Promise.resolve({ 190 data: entry.data, 191 match: entry.matches[normalizedStar] 192 }); 193 } 194 var cachedAttachments = entry.data.issue && entry.data.issue.attachments ? entry.data.issue.attachments : []; 195 var fallback = findAttachmentByStar(cachedAttachments, normalizedStar); 196 entry.matches[normalizedStar] = fallback || null; 197 return Promise.resolve({ 198 data: entry.data, 199 match: fallback || null 200 }); 201 } 202 if (!entry.promise) { 203 entry.promise = requestIssue(base, normalizedYear, normalizedMonth, normalizedStar).then(function (payload) { 204 entry.data = payload; 205 entry.promise = null; 206 return payload; 207 })["catch"](function (error) { 208 entry.promise = null; 209 throw error; 210 }); 211 } 212 return entry.promise.then(function (payload) { 213 if (!normalizedStar) { 214 return { 215 data: payload, 216 match: payload.match || null 217 }; 218 } 219 if (Object.prototype.hasOwnProperty.call(entry.matches, normalizedStar)) { 220 return { 221 data: payload, 222 match: entry.matches[normalizedStar] 223 }; 224 } 225 var attachments = payload.issue && payload.issue.attachments ? payload.issue.attachments : []; 226 var remoteMatch = payload.match || null; 227 if (!remoteMatch) { 228 remoteMatch = findAttachmentByStar(attachments, normalizedStar); 229 } 230 if (remoteMatch) { 231 entry.matches[normalizedStar] = remoteMatch || null; 232 return { 233 data: payload, 234 match: remoteMatch || null 235 }; 236 } 237 entry.matches[normalizedStar] = null; 238 return { 239 data: payload, 240 match: null 241 }; 242 }); 243 } 244 245 /** 246 * Parse JSON from a [data-paywall-pdf] element payload. 247 * @param {Element} element 248 * @returns {any|null} 249 */ 250 function getPaywallData(element) { 251 var raw = element.getAttribute('data-paywall-pdf'); 252 if (!raw) { 253 return null; 254 } 255 try { 256 return JSON.parse(raw); 257 } catch (error) { 258 console.warn('Invalid paywall PDF metadata', error); 259 return null; 260 } 261 } 262 263 /** 264 * Render a basic iframe viewer in the provided container. 265 * @param {Element} container 266 * @param {string} documentUrl 267 */ 268 function renderPdfViewer(container, documentUrl) { 269 if (!container || !documentUrl) { 270 return; 271 } 272 container.innerHTML = ''; 273 var iframe = document.createElement('iframe'); 274 iframe.src = documentUrl; 275 iframe.title = 'PDF viewer'; 276 iframe.loading = 'lazy'; 277 iframe.setAttribute('style', 'width: 100%; min-height: 720px; border: 0;'); 278 container.appendChild(iframe); 279 } 280 281 /** 282 * Hydrate a single [data-paywall-pdf] elementâs link and optional viewer. 283 * Attributes expected inside the element:
284 * - [data-paywall-pdf-link] 285 * - [data-paywall-pdf-viewer] (optional) 286 * 287 * @param {Element} element 288 * @returns {Promise<void>} 289 */ 290 function hydratePdfElement(element) { 291 if (!element || element.dataset.paywallPdfHydrated === 'ready') { 292 return Promise.resolve(); 293 } 294 element.dataset.paywallPdfHydrated = 'loading'; 295 var metadata = getPaywallData(element); 296 if (!metadata) { 297 element.dataset.paywallPdfHydrated = 'error'; 298 return Promise.resolve(); 299 } 300 var year = metadata.issueYear; 301 var month = metadata.issueMonth; 302 var star = metadata.starNumber; 303 var base = trimTrailingSlash(metadata.apiBase || resolvedGlobalBase); 304 return getIssue(base, year, month, star).then(function (result) { 305 var payload = result.data || {}; 306 var subscribed = !!payload.subscribed; 307 var attachments = payload.issue && payload.issue.attachments ? payload.issue.attachments : []; 308 var attachment = result.match || null; 309 if (!attachment && star) { 310 attachment = findAttachmentByStar(attachments, star); 311 } 312 if (!attachment && attachments.length) { 313 attachment = attachments[0]; 314 } 315 var link = element.querySelector('[data-paywall-pdf-link]'); 316 if (!link) { 317 element.dataset.paywallPdfHydrated = 'error'; 318 return; 319 } 320 if (!attachment || !subscribed) { 321 link.classList.add('disabled'); 322 link.setAttribute('aria-disabled', 'true'); 323 if (!subscribed) { 324 element.dataset.paywallPdfHydrated = 'unauthorized'; 325 } else { 326 element.dataset.paywallPdfHydrated = 'no-match'; 327 } 328 return; 329 } 330 var documentUrl = base + '/document/' + attachment.id; 331 link.href = documentUrl; 332 link.classList.remove('disabled'); 333 link.classList.add('active'); 334 link.removeAttribute('aria-disabled'); 335 link.setAttribute('target', '_blank'); 336 link.setAttribute('rel', 'noopener'); 337 link.dataset.pdfAttachmentId = attachment.id; 338 element.dataset.paywallPdfHydrated = 'ready'; 339 var viewer = element.querySelector('[data-paywall-pdf-viewer]'); 340 if (viewer) { 341 renderPdfViewer(viewer, documentUrl); 342 } 343 })["catch"](function (error) { 344 console.warn('Unable to hydrate paywall PDF link', error); 345 element.dataset.paywallPdfHydrated = 'error'; 346 }); 347 } 348 349 /** Hydrate all [data-paywall-pdf] elements on the page. */ 350 function hydratePdfLinks() { 351 var nodes = document.querySelectorAll('[data-paywall-pdf]'); 352 for (var i = 0; i < nodes.length; i += 1) { 353 hydratePdfElement(nodes[i]); 354 } 355 } 356 return { 357 hydratePdfLinks: hydratePdfLinks, 358 fetchIssue: function fetchIssue(year, month, options) { 359 var opts = options || {}; 360 var base = trimTrailingSlash(opts.apiBase || resolvedGlobalBase); 361 var star = opts.starNumber || null; 362 return getIssue(base, year, month, star).then(function (result) { 363 return { 364 issue: result.data && result.data.issue ? result.data.issue : null, 365 subscribed: !!(result.data && result.data.subscribed), 366 match: result.match || null, 367 apiBase: base 368 }; 369 }); 370 }, 371 getApiBase: function getApiBase() { 372 return resolvedGlobalBase; 373 }, 374 hydrateElement: hydratePdfElement 375 }; 376 } 377 378 /** Toggle index grid vs active-page wrapper for the in-page viewer. */ 379 function toggleMicrofiche() { 380 var grid = document.querySelector('.js-microfiche-contents .pages-grid'); 381 var active = document.querySelector('.js-microfiche-contents .active-page-wrapper'); 382 if (grid) grid.classList.toggle('d-none'); 383 if (active) active.classList.toggle('d-none'); 384 } 385 var documentsClient = createDocumentsClient(); 386 387 /** 388 * Wire up the microfiche modal + grid interactions. 389 * @param {boolean} active - open the viewer immediately 390 * @param {number|false} pageInit - optional initial page index 391 */ 392 function microficheHandler(active, pageInit) { 393 var triggers = document.querySelectorAll('.js-load-microfiche'); 394 for (var t = 0; t < triggers.length; t += 1) { 395 triggers[t].addEventListener('click', function (event) { 396 event.preventDefault(); 397 var trigger = event.currentTarget; 398 var year = trigger.getAttribute('data-year');
399 var month = trigger.getAttribute('data-month'); 400 var base = trigger.getAttribute('data-paywallApi') || documentsClient.getApiBase(); 401 documentsClient.fetchIssue(year, month, { 402 apiBase: base 403 }).then(function (issueResult) { 404 var issue = issueResult.issue || {}; 405 var attachments = issue.attachments ? issue.attachments.slice() : []; 406 var subscribed = !!issueResult.subscribed; 407 var contents = document.querySelector('.js-microfiche-contents'); 408 if (!contents) return; 409 var paywallMsg = contents.querySelector('#full-paywall'); 410 var unlocked = contents.querySelector('#non-full-paywall'); 411 if (paywallMsg && unlocked) { 412 if (subscribed) { 413 paywallMsg.classList.add('d-none'); 414 unlocked.classList.remove('d-none'); 415 } else { 416 paywallMsg.classList.remove('d-none'); 417 unlocked.classList.add('d-none'); 418 } 419 } 420 if (!attachments.length) { 421 console.warn('No microfiche attachments for issue', year, month); 422 return; 423 } 424 var baseUrl = trimTrailingSlash(issueResult.apiBase || documentsClient.getApiBase()); 425 var grid = document.querySelector('.js-microfiche-contents .pages-grid'); 426 var activeWrapper = document.querySelector('.js-microfiche-contents .active-page-wrapper'); 427 if (!grid || !activeWrapper) return; 428 grid.innerHTML = ''; 429 attachments.forEach(function (attachment, index) { 430 var previous = index > 0 && attachments[index - 1] ? attachments[index - 1].id : ''; 431 var next = index < attachments.length - 1 && attachments[index + 1] ? attachments[index + 1].id : ''; 432 var a = document.createElement('a'); 433 a.className = 'js-microfiche-link microfiche-item'; 434 a.setAttribute('data-id', attachment.id); 435 a.setAttribute('data-index', String(index)); 436 a.setAttribute('data-prev', previous); 437 a.setAttribute('data-next', next); 438 a.innerHTML = '<img src="' + baseUrl + '/thumb/' + attachment.id + '" alt="Microfiche page ' + (index + 1) + '" />'; 439 grid.appendChild(a); 440 }); 441 function setActivePage(index) { 442 if (!attachments[index]) return; 443 activeWrapper.classList.add('active'); 444 var activePage = document.querySelector('.js-microfiche-contents .active-page'); 445 if (activePage) { 446 activePage.innerHTML = '<img src="' + baseUrl + '/full/' + attachments[index].id + '" alt="Microfiche page ' + (index + 1) + '" />'; 447 } 448 var prevBtn = document.querySelector('.js-microfiche-prev-page'); 449 var nextBtn = document.querySelector('.js-microfiche-next-page'); 450 if (prevBtn) { 451 prevBtn.onclick = function (e) { 452 e.preventDefault(); 453 index = index - 1 < 0 ? attachments.length - 1 : index - 1;
454 setActivePage(index); 455 }; 456 } 457 if (nextBtn) { 458 nextBtn.onclick = function (e) { 459 e.preventDefault(); 460 index = index + 1 >= attachments.length ? 0 : index + 1; 461 setActivePage(index); 462 }; 463 } 464 var nav = document.querySelector('.js-microfiche-page-activenav'); 465 if (nav) nav.textContent = index + 1 + ' of ' + attachments.length; 466 } 467 468 // Open modal if Bootstrap available, else fallback to class toggle 469 var modal = document.querySelector('.js-microfiche-modal'); 470 if (modal) { 471 var $ = window.jQuery || window.$; 472 if ($ && $.fn && typeof $.fn.modal === 'function') { 473 $(modal).modal(); 474 } else { 475 modal.classList.add('show'); 476 modal.style.display = 'block'; 477 } 478 } 479 var returnBtn = document.querySelector('.js-microfiche-return-index'); 480 if (returnBtn) { 481 returnBtn.onclick = function (e) { 482 e.preventDefault(); 483 toggleMicrofiche(); 484 }; 485 } 486 var links = document.querySelectorAll('.js-microfiche-link'); 487 for (var li = 0; li < links.length; li += 1) { 488 links[li].addEventListener('click', function (e) { 489 e.preventDefault(); 490 var idx = parseInt(this.getAttribute('data-index'), 10); 491 if (!isNaN(idx)) {
492 setActivePage(idx); 493 toggleMicrofiche(); 494 } 495 }); 496 } 497 if (active && typeof pageInit === 'number') { 498 setActivePage(pageInit); 499 toggleMicrofiche(); 500 } 501 })["catch"](function (error) { 502 console.warn('Unable to load microfiche issue', error); 503 }); 504 }); 505 } 506 if (active && triggers.length) triggers[0].click(); 507 } 508 509 /** DOM ready helper. */ 510 function onReady(fn) { 511 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);else fn(); 512 } 513 onReady(function () { 514 if (document.querySelector('.js-microfiche-modal')) { 515 var active = false; 516 var page = false; 517 if (window.location.search.indexOf('mode=microfiche') > -1) { 518 try { 519 if (typeof window.WmpciPop !== 'undefined') window.WmpciPop.close(); 520 } catch (_) {} 521 var lb = document.getElementById('lightbox'); 522 if (lb) lb.parentNode.removeChild(lb); 523 active = true; 524 var m = /[?&]page=(\d+)/.exec(window.location.search); 525 if (m) { 526 var pi = parseInt(m[1], 10) - 1; 527 if (!isNaN(pi)) page = pi; 528 } 529 } 530 microficheHandler(active, page); 531 } 532 }); 533 534 /** 535 * Public API for documents/microfiche consumers. 536 * @type {{ hydratePdfLinks: Function, fetchIssue: Function, getApiBase: Function }} 537 */ 538 window.HarpersPaywallDocuments = { 539 fetchIssue: documentsClient.fetchIssue, 540 getApiBase: documentsClient.getApiBase 541 }; 542})(window, document);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.