MediaWiki:Gadget-OralHistoryRecords-Updated.js: Difference between revisions
Appearance
Created page with "→===== Oral History Records (JS) =====: →global mw: (function () { 'use strict'; // ====== CONFIG ====== // Always resolve to the latest uploaded version of File:OHData.csv var CSV_URL = mw.util.getUrl('Special:FilePath/OHData.csv') + '?cb=' + Date.now(); // Spreadsheet column names (exactly as provided) var COL = { transcript: 'Transcript Identifier', audio: 'Audio Identifier', video: 'Video Identifier', first: 'First Name', las..." |
No edit summary |
||
| (19 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* ===== | /* ===== OralHistoryRecords-Updated.js (Grouped by Person Key, Option 2) ===== */ | ||
/* global mw */ | /* global mw */ | ||
(function () { | (function () { | ||
'use strict'; | 'use strict'; | ||
// ====== CONFIG ====== | // ====== COLUMN CONFIG (MUST MATCH CSV HEADERS EXACTLY) ====== | ||
var COL = { | var COL = { | ||
personKey: 'Person Key', | |||
first: 'First Name', | first: 'First Name', | ||
last: 'Last Name', | last: 'Last Name', | ||
| Line 19: | Line 13: | ||
dateFrom: 'Date From', | dateFrom: 'Date From', | ||
dateTo: 'Date To', | dateTo: 'Date To', | ||
bio: 'Short Bio', | |||
wiki: 'Wiki Link' | wiki: 'Wiki Link', | ||
audio: 'Audio Link', | |||
video: 'Video Link', | |||
transcripts: 'Transcript Link' | |||
}; | }; | ||
// ====== STATE ====== | // ====== STATE ====== | ||
var | var PEOPLE = []; | ||
var FILTER = { q: '' }; | var FILTER = { q: '' }; | ||
// ====== UTILS ====== | // ====== UTILS ====== | ||
function $(id) { return document.getElementById(id); } | |||
function onReady(fn) { | function onReady(fn) { | ||
if (document.readyState !== 'loading') fn(); | if (document.readyState !== 'loading') fn(); | ||
else document.addEventListener('DOMContentLoaded', fn); | else document.addEventListener('DOMContentLoaded', fn); | ||
} | } | ||
function esc(s) { | function esc(s) { | ||
s = (s == null ? '' : String(s)); | s = (s == null ? '' : String(s)); | ||
| Line 39: | Line 38: | ||
}); | }); | ||
} | } | ||
function norm(s) { | function norm(s) { | ||
return (s || '') | return (s || '') | ||
| Line 46: | Line 46: | ||
.toLowerCase(); | .toLowerCase(); | ||
} | } | ||
function debounce(fn, ms) { | function debounce(fn, ms) { | ||
var t; | var t; | ||
| Line 55: | Line 56: | ||
} | } | ||
// | function personName(person) { | ||
function | 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 root = $('ohr-directory'); | ||
var | var override = root ? (root.getAttribute('data-csv') || '').trim() : ''; | ||
var base = override || ((mw.config.get('wgScript') || '/index.php') + '/Special:FilePath/OHData.csv'); | |||
return ' | 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); | |||
} | } | ||
function recordedLabel( | return ''; | ||
var y1 = toYear( | } | ||
var y2 = toYear( | |||
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 && 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 | function fullName(row) { | ||
var first = (row[COL.first] || '').trim(); | |||
var last = (row[COL.last] || '').trim(); | |||
if (!first || !last) return ''; | |||
return (first + ' ' + last).trim(); | |||
} | } | ||
function | function extractUrls(value) { | ||
if (!value) return []; | |||
var | var s = String(value); | ||
var re = /https?:\/\/[^\s"'<>()]+/g; | |||
var | var m = s.match(re) || []; | ||
var | |||
var seen = Object.create(null); | |||
var out = []; | |||
var | 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 | } | ||
return out; | |||
} | } | ||
| Line 124: | Line 135: | ||
search.id = 'ohr-search'; | search.id = 'ohr-search'; | ||
search.type = 'search'; | search.type = 'search'; | ||
search.placeholder = 'Search names, | search.placeholder = 'Search names, bios…'; | ||
search.setAttribute('aria-label', 'Search oral history records'); | search.setAttribute('aria-label', 'Search oral history records'); | ||
| Line 137: | Line 148: | ||
} | } | ||
// ====== CSV | // ====== CSV PARSER ====== | ||
function parseCSV( | function parseCSV(text) { | ||
if (!text) return []; | |||
if (! | |||
text = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); | |||
var rows = []; | |||
var row = []; | |||
var | var field = ''; | ||
var | var i = 0; | ||
var | var inQuotes = false; | ||
var | 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 = []; | var out = []; | ||
for (var r = 1; r < rows.length; r++) { | |||
for (var | 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 | function buildPersonHeaderHTML(person) { | ||
var | var name = personName(person); | ||
var div = | var b = (person.birth || '').trim(); | ||
var d = (person.death || '').trim(); | |||
var lifeStr = ''; | |||
( | if (b && d) lifeStr = b + '–' + d; | ||
else if (b && !d) lifeStr = b + '–'; | |||
return div; | 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 | function makeAccordionPerson(person, idx) { | ||
var div = document.createElement('div'); | var div = document.createElement('div'); | ||
div.className = 'ohr-item'; | div.className = 'ohr-item'; | ||
| Line 254: | Line 393: | ||
var panelId = 'ohr-panel-' + idx; | var panelId = 'ohr-panel-' + idx; | ||
var btnId = 'ohr-btn-' + 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.innerHTML = | ||
'<div class="ohr-head">' + | '<div class="ohr-head">' + | ||
buildPersonHeaderHTML(person) + | |||
'<button class="ohr-acc-btn" id="' + btnId + '" type="button" aria-expanded="false" aria-controls="' + panelId + '">Details</button>' + | '<button class="ohr-acc-btn" id="' + btnId + '" type="button" aria-expanded="false" aria-controls="' + panelId + '">Details</button>' + | ||
'</div>' + | '</div>' + | ||
'<div class="ohr-acc-panel ohr-hidden" id="' + panelId + '">' + | '<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>' : '') + | ||
'<div class="ohr-divider">' + | (wikiLink ? '<div class="ohr-divider"><div class="ohr-links">' + wikiLink + '</div></div>' : '') + | ||
'<div class="ohr-divider">' + sessionsHTML + '</div>' + | |||
'</div>'; | '</div>'; | ||
var btn = div.querySelector('#' + btnId); | var btn = div.querySelector('#' + btnId); | ||
var panel = div.querySelector('#' + panelId); | var panel = div.querySelector('#' + panelId); | ||
if (btn && panel) { | if (btn && panel) { | ||
btn.addEventListener('click', function () { | btn.addEventListener('click', function () { | ||
var open = !panel.classList.contains('ohr-hidden'); | var open = !panel.classList.contains('ohr-hidden'); | ||
if (open) { | if (open) { | ||
panel.classList.add('ohr-hidden'); | panel.classList.add('ohr-hidden'); | ||
div.classList.remove('is-open'); | |||
btn.setAttribute('aria-expanded', 'false'); | btn.setAttribute('aria-expanded', 'false'); | ||
btn.textContent = 'Details'; | btn.textContent = 'Details'; | ||
} else { | } else { | ||
panel.classList.remove('ohr-hidden'); | panel.classList.remove('ohr-hidden'); | ||
div.classList.add('is-open'); | |||
btn.setAttribute('aria-expanded', 'true'); | btn.setAttribute('aria-expanded', 'true'); | ||
btn.textContent = 'Hide'; | btn.textContent = 'Hide'; | ||
| Line 301: | Line 437: | ||
} | } | ||
function | function renderAccordion(people) { | ||
var container = $('ohr-list'); | var container = $('ohr-list'); | ||
if (!container) return; | if (!container) return; | ||
if (! | if (!people.length) { | ||
container.innerHTML = '<p class="ohr-muted | container.innerHTML = '<p class="ohr-muted ohr-center">No matching records.</p>'; | ||
return; | return; | ||
} | } | ||
var frag = document.createDocumentFragment(); | var frag = document.createDocumentFragment(); | ||
for (var i = 0; i < people.length; i++) { | |||
for (var i = 0; i < | frag.appendChild(makeAccordionPerson(people[i], i)); | ||
} | } | ||
container.innerHTML = ''; | container.innerHTML = ''; | ||
container.appendChild(frag); | container.appendChild(frag); | ||
| Line 335: | Line 455: | ||
function updateCount(n, total) { | function updateCount(n, total) { | ||
var el = $('ohr-count'); if (!el) return; | var el = $('ohr-count'); | ||
el.textContent = (n === total) ? (total + ' | 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 = | var filtered = PEOPLE.filter(function (p) { | ||
if (!q) return true; | if (!q) return true; | ||
var hay = norm( | 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; | return hay.indexOf(q) !== -1; | ||
}); | }); | ||
renderAccordion(filtered); | |||
updateCount(filtered.length, PEOPLE.length); | |||
updateCount(filtered.length, | |||
} | } | ||
| Line 377: | Line 497: | ||
} | } | ||
// ====== | // ====== ERRORS ====== | ||
function displayError(message) { | 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 387: | 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 | '<p class="ohr-muted ohr-mt-sm">' + esc(message) + '</p>'; | ||
} | } | ||
} | } | ||
// ====== | // ====== MAIN ====== | ||
function fetchAndRender() { | function fetchAndRender() { | ||
var root = $('ohr-directory'); | var root = $('ohr-directory'); | ||
| Line 400: | Line 521: | ||
buildToolbar(root); | buildToolbar(root); | ||
fetch( | fetch(getCsvUrl(), { credentials: 'include', cache: 'no-store' }) | ||
.then(function (res) { | .then(function (res) { | ||
if (!res.ok) throw new Error('HTTP ' + res.status); | if (!res.ok) throw new Error('HTTP ' + res.status); | ||
| Line 406: | Line 527: | ||
}) | }) | ||
.then(function (text) { | .then(function (text) { | ||
var rows = parseCSV(text); | |||
PEOPLE = groupByPersonKey(rows); | |||
if (loading) loading.classList.add('ohr-hidden'); | if (loading) loading.classList.add('ohr-hidden'); | ||
attachHandlers(); | attachHandlers(); | ||
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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
}
});
})();