UNPKG

@ibgib/helper-gib

Version:

common helper/utils/etc used in ibgib libs. Node v19+ needed for heavily-used isomorphic webcrypto hashing consumed in both node and browsers.

1,282 lines 47.2 kB
import { HELPER_LOG_A_LOT } from '../constants.mjs'; import { Ssml } from './ssml-helper.mjs'; const logalot = HELPER_LOG_A_LOT; export const LanguageCode = { en: "en", de: "de", enUS: "en-US", enGB: "en-GB", enCA: "en-CA", enIN: "en-IN", deDE: "de-DE", }; /** * keywords are used to filter lex items. this sets how those keywords are * interpreted. */ export const KeywordMode = { /** Any of the keywords must match to return a lex result. */ any: "any", /** All of the keywords must match to return a lex result. */ all: "all", /** Only return results that do NOT include any of the keywords. */ none: "none", }; export const PropertyPredicateLevel = { /** * Predicate acts upon individual properties. * So you'll pass an object in with prop key and a predicate(s). * * Check out advanced unit tests for more concrete examples. * * @example props: { id: x => x === "id2", color: x => x === "orange" } */ prop: "prop", /** * Predicate acts upon the datum's entire props object. * So you'll pass a single predicate that is the entire props * object, i.e. datum[props]. * * Check out advanced unit tests for more concrete examples. * * @example { props: (p: PropsData) => { return p && p.id && p.id === "id1"; }, propsMode: "props" */ props: "props", }; export const LexCapitalize = { /** * Uppercase the first letter of only the first line in texts/ssmls. */ upperfirst: "upperfirst", /** * Uppercase the first letter of each line in texts/ssmls. */ uppereach: "uppereach", /** * Lowercase the first letter of only the first line in texts/ssmls. */ lowerfirst: "lowerfirst", /** * Lowercase the first letter of each line in texts/ssmls. */ lowereach: "lowereach", /** * Leave the casing as-is for texts/ssmls. */ none: "none", }; export const LexLineConcat = { /** * Each line will be combined into a single string of paragraphs. * @example * ["Line 1.", "Line 2."] will become * if text: "Line 1.\n\nLine 2." * if ssml: "<p>Line 1.</p><p>Line 2.</p>" */ p: "paragraph", paragraph: "paragraph", /** * Each line will be combined into a single string of sentences. * @example * ["Line 1.", "Line 2."] will become * if text: "Line 1. Line 2." * if ssml: "<s>Line 1</s><s>Line 2</s>" */ s: "sentence", sentence: "sentence", /** * Each line will be combined into a single string with new * line feeds between each line. * * Note: For Ssml, this is the same as "paragraph". * * @example * ["Line 1.", "Line 2."] will become * if text: "Line 1.\nLine 2." * if ssml: "<p>Line 1.</p><p>Line 2.</p>" */ n: "newline", newline: "newline", /** * Each line will be combined into a single string with each * line delimited by the delimiter specified in the function. * * @example * ["Line 1.", "Line 2."] with delim | will become * if text: "Line 1.|Line 2." * if ssml: "Line 1.|Line 2." */ delim: "delim", }; // export * from "./types"; // import { LanguageCode, KeywordMode, LexData, LexDatum, LexGetOptions, LexResultObj, LexCapitalize, LexLineConcat, PropsFilterMode, PropsFilter, PropsData } from './types'; /** * Imports helper that has logging, among other things. */ // import * as help from 'helper-gib'; /** * Lex is a helper for your lexical data, i.e. the things that you get * Alexa to say. This can be used for i18n, but really it's a broader * helper to create more dynamic speech/text for Alexa to say and * present via cards. * * I am making this after learning my lessons with creating dynamic, * alternative-laden text and/or ssml generation for use with both * Alexa's speech, as well as outputting plain text to * cards. I'm designing it to be (actually) simple to use, but with * robustness allowed the more you become comfortable with it. * * Simple Usage * * To use it, you simply init what you want her to be able to say. * Then, when you want to create her speech, you call `text` or `ssml` * and pass in your options, the primary one being the `id`. * * For example, you could define the following data: ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ]} ] } ``` * To access this, you would call `Lex._('hi').text` to simply get the * plain text entry for "hi". * * Alternatives * * But there are a LOT of ways to say "hi", and this is the primary * reason for using Lex: Alternatives. With Lex, multiple items with * the same id are considered alternatives. * ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ] }, { texts: [ "Hello" ] }, { texts: [ "Howdy" ] } ] } ``` * Again, to access this, you would call the same line: * `Lex._('hi').text * * So by using the _same_ calling code, you could get any one of these * texts as _alternatives_ for the "hi" lex datum. This is a huge * difference between natural voice interaction and computer UI as we * have known it up to now. * * If you want to get really fancy (looking forward to AI/ML), you can * weight the various alternatives, for example if you want to only * say "Howdy" a small percentage of the time. You could define this as follows: * ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ] }, { texts: [ "Hello" ] }, { texts: [ "Howdy" ], weighting: 0.2 } ] } ``` * Again, there is _no_ change to the calling code. This really allows * for a wonderful layer of dynamicism, and is easy to do. * * Internationalization (i18n) * * You can have your text be localized, but not worry about it to start * off with. It's _implicit_ i18n. So the above examples are actually * not really attached to any language, even though I'm writing in * English (en-US). This is because the i18n aspect relies on both the * data and the retrieval of the data via the `language` param option. * ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ] }, { texts: [ "Hello" ] }, { texts: [ "Howdy" ], weighting: 0.2 }, { texts: [ "Cheers" ], language: "en-GB" }, { texts: [ "Guten Tag" ], language: "de-DE" } ] } ``` /** * Lex is a helper for your lexical data, i.e. the things that you get * Alexa to say. This can be used for i18n, but really it's a broader * helper to create more dynamic speech/text for Alexa to say and * present via cards. * * I am making this after learning my lessons with creating dynamic, * alternative-laden text and/or ssml generation for use with both * Alexa's speech, as well as outputting plain text to * cards. I'm designing it to be (actually) simple to use, but with * robustness allowed the more you become comfortable with it. * * Simple Usage * * To use it, you simply init what you want her to be able to say. * Then, when you want to create her speech, you call `text` or `ssml` * and pass in your options, the primary one being the `id`. * * For example, you could define the following data: ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ]} ] } ``` * To access this, you would call `Lex._('hi').text` to simply get the * plain text entry for "hi". * * Alternatives * * But there are a LOT of ways to say "hi", and this is the primary * reason for using Lex: Alternatives. With Lex, multiple items with * the same id are considered alternatives. * ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ] }, { texts: [ "Hello" ] }, { texts: [ "Howdy" ] } ] } ``` * Again, to access this, you would call the same line: * `Lex._('hi').text * * So by using the _same_ calling code, you could get any one of these * texts as _alternatives_ for the "hi" lex datum. This is a huge * difference between natural voice interaction and computer UI as we * have known it up to now. * * If you want to get really fancy (looking forward to AI/ML), you can * weight the various alternatives, for example if you want to only * say "Howdy" a small percentage of the time. You could define this as follows: * ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ] }, { texts: [ "Hello" ] }, { texts: [ "Howdy" ], weighting: 0.2 } ] } ``` * Again, there is _no_ change to the calling code. This really allows * for a wonderful layer of dynamicism, and is easy to do. * * Internationalization (i18n) * * You can have your text be localized, but not worry about it to start * off with. It's _implicit_ i18n. So the above examples are actually * not really attached to any language, even though I'm writing in * English (en-US). This is because the i18n aspect relies on both the * data and the retrieval of the data via the `language` param option. * ``` const data: LexData = { 'hi': [ { texts: [ "Hi" ] }, { texts: [ "Hello" ] }, { texts: [ "Howdy" ], weighting: 0.2 }, { texts: [ "Cheers" ], language: "en-GB" }, { texts: [ "Guten Tag" ], language: "de-DE" } ] } ``` * * You can get at these languages multiple ways: * * 1) Choose the language when instantiating Lex. * `let lex = new Lex(data, "de-DE");` * Now, when you call `lex._(...)`, you will only return German * data. * 2) Override the default language upon calling for data: * `lex._('hi', { language: "en-US" }).text;` */ export class Lex { constructor( /** * This is the initial lexical data that you want Alexa to be able to * say. You can always change this dynamically at runtime as well. */ data, /** optional opts */ { /** * This is the language that your data will default to. * If a language isn't specified in `get`, `text`, or `ssml`, then this is used. * * This means that entries defined in the Lex data that do not * have an explicit 'language' set will be interpreted as this * language. * * So basically, if you're an American with American data, leave * this as en-US. If you're a German speaker writing a skill * that is primarily targeted at a German-speaking audience, * then set this to de-DE and you don't need to explicitly * set each entry to this. * * Then, when you go to translate into other languages, you can * add on the explicit language markers in data. The overall * mechanism allows you to skip this for the first language * you write the skill in. * * @see requestLanguage */ defaultLanguage = "en-US", requestLanguage = "en-US", /** * Defaults to delim & "" because most of the time, I find I * just have a single line and want the single thing returned. * This helps with templating, chunking, etc. * * BREAKING CHANGE: This formerly defaulted to paragraphs, as * I thought lines would mean paragraphs. No longer the case. */ defaultLineConcat = LexLineConcat.delim, /** * Defaults to delim & "" because most of the time, I find I * just have a single line and want the single thing returned. * This helps with templating, chunking, etc. * * BREAKING CHANGE: This formerly defaulted to paragraphs, as * I thought lines would mean paragraphs. No longer the case. */ defaultDelim = "", defaultCapitalize = "none", defaultKeywordMode = "any", defaultPropsMode = "prop", }) { this.lc = `[${Lex.name}]`; if (!data) { throw new Error(`data required (E: 2f8db30fa9d71d76db616ab110392c22)`); } this.data = data; this.defaultLanguage = defaultLanguage ?? "en-US"; this.defaultLineConcat = defaultLineConcat ?? LexLineConcat.delim; this.defaultDelim = defaultDelim ?? ""; this.defaultCapitalize = defaultCapitalize ?? "none"; this.requestLanguage = requestLanguage ?? "en-US"; this.defaultKeywordMode = defaultKeywordMode ?? "any"; this.defaultPropsMode = defaultPropsMode ?? "prop"; } /** * Gets a string or array of strings of text or ssml. * Builds the string or obj depending on the passed in options. * * NOTE: You'll probably want to actually use the `text` or `ssml` * functions instead of this one. * * @param id Lexical items with the same id are considered alternatives for the equivalent message, e.g. "Hello" and "Howdy". * @see LexGetOptions */ get(id, { language = this.requestLanguage, specifier, keywords, keywordMode = this.defaultKeywordMode, lineIndex, lineConcat = this.defaultLineConcat, lineConcatDelim = this.defaultDelim, capitalize = this.defaultCapitalize, vars, ssmlVars, props, propsMode = this.defaultPropsMode, fnDatumPredicate, } = { // simplest case default options language: this.requestLanguage, keywordMode: this.defaultKeywordMode, lineConcat: this.defaultLineConcat, lineConcatDelim: this.defaultDelim, capitalize: this.defaultCapitalize, propsMode: this.defaultPropsMode, }) { const lc = `${this.lc}[${this.get.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: e4e76a6bb301010fc13cf4456efcdc22)`); } let lexData = this.data[id]; if (!lexData) { throw new Error(`Data id not found: ${id} (E: 4a79897167714dd5a34df77953072aaf)`); } lexData = this.filterLexData({ lexData, language, specifier, keywords, keywordMode, props: props, propsMode, fnDatumPredicate, }); if (lexData.length === 0) { // no data found matching filtering. // just return null and do not error. return null; } const lexDatum = this.pickDatum(lexData); let textLines = this.extractLines({ lexDatum, resultAs: "text", lineIndex }); textLines = this.replaceTemplateRefs({ lines: textLines, resultAs: "text" }); // Replace vars after references, so all text is fully // expanded first. textLines = this.replaceTemplateVars({ lines: textLines, vars: vars }); textLines = this.capitalizeLines({ lines: textLines, resultAs: "text", capitalize }); const text = this.concatLines({ lines: textLines, lineType: "text", lineConcat, lineConcatDelim }); let ssmlLines = this.extractLines({ lexDatum, resultAs: "ssml", lineIndex }); ssmlLines = this.replaceTemplateRefs({ lines: ssmlLines, resultAs: "ssml" }); // Replace vars after references, so all text is fully // expanded first. ssmlLines = this.replaceTemplateVars({ lines: ssmlLines, vars: ssmlVars || vars }); ssmlLines = this.capitalizeLines({ lines: ssmlLines, resultAs: "ssml", capitalize }); const ssml = this.concatLines({ lines: ssmlLines, lineType: "ssml", lineConcat, lineConcatDelim }); return { text, ssml, datum: lexDatum, rawData: lexData }; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Does a reverse lookup for lex data ids that correspond to the find * criteria. * * @returns array of data ids that have at least one LexDatum entry that matches criteria. */ find({ fnDatumPredicate }) { const lc = `${this.lc}[${this.find.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 35e26d234ca2d0e987b39ee939f29c22)`); } if (!fnDatumPredicate) { throw new Error(`only fnDatumPredicate implemented atow (E: 8c8babd2fa6fc4cb223b7b8c10ad5c22)`); } const results = {}; const ids = Object.keys(this.data); for (let i = 0; i < ids.length; i++) { const id = ids[i]; const matchingDatums = this.data[id].filter(d => fnDatumPredicate(d)); if (matchingDatums.length > 0) { results[id] = matchingDatums; } } return Object.keys(results).length > 0 ? results : null; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * This is the original single function. it is just for backwards compatibility at this point. * Probably not needed... * * @deprecated */ _(id, opts) { return this.get(id, opts); } // #region syntactic sugar calls for `get` /** * just syntactic sugar for {@link Lex.get} . */ getVariant(id, opts) { return this.get(id, opts); } /** * just syntactic sugar for {@link Lex.get} . */ variant(id, opts) { return this.get(id, opts); } /** * just syntactic sugar for {@link Lex.get} . */ getTranslation(id, opts) { return this.get(id, opts); } /** * just syntactic sugar for {@link Lex.get} . */ translate(id, opts) { return this.get(id, opts); } /** * just syntactic sugar for {@link Lex.get} . */ getSynonym(id, opts) { return this.get(id, opts); } /** * just syntactic sugar for {@link Lex.get} . */ synonym(id, opts) { return this.get(id, opts); } /** * just syntactic sugar for {@link Lex.get} . */ getI18n(id, opts) { return this.get(id, opts); } /** * just syntactic sugar for {@link Lex.get} . */ i18n(id, opts) { return this.get(id, opts); } // #endregion syntactic sugar calls for `get` /** * Pulls out lines from the datum, based on the what is wanted * and what exists in the data. * * For example, you may be trying to extract text but only ssml * is defined in the data. So you'll have to strip the ssml and * return that. Or if you want ssml and only text exists in the * data, then you'll simply return the texts. * * If you only want a single line out of multiple strings in the * texts/ssmls array, then use lineIndex. * * @param param0 info */ extractLines({ lexDatum, resultAs, lineIndex }) { const lc = `${this.lc}[${this.extractLines.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 0c21a7ed7c1b56251f39342380c47922)`); } // ensure that either texts or ssmls is defined in data if ((!lexDatum.texts || lexDatum.texts.length === 0) && (!lexDatum.ssmls || lexDatum.ssmls.length === 0)) { throw new Error(`Invalid lexDatum. Datum texts and ssmls are both undefined. lexDatum: ${JSON.stringify(lexDatum)}.`); } // let lines; let useLineIndex = lineIndex || lineIndex === 0; if (resultAs === "text" && lexDatum.texts && lexDatum.texts.length > 0) { // text wanted, text defined in data return useLineIndex ? [lexDatum.texts[lineIndex]] : lexDatum.texts; } else if (resultAs === "text") { // text wanted, but no text defined in data console.warn(`building text lines from ssml (W: cfb59a05efda475ab7511acc659a5ee3)`); return useLineIndex ? [Ssml.stripSsml(lexDatum.ssmls[lineIndex])] : lexDatum.ssmls.map(ssml => Ssml.stripSsml(ssml)); } else if (lexDatum.ssmls && lexDatum.ssmls.length > 0) { // ssml wanted, ssml defined in data return useLineIndex ? [lexDatum.ssmls[lineIndex]] : lexDatum.ssmls; } else { // ssml wanted, but no ssml defined in data return useLineIndex ? [lexDatum.texts[lineIndex]] : lexDatum.texts; } } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Replaces any embedded template variables, e.g. $name, $0, etc. * Note the format is "$" proceeded by any word characters * ([a-zA-Z0-9_]). * * This is different than template references. * * @see {replaceTemplateRefs} */ replaceTemplateVars({ lines, vars, }) { const lc = `${this.lc}[${this.replaceTemplateVars.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: a756e7603d03d427c60c202cb4f6dd22)`); } let replaceVarsSingleLine = (line) => { let varNames = Object.keys(vars || {}); if (logalot) { console.log(`${lc} varNames: ${JSON.stringify(varNames)} (I: d9351cf582420b35eb3eceb923e64f22)`); } return varNames.reduce((l, varName) => { if (logalot) { console.log(`${lc} varName: ${varName} (I: 8c0e4261972945d2bb4624efad14870b)`); } return l.replace(new RegExp('\\$' + varName, "g"), vars[varName]); }, line); }; if (vars) { return lines.map(line => replaceVarsSingleLine(line)); } else { return lines; } } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Replaces any embedded template references, e.g. $(hi). Note the * parenthesis around "hi". This means it is a reference to another lex * datum. * * This is different than template variables, e.g. $name, $0, etc. * * The template refs can be recursive, i.e. datum A can include a ref to * datum B which includes a template to datum C. But these cannot be * self-referencing, i.e. C cannot then include a reference back to A. * * Template refs CANNOT work with props for filtering, as these require * lambda functions. * * @see {replaceTemplateVars} */ replaceTemplateRefs({ lines, resultAs, }) { const lc = `${this.lc}[${this.replaceTemplateRefs.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 548949b651bbec84de5b25f3c01cf422)`); } const regex = /\$\([\w-]+\|?[\w-|\{\}:'"\s,\[\].,<>]+\)/; let replaceRefsSingleLine = (line) => { let match = regex.exec(line); if (match) { let template = match[0]; // strip the $() template = template.substring(2, template.length - 1); // id|options const idAndOptions = template.split('|'); const id = idAndOptions[0]; const options = idAndOptions.length === 2 ? JSON.parse(idAndOptions[1]) : {}; if (!options.lineConcat) { options.lineConcat = LexLineConcat.delim; options.lineConcatDelim = ""; } const replacementResult = this.get(id, options); const replacement = resultAs === "text" ? replacementResult.text : replacementResult.ssml; line = line.replace(regex, replacement); // recursively call if more templates in line return regex.test(line) ? replaceRefsSingleLine(line) : line; } else { return line; } }; return lines.map(line => replaceRefsSingleLine(line)); } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Capitalizes the given lines depending on the given * capitalize options. * * @param param0 */ capitalizeLines({ lines, resultAs, capitalize, }) { const lc = `${this.lc}[${this.capitalizeLines.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: b0ec8263fd94c68ee53fdf61534c8322)`); } const replaceAt = (s, i, replacement) => { // todo: change substr to use substring return s.substr(0, i) + replacement + s.substr(i + replacement.length); }; const upperText = (line) => { if (line === "") { return ""; } // Thanks https://paulund.co.uk/capitalize-first-letter-string-javascript return line.charAt(0).toUpperCase() + line.slice(1); }; const upperSsml = (line) => { if (line === "") { return ""; } if (line.charAt(0) === "<") { let iFirstLetter = line.indexOf(">") + 1; return replaceAt(line, iFirstLetter, line[iFirstLetter].toUpperCase()); } else { return upperText(line); } }; const lowerText = (line) => { if (line === "") { return ""; } // Thanks https://paulund.co.uk/capitalize-first-letter-string-javascript return line.charAt(0).toLowerCase() + line.slice(1); }; const lowerSsml = (line) => { if (line === "") { return ""; } if (line.charAt(0) === "<") { let iFirstLetter = line.indexOf(">") + 1; return replaceAt(line, iFirstLetter, line[iFirstLetter].toLowerCase()); } else { return lowerText(line); } }; const firstLine = lines[0]; switch (capitalize) { case LexCapitalize.upperfirst: lines[0] = resultAs === "text" ? upperText(firstLine) : upperSsml(firstLine); return lines; case LexCapitalize.uppereach: return lines.map(l => { return resultAs === "text" ? upperText(l) : upperSsml(l); }); case LexCapitalize.lowerfirst: lines[0] = resultAs === "text" ? lowerText(firstLine) : lowerSsml(firstLine); return lines; case LexCapitalize.lowereach: return lines.map(l => { return resultAs === "text" ? lowerText(l) : lowerSsml(l); }); case LexCapitalize.none: return lines; default: throw new Error(`Unknown LexCapitalize: ${capitalize}`); } } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Concatenates lines depending on given params. * * @param param0 */ concatLines({ lines, lineType, lineConcat, lineConcatDelim }) { const lc = `${this.lc}[${this.concatLines.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 59e0a7b89457283751b376295b61bb22)`); } const firstLine = lines[0]; // This used in both LexLineConcat.p and .n const concatSsmlP = () => { const pTag = "<p>"; if (firstLine.length < pTag.length || firstLine.substring(0, pTag.length).toLowerCase() !== pTag) { return lines.map(l => `<p>${l}</p>`).join(''); } else { // First line starts with <p> so // we will simply concat all lines, // assuming the user has wrapped all lines. return lines.join(''); } }; switch (lineConcat) { case LexLineConcat.p: if (lineType === "text") { return lines.join("\n\n"); } else { return concatSsmlP(); } case LexLineConcat.s: if (lineType === "text") { // Append period if not in data. // e.g. Data may just be "hello" and we want to // make it a sentence by appending "." return lines.map(l => { let lastChar = l.substring(l.length - 1); return [".", "!", "?"].includes(lastChar) ? l : l + "."; }).join(' '); } else { const sTag = "<s>"; if (firstLine.length < sTag.length || firstLine.substring(0, sTag.length).toLowerCase() !== sTag) { return lines.map(l => `<s>${l}</s>`).join(''); } else { // First line starts with <s> so // we will simply concat all lines, // assuming the user has wrapped all lines. return lines.join(''); } } case LexLineConcat.n: if (lineType === "text") { return lines.join('\n'); } else { return concatSsmlP(); } case LexLineConcat.delim: return lines.join(lineConcatDelim); default: throw new Error(`Unknown LexLineConcat: ${lineConcat} (E: e19f9c44217f4eb5999e2085d3f77b5c)`); } } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Filters the given lexData per the language, specifier, * and keywords. * * @param param0 Filter params * @returns filtered datum array */ filterLexData({ lexData, language, specifier, keywords, keywordMode, props, propsMode, fnDatumPredicate, }) { const lc = `${this.lc}[${this.filterLexData.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 1091755e98f390954d296575d1096722)`); } let result = lexData.concat(); // makes a copy if (language) { result = this.filterLanguage(result, language); } if (specifier) { result = result.filter(d => d.specifier && d.specifier === specifier); } if (keywords && keywords.length > 0) { // Datum must contain keywords that overlap with given // keywords args. keywords = keywords.map(kw => kw.toLocaleLowerCase()); switch (keywordMode) { case "any": result = result.filter(d => d.keywords && d.keywords.some(kwDatum => keywords .map(kwArg => kwArg.toLocaleLowerCase()) .some(kwArg => kwDatum === kwArg))); break; case "all": result = result.filter(d => { let dKeywords = (d.keywords || []) .map(x => x.toLocaleLowerCase()); return keywords.every(kwArg => dKeywords.includes(kwArg)); }); break; case "none": result = result.filter(d => { let dKeywords = (d.keywords || []) .map(x => x.toLocaleLowerCase()); return keywords.every(kwArg => !dKeywords.includes(kwArg)); }); break; default: console.error(`${lc} Unknown keywordMode: ${keywordMode}`); break; } } if (props) { result = this.filterProps({ result, props, propsMode }); } if (fnDatumPredicate) { result = result.filter(x => fnDatumPredicate(x)); } return result; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * executes the property filter on the given (intermediate) result. */ filterProps({ result, props, propsMode }) { // not within a try...catch because used in a tight loop within another fn that catches it if (propsMode === "prop") { const propsFilter = props; return result.filter(d => { return Object.keys(propsFilter).every((propName) => { const propFn = propsFilter[propName]; const dPropValue = !!d.props ? d.props[propName] : undefined; return propFn(dPropValue); }); }); } else if (propsMode === "props") { let propsFn = props; return result.filter(d => propsFn(d.props)); } else { throw new Error(`Invalid propsMode: ${propsMode} (E: 216a926144d2436a900b81ffe0aa6174)`); } } filterLanguage(result, language) { result = result.filter(d => // explicit language given (d.language && d.language === language) || // default language is 2 letters and matches (!d.language && this.defaultLanguage.length === 2 && language.startsWith(this.defaultLanguage)) || // default language is 4 letters and is equal (!d.language && this.defaultLanguage === language)); return result; } /** * Picks a randomesque datum from the given lexData, taking into * account the weighting of each datum. * * @param lexData Filtered lexData from which to choose a random item, per the item's weighting * * @see LexDatum.weighting */ pickDatum(lexData) { const lc = `${this.lc}[${this.pickDatum.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: f95c8585a8274f78a1e724d6ab58ff22)`); } if (!lexData) { throw new Error(`lexData required (E: cedb1fd9b67856c33b41c7dd4aab7c22)`); } if (lexData.length === 0) { return null; } else if (lexData.length === 1) { return lexData[0]; } else { const totalWeight = lexData.reduce((agg, item) => { return agg + (item.weighting ? item.weighting : 1); }, 0); // normalized random number const randomNumber = Math.random() * totalWeight; let result = null; lexData.reduce((runningWeight, item) => { if (result) { // already got a result return -1; } else { runningWeight += (item.weighting ? item.weighting : 1); if (runningWeight >= randomNumber) { result = item; } return runningWeight; } }, 0); return result; } } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } } /** * Builds an OutputSpeech object that contains both text and ssml. The text is * convenient for showing information on cards, while building the ssml at the * same time. * * The OutputSpeech.type is always ssml. */ export class SpeechBuilder { constructor() { this.lc = `[${SpeechBuilder.name}]`; const lc = `${this.lc}[ctor]`; try { if (logalot) { console.log(`${lc} starting... (I: 38d63eb021d971a24d532aec75b60e22)`); } this._bits = []; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Static factory function for fluent-style speech building. */ static with() { return new SpeechBuilder(); } /** * Adds a bit of speech corresponding to bare (non-ssml/tagged) text. * * @param text text to add to the builder */ text(text) { let bit = { type: "text", value: text + "" }; this._bits.push(bit); return this; } /** * Adds a bit of speech corresponding to existing ssml. * * NOTE: This ssml should **NOT** contain a `<speak>` tag, as this * is not automatically stripped. Also, if you choose * `newParagraph`, ssml should **NOT** contain any hard-coded <p> * tags * * @param ssml ssml to add to the builder. See NOTE in function description. * @param newParagraph if true, wraps ssml in <p> tags. See NOTE in function description. */ ssml(ssml, newParagraph = false) { const bit = { type: SpeechBitType.ssml, value: newParagraph ? `<p>${ssml}</p>` : ssml }; this._bits.push(bit); return this; } /** * Adds a pause (<break> tag) in the speech builder. * Equivalent to `<break='${seconds}s'/>` ATOW. * * @param seconds amount of time to pause. */ pause(seconds) { const bit = { type: SpeechBitType.break, value: seconds }; this._bits.push(bit); return this; } /** * Syntactic sugar for adding text of '\n' */ newLine() { const bit = { type: 'text', value: '\n' }; this._bits.push(bit); return this; } /** * Syntactic sugar for adding text of '\n\n' */ newParagraph() { const bit = { type: 'text', value: '\n\n' }; this._bits.push(bit); return this; } /** * Takes text and/or ssml from existing `OutputSpeech` * object and adds it to the builder. * * For example, say you already have an outputSpeech and you just * want to add an intro text to it. You would create the builder, * add the intro text via `text` function and then call this * function with your existing outputSpeech. * * @example `let outputWithIntro = SpeechBuilder.with().text('Some intro text').existing(prevOutputSpeech).outputSpeech();` * @param outputSpeech existing `OutputSpeech` to weave into the builder. */ existing(outputSpeech) { const bit = { type: SpeechBitType.existingOutputSpeech, value: outputSpeech }; this._bits.push(bit); return this; } /** * Creates an `OutputSpeech` from the builder's state. */ outputSpeech({ outputType = 'PlainText', }) { const lc = `${this.lc}[${this.outputSpeech.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 3820fa4816d7ca999489b45fff5e2622)`); } outputType = outputType || 'PlainText'; let text = "", ssml = ""; if (logalot) { console.log(`${lc} about to do bits...`); } if (logalot) { console.log(`${lc} bits: ${JSON.stringify(this._bits)}`); } this._bits.forEach(bit => { if (logalot) { console.log(`${lc} ssml: ${ssml}`); } if (text || ssml) { text = text + " "; ssml = ssml + " "; } if (logalot) { console.log(`${lc} bit: ${JSON.stringify(bit)}`); } switch (bit.type) { case "text": if (logalot) { console.log(`${lc} text in case`); } text += bit.value; ssml += bit.value; break; case "ssml": if (logalot) { console.log(`${lc} ssml in case`); } // do these in two steps to fully strip ssml. text += bit.value; text = Ssml.stripSsml(text); ssml += bit.value; break; case "break": if (logalot) { console.log(`${lc} break in case`); } // ridic edge case, if pause before any text/ssml. if (ssml === " ") { ssml = ""; } // text doesn't change ssml += `<break time='${bit.value}s'/>`; break; case "existingOutputSpeech": if (logalot) { console.log(`${lc} existing in case`); } const existing = bit.value; if (existing.text && existing.ssml) { if (logalot) { console.log(`${lc} existing text and ssml`); } text += existing.text; ssml += Ssml.unwrapSsmlSpeak(existing.ssml); } else if (existing.text) { if (logalot) { console.log(`${lc} existing text only`); } text += existing.text; ssml += text; } else if (existing.ssml) { if (logalot) { console.log(`${lc} existing ssml only`); } let unwrapped = Ssml.unwrapSsmlSpeak(existing.ssml); // do these in two steps to fully strip ssml. text += unwrapped; text = Ssml.stripSsml(text); ssml += unwrapped; } else { // neither existing ssml nor text? throw new Error("both existing.ssml and existing.text are falsy? (E: 089138a6ce8e45038028828515d9cd0c)"); } break; case "phoneme": throw new Error("phoneme case not implemented"); // break default: throw new Error(`Unknown bit.type: ${bit.type}`); } }); if (logalot) { console.log(`${lc} text: ${JSON.stringify(text)}`); console.log(`${lc} ssml: ${JSON.stringify(ssml)}`); } const output = { type: outputType, text: text, ssml: Ssml.wrapSsmlSpeak([ssml], /*addParaTags*/ false) }; // console.log(`${lc} output: ${JSON.stringify(output)}`); return output; } catch (error) { console.error(`${lc} ${error.message}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } } //# sourceMappingURL=lex-helper.mjs.map