PageSourceSearch

https://wp.harpers.org/wp-content/themes/timber/static/js/paywall-microfiche.f86e024a.js

js harpers.org collected 2026-10-01 08:43:59 UTC 19,703 bytes, 542 lines download raw bytes

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.