MediaWiki:Common.js: Difference between revisions
No edit summary |
No edit summary |
||
| (6 intermediate revisions by the same user not shown) | |||
| Line 1,728: | Line 1,728: | ||
if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST); | if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST); | ||
var h = el.querySelector('.teeka-title'); | var h = el.querySelector('.teeka-title'); | ||
if (h && | var label = teekaLabel(g); | ||
if (h && label) h.textContent = label; | |||
var slug = el.getAttribute('data-teeka-slug'); | |||
if (slug && label) { | |||
labelBySlug[slug] = label; | |||
// A footnote section for this commentary may | |||
// already be on screen; rename it too. | |||
document.querySelectorAll( | |||
'.gr-teeka-notes-group[data-teeka-slug="' + slug + | |||
'"] .gr-teeka-notes-title').forEach(function (t) { | |||
t.textContent = label; | |||
}); | |||
} | |||
}); | }); | ||
}($entry[0], entry.teekaGranthaId, entry.teekaPage)); | }($entry[0], entry.teekaGranthaId, entry.teekaPage)); | ||
| Line 2,371: | Line 2,383: | ||
var since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString().replace(/\.\d+Z$/, 'Z'); | var since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString().replace(/\.\d+Z$/, 'Z'); | ||
var all = []; | var all = []; | ||
function page(cont) { | function page(cont, limit) { | ||
var p = { | var p = { | ||
action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0, | action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0, | ||
rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: | rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: limit, rcend: since | ||
}; | }; | ||
if (cont) p.rccontinue = cont; | if (cont) p.rccontinue = cont; | ||
return api(p).then(function (j) { | return api(p).then(function (j) { | ||
// The API answers 200 with an {error:...} body, so a failed | |||
// query is not a rejected promise. Treat it as one. | |||
if (j && j.error) throw new Error(j.error.code || 'api error'); | |||
all = all.concat((j.query && j.query.recentchanges) || []); | all = all.concat((j.query && j.query.recentchanges) || []); | ||
// Two pages (1,000 changes) is plenty for a notifications list. | // Two pages (1,000 changes) is plenty for a notifications list. | ||
if (j['continue'] && j['continue'].rccontinue && all.length < 1000) return page(j['continue'].rccontinue); | if (j['continue'] && j['continue'].rccontinue && all.length < 1000) { | ||
return page(j['continue'].rccontinue, limit); | |||
} | |||
return all; | |||
}).catch(function (err) { | |||
// recentchanges over a date range is the most expensive query | |||
// this site makes, and on a busy or constrained database it | |||
// can come back as a DBQueryError. Halve the window and retry | |||
// once; failing that, return nothing. A "what's new" list is | |||
// not worth breaking the page for. | |||
if (limit > 100) return page(cont, 100); | |||
try { | |||
console.warn('[gr] recent changes unavailable: ' + | |||
(err && err.message || err)); | |||
} catch (e) { } | |||
return all; | return all; | ||
}); | }); | ||
} | } | ||
return page(null); | return page(null, 500); | ||
} | } | ||
| Line 2,456: | Line 2,485: | ||
} catch (e) { } | } catch (e) { } | ||
} | } | ||
return Promise.all([fetchChanges(), fetchCatalogue()]).then(function (r) { | return Promise.all([ | ||
fetchChanges().catch(function () { return []; }), | |||
fetchCatalogue().catch(function () { return null; }) | |||
]).then(function (r) { | |||
return dropMissing(summarise(r[0], r[1]).slice(0, MAX_ITEMS + 10)); | return dropMissing(summarise(r[0], r[1]).slice(0, MAX_ITEMS + 10)); | ||
}).then(function (items) { | }).then(function (items) { | ||
| Line 2,987: | Line 3,019: | ||
var h = document.createElement('div'); | var h = document.createElement('div'); | ||
h.className = 'gr-teeka-notes-title'; | h.className = 'gr-teeka-notes-title'; | ||
h.textContent = g.title || g.slug; | h.textContent = (window.grLibrary && window.grLibrary.labelFor(g.slug)) | ||
|| g.title || g.slug; | |||
wrap.appendChild(h); | wrap.appendChild(h); | ||
var ol = document.createElement('ol'); | var ol = document.createElement('ol'); | ||
| Line 3,125: | Line 3,158: | ||
if (window.grLibrary) return; | if (window.grLibrary) return; | ||
// Same-origin by default | // ---- CONFIGURE ME ------------------------------------------------- | ||
// | // Where the Grantha library's REST API lives. Same-origin by default, | ||
var | // which needs no CORS - but the library is its own app, so unless it is | ||
// reverse-proxied under the wiki's domain this must be its real host: | |||
// var API_BASE = 'https://library.example.org/api/granthas'; | |||
// On another host the library must send | |||
// Access-Control-Allow-Origin: https://anandamakaranda.in | |||
// or the browser will refuse the reply and every citation will fall | |||
// back to the author baked into the page (usually none). | |||
var API_BASE = window.GR_LIBRARY_API || '/api/granthas'; | |||
// Set to false while the library is not yet reachable: no request is | |||
// made at all, and citations use whatever the page itself carries. | |||
// Flip it back to true once API_BASE resolves. | |||
var LIBRARY_ENABLED = true; | |||
// ------------------------------------------------------------------- | |||
var API = API_BASE.replace(/\/+$/, ''); | |||
var mem = {}; | var mem = {}; | ||
var warned = false; | |||
// Once the library has answered "not here", every further call will get | |||
// the same answer. Stop asking: a wrong or missing API base otherwise | |||
// fills the network log with one 404 per work on the page. | |||
var libraryDown = !LIBRARY_ENABLED; | |||
// In memory only, for the life of the page. Names used to be kept in | |||
// sessionStorage for six hours, which quietly outlived any correction | |||
// made in the library or on the map page. They now come from the map | |||
// page, which is read once per load, so there is nothing to gain from | |||
// a second cache that can disagree with it. | |||
function cacheGet(id) { return mem[id] || null; } | |||
function cacheSet(id, v) { mem[id] = v; } | |||
function cacheSet(id, v) { | |||
// The library returns camelCase fields; normalise to what the citation | // The library returns camelCase fields; normalise to what the citation | ||
| Line 3,169: | Line 3,210: | ||
var hit = cacheGet(id); | var hit = cacheGet(id); | ||
if (hit) return Promise.resolve(hit); | if (hit) return Promise.resolve(hit); | ||
// The map page carries the names for every work this wiki uses, so | |||
// normally nothing is fetched from the library at all. That matters | |||
// because the library is someone else's server: we cannot add CORS | |||
// headers to it, and a cross-origin call we cannot configure is a | |||
// call that will not work. The live API stays as an enhancement for | |||
// whenever it IS reachable. | |||
return granthaMap().then(function (m) { | |||
var w = (m.works || {})[id]; | |||
if (w && (w.name || w.nameIAST)) { | |||
var v = { id: id, name: w.name || '', nameIAST: w.nameIAST || '', | |||
author: w.author || '', authorIAST: w.authorIAST || '', | |||
category: w.category || '', prasthana: w.prasthana || '' }; | |||
cacheSet(id, v); | |||
return v; | |||
} | |||
return fetchFromLibrary(id); | |||
}); | |||
} | |||
function fetchFromLibrary(id) { | |||
if (libraryDown) return Promise.resolve(null); | |||
if (inflight[id]) return inflight[id]; | if (inflight[id]) return inflight[id]; | ||
inflight[id] = fetch(API + '/' + encodeURIComponent(id), { | inflight[id] = fetch(API + '/' + encodeURIComponent(id), { | ||
credentials: 'omit', headers: { 'Accept': 'application/json' } | credentials: 'omit', headers: { 'Accept': 'application/json' } | ||
}).then(function (r) { | }).then(function (r) { | ||
if (!r.ok) throw new Error('library ' + r.status); | // A 404 or 5xx means the base URL is wrong or the service is | ||
// down - not that this one work is missing - so trip the breaker. | |||
if (!r.ok) { | |||
if (r.status === 404 || r.status >= 500) libraryDown = true; | |||
throw new Error('library ' + r.status); | |||
} | |||
return r.json(); | return r.json(); | ||
}).then(function (j) { | }).then(function (j) { | ||
| Line 3,180: | Line 3,247: | ||
delete inflight[id]; | delete inflight[id]; | ||
return v; | return v; | ||
}).catch(function () { | }).catch(function (err) { | ||
// Offline, blocked, or the id is not in the library yet. The | // Offline, blocked by CORS, wrong host, or the id is not in the | ||
// library yet. The page's own data-author/data-doc-title stand | |||
// is never blocked on this request. | // in, so a citation is never blocked on this request - but an | ||
// unreachable library looks exactly like a work with no author, | |||
// so say which it is, once. | |||
delete inflight[id]; | delete inflight[id]; | ||
libraryDown = true; | |||
if (!warned) { | |||
warned = true; | |||
try { | |||
console.warn('[grantha] could not read the library at ' + API + | |||
' (asked for "' + id + '"): ' + (err && err.message || err) + | |||
'. Citations will use whatever the page itself carries. ' + | |||
'Set window.GR_LIBRARY_API to the library host, and allow ' + | |||
'this origin via Access-Control-Allow-Origin.'); | |||
} catch (e) { } | |||
} | |||
return null; | return null; | ||
}); | }); | ||
| Line 3,203: | Line 3,283: | ||
function granthaMap() { | function granthaMap() { | ||
if (mapPromise) return mapPromise; | if (mapPromise) return mapPromise; | ||
// Deliberately NOT cached in sessionStorage. It was, for six hours, | |||
try { | // which meant that editing the map page changed nothing for anyone | ||
// whose tab had already read the old one - the exact failure you hit | |||
// while setting this up. mapPromise already collapses it to one | |||
// request per page load, and the browser's own HTTP cache handles | |||
// the rest; a stale map is far more expensive than a small fetch. | |||
try { sessionStorage.removeItem('gr-lib:map'); } catch (e) { } | |||
var url = (window.mw && mw.util && mw.util.getUrl) | var url = (window.mw && mw.util && mw.util.getUrl) | ||
| Line 3,217: | Line 3,297: | ||
.then(function (r) { return r.ok ? r.json() : {}; }) | .then(function (r) { return r.ok ? r.json() : {}; }) | ||
.then(function (m) { | .then(function (m) { | ||
// Either a flat {page: id} object, or {pages | // Either a flat {page: id} object, or {pages, works, | ||
// locators | // locators} when names and division names are recorded too. | ||
m = m || {}; | m = m || {}; | ||
if (!m.pages) m = { pages: m | if (!m.pages) m = { pages: m }; | ||
m.works = m.works || {}; | |||
m.locators = m.locators || {}; | m.locators = m.locators || {}; | ||
try { | try { | ||
catch (e) { } | if (!Object.keys(m.works).length) { | ||
console.info('[grantha] the map page has no "works" section, ' + | |||
'so names must come from the library API. Re-run ' + | |||
'sync_grantha_map.py to embed them.'); | |||
} | |||
} catch (e) { } | |||
return m; | return m; | ||
}) | }) | ||
| Line 3,263: | Line 3,349: | ||
return lookup(gid).then(function (g) { | return lookup(gid).then(function (g) { | ||
if (!g) return; | if (!g) return; | ||
// On a commentary's own page the heading still reads as the | |||
// docx colophon ("...विरचिता प्रकाशिका"). Name it the way the | |||
// blocks are named instead. | |||
if (t.getAttribute('data-parent-doc')) { | |||
var lab = teekaLabel(g); | |||
if (lab) t.textContent = lab; | |||
} else if (g.name) { | |||
t.textContent = g.name; | |||
} | |||
if (g.name) t.setAttribute('data-doc-title', g.name); | if (g.name) t.setAttribute('data-doc-title', g.name); | ||
if (g.nameIAST) t.setAttribute('data-doc-title-iast', g.nameIAST); | if (g.nameIAST) t.setAttribute('data-doc-title-iast', g.nameIAST); | ||
| Line 3,278: | Line 3,373: | ||
var cataloguePromise = null; | var cataloguePromise = null; | ||
function catalogue() { | function catalogue() { | ||
if (libraryDown) return Promise.resolve({}); | |||
if (cataloguePromise) return cataloguePromise; | if (cataloguePromise) return cataloguePromise; | ||
cataloguePromise = fetch(API + '?limit=1000', { | cataloguePromise = fetch(API + '?limit=1000', { | ||
credentials: 'omit', headers: { 'Accept': 'application/json' } | credentials: 'omit', headers: { 'Accept': 'application/json' } | ||
}).then(function (r) { | }).then(function (r) { | ||
if (!r.ok) { libraryDown = true; return null; } | |||
return r.json(); | |||
}) | |||
.then(function (j) { | .then(function (j) { | ||
var list = (j && (j.granthas || j.data || j.results)) || []; | var list = (j && (j.granthas || j.data || j.results)) || []; | ||
| Line 3,294: | Line 3,393: | ||
var want = [], seen = {}; | var want = [], seen = {}; | ||
(ids || []).forEach(function (i) { if (i && !seen[i]) { seen[i] = 1; want.push(i); } }); | (ids || []).forEach(function (i) { if (i && !seen[i]) { seen[i] = 1; want.push(i); } }); | ||
// lookup() answers from the map page without a network call, so | |||
// resolving fifty works costs one shared map fetch, not fifty | |||
// requests. Only ids the map does not name reach the library. | |||
return Promise.all(want.map(lookup)).then(function (vals) { | return Promise.all(want.map(lookup)).then(function (vals) { | ||
var out = {}; | var out = {}; | ||
| Line 3,307: | Line 3,401: | ||
return out; | return out; | ||
}); | }); | ||
} | |||
// How a commentary is named on screen: its author, then the work. | |||
// "श्रीवादिराजतीर्थः: काठकोपनिषद्भाष्यप्रकाशिका" - because several | |||
// commentaries on one moola share a work-type name (प्रकाशिका, | |||
// विवरणम्, खण्डार्थः), and the author is what actually tells them | |||
// apart for a reader scanning down the page. | |||
var labelBySlug = {}; | |||
function teekaLabel(g) { | |||
if (!g) return ''; | |||
var name = g.name || g.nameIAST || ''; | |||
var author = g.author || g.authorIAST || ''; | |||
if (!author || !name) return name || author; | |||
// Nothing to prefix if the work already opens with the author. | |||
if (name.indexOf(author) === 0) return name; | |||
return author + ': ' + name; | |||
} | } | ||
| Line 3,346: | Line 3,456: | ||
} | } | ||
window.grLibraryVersion = '2026-09-30a'; | |||
window.grLibrary = { lookup: lookup, lookupMany: lookupMany, catalogue: catalogue, | window.grLibrary = { lookup: lookup, lookupMany: lookupMany, catalogue: catalogue, | ||
teekaLabel: teekaLabel, | |||
labelFor: function (slug) { return labelBySlug[slug] || ''; }, | |||
idForPage: idForPage, map: granthaMap, api: API, | idForPage: idForPage, map: granthaMap, api: API, | ||
relabelDocLinks: relabelDocLinks }; | relabelDocLinks: relabelDocLinks }; | ||