Jump to content

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

No edit summary
No edit summary
 
(15 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* ===== OralHistoryRecords-Updated.js ===== */
/* ===== OralHistoryRecords-Updated.js (Grouped by Person Key, Option 2) ===== */
/* global mw */
/* global mw */
(function () {
(function () {
   'use strict';
   'use strict';


   // ====== COLUMN CONFIG ======
   // ====== COLUMN CONFIG (MUST MATCH CSV HEADERS EXACTLY) ======
   var COL = {
   var COL = {
     transcript: 'Transcript Identifier',
     personKey: 'Person Key',
    audio: 'Audio Identifier',
    video: 'Video Identifier',
     first: 'First Name',
     first: 'First Name',
     last: 'Last Name',
     last: 'Last Name',
Line 15: Line 13:
     dateFrom: 'Date From',
     dateFrom: 'Date From',
     dateTo: 'Date To',
     dateTo: 'Date To',
     summary: 'Summary',
     bio: 'Short Bio',
     wiki: 'Wiki Link'
     wiki: 'Wiki Link',
    audio: 'Audio Link',
    video: 'Video Link',
    transcripts: 'Transcript Link'
   };
   };


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


   // ====== HELPERS ======
   // ====== UTILS ======
   function $(id) { return document.getElementById(id); }
   function $(id) { return document.getElementById(id); }
  function onReady(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn);
  }


   function esc(s) {
   function esc(s) {
Line 44: Line 50:
     var t;
     var t;
     return function () {
     return function () {
       var args = arguments;
       var a = arguments, self = this;
       clearTimeout(t);
       clearTimeout(t);
       t = setTimeout(function () { fn.apply(null, args); }, ms);
       t = setTimeout(function () { fn.apply(self, a); }, ms);
     };
     };
   }
   }


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


   // ====== CSV URL (SAFE + FLEXIBLE) ======
   // Optional override:
  // <div id="ohr-directory" data-csv="/index.php/Special:FilePath/OHData.csv">
   function getCsvUrl() {
   function getCsvUrl() {
     var root = $('ohr-directory');
     var root = $('ohr-directory');
     var override = root ? (root.getAttribute('data-csv') || '').trim() : '';
     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();
  }
function toYear(value) {
  if (!value) return '';
  var s = String(value).trim();


    var base = override || (
  // First: look for a 4-digit year anywhere in the string
      (mw.config.get('wgScript') || '/index.php') +
  var m4 = s.match(/\b(1[6-9]\d{2}|20\d{2}|21\d{2})\b/);
      '/Special:FilePath/OHData.csv'
  if (m4) return m4[1];
     );
 
  // Next: handle common numeric dates like 4/7/11 or 4-7-11
  var m2 = s.match(/^\d{1,2}[\/-]\d{1,2}[\/-](\d{2})$/);
  if (m2) {
     var yy = Number(m2[1]);


     return base + (base.indexOf('?') === -1 ? '?' : '&') + 'cb=' + Date.now();
    // Adjust pivot if needed
     return String(yy <= 29 ? 2000 + yy : 1900 + yy);
   }
   }


   // ====== DATA FORMATTERS ======
   return '';
  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) {
   function recordedLabel(row) {
     var y1 = toYear(person[COL.dateFrom]);
     var y1 = toYear(row[COL.dateFrom]);
     var y2 = toYear(person[COL.dateTo]);
     var y2 = toYear(row[COL.dateTo]);
     if (y1 && y2) return (y1 === y2) ? ('Recorded ' + y1) : ('Recorded ' + y1 + '–' + y2);
     if (y1 && y2) return (y1 === y2) ? ('Recorded ' + y1) : ('Recorded ' + y1 + '–' + y2);
     if (y1) return 'Recorded ' + y1;
     if (y1) return 'Recorded ' + y1;
     if (y2) return 'Recorded ' + y2;
     if (y2) return 'Recorded ' + y2;
     return '';
     return 'Recorded (date unknown)';
   }
   }


   function splitLinks(value) {
   function fullName(row) {
     if (!value) return [];
     var first = (row[COL.first] || '').trim();
     return String(value).split(',').map(function (x) { return x.trim(); }).filter(Boolean);
     var last = (row[COL.last] || '').trim();
    if (!first || !last) return '';
    return (first + ' ' + last).trim();
   }
   }


   function fullName(p) {
   function extractUrls(value) {
     return ((p[COL.first] || '') + ' ' + (p[COL.last] || '')).trim() || 'Untitled';
     if (!value) return [];
  }
    var s = String(value);


  function lifespan(p) {
     var re = /https?:\/\/[^\s"'<>()]+/g;
     var b = p[COL.birth] || '';
     var m = s.match(re) || [];
     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 seen = Object.create(null);
     var part = total > 1 ? ' (Part ' + (i + 1) + '/' + total + ')' : '';
     var out = [];
     return 'Access ' + name + '’s ' + type.charAt(0).toUpperCase() + type.slice(1) + part;
     for (var i = 0; i < m.length; i++) {
  }
      var url = m[i].trim();
 
      url = url.replace(/[);.,]+$/g, '');
  function chipText(type, i, total) {
      if (!url) continue;
    if (total > 1) return type.charAt(0).toUpperCase() + type.slice(1) + ' ' + (i + 1) + '/' + total;
      if (!seen[url]) {
    return type.charAt(0).toUpperCase() + type.slice(1);
        seen[url] = true;
        out.push(url);
      }
    }
    return out;
   }
   }


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


     var search = document.createElement('input');
     var search = document.createElement('input');
     search.id = 'ohr-search';
     search.id = 'ohr-search';
     search.type = 'search';
     search.type = 'search';
     search.placeholder = 'Search names, summaries…';
     search.placeholder = 'Search names, bios…';
    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');


     bar.appendChild(search);
     toolbar.appendChild(search);
     bar.appendChild(count);
     toolbar.appendChild(count);
     root.insertBefore(bar, root.firstChild);
     root.insertBefore(toolbar, root.firstChild);
   }
   }
function parseCSV(text) {
  var rows = [];
  var pattern = /(?:^|,|\r?\n)(?:"([^"]*(?:""[^"]*)*)"|([^",\r\n]*))/g;


   var matches = [];
   // ====== CSV PARSER ======
  var match;
  function parseCSV(text) {
  while ((match = pattern.exec(text)) !== null) {
    if (!text) return [];
     matches.push(match[1] ? match[1].replace(/""/g, '"') : match[2]);
 
    text = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
 
    var rows = [];
    var row = [];
    var field = '';
    var i = 0;
    var inQuotes = false;
 
    while (i < text.length) {
      var c = text[i];
 
      if (inQuotes) {
        if (c === '"') {
          if (i + 1 < text.length && text[i + 1] === '"') {
            field += '"';
            i += 2;
            continue;
          }
          inQuotes = false;
          i += 1;
          continue;
        }
        field += c;
        i += 1;
        continue;
      }
 
      if (c === '"') {
        inQuotes = true;
        i += 1;
        continue;
      }
      if (c === ',') {
        row.push(field);
        field = '';
        i += 1;
        continue;
      }
      if (c === '\n') {
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i += 1;
        continue;
      }
 
      field += c;
      i += 1;
    }
 
     row.push(field);
    if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);
 
    if (!rows.length) return [];
 
    var headers = rows[0].map(function (h) { return (h || '').trim(); });
    var out = [];
 
    for (var r = 1; r < rows.length; r++) {
      var values = rows[r];
 
      var nonEmpty = false;
      for (var k = 0; k < values.length; k++) {
        if (String(values[k] || '').trim() !== '') { nonEmpty = true; break; }
      }
      if (!nonEmpty) continue;
 
      var obj = {};
      for (var c2 = 0; c2 < headers.length; c2++) {
        var key = headers[c2];
        if (!key) continue;
        obj[key] = (c2 < values.length) ? values[c2] : '';
      }
      out.push(obj);
    }
    return out;
   }
   }


   if (!matches.length) return [];
   // ====== GROUPING ======
  function firstNonEmpty(a, b) {
    var A = (a || '').trim();
    if (A) return A;
    return (b || '').trim();
  }
 
  function groupByPersonKey(rows) {
    var map = Object.create(null);
 
    for (var i = 0; i < rows.length; i++) {
      var row = rows[i];
      var key = (row[COL.personKey] || '').trim();
 
      if (!key) continue;
      if (!fullName(row)) continue;


  var headers = matches.slice(0, text.indexOf('\n') > -1 ? text.split('\n')[0].split(',').length : matches.length);
      if (!map[key]) {
  var data = matches.slice(headers.length);
        map[key] = {
          key: key,
          first: (row[COL.first] || '').trim(),
          last: (row[COL.last] || '').trim(),
          birth: (row[COL.birth] || '').trim(),
          death: (row[COL.death] || '').trim(),
          wiki: (row[COL.wiki] || '').trim(),
          bio: (row[COL.bio] || '').trim(),
          sessions: []
        };
      } else {
        map[key].first = firstNonEmpty(map[key].first, row[COL.first]);
        map[key].last = firstNonEmpty(map[key].last, row[COL.last]);
        map[key].birth = firstNonEmpty(map[key].birth, row[COL.birth]);
        map[key].death = firstNonEmpty(map[key].death, row[COL.death]);
        map[key].wiki = firstNonEmpty(map[key].wiki, row[COL.wiki]);
        map[key].bio = firstNonEmpty(map[key].bio, row[COL.bio]);
      }


  var colCount = headers.length;
      var session = {
        recorded: recordedLabel(row),
        dateFrom: (row[COL.dateFrom] || '').trim(),
        dateTo: (row[COL.dateTo] || '').trim(),
        video: extractUrls(row[COL.video]),
        audio: extractUrls(row[COL.audio]),
        transcripts: extractUrls(row[COL.transcripts])
      };


  for (var i = 0; i < data.length; i += colCount) {
      map[key].sessions.push(session);
    var row = {};
    for (var j = 0; j < colCount; j++) {
      row[headers[j]] = data[i + j] || '';
     }
     }
     rows.push(row);
 
     var people = Object.keys(map).map(function (k) { return map[k]; });
 
    people.sort(function (a, b) {
      var A = norm((a.last || '') + ' ' + (a.first || ''));
      var B = norm((b.last || '') + ' ' + (b.first || ''));
      return A.localeCompare(B);
    });
 
    people.forEach(function (p) {
      p.sessions.sort(function (s1, s2) {
        var y1 = toYear(s1.dateFrom) || toYear(s1.dateTo) || '';
        var y2 = toYear(s2.dateFrom) || toYear(s2.dateTo) || '';
        if (y1 && y2) return Number(y2) - Number(y1);
        if (y1 && !y2) return -1;
        if (!y1 && y2) return 1;
        return 0;
      });
    });
 
    return people;
   }
   }


   return rows;
   // ====== RENDERING ======
}
  function buildPersonHeaderHTML(person) {
    var name = personName(person);
    var b = (person.birth || '').trim();
    var d = (person.death || '').trim();
    var lifeStr = '';


    if (b && d) lifeStr = b + '–' + d;
    else if (b && !d) lifeStr = b + '–';
    else if (!b && d) lifeStr = '–' + d;


  function buildHeader(p) {
     var meta = lifeStr ? '<span>' + esc(lifeStr) + '</span>' : '';
     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 (
     return (
       '<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>'
     );
     );
   }
   }


   function buildLinks(p, style) {
   function buildWikiLinkHTML(url) {
     var name = fullName(p);
    url = (url || '').trim();
    if (!url) return '';
    return '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(url) + '">Read Wiki Page</a>';
  }
 
  function buildLinkListHTML(name, kind, urls) {
     if (!urls || !urls.length) return '';
     var out = [];
     var out = [];
    for (var i = 0; i < urls.length; i++) {
      var label = 'Access ' + name + '’s ' + kind +
        (urls.length > 1 ? (' (Part ' + (i + 1) + '/' + urls.length + ')') : '');
      out.push(
        '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(urls[i]) + '">' +
          esc(label) +
        '</a>'
      );
    }
    return out.join('');
  }
  function buildSessionHTML(person, session) {
    var name = personName(person);
    var title = session.recorded;
    var groups = [
      { kind: 'Video', urls: session.video },
      { kind: 'Audio', urls: session.audio },
      { kind: 'Transcript', urls: session.transcripts }
    ].filter(function (g) { return g.urls && g.urls.length; });
    var links = '';
    if (!groups.length) {
      links = '<span class="ohr-muted">Information unavailable. Please contact us if you have details about this record.</span>';
    } else if (groups.length === 1) {
      links =
        '<div class="ohr-links">' +
          buildLinkListHTML(name, groups[0].kind, groups[0].urls) +
        '</div>';
    } else {
      links =
        '<div class="ohr-links-stack">' +
          groups.map(function (g) {
            return (
              '<div class="ohr-resource-group">' +
                '<div class="ohr-resource-label">' + esc(g.kind) + '</div>' +
                '<div class="ohr-links">' +
                  buildLinkListHTML(name, g.kind, g.urls) +
                '</div>' +
              '</div>'
            );
          }).join('') +
        '</div>';
    }
    return (
      '<div class="ohr-session">' +
        '<div class="ohr-session__title">' + esc(title) + '</div>' +
        links +
      '</div>'
    );
  }
  function makeAccordionPerson(person, idx) {
    var div = document.createElement('div');
    div.className = 'ohr-item';
    var panelId = 'ohr-panel-' + idx;
    var btnId = 'ohr-btn-' + idx;
    var wikiLink = buildWikiLinkHTML(person.wiki);
    var bio = (person.bio || '').trim();
    var sessionsHTML = '';
    for (var i = 0; i < person.sessions.length; i++) {
      sessionsHTML += buildSessionHTML(person, person.sessions[i]);
    }
    div.innerHTML =
      '<div class="ohr-head">' +
        buildPersonHeaderHTML(person) +
        '<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 + '">' +
        (bio ? '<div class="ohr-divider"><p class="ohr-bio ohr-m-0">' + esc(bio) + '</p></div>' : '') +
        (wikiLink ? '<div class="ohr-divider"><div class="ohr-links">' + wikiLink + '</div></div>' : '') +
        '<div class="ohr-divider">' + sessionsHTML + '</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');


    function add(type, urls) {
        if (open) {
      urls.forEach(function (url, i) {
          panel.classList.add('ohr-hidden');
        var label = accessLabel(name, type, i, urls.length);
          div.classList.remove('is-open');
        if (style === 'chips') {
           btn.setAttribute('aria-expanded', 'false');
           out.push(
          btn.textContent = 'Details';
            '<a class="ohr-chip" target="_blank" rel="noopener" href="' + esc(url) +
            '" title="' + esc(label) + '">' +
            esc(chipText(type, i, urls.length)) +
            '</a>'
          );
         } else {
         } else {
           out.push(
           panel.classList.remove('ohr-hidden');
            '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(url) + '">' +
          div.classList.add('is-open');
            esc(label) +
          btn.setAttribute('aria-expanded', 'true');
            '</a>'
          btn.textContent = 'Hide';
          );
         }
         }
       });
       });
     }
     }


     var wiki = (p[COL.wiki] || '').trim();
     return div;
    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) {
   function renderAccordion(people) {
     var container = $('ohr-list');
     var container = $('ohr-list');
     if (!container) return;
     if (!container) return;


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


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


     container.innerHTML = html;
  function updateCount(n, total) {
     var el = $('ohr-count');
    if (!el) return;
    el.textContent = (n === total) ? (total + ' people') : (n + ' of ' + total + ' people');
   }
   }


  // ====== FILTERING ======
   function applyFilters() {
   function applyFilters() {
     var q = norm(FILTER.q);
     var q = norm(FILTER.q);
     var filtered = ALL.filter(function (row) {
 
     var filtered = PEOPLE.filter(function (p) {
       if (!q) return true;
       if (!q) return true;
       var hay = norm(
       var hay = norm(
         (row[COL.first] || '') + ' ' +
         (p.first || '') + ' ' +
         (row[COL.last] || '') + ' ' +
         (p.last || '') + ' ' +
         (row[COL.summary] || '')
         (p.bio || '') + ' ' +
        (p.birth || '') + ' ' +
        (p.death || '') + ' ' +
        (p.wiki || '')
       );
       );
      for (var i = 0; i < p.sessions.length; i++) {
        hay += ' ' + norm(p.sessions[i].recorded || '');
      }
       return hay.indexOf(q) !== -1;
       return hay.indexOf(q) !== -1;
     });
     });


     filtered.sort(function (a, b) {
     renderAccordion(filtered);
      return norm(a[COL.last]).localeCompare(norm(b[COL.last]));
     updateCount(filtered.length, PEOPLE.length);
     });
 
    render(filtered);
    var count = $('ohr-count');
    if (count) count.textContent = filtered.length + ' records';
   }
   }


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


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


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


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


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

Latest revision as of 09:25, 29 March 2026

/* ===== OralHistoryRecords-Updated.js (Grouped by Person Key, Option 2) ===== */
/* global mw */
(function () {
  'use strict';

  // ====== COLUMN CONFIG (MUST MATCH CSV HEADERS EXACTLY) ======
  var COL = {
    personKey: 'Person Key',
    first: 'First Name',
    last: 'Last Name',
    birth: 'Birth Year',
    death: 'Death Year',
    dateFrom: 'Date From',
    dateTo: 'Date To',
    bio: 'Short Bio',
    wiki: 'Wiki Link',
    audio: 'Audio Link',
    video: 'Video Link',
    transcripts: 'Transcript Link'
  };

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

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

  function onReady(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn);
  }

  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 a = arguments, self = this;
      clearTimeout(t);
      t = setTimeout(function () { fn.apply(self, a); }, ms);
    };
  }

  function personName(person) {
    return ((person.first || '').trim() + ' ' + (person.last || '').trim()).trim();
  }

  // Optional override:
  // <div id="ohr-directory" data-csv="/index.php/Special:FilePath/OHData.csv">
  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();
  }

function toYear(value) {
  if (!value) return '';

  var s = String(value).trim();

  // First: look for a 4-digit year anywhere in the string
  var m4 = s.match(/\b(1[6-9]\d{2}|20\d{2}|21\d{2})\b/);
  if (m4) return m4[1];

  // Next: handle common numeric dates like 4/7/11 or 4-7-11
  var m2 = s.match(/^\d{1,2}[\/-]\d{1,2}[\/-](\d{2})$/);
  if (m2) {
    var yy = Number(m2[1]);

    // Adjust pivot if needed
    return String(yy <= 29 ? 2000 + yy : 1900 + yy);
  }

  return '';
}

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

  function fullName(row) {
    var first = (row[COL.first] || '').trim();
    var last = (row[COL.last] || '').trim();
    if (!first || !last) return '';
    return (first + ' ' + last).trim();
  }

  function extractUrls(value) {
    if (!value) return [];
    var s = String(value);

    var re = /https?:\/\/[^\s"'<>()]+/g;
    var m = s.match(re) || [];

    var seen = Object.create(null);
    var out = [];
    for (var i = 0; i < m.length; i++) {
      var url = m[i].trim();
      url = url.replace(/[);.,]+$/g, '');
      if (!url) continue;
      if (!seen[url]) {
        seen[url] = true;
        out.push(url);
      }
    }
    return out;
  }

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

    var search = document.createElement('input');
    search.id = 'ohr-search';
    search.type = 'search';
    search.placeholder = 'Search names, bios…';
    search.setAttribute('aria-label', 'Search oral history records');

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

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

  // ====== CSV PARSER ======
  function parseCSV(text) {
    if (!text) return [];

    text = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');

    var rows = [];
    var row = [];
    var field = '';
    var i = 0;
    var inQuotes = false;

    while (i < text.length) {
      var c = text[i];

      if (inQuotes) {
        if (c === '"') {
          if (i + 1 < text.length && text[i + 1] === '"') {
            field += '"';
            i += 2;
            continue;
          }
          inQuotes = false;
          i += 1;
          continue;
        }
        field += c;
        i += 1;
        continue;
      }

      if (c === '"') {
        inQuotes = true;
        i += 1;
        continue;
      }
      if (c === ',') {
        row.push(field);
        field = '';
        i += 1;
        continue;
      }
      if (c === '\n') {
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i += 1;
        continue;
      }

      field += c;
      i += 1;
    }

    row.push(field);
    if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);

    if (!rows.length) return [];

    var headers = rows[0].map(function (h) { return (h || '').trim(); });
    var out = [];

    for (var r = 1; r < rows.length; r++) {
      var values = rows[r];

      var nonEmpty = false;
      for (var k = 0; k < values.length; k++) {
        if (String(values[k] || '').trim() !== '') { nonEmpty = true; break; }
      }
      if (!nonEmpty) continue;

      var obj = {};
      for (var c2 = 0; c2 < headers.length; c2++) {
        var key = headers[c2];
        if (!key) continue;
        obj[key] = (c2 < values.length) ? values[c2] : '';
      }
      out.push(obj);
    }
    return out;
  }

  // ====== GROUPING ======
  function firstNonEmpty(a, b) {
    var A = (a || '').trim();
    if (A) return A;
    return (b || '').trim();
  }

  function groupByPersonKey(rows) {
    var map = Object.create(null);

    for (var i = 0; i < rows.length; i++) {
      var row = rows[i];
      var key = (row[COL.personKey] || '').trim();

      if (!key) continue;
      if (!fullName(row)) continue;

      if (!map[key]) {
        map[key] = {
          key: key,
          first: (row[COL.first] || '').trim(),
          last: (row[COL.last] || '').trim(),
          birth: (row[COL.birth] || '').trim(),
          death: (row[COL.death] || '').trim(),
          wiki: (row[COL.wiki] || '').trim(),
          bio: (row[COL.bio] || '').trim(),
          sessions: []
        };
      } else {
        map[key].first = firstNonEmpty(map[key].first, row[COL.first]);
        map[key].last = firstNonEmpty(map[key].last, row[COL.last]);
        map[key].birth = firstNonEmpty(map[key].birth, row[COL.birth]);
        map[key].death = firstNonEmpty(map[key].death, row[COL.death]);
        map[key].wiki = firstNonEmpty(map[key].wiki, row[COL.wiki]);
        map[key].bio = firstNonEmpty(map[key].bio, row[COL.bio]);
      }

      var session = {
        recorded: recordedLabel(row),
        dateFrom: (row[COL.dateFrom] || '').trim(),
        dateTo: (row[COL.dateTo] || '').trim(),
        video: extractUrls(row[COL.video]),
        audio: extractUrls(row[COL.audio]),
        transcripts: extractUrls(row[COL.transcripts])
      };

      map[key].sessions.push(session);
    }

    var people = Object.keys(map).map(function (k) { return map[k]; });

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

    people.forEach(function (p) {
      p.sessions.sort(function (s1, s2) {
        var y1 = toYear(s1.dateFrom) || toYear(s1.dateTo) || '';
        var y2 = toYear(s2.dateFrom) || toYear(s2.dateTo) || '';
        if (y1 && y2) return Number(y2) - Number(y1);
        if (y1 && !y2) return -1;
        if (!y1 && y2) return 1;
        return 0;
      });
    });

    return people;
  }

  // ====== RENDERING ======
  function buildPersonHeaderHTML(person) {
    var name = personName(person);
    var b = (person.birth || '').trim();
    var d = (person.death || '').trim();
    var lifeStr = '';

    if (b && d) lifeStr = b + '–' + d;
    else if (b && !d) lifeStr = b + '–';
    else if (!b && d) lifeStr = '–' + d;

    var meta = lifeStr ? '<span>' + esc(lifeStr) + '</span>' : '';

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

  function buildWikiLinkHTML(url) {
    url = (url || '').trim();
    if (!url) return '';
    return '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(url) + '">Read Wiki Page</a>';
  }

  function buildLinkListHTML(name, kind, urls) {
    if (!urls || !urls.length) return '';
    var out = [];
    for (var i = 0; i < urls.length; i++) {
      var label = 'Access ' + name + '’s ' + kind +
        (urls.length > 1 ? (' (Part ' + (i + 1) + '/' + urls.length + ')') : '');
      out.push(
        '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(urls[i]) + '">' +
          esc(label) +
        '</a>'
      );
    }
    return out.join('');
  }

  function buildSessionHTML(person, session) {
    var name = personName(person);
    var title = session.recorded;

    var groups = [
      { kind: 'Video', urls: session.video },
      { kind: 'Audio', urls: session.audio },
      { kind: 'Transcript', urls: session.transcripts }
    ].filter(function (g) { return g.urls && g.urls.length; });

    var links = '';

    if (!groups.length) {
      links = '<span class="ohr-muted">Information unavailable. Please contact us if you have details about this record.</span>';
    } else if (groups.length === 1) {
      links =
        '<div class="ohr-links">' +
          buildLinkListHTML(name, groups[0].kind, groups[0].urls) +
        '</div>';
    } else {
      links =
        '<div class="ohr-links-stack">' +
          groups.map(function (g) {
            return (
              '<div class="ohr-resource-group">' +
                '<div class="ohr-resource-label">' + esc(g.kind) + '</div>' +
                '<div class="ohr-links">' +
                  buildLinkListHTML(name, g.kind, g.urls) +
                '</div>' +
              '</div>'
            );
          }).join('') +
        '</div>';
    }

    return (
      '<div class="ohr-session">' +
        '<div class="ohr-session__title">' + esc(title) + '</div>' +
        links +
      '</div>'
    );
  }

  function makeAccordionPerson(person, idx) {
    var div = document.createElement('div');
    div.className = 'ohr-item';

    var panelId = 'ohr-panel-' + idx;
    var btnId = 'ohr-btn-' + idx;

    var wikiLink = buildWikiLinkHTML(person.wiki);
    var bio = (person.bio || '').trim();

    var sessionsHTML = '';
    for (var i = 0; i < person.sessions.length; i++) {
      sessionsHTML += buildSessionHTML(person, person.sessions[i]);
    }

    div.innerHTML =
      '<div class="ohr-head">' +
        buildPersonHeaderHTML(person) +
        '<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 + '">' +
        (bio ? '<div class="ohr-divider"><p class="ohr-bio ohr-m-0">' + esc(bio) + '</p></div>' : '') +
        (wikiLink ? '<div class="ohr-divider"><div class="ohr-links">' + wikiLink + '</div></div>' : '') +
        '<div class="ohr-divider">' + sessionsHTML + '</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');
          div.classList.remove('is-open');
          btn.setAttribute('aria-expanded', 'false');
          btn.textContent = 'Details';
        } else {
          panel.classList.remove('ohr-hidden');
          div.classList.add('is-open');
          btn.setAttribute('aria-expanded', 'true');
          btn.textContent = 'Hide';
        }
      });
    }

    return div;
  }

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

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

    var frag = document.createDocumentFragment();
    for (var i = 0; i < people.length; i++) {
      frag.appendChild(makeAccordionPerson(people[i], i));
    }
    container.innerHTML = '';
    container.appendChild(frag);
  }

  function updateCount(n, total) {
    var el = $('ohr-count');
    if (!el) return;
    el.textContent = (n === total) ? (total + ' people') : (n + ' of ' + total + ' people');
  }

  // ====== FILTERING ======
  function applyFilters() {
    var q = norm(FILTER.q);

    var filtered = PEOPLE.filter(function (p) {
      if (!q) return true;

      var hay = norm(
        (p.first || '') + ' ' +
        (p.last || '') + ' ' +
        (p.bio || '') + ' ' +
        (p.birth || '') + ' ' +
        (p.death || '') + ' ' +
        (p.wiki || '')
      );

      for (var i = 0; i < p.sessions.length; i++) {
        hay += ' ' + norm(p.sessions[i].recorded || '');
      }

      return hay.indexOf(q) !== -1;
    });

    renderAccordion(filtered);
    updateCount(filtered.length, PEOPLE.length);
  }

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

  // ====== ERRORS ======
  function displayError(message) {
    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 ohr-mt-sm">' + esc(message) + '</p>';
    }
  }

  // ====== MAIN ======
  function fetchAndRender() {
    var root = $('ohr-directory');
    var loading = $('ohr-loading');
    var container = $('ohr-list');
    if (!root || !container) 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) {
        var rows = parseCSV(text);
        PEOPLE = groupByPersonKey(rows);

        if (loading) loading.classList.add('ohr-hidden');
        attachHandlers();
        applyFilters();
      })
      .catch(function (err) {
        displayError('There was an error accessing the records. Details: ' + err.message);
        console.error(err);
      });
  }

  onReady(function () {
    if ($('ohr-directory')) {
      mw.loader.using([]).then(fetchAndRender);
    }
  });
})();