diff --git a/web/static/js/scripture-validator.v1.js b/web/static/js/scripture-validator.v1.js new file mode 100644 index 0000000..22cebaf --- /dev/null +++ b/web/static/js/scripture-validator.v1.js @@ -0,0 +1,161 @@ +/* Scripture Validator (Django static) — v1 + Usage: + 1) Include this file in your template: + + 2a) EITHER: add data attribute to any input/textarea to auto-enable: + + (Auto-runs on DOMContentLoaded) + 2b) OR: call programmatically: + ScriptureValidator.attach('#id_scripture_raw'); +*/ + +(function (global) { + const WOL = new Set([ + // OT + "Ge","Ex","Le","Nu","De","Jos","Jg","Ru","1Sa","2Sa","1Ki","2Ki","1Ch","2Ch","Ezr","Ne","Es","Job","Ps","Pr","Ec","Ca","Isa","Jer","La","Eze","Da","Ho","Joe","Am","Ob","Jon","Mic","Na","Hab","Zep","Hag","Zec","Mal", + // NT + "Mt","Mr","Lu","Joh","Ac","Ro","1Co","2Co","Ga","Eph","Php","Col","1Th","2Th","1Ti","2Ti","Tit","Phm","Heb","Jas","1Pe","2Pe","1Jo","2Jo","3Jo","Jude","Re" + ]); + + const FULL = new Map([ + // OT + ["genesis","Ge"],["exodus","Ex"],["leviticus","Le"],["numbers","Nu"],["deuteronomy","De"], + ["joshua","Jos"],["judges","Jg"],["ruth","Ru"], + ["1 samuel","1Sa"],["2 samuel","2Sa"],["1 kings","1Ki"],["2 kings","2Ki"], + ["1 chronicles","1Ch"],["2 chronicles","2Ch"], + ["ezra","Ezr"],["nehemiah","Ne"],["esther","Es"],["job","Job"],["psalms","Ps"],["psalm","Ps"], + ["proverbs","Pr"],["ecclesiastes","Ec"],["song of solomon","Ca"],["song of songs","Ca"], + ["isaiah","Isa"],["jeremiah","Jer"],["lamentations","La"],["ezekiel","Eze"],["daniel","Da"], + ["hosea","Ho"],["joel","Joe"],["amos","Am"],["obadiah","Ob"],["jonah","Jon"],["micah","Mic"], + ["nahum","Na"],["habakkuk","Hab"],["zephaniah","Zep"],["haggai","Hag"],["zechariah","Zec"],["malachi","Mal"], + // NT + ["matthew","Mt"],["mark","Mr"],["luke","Lu"],["john","Joh"],["acts","Ac"],["romans","Ro"], + ["1 corinthians","1Co"],["2 corinthians","2Co"],["galatians","Ga"],["ephesians","Eph"], + ["philippians","Php"],["colossians","Col"], + ["1 thessalonians","1Th"],["2 thessalonians","2Th"], + ["1 timothy","1Ti"],["2 timothy","2Ti"], + ["titus","Tit"],["philemon","Phm"],["hebrews","Heb"],["james","Jas"], + ["1 peter","1Pe"],["2 peter","2Pe"], + ["1 john","1Jo"],["2 john","2Jo"],["3 john","3Jo"], + ["jude","Jude"],["revelation","Re"] + ]); + + // Short full-name aliases (with/without trailing period, normalized below) + const ALIAS = new Map([ + // OT + ["gen","Ge"],["exod","Ex"],["lev","Le"],["num","Nu"],["deut","De"], + ["josh","Jos"],["judg","Jg"],["ps","Ps"],["prov","Pr"],["eccl","Ec"],["song","Ca"],["cant","Ca"], + ["isa","Isa"],["jer","Jer"],["lam","La"],["ezek","Eze"],["dan","Da"],["hos","Ho"],["joel","Joe"], + ["amos","Am"],["obad","Ob"],["jon","Jon"],["mic","Mic"],["nah","Na"],["hab","Hab"],["zeph","Zep"], + ["hag","Hag"],["zech","Zec"],["mal","Mal"], + // NT + ["matt","Mt"],["mark","Mr"],["luke","Lu"],["john","Joh"],["acts","Ac"],["rom","Ro"], + ["gal","Ga"],["eph","Eph"],["phil","Php"],["col","Col"],["heb","Heb"],["jas","Jas"], + ["jude","Jude"],["rev","Re"] + ]); + + // Numbered-series prose forms like "2 Sam.", "1 Chron.", "2 Cor.", "1 Thes.", "2 Tim.", "1 Pet.", "1 Jn." + const NUMBERED = [ + { prefixes: ["sam","samu","samuel"], codes: {1:"1Sa",2:"2Sa"} }, + { prefixes: ["ki","king","kings","kgs"], codes: {1:"1Ki",2:"2Ki"} }, + { prefixes: ["chron","chr","ch","chronicles"], codes: {1:"1Ch",2:"2Ch"} }, + { prefixes: ["cor","corin","corinth","corinthians","co","c"], codes: {1:"1Co",2:"2Co"} }, + { prefixes: ["thes","thess","thessalon","thessalonians","th"], codes: {1:"1Th",2:"2Th"} }, + { prefixes: ["tim","ti","timothy","t"], codes: {1:"1Ti",2:"2Ti"} }, + { prefixes: ["pet","pe","peter","pt","p"], codes: {1:"1Pe",2:"2Pe"} }, + { prefixes: ["jo","jn","joh","john","jno","jhn"], codes: {1:"1Jo",2:"2Jo",3:"3Jo"} }, + ]; + + // Chapter only | chapter:verses list | ranges | cross-chapter range + const versesRe = + /^(\d{1,3})$|^(\d{1,3}):\s*(\d{1,3}(?:\s*-\s*(?:\d{1,3}|\d{1,3}:\d{1,3}))?(?:\s*,\s*\d{1,3}(?:\s*-\s*(?:\d{1,3}|\d{1,3}:\d{1,3}))?)*)$/; + + function splitBookAndRest(s) { + const m = s.match(/^(.+?)\s+(\d{1,3}(?:\s*:\s*.*)?)$/); + return m ? {book: m[1], rest: m[2]} : null; + } + + function lookupBookCode(bookRaw) { + // Trim, drop trailing periods (any count), collapse spaces + let b = bookRaw.trim().replace(/\.+$/, "").replace(/\s+/g, " "); + const lower = b.toLowerCase(); + + // 1) Full-name match + if (FULL.has(lower)) return FULL.get(lower); + + // 2) Short full-name alias (un-numbered) + if (ALIAS.has(lower)) return ALIAS.get(lower); + + // 3) Numbered series, prose short forms (e.g., "2 Sam", "1 Chron", "1 Jn") + const m = lower.match(/^([1-3])\s*([a-z]+)$/); + if (m) { + const num = parseInt(m[1], 10); + const base = m[2]; + for (const series of NUMBERED) { + if (series.prefixes.some(p => base.startsWith(p))) { + const code = series.codes[num]; + if (code) return code; + } + } + } + + // 4) WOL abbreviations (allow optional space after number) + const abbr = b.replace(/^([1-3])\s+([A-Za-z]+)/, (_, n, letters) => n + letters); + if (WOL.has(abbr)) return abbr; + const abbrTight = b.replace(/\s+/g, ""); + if (WOL.has(abbrTight)) return abbrTight; + + return null; + } + + function isValidSingleRef(ref) { + const s = ref.trim(); + if (!s) return false; + + const parts = splitBookAndRest(s); + if (!parts) return false; + + const code = lookupBookCode(parts.book); + if (!code) return false; + + const rest = (parts.rest || "").trim(); + if (!rest) return false; + + return versesRe.test(rest); + } + + function validateElement(el) { + const raw = el.value || ""; + const parts = raw.split(";").map(t => t.trim()).filter(Boolean); + if (parts.length === 0) { + el.classList.remove("scripture-valid","scripture-invalid"); + return; + } + const allValid = parts.every(isValidSingleRef); + el.classList.toggle("scripture-valid", allValid); + el.classList.toggle("scripture-invalid", !allValid); + } + + function attach(target) { + const el = typeof target === "string" ? document.querySelector(target) : target; + if (!el) return; + const handler = () => validateElement(el); + el.addEventListener("input", handler); + el.addEventListener("change", handler); + validateElement(el); + } + + // Auto-enable for any element with data-scripture-validate + function autoscan() { + document.querySelectorAll("[data-scripture-validate]").forEach(attach); + } + + // Export public API + global.ScriptureValidator = { attach }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", autoscan); + } else { + autoscan(); + } +})(window); \ No newline at end of file