UNPKG

ebt-vue

Version:

Vue/Vuetify component library for EBT-Site

623 lines (583 loc) • 19.6 kB
(function (exports) { const { logger } = require("log-instance"); const { MerkleJson } = require("merkle-json"); const { Authors, Examples, SuidMap, SuttaCentralId, SuttaRef, } = require("scv-esm"); const VOICES = require("./voices.json"); const assert = require("assert"); class BilaraWeb { constructor(opts = {}) { (opts.logger || logger).logInstance(this, opts); this.examples = opts.examples || Examples; this.suidMap = opts.suidMap || SuidMap; this.suids = Object.keys(this.suidMap).sort(SuttaCentralId.compareLow); this.lang = opts.lang || "en"; this.mj = new MerkleJson(); this.maxResults = opts.maxResults == null ? 1000 : opts.maxResults; if (opts.fetch == null) { throw new Error("BilaraWeb() fetch callback is required"); } this.endpoints = Object.assign( { playSegment: "https://voice.suttacentral.net/scv/play/segment", audio: "https://voice.suttacentral.net/scv/audio", }, opts.endpoints ); this.fetch = opts.fetch; this.host = opts.host || "https://raw.githubusercontent.com"; this.authors = Authors.authors; this.includeUnpublished = opts.includeUnpublished === true; // private Object.defineProperty(this, "suttaCache", { value: {}, }); let matchHighlight = (this.matchHighlight = opts.matchHighlight || '<span class="ebt-matched">$&</span>'); this.highlightMatch = opts.highlightMatch || ((match) => matchHighlight.replace("$&", match) || match); } static decodeHash(hash = "") { let hq = hash.substring(1).split("?"); let hqParms = hq[1] && new URLSearchParams(`?${hq[1]}`); let search = hqParms && hqParms.get("search"); let hc = hq[0].split(":"); let [sutta_uid, lang, translator] = hc[0].split("/") || hc; let segnum = hc[1]; let result = {}; sutta_uid && (result.sutta_uid = sutta_uid); lang && (result.lang = lang); translator && (result.translator = translator); segnum && (result.segnum = segnum); search && (result.search = search); return result; } static encodeHash({ sutta_uid, lang, translator, segnum, search }) { let hash = "#"; sutta_uid && (hash += sutta_uid); lang && (hash += `/${lang}`); translator && (hash += `/${translator}`); segnum && (hash += `:${segnum}`); if (search) { let parms = new URLSearchParams(); parms.set("search", search); hash += `?${parms.toString()}`; } return hash; } static sanitizePattern(pattern) { if (!pattern) { throw new Error("search pattern is required"); } const MAX_PATTERN = 1024; var excess = pattern.length - MAX_PATTERN; if (excess > 0) { throw new Error( `Search text too long by ${excess} characters: ${pattern}` ); } // replace quotes (code injection on grep argument) pattern = pattern.replace(/["']/g, "."); // eliminate tabs, newlines and carriage returns pattern = pattern.replace(/\s/g, " "); // remove control characters pattern = pattern.replace(/[\u0000-\u001f\u007f]+/g, ""); // must be valid new RegExp(pattern); return pattern; } static normalizePattern(pattern) { // normalize white space to space pattern = pattern.trim().replace(/[\s]+/g, " ").toLowerCase(); return pattern; } get reExample() { var reExample = this._reExample; if (!reExample) { let examples = Object.assign({}, this.examples); delete examples.authors; delete examples.comments; reExample = Object.keys(examples).reduce((a, lang) => { let egLang = examples[lang].map((e) => BilaraWeb.sanitizePattern(e)); let pat = egLang.join("|\\b"); a[lang] = new RegExp(`\\b${pat}`, "gimu"); return a; }, {}); Object.defineProperty(this, "_reExample", reExample); } return reExample; } isExample(pattern, lang = this.lang) { return Examples.isExample(pattern); } exampleOfMatch(match, lang = "en") { let exLang = this.examples[lang] || []; return exLang.find((ex) => { let re = new RegExp(ex, "mui"); return re.test(match); }); } exampleGuid(example, lang = "en", verbose = false) { // TODO: THIS IS REALLY FRAGILE AND DEPENDS // ENTIRELY ON THE MEMOIZED FUNCTION IDENTATION IN // scv-bilara/src/seeker.js // IF THE IDENTATTION CHANGES, THE EXAMPLE GUIDS // WILL NO LONGER MATCH!!! const fbody = [ "(args) => {", " return that.slowFind.call(that, args);", " }", ].join("\n"); let { includeUnpublished } = this; let key = { volume: "Seeker.callSlowFind", fbody, args: [ { pattern: example, languages: lang === "en" ? ["pli", "en"] : ["pli", "en", lang], searchLang: lang, lang, showMatchesOnly: true, maxResults: 1000, maxDoc: 50, minLang: lang === "en" ? 2 : 3, matchHighlight: this.matchHighlight, types: ["root", "translation"], includeUnpublished, sortLines: undefined, // These are not serialized tipitakaCategories: undefined, // These are not serialized }, ], }; let guid = this.mj.hash(key); verbose && console.log( `bilaraWeb.exampleGuid(${example}, ${lang}) => ${guid}`, JSON.stringify(key, null, 2) ); return guid; } findArgs(args) { if (!(args instanceof Array)) { throw new Error("findArgs(?ARRAY-OF-ARGS?)"); } if (typeof args[0] === "string") { var opts = { pattern: args[0], maxResults: args[1], }; } else { var opts = args[0]; } var { pattern: rawPattern, searchLang, lang, language, // DEPRECATED languages, minLang, // minimum number of languages maxResults, // maximum number of grep files maxDoc, // maximum number of returned documents matchHighlight, sortLines, showMatchesOnly, tipitakaCategories, types, includeUnpublished = this.includeUnpublished, verbose, } = opts; if (rawPattern == null) { throw new Error(`pattern is required`); } // STEP 1. extract embeddable options var argv = rawPattern.split(" "); var pattern = ""; for (var i = 0; i < argv.length; i++) { var arg = argv[i]; if (arg === "-d" || arg === "--maxDoc") { let n = Number(argv[++i]); if (!isNaN(n) && 0 < n) { maxDoc = n; } } else if (arg === "-mr" || arg === "--maxResults") { let n = Number(argv[++i]); if (!isNaN(n) && 0 < n && n < 4000) { maxResults = n; } } else if (arg.startsWith("-tc:")) { tipitakaCategories = arg.substring("-tc:".length); } else if (arg === "-ml1") { minLang = 1; } else if (arg === "-ml2") { minLang = 2; } else if (arg === "-ml3") { minLang = 3; } else if (arg === "-ml" || arg === "--minLang") { let n = Number(argv[++i]); if (!isNaN(n) && 0 < n && n <= 3) { minLang = n; } } else if (arg === "-l" || arg === "--lang") { (arg = argv[++i]) && (lang = arg); } else if (arg === "-sl" || arg === "--searchLang") { (arg = argv[++i]) && (searchLang = arg); } else { pattern = pattern ? `${pattern} ${arg}` : arg; } } // STEP 2. Assign default values var thisLang = this.lang; lang = lang || language || thisLang; minLang = minLang || (lang === "en" || searchLang === "en" ? 2 : 3); pattern = BilaraWeb.sanitizePattern(pattern); pattern = BilaraWeb.normalizePattern(pattern); showMatchesOnly == null && (showMatchesOnly = true); languages = languages || []; lang && !languages.includes(lang) && languages.push(lang); maxResults = maxResults == null ? this.maxResults : maxResults; if (isNaN(Number(maxResults))) { throw new Error(`maxResults must be a number:${maxResults}`); } maxResults = Number(maxResults); maxDoc = Number(maxDoc == null ? this.maxDoc : maxDoc); matchHighlight == null && (matchHighlight = this.matchHighlight); types = types || ["root", "translation"]; return { pattern, showMatchesOnly, languages, maxResults, searchLang, maxDoc, minLang, matchHighlight, sortLines, tipitakaCategories, lang, types, includeUnpublished, verbose, }; } async find(...args) { try { var { fetch, findMemo, memoizer } = this; var { lang, pattern, verbose } = this.findArgs(args); var that = this; var callSlowFind = (args) => { return that.slowFind.call(that, args); }; var result; if (this.isExample(pattern, lang)) { let guid = this.exampleGuid(pattern, lang, verbose); let url = [ "https://raw.githubusercontent.com", "ebt-site", "ebt-vue", "main", "api", "Seeker.callSlowFind", guid.substring(0, 2), `${guid}.json`, ].join("/"); try { let res = await fetch(url, { headers: { Accept: "text/plain" } }); result = (await res.json()).value; } catch (e) { let guid = this.exampleGuid(pattern, lang, true); let err = new Error(`${url} => ${e.message}`); throw err; } } else { this.info(`find() non-example:`, pattern); } return result; } catch (e) { this.warn(`find(${pattern})`, e.message); throw e; } } highlightExamples({ segments, lang = this.lang }) { let highlightMatch = this.highlightMatch; let reLang = this.reExample[lang]; if (!reLang) { return segments; } return segments.map((seg) => { let segLang = seg[lang]; let newSeg = Object.assign({}, seg); return segLang ? Object.assign(newSeg, { [lang]: segLang.replace(reLang, highlightMatch), }) : newSeg; }); } suidPaths(suid = "") { var suidParts = suid.split("/"); var [key, lang, author] = suidParts; let map = this.suidMap[key]; let keys = map && Object.keys(map); if (author) { let patAuth = new RegExp(`/${author.toLowerCase()}$`); keys = keys.filter((k) => k.match(patAuth) || k.match(`/pli/`)); } else if (lang) { let patAuth = new RegExp(`/${lang.toLowerCase()}$`); let patLang = new RegExp(`/{lang.toLowerCase()/`); keys = keys.filter( (k) => k.match(patAuth) || k.match(patLang) || k.match(`/pli/`) ); } return ( map && keys.reduce((a, k) => { let v = map[k]; let kParts = k.split("/"); let vParts = v.split("/"); let suidParts = suid.split("/"); a[k] = `${k}/${v}/${suidParts[0]}_${kParts.join("-")}.json`; return a; }, {}) ); } bilaraPathOf(suttaRef) { let { sutta_uid, lang, author } = SuttaRef.create(suttaRef, null) || {}; let { authors, fetch, host, lang: defaultLang, includeUnpublished, } = this; let segments; let bilaraPaths = this.suidPaths(sutta_uid) || {}; let bpKeys = Object.keys(bilaraPaths); if (author) { bpKeys = bpKeys.filter((bp) => bp.endsWith(`/${author}`)); } else { bpKeys.length > 1 && bpKeys.sort((a, b) => { let [aTrans, aLang, aId] = a.split("/"); let [bTrans, bLang, bId] = b.split("/"); // Prioritize sutta selection by author exampleVersion let aAuth = authors[aId] || {}; let bAuth = authors[bId] || {}; let aEV = aAuth.exampleVersion || 0; let bEV = bAuth.exampleVersion || 0; let cmp = bEV - aEV; return cmp; }); } if (lang) { bpKeys = bpKeys.filter((key) => key.includes(`/${lang}/`)); } else if (author == null) { bpKeys = bpKeys.filter((key) => key.includes(`/${defaultLang}/`)); } let bpKey = bpKeys[0]; let bilaraPath = bilaraPaths[bpKey]; bilaraPath == null && this.info(`bilaraPathOf(${bpKey}) undefined:`, suttaRef); return bilaraPath; } async loadBilaraPath(bilaraPath) { assert(bilaraPath); let { authors, fetch, host, includeUnpublished } = this; let segments; let branch = includeUnpublished ? "unpublished" : "published"; let url = `${host}/suttacentral/bilara-data/${branch}/${bilaraPath}`; try { let res = await fetch(url, { headers: { Accept: "text/plain" } }); segments = await res.json(); } catch (e) { this.info(`loadBilaraPath(${sutta_uid}) ${url} => ${e.message}`); } let [segType, segLang, author] = bilaraPath.split("/") || []; let sutta = { bilaraPath, lang: segLang, author, segments, }; Object.defineProperty(sutta, "translator", { value: author }); // deprecated return sutta; } async loadSuttaRef(suttaRef, refLang) { try { suttaRef = SuttaRef.create(suttaRef, null); let { sutta_uid, lang, author, segnum } = suttaRef; this.info("loadSuttaRef", { sutta_uid, lang, author, segnum, refLang, }); let { suttaCache } = this; var url = ""; let key = [sutta_uid, lang, author].join("/"); let sutta = suttaCache[key]; if (sutta) { return sutta; } sutta = {}; assert(lang == null || typeof lang === "string"); // Load Pali first as main segment reference let pliBilaraPath = this.bilaraPathOf({ sutta_uid, lang: "pli" }); let { segments: pli = [] } = (await this.loadBilaraPath(pliBilaraPath)) || {}; let segMap = Object.keys(pli).reduce((a, scid) => { a[scid] = { scid, pli: pli[scid] }; return a; }, {}); // Load translation segments let transBilaraPath = this.bilaraPathOf({ sutta_uid, lang, author }); //assert(transBilaraPath, //`bilaraPathOf(${JSON.stringify({suttaRef, sutta_uid, lang, author})}`); let translation = transBilaraPath ? await this.loadBilaraPath(transBilaraPath) : {}; let [transRoot, transLang] = (transBilaraPath && transBilaraPath.split("/")) || []; let { translator, segments: langSegs = [] } = translation; Object.keys(langSegs).forEach((scid) => { segMap[scid] = segMap[scid] || { scid }; segMap[scid][transLang] = langSegs[scid]; }); // Load segments from reference language document if (refLang) { let refBilaraPath = this.bilaraPathOf({ sutta_uid, lang: refLang }); let reference = refBilaraPath ? await this.loadBilaraPath(refBilaraPath) : {}; let { translator: refAuthor, segments: refSegs = [] } = reference; sutta.refAuthor = refAuthor; Object.keys(refSegs).forEach((scid) => { segMap[scid] = segMap[scid] || { scid }; segMap[scid].ref = refSegs[scid]; }); } let segments = Object.keys(segMap) .sort(SuttaCentralId.compareLow) .map((scid) => segMap[scid]); segments = this.highlightExamples({ segments, lang }); let titleSegs = []; for (let s of segments) { if (!s.scid.includes(":0")) { break; } titleSegs.push(s); } let titles = titleSegs.map((s) => s[lang] || s.pli || ""); sutta = suttaCache[key] = Object.assign(sutta, { sutta_uid, lang: transLang, author: translator, titles, segments, }); Object.defineProperty(sutta, "translator", { value: translator }); // deprecated return sutta; } catch (e) { this.warn(`loadSuttaRef(${suttaRef}) ${url}`, e.message); throw e; } } async voices() { return VOICES; } langDefaultVoice(lang = "en") { return VOICES.filter((v) => v.langTrans === lang)[0]; } parseSuttaRef(suttaRef, defaultLang = this.lang) { if (typeof suttaRef === "string") { let { suids } = this; let refLower = suttaRef.toLowerCase(); let [ref, segnum] = refLower.split(":"); let [sutta_uid, lang = defaultLang, author] = ref .replace(/ /gu, "") .split("/"); let { compareLow, compareHigh } = SuttaCentralId; let keys = suids.filter((k) => { return ( compareLow(k, sutta_uid) <= 0 && compareHigh(sutta_uid, k) <= 0 ); }); if (keys.length === 1) { return { sutta_uid: keys[0], lang, author, segnum, }; } } else if (suttaRef) { let parsed = this.parseSuttaRef( suttaRef.sutta_uid, suttaRef.lang || defaultLang ); let { sutta_uid, lang, author, segnum } = parsed || {}; return { sutta_uid, lang, author: author || suttaRef.author || suttaRef.translator, segnum: segnum || suttaRef.segnum, }; } return null; } async segmentAudioUrls(opts = {}) { let { scid, lang = "en", translator = "sujato", vtrans = "amy", vroot = "aditi", } = opts; if (!scid) { throw new Error("segmentAudioUrls() required: scid"); } let { fetch, endpoints } = this; let suid = scid.split(":")[0]; let url = [ endpoints.playSegment, suid, lang, translator, scid, vtrans, vroot, ].join("/"); try { var res = await fetch(url); var json = await res.json(); var audio = json.segment.audio; } catch (e) { console.error(`HTTP${res.status} ${url}`, e.message); throw e; } let result = Object.keys(audio).reduce((a, k) => { if (k !== "vnameTrans") { let prefix = [endpoints.audio, suid, k]; a[k] = [endpoints.audio, suid, k] .concat( k === "pli" ? ["ms", vroot, audio[k]] : [translator, vtrans, audio[k]] ) .join("/"); } return a; }, {}); return result; } } module.exports = exports.BilaraWeb = BilaraWeb; })(typeof exports === "object" ? exports : (exports = {}));