Jump to content

MediaWiki:Gadget-OralHistoryRecords-Updated.js

Revision as of 14:45, 21 February 2026 by Hthach (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* ===== OralHistoryRecords-Updated.js ===== */
/* global mw */
(function () {
  'use strict';

  // ====== COLUMN CONFIG ======
  // These must match your CSV header names exactly.
  var COL = {
    transcripts: 'Transcript Link',
    audio: 'Audio Link',
    video: 'Video Link',
    first: 'First Name',
    last: 'Last Name',
    birth: 'Birth Year',
    death: 'Death Year',
    dateFrom: 'Date From',
    dateTo: 'Date To',
    bio: 'Short Bio',
    wiki: 'Wiki Link'
  };

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

  // ====== UTILS ======
  function onReady(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn);
  }
  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 a = arguments, self = this;
      clearTimeout(t);
      t = setTimeout(function () { fn.apply(self, a); }, ms);
    };
  }

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

  // 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() : '';

    // wgScript is usually "/index.php" on your site (matches your working URL style)
    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 m = s.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 '';
  }

  // IMPORTANT: Only split on commas for the 3 link columns (Audio/Video/Transcripts).
  // Other columns may contain commas as normal punctuation.
  function splitLinkCell(value) {
    if (!value) return [];
    return String(value)
      .split(',')
      .map(function (x) { return x.trim(); })
      .filter(Boolean);
  }

  // Must have both first & last name
  function hasValidName(person) {
    var first = (person[COL.first] || '').trim();
    var last = (person[COL.last] || '').trim();
    return !!(first && last);
  }

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

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

  // Directory + Chips: truncate to 300 chars.
  // Accordion: full bio.
  function truncate(text, maxChars) {
    if (!text) return '';
    var s = String(text).trim();
    if (s.length <= maxChars) return s;
    return s.slice(0, maxChars).trim() + '…';
  }

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

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

  // ====== 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);
  }

  // ====== ROBUST CSV PARSER ======
  // Handles: quotes, embedded commas, embedded newlines, escaped quotes ("")
  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];

      // Skip totally empty rows
      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;
  }

  // ====== Rendering building blocks ======
  function buildHeaderHTML(person) {
    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 (
      '<div class="ohr-head">' +
        '<div class="ohr-titleblock">' +
          '<h3 class="ohr-name">' + esc(name) + '</h3>' +
          (meta ? '<div class="ohr-meta">' + meta + '</div>' : '') +
        '</div>' +
      '</div>'
    );
  }

  function buildWikiLinkHTML(person, cls) {
    var wiki = (person[COL.wiki] || '').trim();
    if (!wiki) return '';
    return '<a class="' + cls + '" target="_blank" rel="noopener" href="' + esc(wiki) + '">Read Wiki Page</a>';
  }

  function buildLinksHTML(person, style) {
    var name = fullName(person);

    var video = splitLinkCell(person[COL.video]);
    var audio = splitLinkCell(person[COL.audio]);
    var transcripts = splitLinkCell(person[COL.transcripts]);

    var out = [];

    function add(kind, urls) {
      for (var i = 0; i < urls.length; i++) {
        var label = buildAccessLabel(name, kind, i, urls.length);

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

    // Wiki link first (if present)
    if (style === 'chips') {
      var wikiChip = buildWikiLinkHTML(person, 'ohr-chip');
      if (wikiChip) out.push(wikiChip);
    } else {
      var wikiLink = buildWikiLinkHTML(person, 'ohr-link');
      if (wikiLink) out.push(wikiLink);
    }

    if (video.length) add('video', video);
    if (audio.length) add('audio', audio);
    if (transcripts.length) add('transcript', transcripts);

    if (!out.length) return '';

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

  // ====== Views ======
  function makeDirectoryItem(person) {
    var bio = truncate((person[COL.bio] || '').trim(), 300); // truncated
    var div = document.createElement('div');
    div.className = 'ohr-item';
    div.innerHTML =
      buildHeaderHTML(person) +
      (bio ? '<p class="ohr-bio">' + esc(bio) + '</p>' : '') +
      buildLinksHTML(person, 'links');
    return div;
  }

  function makeAccordionItem(person, idx) {
    var bio = (person[COL.bio] || '').trim(); // full bio (Option 2)
    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 + '">' +
        (bio ? '<div class="ohr-divider"><p class="ohr-bio" style="margin:0">' + esc(bio) + '</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 bio = truncate((person[COL.bio] || '').trim(), 300); // truncated
    var div = document.createElement('div');
    div.className = 'ohr-item';
    div.innerHTML =
      buildHeaderHTML(person) +
      (bio ? '<p class="ohr-bio">' + esc(bio) + '</p>' : '') +
      buildLinksHTML(person, 'chips');
    return div;
  }

  function renderList(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 frag = document.createDocumentFragment();

    for (var i = 0; i < rows.length; i++) {
      if (mode === 'accordion') frag.appendChild(makeAccordionItem(rows[i], i));
      else if (mode === 'chips') frag.appendChild(makeChipsItem(rows[i]));
      else frag.appendChild(makeDirectoryItem(rows[i]));
    }

    container.innerHTML = '';
    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() {
    var q = norm(FILTER.q);

    // Remove invalid-name records entirely
    var valid = ALL.filter(function (row) {
      return hasValidName(row);
    });

    var filtered = valid.filter(function (row) {
      if (!q) return true;
      var hay = norm(
        (row[COL.first] || '') + ' ' +
        (row[COL.last] || '') + ' ' +
        (row[COL.bio] || '') + ' ' +
        (row[COL.birth] || '') + ' ' +
        (row[COL.death] || '') + ' ' +
        (row[COL.dateFrom] || '') + ' ' +
        (row[COL.dateTo] || '')
      );
      return hay.indexOf(q) !== -1;
    });

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

    renderList(filtered);
    updateCount(filtered.length, valid.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" style="margin-top:0.5rem">' + 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) {
        ALL = parseCSV(text);
        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);
    }
  });
})();