MediaWiki:Common.js: Difference between revisions

No edit summary
No edit summary
 
(27 intermediate revisions by the same user not shown)
Line 160: Line 160:
                     if (p.closest('.gr-controls')) return;
                     if (p.closest('.gr-controls')) return;
                     if (p.closest('.mw-editsection')) return;
                     if (p.closest('.mw-editsection')) return;
                    // Note markers are [1], [2] … — machinery, not text, and
                    // the numbers would be transliterated along with the page.
                    if (p.closest('.gr-note-marker')) return;
                 }
                 }
                 var orig = node.textContent;
                 var orig = node.textContent;
Line 170: Line 173:
             });
             });
         }
         }
         document.querySelectorAll('.vector-toc .vector-toc-text').forEach(function (span) {
         document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
            if (span.hasAttribute('data-deva')) return;
    }
             var orig = span.textContent;
 
            if (!orig.trim()) return;
    // A contents entry is "1.2 शीर्षकम्": the number lives in its own
            span.setAttribute('data-deva', orig);
    // .vector-toc-numb span, which Common.css hides. Tagging the whole
            translatableSpans.push(span);
    // .vector-toc-text meant transliteration replaced its contents with
         });
    // one flat string — number included — so switching to Tamil or any
    // other script made the numbering appear. Only the label is tagged.
    function tagTocEntry(textEl) {
        if (!textEl || textEl.hasAttribute('data-deva')) return;
        var label = textEl.querySelector('[data-deva]');
        if (!label) {
            label = document.createElement('span');
             label.className = 'gr-toc-label';
            Array.prototype.slice.call(textEl.childNodes).forEach(function (n) {
                if (n.nodeType === 1 && n.classList && n.classList.contains('vector-toc-numb')) return;
                label.appendChild(n);                // moves it, order preserved
            });
            textEl.appendChild(label);
        }
        var orig = label.textContent;
        if (!orig.trim()) return;
        if (!label.hasAttribute('data-deva')) label.setAttribute('data-deva', orig);
        if (translatableSpans.indexOf(label) === -1) translatableSpans.push(label);
    }
 
    // Which script is on screen, published to CSS. Common.css §2b uses it to
    // swap the reading area onto --font-indic while a transliteration is
    // showing: most rules here name Adishila directly, which has no Tamil,
    // Kannada or Telugu letters, so without this the switched text fell back
    // to whatever serif the visitor's machine happened to have — the reason
    // it looked uneven from one line to the next.
    function markScriptOnRoot(script) {
        try {
            var el = document.documentElement;
            if (!el) return;
            if (!script || script === 'deva') el.removeAttribute('data-gr-script');
            else el.setAttribute('data-gr-script', script);
         } catch (e) { }
     }
     }


     function applyScript(script) {
     function applyScript(script) {
         currentScript = script;
         currentScript = script;
        markScriptOnRoot(script);
         translatableSpans.forEach(function (span) {
         translatableSpans.forEach(function (span) {
             if (!span.parentNode) return;
             if (!span.parentNode) return;
Line 231: Line 267:
         }
         }
         if (el && el.parentNode) el.parentNode.removeChild(el);
         if (el && el.parentNode) el.parentNode.removeChild(el);
    }
    // The commentary list at the top of a moola page has its own heading
    // (टीकाः). It isn't part of the text, so it doesn't belong in the
    // contents. Recognised by what the section holds — the .gr-doc-teekas
    // list, or a section whose only links are to /Vyakhya/ pages — with
    // the heading's own wording as a last resort, since the list markup
    // differs between pages and the commentary picker rewrites it.
    var TEEKA_HEADINGS = /^(टीकाः?|व्याख्याः?|टीकाग्रन्थाः|Teekas?|Commentaries)$/i;
    function isTeekaSection(headingEl) {
        var wrap = (headingEl.closest && headingEl.closest('.mw-heading')) || headingEl;
        var n = wrap.nextElementSibling, links = [], vyakhya = 0, steps = 0;
        while (n && steps < 6) {
            if (n.classList && n.classList.contains('gr-doc-teekas')) return true;
            if (n.querySelector && n.querySelector('.gr-doc-teekas')) return true;
            if ((n.tagName && /^H[1-6]$/.test(n.tagName)) || (n.classList && n.classList.contains('mw-heading'))) break;
            if (n.querySelectorAll) {
                Array.prototype.forEach.call(n.querySelectorAll('a[href]'), function (a) {
                    var href = a.getAttribute('href') || '';
                    if (/^#/.test(href)) return;
                    links.push(href);
                    if (/\/Vyakhya\//i.test(decodeURIComponent(href))) vyakhya++;
                });
            }
            n = n.nextElementSibling; steps++;
        }
        if (links.length && vyakhya === links.length) return true;
        return TEEKA_HEADINGS.test((headingEl.textContent || '').trim());
    }
    function removeTocTeekaEntry() {
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-link[href^="#"]'), function (a) {
            var id = a.getAttribute('href').slice(1);
            try { id = decodeURIComponent(id); } catch (e) { }
            var h = id && document.getElementById(id);
            if (!h || !isTeekaSection(h)) return;
            var li = a.closest('.vector-toc-list-item');
            if (li && li.parentNode) li.parentNode.removeChild(li);
        });
    }
    // Section numbers ("2.1.1") are removed from the contents outright.
    // Hiding them with CSS was not enough: the script switcher replaces an
    // entry's contents with one flat string, which turned the hidden number
    // into ordinary text — so it reappeared in Tamil, Kannada and the rest.
    // They are also wrong here, since this contents list leaves entries out.
    var NUMBERED = /^\s*\d+(?:\.\d+)*[.)]?\s+([\s\S]*\S)\s*$/;
    function stripTocNumbers() {
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-numb'), function (n) {
            if (n.parentNode) n.parentNode.removeChild(n);
        });
        // Entries another script already flattened: take the number off the
        // text, and off the Devanagari original the switcher re-reads.
        Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-text, .vector-toc-text [data-deva]'), function (el) {
            var d = el.getAttribute && el.getAttribute('data-deva');
            if (d) { var md = NUMBERED.exec(d); if (md) el.setAttribute('data-deva', md[1]); }
            if (!el.children.length) {
                var mt = NUMBERED.exec(el.textContent);
                if (mt) el.textContent = mt[1];
            }
        });
    }
    // The switcher can rewrite the contents at any time, so watch for it.
    var _numbObserver = null;
    function watchTocNumbers() {
        var toc = document.querySelector('.vector-toc');
        if (!toc || _numbObserver || !window.MutationObserver) return;
        var pending = false;
        _numbObserver = new MutationObserver(function () {
            if (pending) return;
            pending = true;
            setTimeout(function () { pending = false; stripTocNumbers(); }, 50);
        });
        _numbObserver.observe(toc, { childList: true, subtree: true, characterData: true });
     }
     }


Line 281: Line 395:
         if (showMoolaBack) nav.appendChild(makeBtn(wikiUrl(primarySlug), 'मूल'));
         if (showMoolaBack) nav.appendChild(makeBtn(wikiUrl(primarySlug), 'मूल'));
         if (showUllekha) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
         if (showUllekha) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
        // Under the document's own title in the page, the same as on a
        // phone — not tucked into the contents panel, where these were
        // easy to miss. The contents panel is only the fallback.
        var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
        if (docTitle && docTitle.parentNode) { docTitle.parentNode.insertBefore(nav, docTitle.nextSibling); return; }
        var h1 = document.getElementById('firstHeading') || document.querySelector('.mw-first-heading');
        if (h1 && h1.parentNode) { h1.parentNode.insertBefore(nav, h1.nextSibling); return; }
         var tocContents = toc.querySelector('.vector-toc-contents');
         var tocContents = toc.querySelector('.vector-toc-contents');
         if (tocContents) toc.insertBefore(nav, tocContents);
         if (tocContents) toc.insertBefore(nav, tocContents);
Line 316: Line 437:
         toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); });
         toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); });
     }
     }
     function setActiveToc(id) {
    function ownLink(li) {
        return li.querySelector(':scope > .vector-toc-link') || li.querySelector('.vector-toc-link');
    }
    function isShown(el) {
        return !!(el && (el.offsetParent || (el.getClientRects && el.getClientRects().length)));
    }
    // Open a collapsed section so a given entry can be seen. Vector marks a
    // top-level item open with vector-toc-list-item-expanded plus
    // aria-expanded on its toggle, so this matches what its own button does.
    function expandTocFor(li) {
        var top = li.closest ? li.closest('.vector-toc-level-1') : null;
        if (!top) return;
        if (!top.classList.contains('vector-toc-list-item-expanded')) {
            top.classList.add('vector-toc-list-item-expanded');
            var tg = top.querySelector('.vector-toc-toggle');
            if (tg) tg.setAttribute('aria-expanded', 'true');
        }
        var anc = li.parentNode;
        while (anc && anc.classList) {
            anc.classList.remove('vector-toc-list-item-collapsed');
            if (anc.classList.contains('vector-toc')) break;
            anc = anc.parentNode;
        }
    }
 
    // expand=true when the reader picked the entry (TOC click, search
    // result, #hash): open its section so the exact entry is shown.
    // expand=false while scrolling: never open or close anything — if the
    // entry is hidden inside a collapsed section, the section itself is
    // highlighted instead, so the reader still sees where they are.
     function setActiveToc(id, expand) {
         var toc = document.querySelector('.vector-toc');
         var toc = document.querySelector('.vector-toc');
         if (!toc) return;
         if (!toc) return;
         if (_tocActiveId === id) return;
        var shownNow = toc.querySelector('.gr-toc-current');
         if (_tocActiveId === id && !expand && shownNow && isShown(shownNow)) return;
         _tocActiveId = id;
         _tocActiveId = id;
         clearActiveToc();
         clearActiveToc();
Line 325: Line 477:
         var li = getTocLi(id);
         var li = getTocLi(id);
         if (!li) return;
         if (!li) return;
         // The entry itself — the link only, so sub-entries stay as they are.
         if (expand) expandTocFor(li);
        var lnk = li.querySelector(':scope > .vector-toc-link') || li.querySelector('.vector-toc-link');
 
        var lnk = ownLink(li);
        var hostLi = li;
        if (lnk && !isShown(lnk)) {          // hidden in a collapsed section
            var p = li.parentNode;
            while (p && p !== toc) {
                if (p.classList && p.classList.contains('vector-toc-list-item')) {
                    var pl = ownLink(p);
                    if (pl && isShown(pl)) { lnk = pl; hostLi = p; break; }
                }
                p = p.parentNode;
            }
        }
         if (lnk) lnk.classList.add('gr-toc-current');
         if (lnk) lnk.classList.add('gr-toc-current');
         // Its parent sections get the quieter "you are in here" treatment,
         // Sections above it get the quieter "you are in here" treatment.
        // and any collapsed ancestor is opened so the entry is visible.
         var anc = hostLi.parentNode;
         var anc = li.parentNode;
         while (anc && anc !== toc) {
         while (anc && anc !== toc) {
             if (anc.classList) {
             if (anc.classList && anc.classList.contains('vector-toc-list-item')) anc.classList.add('gr-toc-in-path');
                anc.classList.remove('vector-toc-list-item-collapsed');
                if (anc.classList.contains('vector-toc-list-item')) anc.classList.add('gr-toc-in-path');
            }
            if (anc.tagName === 'UL' || anc.tagName === 'LI') anc.style.removeProperty('display');
             anc = anc.parentNode;
             anc = anc.parentNode;
         }
         }
         var sticky = document.querySelector('.vector-sticky-pinned-container');
         keepTocEntryVisible(lnk || hostLi);
         var scrollEl = sticky || toc;
    }
         if (scrollEl.scrollHeight > scrollEl.clientHeight) {
 
             var lr = li.getBoundingClientRect();
    // Scroll the TOC panel so the active entry is visible. This used to
            var cr = scrollEl.getBoundingClientRect();
    // measure the whole <li>, which for a section with sub-sections is as
             if (lr.top < cr.top + 8 || lr.bottom > cr.bottom - 8) {
    // tall as the entire branch — so "centre it" landed somewhere inside
                scrollEl.scrollTop += lr.top - cr.top - scrollEl.clientHeight / 2 + li.offsetHeight / 2;
    // the NEXT section. It now measures the link itself, and finds the
            }
    // element that actually scrolls rather than assuming one.
    function keepTocEntryVisible(el) {
        if (!el) return;
         var sc = el.parentNode;
         while (sc && sc !== document.body && sc.nodeType === 1) {
             var oy = window.getComputedStyle(sc).overflowY;
             if ((oy === 'auto' || oy === 'scroll') && sc.scrollHeight > sc.clientHeight + 4) break;
            sc = sc.parentNode;
         }
         }
        if (!sc || sc === document.body || sc.nodeType !== 1) return;  // nothing scrolls: leave the page alone
        var er = el.getBoundingClientRect(), cr = sc.getBoundingClientRect();
        if (er.top >= cr.top + 8 && er.bottom <= cr.bottom - 8) return; // already in view
        sc.scrollTop += (er.top - cr.top) - Math.max((sc.clientHeight - er.height) / 2, 0);
     }
     }


Line 469: Line 639:
         if (!id) return;
         if (!id) return;
         _pin = { id: id, y: scrollY(), until: Date.now() + 900 };
         _pin = { id: id, y: scrollY(), until: Date.now() + 900 };
         setActiveToc(id);
         setActiveToc(id, true);         // open its section if collapsed
         scheduleRecheck(950);
         scheduleRecheck(950);
     }
     }
Line 483: Line 653:
             if (Date.now() < _pin.until) {              // still settling after the jump
             if (Date.now() < _pin.until) {              // still settling after the jump
                 _pin.y = y;
                 _pin.y = y;
                 setActiveToc(_pin.id);
                 setActiveToc(_pin.id, true);
                 scheduleRecheck(_pin.until - Date.now() + 30);
                 scheduleRecheck(_pin.until - Date.now() + 30);
                 return;
                 return;
Line 509: Line 679:
         var content = document.querySelector('.mw-parser-output');
         var content = document.querySelector('.mw-parser-output');
         if (!content) return;
         if (!content) return;
         _tocHeadings = Array.prototype.slice.call(content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]'));
        if (!document.querySelector('.vector-toc')) return;
        // Only headings the contents lists — a heading with no entry (the
        // commentary list, say) would otherwise leave nothing highlighted.
         _tocHeadings = Array.prototype.slice.call(content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]'))
            .filter(function (h) { return !!getTocLink(h.id); });
         if (!_tocHeadings.length) return;
         if (!_tocHeadings.length) return;
        if (!document.querySelector('.vector-toc')) return;
         measureHeadings();
         measureHeadings();
         // Time-based throttle rather than a requestAnimationFrame latch: if
         // Time-based throttle rather than a requestAnimationFrame latch: if
Line 558: Line 731:
         if (!toc) return;
         if (!toc) return;
         removeTocBeginning();
         removeTocBeginning();
        removeTocTeekaEntry();
        stripTocNumbers();
        watchTocNumbers();
         renameTocTitle();
         renameTocTitle();
         expandTocSections();
         expandTocSections();
Line 597: Line 773:
         if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
         if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
         else {
         else {
             document.querySelectorAll('.vector-toc .vector-toc-text:not([data-deva])').forEach(function (span) {
             document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
                var orig = span.textContent; if (!orig.trim()) return;
                span.setAttribute('data-deva', orig); translatableSpans.push(span);
            });
         }
         }
         var saved = (function () { try { return localStorage.getItem(LS_SCRIPT_KEY); } catch (e) { return null; } }());
         var saved = (function () { try { return localStorage.getItem(LS_SCRIPT_KEY); } catch (e) { return null; } }());
         if (saved && saved !== 'deva') { applyScript(saved); } else { currentScript = 'deva'; }
         if (saved && saved !== 'deva') { applyScript(saved); } else { currentScript = 'deva'; }
        watchNewContent();
         setTimeout(setupToc, 200);
         setTimeout(setupToc, 200);
     }
     }
Line 612: Line 786:
     });
     });


    var _translitBusy = false;
    function tagNewContent(container) {
        if (!container || !container.nodeType) return;
        _translitBusy = true;
        try {
            var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
            var nodes = [];
            while (walker.nextNode()) nodes.push(walker.currentNode);
            nodes.forEach(function (node) {
                var p = node.parentNode;
                if (!p || (p.hasAttribute && p.hasAttribute('data-deva'))) return;
                if (p.closest && (p.closest('.gr-controls') || p.closest('.mw-editsection')
                                  || p.closest('.gr-note-marker'))) return;
                var orig = node.textContent;
                if (!orig.trim()) return;
                var span = document.createElement('span');
                span.setAttribute('data-deva', orig);
                span.textContent = currentScript !== 'deva' ? transliterateText(orig, currentScript) : orig;
                p.replaceChild(span, node);
                translatableSpans.push(span);
            });
        } finally { _translitBusy = false; }
    }
     window.addEventListener('gr-new-content', function (e) {
     window.addEventListener('gr-new-content', function (e) {
         var container = e && e.detail && e.detail.container;
         tagNewContent(e && e.detail && e.detail.container);
        if (!container) return;
        var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
        var nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);
        nodes.forEach(function (node) {
            var p = node.parentNode;
            if (!p || (p.hasAttribute && p.hasAttribute('data-deva'))) return;
            var orig = node.textContent;
            if (!orig.trim()) return;
            var span = document.createElement('span');
            span.setAttribute('data-deva', orig);
            span.textContent = currentScript !== 'deva' ? transliterateText(orig, currentScript) : orig;
            p.replaceChild(span, node);
            translatableSpans.push(span);
        });
     });
     });
    // Safety net: anything else that adds text to the page later — an
    // interleaved commentary, a sub-commentary, an edit preview — is picked
    // up and put into the reader's chosen script, rather than being left in
    // Devanagari because it wasn't there when the page was first tagged.
    function watchNewContent() {
        if (!window.MutationObserver) return;
        var content = document.querySelector('.mw-parser-output');
        if (!content || content.getAttribute('data-gr-translit-watch')) return;
        content.setAttribute('data-gr-translit-watch', '1');
        var queued = [], timer = null;
        function flush() {
            timer = null;
            var items = queued; queued = [];
            items.forEach(function (n) { if (document.body.contains(n)) tagNewContent(n); });
        }
        new MutationObserver(function (muts) {
            if (_translitBusy) return;
            muts.forEach(function (m) {
                Array.prototype.forEach.call(m.addedNodes, function (n) {
                    var el = n.nodeType === 1 ? n : (n.nodeType === 3 ? n.parentNode : null);
                    if (!el || el.nodeType !== 1) return;
                    if (el.hasAttribute && el.hasAttribute('data-deva')) return;
                    queued.push(el);
                });
            });
            if (queued.length && !timer) timer = setTimeout(flush, 60);
        }).observe(content, { childList: true, subtree: true });
    }


     try {
     try {
Line 646: Line 857:
                 if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
                 if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
                 else {
                 else {
                     document.querySelectorAll('.vector-toc .vector-toc-text:not([data-deva])').forEach(function (span) {
                     // tagTocEntry, not a re-tag from textContent: an entry
                        var orig = span.textContent; if (!orig.trim()) return;
                    // keeps its Devanagari in a span *inside* .vector-toc-text,
                        span.setAttribute('data-deva', orig); translatableSpans.push(span);
                    // so .vector-toc-text itself never carries data-deva and a
                    });
                    // :not([data-deva]) sweep matched every entry on every
                    // fire. It then stored the text as it stood — Tamil, once
                    // the reader had switched — as that entry's "original", so
                    // the next switch had no Devanagari left to work from and
                    // the contents panel stayed in the previous script while
                    // the body moved on. tagTocEntry leaves a tagged entry be.
                    document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
                 }
                 }
                 if (currentScript !== 'deva') applyScript(currentScript);
                 if (currentScript !== 'deva') applyScript(currentScript);
Line 1,216: Line 1,433:
         if (!showMoolam && !showUllekha) return;
         if (!showMoolam && !showUllekha) return;
         var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav';
         var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav';
         nav.style.cssText = 'display:flex;gap:10px;padding:10px 16px 8px;background:#fdf8f5;border-bottom:1px solid #f0e0d6;font-family:system-ui,sans-serif;';
         nav.className = 'toc-main-links';       // styled in Common.css §24
         function makeLink(href, label) { var a = document.createElement('a'); a.href = href; a.textContent = label; a.style.cssText = 'display:inline-flex;align-items:center;padding:5px 16px;border-radius:20px;background:#fff;border:1.5px solid #e8cfc4;color:#b5451b;font-size:14px;font-weight:600;text-decoration:none;'; return a; }
         function makeLink(href, label) { var a = document.createElement('a'); a.href = href; a.textContent = label; a.className = 'toc-main-link-item'; return a; }
         if (teekaPage) nav.appendChild(makeLink(wikiUrl(primarySlug), 'मूल'));
         if (teekaPage) nav.appendChild(makeLink(wikiUrl(primarySlug), 'मूल'));
         else if (hasMoolaPage) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
         else if (hasMoolaPage) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
         if (showUllekha) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
         if (showUllekha) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
        // Right under the document's own title, which is what a reader on a
        // phone actually sees as the title; the skin's h1 is the fallback.
        var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
         var h1 = document.getElementById('firstHeading') || document.querySelector('.page-heading, h1.firstHeading, .mw-first-heading');
         var h1 = document.getElementById('firstHeading') || document.querySelector('.page-heading, h1.firstHeading, .mw-first-heading');
         if (h1 && h1.parentNode) h1.parentNode.insertBefore(nav, h1.nextSibling);
         if (docTitle && docTitle.parentNode) docTitle.parentNode.insertBefore(nav, docTitle.nextSibling);
        else if (h1 && h1.parentNode) h1.parentNode.insertBefore(nav, h1.nextSibling);
         else { var ct = document.getElementById('mw-content-text'); if (ct) ct.insertBefore(nav, ct.firstChild); }
         else { var ct = document.getElementById('mw-content-text'); if (ct) ct.insertBefore(nav, ct.firstChild); }
     }
     }
Line 1,322: Line 1,543:
     }
     }


     function fetchTeekaJson(title) {
     // One request for ALL of them, not one each. The API takes up to 50
         return $.ajax({
    // titles at a time, and a moola page now shows every commentary by
            url: mw.util.wikiScript('api'),
    // default - so what used to be one round trip became five or ten,
            data: { action: 'query', titles: 'JSON:' + title, prop: 'revisions', rvslots: 'main', rvprop: 'content', format: 'json', formatversion: 2 },
    // fired together but each with its own latency, and the commentaries
            dataType: 'json'
    // appeared well after the text. Batching makes it a single request
        }).then(function (data) {
    // whatever the page carries. maxage/smaxage let the browser and the
            var pages = (data.query && data.query.pages) || [];
    // CDN reuse the response, so moving between chapters of one grantha
            var page = pages[0];
    // does not re-fetch the same commentaries.
            if (!page || page.missing) return null;
    var teekaJsonCache = {};
            var content = page.revisions && page.revisions[0] && page.revisions[0].slots.main.content;
 
            if (!content) return null;
    function fetchTeekaJsons(titles) {
            try { var parsed = JSON.parse(content); parsed._title = title; return parsed; }
        var want = titles.filter(function (t) { return !(t in teekaJsonCache); });
             catch (e) { return null; }
        if (!want.length) return $.Deferred().resolve(teekaJsonCache).promise();
         }).catch(function () { return null; });
 
        var batches = [];
        for (var i = 0; i < want.length; i += 40) batches.push(want.slice(i, i + 40));
 
         return $.when.apply($, batches.map(function (batch) {
            return $.ajax({
                url: mw.util.wikiScript('api'),
                data: {
                    action: 'query',
                    titles: batch.map(function (t) { return 'JSON:' + t; }).join('|'),
                    prop: 'revisions', rvslots: 'main', rvprop: 'content',
                    format: 'json', formatversion: 2,
                    maxage: 300, smaxage: 300
                },
                dataType: 'json'
            }).then(function (data) {
                var pages = (data.query && data.query.pages) || [];
                pages.forEach(function (page) {
                    // Match the response back to the title we asked for:
                    // the API may normalise or reorder, so never pair by
                    // index. Strip the namespace the same way it was added.
                    var title = (page.title || '').replace(/^JSON:/, '');
                    if (page.missing) { teekaJsonCache[title] = null; return; }
                    var content = page.revisions && page.revisions[0] &&
                        page.revisions[0].slots && page.revisions[0].slots.main &&
                        page.revisions[0].slots.main.content;
                    if (!content) { teekaJsonCache[title] = null; return; }
                    try {
                        var parsed = JSON.parse(content);
                        parsed._title = title;
                        teekaJsonCache[title] = parsed;
                    } catch (e) { teekaJsonCache[title] = null; }
                });
             }).catch(function () {
                batch.forEach(function (t) { if (!(t in teekaJsonCache)) teekaJsonCache[t] = null; });
            });
         })).then(function () {
            // Anything the response never mentioned is recorded as absent,
            // so a later call does not ask for it again.
            want.forEach(function (t) { if (!(t in teekaJsonCache)) teekaJsonCache[t] = null; });
            return teekaJsonCache;
        });
     }
     }


Line 1,401: 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,
                     text: (b.lines || []).join(' '),
                    // join('\n') - each entry of `lines` is a printed line,
                    // and joining with a space ran a two-pada shloka into
                    // one. The .shloka-line markup inside already carries
                    // the break where the converter put one; this only
                    // matters for older documents that have none.
                     text: (b.lines || []).join('\n'),
                    footnotes: b.footnotes || [],
                     blockId: b.id
                     blockId: b.id
                 });
                 });
Line 1,428: 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);
                // A teeka's footnotes live in ITS document, not in this
                // page's own footnote list - so the highlighted words came
                // through the injection but nothing on this page knew what
                // they pointed at, and clicking them did nothing. Register
                // them here, under the commentary's title, for the note
                // card and the right-hand panel to read.
                if (entry.footnotes && entry.footnotes.length && window.grFootnoteRegistry) {
                    window.grFootnoteRegistry.add(entry.teekaSlug, entry.teekaTitle, entry.footnotes);
                }
                 $wrap.append($entry);
                 $wrap.append($entry);
             });
             });
             $verseEl.after($wrap);
             // After the bhashya, not between the verse and it. The moola
            // page prints verse then bhashya as one unit, and inserting
            // straight after the verse element split them apart, putting
            // the commentaries ahead of the bhashya they comment on.
            // The bhashya-collection for a verse carries id
            // "bhashya-<verse oldKey>"; where a page has none, the verse
            // itself is still the anchor.
            var anchor = $verseEl;
            var coll = document.getElementById('bhashya-' + verseId);
            if (coll && coll.compareDocumentPosition($verseEl[0]) & Node.DOCUMENT_POSITION_PRECEDING) {
                anchor = $(coll);
            } else {
                // Some pages emit the bhashya as a sibling block without
                // the collection wrapper - walk forward over any blocks
                // that belong to this same verse.
                var n = $verseEl[0].nextElementSibling;
                while (n && n.getAttribute &&
                      (n.getAttribute('data-parent-verse') === verseId ||
                        n.id === 'bhashya-' + verseId)) {
                    anchor = $(n);
                    n = n.nextElementSibling;
                }
            }
            anchor.after($wrap);
            // Tell the script switcher about the new text, so an interleaved
            // commentary is transliterated like the rest of the page instead
            // of staying in Devanagari.
            try {
                window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: $wrap[0] } }));
            } catch (e) { }
             injectedAny = true;
             injectedAny = true;
         });
         });
         return injectedAny;
         return injectedAny;
     }
     }
     function removeTeekaInMoola() { $('.gr-injected-teeka-group').remove(); }
     function removeTeekaInMoola() {
        $('.gr-injected-teeka-group').remove();
        if (window.grFootnoteRegistry) window.grFootnoteRegistry.clearInjected();
    }


     // CRITICAL FIX: confirmed directly this feature was corrupting saved
     // CRITICAL FIX: confirmed directly this feature was corrupting saved
Line 1,516: Line 1,872:


     // ── State ──────────────────────────────────────────────────────
     // ── State ──────────────────────────────────────────────────────
    // Not seeded from storage: every page load starts with all
    // commentaries on (see where availableList is filled). Unchecking one
    // still writes to storage, which keeps the control's own state
    // consistent within the page and leaves the value there for anything
    // else that reads it, but it is not what decides the initial view.
     var selectedTeekaSlugs = {};
     var selectedTeekaSlugs = {};
    try { JSON.parse(readLS(LS_TEEKA_KEY) || '[]').forEach(function (s) { selectedTeekaSlugs[s] = true; }); } catch (e) { }
     var teekaIndex = null;
     var teekaIndex = null;
     var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}]
     var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}]
Line 1,643: Line 2,003:
         findCommentarySubpages(rootTitle).then(function (subpages) {
         findCommentarySubpages(rootTitle).then(function (subpages) {
             if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
             if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
             $.when.apply($, subpages.map(fetchTeekaJson)).then(function () {
             fetchTeekaJsons(subpages).then(function (cache) {
                 var fetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
                 var fetched = subpages.map(function (t) { return cache[t] || null; });
                 // Pair title+doc together FIRST, then filter as one unit -
                 // Pair title+doc together FIRST, then filter as one unit -
                 // filtering `docs` separately from `subpages` and passing
                 // filtering `docs` separately from `subpages` and passing
Line 1,654: Line 2,014:
                         return !!p.doc;
                         return !!p.doc;
                     });
                     });
                // Reading order = the order the book prints the
                // commentaries in, which a document states as doc.order.
                // findCommentarySubpages gets its list from
                // action=query&list=allpages, and that is ALPHABETICAL BY
                // TITLE - so Vadiraja came before Vyasatirtha, and the
                // checkbox list, the colour slots and the interleaved
                // blocks all inherited that same wrong order, since every
                // one of them is derived from this array. Sorting here
                // fixes all three at once. A document with no doc.order
                // (anything published before this field existed) keeps its
                // existing relative position rather than jumping to the
                // front, so mixed pages stay stable.
                pairs = pairs.map(function (p, i) { return { p: p, i: i }; })
                    .sort(function (a, b) {
                        var ao = a.p.doc && a.p.doc.doc && a.p.doc.doc.order;
                        var bo = b.p.doc && b.p.doc.doc && b.p.doc.doc.order;
                        if (typeof ao === 'number' && typeof bo === 'number' && ao !== bo) return ao - bo;
                        if (typeof ao === 'number' && typeof bo !== 'number') return -1;
                        if (typeof bo === 'number' && typeof ao !== 'number') return 1;
                        return a.i - b.i;
                    })
                    .map(function (x) { return x.p; });
                 var topDocs = pairs.map(function (p) { return p.doc; });
                 var topDocs = pairs.map(function (p) { return p.doc; });


Line 1,683: Line 2,067:
                     // cancelled edit, without needing to re-fetch
                     // cancelled edit, without needing to re-fetch
                     // anything from the server.
                     // anything from the server.
                    // Every commentary is on at the start of EVERY page
                    // load, not only a reader's first visit. The moola is
                    // published together with its commentaries and the
                    // complete text is what a reader should land on; the
                    // checkboxes are for narrowing it down while reading,
                    // and that narrowing lasts for as long as the page is
                    // open rather than following the reader to the next one.
                    //
                    // This MUST come before augmentExistingTeekaLinks:
                    // that builds each inline checkbox with
                    // .prop('checked', !!selectedTeekaSlugs[slug]), reading
                    // the selection as it stands at that moment. Selecting
                    // afterwards left every box drawn unchecked while the
                    // commentaries themselves were shown - the state and
                    // the control disagreeing on screen.
                    availableList.forEach(function (it) {
                        selectedTeekaSlugs[it.slug] = true;
                        (it.subItems || []).forEach(function (sub) { selectedTeekaSlugs[sub.slug] = true; });
                    });
                     lastAugmentArgs = { titles: pairs.map(function (p) { return p.title; }), docs: topDocs };
                     lastAugmentArgs = { titles: pairs.map(function (p) { return p.title; }), docs: topDocs };
                     var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
                     var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
Line 1,694: Line 2,098:


                 if (!uniqueRelated.length) { finishInit({}); return; }
                 if (!uniqueRelated.length) { finishInit({}); return; }
                 $.when.apply($, uniqueRelated.map(fetchTeekaJson)).then(function () {
                 fetchTeekaJsons(uniqueRelated).then(function (cache) {
                    var subFetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
                     var subDocsBySlug = {};
                     var subDocsBySlug = {};
                     uniqueRelated.forEach(function (slug, i) {
                     uniqueRelated.forEach(function (slug) {
                         if (!subFetched[i]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug);
                         if (!cache[slug]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug);
                         subDocsBySlug[slug] = subFetched[i];
                         subDocsBySlug[slug] = cache[slug] || null;
                     });
                     });
                     finishInit(subDocsBySlug);
                     finishInit(subDocsBySlug);
Line 1,980: 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,065: 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,294: Line 2,717:
     if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
     if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
     else decorate();
     else decorate();
}());
// ── Footnote markers and their cards ────────────────────────────────
/* ── FOOTNOTE REGISTRY ────────────────────────────────────────────────
  Notes that belong to content injected into this page rather than baked
  into it - an interleaved commentary's own footnotes live in ITS
  document, so nothing on the moola page knew what its highlighted words
  pointed at. Anything registered here is visible to the note card and to
  the right-hand panel, grouped under the commentary it came from. */
window.grFootnoteRegistry = (function () {
    'use strict';
    var byId = {};            // gra-id -> {text, quote, type, group, groupTitle}
    var groups = [];          // [{slug, title}] in the order they were added
    var listeners = [];
    var pending = null;
    // Deferred by a tick on purpose. Notes are registered while a
    // commentary's block is still being built, BEFORE it is inserted into
    // the page - so a listener that re-scans the document synchronously
    // would run against a DOM that does not contain the highlighted words
    // yet, and find nothing to wire up. Coalescing to one call after the
    // current task also means a page with ten commentaries re-scans once
    // rather than ten times.
    function notify() {
        if (pending) return;
        pending = setTimeout(function () {
            pending = null;
            listeners.forEach(function (f) { try { f(); } catch (e) {} });
        }, 0);
    }
    return {
        add: function (slug, title, notes) {
            if (!groups.some(function (g) { return g.slug === slug; })) {
                groups.push({ slug: slug, title: title });
            }
            var added = false;
            (notes || []).forEach(function (n) {
                if (!n || !n.id || byId[n.id]) return;
                byId[n.id] = {
                    text: n.text || '', quote: n.quote || '',
                    type: n.kind || n.type || 'note',
                    group: slug, groupTitle: title
                };
                added = true;
            });
            if (added) notify();
        },
        get: function (id) { return byId[id] || null; },
        all: function () { return byId; },
        groups: function () { return groups.slice(); },
        isInjected: function (id) { return !!byId[id]; },
        clearInjected: function () { byId = {}; groups = []; notify(); },
        onChange: function (f) { listeners.push(f); }
    };
}());
/* QuickEdit already stores footnotes: it wraps the annotated words in
  <span class="gra-qe-footnote-highlight" data-gra-id="…"> and appends a
  <div class="gra-qe-footnotes"><ol> at the foot of the document, one
  <li data-gra-id="…"> per note carrying the quoted words and the note.
  That is the whole apparatus, but reading it meant scrolling to the end of
  a long document and scrolling back. This turns the same data into what
  Setutila shows: a small superscript after the annotated words, and a card
  at the point of reading. Nothing is stored differently and QuickEdit is
  untouched — the list is still written, still saved, and still printed; it
  is only taken off the screen, where the markers replace it.
  The note text is transliterated with the rest of the page: the card's body
  is tagged data-deva and announced through gr-new-content, which the script
  switcher already listens for. */
(function () {
    'use strict';
    var HL = '.gra-qe-footnote-highlight';
    var LIST = '.gra-qe-footnotes';
    // data-gra-type on the <li>. The names the converter writes, and the
    // ones a QuickEdit button could write later, land in the same map.
    var TYPES = {
        pathantara: 'पाठान्तरम्',  // a variant reading
        locus: 'प्रमाणम्',          // where a quotation is from — ऋ.सं.७.९९
        source: 'प्रमाणम्',
        note: 'टिप्पणी'            // an editorial remark
    };
    var card = null, openFor = null;
    function notesFromList() {
        var out = {};
        var reg = window.grFootnoteRegistry.all();
        Object.keys(reg).forEach(function (id) {
            out[id] = { text: reg[id].text, quote: reg[id].quote,
                        type: reg[id].type, injected: true,
                        groupTitle: reg[id].groupTitle };
        });
        document.querySelectorAll(LIST + ' .gra-qe-footnote-item').forEach(function (li) {
            var id = li.getAttribute('data-gra-id');
            if (!id) return;
            // An injected note is published into this same list so the
            // right-hand panel can see it, which means the list entry gets
            // read back here. Keep the registry's own record for those, or
            // the entry overwrites it, the `injected` flag is lost, and the
            // note is numbered as though it belonged to this document.
            if (out[id] && out[id].injected) return;
            var textEl = li.querySelector('.gra-qe-footnote-text');
            out[id] = {
                text: (textEl ? textEl.textContent : li.textContent).trim(),
                quote: (li.querySelector('.gra-qe-footnote-quote') || {}).textContent || '',
                type: li.getAttribute('data-gra-type') || 'note'
            };
        });
        return out;
    }
    function buildCard() {
        if (card) return card;
        card = document.createElement('div');
        card.className = 'gr-note-card';
        card.setAttribute('role', 'dialog');
        card.hidden = true;
        card.innerHTML =
            '<div class="gr-note-card-head">' +
            '<span class="gr-note-card-kind"></span>' +
            '<button type="button" class="gr-note-card-close" aria-label="Close">×</button>' +
            '</div><div class="gr-note-card-body"></div>';
        card.querySelector('.gr-note-card-close').addEventListener('click', close);
        card.addEventListener('mouseenter', function () { clearTimeout(hoverTimer); });
        card.addEventListener('mouseleave', function () { if (!COARSE) hoverClose(); });
        document.body.appendChild(card);
        return card;
    }
    function close() {
        if (!card || card.hidden) return;
        card.hidden = true;
        if (openFor) {
            openFor.setAttribute('aria-expanded', 'false');
            openFor.classList.remove('gr-note-marker-open');
            openFor.classList.remove('gr-note-hl-open');
        }
        openFor = null;
    }
    // Anchored under the marker, nudged back inside the viewport rather than
    // flipped: these sit in running text, and a card that jumps above the line
    // is harder to connect to the word it belongs to than one that shifts left.
    function place(marker) {
        var r = marker.getBoundingClientRect();
        var w = card.offsetWidth, h = card.offsetHeight;
        var pad = 10;
        var left = r.left + r.width / 2 - w / 2;
        left = Math.max(pad, Math.min(left, window.innerWidth - w - pad));
        var top = r.bottom + 8;
        if (top + h > window.innerHeight - pad && r.top - h - 8 > pad) top = r.top - h - 8;
        card.style.left = Math.round(left) + 'px';
        card.style.top = Math.round(top) + 'px';
    }
    function open(marker, note, num) {
        buildCard();
        if (openFor === marker && !card.hidden) { close(); return; }
        close();
        card.querySelector('.gr-note-card-kind').textContent =
            (TYPES[note.type] || TYPES.note) +
            (typeof num === 'number' ? '  [' + num + ']' : (num ? '  \u00b7  ' + num : ''));
        var body = card.querySelector('.gr-note-card-body');
        // A bare text node, deliberately. gr-new-content's handler wraps it,
        // records it as translatable and applies whatever script is showing —
        // tagging it here instead would make the handler skip it as already
        // done, and the note would sit in Devanagari on a Telugu page.
        body.textContent = note.text;
        card.hidden = false;
        place(marker);
        try {
            window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: body } }));
        } catch (e) { }
        marker.setAttribute('aria-expanded', 'true');
        // A commentary's note has no superscript, so the element the card is
        // anchored to IS the highlighted word. gr-note-marker-open paints a
        // solid maroon chip with white text - right for a small superscript,
        // and it made the word itself unreadable. The word gets its own,
        // quieter open state instead.
        marker.classList.add(marker.classList.contains('gr-note-marker')
            ? 'gr-note-marker-open' : 'gr-note-hl-open');
        openFor = marker;
    }
    // A coarse pointer has no hover, so there the card opens on tap. With a
    // mouse, hover shows the note and a click is left free for the reader's
    // own panel - which is what the marker is for on desktop.
    var COARSE = (window.matchMedia && window.matchMedia('(hover: none), (pointer: coarse)').matches)
        || ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
    var hoverTimer = null;
    function hoverOpen(m, note, num) {
        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(function () { open(m, note, num); }, 90);
    }
    function hoverClose() {
        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(function () {
            if (card && !card.matches(':hover')) close();
        }, 220);
    }
    function decorate() {
        var notes = notesFromList();
        var n = 0;
        document.querySelectorAll('#mw-content-text ' + HL).forEach(function (hl) {
            var id = hl.getAttribute('data-gra-id');
            var note = id && notes[id];
            if (!note || !note.text) return;          // orphan highlight: leave it alone
            // An interleaved commentary's notes get NO superscript. The
            // numbers would restart per commentary and interleave with the
            // moola's own, so "[3]" on the page would mean three different
            // notes depending on which block it sat in. The highlighted
            // words stay clickable, and the panel lists them by commentary.
            var injected = !!note.injected;
            var num = injected ? null : ++n;
            var m = null;
            if (!injected) {
                if (hl.nextElementSibling &&
                    hl.nextElementSibling.classList.contains('gr-note-marker')) {
                    m = hl.nextElementSibling;
                    m.textContent = '[' + num + ']';
                } else {
                    m = document.createElement('sup');
                    m.className = 'gr-note-marker';
                    m.textContent = '[' + num + ']';
                    m.setAttribute('role', 'button');
                    m.setAttribute('tabindex', '0');
                    m.setAttribute('aria-expanded', 'false');
                    hl.parentNode.insertBefore(m, hl.nextSibling);
                }
            }
            if (hl.dataset.grNoteWired) return;
            hl.dataset.grNoteWired = '1';
            var anchorEl = m || hl;
            var label = injected ? (note.groupTitle || '') : num;
            function show(e) { e.preventDefault(); e.stopPropagation(); open(anchorEl, note, label); }
            if (COARSE) {
                if (m) m.addEventListener('click', show);
                hl.addEventListener('click', show);
            } else {
                // Hover opens the card; the click is NOT swallowed, so the
                // annotation gadget still gets it and opens its Footnotes
                // panel - the behaviour that was lost when this handler
                // called stopPropagation on every click.
                [m, hl].forEach(function (el) {
                    if (!el) return;
                    el.addEventListener('mouseenter', function () { hoverOpen(anchorEl, note, label); });
                    el.addEventListener('mouseleave', hoverClose);
                });
                if (m) m.addEventListener('click', show);
            }
            if (m) {
                m.addEventListener('keydown', function (e) {
                    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(m, note, label); }
                });
            }
        });
        var list = document.querySelector(LIST);
        if (list) list.classList.toggle('gr-notes-listed', n > 0);
    }
    document.addEventListener('click', function (e) {
        if (!card || card.hidden) return;
        if (!card.contains(e.target)) close();
    });
    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
    window.addEventListener('resize', function () { if (openFor) place(openFor); });
    window.addEventListener('scroll', function () { if (openFor) place(openFor); }, true);
    // Commentaries are fetched and injected after the page has rendered, so
    // their notes arrive later than this module's first pass. Re-run when
    // the registry changes, and publish them into the page's own footnote
    // list so the right-hand panel - which reads that list, not this
    // module - shows them too, under the commentary each came from.
    function publishInjectedList() {
        var host = document.querySelector(LIST);
        if (!host) {
            var content = document.getElementById('mw-content-text');
            if (!content) return;
            host = document.createElement('div');
            host.className = 'gra-qe-footnotes';
            content.appendChild(host);
        }
        host.querySelectorAll('.gr-teeka-notes-group').forEach(function (g) { g.remove(); });
        var reg = window.grFootnoteRegistry.all();
        window.grFootnoteRegistry.groups().forEach(function (g) {
            var ids = Object.keys(reg).filter(function (id) { return reg[id].group === g.slug; });
            if (!ids.length) return;
            var wrap = document.createElement('div');
            wrap.className = 'gr-teeka-notes-group';
            wrap.setAttribute('data-teeka-slug', g.slug);
            var h = document.createElement('div');
            h.className = 'gr-teeka-notes-title';
            h.textContent = (window.grLibrary && window.grLibrary.labelFor(g.slug))
                            || g.title || g.slug;
            wrap.appendChild(h);
            var ol = document.createElement('ol');
            ids.forEach(function (id) {
                var n = reg[id];
                var li = document.createElement('li');
                li.className = 'gra-qe-footnote-item';
                li.setAttribute('data-gra-id', id);
                li.setAttribute('data-gra-type', n.type);
                if (n.quote) {
                    var q = document.createElement('span');
                    q.className = 'gra-qe-footnote-quote';
                    q.textContent = n.quote;
                    li.appendChild(q);
                }
                var t = document.createElement('span');
                t.className = 'gra-qe-footnote-text';
                t.textContent = n.text;
                li.appendChild(t);
                ol.appendChild(li);
            });
            wrap.appendChild(ol);
            host.appendChild(wrap);
        });
        try {
            window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: host } }));
        } catch (e) { }
    }
    window.grFootnoteRegistry.onChange(function () {
        publishInjectedList();
        decorate();
    });
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
    else decorate();
    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);
    });
}());
}());