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);
    });
}());
}());