MediaWiki:Gadget-OralHistoryRecords-Updated.js: Difference between revisions
Appearance
No edit summary |
No edit summary |
||
| 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 = { | ||
personKey: 'Person Key', | |||
first: 'First Name', | first: 'First Name', | ||
last: 'Last Name', | last: 'Last Name', | ||
| Line 17: | Line 14: | ||
dateTo: 'Date To', | dateTo: 'Date To', | ||
bio: 'Short Bio', | bio: 'Short Bio', | ||
wiki: 'Wiki Link' | wiki: 'Wiki Link', | ||
audio: 'Audio links', | |||
video: 'Video links', | |||
transcripts: 'Transcripts links' | |||
}; | }; | ||
// ====== STATE ====== | // ====== STATE ====== | ||
var | var PEOPLE = []; // grouped person records | ||
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 36: | Line 38: | ||
}); | }); | ||
} | } | ||
function norm(s) { | function norm(s) { | ||
return (s || '') | return (s || '') | ||
| Line 43: | Line 46: | ||
.toLowerCase(); | .toLowerCase(); | ||
} | } | ||
function debounce(fn, ms) { | function debounce(fn, ms) { | ||
var t; | var t; | ||
| Line 50: | Line 54: | ||
t = setTimeout(function () { fn.apply(self, a); }, ms); | t = setTimeout(function () { fn.apply(self, a); }, ms); | ||
}; | }; | ||
} | } | ||
| Line 65: | Line 61: | ||
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'); | 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(); | ||
} | } | ||
| Line 79: | Line 72: | ||
} | } | ||
function recordedLabel( | function recordedLabel(row) { | ||
var y1 = toYear( | var y1 = toYear(row[COL.dateFrom]); | ||
var y2 = toYear( | 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 fullName( | function fullName(row) { | ||
var first = ( | var first = (row[COL.first] || '').trim(); | ||
var last = ( | var last = (row[COL.last] || '').trim(); | ||
if (!first || !last) return ''; | |||
return (first + ' ' + last).trim(); | |||
} | } | ||
function lifespan( | function lifespan(row) { | ||
var b = ( | var b = (row[COL.birth] || '').trim(); | ||
var d = ( | var d = (row[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 120: | Line 97: | ||
} | } | ||
// | // Extract URLs from a cell that may include commentary, newlines, semicolons, etc. | ||
// | // We only call this for link columns. | ||
function | function extractUrls(value) { | ||
if (! | if (!value) return []; | ||
var s = String( | var s = String(value); | ||
// Match http/https URLs up to whitespace/quote/angle bracket | |||
var | var re = /https?:\/\/[^\s"'<>()]+/g; | ||
var | var m = s.match(re) || []; | ||
// De-dupe while preserving order | |||
var seen = Object.create(null); | |||
var out = []; | |||
return | for (var i = 0; i < m.length; i++) { | ||
var url = m[i].trim(); | |||
// Strip trailing punctuation that often sticks to URLs | |||
url = url.replace(/[);.,]+$/g, ''); | |||
if (!url) continue; | |||
if (!seen[url]) { | |||
seen[url] = true; | |||
out.push(url); | |||
} | |||
} | |||
return out; | |||
} | } | ||
| Line 163: | Line 145: | ||
// ====== ROBUST CSV PARSER ====== | // ====== ROBUST CSV PARSER ====== | ||
// Handles | // Handles quotes, commas in quotes, embedded newlines, escaped quotes ("") | ||
function parseCSV(text) { | function parseCSV(text) { | ||
if (!text) return []; | if (!text) return []; | ||
| Line 224: | Line 206: | ||
var headers = rows[0].map(function (h) { return (h || '').trim(); }); | var headers = rows[0].map(function (h) { return (h || '').trim(); }); | ||
var out = []; | |||
for (var r = 1; r < rows.length; r++) { | for (var r = 1; r < rows.length; r++) { | ||
var values = rows[r]; | var values = rows[r]; | ||
| Line 247: | Line 229: | ||
} | } | ||
// ====== | // ====== GROUPING ====== | ||
function | function firstNonEmpty(a, b) { | ||
var name = | 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 Person Key is missing, skip (since you said you made it—this enforces it) | |||
if (!key) continue; | |||
// Also require full name to avoid junk records | |||
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 { | |||
// Fill any missing person-level fields from other rows | |||
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]); | |||
} | |||
// Session links: extract URLs ONLY from the 3 link columns | |||
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]) | |||
}; | |||
// Keep even if only one type exists (common case) | |||
map[key].sessions.push(session); | |||
} | |||
// Convert to array and sort by last, first | |||
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); | |||
}); | |||
// Optional: sort sessions inside each person by Date From (or recorded year) | |||
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) || ''; | |||
// Descending (newest first) | |||
if (y1 && y2) return Number(y2) - Number(y1); | |||
if (y1 && !y2) return -1; | |||
if (!y1 && y2) return 1; | |||
return 0; | |||
}); | |||
}); | |||
return people; | |||
} | |||
// ====== RENDERING (OPTION 2) ====== | |||
function buildPersonHeaderHTML(person) { | |||
var name = ((person.first || '').trim() + ' ' + (person.last || '').trim()).trim(); | |||
var life = lifespan(person); | var life = lifespan(person); | ||
var | // Lifespan helper expects COL-based row; we’ll compute inline for person object: | ||
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 metaBits = []; | var metaBits = []; | ||
if ( | if (lifeStr) metaBits.push('<span>' + esc(lifeStr) + '</span>'); | ||
// Could add “# sessions” but keeping minimalist | |||
var meta = metaBits.join('<span class="ohr-dot">·</span>'); | var meta = metaBits.join('<span class="ohr-dot">·</span>'); | ||
return ( | return ( | ||
'<div class="ohr- | '<div class="ohr-titleblock">' + | ||
'<h3 class="ohr-name">' + esc(name) + '</h3>' + | |||
(meta ? '<div class="ohr-meta">' + meta + '</div>' : '') + | |||
'</div>' | '</div>' | ||
); | ); | ||
} | } | ||
function buildWikiLinkHTML( | function buildWikiLinkHTML(url) { | ||
url = (url || '').trim(); | |||
if (! | if (!url) return ''; | ||
return '<a class=" | return '<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(url) + '">Read Wiki Page</a>'; | ||
} | } | ||
function | function buildLinkListHTML(personName, kind, urls) { | ||
if (!urls || !urls.length) return ''; | |||
var out = []; | var out = []; | ||
for (var i = 0; i < urls.length; i++) { | |||
var label = 'Access ' + personName + '’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, idx) { | |||
var name = ((person.first || '').trim() + ' ' + (person.last || '').trim()).trim(); | |||
var title = session.recorded; | |||
// If multiple sessions have identical "Recorded YEAR", add tiny disambiguator: | |||
if (person.sessions.length > 1) title = title + ' (Interview ' + (idx + 1) + ')'; | |||
var links = ''; | |||
links += buildLinkListHTML(name, 'Video', session.video); | |||
links += buildLinkListHTML(name, 'Audio', session.audio); | |||
links += buildLinkListHTML(name, 'Transcript', session.transcripts); | |||
if (!links) { | |||
if ( | links = '<span class="ohr-muted">No links available for this session.</span>'; | ||
} else { | } else { | ||
links = '<div class="ohr-links">' + links + '</div>'; | |||
} | } | ||
return ( | |||
'<div class="ohr-session">' + | |||
'<div class="ohr-session__title">' + esc(title) + '</div>' + | |||
links + | |||
'</div>' | |||
); | |||
} | } | ||
function makeAccordionPerson(person, idx) { | |||
function | |||
var div = document.createElement('div'); | var div = document.createElement('div'); | ||
div.className = 'ohr-item'; | div.className = 'ohr-item'; | ||
| Line 344: | Line 387: | ||
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], 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" style="margin:0">' + esc(bio) + '</p></div>' : '') + | (bio ? '<div class="ohr-divider"><p class="ohr-bio" style="margin: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>'; | ||
| Line 391: | Line 427: | ||
} | } | ||
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" style="text-align:center">No matching records.</p>'; | container.innerHTML = '<p class="ohr-muted" style="text-align: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 425: | Line 445: | ||
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 = PEOPLE.filter(function (p) { | |||
var | if (!q) return true; | ||
var hay = norm( | var hay = norm( | ||
( | (p.first || '') + ' ' + | ||
( | (p.last || '') + ' ' + | ||
( | (p.bio || '') + ' ' + | ||
( | (p.birth || '') + ' ' + | ||
( | (p.death || '') + ' ' + | ||
( | (p.wiki || '') | ||
); | ); | ||
// Also include session recorded labels in search (years) | |||
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 472: | Line 488: | ||
} | } | ||
// ====== | // ====== ERRORS ====== | ||
function displayError(message) { | function displayError(message) { | ||
var loading = $('ohr-loading'); | var loading = $('ohr-loading'); | ||
| Line 486: | Line 502: | ||
} | } | ||
// ====== | // ====== MAIN ====== | ||
function fetchAndRender() { | function fetchAndRender() { | ||
var root = $('ohr-directory'); | var root = $('ohr-directory'); | ||
| Line 501: | Line 517: | ||
}) | }) | ||
.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(); | ||
applyFilters(); | applyFilters(); // initial render | ||
}) | }) | ||
.catch(function (err) { | .catch(function (err) { | ||
Revision as of 15:28, 21 February 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 links',
video: 'Video links',
transcripts: 'Transcripts links'
};
// ====== STATE ======
var PEOPLE = []; // grouped person records
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);
};
}
// 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();
var m = s.match(/\b(1[6-9]\d{2}|20\d{2}|21\d{2})\b/);
return m ? m[1] : '';
}
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 lifespan(row) {
var b = (row[COL.birth] || '').trim();
var d = (row[COL.death] || '').trim();
if (b && d) return b + '–' + d;
if (b && !d) return b + '–';
if (!b && d) return '–' + d;
return '';
}
// Extract URLs from a cell that may include commentary, newlines, semicolons, etc.
// We only call this for link columns.
function extractUrls(value) {
if (!value) return [];
var s = String(value);
// Match http/https URLs up to whitespace/quote/angle bracket
var re = /https?:\/\/[^\s"'<>()]+/g;
var m = s.match(re) || [];
// De-dupe while preserving order
var seen = Object.create(null);
var out = [];
for (var i = 0; i < m.length; i++) {
var url = m[i].trim();
// Strip trailing punctuation that often sticks to URLs
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);
}
// ====== ROBUST CSV PARSER ======
// Handles quotes, commas in quotes, 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;
}
// ====== 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 Person Key is missing, skip (since you said you made it—this enforces it)
if (!key) continue;
// Also require full name to avoid junk records
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 {
// Fill any missing person-level fields from other rows
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]);
}
// Session links: extract URLs ONLY from the 3 link columns
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])
};
// Keep even if only one type exists (common case)
map[key].sessions.push(session);
}
// Convert to array and sort by last, first
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);
});
// Optional: sort sessions inside each person by Date From (or recorded year)
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) || '';
// Descending (newest first)
if (y1 && y2) return Number(y2) - Number(y1);
if (y1 && !y2) return -1;
if (!y1 && y2) return 1;
return 0;
});
});
return people;
}
// ====== RENDERING (OPTION 2) ======
function buildPersonHeaderHTML(person) {
var name = ((person.first || '').trim() + ' ' + (person.last || '').trim()).trim();
var life = lifespan(person);
// Lifespan helper expects COL-based row; we’ll compute inline for person object:
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 metaBits = [];
if (lifeStr) metaBits.push('<span>' + esc(lifeStr) + '</span>');
// Could add “# sessions” but keeping minimalist
var meta = metaBits.join('<span class="ohr-dot">·</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(personName, kind, urls) {
if (!urls || !urls.length) return '';
var out = [];
for (var i = 0; i < urls.length; i++) {
var label = 'Access ' + personName + '’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, idx) {
var name = ((person.first || '').trim() + ' ' + (person.last || '').trim()).trim();
var title = session.recorded;
// If multiple sessions have identical "Recorded YEAR", add tiny disambiguator:
if (person.sessions.length > 1) title = title + ' (Interview ' + (idx + 1) + ')';
var links = '';
links += buildLinkListHTML(name, 'Video', session.video);
links += buildLinkListHTML(name, 'Audio', session.audio);
links += buildLinkListHTML(name, 'Transcript', session.transcripts);
if (!links) {
links = '<span class="ohr-muted">No links available for this session.</span>';
} else {
links = '<div class="ohr-links">' + links + '</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], 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" style="margin: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');
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 renderAccordion(people) {
var container = $('ohr-list');
if (!container) return;
if (!people.length) {
container.innerHTML = '<p class="ohr-muted" style="text-align: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 || '')
);
// Also include session recorded labels in search (years)
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" 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) {
var rows = parseCSV(text);
PEOPLE = groupByPersonKey(rows);
if (loading) loading.classList.add('ohr-hidden');
attachHandlers();
applyFilters(); // initial render
})
.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);
}
});
})();