MediaWiki:Gadget-OralHistoryRecords-Updated.js: Difference between revisions
Appearance
No edit summary |
No edit summary |
||
| Line 5: | Line 5: | ||
// ====== COLUMN CONFIG ====== | // ====== COLUMN CONFIG ====== | ||
// These must match your CSV header names exactly. | |||
var COL = { | var COL = { | ||
transcript: 'Transcript Identifier', | transcript: 'Transcript Identifier', | ||
| Line 23: | Line 24: | ||
var FILTER = { q: '' }; | var FILTER = { q: '' }; | ||
// ====== | // ====== 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 | var a = arguments, self = this; | ||
clearTimeout(t); | clearTimeout(t); | ||
t = setTimeout(function () { fn.apply( | t = setTimeout(function () { fn.apply(self, a); }, 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'); | ||
| Line 57: | Line 64: | ||
} | } | ||
// == | // 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 || ( | // 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(); | return base + (base.indexOf('?') === -1 ? '?' : '&') + 'cb=' + Date.now(); | ||
} | } | ||
function toYear(value) { | function toYear(value) { | ||
if (!value) return ''; | if (!value) return ''; | ||
var | var s = String(value).trim(); | ||
// capture a plausible year anywhere in the string | |||
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 86: | Line 93: | ||
} | } | ||
function | function splitLinksCSV(value) { | ||
// Multiple URLs in a cell are comma-separated | |||
if (!value) return []; | if (!value) return []; | ||
return String(value).split(',').map(function (x) { return x.trim(); }).filter(Boolean); | return String(value) | ||
.split(',') | |||
.map(function (x) { return x.trim(); }) | |||
.filter(Boolean); | |||
} | } | ||
function fullName( | function fullName(person) { | ||
var first = (person[COL.first] || '').trim(); | |||
var last = (person[COL.last] || '').trim(); | |||
return (first + ' ' + last).trim() || 'Untitled'; | |||
} | } | ||
function lifespan( | function lifespan(person) { | ||
var b = | var b = (person[COL.birth] || '').trim(); | ||
var d = | var d = (person[COL.death] || '').trim(); | ||
if (b && d) return b + '–' + d; | if (b && d) return b + '–' + d; | ||
if (b && !d) return b + '–'; | if (b && !d) return b + '–'; | ||
| Line 104: | Line 117: | ||
} | } | ||
function | function buildAccessLabel(name, kind, idx, total) { | ||
var part = total > 1 ? ' (Part ' + ( | var part = (total > 1) ? (' (Part ' + (idx + 1) + '/' + total + ')') : ''; | ||
var kindTitle = kind.charAt(0).toUpperCase() + kind.slice(1); | |||
return 'Access ' + name + '’s ' + kindTitle + part; | |||
} | } | ||
function | function buildChipText(kind, idx, total) { | ||
var kindTitle = kind.charAt(0).toUpperCase() + kind.slice(1); | |||
return | if (total > 1) return kindTitle + ' ' + (idx + 1) + '/' + total; | ||
return kindTitle; | |||
} | } | ||
// ====== UI ====== | // ====== UI ====== | ||
function buildToolbar(root) { | function buildToolbar(root) { | ||
var | var toolbar = document.createElement('div'); | ||
toolbar.className = 'ohr-toolbar'; | |||
var search = document.createElement('input'); | var search = document.createElement('input'); | ||
| Line 123: | Line 138: | ||
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); | |||
toolbar.appendChild(count); | |||
root.insertBefore( | root.insertBefore(toolbar, root.firstChild); | ||
} | } | ||
var | // ====== ROBUST CSV PARSER ====== | ||
// Handles: quotes, embedded commas, embedded newlines, escaped quotes ("") | |||
function parseCSV(text) { | |||
if (!text) return []; | |||
// Normalize newlines | |||
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 === '"') { | |||
// Escaped quote? | |||
if (i + 1 < text.length && text[i + 1] === '"') { | |||
field += '"'; | |||
i += 2; | |||
continue; | |||
} | |||
// End quote | |||
inQuotes = false; | |||
i += 1; | |||
continue; | |||
} | |||
// Regular character inside quotes (including newlines) | |||
field += c; | |||
i += 1; | |||
continue; | |||
} | |||
// Not in quotes | |||
if (c === '"') { | |||
inQuotes = true; | |||
i += 1; | |||
continue; | |||
} | |||
if (c === ',') { | |||
row.push(field); | |||
field = ''; | |||
i += 1; | |||
continue; | |||
} | |||
if (c === '\n') { | |||
row.push(field); | |||
field = ''; | |||
// Avoid pushing a final blank row if file ends with newline | |||
rows.push(row); | |||
row = []; | |||
i += 1; | |||
continue; | |||
} | |||
field += c; | |||
i += 1; | |||
} | |||
// Flush last field/row | |||
row.push(field); | |||
// Only push if it’s not a totally empty trailing row | |||
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(); }); | |||
// Build objects; tolerate short/long rows by padding/truncating | |||
var out = []; | |||
for (var r = 1; r < rows.length; r++) { | |||
var values = rows[r]; | |||
// Skip completely 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; // ignore blank header columns | |||
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>'); | |||
var meta = | |||
return ( | return ( | ||
'<div class="ohr-head">' + | '<div class="ohr-head">' + | ||
'<div class="ohr-titleblock">' + | '<div class="ohr-titleblock">' + | ||
'<h3 class="ohr-name">' + esc( | '<h3 class="ohr-name">' + esc(name) + '</h3>' + | ||
( | (meta ? '<div class="ohr-meta">' + meta + '</div>' : '') + | ||
'</div>' + | '</div>' + | ||
'</div>' | '</div>' | ||
| Line 179: | Line 266: | ||
} | } | ||
function | function buildWikiLinkHTML(person, cls) { | ||
var name = fullName( | 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) { | |||
// 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( | function add(kind, urls) { | ||
urls. | for (var i = 0; i < urls.length; i++) { | ||
var label = | var label = buildAccessLabel(name, kind, 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( | '<a class="ohr-chip" target="_blank" rel="noopener" href="' + esc(urls[i]) + '"' + | ||
' title="' + esc(label) + '"' + | |||
esc(chipText | ' aria-label="' + esc(label) + '"' + | ||
'>' + esc(chipText) + '</a>' | |||
); | ); | ||
} else { | } else { | ||
out.push( | out.push( | ||
'<a class="ohr-link" target="_blank" rel="noopener" href="' + esc( | '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(urls[i]) + '">' + | ||
esc(label) + | |||
'</a>' | '</a>' | ||
); | ); | ||
} | } | ||
} | } | ||
} | } | ||
var | // 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); | |||
} | } | ||
add('video', | // Then media identifiers (only render what exists) | ||
add('audio', | if (video.length) add('video', video); | ||
add('transcript', | if (audio.length) add('audio', audio); | ||
if (transcript.length) add('transcript', transcript); | |||
if (!out.length) return ''; | if (!out.length) return ''; | ||
return '<div class="' + ( | |||
if (style === 'chips') return '<div class="ohr-chips">' + out.join('') + '</div>'; | |||
return '<div class="ohr-links">' + out.join('') + '</div>'; | |||
} | } | ||
function | // ====== 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) { | |||
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; | |||
// Header with button | |||
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>'); | |||
div.innerHTML = | |||
'<div class="ohr-head">' + | |||
'<div class="ohr-titleblock">' + | |||
'<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 229: | Line 408: | ||
var mode = getViewMode(); | var mode = getViewMode(); | ||
var | 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.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() { | 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 248: | Line 435: | ||
(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 254: | Line 445: | ||
filtered.sort(function (a, b) { | 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, ALL.length); | |||
} | } | ||
function attachHandlers() { | function attachHandlers() { | ||
var | var qInput = $('ohr-search'); | ||
if ( | if (qInput) { | ||
qInput.addEventListener('input', debounce(function () { | |||
FILTER.q = | FILTER.q = qInput.value.trim(); | ||
applyFilters(); | applyFilters(); | ||
}, 200)); | }, 200)); | ||
| Line 272: | Line 464: | ||
} | } | ||
function displayError( | // ====== Errors ====== | ||
function displayError(message) { | |||
var loading = $('ohr-loading'); | var loading = $('ohr-loading'); | ||
var error = $('ohr-error'); | var error = $('ohr-error'); | ||
| Line 281: | Line 474: | ||
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( | '<p class="ohr-muted" style="margin-top:0.5rem">' + esc(message) + '</p>'; | ||
} | } | ||
} | } | ||
function | // ====== 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 298: | Line 494: | ||
.then(function (text) { | .then(function (text) { | ||
ALL = parseCSV(text); | ALL = parseCSV(text); | ||
// If parse returns 0 rows but file isn't empty, surface a helpful error | |||
if (!ALL.length && String(text || '').trim().length > 0) { | |||
// Still allow rendering "No matching records", but log debug info | |||
console.warn('CSV parsed to 0 rows. Check headers/CSV formatting.'); | |||
} | |||
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 ( | onReady(function () { | ||
if ($('ohr-directory')) { | |||
mw.loader.using([]).then(fetchAndRender); | |||
} | |||
}); | |||
})(); | })(); | ||
Revision as of 14:19, 21 February 2026
/* ===== OralHistoryRecords-Updated.js ===== */
/* global mw */
(function () {
'use strict';
// ====== COLUMN CONFIG ======
// These must match your CSV header names exactly.
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: '' };
// ====== 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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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') || '').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();
// capture a plausible year anywhere in the string
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 '';
}
function splitLinksCSV(value) {
// Multiple URLs in a cell are comma-separated
if (!value) return [];
return String(value)
.split(',')
.map(function (x) { return x.trim(); })
.filter(Boolean);
}
function fullName(person) {
var first = (person[COL.first] || '').trim();
var last = (person[COL.last] || '').trim();
return (first + ' ' + last).trim() || 'Untitled';
}
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 '';
}
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, summaries…';
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 [];
// Normalize newlines
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 === '"') {
// Escaped quote?
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
// End quote
inQuotes = false;
i += 1;
continue;
}
// Regular character inside quotes (including newlines)
field += c;
i += 1;
continue;
}
// Not in quotes
if (c === '"') {
inQuotes = true;
i += 1;
continue;
}
if (c === ',') {
row.push(field);
field = '';
i += 1;
continue;
}
if (c === '\n') {
row.push(field);
field = '';
// Avoid pushing a final blank row if file ends with newline
rows.push(row);
row = [];
i += 1;
continue;
}
field += c;
i += 1;
}
// Flush last field/row
row.push(field);
// Only push if it’s not a totally empty trailing row
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(); });
// Build objects; tolerate short/long rows by padding/truncating
var out = [];
for (var r = 1; r < rows.length; r++) {
var values = rows[r];
// Skip completely 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; // ignore blank header columns
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) {
// 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 = [];
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);
}
// Then media identifiers (only render what exists)
if (video.length) add('video', video);
if (audio.length) add('audio', audio);
if (transcript.length) add('transcript', transcript);
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 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) {
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;
// Header with button
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>');
div.innerHTML =
'<div class="ohr-head">' +
'<div class="ohr-titleblock">' +
'<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');
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);
var filtered = ALL.filter(function (row) {
if (!q) return true;
var hay = norm(
(row[COL.first] || '') + ' ' +
(row[COL.last] || '') + ' ' +
(row[COL.summary] || '') + ' ' +
(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, ALL.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 parse returns 0 rows but file isn't empty, surface a helpful error
if (!ALL.length && String(text || '').trim().length > 0) {
// Still allow rendering "No matching records", but log debug info
console.warn('CSV parsed to 0 rows. Check headers/CSV formatting.');
}
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);
}
});
})();