MediaWiki:Common.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 1,665: | Line 1,665: | ||
teekaAuthor: td.doc.author || '', | teekaAuthor: td.doc.author || '', | ||
teekaPage: td.doc.pageTitle || td._title, | teekaPage: td.doc.pageTitle || td._title, | ||
teekaGranthaId: td.doc.granthaId || '', | |||
teekaSlug: slug, | teekaSlug: slug, | ||
// join('\n') - each entry of `lines` is a printed line, | // join('\n') - each entry of `lines` is a printed line, | ||
| Line 1,705: | Line 1,706: | ||
.attr('data-cite-title', entry.teekaTitle || '') | .attr('data-cite-title', entry.teekaTitle || '') | ||
.attr('data-cite-page', entry.teekaPage || '') | .attr('data-cite-page', entry.teekaPage || '') | ||
.attr('data-cite-grantha-id', entry.teekaGranthaId || '') | |||
.attr('id', entry.blockId) | .attr('id', entry.blockId) | ||
.css('background', color); | .css('background', color); | ||
if (window.grLibrary) { | |||
(function (el, gid, page) { | |||
// The id may come from the teeka's own JSON (if that | |||
// document has been rebuilt) or from the map page. | |||
// Either way the commentary cites ITS work, not the | |||
// moola it is displayed inside. | |||
var got = gid ? Promise.resolve(gid) | |||
: window.grLibrary.idForPage(page, null); | |||
got.then(function (id) { | |||
if (!id) return null; | |||
el.setAttribute('data-cite-grantha-id', id); | |||
return window.grLibrary.lookup(id); | |||
}).then(function (g) { | |||
if (!g) return; | |||
if (g.name) el.setAttribute('data-cite-title', g.name); | |||
if (g.nameIAST) el.setAttribute('data-cite-title-iast', g.nameIAST); | |||
if (g.author) el.setAttribute('data-cite-author', g.author); | |||
if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST); | |||
var h = el.querySelector('.teeka-title'); | |||
if (h && g.name) h.textContent = g.name; | |||
}); | |||
}($entry[0], entry.teekaGranthaId, entry.teekaPage)); | |||
} | |||
$('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry); | $('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry); | ||
$('<div>').addClass('teeka-body').html(entry.text).appendTo($entry); | $('<div>').addClass('teeka-body').html(entry.text).appendTo($entry); | ||
| Line 2,999: | Line 3,024: | ||
else decorate(); | else decorate(); | ||
if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { setTimeout(decorate, 150); }); | if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { setTimeout(decorate, 150); }); | ||
}()); | |||
/* ── HASH REVEAL ─────────────────────────────────────────────────────── | |||
A copied id or a bookmark citation is only useful if its #link actually | |||
lands on the block. The browser's own fragment scroll fires once, at | |||
parse time, and then our fonts swap in and the teeka blocks inject - | |||
the page grows under the reader and the target drifts off screen. It | |||
looks exactly like the link didn't work. | |||
This retries until the element exists and re-settles after late content | |||
arrives. It used to live in copyId.js (Editor extension), which is no | |||
longer loaded on this wiki, so it moves here where it always runs. If | |||
copyId IS loaded it does the same job; both are idempotent, and the | |||
guard below keeps them from fighting over the same jump. */ | |||
(function () { | |||
'use strict'; | |||
if (window.grHashReveal) return; | |||
var FLASH_MS = 2600; | |||
var _last = null; | |||
function findTarget(id) { | |||
if (!id) return null; | |||
var esc = (window.CSS && CSS.escape) ? CSS.escape(id) : id.replace(/["\\]/g, '\\$&'); | |||
return document.getElementById(id) || | |||
document.querySelector('[data-block-id="' + esc + '"]') || | |||
document.querySelector('[data-copyid="' + esc + '"]') || | |||
null; | |||
} | |||
function currentHash() { | |||
var raw = (location.hash || '').slice(1); | |||
if (!raw) return ''; | |||
try { return decodeURIComponent(raw); } catch (e) { return raw; } | |||
} | |||
function land(el, flash) { | |||
el.scrollIntoView({ block: 'center', behavior: 'smooth' }); | |||
if (!flash) return; | |||
el.classList.add('gid-target'); | |||
setTimeout(function () { el.classList.remove('gid-target'); }, FLASH_MS); | |||
} | |||
function reveal(attemptsLeft, flash) { | |||
var id = currentHash(); | |||
if (!id) return; | |||
var el = findTarget(id); | |||
if (!el) { | |||
if (attemptsLeft > 0) setTimeout(function () { reveal(attemptsLeft - 1, flash); }, 400); | |||
return; | |||
} | |||
_last = id; | |||
land(el, flash); | |||
// Re-settle once the late arrivals (fonts, injected teekas) have | |||
// shifted the layout. Cheap, and it is what makes the link feel | |||
// like it worked rather than like it missed. | |||
setTimeout(function () { | |||
if (currentHash() !== id) return; | |||
var again = findTarget(id); | |||
if (again) again.scrollIntoView({ block: 'center', behavior: 'auto' }); | |||
}, 1200); | |||
} | |||
function onLateContent() { | |||
var id = currentHash(); | |||
if (!id || id !== _last) return; | |||
var el = findTarget(id); | |||
if (el) el.scrollIntoView({ block: 'center', behavior: 'auto' }); | |||
} | |||
window.grHashReveal = function (attempts) { reveal(attempts || 12, true); }; | |||
function boot() { | |||
reveal(12, true); | |||
window.addEventListener('hashchange', function () { reveal(6, true); }); | |||
window.addEventListener('gr-new-content', onLateContent); | |||
if (document.fonts && document.fonts.ready && document.fonts.ready.then) { | |||
document.fonts.ready.then(onLateContent); | |||
} | |||
} | |||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | |||
else boot(); | |||
}()); | |||
/* ── GRANTHA LIBRARY ─────────────────────────────────────────────────── | |||
The colophon in the source docx says "श्रीमद्वादिराजपूज्यचरणविरचिता | |||
प्रकाशिका" - an author phrase welded to a generic label. That is not a | |||
citable title, and it is what the client saw as "Vadirajathirtha | |||
virachitha". The Grantha library holds the real title and author, so | |||
the page carries only the library's stable id (data-grantha-id) and we | |||
resolve the rest here, at read time. Correct a name in the library and | |||
every citation on the site is correct on the next load - nothing to | |||
rebuild, nothing to re-upload. | |||
Reads only, and the library's GET endpoints are public: no API key is | |||
sent from the browser, and none may ever be put in this file, which | |||
any reader can fetch. */ | |||
(function () { | |||
'use strict'; | |||
if (window.grLibrary) return; | |||
// Same-origin by default so no CORS is needed. Override before this | |||
// file loads if the library app lives on another host. | |||
var API = (window.GR_LIBRARY_API || '/api/granthas').replace(/\/+$/, ''); | |||
var TTL = 6 * 60 * 60 * 1000; // names change rarely | |||
var mem = {}; | |||
function cacheGet(id) { | |||
if (mem[id]) return mem[id]; | |||
try { | |||
var raw = sessionStorage.getItem('gr-lib:' + id); | |||
if (!raw) return null; | |||
var o = JSON.parse(raw); | |||
if (!o || (Date.now() - o.t) > TTL) return null; | |||
mem[id] = o.v; | |||
return o.v; | |||
} catch (e) { return null; } | |||
} | |||
function cacheSet(id, v) { | |||
mem[id] = v; | |||
try { sessionStorage.setItem('gr-lib:' + id, JSON.stringify({ t: Date.now(), v: v })); } | |||
catch (e) { } | |||
} | |||
// The library returns camelCase fields; normalise to what the citation | |||
// builder wants and keep BOTH scripts, since a Sanskrit reference is | |||
// cited in Devanagari and an English-language one in IAST. | |||
function shape(g) { | |||
if (!g) return null; | |||
return { | |||
id: g.id || '', | |||
name: g.name || '', | |||
nameIAST: g.transliteration || '', | |||
author: g.author || '', | |||
authorIAST: g.authorTransliteration || g.author_transliteration || '', | |||
category: g.category || '', | |||
prasthana: g.prasthana || '' | |||
}; | |||
} | |||
var inflight = {}; | |||
function lookup(id) { | |||
if (!id) return Promise.resolve(null); | |||
var hit = cacheGet(id); | |||
if (hit) return Promise.resolve(hit); | |||
if (inflight[id]) return inflight[id]; | |||
inflight[id] = fetch(API + '/' + encodeURIComponent(id), { | |||
credentials: 'omit', headers: { 'Accept': 'application/json' } | |||
}).then(function (r) { | |||
if (!r.ok) throw new Error('library ' + r.status); | |||
return r.json(); | |||
}).then(function (j) { | |||
var v = shape(j && (j.grantha || j.data || j)); | |||
if (v && v.name) cacheSet(id, v); | |||
delete inflight[id]; | |||
return v; | |||
}).catch(function () { | |||
// Offline, blocked, or the id is not in the library yet. The | |||
// page's own data-author/data-doc-title stand in; a citation | |||
// is never blocked on this request. | |||
delete inflight[id]; | |||
return null; | |||
}); | |||
return inflight[id]; | |||
} | |||
// ── WHICH GRANTHA IS THIS PAGE? ──────────────────────────────────── | |||
// A page can say so itself (data-grantha-id, written by the pipeline), | |||
// but that would mean re-importing every document just to add one | |||
// attribute - and a re-import risks losing edits made on the site | |||
// since. So the mapping also lives in ONE wiki page, keyed by page | |||
// name, and the page looks itself up. Adding a document to the | |||
// library is then a single edit to that one page, with no content | |||
// touched anywhere. | |||
var MAP_PAGE = window.GR_GRANTHA_MAP_PAGE || 'MediaWiki:GranthaMap.json'; | |||
var mapPromise = null; | |||
function granthaMap() { | |||
if (mapPromise) return mapPromise; | |||
var cached = null; | |||
try { | |||
var raw = sessionStorage.getItem('gr-lib:map'); | |||
var o = raw && JSON.parse(raw); | |||
if (o && (Date.now() - o.t) < TTL) cached = o.v; | |||
} catch (e) { } | |||
if (cached) { mapPromise = Promise.resolve(cached); return mapPromise; } | |||
var url = (window.mw && mw.util && mw.util.getUrl) | |||
? mw.util.getUrl(MAP_PAGE, { action: 'raw', ctype: 'application/json' }) | |||
: '/index.php?title=' + encodeURIComponent(MAP_PAGE) + '&action=raw'; | |||
mapPromise = fetch(url, { credentials: 'same-origin' }) | |||
.then(function (r) { return r.ok ? r.json() : {}; }) | |||
.then(function (m) { | |||
// Either a flat {page: id} object, or {pages:{...}, | |||
// locators:{...}} when division names are recorded too. | |||
m = m || {}; | |||
if (!m.pages) m = { pages: m, locators: {} }; | |||
m.locators = m.locators || {}; | |||
try { sessionStorage.setItem('gr-lib:map', JSON.stringify({ t: Date.now(), v: m })); } | |||
catch (e) { } | |||
return m; | |||
}) | |||
.catch(function () { return {}; }); // no map page yet - fine | |||
return mapPromise; | |||
} | |||
// page name -> library id, however it is known. The attribute wins, | |||
// so a rebuilt page needs no map entry and a map entry needs no rebuild. | |||
function idForPage(pageName, el) { | |||
var own = el && el.getAttribute && el.getAttribute('data-grantha-id'); | |||
if (own) return Promise.resolve(own); | |||
if (!pageName) return Promise.resolve(''); | |||
return granthaMap().then(function (m) { | |||
var p = m.pages || {}; | |||
return p[pageName] || p[String(pageName).replace(/_/g, ' ')] || ''; | |||
}); | |||
} | |||
// Resolve this page and hand the result to anything listening (the | |||
// citation builder). Also fills in the visible title when the library | |||
// and the page disagree, so the reader sees the real name. | |||
function resolvePage() { | |||
var t = document.querySelector('.gr-doc-title'); | |||
if (!t) return; | |||
var page = (window.mw && mw.config.get('wgPageName')) || ''; | |||
// Division names (adhyaya / valli / mantra) come from the same | |||
// map page, so a locator can be spelled out without a rebuild too. | |||
if (!t.getAttribute('data-locator-scheme')) { | |||
granthaMap().then(function (m) { | |||
var sc = (m.locators || {})[page] || | |||
(m.locators || {})[String(page).replace(/_/g, ' ')]; | |||
if (sc) t.setAttribute('data-locator-scheme', sc); | |||
}); | |||
} | |||
idForPage(page, t).then(function (gid) { | |||
if (!gid) return; | |||
t.setAttribute('data-grantha-id', gid); | |||
return lookup(gid).then(function (g) { | |||
if (!g) return; | |||
if (g.name) t.setAttribute('data-doc-title', g.name); | |||
if (g.nameIAST) t.setAttribute('data-doc-title-iast', g.nameIAST); | |||
if (g.author) t.setAttribute('data-author', g.author); | |||
if (g.authorIAST) t.setAttribute('data-author-iast', g.authorIAST); | |||
try { | |||
window.dispatchEvent(new CustomEvent('gr-library-resolved', { detail: g })); | |||
} catch (e) { } | |||
}); | |||
}); | |||
} | |||
// Fetching the whole catalogue is one request; fetching fifty records | |||
// one at a time is fifty. Past a handful, take the catalogue. | |||
var cataloguePromise = null; | |||
function catalogue() { | |||
if (cataloguePromise) return cataloguePromise; | |||
cataloguePromise = fetch(API + '?limit=1000', { | |||
credentials: 'omit', headers: { 'Accept': 'application/json' } | |||
}).then(function (r) { return r.ok ? r.json() : null; }) | |||
.then(function (j) { | |||
var list = (j && (j.granthas || j.data || j.results)) || []; | |||
var by = {}; | |||
list.forEach(function (g) { var v = shape(g); if (v && v.id) { by[v.id] = v; cacheSet(v.id, v); } }); | |||
return by; | |||
}).catch(function () { return {}; }); | |||
return cataloguePromise; | |||
} | |||
function lookupMany(ids) { | |||
var want = [], seen = {}; | |||
(ids || []).forEach(function (i) { if (i && !seen[i]) { seen[i] = 1; want.push(i); } }); | |||
var missing = want.filter(function (i) { return !cacheGet(i); }); | |||
if (missing.length > 5) { | |||
return catalogue().then(function (by) { | |||
var out = {}; | |||
want.forEach(function (i) { out[i] = by[i] || cacheGet(i) || null; }); | |||
return out; | |||
}); | |||
} | |||
return Promise.all(want.map(lookup)).then(function (vals) { | |||
var out = {}; | |||
want.forEach(function (i, n) { out[i] = vals[n]; }); | |||
return out; | |||
}); | |||
} | |||
// ── DOCUMENT LINKS ──────────────────────────────────────────────── | |||
// The home page's cards were written by hand and carry the same | |||
// colophon labels the citation had to stop using - "प्रकाशिका", | |||
// "भाष्यम्" - which say what KIND of work it is, not which one. Any | |||
// link to a mapped page gets the library's name instead, so the | |||
// catalogue and the site agree without the page being edited. | |||
function relabelDocLinks(root) { | |||
var scope = root || document; | |||
var links = scope.querySelectorAll('.gr-home-grid a, .gr-doc-nav a, .gr-vadavali a'); | |||
if (!links.length) return; | |||
granthaMap().then(function (m) { | |||
var pages = m.pages || {}; | |||
var jobs = []; | |||
Array.prototype.forEach.call(links, function (a) { | |||
if (a.getAttribute('data-gr-relabelled')) return; | |||
var t = a.getAttribute('title') || | |||
decodeURIComponent((a.getAttribute('href') || '').split('#')[0] | |||
.replace(/^.*\/(?:wiki\/)?/, '')); | |||
var gid = pages[t] || pages[String(t).replace(/_/g, ' ')]; | |||
if (gid) jobs.push([a, gid]); | |||
}); | |||
if (!jobs.length) return; | |||
lookupMany(jobs.map(function (j) { return j[1]; })).then(function (by) { | |||
jobs.forEach(function (j) { | |||
var g = by[j[1]]; | |||
if (!g || !g.name) return; | |||
j[0].setAttribute('data-gr-relabelled', '1'); | |||
j[0].setAttribute('data-grantha-id', j[1]); | |||
if (g.authorIAST || g.author) { | |||
j[0].setAttribute('title', (g.author || '') + ' — ' + g.name); | |||
} | |||
j[0].textContent = g.name; | |||
}); | |||
}); | |||
}); | |||
} | |||
window.grLibrary = { lookup: lookup, lookupMany: lookupMany, catalogue: catalogue, | |||
idForPage: idForPage, map: granthaMap, api: API, | |||
relabelDocLinks: relabelDocLinks }; | |||
function boot() { resolvePage(); relabelDocLinks(); } | |||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | |||
else boot(); | |||
window.addEventListener('gr-new-content', function (e) { | |||
relabelDocLinks(e && e.detail && e.detail.container); | |||
}); | |||
}()); | }()); | ||