@litexa/core
Version:
Litexa, a programming language for writing Alexa skills
368 lines (322 loc) • 14.5 kB
JavaScript
const chalk = require('chalk');
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const LoggingChannel = require('./loggingChannel');
const projectConfig = require('./project-config');
const { Skill } = require('../parser/skill');
/* The skill localization object has the following contents:
{
intents: { // map of all skill intents, and model-ready utterances
"SomeIntentName": {
"default": [ // utterances parsed from the default Litexa files
"default utterance {slot_name}",
"default utterance {slot_name}"
],
"fr-FR": [ // translated utterances added to the localization.json by a translator
"french utterance {slot_name}",
"french utterance {slot_name}"
]
}
},
speech: { // map of all in-skill speech/reprompts, with any available translations
"default speech string": { // say/reprompt string found in default Litexa files
"fr-FR": "override string for FR"
},
"alternate one|alternate two|alternate three": { // say/reprompt 'or' alternates are delineated by | characters
"fr-FR": "french alternate one|french alternate two"
}
}
}
*/
// Utility function that will crawl Litexa code, to retrieve all intents/utterances and say/reprompts.
async function localizeSkill(options) {
options.logger = options.logger || new LoggingChannel({
logPrefix: 'localization',
logStream: options.logStream || console,
verbose: options.verbose || false
});
// Green log stream for any new content.
options.logger.green = function (line) {
options.logger.write({
line,
format: chalk.green
});
};
// Red log stream for any orphaned content.
options.logger.red = function (line) {
options.logger.write({
line,
format: chalk.red
});
};
try {
const skill = await buildSkill(options);
options.logger.log('parsing default skill intents, utterances, and output speech ...')
const prevLocalization = { ...skill.projectInfo.localization };
const curLocalization = skill.toLocalization();
// Merge previous localization into the existing localization:
// 1) Log any newly added intents, utterances, and speech lines in green, prefixed with '+'.
// 2) Log any orphaned (no longer in skill) utterances and speech lines in red, prefixed with '-'.
// Persist orphaned content in localization.json unless otherwise specified by --remove flags.
mergePreviousLocalization(options, prevLocalization, curLocalization);
const outputPath = path.join(skill.projectInfo.root, 'localization.json');
const promisifiedFileWrite = promisify(fs.writeFile);
await promisifiedFileWrite(outputPath, JSON.stringify(curLocalization, null, 2));
options.logger.important(`localization summary saved to: ${outputPath}`);
} catch (err) {
options.logger.error(err);
process.exit(1);
}
}
async function buildSkill(options, variant = 'development') {
const jsonConfig = await projectConfig.loadConfig(options.root);
const projectInfo = new (require('./project-info'))({jsonConfig, variant, doNotParseExtensions: options.doNotParseExtensions});
const skill = new Skill(projectInfo);
skill.strictMode = true;
for (const [language, languageInfo] of Object.entries(projectInfo.languages)) {
const codeInfo = languageInfo.code;
const files = codeInfo.files;
for (const file of files) {
const filename = path.join(codeInfo.root, file);
const promisifiedFileRead = promisify(fs.readFile);
const data = await promisifiedFileRead(filename, 'utf8');
skill.setFile(file, language, data);
}
};
return skill;
}
function mergePreviousLocalization(options, prevLocalization, curLocalization) {
mergePreviousIntents(options, prevLocalization, curLocalization);
mergePreviousUtterances(options, prevLocalization, curLocalization);
mergePreviousSpeech(options, prevLocalization, curLocalization);
// Perform any cloning steps after merging to not interleave cloning errors with merging process.
// If CLI command specified from/to for cloning, copy everything to the target language.
if (shouldPerformCloning(options)) {
cloneUtterancesBetweenLocalizations(options, curLocalization);
cloneSpeechBetweenLocalizations(options, curLocalization);
}
}
function mergePreviousIntents(options, prevLocalization, curLocalization) {
checkForNewIntents(options, prevLocalization, curLocalization);
checkForOrphanedIntents(options, prevLocalization, curLocalization);
}
function mergePreviousUtterances(options, prevLocalization, curLocalization) {
checkForNewUtterances(options, prevLocalization, curLocalization);
checkForOrphanedUtterances(options, prevLocalization, curLocalization);
}
function mergePreviousSpeech(options, prevLocalization, curLocalization) {
checkForNewSpeechLines(options, prevLocalization, curLocalization);
checkForOrphanedSpeechLines(options, prevLocalization, curLocalization);
}
function shouldPerformCloning(options) {
if (!options.cloneFrom && !options.cloneTo) {
return false;
}
if (options.cloneTo === 'default') {
throw new Error("Not allowed to clone localizations to `default` language.");
}
if (!options.cloneFrom) {
throw new Error("Missing `cloneFrom` option. Please specify a Litexa language to clone from.");
}
if (!options.cloneTo) {
throw new Error("Missing `cloneTo` option. Please specify a Litexa language to clone to.");
}
return true;
}
function checkForNewIntents(options, prevLocalization, curLocalization) {
options.logger.verbose(`checking for new intents ...`);
let numNewIntents = 0;
let newIntentHeaderPrinted = false;
for (const intent of Object.keys(curLocalization.intents)) {
if (!prevLocalization.intents.hasOwnProperty(intent)) {
if (!newIntentHeaderPrinted) {
options.logger.warning(`the following intents are new since the last localization:`)
newIntentHeaderPrinted = true;
}
options.logger.green(`+ ${intent}`);
++numNewIntents;
}
}
options.logger.verbose(`number of new intents added since last localization: ${numNewIntents}`);
}
function checkForOrphanedIntents(options, prevLocalization, curLocalization) {
options.logger.verbose(`checking for orphaned intents ...`)
let numOrphanedIntents = 0;
let orphanedIntentHeaderPrinted = false;
for (const intent of Object.keys(prevLocalization.intents)) {
if (!curLocalization.intents.hasOwnProperty(intent)) {
// Logging orphaned intents in verbose output only, since Litexa automatically adds builtin intents to the skill
// model depending on certain requirements.
if (!orphanedIntentHeaderPrinted) {
options.logger.verbose(`the following intents in localization.json are missing in skill:`)
orphanedIntentHeaderPrinted = true;
}
options.logger.verbose(`- ${intent}`);
++numOrphanedIntents;
// Persist the orphaned intent and all utterances within.
curLocalization.intents[intent] = { ...prevLocalization.intents[intent] };
}
}
options.logger.verbose(`number of localization intents that are missing in skill: ${numOrphanedIntents}`);
}
function checkForNewUtterances(options, prevLocalization, curLocalization) {
options.logger.verbose(`checking for new utterances ...`)
let numNewUtterances = 0;
let newUtteranceHeaderPrinted = false;
for (const intent of Object.keys(curLocalization.intents)) {
const curUtterances = curLocalization.intents[intent]
for (const utterance of Object.values(curUtterances.default)) {
if (!prevLocalization.intents.hasOwnProperty(intent) || !prevLocalization.intents[intent].default.includes(utterance)) {
if (!newUtteranceHeaderPrinted) {
options.logger.warning(`the following utterances are new since the last localization:`)
newUtteranceHeaderPrinted = true;
}
options.logger.green(`+ ${utterance}`);
++numNewUtterances;
}
}
}
options.logger.verbose(`number of new utterances added since last localization: ${numNewUtterances}`);
}
function checkForOrphanedUtterances(options, prevLocalization, curLocalization) {
options.logger.verbose(`checking for orphaned utterances ...`)
let numOrphanedUtterances = 0;
let orphanedUtteranceHeaderPrinted = false;
for (const intent of Object.keys(prevLocalization.intents)) {
let intentObject = prevLocalization.intents[intent];
for (const [language, utterances] of Object.entries(intentObject)) {
if (language === 'default') {
for (const utterance of utterances) {
if (!curLocalization.intents[intent].default.includes(utterance)) {
if (!orphanedUtteranceHeaderPrinted) {
if (options.removeOrphanedUtterances) {
options.logger.warning(`--remove-orphaned-utterances set -> going to remove the below utterances`);
}
options.logger.warning(`the following utterances in localization.json are missing in skill:`);
orphanedUtteranceHeaderPrinted = true;
}
options.logger.red(`- ${utterance}`);
++numOrphanedUtterances;
if (!options.removeOrphanedUtterances) {
curLocalization.intents[intent].default.push(utterance);
}
}
}
} else {
// For any non-default language, persist the existing translations.
curLocalization.intents[intent][language] = [...utterances];
if (!options.disableSortUtterances) {
curLocalization.intents[intent][language] = localeSortArray(curLocalization.intents[intent][language]);
}
}
}
if (!options.disableSortLanguages) {
curLocalization.intents[intent] = sortObjectByKeys(curLocalization.intents[intent]);
}
}
if (options.removeOrphanedUtterances) {
options.logger.verbose(`number of orphaned utterances removed from localization.json: ${numOrphanedUtterances}`);
} else {
options.logger.verbose(`number of localization.json utterances that are missing in skill: ${numOrphanedUtterances}`);
}
}
function cloneUtterancesBetweenLocalizations(options, curLocalization) {
let performedClone = false;
for (const intent of Object.keys(curLocalization.intents)) {
let intentObject = curLocalization.intents[intent];
for (const language of Object.keys(intentObject)) {
if (language === options.cloneFrom) {
curLocalization.intents[intent][options.cloneTo] = curLocalization.intents[intent][options.cloneFrom];
performedClone = true;
break;
}
}
if (!options.disableSortLanguages) {
curLocalization.intents[intent] = sortObjectByKeys(curLocalization.intents[intent]);
}
}
if (!performedClone) {
options.logger.verbose(`No sample utterances were found for \`${options.cloneFrom}\`, so no utterances were cloned.`);
}
}
function checkForNewSpeechLines(options, prevLocalization, curLocalization) {
options.logger.verbose(`checking for new speech lines ...`)
let numNewLines = 0;
let newLinesHeaderPrinted = false;
for (const line of Object.keys(curLocalization.speech)) {
if (!prevLocalization.speech.hasOwnProperty(line)) {
if (!newLinesHeaderPrinted) {
options.logger.warning(`the following speech lines are new since the last localization:`)
newLinesHeaderPrinted = true;
}
options.logger.green(`+ ${line}`);
++numNewLines;
}
}
options.logger.verbose(`number of new speech lines added since last localization: ${numNewLines}`);
}
function checkForOrphanedSpeechLines(options, prevLocalization, curLocalization) {
options.logger.verbose(`checking for orphaned speech lines ...`);
let numOrphanedLines = 0;
let orphanedHeaderPrinted = false;
for (const [line, translations] of Object.entries(prevLocalization.speech)) {
if (!curLocalization.speech.hasOwnProperty(line)) {
if (!orphanedHeaderPrinted) {
if (options.removeOrphanedSpeech) {
options.logger.warning(`--remove-orphaned-speech set -> going to remove the below speech lines`);
}
options.logger.warning(`the following speech lines in localization.json are missing in skill:`);
orphanedHeaderPrinted = true;
}
options.logger.red(`- ${line}`);
++numOrphanedLines;
}
if (curLocalization.speech.hasOwnProperty(line) || !options.removeOrphanedSpeech) {
// Regardless of whether the speech line was orphaned, persist its translations in the output.
curLocalization.speech[line] = options.disableSortLanguages ? { ...translations } : sortObjectByKeys({ ...translations });
}
}
if (options.removeOrphanedSpeech) {
options.logger.verbose(`number of orphaned speech lines removed from localization.json: ${numOrphanedLines}`);
} else {
options.logger.verbose(`number of localization.json speech lines that are missing in skill: ${numOrphanedLines}`);
}
}
function cloneSpeechBetweenLocalizations(options, curLocalization) {
let performedClone = false;
for (const line of Object.keys(curLocalization.speech)) {
if (curLocalization.speech[line].hasOwnProperty(options.cloneFrom)) {
curLocalization.speech[line][options.cloneTo] = curLocalization.speech[line][options.cloneFrom];
performedClone = true;
} else if (options.cloneFrom === 'default') {
curLocalization.speech[line][options.cloneTo] = line;
performedClone = true;
}
if (!options.disableSortLanguages) {
curLocalization.speech[line] = sortObjectByKeys(curLocalization.speech[line]);
}
}
if (!performedClone) {
options.logger.warning(`No speech was found for ${options.cloneFrom}, so no speech cloning occurred.`);
}
}
function sortObjectByKeys(obj) {
const sortedObj = {};
Object.keys(obj).sort().forEach(function (key) {
sortedObj[key] = obj[key];
});
return sortedObj;
}
function localeSortArray(arr) {
const result = arr.sort(function (a, b) {
// Custom sort for utterances that begin with a slot value "{slot} ..." to be higher in the list.
if (a.startsWith('{') && !b.startsWith('{')) { return -1; }
if (b.startsWith('{') && !a.startsWith('{')) { return 1; }
return a.toLowerCase().localeCompare(b.toLowerCase());
});
return result;
}
module.exports = {
localizeSkill
}