MediaWiki:Common.js: Difference between revisions
No edit summary Tag: Reverted |
No edit summary |
||
| (58 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* MediaWiki:Common.js | /* MediaWiki:Common.js */ | ||
( function () { | (function () { | ||
var LS_SCRIPT_KEY = 'grantha_reader_script'; | |||
var currentScript = 'deva'; | |||
function devanagariToTamil(text) { | |||
var CONSONANTS = { | |||
'क': 'க', 'ख': 'க', 'ग': 'க', 'घ': 'க', 'ङ': 'ங', | |||
'च': 'ச', 'छ': 'ச', 'ज': 'ஜ', 'झ': 'ஜ', 'ञ': 'ஞ', | |||
'ट': 'ட', 'ठ': 'ட', 'ड': 'ட', 'ढ': 'ட', 'ण': 'ண', | |||
'त': 'த', 'थ': 'த', 'द': 'த', 'ध': 'த', 'न': 'ந', | |||
'प': 'ப', 'फ': 'ப', 'ब': 'ப', 'भ': 'ப', 'म': 'ம', | |||
'य': 'ய', 'र': 'ர', 'ल': 'ல', 'ळ': 'ள', 'व': 'வ', | |||
'श': 'ஶ', 'ष': 'ஷ', 'स': 'ஸ', 'ह': 'ஹ' | |||
}; | |||
var DIACRITICS = { | |||
'ा': 'ா', 'ि': 'ி', 'ी': 'ீ', 'ु': 'ு', 'ू': 'ூ', | |||
'े': 'ே', 'ै': 'ை', 'ो': 'ோ', 'ौ': 'ௌ' | |||
}; | |||
var VOWELS = { | |||
'अ': 'அ', 'आ': 'ஆ', 'इ': 'இ', 'ई': 'ஈ', 'उ': 'உ', 'ऊ': 'ஊ', | |||
'ए': 'ஏ', 'ऐ': 'ஐ', 'ओ': 'ஓ', 'औ': 'ஔ', 'ऽ': 'ऽ' | |||
}; | |||
var MISC = { | |||
'ं':'ம்', 'ः':':', 'ँ':'ம்', 'ॐ':'ௐ', | |||
'०': '0', '१': '1', '२': '2', '३': '3', '४': '4', | |||
'५': '5', '६': '6', '७': '7', '८': '8', '९': '9' | |||
}; | |||
var HALANTA = '्'; | |||
var chars = Array.from(text), result = '', i = 0; | |||
while (i < chars.length) { | |||
var ch = chars[i], next = chars[i + 1]; | |||
if (CONSONANTS[ch]) { | |||
var base = CONSONANTS[ch]; | |||
if (next === HALANTA) { result += base + '்'; i += 2; } | |||
else if (next === 'ृ') { result += base + '்ரி'; i += 2; } | |||
else if (next === 'ॄ') { result += base + '்ரீ'; i += 2; } | |||
else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; } | |||
else { result += base; i++; } // inherent 'a' | |||
} | |||
else if (ch === 'ऋ') { result += 'ரி'; i++; } | |||
else if (ch === 'ॠ') { result += 'ரீ'; i++; } | |||
else if (VOWELS[ch]) { result += VOWELS[ch]; i++; } | |||
else if (MISC[ch]) { result += MISC[ch]; i++; } | |||
else { result += ch; i++; } | |||
} | |||
return result; | |||
} | } | ||
function devanagariToIAST(text) { | |||
var CONSONANTS = { | |||
'क': 'k', 'ख': 'kh', 'ग': 'g', 'घ': 'gh', 'ङ': 'ṅ', | |||
'च': 'c', 'छ': 'ch', 'ज': 'j', 'झ': 'jh', 'ञ': 'ñ', | |||
'ट': 'ṭ', 'ठ': 'ṭh', 'ड': 'ḍ', 'ढ': 'ḍh', 'ण': 'ṇ', | |||
'त': 't', 'थ': 'th', 'द': 'd', 'ध': 'dh', 'न': 'n', | |||
'प': 'p', 'फ': 'ph', 'ब': 'b', 'भ': 'bh', 'म': 'm', | |||
'य': 'y', 'र': 'r', 'ल': 'l', 'ळ': 'ḷ', 'व': 'v', | |||
'श': 'ś', 'ष': 'ṣ', 'स': 's', 'ह': 'h' | |||
}; | |||
var DIACRITICS = { | |||
'ा': 'ā', 'ि': 'i', 'ी': 'ī', 'ु': 'u', 'ू': 'ū', | |||
'ृ': 'ṛ', 'ॄ': 'ṝ', 'े': 'e', 'ै': 'ai', 'ो': 'o', 'ौ': 'au' | |||
}; | |||
var VOWELS = { | |||
'अ': 'a', 'आ': 'ā', 'इ': 'i', 'ई': 'ī', 'उ': 'u', 'ऊ': 'ū', | |||
'ऋ': 'ṛ', 'ॠ': 'ṝ', 'ए': 'e', 'ऐ': 'ai', 'ओ': 'o', 'औ': 'au', 'ऽ': "'" | |||
}; | |||
var MISC = { | |||
'ं': 'ṃ', 'ः': 'ḥ', 'ँ': 'm̐', 'ॐ': 'oṃ', | |||
'०': '0', '१': '1', '२': '2', '३': '3', '४': '4', | |||
'५': '5', '६': '6', '७': '7', '८': '8', '९': '9' | |||
}; | |||
var HALANTA = '्'; | |||
var chars = Array.from(text), result = '', i = 0; | |||
while (i < chars.length) { | |||
var ch = chars[i], next = chars[i + 1]; | |||
if (CONSONANTS[ch]) { | |||
var base = CONSONANTS[ch]; | |||
if (next === HALANTA) { result += base; i += 2; } | |||
else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; } | |||
else if (next === 'ं' || next === 'ः') { result += base + 'a' + MISC[next]; i += 2; } | |||
else { result += base + 'a'; i++; } | |||
} | |||
else if (VOWELS[ch]) { result += VOWELS[ch]; i++; } | |||
else if (DIACRITICS[ch]) { result += DIACRITICS[ch]; i++; } | |||
else if (MISC[ch]) { result += MISC[ch]; i++; } | |||
else { result += ch; i++; } | |||
} | |||
return result; | |||
} | } | ||
var SCRIPT_MAP = { | |||
kn: { | |||
'अ': 'ಅ', 'आ': 'ಆ', 'इ': 'ಇ', 'ई': 'ಈ', 'उ': 'ಉ', 'ऊ': 'ಊ', 'ऋ': 'ಋ', 'ॠ': 'ೠ', | |||
'ए': 'ಏ', 'ऐ': 'ಐ', 'ओ': 'ಓ', 'औ': 'ಔ', 'ऽ': 'ಽ', | |||
'क': 'ಕ', 'ख': 'ಖ', 'ग': 'ಗ', 'घ': 'ಘ', 'ङ': 'ಙ', | |||
'च': 'ಚ', 'छ': 'ಛ', 'ज': 'ಜ', 'झ': 'ಝ', 'ञ': 'ಞ', | |||
'ट': 'ಟ', 'ठ': 'ಠ', 'ड': 'ಡ', 'ढ': 'ಢ', 'ण': 'ಣ', | |||
'त': 'ತ', 'थ': 'ಥ', 'द': 'ದ', 'ध': 'ಧ', 'न': 'ನ', | |||
'प': 'ಪ', 'फ': 'ಫ', 'ब': 'ಬ', 'भ': 'ಭ', 'म': 'ಮ', | |||
'य': 'ಯ', 'र': 'ರ', 'ल': 'ಲ', 'ळ': 'ಳ', 'व': 'ವ', | |||
'श': 'ಶ', 'ष': 'ಷ', 'स': 'ಸ', 'ह': 'ಹ', | |||
'ा': 'ಾ', 'ि': 'ಿ', 'ी': 'ೀ', 'ु': 'ು', 'ू': 'ೂ', | |||
'ृ': 'ೃ', 'ॄ': 'ೄ', 'े': 'ೇ', 'ै': 'ೈ', 'ो': 'ೋ', 'ौ': 'ೌ', | |||
'ं': 'ಂ', 'ः': 'ಃ', '्': '್', 'ँ': 'ಁ', | |||
'०': '೦', '१': '೧', '२': '೨', '३': '೩', '४': '೪', | |||
'५': '೫', '६': '೬', '७': '೭', '८': '೮', '९': '೯' | |||
}, | |||
te: { | |||
'अ': 'అ', 'आ': 'ఆ', 'इ': 'ఇ', 'ई': 'ఈ', 'उ': 'ఉ', 'ऊ': 'ఊ', 'ऋ': 'ఋ', 'ॠ': 'ౠ', | |||
'ए': 'ఏ', 'ऐ': 'ఐ', 'ओ': 'ఓ', 'औ': 'ఔ', 'ऽ': 'ఽ', | |||
'क': 'క', 'ख': 'ఖ', 'ग': 'గ', 'घ': 'ఘ', 'ङ': 'ఙ', | |||
'च': 'చ', 'छ': 'ఛ', 'ज': 'జ', 'झ': 'ఝ', 'ञ': 'ఞ', | |||
'ट': 'ట', 'ठ': 'ఠ', 'ड': 'డ', 'ढ': 'ఢ', 'ण': 'ణ', | |||
'त': 'త', 'थ': 'థ', 'द': 'ద', 'ध': 'ధ', 'न': 'న', | |||
'प': 'ప', 'फ': 'ఫ', 'ब': 'బ', 'भ': 'భ', 'म': 'మ', | |||
'य': 'య', 'र': 'ర', 'ल': 'ల', 'ळ': 'ళ', 'व': 'వ', | |||
'श': 'శ', 'ष': 'ష', 'स': 'స', 'ह': 'హ', | |||
'ा': 'ా', 'ि': 'ి', 'ी': 'ీ', 'ु': 'ు', 'ू': 'ూ', | |||
'ृ': 'ృ', 'ॄ': 'ౄ', 'े': 'ే', 'ै': 'ై', 'ो': 'ో', 'ौ': 'ౌ', | |||
'ं': 'ం', 'ः': 'ః', '्': '్', 'ँ': 'ఁ', | |||
'०': '౦', '१': '౧', '२': '౨', '३': '౩', '४': '౪', | |||
'५': '౫', '६': '౬', '७': '౭', '८': '౮', '९': '౯' | |||
} | |||
}; | |||
var PRE = [ | |||
[/ङ्क/g, 'ंक'], [/ङ्ख/g, 'ंख'], [/ङ्ग/g, 'ंग'], [/ङ्घ/g, 'ंघ'], | |||
[/ञ्च/g, 'ंच'], [/ञ्ज/g, 'ंज'], [/ण्ट/g, 'ंट'], [/ण्ड/g, 'ंड'], | |||
[/न्त/g, 'ंत'], [/न्द/g, 'ंद'], [/म्ब/g, 'ंब'], [/म्भ/g, 'ंभ'] | |||
]; | |||
function transliterateText(text, script) { | |||
if (script === 'en') return devanagariToIAST(text); | |||
if (script === 'ta') return devanagariToTamil(text); | |||
var map = SCRIPT_MAP[script]; | |||
if (!map) return text; | |||
var t = text; | |||
PRE.forEach(function (p) { t = t.replace(p[0], p[1]); }); | |||
return Array.from(t).map(function (ch) { | |||
return map[ch] !== undefined ? map[ch] : ch; | |||
}).join(''); | |||
} | |||
var translatableSpans = []; | |||
var | |||
function tagTextNodes() { | |||
var content = document.querySelector('.mw-parser-output'); | |||
if (content) { | |||
var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); | |||
var nodes = []; | |||
while (walker.nextNode()) nodes.push(walker.currentNode); | |||
nodes.forEach(function (node) { | |||
var p = node.parentNode; | |||
if (!p) return; | |||
if (p.hasAttribute && p.hasAttribute('data-deva')) return; | |||
if (p.closest) { | |||
if (p.closest('.gr-controls')) 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; | |||
if (!orig.trim()) return; | |||
var span = document.createElement('span'); | |||
span.setAttribute('data-deva', orig); | |||
span.textContent = orig; | |||
p.replaceChild(span, node); | |||
translatableSpans.push(span); | |||
}); | |||
} | |||
document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry); | |||
} | |||
// A contents entry is "1.2 शीर्षकम्": the number lives in its own | |||
// .vector-toc-numb span, which Common.css hides. Tagging the whole | |||
// .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 = | var orig = label.textContent; | ||
if ( !orig.trim() ) return; | 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) { | |||
currentScript = script; | |||
markScriptOnRoot(script); | |||
translatableSpans.forEach(function (span) { | |||
if (!span.parentNode) return; | |||
var orig = span.getAttribute('data-deva'); | |||
if (!orig) return; | |||
span.textContent = (script === 'deva') | |||
? orig | |||
: transliterateText(orig, script); | |||
}); | |||
} | |||
function _isNoTocPage() { | |||
var pn = (window.mw && mw.config && mw.config.get('wgPageName')) || ''; | |||
return pn === 'Main_Page' || /^[A-Za-z0-9_]+:About$/.test(pn); | |||
} | } | ||
function renameTocTitle() { | |||
if (_isNoTocPage()) return; | |||
var toc = document.querySelector('.vector-toc'); | |||
if (!toc) return; | |||
var titleEl = toc.querySelector('.vector-toc-title') || toc.querySelector('.vector-pinnable-header-label'); | |||
if (!titleEl) return; | |||
var LABEL = 'विषयसूची'; | |||
var span = titleEl.querySelector('.gr-toc-title'); | |||
var | if (!span) { | ||
if ( | titleEl.innerHTML = ''; | ||
span = document.createElement('span'); | |||
span.className = 'gr-toc-title'; | |||
span.setAttribute('data-deva', LABEL); | |||
titleEl.appendChild(span); | |||
translatableSpans.push(span); | |||
} | } | ||
span.textContent = currentScript === 'deva' ? LABEL : transliterateText(LABEL, currentScript); | |||
} | } | ||
function removeTocBeginning() { | |||
if (_isNoTocPage()) return; | |||
var toc = document.querySelector('.vector-toc'); | |||
if (!toc) return; | |||
var el = toc.querySelector('#vector-toc-beginning'); | |||
if (!el) { | |||
var items = toc.querySelectorAll('.vector-toc-list-item'); | |||
for (var i = 0; i < items.length; i++) { | |||
var a = items[i].querySelector('a'); | |||
if (a) { | |||
var href = a.getAttribute('href') || ''; | |||
if (href.indexOf('#') === -1) { el = items[i]; break; } | |||
} | |||
} | |||
} | |||
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 | |||
var | // 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 }); | |||
} | |||
function expandTocSections() { | |||
if (_isNoTocPage()) return; | |||
var toc = document.querySelector('.vector-toc'); | |||
if (!toc) return; | |||
toc.querySelectorAll('.vector-toc-list-item-collapsed').forEach(function (li) { | |||
li.classList.remove('vector-toc-list-item-collapsed'); | |||
}); | |||
} | } | ||
function | |||
function injectTocDocNav() { | |||
if (_isNoTocPage()) return; | |||
var toc = document.querySelector('.vector-toc'); | |||
if (!toc) return; | |||
if (document.getElementById('gr-toc-doc-nav')) return; | |||
var artPath = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1'; | |||
var pageTitle = (window.mw && mw.config && mw.config.get('wgPageName')) || ''; | |||
var teekaPage = document.querySelector('.gr-teeka-page'); | |||
var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || '') : ''; | |||
var | if (!primarySlug) { primarySlug = pageTitle.split('/')[0]; } | ||
if (!primarySlug) return; | |||
function wikiUrl(slug) { | |||
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug); | |||
return artPath.replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/')); | |||
} | } | ||
var docTitleEl = document.querySelector('.gr-doc-title'); | |||
var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1'; | |||
var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1'; | |||
var showMoolaPage = !teekaPage && hasMoolaPage; | |||
var showMoolaBack = !!teekaPage; | |||
var showUllekha = hasUllekhaPage || !!teekaPage; | |||
if (!showMoolaPage && !showMoolaBack && !showUllekha) return; | |||
var nav = document.createElement('div'); | |||
nav.id = 'gr-toc-doc-nav'; | |||
nav.setAttribute('class', 'toc-main-links'); | |||
function makeBtn(href, label) { | |||
var a = document.createElement('a'); | |||
a.href = href; | |||
a.setAttribute('class', 'toc-main-link-item'); | |||
var lspan = document.createElement('span'); | |||
lspan.setAttribute('data-deva', label); | |||
lspan.textContent = (currentScript && currentScript !== 'deva') ? transliterateText(label, currentScript) : label; | |||
translatableSpans.push(lspan); | |||
a.appendChild(lspan); | |||
return a; | |||
} | } | ||
if (showMoolaPage) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Moola'), 'मूलम्')); | |||
if (showMoolaBack) nav.appendChild(makeBtn(wikiUrl(primarySlug), 'मूल')); | |||
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'); | |||
if (tocContents) toc.insertBefore(nav, tocContents); | |||
else toc.appendChild(nav); | |||
} | } | ||
var _headingObserver = null; | |||
var _tocActiveId = null; | |||
var | |||
// NEW: moved out of attachHeadingObserver's own local scope so they | |||
// can be reused both by the scroll observer AND by a direct trigger | |||
function | // for search-result clicks — was previously only reachable from | ||
// inside attachHeadingObserver's own IntersectionObserver callback. | |||
// _tocActiveId (was _activeId, local to attachHeadingObserver) is | |||
// module-level now for the same reason — both callers need to share | |||
// the same "what's currently active" state, not track it separately. | |||
function getTocLink(id) { | |||
var toc = document.querySelector('.vector-toc'); | |||
if (!toc) return null; | |||
return toc.querySelector('a[href="#' + CSS.escape(id) + '"]'); | |||
} | } | ||
function getTocLi(id) { | |||
var a = getTocLink(id); | |||
return a ? a.closest('.vector-toc-list-item') : null; | |||
} | } | ||
var | // Highlighting is class-only (styled in Common.css §6): | ||
// .gr-toc-current — the one entry being read, on the <a> itself | |||
// .gr-toc-in-path — its parent sections, marked on the <li> | |||
// Previously this set inline colours on the active item AND on every | |||
// element inside it, so a section with sub-sections lit up whole. | |||
var | function clearActiveToc() { | ||
var toc = document.querySelector('.vector-toc'); | |||
if (!toc) return; | |||
toc.querySelectorAll('.gr-toc-current').forEach(function (el) { el.classList.remove('gr-toc-current'); }); | |||
toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); }); | |||
} | |||
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'); | |||
if (!toc) return; | |||
var shownNow = toc.querySelector('.gr-toc-current'); | |||
if (_tocActiveId === id && !expand && shownNow && isShown(shownNow)) return; | |||
_tocActiveId = id; | |||
clearActiveToc(); | |||
if (!id) return; | |||
var li = getTocLi(id); | |||
if (!li) return; | |||
if (expand) expandTocFor(li); | |||
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'); | |||
// Sections above it get the quieter "you are in here" treatment. | |||
var anc = hostLi.parentNode; | |||
while (anc && anc !== toc) { | |||
if (anc.classList && anc.classList.contains('vector-toc-list-item')) anc.classList.add('gr-toc-in-path'); | |||
anc = anc.parentNode; | |||
} | |||
keepTocEntryVisible(lnk || hostLi); | |||
} | |||
// Scroll the TOC panel so the active entry is visible. This used to | |||
// measure the whole <li>, which for a section with sub-sections is as | |||
// tall as the entire branch — so "centre it" landed somewhere inside | |||
// 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); | |||
} | |||
// NEW: given any element in the article content, finds the nearest | |||
// heading (with an id) that precedes it in document order, using | |||
// compareDocumentPosition — robust regardless of nesting depth. | |||
// Verified against a realistic multi-section fragment, including | |||
// the "before any heading" and "right after a chapter heading, no | |||
// sub-heading yet" edge cases, before wiring this in. | |||
function findNearestHeadingId(target) { | |||
var content = document.querySelector('.mw-parser-output'); | |||
if (!content) return null; | |||
var headings = content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]'); | |||
var nearest = null; | |||
for (var i = 0; i < headings.length; i++) { | |||
var h = headings[i]; | |||
var pos = h.compareDocumentPosition(target); | |||
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) { | |||
nearest = h.id; | |||
} else { | |||
break; | |||
} | |||
} | |||
return nearest; | |||
} | |||
// NEW: exposed so the search-highlight IIFE (below) can directly | |||
// trigger the TOC to expand and highlight the right section right | |||
// after a search-result scroll lands — rather than relying solely on | |||
// the IntersectionObserver to catch up on its own timing. | |||
window.grHighlightTocForElement = function (el) { | |||
if (!el) return; | |||
var id = findNearestHeadingId(el); | |||
if (id) pinActive(id); | |||
}; | |||
// ── Which section am I reading? ──────────────────────────────── | |||
// Was an IntersectionObserver with a shrunken root band. Two faults | |||
// came out of that, both reported as "the highlight is wrong": | |||
// • right after jumping to a heading, that heading sits ABOVE the | |||
// band, so nothing was "visible" and the fallback picked the | |||
// PREVIOUS heading — clicking a sub-section highlighted its parent; | |||
// • once every heading was outside the band, no intersection ever | |||
// changed again, so the observer stopped firing and the highlight | |||
// froze wherever it was. | |||
// This measures instead: the active section is the last heading above | |||
// a reading line just under the sticky toolbar. It updates on every | |||
// scroll frame and can't go stale. | |||
var _tocHeadings = []; | |||
var _tocTops = []; | |||
var _tocMeasuredHeight = 0; | |||
// A clicked / jumped-to heading is "pinned": it stays the active entry | |||
// until the reader actually scrolls away from it. Without this, the | |||
// anchor jump leaves the heading below the reading line (the sticky | |||
// bar and scroll-margin push it down the viewport), so the measurement | |||
// picked the heading ABOVE it and the highlight jumped back one. | |||
var _pin = null; // {id, y, until} | |||
function measureHeadings() { | |||
var y = window.pageYOffset || document.documentElement.scrollTop || 0; | |||
_tocTops = _tocHeadings.map(function (h) { return h.getBoundingClientRect().top + y; }); | |||
_tocMeasuredHeight = document.documentElement.scrollHeight; | |||
} | |||
function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; } | |||
// The line down the page that decides "which section am I reading": | |||
// just below whatever is stuck to the top, plus a quarter of the | |||
// screen, so a heading sitting near the top of the viewport counts as | |||
// the one being read rather than the one before it. | |||
function readingLine() { | |||
var bar = document.getElementById('gr-static-bar'); | |||
var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0; | |||
var vh = window.innerHeight || document.documentElement.clientHeight || 800; | |||
return scrollY() + barBottom + Math.max(Math.round(vh * 0.25), 80); | |||
} | |||
// Scroll something into a comfortable reading position and KEEP it | |||
// there. A plain scrollIntoView lands short or long whenever the page | |||
// reflows just after the jump — a web font finishing, a commentary | |||
// being interleaved, the match itself being wrapped in a highlight — | |||
// which is why search results came to rest slightly above or below | |||
// the text. This aims just under whatever is stuck to the top, then | |||
// re-checks until the page has settled, and gives up the moment the | |||
// reader scrolls, so it never fights them. | |||
function grDesiredTop() { | |||
var bar = document.getElementById('gr-static-bar'); | |||
var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0; | |||
var vh = window.innerHeight || document.documentElement.clientHeight || 800; | |||
return barBottom + Math.max(Math.round(vh * 0.22), 72); | |||
} | |||
window.grScrollToElement = function (el) { | |||
if (!el || !el.getBoundingClientRect) return; | |||
var cancelled = false, ticks = 0, lastY = null, iv = null; | |||
function onUser() { cancelled = true; } | |||
function cleanup() { | |||
if (iv) clearInterval(iv); | |||
window.removeEventListener('wheel', onUser); | |||
window.removeEventListener('touchstart', onUser); | |||
window.removeEventListener('keydown', onUser); | |||
} | |||
window.addEventListener('wheel', onUser, { passive: true }); | |||
window.addEventListener('touchstart', onUser, { passive: true }); | |||
window.addEventListener('keydown', onUser); | |||
function delta() { return el.getBoundingClientRect().top - grDesiredTop(); } | |||
try { window.scrollBy({ top: delta(), left: 0, behavior: 'smooth' }); } | |||
catch (e) { window.scrollBy(0, delta()); } | |||
iv = setInterval(function () { | |||
ticks++; | |||
if (cancelled || ticks > 12 || !document.body.contains(el)) { cleanup(); return; } | |||
var y = scrollY(); | |||
if (lastY !== null && Math.abs(y - lastY) < 1) { // the scroll has come to rest | |||
var d = delta(); | |||
if (Math.abs(d) > 6) window.scrollBy(0, d); // reflow moved it: correct at once | |||
else { cleanup(); return; } | |||
} | |||
lastY = y; | |||
}, 200); | |||
}; | }; | ||
// Called when the reader picks a heading (TOC click, search result, | |||
// or a #hash in the URL) — that heading wins until they scroll on. | |||
function pinActive(id) { | |||
if (!id) return; | |||
var | _pin = { id: id, y: scrollY(), until: Date.now() + 900 }; | ||
setActiveToc(id, true); // open its section if collapsed | |||
scheduleRecheck(950); | |||
} | |||
var _recheck = null; | |||
function scheduleRecheck(ms) { | |||
if (_recheck) return; | |||
_recheck = setTimeout(function () { _recheck = null; updateActiveFromScroll(); }, ms); | |||
} | |||
function updateActiveFromScroll() { | |||
if (!_tocHeadings.length) return; | |||
if (_pin) { | |||
var y = scrollY(); | |||
if (Date.now() < _pin.until) { // still settling after the jump | |||
_pin.y = y; | |||
setActiveToc(_pin.id, true); | |||
scheduleRecheck(_pin.until - Date.now() + 30); | |||
return; | |||
} | |||
if (Math.abs(y - _pin.y) < 60) { // reader hasn't moved off it | |||
setActiveToc(_pin.id); | |||
return; | |||
} | |||
_pin = null; // they've scrolled on | |||
} | |||
// Interleaving a commentary or changing the font size moves things; | |||
// re-measure when the page's height has changed. | |||
if (document.documentElement.scrollHeight !== _tocMeasuredHeight) measureHeadings(); | |||
var line = readingLine(), lo = 0, hi = _tocTops.length - 1, idx = 0; | |||
while (lo <= hi) { // last heading at or above the line | |||
var mid = (lo + hi) >> 1; | |||
if (_tocTops[mid] <= line + 1) { idx = mid; lo = mid + 1; } else { hi = mid - 1; } | |||
} | } | ||
setActiveToc(_tocHeadings[idx].id); | |||
} | |||
function attachHeadingObserver() { | |||
if (_isNoTocPage()) return; | |||
if (_headingObserver) return; | |||
var content = document.querySelector('.mw-parser-output'); | |||
if (!content) return; | |||
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; | |||
measureHeadings(); | |||
// Time-based throttle rather than a requestAnimationFrame latch: if | |||
// one frame callback is ever skipped (background tab, a jump during | |||
// load) a latch stays stuck and the highlight stops updating for | |||
// good — which is one of the ways this went stale before. | |||
var lastRun = 0, trailing = null; | |||
function onScroll() { | |||
var now = Date.now(); | |||
if (now - lastRun >= 80) { lastRun = now; updateActiveFromScroll(); } | |||
else if (!trailing) { | |||
trailing = setTimeout(function () { trailing = null; lastRun = Date.now(); updateActiveFromScroll(); }, 80); | |||
} | |||
} | |||
window.addEventListener('scroll', onScroll, { passive: true }); | |||
window.addEventListener('resize', function () { measureHeadings(); onScroll(); }, { passive: true }); | |||
if (document.fonts && document.fonts.ready && document.fonts.ready.then) { | |||
document.fonts.ready.then(function () { measureHeadings(); onScroll(); }); | |||
} | |||
_headingObserver = { onScroll: onScroll }; // marks "already wired" | |||
var hash = ''; | |||
try { hash = decodeURIComponent((location.hash || '').slice(1)); } catch (e) { hash = (location.hash || '').slice(1); } | |||
if (hash && document.getElementById(hash)) pinActive(hash); | |||
else updateActiveFromScroll(); | |||
} | |||
}() ); | // Clicking an entry highlights it straight away, rather than waiting | ||
// for the scroll observer to catch up after the jump. | |||
function attachTocClickHandler(toc) { | |||
if (toc.getAttribute('data-gr-click-wired')) return; | |||
toc.setAttribute('data-gr-click-wired', '1'); | |||
toc.addEventListener('click', function (e) { | |||
var a = e.target.closest ? e.target.closest('.vector-toc-link') : null; | |||
if (!a) return; | |||
var href = a.getAttribute('href') || ''; | |||
if (href.charAt(0) !== '#') return; | |||
var id; | |||
try { id = decodeURIComponent(href.slice(1)); } catch (err) { id = href.slice(1); } | |||
if (id) pinActive(id); | |||
}); | |||
} | |||
function setupToc() { | |||
if (_isNoTocPage()) return; | |||
var toc = document.querySelector('.vector-toc'); | |||
if (!toc) return; | |||
removeTocBeginning(); | |||
removeTocTeekaEntry(); | |||
stripTocNumbers(); | |||
watchTocNumbers(); | |||
renameTocTitle(); | |||
expandTocSections(); | |||
injectTocDocNav(); | |||
attachTocClickHandler(toc); | |||
attachHeadingObserver(); | |||
} | |||
function init() { | |||
( function () { | var HIDE_IDS = ['vector-appearance', 'vector-appearance-pinned-container', 'vector-appearance-unpinned-container']; | ||
function removeHiddenEls() { | |||
HIDE_IDS.forEach(function (id) { var el = document.getElementById(id); if (el && el.parentNode) el.parentNode.removeChild(el); }); | |||
var pt = document.getElementById('vector-page-tools') || document.querySelector('.vector-page-tools-pinned-container'); | |||
if (pt) pt.querySelectorAll('[aria-controls="vector-appearance"]').forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); }); | |||
} | |||
removeHiddenEls(); | |||
(function detectTeekaMode() { | |||
var tp = document.querySelector('.gr-teeka-page'); | |||
if (!tp) return; | |||
var primary = tp.getAttribute('data-primary') || ''; | |||
var artPath = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1'; | |||
var mainUrl = artPath.replace('$1', primary); | |||
var refParam = window.location.search.match(/[?&]ref=([01])/); | |||
if (refParam) { document.body.classList.add(refParam[1] === '1' ? 'gr-ref-mode' : 'gr-standalone'); return; } | |||
var ref = document.referrer || ''; | |||
document.body.classList.add((ref && primary && ref.indexOf(mainUrl) !== -1) ? 'gr-ref-mode' : 'gr-standalone'); | |||
}()); | |||
if (window.MutationObserver) { | |||
var hideObs = new MutationObserver(function (mutations) { | |||
var dirty = false; | |||
mutations.forEach(function (m) { if (m.addedNodes.length) dirty = true; }); | |||
if (dirty) removeHiddenEls(); | |||
}); | |||
hideObs.observe(document.body, { childList: true, subtree: false }); | |||
setTimeout(function () { hideObs.disconnect(); }, 6000); | |||
} | |||
var content = document.querySelector('.mw-parser-output'); | |||
var alreadyTagged = content && content.querySelector('[data-deva]'); | |||
if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); } | |||
else { | |||
document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry); | |||
} | |||
var saved = (function () { try { return localStorage.getItem(LS_SCRIPT_KEY); } catch (e) { return null; } }()); | |||
if (saved && saved !== 'deva') { applyScript(saved); } else { currentScript = 'deva'; } | |||
watchNewContent(); | |||
setTimeout(setupToc, 200); | |||
} | |||
window.addEventListener('gr-script-change', function (e) { | |||
var script = e && e.detail && e.detail.script; | |||
if (script) applyScript(script); | |||
}); | |||
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) { | |||
tagNewContent(e && e.detail && e.detail.container); | |||
}); | |||
var | // 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 { | |||
var _grBC = new BroadcastChannel('gr-script'); | |||
_grBC.onmessage = function (e) { | |||
var script = e && e.data && e.data.script; | |||
if (script) { currentScript = script; var sel = document.querySelector('.gr-script-sel'); if (sel) sel.value = script; applyScript(script); } | |||
}; | |||
} catch (e) { } | |||
if (window.mw) { | |||
mw.hook('wikipage.content').add(function () { | |||
setTimeout(function () { | |||
var content = document.querySelector('.mw-parser-output'); | |||
var alreadyTagged = content && content.querySelector('[data-deva]'); | |||
if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); } | |||
else { | |||
// tagTocEntry, not a re-tag from textContent: an entry | |||
// keeps its Devanagari in a span *inside* .vector-toc-text, | |||
// 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); | |||
setupToc(); | |||
}, 150); | |||
}); | |||
} | } | ||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } | |||
else { init(); } | |||
}()); /* ← end of main IIFE */ | |||
// ── Inject "Help" and "About" links into the header ───────────────── | |||
function | (function () { | ||
function wikiHref(title) { | |||
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title); | |||
var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1'; | |||
return ap.replace('$1', title); | |||
} | } | ||
function makeHeaderLink(id, href, label) { | |||
var a = document.createElement('a'); | |||
a.id = id; a.href = href; a.textContent = label; | |||
a.className = 'gr-header-link'; // styled in Common.css §37 | |||
return a; | |||
} | |||
function injectHeaderLinks() { | |||
if (document.getElementById('gr-about-link')) return; | |||
var headerEnd = document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header'); | |||
if (!headerEnd) return; | |||
var helpLink = makeHeaderLink('gr-help-link', wikiHref('My_wiki:Help'), 'Help'); | |||
var aboutLink = makeHeaderLink('gr-about-link', wikiHref('My_wiki:About'), 'About'); | |||
var userName = window.mw ? mw.config.get('wgUserName') : null; | |||
var isAnon = !userName || (window.mw && mw.config.get('wgUserId') === null); | |||
var authEl = null; | |||
if (isAnon) { | |||
if (!document.getElementById('gr-header-login')) { | |||
authEl = makeHeaderLink('gr-header-login', wikiHref('Special:UserLogin'), 'Login'); | |||
} | |||
} else if (!document.getElementById('gr-header-user')) { | |||
authEl = buildHeaderUserDropdown(userName); | |||
} | |||
var ul = document.querySelector('.vector-user-links') || document.querySelector('#pt-userpage'); | |||
if (ul && ul.parentNode === headerEnd) { | |||
if (authEl) headerEnd.insertBefore(authEl, ul); | |||
headerEnd.insertBefore(aboutLink, authEl || ul); | |||
headerEnd.insertBefore(helpLink, aboutLink); | |||
} else { | |||
headerEnd.appendChild(helpLink); | |||
headerEnd.appendChild(aboutLink); | |||
if (authEl) headerEnd.appendChild(authEl); | |||
} | |||
} | } | ||
function buildHeaderUserDropdown(userName) { | |||
var wrap = document.createElement('div'); | |||
wrap.id = 'gr-header-user'; | |||
wrap.style.cssText = 'position:relative;display:inline-block;margin-right:4px;'; | |||
var trigger = document.createElement('button'); | |||
trigger.id = 'gr-header-user-trigger'; | |||
trigger.type = 'button'; | |||
trigger.setAttribute('aria-haspopup', 'true'); | |||
trigger.setAttribute('aria-expanded', 'false'); | |||
trigger.style.cssText = [ | |||
'display:inline-flex', 'align-items:center', 'gap:5px', | |||
'color:rgba(255,255,255,0.88)', 'font-size:0.88em', | |||
'font-family:system-ui,sans-serif', 'font-weight:500', | |||
'background:transparent', 'border:none', 'cursor:pointer', | |||
'padding:4px 10px', 'border-radius:4px', 'white-space:nowrap', | |||
'transition:color 0.15s,background 0.15s' | |||
' | ].join(';'); | ||
' | trigger.innerHTML = | ||
'<span>' + userName.replace(/</g, '<') + '</span>' + | |||
'<span id="gr-header-caret" style="font-size:0.8em;transition:transform 0.2s;">\u25be</span>'; | |||
trigger.addEventListener('mouseover', function () { this.style.color = '#fff'; this.style.background = 'rgba(255,255,255,0.12)'; }); | |||
trigger.addEventListener('mouseout', function () { if (menu.style.display === 'none') { this.style.color = 'rgba(255,255,255,0.88)'; this.style.background = 'transparent'; } }); | |||
var menu = document.createElement('div'); | |||
menu.id = 'gr-header-user-menu'; | |||
menu.setAttribute('role', 'menu'); | |||
menu.style.cssText = [ | |||
'display:none', 'position:absolute', 'top:100%', 'right:0', 'margin-top:4px', | |||
'min-width:150px', 'background:#fff', 'border-radius:6px', | |||
'box-shadow:0 4px 16px rgba(0,0,0,0.18)', 'overflow:hidden', 'z-index:1000' | |||
].join(';'); | |||
var itemStyle = 'display:block;padding:10px 16px;font-size:0.9em;color:#2c1810;text-decoration:none;font-family:system-ui,sans-serif;background:#fff;'; | |||
function makeMenuItem(href, label, id) { | |||
var a = document.createElement('a'); | |||
a.href = href; a.setAttribute('role', 'menuitem'); | |||
a.textContent = label; a.style.cssText = itemStyle; | |||
if (id) a.id = id; | |||
a.addEventListener('mouseover', function () { this.style.background = '#f5efe9'; }); | |||
a.addEventListener('mouseout', function () { this.style.background = '#fff'; }); | |||
return a; | |||
} | } | ||
menu.appendChild(makeMenuItem(wikiHref('Special:Profile'), 'Profile')); | |||
menu.appendChild(makeMenuItem('#', 'Log out', 'gr-header-logout')); | |||
function setOpen(open) { | |||
menu.style.display = open ? 'block' : 'none'; | |||
trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); | |||
var caret = document.getElementById('gr-header-caret'); | |||
if (caret) caret.style.transform = open ? 'rotate(180deg)' : ''; | |||
if (open) { trigger.style.color = '#fff'; trigger.style.background = 'rgba(255,255,255,0.12)'; } | |||
else { trigger.style.color = 'rgba(255,255,255,0.88)'; trigger.style.background = 'transparent'; } | |||
} | |||
trigger.addEventListener('click', function (e) { | |||
e.stopPropagation(); | |||
setOpen(menu.style.display === 'none'); | |||
}); | |||
document.addEventListener('click', function (e) { | |||
if (!wrap.contains(e.target)) setOpen(false); | |||
}); | |||
menu.querySelector('#gr-header-logout').addEventListener('click', function (e) { | |||
e.preventDefault(); | |||
if (window.mw && mw.user && mw.user.tokens) { | |||
var t = mw.user.tokens.get('csrfToken'); | |||
if (t) { | |||
var script = (mw.util && mw.util.wikiScript) ? mw.util.wikiScript() : '/index.php'; | |||
var form = document.createElement('form'); | |||
form.method = 'post'; | |||
form.action = script + '?title=Special:UserLogout'; | |||
form.style.display = 'none'; | |||
form.innerHTML = | |||
'<input type="hidden" name="wpEditToken" value="' + String(t).replace(/"/g, '"') + '">' + | |||
'<input type="hidden" name="title" value="Special:UserLogout">' + | |||
'<input type="hidden" name="returnto" value="Main Page">'; | |||
document.body.appendChild(form); | |||
form.submit(); | |||
return; | |||
} | |||
} | |||
if (window.mw && mw.Api) { | |||
new mw.Api().postWithToken('csrf', { action: 'logout' }) | |||
.done(function () { location.href = '/Main_Page'; }) | |||
.fail(function () { location.href = '/index.php?title=Special:UserLogout'; }); | |||
} else { | |||
location.href = '/index.php?title=Special:UserLogout'; | |||
} | |||
}); | |||
wrap.appendChild(trigger); | |||
wrap.appendChild(menu); | |||
return wrap; | |||
} | |||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectHeaderLinks); | |||
else injectHeaderLinks(); | |||
}()); | |||
// ── Main page: by-Grantha / by-Author toggle ────────────────────── | // ── Main page: by-Grantha / by-Author toggle ────────────────────── | ||
( function () { | (function () { | ||
function grHomeView(v) { | |||
var gView = document.getElementById('gr-view-grantha'); | |||
var aView = document.getElementById('gr-view-author'); | |||
var gBtn = document.getElementById('gr-toggle-grantha'); | |||
var aBtn = document.getElementById('gr-toggle-author'); | |||
if (!gView || !aView || !gBtn || !aBtn) return; | |||
gView.style.display = (v === 'grantha') ? '' : 'none'; | |||
aView.style.display = (v === 'author') ? '' : 'none'; | |||
gBtn.className = 'gr-toggle-btn' + (v === 'grantha' ? ' gr-toggle-active' : ''); | |||
aBtn.className = 'gr-toggle-btn' + (v === 'author' ? ' gr-toggle-active' : ''); | |||
try { localStorage.setItem('gr_home_view', v); } catch (e) { } | |||
} | |||
}()); | |||
}() ); | |||
// ── Ullekha reference link handler ───────────────────────────────── | // ── Ullekha reference link handler ───────────────────────────────── | ||
( function () { | (function () { | ||
// Uses the shared, reflow-proof scroller when it is available. | |||
function grScrollTo(el) { | |||
if (!el) return; | |||
if (window.grScrollToElement) window.grScrollToElement(el); | |||
else el.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |||
} | |||
function highlightOnArrival() { | |||
var search = window.location.search; | |||
if (!search) return; | |||
var m = search.match(/[?&]hlUllekha=([^&]+)/); | |||
if (!m) return; | |||
var needle; | |||
try { needle = decodeURIComponent(m[1]); } catch (e) { return; } | |||
if (!needle || needle.length < 4) return; | |||
function doHighlight() { | |||
var content = document.querySelector('.mw-parser-output'); | |||
if (!content) return; | |||
var snippet = needle.slice(0, 40); | |||
var found = false; | |||
var spans = content.querySelectorAll('[data-deva]'); | |||
for (var i = 0; i < spans.length && !found; i++) { | |||
var spanEl = spans[i]; | |||
var orig = spanEl.getAttribute('data-deva') || ''; | |||
if (orig.indexOf(snippet) === -1) continue; | |||
var idx = orig.indexOf(snippet); | |||
var hlText = orig.slice(idx, Math.min(idx + needle.length, orig.length)); | |||
var mark = document.createElement('mark'); | |||
mark.className = 'gr-ullekha-highlight'; | |||
mark.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;'; | |||
mark.textContent = hlText; | |||
var parent = spanEl.parentNode; | |||
if (!parent) continue; | |||
var before = document.createTextNode(orig.slice(0, idx)); | |||
var after = document.createTextNode(orig.slice(idx + hlText.length)); | |||
parent.insertBefore(before, spanEl); parent.insertBefore(mark, spanEl); parent.insertBefore(after, spanEl); parent.removeChild(spanEl); | |||
setTimeout(function () { grScrollTo(mark); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark); }, 100); | |||
found = true; | |||
} | |||
if (!found) { | |||
var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); | |||
while (walker.nextNode() && !found) { | |||
var node = walker.currentNode; | |||
var txt = node.textContent || ''; | |||
if (txt.indexOf(snippet) === -1) continue; | |||
var idx2 = txt.indexOf(snippet); | |||
var mark2 = document.createElement('mark'); | |||
mark2.className = 'gr-ullekha-highlight'; | |||
mark2.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;'; | |||
mark2.textContent = txt.slice(idx2, Math.min(idx2 + needle.length, txt.length)); | |||
var p = node.parentNode; | |||
p.insertBefore(document.createTextNode(txt.slice(0, idx2)), node); | |||
p.insertBefore(mark2, node); | |||
p.insertBefore(document.createTextNode(txt.slice(idx2 + mark2.textContent.length)), node); | |||
p.removeChild(node); | |||
setTimeout(function () { grScrollTo(mark2); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark2); }, 100); | |||
found = true; | |||
} | |||
} | |||
} | } | ||
doHighlight(); | |||
} | } | ||
function wireUllekhaLinks() { | |||
document.querySelectorAll('.gr-ullekha-ref-link').forEach(function (wrap) { | |||
var anchor = wrap.getAttribute('data-anchor') || ''; | |||
var hl = wrap.getAttribute('data-hl') || ''; | |||
var a = wrap.querySelector('a'); | |||
if (!a) return; | |||
var base = a.href.split('#')[0]; | |||
var encoded = encodeURIComponent(hl); | |||
a.href = base + (hl ? '?hlUllekha=' + encoded : '') + (anchor ? '#' + anchor : ''); | |||
}); | |||
} | |||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { highlightOnArrival(); wireUllekhaLinks(); }); } | |||
else { highlightOnArrival(); wireUllekhaLinks(); } | |||
}()); | |||
}() ); | |||
/* ── Search result highlight ──────────────────────────────────── */ | /* ── Search result highlight ──────────────────────────────────── */ | ||
(function () { | |||
function storeQueryForLink(url, query) { | |||
try { | |||
var a = document.createElement('a'); | |||
a.href = url; | |||
sessionStorage.setItem('gr_search_hl', JSON.stringify({ | |||
( | query: query, | ||
pathname: a.pathname | |||
})); | |||
} catch (e) { } | |||
} | |||
function applyHighlight() { | |||
var stored; | |||
try { | |||
stored = JSON.parse(sessionStorage.getItem('gr_search_hl') || 'null'); | |||
} catch (e) { return; } | |||
if (!stored || !stored.query) return; | |||
var currentPath = window.location.pathname; | |||
var storedPath = stored.pathname || ''; | |||
function normPath(p) { return decodeURIComponent(p).replace(/\/+$/, ''); } | |||
if (storedPath && normPath(storedPath) !== normPath(currentPath)) { | |||
query | try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { } | ||
return; | |||
} | |||
var query = stored.query.trim(); | |||
if (!query) return; | |||
try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { } | |||
var delays = [0, 200, 600]; | |||
delays.forEach(function (ms) { | |||
setTimeout(function () { | |||
if (document.querySelector('.gr-search-hl')) return; | |||
highlightText(query); | |||
}, ms); | |||
}); | |||
} | |||
function | // Uses the shared, reflow-proof scroller when it is available. | ||
function grScrollTo(el) { | |||
if (!el) return; | |||
if (window.grScrollToElement) window.grScrollToElement(el); | |||
else el.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |||
} | } | ||
var | function highlightText(query) { | ||
var content = document.querySelector('#mw-content-text .mw-parser-output'); | |||
if (!content) return; | |||
var raw = query.replace(/^"|"$/g, '').trim(); | |||
if (!raw) return; | |||
var patterns = []; | |||
patterns.push(escapeRegex(raw)); | |||
raw.split(/\s+/).forEach(function (w) { | |||
if (w.length >= 2) patterns.push(escapeRegex(w)); | |||
}); | |||
var matched = false; | |||
for (var pi = 0; pi < patterns.length; pi++) { | |||
var re; | |||
try { re = new RegExp('(' + patterns[pi] + ')', 'gi'); } | |||
catch (e) { continue; } | |||
var count = wrapMatches(content, re); | |||
if (count > 0) { matched = true; break; } | |||
} | |||
if (!matched) return; | |||
var first = document.querySelector('.gr-search-hl'); | |||
if (first) { | |||
grScrollTo(first); | |||
first.classList.add('gr-search-hl-pulse'); | |||
setTimeout(function () { first.classList.remove('gr-search-hl-pulse'); }, 2000); | |||
// NEW: directly trigger the TOC to expand and highlight the | |||
// right section, rather than relying solely on the scroll | |||
// observer to catch up on its own. This is the actual fix — | |||
// confirmed the helper against a realistic multi-section | |||
// fragment before wiring it in here. | |||
if (window.grHighlightTocForElement) window.grHighlightTocForElement(first); | |||
} | |||
showDismissBar(query); | |||
} | |||
} | |||
function escapeRegex(s) { | |||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |||
} | } | ||
if ( ! | function wrapMatches(root, re) { | ||
var count = 0; | |||
var walker = document.createTreeWalker( | |||
root, NodeFilter.SHOW_TEXT, { | |||
acceptNode: function (node) { | |||
var p = node.parentElement; | |||
if (!p) return NodeFilter.FILTER_REJECT; | |||
var tag = p.tagName.toUpperCase(); | |||
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return NodeFilter.FILTER_REJECT; | |||
if (p.classList.contains('gr-search-hl')) return NodeFilter.FILTER_REJECT; | |||
return NodeFilter.FILTER_ACCEPT; | |||
} | |||
}, false | |||
); | |||
var nodes = []; | |||
var node; | |||
while ((node = walker.nextNode())) nodes.push(node); | |||
nodes.forEach(function (textNode) { | |||
var val = textNode.nodeValue; | |||
if (!re.test(val)) return; | |||
re.lastIndex = 0; | |||
var frag = document.createDocumentFragment(); | |||
var last = 0; | |||
var m; | |||
while ((m = re.exec(val)) !== null) { | |||
if (m.index > last) { | |||
frag.appendChild(document.createTextNode(val.slice(last, m.index))); | |||
} | |||
var span = document.createElement('span'); | |||
span.className = 'gr-search-hl'; | |||
span.textContent = m[0]; | |||
frag.appendChild(span); | |||
last = m.index + m[0].length; | |||
count++; | |||
} | |||
if (last < val.length) { | |||
frag.appendChild(document.createTextNode(val.slice(last))); | |||
} | |||
textNode.parentNode.replaceChild(frag, textNode); | |||
}); | |||
return count; | |||
} | } | ||
showDismissBar( query ); | function showDismissBar(query) { | ||
var existing = document.getElementById('gr-hl-bar'); | |||
if (existing) existing.remove(); | |||
var count = document.querySelectorAll('.gr-search-hl').length; | |||
if (!count) return; | |||
var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport'); | |||
var bar = document.createElement('div'); | |||
bar.id = 'gr-hl-bar'; | |||
if (isMob) { | |||
bar.className = 'gr-search-hl-mobile'; | |||
bar.innerHTML = | |||
'<button id="gr-hl-results" class="gr-search-bar-btn">' + | |||
'<span class="gra-icon gra-icon-search" aria-hidden="true"></span>' + | |||
'<span>' + count + ' match' + (count === 1 ? '' : 'es') + '</span></button>' + | |||
'<button id="gr-hl-prev" class="gr-search-bar-btn">' + | |||
'<span class="gra-icon gra-icon-prev" aria-hidden="true"></span>' + | |||
'<span>Prev</span></button>' + | |||
'<button id="gr-hl-next" class="gr-search-bar-btn">' + | |||
'<span class="gra-icon gra-icon-next" aria-hidden="true"></span>' + | |||
'<span>Next</span></button>' + | |||
'<button id="gr-hl-dismiss" class="gr-search-bar-btn">' + | |||
'<span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>' + | |||
'<span>Close</span></button>'; | |||
} else { | |||
bar.style.cssText = [ | |||
'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200', | |||
'background:#b5451b', 'color:#fff', 'padding:10px 16px', | |||
'display:flex', 'align-items:center', 'justify-content:space-between', | |||
'font-family:system-ui,sans-serif', 'font-size:14px', | |||
'box-shadow:0 -2px 8px rgba(0,0,0,0.2)' | |||
].join(';'); | |||
var nav = document.createElement('div'); | |||
nav.style.cssText = 'display:flex;align-items:center;gap:12px;'; | |||
nav.innerHTML = | |||
'<span>🔍 <strong>' + escHtml(query) + '</strong> — ' + count + ' match' + (count === 1 ? '' : 'es') + '</span>' + | |||
'<button id="gr-hl-prev" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↑ Prev</button>' + | |||
'<button id="gr-hl-next" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↓ Next</button>' + | |||
'<button id="gr-hl-results" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">← Results</button>'; | |||
bar.appendChild(nav); | |||
var db = document.createElement('button'); | |||
db.id = 'gr-hl-dismiss'; db.textContent = '✕ Close'; | |||
db.style.cssText = 'background:rgba(255,255,255,0.15);border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;'; | |||
bar.appendChild(db); | |||
} | } | ||
document.body.appendChild(bar); | |||
var hlEls = Array.from(document.querySelectorAll('.gr-search-hl')); | |||
var current = 0; | |||
function goTo(idx) { | |||
hlEls.forEach(function (el) { el.classList.remove('gr-search-hl-current'); }); | |||
current = ((idx % hlEls.length) + hlEls.length) % hlEls.length; | |||
hlEls[current].classList.add('gr-search-hl-current'); | |||
grScrollTo(hlEls[current]); | |||
// NEW: same TOC sync on Prev/Next navigation within the | |||
// results, not just the initial jump. | |||
if (window.grHighlightTocForElement) window.grHighlightTocForElement(hlEls[current]); | |||
} | } | ||
function dismiss() { clearHighlights(); bar.remove(); } | |||
var nb = document.getElementById('gr-hl-next'); | |||
var pb = document.getElementById('gr-hl-prev'); | |||
var rb = document.getElementById('gr-hl-results'); | |||
var db2 = document.getElementById('gr-hl-dismiss'); | |||
if (nb) nb.onclick = function () { goTo(current + 1); }; | |||
if (pb) pb.onclick = function () { goTo(current - 1); }; | |||
if (db2) db2.onclick = dismiss; | |||
if (rb) rb.onclick = function () { | |||
bar.remove(); clearHighlights(); | |||
if (window.showSearchDialog) window.showSearchDialog(query); | |||
}; | |||
} | |||
function clearHighlights() { | |||
document.querySelectorAll('.gr-search-hl').forEach(function (span) { | |||
var p = span.parentNode; if (!p) return; | |||
while (span.firstChild) p.insertBefore(span.firstChild, span); | |||
p.removeChild(span); | |||
}); | |||
} | |||
function escHtml(s) { | |||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | |||
} | |||
if ( | function injectHighlightCSS() { | ||
if (document.getElementById('gr-hl-css')) return; | |||
var s = document.createElement('style'); | |||
s.id = 'gr-hl-css'; | |||
s.textContent = [ | |||
'.gr-search-hl{background:#fff176;color:#1a1a1a;border-radius:2px;padding:0 1px;box-shadow:0 0 0 1px rgba(181,69,27,0.25);}', | |||
'.gr-search-hl-current{background:#ffb300!important;box-shadow:0 0 0 2px #b5451b!important;}', | |||
'@keyframes gr-hl-pulse{0%{background:#ffb300;}50%{background:#fff176;}100%{background:#fff176;}}', | |||
'.gr-search-hl-pulse{animation:gr-hl-pulse 1.2s ease 2;}', | |||
].join(''); | |||
document.head.appendChild(s); | |||
} | |||
injectHighlightCSS(); | |||
if (document.readyState === 'loading') { | |||
document.addEventListener('DOMContentLoaded', applyHighlight); | |||
} else { | } else { | ||
applyHighlight(); | |||
} | |||
if (window.mw) { | |||
mw.hook('wikipage.content').add(function () { | |||
setTimeout(applyHighlight, 100); | |||
}); | |||
} | |||
window.grStoreSearchHL = storeQueryForLink; | |||
}()); | |||
/* ═══════════════════════════════════════════════════════════════ | |||
Mobile addon — only runs on Minerva (mobile) skin | |||
═══════════════════════════════════════════════════════════════ */ | |||
(function () { | |||
if (!document.body.classList.contains('skin-minerva')) return; | |||
function injectMenuLinks() { | |||
if (document.getElementById('gr-mob-menu-items')) return; | |||
var navDrawer = document.querySelector('.navigation-drawer'); | |||
if (!navDrawer) return; | |||
var wrap = document.createElement('div'); wrap.id = 'gr-mob-menu-items'; wrap.className = 'gr-mob-menu'; | |||
function makeItem(href, label) { var a = document.createElement('a'); a.href = href; a.className = 'gr-mob-item'; a.textContent = label; return a; } | |||
wrap.appendChild(makeItem('/Main_Page', 'Home')); | |||
wrap.appendChild(makeItem('/My_wiki:Help', 'Help')); | |||
wrap.appendChild(makeItem('/My_wiki:About', 'About')); | |||
var userName = window.mw ? mw.config.get('wgUserName') : null; | |||
if (userName) { | |||
wrap.appendChild(makeItem((window.mw && mw.util && mw.util.getUrl) ? mw.util.getUrl('Special:Profile') : '/Special:Profile', 'Profile')); | |||
var la = document.querySelector('a[href*="action=logout"]'); | |||
wrap.appendChild(makeItem(la ? la.href : '/index.php?title=Special:UserLogout', 'Log out')); | |||
} | |||
else wrap.appendChild(makeItem('/index.php?title=Special:UserLogin', 'Log in')); | |||
var pageLeft = document.getElementById('mw-mf-page-left'); | |||
if (pageLeft) { | |||
// Hide Minerva's own menu lists rather than deleting them: Minerva's | |||
// initMobile.js looks up .toggle-list__list inside this panel and | |||
// throws ("Cannot read properties of null (reading 'parentNode')") | |||
// when it is gone, which also stops the rest of Minerva's setup. | |||
Array.prototype.forEach.call(pageLeft.children, function (c) { c.classList.add('gr-mf-native-menu'); }); | |||
pageLeft.style.removeProperty('display'); | |||
pageLeft.appendChild(wrap); | |||
} | |||
else navDrawer.appendChild(wrap); | |||
} | |||
function injectMoolaUllekhaLinks() { | |||
if (document.getElementById('gr-mob-doc-nav')) return; | |||
var pageName = (window.mw && mw.config && mw.config.get('wgPageName')) || ''; | |||
if (pageName === 'Main_Page' || !pageName) return; | |||
function wikiUrl(slug) { if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug); return ((window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1').replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/')); } | |||
var teekaPage = document.querySelector('.gr-teeka-page'); | |||
var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || pageName.split('/')[0]) : pageName.split('/')[0]; | |||
var docTitleEl = document.querySelector('.gr-doc-title'); | |||
var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1'; | |||
var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1'; | |||
var showMoolam = !!teekaPage || hasMoolaPage, showUllekha = hasUllekhaPage || !!teekaPage; | |||
if (!showMoolam && !showUllekha) return; | |||
var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav'; | |||
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.className = 'toc-main-link-item'; return a; } | |||
if (teekaPage) nav.appendChild(makeLink(wikiUrl(primarySlug), 'मूल')); | |||
else if (hasMoolaPage) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Moola'), 'मूलम्')); | |||
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'); | |||
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); } | |||
} | } | ||
function boot() { injectMenuLinks(); injectMoolaUllekhaLinks();} | |||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | |||
else boot(); | |||
if (window.mw) mw.hook('wikipage.content'); | |||
}()); | |||
document.body. | (function () { | ||
'use strict'; | |||
if (mw.config.get('wgPageName') !== 'Main_Page') return; | |||
mw.loader.using('mediawiki.util').done(function () { | |||
$(function () { applyHomeToggleOffset(); window.addEventListener('resize', applyHomeToggleOffset, { passive: true }); setTimeout(applyHomeToggleOffset, 300); setTimeout(applyHomeToggleOffset, 800); }); | |||
}); | |||
function applyHomeToggleOffset() { | |||
var bar = document.getElementById('gr-static-bar'); if (!bar) return; | |||
var barBottom = Math.round(bar.getBoundingClientRect().bottom); | |||
[document.getElementById('gr-home'), document.getElementById('gr-home-toggle'), document.querySelector('#mw-content-text .mw-parser-output > .gr-home, #mw-content-text .mw-parser-output > *:first-child')].forEach(function (el) { if (el) el.style.scrollMarginTop = (barBottom + 4) + 'px'; }); | |||
var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport'); | |||
if (isMob) { var toggleEl = document.getElementById('gr-home-toggle'); if (toggleEl) { var tr = toggleEl.getBoundingClientRect(); if (tr.top < barBottom) { var cp = parseInt(window.getComputedStyle(document.body).paddingTop, 10) || 0; document.body.style.paddingTop = (cp + (barBottom - tr.top) + 4) + 'px'; } } } | |||
} | |||
$(function () { | |||
var $toggle = $('#gr-home-toggle'), $viewG = $('#gr-view-grantha'), $viewA = $('#gr-view-author'), $btnG = $('#gr-toggle-grantha'), $btnA = $('#gr-toggle-author'); | |||
if (!$toggle.length || !$viewG.length || !$viewA.length) return; if ($toggle.data('gr-wired')) return; $toggle.data('gr-wired', true); | |||
function showView(which) { if (which === 'grantha') { $viewG.show(); $viewA.hide(); $btnG.addClass('gr-toggle-active'); $btnA.removeClass('gr-toggle-active'); } else { $viewA.show(); $viewG.hide(); $btnA.addClass('gr-toggle-active'); $btnG.removeClass('gr-toggle-active'); } try { localStorage.setItem('grantha_home_tab', which); } catch (e) { } } | |||
$btnG.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('grantha'); }); | |||
$btnA.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('author'); }); | |||
try { var saved = localStorage.getItem('grantha_home_tab'); if (saved === 'author') showView('author'); else showView('grantha'); } catch (e) { showView('grantha'); } | |||
}); | |||
}()); | |||
// ── Replace MediaWiki user links with a single username dropdown (Profile + Logout) ── | |||
(function () { | |||
function wikiHref(title) { | |||
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title); | |||
var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1'; | |||
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. CHANGED: was a 30-color palette | |||
// transcribed from a screenshot - confirmed directly that at least | |||
// one entry (#F6C500, a saturated gold) wasn't actually light at | |||
// all, which is very likely what read as "really dark" in practice. | |||
// Replaced with 10 colors in the site's own warm palette (matching | |||
// the maroon/cream tones already used throughout Common.css - | |||
// #b5451b, #fdf0eb, #f0cfc4), all kept in the ~94-97% lightness | |||
// range so they stay easy to read against dark text regardless of | |||
// which one a given teeka lands on. First entry is #fcf5f3 - the | |||
// exact background .teeka-block itself already uses on a native | |||
// teeka page (verified at 97.1% lightness), per direction, so the | |||
// most common single-commentary case matches the site's own | |||
// existing look exactly rather than introducing a new shade for it. | |||
var TEEKA_COLOR_PALETTE = [ | |||
'#fcf5f3', '#F5EFE6', '#F0F5EC', '#EEF3F6', '#F6EFF3', | |||
'#F3F0F8', '#F8F3EA', '#EEF6F2', '#F6EEEE', '#F2F0EC' | |||
]; | |||
// 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(); | |||
} | |||
// One request for ALL of them, not one each. The API takes up to 50 | |||
// titles at a time, and a moola page now shows every commentary by | |||
// default - so what used to be one round trip became five or ten, | |||
// fired together but each with its own latency, and the commentaries | |||
// appeared well after the text. Batching makes it a single request | |||
// whatever the page carries. maxage/smaxage let the browser and the | |||
// CDN reuse the response, so moving between chapters of one grantha | |||
// does not re-fetch the same commentaries. | |||
var teekaJsonCache = {}; | |||
function fetchTeekaJsons(titles) { | |||
var want = titles.filter(function (t) { return !(t in teekaJsonCache); }); | |||
if (!want.length) return $.Deferred().resolve(teekaJsonCache).promise(); | |||
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; | |||
}); | |||
} | |||
// 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. | |||
// CHANGED: also builds a slug -> color-palette-index map here, based | |||
// on teekaDocs' OWN ORDER (the order commentaries were actually | |||
// discovered/fetched for this page) rather than hashing each slug | |||
// independently. Confirmed directly this was the actual issue: hash- | |||
// based assignment gives every teeka a color, but nothing guarantees | |||
// the FIRST one shown on a page lands on palette slot 0 (#fcf5f3, | |||
// the site's own real .teeka-block default) - it gets whatever slot | |||
// its hash happens to produce. Positional assignment makes "first | |||
// shown = the exact default color" a guarantee, not a coincidence, | |||
// at the cost of a specific teeka's color now depending on where it | |||
// appears in a given page's own list rather than being fixed | |||
// site-wide - a reasonable trade given the direction received. | |||
var teekaColorIndexBySlug = {}; | |||
function buildTeekaInMoolaIndex(teekaDocs) { | |||
var index = {}; | |||
teekaColorIndexBySlug = {}; | |||
var colorIdx = 0; | |||
teekaDocs.forEach(function (td) { | |||
if (!td || !td.blocks || !td.doc) return; | |||
var slug = td.doc.slug || td._title; | |||
if (!(slug in teekaColorIndexBySlug)) { | |||
teekaColorIndexBySlug[slug] = colorIdx % TEEKA_COLOR_PALETTE.length; | |||
colorIdx++; | |||
} | |||
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, | |||
teekaAuthor: td.doc.author || '', | |||
teekaPage: td.doc.pageTitle || td._title, | |||
teekaGranthaId: td.doc.granthaId || '', | |||
teekaSlug: slug, | |||
// 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 | |||
}); | |||
}); | |||
}); | |||
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) { | |||
// CHANGED: was teekaColorForSlug(slug) (hash-based) - | |||
// now looks up the position assigned in | |||
// buildTeekaInMoolaIndex, falling back to the hash only | |||
// if this slug is somehow missing from that map (should | |||
// not normally happen, but safer than an undefined color). | |||
var colorIdx = teekaColorIndexBySlug[entry.teekaSlug]; | |||
var color = (colorIdx !== undefined) ? TEEKA_COLOR_PALETTE[colorIdx] : teekaColorForSlug(entry.teekaSlug); | |||
var $entry = $('<div>').addClass('teeka-block') | |||
.attr('data-teeka-slug', entry.teekaSlug) | |||
.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); | |||
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-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); | |||
}); | |||
// 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; | |||
}); | |||
return injectedAny; | |||
} | |||
function removeTeekaInMoola() { | |||
$('.gr-injected-teeka-group').remove(); | |||
if (window.grFootnoteRegistry) window.grFootnoteRegistry.clearInjected(); | |||
} | |||
// CRITICAL FIX: confirmed directly this feature was corrupting saved | |||
// page content. QuickEdit is a contenteditable-based editor operating | |||
// on the page's LIVE DOM - since this feature injects real elements | |||
// (interleaved teeka blocks, inline checkboxes wrapping links) into | |||
// that same DOM, QuickEdit's save/serialization pipeline had no way | |||
// to distinguish them from genuine content, and was saving them back | |||
// into the page's actual wikitext as literal escaped HTML text. This | |||
// teardown strips every trace of this feature's DOM changes - | |||
// removing injected teeka blocks entirely, and for inline checkboxes, | |||
// specifically UNWRAPPING each link back to its own original | |||
// position (not just deleting the wrapper, which would also delete | |||
// the link itself) - restoring the DOM to exactly what the page's | |||
// real, saved content actually is, before any editor can see it. | |||
function teardownTeekaInterleave() { | |||
removeTeekaInMoola(); | |||
$('.gr-teeka-cb-wrap').each(function () { | |||
var $wrap = $(this); | |||
var $link = $wrap.find('a'); | |||
if ($link.length) $wrap.before($link); | |||
$wrap.remove(); | |||
}); | |||
// CHANGED: also strips the gr-teeka-list-item class added to any | |||
// <li> for bullet-removal - confirmed via direct test this was | |||
// missed initially, which would have left a class attribute | |||
// change in the saved content even after the wrapper/checkbox | |||
// itself was correctly removed. Also removes the class | |||
// ATTRIBUTE entirely (not just the class name) once empty - | |||
// classList.remove() alone leaves class="" behind, which a | |||
// byte-for-byte test caught as still not matching the true | |||
// original markup (which never had a class attribute at all). | |||
$('.gr-teeka-list-item').each(function () { | |||
this.classList.remove('gr-teeka-list-item'); | |||
if (!this.className) this.removeAttribute('class'); | |||
}); | |||
} | |||
var | // Watches for the exact DOM signal that editing has actually begun - | ||
// Common.css targets .mw-body-content[contenteditable="true"] | |||
// directly, confirming this is the real, load-order-independent | |||
// trigger QuickEdit itself relies on, rather than trying to | |||
// intercept every possible UI path that could invoke it (edit | |||
// button, keyboard shortcut, or anything else) individually and | |||
// risk missing one. | |||
function watchForEditMode() { | |||
var target = document.querySelector('.mw-body-content') || document.body; | |||
if (!target || !window.MutationObserver) return; | |||
var wasEditing = false; | |||
var obs = new MutationObserver(function (mutations) { | |||
mutations.forEach(function (m) { | |||
if (m.attributeName !== 'contenteditable') return; | |||
var isEditingNow = m.target.getAttribute('contenteditable') === 'true'; | |||
if (isEditingNow && !wasEditing) { | |||
teardownTeekaInterleave(); | |||
wasEditing = true; | |||
} else if (!isEditingNow && wasEditing) { | |||
// Editing ended without a page reload (e.g. cancelled) - | |||
// restore using the already-fetched data cached in | |||
// memory, no need to re-fetch anything from the server. | |||
if (teekaIndex) renderInterleave(); | |||
if (lastAugmentArgs) { | |||
var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs); | |||
if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; })); | |||
} | |||
wasEditing = false; | |||
} | |||
}); | |||
}); | |||
obs.observe(target, { attributes: true, attributeFilter: ['contenteditable'], subtree: true }); | |||
} | |||
function | function normalizeWikiPath(p) { | ||
try { p = decodeURIComponent(p); } catch (e) { } | |||
return p.replace(/_/g, ' ').replace(/\/+$/, ''); | |||
} | } | ||
function | // ── 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 teekaIndex = null; | |||
var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}] | |||
var lastAugmentArgs = null; // {titles, docs} - cached to restore inline checkboxes after a cancelled edit without re-fetching | |||
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. | |||
// CHANGED: wraps the checkbox AND the link together in one flex | |||
// container, rather than just wrapping the checkbox alone next to | |||
// an untouched sibling link - confirmed directly (screenshot, twice | |||
// now) that aligning a wrapped checkbox against ambient text- | |||
// baseline metrics alone still leaves a small vertical offset, | |||
// especially with the site's custom Devanagari font's own metrics. | |||
// Flexbox align-items:center between the checkbox and the link's | |||
// own line box is a more reliable way to center them against EACH | |||
// OTHER directly, rather than each independently against the | |||
// surrounding line. The link is moved (not cloned) into the | |||
// wrapper at its original DOM position, so its href/click behavior | |||
// and the surrounding "\u2014 author" text's sibling order are both | |||
// preserved exactly as before. | |||
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 li = this.closest('li'); | |||
if (li) li.classList.add('gr-teeka-list-item'); | |||
var $anchor = $(this); | |||
var $wrap = $('<span>').addClass('gr-teeka-cb-wrap'); | |||
var $cb = $('<input>').attr({ type: 'checkbox' }).addClass('gr-teeka-inline-checkbox').prop('checked', !!selectedTeekaSlugs[slug]); | |||
$anchor.before($wrap); | |||
$wrap.append($cb); | |||
$wrap.append($anchor); | |||
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() { | |||
// Runs unconditionally, before any of the early-return checks | |||
// below - harmless as a no-op teardown on pages where nothing | |||
// ever gets injected, but critical to have active on every page | |||
// this feature COULD have injected into, regardless of which | |||
// exact code path led here. | |||
watchForEditMode(); | |||
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; } | |||
fetchTeekaJsons(subpages).then(function (cache) { | |||
var fetched = subpages.map(function (t) { return cache[t] || null; }); | |||
// 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; | |||
}); | |||
// 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; }); | |||
// NEW: sub-commentaries (tippanis) - a teeka doc declares | |||
// its own children via doc.related (an array of slugs, | |||
// matching the same field this session's own generator | |||
// already writes into new JSON docs). Only the ASK here | |||
// is to show these as sub-categories in the options list | |||
// and gate their visibility on the parent being checked | |||
// - so this only needs one more level of fetching, not | |||
// unbounded recursion. | |||
var relatedSlugs = []; | |||
topDocs.forEach(function (d) { (d.doc.related || []).forEach(function (s) { relatedSlugs.push(s); }); }); | |||
var uniqueRelated = relatedSlugs.filter(function (s, i) { return relatedSlugs.indexOf(s) === i; }); | |||
function finishInit(subDocsBySlug) { | |||
var allDocs = topDocs.slice(); | |||
Object.keys(subDocsBySlug).forEach(function (slug) { if (subDocsBySlug[slug]) allDocs.push(subDocsBySlug[slug]); }); | |||
teekaIndex = buildTeekaInMoolaIndex(allDocs); | |||
availableList = topDocs.map(function (d) { | |||
var subItems = (d.doc.related || []) | |||
.map(function (s) { return subDocsBySlug[s]; }) | |||
.filter(Boolean) | |||
.map(function (sd) { return { slug: sd.doc.slug || sd._title, title: sd.doc.title || sd._title }; }); | |||
return { slug: d.doc.slug || d._title, title: d.doc.title || d._title, subItems: subItems }; | |||
}); | |||
// Cached at module level so watchForEditMode can | |||
// correctly re-run link augmentation after a | |||
// cancelled edit, without needing to re-fetch | |||
// 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 }; | |||
var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.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(availableList); }); | |||
if (inlineListVisible !== null) visibilityCallbacks.forEach(function (cb) { cb(inlineListVisible); }); | |||
} | |||
if (!uniqueRelated.length) { finishInit({}); return; } | |||
fetchTeekaJsons(uniqueRelated).then(function (cache) { | |||
var subDocsBySlug = {}; | |||
uniqueRelated.forEach(function (slug) { | |||
if (!cache[slug]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug); | |||
subDocsBySlug[slug] = cache[slug] || null; | |||
}); | |||
finishInit(subDocsBySlug); | |||
}); | |||
}); | |||
}); | |||
} | |||
window.grTeekaInterleave = { | |||
teardown: teardownTeekaInterleave, | |||
onReady: function (cb) { if (isReady) cb(availableList); 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(); | |||
}()); | |||
/* ═══════════════════════════════════════════════════════════════ | |||
Devanagari page titles, site-wide (window.grTitles) | |||
─────────────────────────────────────────────────────────────── | |||
One source of truth for "what is this page called": the Main Page | |||
catalogue, where every published work and commentary is listed as | |||
[[Slug|देवनागरी नाम]]. update_main_page.py keeps that list current, | |||
so a newly pushed commentary gets its Devanagari name everywhere | |||
without anyone editing a hard-coded list. | |||
Used by: | |||
• readerToolbar.js search results (grDocDisplayTitle & co. ask | |||
grTitles first, then fall back to their own GR_DOC_TITLES map) | |||
• the Updates page | |||
• this block itself, which relabels any link on the page whose | |||
text is still the English slug (e.g. the "Vadavali · Bhavavivaranam" | |||
page-nav line), the mobile page heading, and the browser tab title. | |||
The catalogue is cached in localStorage for CACHE_HOURS and refreshed | |||
in the background, so lookups are synchronous on almost every view. | |||
═══════════════════════════════════════════════════════════════ */ | |||
(function () { | |||
'use strict'; | |||
var CATALOGUE_PAGE = 'Main_Page'; | |||
var CACHE_KEY = 'gr_titles_v1'; | |||
var | var CACHE_HOURS = 1; | ||
var DEVA = /[ऀ-ॿ]/; | |||
var full = {}; // "Katha/Vyakhya/VyasatirthaVivarana" → "श्रीव्यासतीर्थविरचितविवरणम्" | |||
var leaf = {}; // "VyasatirthaVivarana" → same; null when two pages share a leaf | |||
var waiting = []; | |||
var loaded = false; | |||
function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; } | |||
function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); } | |||
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } | |||
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } } | |||
function script(name) { | |||
if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name); | |||
return (cfg('wgScriptPath') || '') + '/' + name + '.php'; | |||
} | |||
function add(title, label, override) { | |||
title = norm(title); label = String(label || '').replace(/<[^>]*>/g, '').trim(); | |||
if (!title || !label || !DEVA.test(label)) return; | |||
} ); | if (override || !full[title]) full[title] = label; | ||
var l = title.split('/').pop(); | |||
if (!(l in leaf) || override) leaf[l] = label; | |||
else if (leaf[l] !== label) leaf[l] = null; // ambiguous leaf: don't guess | |||
} | |||
function parseCatalogue(txt) { | |||
var out = {}, re = /\[\[([^\]|#]+)\|([^\]]+)\]\]/g, m; | |||
while ((m = re.exec(txt || ''))) { | |||
var k = norm(m[1]); | |||
if (!out[k]) out[k] = m[2].replace(/<[^>]*>/g, '').trim(); | |||
} | |||
return out; | |||
} | |||
function useCatalogue(map) { | |||
full = {}; leaf = {}; | |||
Object.keys(map).forEach(function (k) { add(k, map[k]); }); | |||
addCurrentPage(); | |||
} | |||
// The page being viewed names itself in .gr-doc-title — covers a page | |||
// that isn't on the Main Page yet. | |||
function addCurrentPage() { | |||
var el = document.querySelector('#mw-content-text .gr-doc-title'); | |||
var me = norm(cfg('wgPageName')); | |||
if (el && me && !full[me]) add(me, el.textContent); | |||
} | |||
// ── public API ──────────────────────────────────────────────── | |||
function get(title) { return full[norm(title)] || null; } | |||
function getLeaf(seg) { var v = leaf[norm(seg)]; return v || null; } | |||
function whenReady(cb) { if (loaded) cb(); else waiting.push(cb); } | |||
function all() { var o = {}; for (var k in full) o[k] = full[k]; return o; } | |||
window.grTitles = { get: get, leaf: getLeaf, all: all, whenReady: whenReady, parse: parseCatalogue }; | |||
function finish() { | |||
loaded = true; | |||
var cbs = waiting; waiting = []; | |||
cbs.forEach(function (cb) { try { cb(); } catch (e) { } }); | |||
relabel(); | |||
if (window.mw && mw.hook) mw.hook('gr.titles').fire(window.grTitles); | |||
} | |||
// ── relabel what's on screen ────────────────────────────────── | |||
function articleTitleFromHref(href) { | |||
var u; | |||
try { u = new URL(href, location.href); } catch (e) { return null; } | |||
if (u.origin !== location.origin) return null; | |||
var t = u.searchParams.get('title'); | |||
if (!t) { | |||
var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0]; | |||
if (u.pathname.indexOf(ap) !== 0) return null; | |||
t = u.pathname.slice(ap.length); | |||
} | |||
try { t = decodeURIComponent(t); } catch (e) { } | |||
return norm(t); | |||
} | |||
function relabel(root) { | |||
root = root || document; | |||
var links = root.querySelectorAll('#mw-content-text a[href], .gr-sticky-title a[href], #mw-panel a[href], .vector-toc a[href]'); | |||
Array.prototype.forEach.call(links, function (a) { | |||
if (a.children.length) return; // leave rich links alone | |||
var text = norm(a.textContent); | |||
if (!text || DEVA.test(text)) return; // already Devanagari | |||
var target = articleTitleFromHref(a.getAttribute('href')) || norm(a.getAttribute('title')); | |||
if (!target) return; | |||
var tail = target.split('/').pop(); | |||
if (text !== target && text !== tail) return; // only replace a bare slug | |||
var label = get(target) || (text === tail ? getLeaf(tail) : null); | |||
if (label) a.textContent = label; | |||
}); | |||
// Page heading (shown on mobile) and the browser tab | |||
var me = norm(cfg('wgPageName')); | |||
var myLabel = get(me); | |||
if (!myLabel) return; | |||
var h = document.querySelector('#firstHeading .mw-page-title-main') || document.getElementById('firstHeading'); | |||
if (h && !h.children.length && norm(h.textContent) === me) h.textContent = myLabel; | |||
var wgTitle = norm(cfg('wgTitle')); | |||
if (wgTitle && document.title.indexOf(wgTitle) === 0) document.title = myLabel + document.title.slice(wgTitle.length); | |||
else if (document.title.indexOf(me) === 0) document.title = myLabel + document.title.slice(me.length); | |||
} | |||
// ── load ───────────────────────────────────────────────────── | |||
function fetchFresh() { | |||
return fetch(script('index') + '?title=' + CATALOGUE_PAGE + '&action=raw', { credentials: 'same-origin' }) | |||
.then(function (r) { return r.ok ? r.text() : Promise.reject(); }) | |||
.then(function (txt) { | |||
var map = parseCatalogue(txt); | |||
lsSet(CACHE_KEY, JSON.stringify({ at: Date.now(), map: map })); | |||
return map; | |||
}); | |||
} | |||
function boot() { | |||
var cached = null; | |||
try { cached = JSON.parse(lsGet(CACHE_KEY) || 'null'); } catch (e) { } | |||
if (cached && cached.map) { useCatalogue(cached.map); finish(); } | |||
var stale = !cached || (Date.now() - cached.at > CACHE_HOURS * 3600000); | |||
if (!stale) return; | |||
fetchFresh().then(function (map) { | |||
useCatalogue(map); | |||
if (!loaded) finish(); else relabel(); | |||
}).catch(function () { if (!loaded) { addCurrentPage(); finish(); } }); | |||
} | |||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | |||
else boot(); | |||
// Content that arrives later (previews, lazy sections, search panes) | |||
if (window.mw && mw.hook) mw.hook('wikipage.content').add(function ($c) { | |||
if (loaded) { addCurrentPage(); relabel($c && $c[0] ? $c[0] : document); } | |||
}); | |||
}()); | |||
/* ═══════════════════════════════════════════════════════════════ | /* ═══════════════════════════════════════════════════════════════ | ||
Mobile | Header links "Library" and "Updates", and the Updates page | ||
─────────────────────────────────────────────────────────────── | |||
Desktop (Vector): "Library" and "Updates" added before "Help". | |||
Mobile (Minerva): the same two items in the menu panel, after "Home". | |||
"Updates" opens the page My_wiki:Updates, which has two parts: | |||
1. What's new — filled in automatically from the wiki's edit history | |||
into <div id="gr-updates-feed">. Nothing to maintain by hand: | |||
• New a page created in the last WINDOW_DAYS days | |||
• Updated an existing page whose edits in that window add up to | |||
at least UPDATE_MIN_BYTES (so typo fixes don't show) | |||
Titles are the Devanagari names from window.grTitles (Main Page catalogue). | |||
Test pages, helper subpages (/Ullekha, /Moola, /C1 …) and deleted | |||
pages are left out. | |||
2. Announcements & events — a card editors fill by hand with | |||
{{Bulletin|type=…|date=…|title=…|text=…}} lines on that page. | |||
Styling: MediaWiki:Common.css §37 (no inline styles here). | |||
═══════════════════════════════════════════════════════════════ */ | |||
(function () { | |||
'use strict'; | |||
var LIBRARY_URL = 'https://dvaita-grantha-hierarchy-library.ai.studio/'; | |||
var LIBRARY_LABEL = 'Library'; | |||
var UPDATES_LABEL = 'Updates'; | |||
var UPDATES_PAGE = 'My_wiki:Updates'; // the page the "Updates" link opens | |||
var WINDOW_DAYS = 90; // how far back the list looks | |||
var UPDATE_MIN_BYTES = 1500; // edits smaller than this in total aren't "updates" | |||
var MAX_ITEMS = 100; // rows shown on the page | |||
var CACHE_MINUTES = 10; // per-tab cache so every page view isn't an API call | |||
var CATALOGUE_PAGE = 'Main_Page'; | |||
var SKIP_TITLE = /^(Main Page|Test|Sandbox)|(^|\/)(Ullekha|Moola|C\d+)$|(^|\/)(Test|Teeka|Tippani)\d*$/i; | |||
var HIDE = []; // exact titles never to list, e.g. ['Sumadhwavijaya/Vyakhya/पदार्थदीपिका'] | |||
var SEEN_KEY = 'gr_updates_seen'; | |||
var CACHE_KEY = 'gr_updates_cache_v3'; | |||
var isMobile = document.body.classList.contains('skin-minerva'); | |||
// ── helpers ──────────────────────────────────────────────── | |||
function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; } | |||
function wikiHref(title) { | |||
if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title); | |||
var ap = cfg('wgArticlePath') || '/wiki/$1'; | |||
return ap.replace('$1', encodeURIComponent(title.replace(/ /g, '_')).replace(/%2F/g, '/').replace(/%3A/g, ':')); | |||
} | |||
function script(name) { | |||
if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name); | |||
return (cfg('wgScriptPath') || '') + '/' + name + '.php'; | |||
} | |||
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } | |||
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } } | |||
function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } } | |||
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } } | |||
function el(tag, cls, text) { | |||
var n = document.createElement(tag); | |||
if (cls) n.className = cls; | |||
if (text != null) n.textContent = text; | |||
return n; | |||
} | |||
function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); } | |||
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; | |||
function absDate(d) { return MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); } | |||
// Today · 1 day ago · 5 days ago (under a week) · Sep 16, 2026 | |||
function relDate(d, now) { | |||
now = now || new Date(); | |||
var a = new Date(d.getFullYear(), d.getMonth(), d.getDate()); | |||
var b = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |||
var days = Math.round((b - a) / 86400000); | |||
if (days <= 0) return 'Today'; | |||
if (days === 1) return '1 day ago'; | |||
if (days < 7) return days + ' days ago'; | |||
return absDate(d); | |||
} | |||
// ── data ─────────────────────────────────────────────────── | |||
function getJSON(url) { | |||
return fetch(url, { credentials: 'same-origin' }).then(function (r) { | |||
if (!r.ok) throw new Error('HTTP ' + r.status); | |||
return r.json(); | |||
}); | |||
} | |||
function api(params) { | |||
var q = ['format=json', 'formatversion=2']; | |||
for (var k in params) q.push(k + '=' + encodeURIComponent(params[k])); | |||
return getJSON(script('api') + '?' + q.join('&')); | |||
} | |||
// Devanagari names come from window.grTitles (the Main Page catalogue, | |||
// cached) — the same names search and links use. | |||
function fetchCatalogue() { | |||
return new Promise(function (resolve) { | |||
if (window.grTitles) window.grTitles.whenReady(function () { resolve(window.grTitles.all()); }); | |||
else resolve({}); | |||
}); | |||
} | |||
// Every main-namespace change in the window, oldest→newest pages grouped. | |||
function fetchChanges() { | |||
var since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString().replace(/\.\d+Z$/, 'Z'); | |||
var all = []; | |||
function page(cont, limit) { | |||
var p = { | |||
action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0, | |||
rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: limit, rcend: since | |||
}; | |||
if (cont) p.rccontinue = cont; | |||
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) || []); | |||
// 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, 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 page(null, 500); | |||
} | |||
function summarise(changes, catalogue) { | |||
var byTitle = {}; | |||
changes.forEach(function (c) { | |||
var t = norm(c.title); | |||
if (SKIP_TITLE.test(t) || HIDE.indexOf(t) !== -1) return; | |||
var e = byTitle[t] || (byTitle[t] = { title: t, created: null, last: null, bytes: 0 }); | |||
var ts = new Date(c.timestamp); | |||
if (c.type === 'new') e.created = ts; | |||
else e.bytes += Math.abs((c.newlen || 0) - (c.oldlen || 0)); | |||
if (!e.last || ts > e.last) e.last = ts; | |||
}); | |||
var out = []; | |||
Object.keys(byTitle).forEach(function (t) { | |||
var e = byTitle[t]; | |||
var listed = !!catalogue[t]; | |||
// Pages the catalogue doesn't know about still count if they look like | |||
// real content: a top-level work or a commentary under /Vyakhya/. | |||
if (!listed && t.indexOf('/') !== -1 && !/\/Vyakhya\/[^/]+$/.test(t)) return; | |||
if (e.created) out.push({ title: t, kind: 'new', when: e.created }); | |||
else if (e.bytes >= UPDATE_MIN_BYTES) out.push({ title: t, kind: 'update', when: e.last }); | |||
}); | |||
out.forEach(function (x) { x.listed = !!catalogue[x.title]; x.label = labelFor(x.title, catalogue); x.sub = subFor(x.title, catalogue); }); | |||
return out.sort(function (a, b) { return b.when - a.when; }); | |||
} | |||
function labelFor(t, cat) { | |||
if (cat[t]) return cat[t]; | |||
var parts = t.split('/'); | |||
return parts[parts.length - 1]; | |||
} | |||
// Second line: which work a commentary belongs to. | |||
function subFor(t, cat) { | |||
var m = /^([^/]+)\/Vyakhya\//.exec(t); | |||
if (!m) return ''; | |||
return cat[m[1]] ? cat[m[1]] + ' — टीका' : 'टीका'; | |||
} | |||
// Drop pages created in the window and deleted since. | |||
function dropMissing(items) { | |||
if (!items.length) return Promise.resolve(items); | |||
var batches = []; | |||
for (var i = 0; i < items.length; i += 50) batches.push(items.slice(i, i + 50)); | |||
return Promise.all(batches.map(function (b) { | |||
return api({ action: 'query', prop: 'info', inprop: 'displaytitle', titles: b.map(function (x) { return x.title; }).join('|') }) | |||
.then(function (j) { | |||
var gone = {}, shown = {}; | |||
((j.query && j.query.pages) || []).forEach(function (p) { | |||
if (p.missing) gone[norm(p.title)] = true; | |||
var dt = (p.displaytitle || '').replace(/<[^>]*>/g, '').trim(); | |||
if (dt && norm(dt) !== norm(p.title)) shown[norm(p.title)] = dt; | |||
}); | |||
return b.filter(function (x) { | |||
// A DISPLAYTITLE only helps pages the catalogue doesn't name. | |||
if (shown[x.title] && !x.listed) x.label = shown[x.title]; | |||
return !gone[x.title]; | |||
}); | |||
}).catch(function () { return b; }); | |||
})).then(function (r) { return [].concat.apply([], r); }); | |||
} | |||
function loadUpdates() { | |||
var cached = ssGet(CACHE_KEY); | |||
if (cached) { | |||
try { | |||
var c = JSON.parse(cached); | |||
if (Date.now() - c.at < CACHE_MINUTES * 60000) { | |||
return Promise.resolve(c.items.map(function (x) { x.when = new Date(x.when); return x; })); | |||
} | |||
} catch (e) { } | |||
} | |||
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)); | |||
}).then(function (items) { | |||
items = items.slice(0, MAX_ITEMS); | |||
ssSet(CACHE_KEY, JSON.stringify({ at: Date.now(), items: items })); | |||
return items; | |||
}); | |||
} | |||
function lastSeen() { return +lsGet(SEEN_KEY) || 0; } | |||
function markSeen() { lsSet(SEEN_KEY, String(Date.now())); } | |||
// ── Updates page: the automatic list ───────────────────────── | |||
// Filled into <div id="gr-updates-feed"> on UPDATES_PAGE. | |||
// All styling lives in Common.css §37. | |||
var KIND = { 'new': ['New', 'gr-upd-new'], 'update': ['Updated', 'gr-upd-update'] }; | |||
// Today · This week · This month · then one group per month ("August 2026") | |||
var MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; | |||
function groupName(d, now) { | |||
var a = new Date(d.getFullYear(), d.getMonth(), d.getDate()); | |||
var b = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |||
var days = Math.round((b - a) / 86400000); | |||
if (days <= 0) return 'Today'; | |||
if (days < 7) return 'This week'; | |||
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) return 'Earlier this month'; | |||
return MONTHS_LONG[d.getMonth()] + ' ' + d.getFullYear(); | |||
} | |||
function renderFeed(box, items, seenBefore) { | |||
while (box.firstChild) box.removeChild(box.firstChild); | |||
if (items === null) { box.appendChild(el('p', 'gr-upd-empty', 'Loading…')); return; } | |||
if (!items.length) { box.appendChild(el('p', 'gr-upd-empty', 'No new or updated pages in the last ' + WINDOW_DAYS + ' days.')); return; } | |||
var now = new Date(), group = null, list = null; | |||
items.forEach(function (x) { | |||
var g = groupName(x.when, now); | |||
if (g !== group) { | |||
group = g; | |||
// A div, not an h3: the site's heading rules would restyle an h3. | |||
var h = el('div', 'gr-upd-group', g); | |||
h.setAttribute('role', 'heading'); h.setAttribute('aria-level', '3'); | |||
box.appendChild(h); | |||
list = el('div', 'gr-upd-list'); | |||
list.setAttribute('role', 'list'); | |||
box.appendChild(list); | |||
} | |||
var li = el('div', 'gr-upd-item' + (seenBefore && x.when.getTime() > seenBefore ? ' gr-upd-unseen' : '')); | |||
li.setAttribute('role', 'listitem'); | |||
var main = el('div', 'gr-upd-main'); | |||
var a = el('a', 'gr-upd-title', x.label); | |||
a.href = wikiHref(x.title); | |||
main.appendChild(a); | |||
var sub = el('div', 'gr-upd-sub'); | |||
sub.appendChild(el('span', 'gr-upd-tag ' + KIND[x.kind][1], KIND[x.kind][0])); | |||
if (x.sub) sub.appendChild(el('span', 'gr-upd-work', x.sub)); | |||
main.appendChild(sub); | |||
li.appendChild(main); | |||
var d = el('time', 'gr-upd-date', relDate(x.when, now)); | |||
d.setAttribute('datetime', x.when.toISOString()); | |||
d.title = absDate(x.when); | |||
li.appendChild(d); | |||
list.appendChild(li); | |||
}); | |||
} | |||
// ── Updates page: the bulletin card ───────────────────────── | |||
// Items are written on the page with {{Bulletin|…}}; this only | |||
// formats their dates, marks past events, and shows an empty note. | |||
function decorateBulletin() { | |||
var card = document.querySelector('.gr-bulletin'); | |||
if (!card) return; | |||
var now = new Date(), today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |||
var items = card.querySelectorAll('.gr-bulletin-item'); | |||
Array.prototype.forEach.call(items, function (it) { | |||
var raw = it.getAttribute('data-date') || ''; | |||
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim()); | |||
var d = m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; | |||
var slot = it.querySelector('.gr-bulletin-date'); | |||
if (d && slot) { slot.textContent = absDate(d); slot.setAttribute('title', raw); } | |||
// Past events stay listed, dimmed; hide them with until= on the item. | |||
if (d && it.getAttribute('data-type') === 'event' && d < today) it.classList.add('gr-bulletin-past'); | |||
var until = /^(\d{4})-(\d{2})-(\d{2})$/.exec((it.getAttribute('data-until') || '').trim()); | |||
if (until && new Date(+until[1], +until[2] - 1, +until[3]) < today) it.classList.add('gr-bulletin-expired'); | |||
}); | |||
var visible = card.querySelectorAll('.gr-bulletin-item:not(.gr-bulletin-expired)').length; | |||
card.classList.toggle('gr-bulletin-is-empty', visible === 0); | |||
var layout = card.closest('.gr-updates-layout'); | |||
if (layout) layout.classList.toggle('gr-no-bulletin', visible === 0); | |||
} | |||
function initUpdatesPage() { | |||
var box = document.getElementById('gr-updates-feed'); | |||
decorateBulletin(); | |||
if (!box || box.getAttribute('data-ready')) return; | |||
box.setAttribute('data-ready', '1'); | |||
var seenBefore = lastSeen(); | |||
renderFeed(box, null); | |||
loadUpdates().then(function (items) { | |||
renderFeed(box, items, seenBefore); | |||
markSeen(); | |||
}).catch(function () { renderFeed(box, []); }); | |||
} | |||
// ── header links ───────────────────────────────────────────── | |||
function updatesHref() { return wikiHref(UPDATES_PAGE); } | |||
// Which wiki page a link points at ("My wiki:Help"), or null. | |||
function linkTarget(a) { | |||
var href = a.getAttribute('href') || ''; | |||
if (!href || href.charAt(0) === '#') return null; // e.g. the Log out button | |||
var u; | |||
try { u = new URL(href, location.href); } catch (e) { return null; } | |||
if (u.origin !== location.origin) return null; | |||
var t = u.searchParams.get('title'); | |||
if (!t) { | |||
var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0]; | |||
if (u.pathname.indexOf(ap) !== 0) return null; | |||
t = u.pathname.slice(ap.length); | |||
} | |||
try { t = decodeURIComponent(t); } catch (e) { } | |||
return norm(t); | |||
} | |||
// Highlight whichever header / menu link is the page being viewed — | |||
// Help, About, Updates, Profile … not only the ones this block adds. | |||
function markCurrent() { | |||
var me = norm(cfg('wgPageName')); | |||
if (!me) return; | |||
var links = document.querySelectorAll('.gr-header-link, #gr-header-user-menu a, .gr-mob-item'); | |||
Array.prototype.forEach.call(links, function (a) { | |||
var on = a.tagName === 'A' && linkTarget(a) === me; | |||
a.classList.toggle('is-current', on); | |||
if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); | |||
}); | |||
} | |||
function injectDesktop() { | |||
if (document.getElementById('gr-library-link')) return true; | |||
var help = document.getElementById('gr-help-link'); | |||
var host = help ? help.parentNode : | |||
(document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header')); | |||
if (!host) return false; | |||
var lib = el('a', 'gr-header-link', LIBRARY_LABEL); | |||
lib.id = 'gr-library-link'; | |||
lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer'; | |||
lib.title = 'Grantha catalogue (opens in a new tab)'; | |||
var upd = el('a', 'gr-header-link', UPDATES_LABEL); | |||
upd.id = 'gr-updates-link'; | |||
upd.href = updatesHref(); | |||
if (help) { host.insertBefore(lib, help); host.insertBefore(upd, help); } | |||
else { host.appendChild(lib); host.appendChild(upd); } | |||
markCurrent(); | |||
return true; | |||
} | |||
function injectMobile() { | |||
if (document.getElementById('gr-mob-library')) return true; | |||
var menu = document.getElementById('gr-mob-menu-items'); | |||
if (!menu) return false; | |||
var lib = el('a', 'gr-mob-item', LIBRARY_LABEL); | |||
lib.id = 'gr-mob-library'; | |||
lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer'; | |||
var upd = el('a', 'gr-mob-item', UPDATES_LABEL); | |||
upd.id = 'gr-mob-updates'; | |||
upd.href = updatesHref(); | |||
var home = null; | |||
for (var i = 0; i < menu.children.length; i++) { | |||
if (/^\s*Home\s*$/.test(menu.children[i].textContent)) { home = menu.children[i]; break; } | |||
} | |||
var ref = home ? home.nextSibling : menu.firstChild; | |||
menu.insertBefore(lib, ref); | |||
menu.insertBefore(upd, ref); | |||
markCurrent(); | |||
return true; | |||
} | |||
// ── boot: the Help/About and mobile-menu code creates the anchors on | |||
// DOMContentLoaded, so wait briefly for them rather than assume order. | |||
function boot() { | |||
initUpdatesPage(); | |||
var inject = isMobile ? injectMobile : injectDesktop; | |||
if (inject()) return; | |||
var tries = 0; | |||
var obs = window.MutationObserver ? new MutationObserver(function () { if (inject()) obs.disconnect(); }) : null; | |||
if (obs) obs.observe(document.body, { childList: true, subtree: true }); | |||
(function poll() { | |||
if (inject()) { if (obs) obs.disconnect(); return; } | |||
if (++tries < 40) setTimeout(poll, 250); else if (obs) obs.disconnect(); | |||
}()); | |||
} | |||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | |||
else boot(); | |||
// Page previews and live-preview re-render content; re-run the page part then. | |||
if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { initUpdatesPage(); }); | |||
window.grUpdates = { relDate: relDate, summarise: summarise, groupName: groupName }; | |||
}()); | |||
/* ═══════════════════════════════════════════════════════════════ | |||
Main Page cards: item count beside each title, and a | |||
.gr-home-card-wide class on long lists. Styling: Common.css §25. | |||
═══════════════════════════════════════════════════════════════ */ | ═══════════════════════════════════════════════════════════════ */ | ||
( function () { | (function () { | ||
'use strict'; | |||
function | var WIDE_AT = 14; // cards with this many items or more span two columns | ||
function decorate() { | |||
var cards = document.querySelectorAll('.gr-home .gr-home-card'); | |||
Array.prototype.forEach.call(cards, function (card) { | |||
var title = card.querySelector('.gr-home-card-title'); | |||
var n = card.querySelectorAll('li').length; | |||
if (!n) return; | |||
if (n >= WIDE_AT) card.classList.add('gr-home-card-wide'); | |||
if (!title || title.querySelector('.gr-home-card-count')) return; | |||
var label = document.createElement('span'); | |||
while (title.firstChild) label.appendChild(title.firstChild); | |||
title.appendChild(label); | |||
var c = document.createElement('span'); | |||
c.className = 'gr-home-card-count'; | |||
c.textContent = String(n); | |||
title.appendChild(c); | |||
}); | |||
} | |||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', 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 | |||
document. | // rather than ten times. | ||
function notify() { | |||
if (pending) return; | |||
pending = setTimeout(function () { | |||
pending = null; | |||
var | listeners.forEach(function (f) { try { f(); } catch (e) {} }); | ||
}, 0); | |||
var | |||
} | } | ||
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 | 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' | |||
function | }; | ||
}); | |||
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 | |||
if ( | 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; | |||
var | // ---- CONFIGURE ME ------------------------------------------------- | ||
var | // 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 || '', | |||
if ( | 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, ' ')] || ''; | |||
}); | |||
} | |||
document. | // 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); | |||
}); | |||
}()); | |||