MediaWiki:Gadget-OralHistoryRecords-Updated.js: Difference between revisions
Appearance
No edit summary |
No edit summary |
||
| Line 21: | Line 21: | ||
// ====== STATE ====== | // ====== STATE ====== | ||
var PEOPLE = []; | var PEOPLE = []; | ||
var FILTER = { q: '' }; | var FILTER = { q: '' }; | ||
| Line 54: | Line 54: | ||
t = setTimeout(function () { fn.apply(self, a); }, ms); | t = setTimeout(function () { fn.apply(self, a); }, ms); | ||
}; | }; | ||
} | |||
function personName(person) { | |||
return ((person.first || '').trim() + ' ' + (person.last || '').trim()).trim(); | |||
} | } | ||
| Line 88: | Line 92: | ||
} | } | ||
function extractUrls(value) { | function extractUrls(value) { | ||
if (!value) return []; | if (!value) return []; | ||
var s = String(value); | var s = String(value); | ||
var re = /https?:\/\/[^\s"'<>()]+/g; | var re = /https?:\/\/[^\s"'<>()]+/g; | ||
var m = s.match(re) || []; | var m = s.match(re) || []; | ||
var seen = Object.create(null); | var seen = Object.create(null); | ||
var out = []; | var out = []; | ||
for (var i = 0; i < m.length; i++) { | for (var i = 0; i < m.length; i++) { | ||
var url = m[i].trim(); | var url = m[i].trim(); | ||
url = url.replace(/[);.,]+$/g, ''); | url = url.replace(/[);.,]+$/g, ''); | ||
if (!url) continue; | if (!url) continue; | ||
| Line 144: | Line 134: | ||
} | } | ||
// ====== | // ====== CSV PARSER ====== | ||
function parseCSV(text) { | function parseCSV(text) { | ||
if (!text) return []; | if (!text) return []; | ||
| Line 211: | Line 200: | ||
var values = rows[r]; | var values = rows[r]; | ||
var nonEmpty = false; | var nonEmpty = false; | ||
for (var k = 0; k < values.length; k++) { | for (var k = 0; k < values.length; k++) { | ||
| Line 243: | Line 231: | ||
var key = (row[COL.personKey] || '').trim(); | var key = (row[COL.personKey] || '').trim(); | ||
if (!key) continue; | if (!key) continue; | ||
if (!fullName(row)) continue; | if (!fullName(row)) continue; | ||
| Line 261: | Line 246: | ||
}; | }; | ||
} else { | } else { | ||
map[key].first = firstNonEmpty(map[key].first, row[COL.first]); | map[key].first = firstNonEmpty(map[key].first, row[COL.first]); | ||
map[key].last = firstNonEmpty(map[key].last, row[COL.last]); | map[key].last = firstNonEmpty(map[key].last, row[COL.last]); | ||
| Line 270: | Line 254: | ||
} | } | ||
var session = { | var session = { | ||
recorded: recordedLabel(row), | recorded: recordedLabel(row), | ||
| Line 283: | Line 266: | ||
} | } | ||
var people = Object.keys(map).map(function (k) { return map[k]; }); | var people = Object.keys(map).map(function (k) { return map[k]; }); | ||
| Line 292: | Line 274: | ||
}); | }); | ||
people.forEach(function (p) { | people.forEach(function (p) { | ||
p.sessions.sort(function (s1, s2) { | p.sessions.sort(function (s1, s2) { | ||
| Line 307: | Line 288: | ||
} | } | ||
// ====== RENDERING | // ====== RENDERING ====== | ||
function buildPersonHeaderHTML(person) { | function buildPersonHeaderHTML(person) { | ||
var name = | var name = personName(person); | ||
var b = (person.birth || '').trim(); | var b = (person.birth || '').trim(); | ||
var d = (person.death || '').trim(); | var d = (person.death || '').trim(); | ||
var lifeStr = ''; | var lifeStr = ''; | ||
if (b && d) lifeStr = b + '–' + d; | if (b && d) lifeStr = b + '–' + d; | ||
else if (b && !d) lifeStr = b + '–'; | else if (b && !d) lifeStr = b + '–'; | ||
else if (!b && d) lifeStr = '–' + d; | else if (!b && d) lifeStr = '–' + d; | ||
var | var meta = lifeStr ? '<span>' + esc(lifeStr) + '</span>' : ''; | ||
return ( | return ( | ||
| Line 335: | Line 315: | ||
} | } | ||
function buildLinkListHTML( | function buildLinkListHTML(name, kind, urls) { | ||
if (!urls || !urls.length) return ''; | if (!urls || !urls.length) return ''; | ||
var out = []; | var out = []; | ||
for (var i = 0; i < urls.length; i++) { | for (var i = 0; i < urls.length; i++) { | ||
var label = 'Access ' + | var label = 'Access ' + name + '’s ' + kind + | ||
(urls.length > 1 ? (' (Part ' + (i + 1) + '/' + urls.length + ')') : ''); | (urls.length > 1 ? (' (Part ' + (i + 1) + '/' + urls.length + ')') : ''); | ||
out.push( | out.push( | ||
| Line 350: | Line 330: | ||
} | } | ||
function buildSessionHTML(person, session | 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 session.</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>'; | |||
} | |||
'</div>'; | |||
return ( | |||
'<div class="ohr-session">' + | |||
'<div class="ohr-session__title">' + esc(title) + '</div>' + | |||
links + | |||
'</div>' | |||
); | |||
} | } | ||
function makeAccordionPerson(person, idx) { | function makeAccordionPerson(person, idx) { | ||
| Line 408: | Line 385: | ||
var sessionsHTML = ''; | var sessionsHTML = ''; | ||
for (var i = 0; i < person.sessions.length; i++) { | for (var i = 0; i < person.sessions.length; i++) { | ||
sessionsHTML += buildSessionHTML(person, person.sessions[i] | sessionsHTML += buildSessionHTML(person, person.sessions[i]); | ||
} | } | ||
| Line 417: | Line 394: | ||
'</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 | (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>' : '') + | (wikiLink ? '<div class="ohr-divider"><div class="ohr-links">' + wikiLink + '</div></div>' : '') + | ||
'<div class="ohr-divider">' + sessionsHTML + '</div>' + | '<div class="ohr-divider">' + sessionsHTML + '</div>' + | ||
| Line 424: | Line 401: | ||
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 () { | |||
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; | return div; | ||
| Line 450: | Line 428: | ||
if (!people.length) { | if (!people.length) { | ||
container.innerHTML = '<p class="ohr-muted | container.innerHTML = '<p class="ohr-muted ohr-center">No matching records.</p>'; | ||
return; | return; | ||
} | } | ||
| Line 484: | Line 462: | ||
); | ); | ||
for (var i = 0; i < p.sessions.length; i++) { | for (var i = 0; i < p.sessions.length; i++) { | ||
hay += ' ' + norm(p.sessions[i].recorded || ''); | hay += ' ' + norm(p.sessions[i].recorded || ''); | ||
| Line 511: | Line 488: | ||
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 516: | Line 494: | ||
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>'; | ||
} | } | ||
} | } | ||
| Line 540: | Line 518: | ||
if (loading) loading.classList.add('ohr-hidden'); | if (loading) loading.classList.add('ohr-hidden'); | ||
attachHandlers(); | attachHandlers(); | ||
applyFilters(); | applyFilters(); | ||
}) | }) | ||
.catch(function (err) { | .catch(function (err) { | ||
Revision as of 15:42, 28 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();
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 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 session.</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);
}
});
})();