MediaWiki:Common.js: Difference between revisions

No edit summary
No edit summary
 
(8 intermediate revisions by the same user not shown)
Line 1,663: Line 1,663:
                 index[moolaId].push({
                 index[moolaId].push({
                     teekaTitle: td.doc.title || td._title,
                     teekaTitle: td.doc.title || td._title,
                    teekaAuthor: td.doc.author || '',
                    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,696: Line 1,699:
                     .attr('data-teeka-slug', entry.teekaSlug)
                     .attr('data-teeka-slug', entry.teekaSlug)
                     .attr('data-teeka-block-id', entry.blockId)
                     .attr('data-teeka-block-id', entry.blockId)
                    // Citation metadata: a bookmark taken inside an
                    // interleaved commentary must cite THAT work and its
                    // author, and link to the commentary's own page - not
                    // to the moola whose page it happens to be shown on.
                    .attr('data-cite-author', entry.teekaAuthor || '')
                    .attr('data-cite-title', entry.teekaTitle || '')
                    .attr('data-cite-page', entry.teekaPage || '')
                    .attr('data-cite-grantha-id', entry.teekaGranthaId || '')
                    .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');
                            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));
                }
                 $('<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,336: 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: 500, rcend: since
                 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,421: 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,952: 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 2,989: Line 3,057:
     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;
    // ---- CONFIGURE ME -------------------------------------------------
    // Where the Grantha library's REST API lives. Same-origin by default,
    // 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 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; }
    // 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);
        // 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];
        inflight[id] = fetch(API + '/' + encodeURIComponent(id), {
            credentials: 'omit', headers: { 'Accept': 'application/json' }
        }).then(function (r) {
            // 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();
        }).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 (err) {
            // 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
            // 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];
            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 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;
        // Deliberately NOT cached in sessionStorage. It was, for six hours,
        // 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)
            ? 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, works,
                // locators} when names and division names are recorded too.
                m = m || {};
                if (!m.pages) m = { pages: m };
                m.works = m.works || {};
                m.locators = m.locators || {};
                try {
                    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;
            })
            .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;
                // 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.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 (libraryDown) return Promise.resolve({});
        if (cataloguePromise) return cataloguePromise;
        cataloguePromise = fetch(API + '?limit=1000', {
            credentials: 'omit', headers: { 'Accept': 'application/json' }
        }).then(function (r) {
              if (!r.ok) { libraryDown = true; return null; }
              return r.json();
          })
          .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); } });
        // 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) {
            var out = {};
            want.forEach(function (i, n) { out[i] = vals[n]; });
            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;
    }
    // ── 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.grLibraryVersion = '2026-09-30a';
    window.grLibrary = { lookup: lookup, lookupMany: lookupMany, catalogue: catalogue,
                        teekaLabel: teekaLabel,
                        labelFor: function (slug) { return labelBySlug[slug] || ''; },
                        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);
    });
}());
}());