// 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, '>'); } // Build the inner HTML for a segment's .segment-text from a typed ref. // Words are wrapped in (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 '' + escapeHtml(tok) + ''; }).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 } })();