Jump to content

MediaWiki:Gadget-OralHistoryRecords-Updated.js: Difference between revisions

No edit summary
No edit summary
Line 4: Line 4:
   'use strict';
   'use strict';


   // ====== CONFIG ======
   // ====== COLUMN CONFIG ======
  // Spreadsheet column names (exactly as provided)
   var COL = {
   var COL = {
     transcript: 'Transcript Identifier',
     transcript: 'Transcript Identifier',
Line 24: Line 23:
   var FILTER = { q: '' };
   var FILTER = { q: '' };


   // ====== UTILS ======
   // ====== HELPERS ======
  function onReady(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn);
  }
   function $(id) { return document.getElementById(id); }
   function $(id) { return document.getElementById(id); }
   function esc(s) {
   function esc(s) {
     s = (s == null ? '' : String(s));
     s = (s == null ? '' : String(s));
Line 36: Line 32:
     });
     });
   }
   }
   function norm(s) {
   function norm(s) {
     return (s || '')
     return (s || '')
Line 43: Line 40:
       .toLowerCase();
       .toLowerCase();
   }
   }
   function debounce(fn, ms) {
   function debounce(fn, ms) {
     var t;
     var t;
     return function () {
     return function () {
       var a = arguments, self = this;
       var args = arguments;
       clearTimeout(t);
       clearTimeout(t);
       t = setTimeout(function () { fn.apply(self, a); }, ms);
       t = setTimeout(function () { fn.apply(null, args); }, ms);
     };
     };
   }
   }


  // Pattern 1: page controls view via <div id="ohr-directory" data-view="...">
   function getViewMode() {
   function getViewMode() {
     var root = $('ohr-directory');
     var root = $('ohr-directory');
     var mode = (root && root.getAttribute('data-view')) ? root.getAttribute('data-view').trim().toLowerCase() : '';
     var mode = root ? (root.getAttribute('data-view') || '').trim().toLowerCase() : '';
     if (mode === 'accordion' || mode === 'chips' || mode === 'directory') return mode;
     if (mode === 'accordion' || mode === 'chips' || mode === 'directory') return mode;
     return 'directory';
     return 'directory';
   }
   }


   // Always resolve to the latest uploaded version of File:OHData.csv
   // ====== CSV URL (SAFE + FLEXIBLE) ======
  // IMPORTANT: use /index.php/Special:FilePath/... form (works on your site)
   function getCsvUrl() {
   function getCsvUrl() {
     return mw.config.get('wgScriptPath') + '/Special:FilePath/OHData.csv?cb=' + Date.now();
     var root = $('ohr-directory');
    var override = root ? (root.getAttribute('data-csv') || '').trim() : '';
 
    var base = override || (
      (mw.config.get('wgScript') || '/index.php') +
      '/Special:FilePath/OHData.csv'
    );
 
    return base + (base.indexOf('?') === -1 ? '?' : '&') + 'cb=' + Date.now();
   }
   }


  // ====== DATA FORMATTERS ======
   function toYear(value) {
   function toYear(value) {
     if (!value) return '';
     if (!value) return '';
     var s = String(value).trim();
     var m = String(value).match(/\b(1[6-9]\d{2}|20\d{2}|21\d{2})\b/);
    var m = s.match(/\b(1[6-9]\d{2}|20\d{2}|21\d{2})\b/);
     return m ? m[1] : '';
     return m ? m[1] : '';
   }
   }
Line 82: Line 86:
   }
   }


   function splitLinksCSV(value) {
   function splitLinks(value) {
    // Multiple URLs in a cell are comma-separated
     if (!value) return [];
     if (!value) return [];
     return String(value)
     return String(value).split(',').map(function (x) { return x.trim(); }).filter(Boolean);
      .split(',')
      .map(function (x) { return x.trim(); })
      .filter(Boolean);
   }
   }


   function fullName(person) {
   function fullName(p) {
     var first = (person[COL.first] || '').trim();
     return ((p[COL.first] || '') + ' ' + (p[COL.last] || '')).trim() || 'Untitled';
    var last = (person[COL.last] || '').trim();
    return (first + ' ' + last).trim() || 'Untitled';
   }
   }


   function lifespan(person) {
   function lifespan(p) {
     var b = (person[COL.birth] || '').trim();
     var b = p[COL.birth] || '';
     var d = (person[COL.death] || '').trim();
     var d = p[COL.death] || '';
     if (b && d) return b + '–' + d;
     if (b && d) return b + '–' + d;
     if (b && !d) return b + '–';
     if (b && !d) return b + '–';
Line 106: Line 104:
   }
   }


   function buildAccessLabel(name, kind, idx, total) {
   function accessLabel(name, type, i, total) {
     var part = (total > 1) ? (' (Part ' + (idx + 1) + '/' + total + ')') : '';
     var part = total > 1 ? ' (Part ' + (i + 1) + '/' + total + ')' : '';
     var kindTitle = kind.charAt(0).toUpperCase() + kind.slice(1);
     return 'Access ' + name + '’s ' + type.charAt(0).toUpperCase() + type.slice(1) + part;
    return 'Access ' + name + '’s ' + kindTitle + part;
   }
   }


   function buildChipText(kind, idx, total) {
   function chipText(type, i, total) {
     var kindTitle = kind.charAt(0).toUpperCase() + kind.slice(1);
     if (total > 1) return type.charAt(0).toUpperCase() + type.slice(1) + ' ' + (i + 1) + '/' + total;
    if (total > 1) return kindTitle + ' ' + (idx + 1) + '/' + total;
     return type.charAt(0).toUpperCase() + type.slice(1);
     return kindTitle;
   }
   }


   // ====== UI ======
   // ====== UI ======
   function buildToolbar(root) {
   function buildToolbar(root) {
     var toolbar = document.createElement('div');
     var bar = document.createElement('div');
     toolbar.className = 'ohr-toolbar';
     bar.className = 'ohr-toolbar';


     var search = document.createElement('input');
     var search = document.createElement('input');
Line 127: Line 123:
     search.type = 'search';
     search.type = 'search';
     search.placeholder = 'Search names, summaries…';
     search.placeholder = 'Search names, summaries…';
    search.setAttribute('aria-label', 'Search oral history records');


     var count = document.createElement('div');
     var count = document.createElement('div');
     count.id = 'ohr-count';
     count.id = 'ohr-count';
     count.className = 'ohr-count';
     count.className = 'ohr-count';
    count.setAttribute('aria-live', 'polite');


     toolbar.appendChild(search);
     bar.appendChild(search);
     toolbar.appendChild(count);
     bar.appendChild(count);
     root.insertBefore(toolbar, root.firstChild);
     root.insertBefore(bar, root.firstChild);
   }
   }


  // ====== CSV parsing ======
   function parseCSV(text) {
   function parseCSV(csvText) {
     var lines = text.trim().split(/\r?\n/);
     var lines = csvText.trim().split(/\r?\n/);
    if (!lines.length) return [];
     var headers = lines[0].split(',').map(function (h) { return h.trim(); });
     var headers = lines[0].split(',').map(function (h) { return h.trim(); });


     return lines.slice(1).map(function (line) {
     return lines.slice(1).map(function (line) {
      // Lightweight parser; can break on very complex quoted CSV.
       var values = line.match(/(?:[^,"]+|"[^"]*")+/g) || [];
       var values = line.match(/(?:[^,"]+|"[^"]*")+/g) || [];
       values = values.map(function (v) { return v.replace(/^"|"$/g, '').trim(); });
       values = values.map(function (v) { return v.replace(/^"|"$/g, '').trim(); });
Line 156: Line 147:
   }
   }


  // ====== Rendering building blocks ======
   function buildHeader(p) {
   function buildHeaderHTML(person) {
     var meta = [];
     var name = fullName(person);
     var life = lifespan(p);
     var life = lifespan(person);
     var rec = recordedLabel(p);
     var rec = recordedLabel(person);
     if (life) meta.push('<span>' + esc(life) + '</span>');
 
     if (rec) meta.push('<span>' + esc(rec) + '</span>');
    var metaBits = [];
     var metaHTML = meta.join('<span class="ohr-dot">·</span>');
     if (life) metaBits.push('<span>' + esc(life) + '</span>');
     if (rec) metaBits.push('<span>' + esc(rec) + '</span>');
 
     var meta = metaBits.join('<span class="ohr-dot">·</span>');


     return (
     return (
       '<div class="ohr-head">' +
       '<div class="ohr-head">' +
         '<div class="ohr-titleblock">' +
         '<div class="ohr-titleblock">' +
           '<h3 class="ohr-name">' + esc(name) + '</h3>' +
           '<h3 class="ohr-name">' + esc(fullName(p)) + '</h3>' +
           (meta ? '<div class="ohr-meta">' + meta + '</div>' : '') +
           (metaHTML ? '<div class="ohr-meta">' + metaHTML + '</div>' : '') +
         '</div>' +
         '</div>' +
       '</div>'
       '</div>'
Line 178: Line 165:
   }
   }


   function buildWikiLinkHTML(person, cls) {
   function buildLinks(p, style) {
    var wiki = (person[COL.wiki] || '').trim();
     var name = fullName(p);
    if (!wiki) return '';
    return '<a class="' + cls + '" target="_blank" rel="noopener" href="' + esc(wiki) + '">Read Wiki Page</a>';
  }
 
  function buildLinksHTML(person, style) {
    // style: 'links' (Directory & Accordion) or 'chips' (Chips view)
     var name = fullName(person);
 
    var video = splitLinksCSV(person[COL.video]);
    var audio = splitLinksCSV(person[COL.audio]);
    var transcript = splitLinksCSV(person[COL.transcript]);
 
     var out = [];
     var out = [];


     function add(kind, urls) {
     function add(type, urls) {
       for (var i = 0; i < urls.length; i++) {
       urls.forEach(function (url, i) {
         var label = buildAccessLabel(name, kind, i, urls.length);
         var label = accessLabel(name, type, i, urls.length);
 
         if (style === 'chips') {
         if (style === 'chips') {
          var chipText = buildChipText(kind, i, urls.length);
           out.push(
           out.push(
             '<a class="ohr-chip" target="_blank" rel="noopener" href="' + esc(urls[i]) + '"' +
             '<a class="ohr-chip" target="_blank" rel="noopener" href="' + esc(url) +
              ' title="' + esc(label) + '"' +
            '" title="' + esc(label) + '">' +
              ' aria-label="' + esc(label) + '"' +
            esc(chipText(type, i, urls.length)) +
             '>' + esc(chipText) + '</a>'
             '</a>'
           );
           );
         } else {
         } else {
           out.push(
           out.push(
             '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(urls[i]) + '">' +
             '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(url) + '">' +
              esc(label) +
            esc(label) +
             '</a>'
             '</a>'
           );
           );
         }
         }
       }
       });
     }
     }


     // Wiki link first (if present)
     var wiki = (p[COL.wiki] || '').trim();
     if (style === 'chips') {
     if (wiki) {
      var wikiChip = buildWikiLinkHTML(person, 'ohr-chip');
      out.push(
      if (wikiChip) out.push(wikiChip);
        '<a class="' + (style === 'chips' ? 'ohr-chip' : 'ohr-link') +
    } else {
        '" target="_blank" rel="noopener" href="' + esc(wiki) + '">Read Wiki Page</a>'
      var wikiLink = buildWikiLinkHTML(person, 'ohr-link');
      );
      if (wikiLink) out.push(wikiLink);
     }
     }


     // Then identifiers (only render what exists)
     add('video', splitLinks(p[COL.video]));
    if (video.length) add('video', video);
     add('audio', splitLinks(p[COL.audio]));
     if (audio.length) add('audio', audio);
     add('transcript', splitLinks(p[COL.transcript]));
     if (transcript.length) add('transcript', transcript);


     if (!out.length) return '';
     if (!out.length) return '';
 
     return '<div class="' + (style === 'chips' ? 'ohr-chips' : 'ohr-links') + '">' + out.join('') + '</div>';
     if (style === 'chips') return '<div class="ohr-chips">' + out.join('') + '</div>';
    return '<div class="ohr-links">' + out.join('') + '</div>';
  }
 
  // ====== Views ======
  function makeDirectoryItem(person) {
    var summary = (person[COL.summary] || '').trim();
    var div = document.createElement('div');
    div.className = 'ohr-item';
    div.innerHTML =
      buildHeaderHTML(person) +
      (summary ? '<p class="ohr-bio">' + esc(summary) + '</p>' : '') +
      buildLinksHTML(person, 'links');
    return div;
   }
   }


   function makeAccordionItem(person, idx) {
   function render(rows) {
    var summary = (person[COL.summary] || '').trim();
    var div = document.createElement('div');
    div.className = 'ohr-item';
 
    var panelId = 'ohr-panel-' + idx;
    var btnId = 'ohr-btn-' + idx;
 
    div.innerHTML =
      '<div class="ohr-head">' +
        '<div class="ohr-titleblock">' +
          (function () {
            var name = fullName(person);
            var life = lifespan(person);
            var rec = recordedLabel(person);
 
            var metaBits = [];
            if (life) metaBits.push('<span>' + esc(life) + '</span>');
            if (rec) metaBits.push('<span>' + esc(rec) + '</span>');
            var meta = metaBits.join('<span class="ohr-dot">·</span>');
 
            return (
              '<h3 class="ohr-name">' + esc(name) + '</h3>' +
              (meta ? '<div class="ohr-meta">' + meta + '</div>' : '')
            );
          })() +
        '</div>' +
        '<button class="ohr-acc-btn" id="' + btnId + '" type="button" aria-expanded="false" aria-controls="' + panelId + '">Details</button>' +
      '</div>' +
      '<div class="ohr-acc-panel ohr-hidden" id="' + panelId + '">' +
        (summary ? '<div class="ohr-divider"><p class="ohr-bio" style="margin:0">' + esc(summary) + '</p></div>' : '') +
        '<div class="ohr-divider">' + buildLinksHTML(person, 'links') + '</div>' +
      '</div>';
 
    var btn = div.querySelector('#' + btnId);
    var panel = div.querySelector('#' + panelId);
    if (btn && panel) {
      btn.addEventListener('click', function () {
        var open = !panel.classList.contains('ohr-hidden');
        if (open) {
          panel.classList.add('ohr-hidden');
          btn.setAttribute('aria-expanded', 'false');
          btn.textContent = 'Details';
        } else {
          panel.classList.remove('ohr-hidden');
          btn.setAttribute('aria-expanded', 'true');
          btn.textContent = 'Hide';
        }
      });
    }
 
    return div;
  }
 
  function makeChipsItem(person) {
    var summary = (person[COL.summary] || '').trim();
    var div = document.createElement('div');
    div.className = 'ohr-item';
    div.innerHTML =
      buildHeaderHTML(person) +
      (summary ? '<p class="ohr-bio">' + esc(summary) + '</p>' : '') +
      buildLinksHTML(person, 'chips');
    return div;
  }
 
  function renderList(rows) {
     var container = $('ohr-list');
     var container = $('ohr-list');
     if (!container) return;
     if (!container) return;
Line 323: Line 215:


     var mode = getViewMode();
     var mode = getViewMode();
     var frag = document.createDocumentFragment();
     var html = rows.map(function (p, i) {
 
       var summary = (p[COL.summary] || '').trim();
    for (var i = 0; i < rows.length; i++) {
      var base =
       if (mode === 'accordion') frag.appendChild(makeAccordionItem(rows[i], i));
        buildHeader(p) +
      else if (mode === 'chips') frag.appendChild(makeChipsItem(rows[i]));
        (summary ? '<p class="ohr-bio">' + esc(summary) + '</p>' : '') +
       else frag.appendChild(makeDirectoryItem(rows[i]));
        buildLinks(p, mode === 'chips' ? 'chips' : 'links');
    }
       return '<div class="ohr-item">' + base + '</div>';
    }).join('');


     container.innerHTML = '';
     container.innerHTML = html;
    container.appendChild(frag);
   }
   }


  function updateCount(n, total) {
    var el = $('ohr-count'); if (!el) return;
    el.textContent = (n === total) ? (total + ' records') : (n + ' of ' + total + ' records');
  }
  // ====== Filtering ======
   function applyFilters() {
   function applyFilters() {
     var q = norm(FILTER.q);
     var q = norm(FILTER.q);
     var filtered = ALL.filter(function (row) {
     var filtered = ALL.filter(function (row) {
       if (!q) return true;
       if (!q) return true;
Line 349: Line 234:
         (row[COL.first] || '') + ' ' +
         (row[COL.first] || '') + ' ' +
         (row[COL.last] || '') + ' ' +
         (row[COL.last] || '') + ' ' +
         (row[COL.summary] || '') + ' ' +
         (row[COL.summary] || '')
        (row[COL.birth] || '') + ' ' +
        (row[COL.death] || '') + ' ' +
        (row[COL.dateFrom] || '') + ' ' +
        (row[COL.dateTo] || '')
       );
       );
       return hay.indexOf(q) !== -1;
       return hay.indexOf(q) !== -1;
Line 359: Line 240:


     filtered.sort(function (a, b) {
     filtered.sort(function (a, b) {
       var A = norm((a[COL.last] || '') + ' ' + (a[COL.first] || ''));
       return norm(a[COL.last]).localeCompare(norm(b[COL.last]));
      var B = norm((b[COL.last] || '') + ' ' + (b[COL.first] || ''));
      return A.localeCompare(B);
     });
     });


     renderList(filtered);
     render(filtered);
     updateCount(filtered.length, ALL.length);
     var count = $('ohr-count');
    if (count) count.textContent = filtered.length + ' records';
   }
   }


   function attachHandlers() {
   function attachHandlers() {
     var qInput = $('ohr-search');
     var input = $('ohr-search');
     if (qInput) {
     if (input) {
       qInput.addEventListener('input', debounce(function () {
       input.addEventListener('input', debounce(function () {
         FILTER.q = qInput.value.trim();
         FILTER.q = input.value.trim();
         applyFilters();
         applyFilters();
       }, 200));
       }, 200));
Line 378: Line 258:
   }
   }


  // ====== Errors ======
   function displayError(msg) {
   function displayError(message) {
     var loading = $('ohr-loading');
     var loading = $('ohr-loading');
     var error = $('ohr-error');
     var error = $('ohr-error');
Line 388: Line 267:
       error.innerHTML =
       error.innerHTML =
         '<p><strong>Failed to load records.</strong></p>' +
         '<p><strong>Failed to load records.</strong></p>' +
         '<p class="ohr-muted" style="margin-top:0.5rem">' + esc(message) + '</p>';
         '<p class="ohr-muted">' + esc(msg) + '</p>';
     }
     }
   }
   }


  // ====== Main ======
   function init() {
   function fetchAndRender() {
     var root = $('ohr-directory');
     var root = $('ohr-directory');
    var loading = $('ohr-loading');
     if (!root) return;
    var container = $('ohr-list');
     if (!root || !container) return;


     buildToolbar(root);
     buildToolbar(root);
Line 408: Line 284:
       .then(function (text) {
       .then(function (text) {
         ALL = parseCSV(text);
         ALL = parseCSV(text);
         if (loading) loading.classList.add('ohr-hidden');
         $('ohr-loading').classList.add('ohr-hidden');
         attachHandlers();
         attachHandlers();
         applyFilters();
         applyFilters();
       })
       })
       .catch(function (err) {
       .catch(function (err) {
         displayError('There was an error accessing the records. Details: ' + err.message);
         displayError(err.message);
         console.error(err);
         console.error(err);
       });
       });
   }
   }


   onReady(function () {
   if (document.readyState !== 'loading') init();
    if ($('ohr-directory')) {
  else document.addEventListener('DOMContentLoaded', init);
      mw.loader.using([]).then(fetchAndRender);
 
    }
  });
})();
})();

Revision as of 14:11, 21 February 2026

/* ===== OralHistoryRecords-Updated.js ===== */
/* global mw */
(function () {
  'use strict';

  // ====== COLUMN CONFIG ======
  var COL = {
    transcript: 'Transcript Identifier',
    audio: 'Audio Identifier',
    video: 'Video Identifier',
    first: 'First Name',
    last: 'Last Name',
    birth: 'Birth Year',
    death: 'Death Year',
    dateFrom: 'Date From',
    dateTo: 'Date To',
    summary: 'Summary',
    wiki: 'Wiki Link'
  };

  // ====== STATE ======
  var ALL = [];
  var FILTER = { q: '' };

  // ====== HELPERS ======
  function $(id) { return document.getElementById(id); }

  function esc(s) {
    s = (s == null ? '' : String(s));
    return s.replace(/[&<>"']/g, function (c) {
      return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]);
    });
  }

  function norm(s) {
    return (s || '')
      .toString()
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase();
  }

  function debounce(fn, ms) {
    var t;
    return function () {
      var args = arguments;
      clearTimeout(t);
      t = setTimeout(function () { fn.apply(null, args); }, ms);
    };
  }

  function getViewMode() {
    var root = $('ohr-directory');
    var mode = root ? (root.getAttribute('data-view') || '').trim().toLowerCase() : '';
    if (mode === 'accordion' || mode === 'chips' || mode === 'directory') return mode;
    return 'directory';
  }

  // ====== CSV URL (SAFE + FLEXIBLE) ======
  function getCsvUrl() {
    var root = $('ohr-directory');
    var override = root ? (root.getAttribute('data-csv') || '').trim() : '';

    var base = override || (
      (mw.config.get('wgScript') || '/index.php') +
      '/Special:FilePath/OHData.csv'
    );

    return base + (base.indexOf('?') === -1 ? '?' : '&') + 'cb=' + Date.now();
  }

  // ====== DATA FORMATTERS ======
  function toYear(value) {
    if (!value) return '';
    var m = String(value).match(/\b(1[6-9]\d{2}|20\d{2}|21\d{2})\b/);
    return m ? m[1] : '';
  }

  function recordedLabel(person) {
    var y1 = toYear(person[COL.dateFrom]);
    var y2 = toYear(person[COL.dateTo]);
    if (y1 && y2) return (y1 === y2) ? ('Recorded ' + y1) : ('Recorded ' + y1 + '–' + y2);
    if (y1) return 'Recorded ' + y1;
    if (y2) return 'Recorded ' + y2;
    return '';
  }

  function splitLinks(value) {
    if (!value) return [];
    return String(value).split(',').map(function (x) { return x.trim(); }).filter(Boolean);
  }

  function fullName(p) {
    return ((p[COL.first] || '') + ' ' + (p[COL.last] || '')).trim() || 'Untitled';
  }

  function lifespan(p) {
    var b = p[COL.birth] || '';
    var d = p[COL.death] || '';
    if (b && d) return b + '–' + d;
    if (b && !d) return b + '–';
    if (!b && d) return '–' + d;
    return '';
  }

  function accessLabel(name, type, i, total) {
    var part = total > 1 ? ' (Part ' + (i + 1) + '/' + total + ')' : '';
    return 'Access ' + name + '’s ' + type.charAt(0).toUpperCase() + type.slice(1) + part;
  }

  function chipText(type, i, total) {
    if (total > 1) return type.charAt(0).toUpperCase() + type.slice(1) + ' ' + (i + 1) + '/' + total;
    return type.charAt(0).toUpperCase() + type.slice(1);
  }

  // ====== UI ======
  function buildToolbar(root) {
    var bar = document.createElement('div');
    bar.className = 'ohr-toolbar';

    var search = document.createElement('input');
    search.id = 'ohr-search';
    search.type = 'search';
    search.placeholder = 'Search names, summaries…';

    var count = document.createElement('div');
    count.id = 'ohr-count';
    count.className = 'ohr-count';

    bar.appendChild(search);
    bar.appendChild(count);
    root.insertBefore(bar, root.firstChild);
  }

  function parseCSV(text) {
    var lines = text.trim().split(/\r?\n/);
    var headers = lines[0].split(',').map(function (h) { return h.trim(); });

    return lines.slice(1).map(function (line) {
      var values = line.match(/(?:[^,"]+|"[^"]*")+/g) || [];
      values = values.map(function (v) { return v.replace(/^"|"$/g, '').trim(); });
      if (values.length !== headers.length) return null;
      var row = {};
      headers.forEach(function (h, i) { row[h] = values[i]; });
      return row;
    }).filter(Boolean);
  }

  function buildHeader(p) {
    var meta = [];
    var life = lifespan(p);
    var rec = recordedLabel(p);
    if (life) meta.push('<span>' + esc(life) + '</span>');
    if (rec) meta.push('<span>' + esc(rec) + '</span>');
    var metaHTML = meta.join('<span class="ohr-dot">·</span>');

    return (
      '<div class="ohr-head">' +
        '<div class="ohr-titleblock">' +
          '<h3 class="ohr-name">' + esc(fullName(p)) + '</h3>' +
          (metaHTML ? '<div class="ohr-meta">' + metaHTML + '</div>' : '') +
        '</div>' +
      '</div>'
    );
  }

  function buildLinks(p, style) {
    var name = fullName(p);
    var out = [];

    function add(type, urls) {
      urls.forEach(function (url, i) {
        var label = accessLabel(name, type, i, urls.length);
        if (style === 'chips') {
          out.push(
            '<a class="ohr-chip" target="_blank" rel="noopener" href="' + esc(url) +
            '" title="' + esc(label) + '">' +
            esc(chipText(type, i, urls.length)) +
            '</a>'
          );
        } else {
          out.push(
            '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(url) + '">' +
            esc(label) +
            '</a>'
          );
        }
      });
    }

    var wiki = (p[COL.wiki] || '').trim();
    if (wiki) {
      out.push(
        '<a class="' + (style === 'chips' ? 'ohr-chip' : 'ohr-link') +
        '" target="_blank" rel="noopener" href="' + esc(wiki) + '">Read Wiki Page</a>'
      );
    }

    add('video', splitLinks(p[COL.video]));
    add('audio', splitLinks(p[COL.audio]));
    add('transcript', splitLinks(p[COL.transcript]));

    if (!out.length) return '';
    return '<div class="' + (style === 'chips' ? 'ohr-chips' : 'ohr-links') + '">' + out.join('') + '</div>';
  }

  function render(rows) {
    var container = $('ohr-list');
    if (!container) return;

    if (!rows.length) {
      container.innerHTML = '<p class="ohr-muted" style="text-align:center">No matching records.</p>';
      return;
    }

    var mode = getViewMode();
    var html = rows.map(function (p, i) {
      var summary = (p[COL.summary] || '').trim();
      var base =
        buildHeader(p) +
        (summary ? '<p class="ohr-bio">' + esc(summary) + '</p>' : '') +
        buildLinks(p, mode === 'chips' ? 'chips' : 'links');
      return '<div class="ohr-item">' + base + '</div>';
    }).join('');

    container.innerHTML = html;
  }

  function applyFilters() {
    var q = norm(FILTER.q);
    var filtered = ALL.filter(function (row) {
      if (!q) return true;
      var hay = norm(
        (row[COL.first] || '') + ' ' +
        (row[COL.last] || '') + ' ' +
        (row[COL.summary] || '')
      );
      return hay.indexOf(q) !== -1;
    });

    filtered.sort(function (a, b) {
      return norm(a[COL.last]).localeCompare(norm(b[COL.last]));
    });

    render(filtered);
    var count = $('ohr-count');
    if (count) count.textContent = filtered.length + ' records';
  }

  function attachHandlers() {
    var input = $('ohr-search');
    if (input) {
      input.addEventListener('input', debounce(function () {
        FILTER.q = input.value.trim();
        applyFilters();
      }, 200));
    }
  }

  function displayError(msg) {
    var loading = $('ohr-loading');
    var error = $('ohr-error');
    if (loading) loading.classList.add('ohr-hidden');
    if (error) {
      error.classList.remove('ohr-hidden');
      error.classList.add('ohr-error');
      error.innerHTML =
        '<p><strong>Failed to load records.</strong></p>' +
        '<p class="ohr-muted">' + esc(msg) + '</p>';
    }
  }

  function init() {
    var root = $('ohr-directory');
    if (!root) return;

    buildToolbar(root);

    fetch(getCsvUrl(), { credentials: 'include', cache: 'no-store' })
      .then(function (res) {
        if (!res.ok) throw new Error('HTTP ' + res.status);
        return res.text();
      })
      .then(function (text) {
        ALL = parseCSV(text);
        $('ohr-loading').classList.add('ohr-hidden');
        attachHandlers();
        applyFilters();
      })
      .catch(function (err) {
        displayError(err.message);
        console.error(err);
      });
  }

  if (document.readyState !== 'loading') init();
  else document.addEventListener('DOMContentLoaded', init);

})();