MediaWiki:Common.js: Difference between revisions

No edit summary
No edit summary
Line 1,113: Line 1,113:
         return ap.replace('$1', title);
         return ap.replace('$1', title);
     }
     }
}());
/* ═══════════════════════════════════════════════════════════════
  Commentary interleaving — discovers a document's /Vyakhya or
  /Bhashya commentary subpages, lets a reader select which ones to
  show interleaved with the moola text, and augments any existing
  link to a commentary (e.g. a "वादावली" list) with its own inline
  checkbox. Exposes window.grTeekaInterleave so readerToolbar.js's
  "Commentaries" dropdown can stay in sync without duplicating this
  logic — same pattern as window.grHighlightTocForElement above.
  ═══════════════════════════════════════════════════════════════ */
(function () {
    var LS_TEEKA_KEY = 'grantha_reader_teeka_in_moola';
    // Distinct light background per commentary, for when multiple
    // different teekas are interleaved together and need to stay
    // visually distinguishable. Palette given directly for this exact
    // purpose. Transcribed from a screenshot, so noting honestly rather
    // than silently "cleaning up": #10/#11 both read as #FAFAD2, and
    // #1/#16 both read as #E8F5E9 - kept as given.
    var TEEKA_COLOR_PALETTE = [
        '#E8F5E9', '#F0F8FF', '#FDF5E6', '#FFF0F5', '#FFE4E1', '#FFEFD5', '#FFE5F6', '#F3E5FB',
        '#F3C5C5', '#FAFAD2', '#FAFAD2', '#E3E589', '#E6F5B6', '#E6C5C0', '#F5F5C0', '#E8F5E9',
        '#FEF5C8', '#F6FEC6', '#F6EECF', '#F5FEC3', '#F6CEF0', '#F6C500', '#F6CEC8', '#F6C6C0', '#EBFFC0'
    ];
    // Stable djb2-style hash - same teeka slug always maps to the SAME
    // color everywhere on the site (not re-randomized per page load or
    // fetch order) - verified directly: a reader learning "this shade =
    // भावप्रकाशः" needs that to hold site-wide.
    function teekaColorForSlug(slug) {
        var h = 5381;
        for (var i = 0; i < slug.length; i++) h = ((h * 33) ^ slug.charCodeAt(i)) >>> 0;
        return TEEKA_COLOR_PALETTE[h % TEEKA_COLOR_PALETTE.length];
    }
    function readLS(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
    function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
    function fetchAllSubpages(rootTitle) {
        var deferred = $.Deferred(), subpages = [], nsNum = mw.config.get('wgNamespaceNumber'), localName = mw.config.get('wgTitle');
        function ask(apfrom) {
            var params = { action: 'query', list: 'allpages', apprefix: localName + '/', apnamespace: nsNum, aplimit: 'max', format: 'json' };
            if (apfrom) params.apcontinue = apfrom;
            $.ajax({ url: mw.util.wikiScript('api'), data: params, dataType: 'json' }).then(function (data) {
                var pages = (data.query && data.query.allpages) || [];
                pages.forEach(function (p) { subpages.push(p.title); });
                if (data.continue && data.continue.apcontinue) ask(data.continue.apcontinue);
                else deferred.resolve(subpages);
            }).fail(function () { deferred.resolve(subpages); });
        }
        ask(null);
        return deferred.promise();
    }
    function fetchTeekaJson(title) {
        return $.ajax({
            url: mw.util.wikiScript('api'),
            data: { action: 'query', titles: 'JSON:' + title, prop: 'revisions', rvslots: 'main', rvprop: 'content', format: 'json', formatversion: 2 },
            dataType: 'json'
        }).then(function (data) {
            var pages = (data.query && data.query.pages) || [];
            var page = pages[0];
            if (!page || page.missing) return null;
            var content = page.revisions && page.revisions[0] && page.revisions[0].slots.main.content;
            if (!content) return null;
            try { var parsed = JSON.parse(content); parsed._title = title; return parsed; }
            catch (e) { return null; }
        }).catch(function () { return null; });
    }
    // Finds this document's commentary subpages - filters to the site's
    // own established commentary-path convention (Vyakhya/Bhashya are
    // shown; Moola/Ullekha are never commentary content).
    function findCommentarySubpages(rootTitle) {
        return $.Deferred(function (d) {
            fetchAllSubpages(rootTitle).then(function (all) {
                d.resolve(all.filter(function (t) {
                    return t !== rootTitle && (/\/vyakhya(\/|$)/i.test(t) || /\/bhashya(\/|$)/i.test(t));
                }));
            });
        }).promise();
    }
    // Builds moola-verse-id -> [{teekaTitle, teekaSlug, text, blockId}].
    // Handles TWO possible parent-linking shapes so this works across
    // every commentary document on the site, not just ones from any one
    // generation pipeline: (1) DIRECT - a type:"teeka" block's own
    // "parent" already IS the moola verse id; (2) TWO-HOP - it points to
    // a type:"verse" block WITHIN THE SAME teeka doc (a quoted-verse
    // reference), and THAT block's own parent is the moola verse id.
    // Deliberately skips anything whose parent is "UNVERIFIED" or
    // missing, rather than guessing.
    function buildTeekaInMoolaIndex(teekaDocs) {
        var index = {};
        teekaDocs.forEach(function (td) {
            if (!td || !td.blocks || !td.doc) return;
            var blockById = {};
            td.blocks.forEach(function (b) { blockById[b.id] = b; });
            td.blocks.forEach(function (b) {
                if (b.type !== 'teeka') return;
                var moolaId = null;
                if (b.parent && b.parent !== 'UNVERIFIED' && !blockById[b.parent]) {
                    moolaId = b.parent;
                } else {
                    var verseRef = blockById[b.parent];
                    if (verseRef && (verseRef.type === 'verse' || verseRef.type === 'verse-ref') && verseRef.parent && verseRef.parent !== 'UNVERIFIED') {
                        moolaId = verseRef.parent;
                    }
                }
                if (!moolaId) return;
                if (!index[moolaId]) index[moolaId] = [];
                index[moolaId].push({
                    teekaTitle: td.doc.title || td._title,
                    teekaSlug: td.doc.slug || td._title,
                    text: (b.lines || []).join(' '),
                    blockId: b.id
                });
            });
        });
        return index;
    }
    function injectTeekaInMoola(index) {
        var injectedAny = false;
        Object.keys(index).forEach(function (verseId) {
            var $verseEl = $(document.getElementById(verseId));
            if (!$verseEl.length) return;
            var entries = index[verseId];
            var $wrap = $('<div>').addClass('gr-injected-teeka-group');
            entries.forEach(function (entry) {
                var $entry = $('<div>').addClass('teeka-block')
                    .attr('data-teeka-slug', entry.teekaSlug)
                    .attr('data-teeka-block-id', entry.blockId)
                    .css('background', teekaColorForSlug(entry.teekaSlug));
                $('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry);
                $('<div>').addClass('teeka-body').html(entry.text).appendTo($entry);
                $wrap.append($entry);
            });
            $verseEl.after($wrap);
            injectedAny = true;
        });
        return injectedAny;
    }
    function removeTeekaInMoola() { $('.gr-injected-teeka-group').remove(); }
    function normalizeWikiPath(p) {
        try { p = decodeURIComponent(p); } catch (e) { }
        return p.replace(/_/g, ' ').replace(/\/+$/, '');
    }
    // ── State ──────────────────────────────────────────────────────
    var selectedTeekaSlugs = {};
    try { JSON.parse(readLS(LS_TEEKA_KEY) || '[]').forEach(function (s) { selectedTeekaSlugs[s] = true; }); } catch (e) { }
    var teekaIndex = null;
    var availableDocs = [];
    var checkboxesBySlug = {};
    var readyCallbacks = [];
    var changeCallbacks = [];
    var visibilityCallbacks = [];
    var isReady = false;
    var inlineListVisible = null; // null = no inline checkboxes exist at all on this page
    function registerCheckbox(slug, $cb) { (checkboxesBySlug[slug] = checkboxesBySlug[slug] || []).push($cb); }
    function renderInterleave() {
        removeTeekaInMoola();
        if (!teekaIndex || !Object.keys(selectedTeekaSlugs).length) return;
        var filtered = {};
        Object.keys(teekaIndex).forEach(function (verseId) {
            var entries = teekaIndex[verseId].filter(function (e) { return selectedTeekaSlugs[e.teekaSlug]; });
            if (entries.length) filtered[verseId] = entries;
        });
        var injectedAny = injectTeekaInMoola(filtered);
        if (!injectedAny) console.warn('[grTeekaInterleave] Selected commentaries loaded, but none of their verse ids matched any verse-block on THIS page.');
    }
    function toggleSlug(slug, checked) {
        if (checked) selectedTeekaSlugs[slug] = true; else delete selectedTeekaSlugs[slug];
        writeLS(LS_TEEKA_KEY, JSON.stringify(Object.keys(selectedTeekaSlugs)));
        (checkboxesBySlug[slug] || []).forEach(function ($cb) { $cb.prop('checked', checked); });
        renderInterleave();
        changeCallbacks.forEach(function (cb) { cb(Object.keys(selectedTeekaSlugs)); });
    }
    // Augments existing <a> links pointing to a known commentary subpage
    // with an inline checkbox, wherever the link already appears -
    // matched by real href rather than any assumed list markup, since
    // that markup isn't something this script controls.
    function augmentExistingTeekaLinks(subpageTitles, docs) {
        var slugByPath = {};
        subpageTitles.forEach(function (title, i) {
            var doc = docs[i];
            if (!doc) return;
            slugByPath[normalizeWikiPath(mw.util.getUrl(title))] = doc.doc.slug || doc._title;
        });
        var $contentLinks = $('#mw-content-text a[href], .mw-parser-output a[href]');
        var augmented = [];
        $contentLinks.each(function () {
            var href = this.getAttribute('href') || '';
            var path;
            try { path = normalizeWikiPath(new URL(href, window.location.href).pathname); }
            catch (e) { return; }
            var slug = slugByPath[path];
            if (!slug || this.closest('.gr-injected-teeka-group')) return;
            var $cb = $('<input>').attr({ type: 'checkbox' }).addClass('gr-teeka-inline-checkbox').prop('checked', !!selectedTeekaSlugs[slug]);
            $(this).before($cb, ' ');
            registerCheckbox(slug, $cb);
            $cb.on('change', function () { toggleSlug(slug, $cb.is(':checked')); });
            augmented.push({ slug: slug, el: $cb[0] });
        });
        var missedTitles = subpageTitles.filter(function (t, i) {
            var d = docs[i]; if (!d) return false;
            var s = d.doc.slug || d._title;
            return !augmented.some(function (a) { return a.slug === s; });
        });
        if (missedTitles.length) console.warn('[grTeekaInterleave] No matching link found on this page for: ' + missedTitles.join(', ') + ' - the toolbar dropdown still covers them.');
        return augmented;
    }
    // Tracks whether ANY inline checkbox is currently on-screen, and
    // notifies subscribers only when that overall state actually flips -
    // verified in isolation before wiring in here. Drives whether
    // readerToolbar.js's "Commentaries" control needs to be shown as a
    // fallback (inline list scrolled out of view) or can stay hidden
    // (reader can already see and use the inline checkboxes).
    function observeInlineVisibility(checkboxEls) {
        if (!checkboxEls.length || !window.IntersectionObserver) return;
        var visibleSet = new Set();
        var obs = new IntersectionObserver(function (entries) {
            entries.forEach(function (entry) {
                var id = entry.target.__grTeekaId;
                if (entry.isIntersecting) visibleSet.add(id); else visibleSet.delete(id);
            });
            var anyVisible = visibleSet.size > 0;
            if (anyVisible !== inlineListVisible) {
                inlineListVisible = anyVisible;
                visibilityCallbacks.forEach(function (cb) { cb(anyVisible); });
            }
        }, { rootMargin: '-60px 0px 0px 0px', threshold: 0 });
        checkboxEls.forEach(function (el, i) { el.__grTeekaId = 'cb' + i; obs.observe(el); });
    }
    function init() {
        var ns = mw.config.get('wgNamespaceNumber');
        var pageName = mw.config.get('wgPageName') || '';
        var isMainPg = (pageName === 'Main_Page' || pageName === 'Main Page');
        var isTeekaPage = !!document.querySelector('.gr-teeka-page[data-interleaved="1"], .gr-parent-ref-block');
        if (ns !== 0 || isMainPg || isTeekaPage) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
        var rootTitle = mw.config.get('wgTitle');
        findCommentarySubpages(rootTitle).then(function (subpages) {
            if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
            $.when.apply($, subpages.map(fetchTeekaJson)).then(function () {
                var fetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
                // Pair title+doc together FIRST, then filter as one unit -
                // filtering `docs` separately from `subpages` and passing
                // both (now misaligned) arrays elsewhere was a confirmed
                // real bug in an earlier version of this feature.
                var pairs = subpages.map(function (title, i) { return { title: title, doc: fetched[i] }; })
                    .filter(function (p) {
                        if (!p.doc) console.warn('[grTeekaInterleave] Could not load JSON:' + p.title);
                        return !!p.doc;
                    });
                var docs = pairs.map(function (p) { return p.doc; });
                teekaIndex = buildTeekaInMoolaIndex(docs);
                availableDocs = docs;
                var augmented = augmentExistingTeekaLinks(pairs.map(function (p) { return p.title; }), docs);
                if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
                else inlineListVisible = false; // no inline checkboxes exist - toolbar control should always show as the only access point
                if (Object.keys(selectedTeekaSlugs).length) renderInterleave();
                isReady = true;
                readyCallbacks.forEach(function (cb) { cb(docs.map(function (d) { return { slug: d.doc.slug || d._title, title: d.doc.title || d._title }; })); });
                if (inlineListVisible !== null) visibilityCallbacks.forEach(function (cb) { cb(inlineListVisible); });
            });
        });
    }
    window.grTeekaInterleave = {
        onReady: function (cb) { if (isReady) cb(availableDocs.map(function (d) { return { slug: d.doc.slug || d._title, title: d.doc.title || d._title }; })); else readyCallbacks.push(cb); },
        onChange: function (cb) { changeCallbacks.push(cb); },
        onVisibilityChange: function (cb) { visibilityCallbacks.push(cb); if (inlineListVisible !== null) cb(inlineListVisible); },
        getSelected: function () { return Object.keys(selectedTeekaSlugs); },
        toggle: function (slug, checked) { toggleSlug(slug, checked); },
        registerCheckbox: registerCheckbox
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
}());
}());