Spaces:
Running on Zero
Running on Zero
| // ref-preview.js — instant client-side ref→Arabic-text preview. | |
| // | |
| // Loads the Digital Khatt word bundle ({dk_words, verse_word_counts}) once per | |
| // session and resolves a typed reference to display HTML with zero server | |
| // round-trips, so the segment text updates live as the user types a ref. | |
| // | |
| // Pure helpers mirror the inspector | |
| // (inspector/.../tabs/segments/utils/data/references.ts) and the server | |
| // normalizer (pipeline.py::_normalize_ref) so the live preview matches the | |
| // authoritative HTML the server returns on commit. Verse markers use the same | |
| // glyph as segments.py::format_verse_marker ('' + Arabic-Indic numeral). | |
| (function () { | |
| 'use strict'; | |
| var ARABIC_DIGITS = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩']; | |
| function toArabicNumeral(n) { | |
| return String(n).split('').map(function (d) { | |
| var i = parseInt(d, 10); | |
| return isNaN(i) ? d : ARABIC_DIGITS[i]; | |
| }).join(''); | |
| } | |
| // Normalize a user-typed ref to canonical "s:a:w-s:a:w" form, or null if | |
| // invalid. Mirrors pipeline.py::_normalize_ref (minus special codes, which | |
| // aren't in dk_words and yield no client preview). `vwc` is the flat | |
| // verse_word_counts map ("s:a" -> count). | |
| function normalizeRef(raw, vwc) { | |
| if (raw == null) return null; | |
| raw = String(raw).trim(); | |
| if (!raw) return null; | |
| vwc = vwc || {}; | |
| function vwCount(surah, ayah) { | |
| var n = vwc[surah + ':' + ayah]; | |
| return (typeof n === 'number') ? n : 0; | |
| } | |
| function num(parts) { | |
| var out = []; | |
| for (var i = 0; i < parts.length; i++) { | |
| var v = parseInt(parts[i], 10); | |
| if (isNaN(v) || String(v) !== String(parts[i]).trim()) return null; | |
| out.push(v); | |
| } | |
| return out; | |
| } | |
| if (raw.indexOf('-') !== -1) { | |
| var halves = raw.split('-'); | |
| if (halves.length !== 2) return null; | |
| var sp = halves[0].split(':'); | |
| var ep = halves[1].split(':'); | |
| // Verse range: "76:1-76:2" -> "76:1:1-76:2:N" | |
| if (sp.length === 2 && ep.length === 2) { | |
| var vr = num(sp.concat(ep)); | |
| if (!vr) return null; | |
| var n = vwCount(vr[2], vr[3]); | |
| if (n === 0) return null; | |
| return vr[0] + ':' + vr[1] + ':1-' + vr[2] + ':' + vr[3] + ':' + n; | |
| } | |
| if (sp.length < 3) return null; | |
| // Short forms: "2:255:1-6" / "2:255:1-255:6" | |
| if (ep.length === 1) ep = [sp[0], sp[1], ep[0]]; | |
| else if (ep.length === 2) ep = [sp[0], ep[0], ep[1]]; | |
| var s = num(sp.slice(0, 3)); | |
| var e = num(ep.slice(0, 3)); | |
| if (!s || !e) return null; | |
| return s[0] + ':' + s[1] + ':' + s[2] + '-' + e[0] + ':' + e[1] + ':' + e[2]; | |
| } | |
| var parts = raw.split(':'); | |
| // Whole verse: "76:5" -> "76:5:1-76:5:N" | |
| if (parts.length === 2) { | |
| var wv = num(parts); | |
| if (!wv) return null; | |
| var nw = vwCount(wv[0], wv[1]); | |
| if (nw === 0) return null; | |
| return wv[0] + ':' + wv[1] + ':1-' + wv[0] + ':' + wv[1] + ':' + nw; | |
| } | |
| // Single word: "2:255:5" -> "2:255:5-2:255:5" | |
| if (parts.length < 3) return null; | |
| var sw = num(parts.slice(0, 3)); | |
| if (!sw) return null; | |
| var r = sw[0] + ':' + sw[1] + ':' + sw[2]; | |
| return r + '-' + r; | |
| } | |
| // Parse a canonical "s:a:w-s:a:w" ref into endpoints, or null. | |
| function parseSegRef(ref) { | |
| if (!ref) return null; | |
| var halves = ref.split('-'); | |
| if (halves.length !== 2 || !halves[0] || !halves[1]) return null; | |
| var s = halves[0].split(':'), e = halves[1].split(':'); | |
| if (s.length < 3 || e.length < 3) return null; | |
| var p = { | |
| surah: +s[0], ayah_from: +s[1], word_from: +s[2], | |
| ayah_to: +e[1], word_to: +e[2] | |
| }; | |
| for (var k in p) { | |
| if (!isFinite(p[k])) return null; | |
| } | |
| return p; | |
| } | |
| // Walk dk_words from the start endpoint through the end endpoint | |
| // (inclusive). Returns space-joined display text, or '' for malformed input. | |
| // Mirrors references.ts::dkTextForRef. | |
| function dkTextForRef(ref, dkWords, vwc) { | |
| if (!ref || !dkWords) return ''; | |
| var halves = ref.split('-'); | |
| if (halves.length !== 2 || !halves[0] || !halves[1]) return ''; | |
| var sP = halves[0].split(':'), eP = halves[1].split(':'); | |
| if (sP.length !== 3 || eP.length !== 3) return ''; | |
| var sSu = +sP[0], sAy = +sP[1], sW = +sP[2]; | |
| var eSu = +eP[0], eAy = +eP[1], eW = +eP[2]; | |
| if (![sSu, sAy, sW, eSu, eAy, eW].every(isFinite)) return ''; | |
| var maxAyah = (typeof window !== 'undefined' && window.QL_REF_MAX_AYAH) || 300; | |
| var su = sSu; | |
| var words = []; | |
| var ay = sAy, w = sW; | |
| function lte() { | |
| return su < eSu || (su === eSu && (ay < eAy || (ay === eAy && w <= eW))); | |
| } | |
| while (lte()) { | |
| var t = dkWords[su + ':' + ay + ':' + w]; | |
| if (t) words.push(t); | |
| w++; | |
| var max = vwc ? (vwc[su + ':' + ay] || 0) : 0; | |
| if (w > max) { | |
| w = 1; | |
| ay++; | |
| if (ay > maxAyah) break; | |
| } | |
| } | |
| return words.join(' '); | |
| } | |
| // Insert verse-end markers at verse boundaries. Mirrors | |
| // references.ts::_addVerseMarkers + segments.py::format_verse_marker. | |
| function addVerseMarkers(text, ref, vwc) { | |
| if (!text || !ref) return text || ''; | |
| var p = parseSegRef(normalizeRef(ref, vwc)); | |
| if (!p || !vwc) return text; | |
| var words = text.split(/\s+/).filter(Boolean); | |
| var out = []; | |
| var ay = p.ayah_from, w = p.word_from; | |
| for (var i = 0; i < words.length; i++) { | |
| var word = words[i]; | |
| if (word == null) continue; | |
| out.push(word); | |
| if (!/[-ٯ]/.test(word)) continue; | |
| var total = vwc[p.surah + ':' + ay] || 0; | |
| if (total > 0 && w >= total) { | |
| out.push('' + toArabicNumeral(ay)); | |
| ay++; | |
| w = 1; | |
| } else { | |
| w++; | |
| } | |
| } | |
| return out.join(' '); | |
| } | |
| function escapeHtml(s) { | |
| return String(s) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| } | |
| // Build the inner HTML for a segment's .segment-text from a typed ref. | |
| // Words are wrapped in <span class="word"> (no data-pos — added | |
| // authoritatively by the server patch on commit); verse markers are bare | |
| // tokens, matching segments.py::get_text_with_markers. Returns '' for | |
| // empty / invalid / special refs (specials aren't in dk_words). | |
| function previewHtml(rawRef) { | |
| var refs = getRefs(); | |
| if (!refs) return ''; | |
| var norm = normalizeRef(rawRef, refs.verse_word_counts); | |
| if (!norm) return ''; | |
| var text = dkTextForRef(norm, refs.dk_words, refs.verse_word_counts); | |
| if (!text) return ''; | |
| var marked = addVerseMarkers(text, norm, refs.verse_word_counts); | |
| return marked.split(/\s+/).filter(Boolean).map(function (tok) { | |
| if (tok.charAt(0) === '') return tok; // bare verse marker | |
| return '<span class="word">' + escapeHtml(tok) + '</span>'; | |
| }).join(' '); | |
| } | |
| // ---- Bundle loader (browser only) --------------------------------------- | |
| var _refs = null; // in-memory cache | |
| var _inflight = null; // shared load promise | |
| function getRefs() { | |
| return _refs; | |
| } | |
| function _sessionKey(version) { | |
| return 'ql-refs:' + version; | |
| } | |
| function loadRefs() { | |
| if (_refs) return Promise.resolve(_refs); | |
| if (_inflight) return _inflight; | |
| if (typeof window === 'undefined' || typeof fetch === 'undefined') { | |
| return Promise.resolve(null); | |
| } | |
| var url = window.QL_REFS_URL; | |
| var version = window.QL_REFS_VERSION || ''; | |
| if (!url) return Promise.resolve(null); | |
| // sessionStorage cache (evict stale-hash entries) | |
| try { | |
| var cached = window.sessionStorage.getItem(_sessionKey(version)); | |
| if (cached) { | |
| _refs = JSON.parse(cached); | |
| return Promise.resolve(_refs); | |
| } | |
| for (var i = window.sessionStorage.length - 1; i >= 0; i--) { | |
| var k = window.sessionStorage.key(i); | |
| if (k && k.indexOf('ql-refs:') === 0) window.sessionStorage.removeItem(k); | |
| } | |
| } catch (e) { /* sessionStorage unavailable — fall through to fetch */ } | |
| _inflight = fetch(url + (version ? '?v=' + encodeURIComponent(version) : '')) | |
| .then(function (r) { return r.json(); }) | |
| .then(function (data) { | |
| _refs = data; | |
| try { | |
| window.sessionStorage.setItem(_sessionKey(version), JSON.stringify(data)); | |
| } catch (e) { /* quota — in-memory cache still serves the session */ } | |
| return _refs; | |
| }) | |
| .catch(function (err) { | |
| console.warn('[ref-preview] bundle load failed', err); | |
| _inflight = null; | |
| return null; | |
| }); | |
| return _inflight; | |
| } | |
| var QLRefPreview = { | |
| toArabicNumeral: toArabicNumeral, | |
| normalizeRef: normalizeRef, | |
| parseSegRef: parseSegRef, | |
| dkTextForRef: dkTextForRef, | |
| addVerseMarkers: addVerseMarkers, | |
| previewHtml: previewHtml, | |
| loadRefs: loadRefs, | |
| getRefs: getRefs | |
| }; | |
| if (typeof window !== 'undefined') { | |
| window.QLRefPreview = QLRefPreview; | |
| // Warm the cache so the bundle is ready before the first ref edit. | |
| if (typeof window.addEventListener === 'function') { | |
| loadRefs(); | |
| } | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = QLRefPreview; // node — parity/unit tests | |
| } | |
| })(); | |