MediaWiki:Gadget-OralHistoryRecords-Updated.js: Difference between revisions
Appearance
No edit summary |
No edit summary |
||
| Line 7: | Line 7: | ||
// These must match your CSV header names exactly. | // These must match your CSV header names exactly. | ||
var COL = { | var COL = { | ||
transcripts: 'Transcript Link', | |||
audio: 'Audio | audio: 'Audio Link', | ||
video: 'Video | video: 'Video Link', | ||
first: 'First Name', | first: 'First Name', | ||
last: 'Last Name', | last: 'Last Name', | ||
| Line 16: | Line 16: | ||
dateFrom: 'Date From', | dateFrom: 'Date From', | ||
dateTo: 'Date To', | dateTo: 'Date To', | ||
bio: 'Short Bio', | |||
wiki: 'Wiki Link' | wiki: 'Wiki Link' | ||
}; | }; | ||
| Line 88: | Line 88: | ||
} | } | ||
function | // IMPORTANT: Only split on commas for the 3 link columns (Audio/Video/Transcripts). | ||
// Other columns may contain commas as normal punctuation. | |||
function splitLinkCell(value) { | |||
if (!value) return []; | if (!value) return []; | ||
return String(value) | return String(value) | ||
| Line 96: | Line 98: | ||
} | } | ||
// | // Must have both first & last name | ||
function hasValidName(person) { | function hasValidName(person) { | ||
var first = (person[COL.first] || '').trim(); | var first = (person[COL.first] || '').trim(); | ||
| Line 106: | Line 108: | ||
var first = (person[COL.first] || '').trim(); | var first = (person[COL.first] || '').trim(); | ||
var last = (person[COL.last] || '').trim(); | var last = (person[COL.last] || '').trim(); | ||
return (first && last) ? (first + ' ' + last) : ''; | |||
} | } | ||
| Line 119: | Line 120: | ||
} | } | ||
// | // Directory + Chips: truncate to 300 chars. | ||
function | // Accordion: full bio. | ||
function truncate(text, maxChars) { | |||
if (!text) return ''; | if (!text) return ''; | ||
var s = String(text).trim(); | var s = String(text).trim(); | ||
| Line 147: | Line 149: | ||
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 276: | Line 278: | ||
var name = fullName(person); | var name = fullName(person); | ||
var video = | var video = splitLinkCell(person[COL.video]); | ||
var audio = | var audio = splitLinkCell(person[COL.audio]); | ||
var | var transcripts = splitLinkCell(person[COL.transcripts]); | ||
var out = []; | var out = []; | ||
| Line 315: | Line 317: | ||
if (video.length) add('video', video); | if (video.length) add('video', video); | ||
if (audio.length) add('audio', audio); | if (audio.length) add('audio', audio); | ||
if ( | if (transcripts.length) add('transcript', transcripts); | ||
if (!out.length) return ''; | if (!out.length) return ''; | ||
| Line 325: | Line 327: | ||
// ====== Views ====== | // ====== Views ====== | ||
function makeDirectoryItem(person) { | function makeDirectoryItem(person) { | ||
var | var bio = truncate((person[COL.bio] || '').trim(), 300); // truncated | ||
var div = document.createElement('div'); | var div = document.createElement('div'); | ||
div.className = 'ohr-item'; | div.className = 'ohr-item'; | ||
div.innerHTML = | div.innerHTML = | ||
buildHeaderHTML(person) + | buildHeaderHTML(person) + | ||
( | (bio ? '<p class="ohr-bio">' + esc(bio) + '</p>' : '') + | ||
buildLinksHTML(person, 'links'); | buildLinksHTML(person, 'links'); | ||
return div; | return div; | ||
| Line 336: | Line 338: | ||
function makeAccordionItem(person, idx) { | function makeAccordionItem(person, idx) { | ||
var | var bio = (person[COL.bio] || '').trim(); // full bio (Option 2) | ||
var div = document.createElement('div'); | var div = document.createElement('div'); | ||
div.className = 'ohr-item'; | div.className = 'ohr-item'; | ||
| Line 365: | Line 367: | ||
'</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>' : '') + | ||
'<div class="ohr-divider">' + buildLinksHTML(person, 'links') + '</div>' + | '<div class="ohr-divider">' + buildLinksHTML(person, 'links') + '</div>' + | ||
'</div>'; | '</div>'; | ||
| Line 390: | Line 392: | ||
function makeChipsItem(person) { | function makeChipsItem(person) { | ||
var | var bio = truncate((person[COL.bio] || '').trim(), 300); // truncated | ||
var div = document.createElement('div'); | var div = document.createElement('div'); | ||
div.className = 'ohr-item'; | div.className = 'ohr-item'; | ||
div.innerHTML = | div.innerHTML = | ||
buildHeaderHTML(person) + | buildHeaderHTML(person) + | ||
( | (bio ? '<p class="ohr-bio">' + esc(bio) + '</p>' : '') + | ||
buildLinksHTML(person, 'chips'); | buildLinksHTML(person, 'chips'); | ||
return div; | return div; | ||
| Line 423: | Line 425: | ||
function updateCount(n, total) { | function updateCount(n, total) { | ||
var el = $('ohr-count'); | var el = $('ohr-count'); if (!el) return; | ||
el.textContent = (n === total) ? (total + ' records') : (n + ' of ' + total + ' records'); | el.textContent = (n === total) ? (total + ' records') : (n + ' of ' + total + ' records'); | ||
} | } | ||
| Line 432: | Line 433: | ||
var q = norm(FILTER.q); | var q = norm(FILTER.q); | ||
// | // Remove invalid-name records entirely | ||
var valid = ALL.filter(function (row) { | var valid = ALL.filter(function (row) { | ||
return hasValidName(row); | return hasValidName(row); | ||
| Line 442: | Line 443: | ||
(row[COL.first] || '') + ' ' + | (row[COL.first] || '') + ' ' + | ||
(row[COL.last] || '') + ' ' + | (row[COL.last] || '') + ' ' + | ||
(row[COL. | (row[COL.bio] || '') + ' ' + | ||
(row[COL.birth] || '') + ' ' + | (row[COL.birth] || '') + ' ' + | ||
(row[COL.death] || '') + ' ' + | (row[COL.death] || '') + ' ' + | ||
Revision as of 14:45, 21 February 2026
/* ===== OralHistoryRecords-Updated.js ===== */
/* global mw */
(function () {
'use strict';
// ====== COLUMN CONFIG ======
// These must match your CSV header names exactly.
var COL = {
transcripts: 'Transcript Link',
audio: 'Audio Link',
video: 'Video Link',
first: 'First Name',
last: 'Last Name',
birth: 'Birth Year',
death: 'Death Year',
dateFrom: 'Date From',
dateTo: 'Date To',
bio: 'Short Bio',
wiki: 'Wiki Link'
};
// ====== STATE ======
var ALL = [];
var FILTER = { q: '' };
// ====== UTILS ======
function onReady(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
function $(id) { return document.getElementById(id); }
function esc(s) {
s = (s == null ? '' : String(s));
return s.replace(/[&<>"']/g, function (c) {
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]);
});
}
function norm(s) {
return (s || '')
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function debounce(fn, ms) {
var t;
return function () {
var a = arguments, self = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(self, a); }, ms);
};
}
// Pattern 1: page controls view via <div id="ohr-directory" data-view="...">
function getViewMode() {
var root = $('ohr-directory');
var mode = (root && root.getAttribute('data-view')) ? root.getAttribute('data-view').trim().toLowerCase() : '';
if (mode === 'accordion' || mode === 'chips' || mode === 'directory') return mode;
return 'directory';
}
// Optional override:
// <div id="ohr-directory" data-csv="/index.php/Special:FilePath/OHData.csv">
function getCsvUrl() {
var root = $('ohr-directory');
var override = root ? (root.getAttribute('data-csv') || '').trim() : '';
// wgScript is usually "/index.php" on your site (matches your working URL style)
var base = override || ((mw.config.get('wgScript') || '/index.php') + '/Special:FilePath/OHData.csv');
return base + (base.indexOf('?') === -1 ? '?' : '&') + 'cb=' + Date.now();
}
function toYear(value) {
if (!value) return '';
var s = String(value).trim();
var m = s.match(/\b(1[6-9]\d{2}|20\d{2}|21\d{2})\b/);
return m ? m[1] : '';
}
function recordedLabel(person) {
var y1 = toYear(person[COL.dateFrom]);
var y2 = toYear(person[COL.dateTo]);
if (y1 && y2) return (y1 === y2) ? ('Recorded ' + y1) : ('Recorded ' + y1 + '–' + y2);
if (y1) return 'Recorded ' + y1;
if (y2) return 'Recorded ' + y2;
return '';
}
// IMPORTANT: Only split on commas for the 3 link columns (Audio/Video/Transcripts).
// Other columns may contain commas as normal punctuation.
function splitLinkCell(value) {
if (!value) return [];
return String(value)
.split(',')
.map(function (x) { return x.trim(); })
.filter(Boolean);
}
// Must have both first & last name
function hasValidName(person) {
var first = (person[COL.first] || '').trim();
var last = (person[COL.last] || '').trim();
return !!(first && last);
}
function fullName(person) {
var first = (person[COL.first] || '').trim();
var last = (person[COL.last] || '').trim();
return (first && last) ? (first + ' ' + last) : '';
}
function lifespan(person) {
var b = (person[COL.birth] || '').trim();
var d = (person[COL.death] || '').trim();
if (b && d) return b + '–' + d;
if (b && !d) return b + '–';
if (!b && d) return '–' + d;
return '';
}
// Directory + Chips: truncate to 300 chars.
// Accordion: full bio.
function truncate(text, maxChars) {
if (!text) return '';
var s = String(text).trim();
if (s.length <= maxChars) return s;
return s.slice(0, maxChars).trim() + '…';
}
function buildAccessLabel(name, kind, idx, total) {
var part = (total > 1) ? (' (Part ' + (idx + 1) + '/' + total + ')') : '';
var kindTitle = kind.charAt(0).toUpperCase() + kind.slice(1);
return 'Access ' + name + '’s ' + kindTitle + part;
}
function buildChipText(kind, idx, total) {
var kindTitle = kind.charAt(0).toUpperCase() + kind.slice(1);
if (total > 1) return kindTitle + ' ' + (idx + 1) + '/' + total;
return kindTitle;
}
// ====== UI ======
function buildToolbar(root) {
var toolbar = document.createElement('div');
toolbar.className = 'ohr-toolbar';
var search = document.createElement('input');
search.id = 'ohr-search';
search.type = 'search';
search.placeholder = 'Search names, bios…';
search.setAttribute('aria-label', 'Search oral history records');
var count = document.createElement('div');
count.id = 'ohr-count';
count.className = 'ohr-count';
count.setAttribute('aria-live', 'polite');
toolbar.appendChild(search);
toolbar.appendChild(count);
root.insertBefore(toolbar, root.firstChild);
}
// ====== ROBUST CSV PARSER ======
// Handles: quotes, embedded commas, embedded newlines, escaped quotes ("")
function parseCSV(text) {
if (!text) return [];
text = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
var rows = [];
var row = [];
var field = '';
var i = 0;
var inQuotes = false;
while (i < text.length) {
var c = text[i];
if (inQuotes) {
if (c === '"') {
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i += 1;
continue;
}
field += c;
i += 1;
continue;
}
if (c === '"') {
inQuotes = true;
i += 1;
continue;
}
if (c === ',') {
row.push(field);
field = '';
i += 1;
continue;
}
if (c === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i += 1;
continue;
}
field += c;
i += 1;
}
row.push(field);
if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);
if (!rows.length) return [];
var headers = rows[0].map(function (h) { return (h || '').trim(); });
var out = [];
for (var r = 1; r < rows.length; r++) {
var values = rows[r];
// Skip totally empty rows
var nonEmpty = false;
for (var k = 0; k < values.length; k++) {
if (String(values[k] || '').trim() !== '') { nonEmpty = true; break; }
}
if (!nonEmpty) continue;
var obj = {};
for (var c2 = 0; c2 < headers.length; c2++) {
var key = headers[c2];
if (!key) continue;
obj[key] = (c2 < values.length) ? values[c2] : '';
}
out.push(obj);
}
return out;
}
// ====== Rendering building blocks ======
function buildHeaderHTML(person) {
var name = fullName(person);
var life = lifespan(person);
var rec = recordedLabel(person);
var metaBits = [];
if (life) metaBits.push('<span>' + esc(life) + '</span>');
if (rec) metaBits.push('<span>' + esc(rec) + '</span>');
var meta = metaBits.join('<span class="ohr-dot">·</span>');
return (
'<div class="ohr-head">' +
'<div class="ohr-titleblock">' +
'<h3 class="ohr-name">' + esc(name) + '</h3>' +
(meta ? '<div class="ohr-meta">' + meta + '</div>' : '') +
'</div>' +
'</div>'
);
}
function buildWikiLinkHTML(person, cls) {
var wiki = (person[COL.wiki] || '').trim();
if (!wiki) return '';
return '<a class="' + cls + '" target="_blank" rel="noopener" href="' + esc(wiki) + '">Read Wiki Page</a>';
}
function buildLinksHTML(person, style) {
var name = fullName(person);
var video = splitLinkCell(person[COL.video]);
var audio = splitLinkCell(person[COL.audio]);
var transcripts = splitLinkCell(person[COL.transcripts]);
var out = [];
function add(kind, urls) {
for (var i = 0; i < urls.length; i++) {
var label = buildAccessLabel(name, kind, i, urls.length);
if (style === 'chips') {
var chipText = buildChipText(kind, i, urls.length);
out.push(
'<a class="ohr-chip" target="_blank" rel="noopener" href="' + esc(urls[i]) + '"' +
' title="' + esc(label) + '"' +
' aria-label="' + esc(label) + '"' +
'>' + esc(chipText) + '</a>'
);
} else {
out.push(
'<a class="ohr-link" target="_blank" rel="noopener" href="' + esc(urls[i]) + '">' +
esc(label) +
'</a>'
);
}
}
}
// Wiki link first (if present)
if (style === 'chips') {
var wikiChip = buildWikiLinkHTML(person, 'ohr-chip');
if (wikiChip) out.push(wikiChip);
} else {
var wikiLink = buildWikiLinkHTML(person, 'ohr-link');
if (wikiLink) out.push(wikiLink);
}
if (video.length) add('video', video);
if (audio.length) add('audio', audio);
if (transcripts.length) add('transcript', transcripts);
if (!out.length) return '';
if (style === 'chips') return '<div class="ohr-chips">' + out.join('') + '</div>';
return '<div class="ohr-links">' + out.join('') + '</div>';
}
// ====== Views ======
function makeDirectoryItem(person) {
var bio = truncate((person[COL.bio] || '').trim(), 300); // truncated
var div = document.createElement('div');
div.className = 'ohr-item';
div.innerHTML =
buildHeaderHTML(person) +
(bio ? '<p class="ohr-bio">' + esc(bio) + '</p>' : '') +
buildLinksHTML(person, 'links');
return div;
}
function makeAccordionItem(person, idx) {
var bio = (person[COL.bio] || '').trim(); // full bio (Option 2)
var div = document.createElement('div');
div.className = 'ohr-item';
var panelId = 'ohr-panel-' + idx;
var btnId = 'ohr-btn-' + idx;
div.innerHTML =
'<div class="ohr-head">' +
'<div class="ohr-titleblock">' +
(function () {
var name = fullName(person);
var life = lifespan(person);
var rec = recordedLabel(person);
var metaBits = [];
if (life) metaBits.push('<span>' + esc(life) + '</span>');
if (rec) metaBits.push('<span>' + esc(rec) + '</span>');
var meta = metaBits.join('<span class="ohr-dot">·</span>');
return (
'<h3 class="ohr-name">' + esc(name) + '</h3>' +
(meta ? '<div class="ohr-meta">' + meta + '</div>' : '')
);
})() +
'</div>' +
'<button class="ohr-acc-btn" id="' + btnId + '" type="button" aria-expanded="false" aria-controls="' + panelId + '">Details</button>' +
'</div>' +
'<div class="ohr-acc-panel ohr-hidden" id="' + panelId + '">' +
(bio ? '<div class="ohr-divider"><p class="ohr-bio" style="margin:0">' + esc(bio) + '</p></div>' : '') +
'<div class="ohr-divider">' + buildLinksHTML(person, 'links') + '</div>' +
'</div>';
var btn = div.querySelector('#' + btnId);
var panel = div.querySelector('#' + panelId);
if (btn && panel) {
btn.addEventListener('click', function () {
var open = !panel.classList.contains('ohr-hidden');
if (open) {
panel.classList.add('ohr-hidden');
btn.setAttribute('aria-expanded', 'false');
btn.textContent = 'Details';
} else {
panel.classList.remove('ohr-hidden');
btn.setAttribute('aria-expanded', 'true');
btn.textContent = 'Hide';
}
});
}
return div;
}
function makeChipsItem(person) {
var bio = truncate((person[COL.bio] || '').trim(), 300); // truncated
var div = document.createElement('div');
div.className = 'ohr-item';
div.innerHTML =
buildHeaderHTML(person) +
(bio ? '<p class="ohr-bio">' + esc(bio) + '</p>' : '') +
buildLinksHTML(person, 'chips');
return div;
}
function renderList(rows) {
var container = $('ohr-list');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="ohr-muted" style="text-align:center">No matching records.</p>';
return;
}
var mode = getViewMode();
var frag = document.createDocumentFragment();
for (var i = 0; i < rows.length; i++) {
if (mode === 'accordion') frag.appendChild(makeAccordionItem(rows[i], i));
else if (mode === 'chips') frag.appendChild(makeChipsItem(rows[i]));
else frag.appendChild(makeDirectoryItem(rows[i]));
}
container.innerHTML = '';
container.appendChild(frag);
}
function updateCount(n, total) {
var el = $('ohr-count'); if (!el) return;
el.textContent = (n === total) ? (total + ' records') : (n + ' of ' + total + ' records');
}
// ====== Filtering ======
function applyFilters() {
var q = norm(FILTER.q);
// Remove invalid-name records entirely
var valid = ALL.filter(function (row) {
return hasValidName(row);
});
var filtered = valid.filter(function (row) {
if (!q) return true;
var hay = norm(
(row[COL.first] || '') + ' ' +
(row[COL.last] || '') + ' ' +
(row[COL.bio] || '') + ' ' +
(row[COL.birth] || '') + ' ' +
(row[COL.death] || '') + ' ' +
(row[COL.dateFrom] || '') + ' ' +
(row[COL.dateTo] || '')
);
return hay.indexOf(q) !== -1;
});
filtered.sort(function (a, b) {
var A = norm((a[COL.last] || '') + ' ' + (a[COL.first] || ''));
var B = norm((b[COL.last] || '') + ' ' + (b[COL.first] || ''));
return A.localeCompare(B);
});
renderList(filtered);
updateCount(filtered.length, valid.length);
}
function attachHandlers() {
var qInput = $('ohr-search');
if (qInput) {
qInput.addEventListener('input', debounce(function () {
FILTER.q = qInput.value.trim();
applyFilters();
}, 200));
}
}
// ====== Errors ======
function displayError(message) {
var loading = $('ohr-loading');
var error = $('ohr-error');
if (loading) loading.classList.add('ohr-hidden');
if (error) {
error.classList.remove('ohr-hidden');
error.classList.add('ohr-error');
error.innerHTML =
'<p><strong>Failed to load records.</strong></p>' +
'<p class="ohr-muted" style="margin-top:0.5rem">' + esc(message) + '</p>';
}
}
// ====== Main ======
function fetchAndRender() {
var root = $('ohr-directory');
var loading = $('ohr-loading');
var container = $('ohr-list');
if (!root || !container) return;
buildToolbar(root);
fetch(getCsvUrl(), { credentials: 'include', cache: 'no-store' })
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.text();
})
.then(function (text) {
ALL = parseCSV(text);
if (loading) loading.classList.add('ohr-hidden');
attachHandlers();
applyFilters();
})
.catch(function (err) {
displayError('There was an error accessing the records. Details: ' + err.message);
console.error(err);
});
}
onReady(function () {
if ($('ohr-directory')) {
mw.loader.using([]).then(fetchAndRender);
}
});
})();