const prompt = require("prompts"); const { colors, Errors, isEmpty, filesystem, reserialize } = require("svcorelib"); const { writeFile, copyFile, readFile } = require("fs-extra"); const { join } = require("path"); const languages = require("../src/languages"); const translate = require("../src/translate"); const parseJokes = require("../src/parseJokes"); const { validateSingle } = parseJokes; const { reformatJoke } = require("../src/jokeSubmission"); const settings = require("../settings"); const col = colors.fg; const { exit } = process; /** Global data that persists until the process exits */ const data = { /** Whether the init() function has been called yet */ initialized: false, }; //#SECTION types /** @typedef {import("tsdef").NullableProps} NullableProps */ /** @typedef {import("./types").AddJoke} AddJoke */ /** @typedef {import("./types").Keypress} Keypress */ /** @typedef {import("../src/types/jokes").Joke} Joke */ /** @typedef {import("../src/types/jokes").JokeSubmission} JokeSubmission */ //#MARKER init //#SECTION on execute try { if(!process.stdin.isTTY) throw new Errors.NoStdinError("The process doesn't have an stdin channel to read input from"); else run(); } catch(err) { exitError(err); } /** * Prints an error and instantly queues exit with status 1 (all async tasks are immediately canceled) * @param {Error} err */ function exitError(err) { if(!(err instanceof Error)) { console.error(`\n${col.red}${err.toString()}${col.rst}\n`); exit(1); } const stackLines = err.stack.toString().split(/\n/g); stackLines.shift(); const stackStr = stackLines.join("\n"); console.error(`\n${col.red}${err.message.match(/(E|e)rror/) ? "" : "Error: "}${err.message}${col.rst}\n${stackStr}\n`); exit(1); } /** * Runs this tool * @param {AddJoke} [incompleteJoke] */ async function run(incompleteJoke = undefined) { try { if(!data.initialized) await init(); data.initialized = true; const joke = await promptJoke(incompleteJoke); await addJoke(joke); blankLine(); const { another } = await prompt({ type: "confirm", message: "Add another joke?", name: "another", initial: false, }); if(another) { blankLine(2); return run(); } blankLine(); exit(0); } catch(err) { exitError(err); } } /** * Initializes the add-joke script * @returns {Promise} */ function init() { return new Promise(async (res, rej) => { try { await languages.init(); await translate.init(); await parseJokes.init(); return res(); } catch(err) { const e = new Error(`Couldn't initialize: ${err.message}`).stack += err.stack; return rej(e); } }); } //#MARKER prompts /** * Prompts the user to enter all joke properties * @param {Joke} currentJoke * @returns {Promise} */ function promptJoke(currentJoke) { return new Promise(async (res, rej) => { try { if(!currentJoke) currentJoke = createEmptyJoke(); /** * Makes a title for the prompt below * @param {string} propName Name of the property (case sensitive) * @param {string} curProp The current value of the property to display * @returns {string} */ const makeTitle = (propName, curProp) => { const truncateLength = 64; if(typeof curProp === "string" && curProp.length > truncateLength) curProp = `${curProp.substring(0, truncateLength)}…`; const boolDeco = typeof curProp === "boolean" ? (curProp === true ? ` ${col.green}✔ ` : ` ${col.red}✘ `) : ""; const propCol = curProp != null ? col.green : col.magenta; return `${propName}${col.rst} ${propCol}(${col.rst}${curProp}${col.rst}${boolDeco}${propCol})${col.rst}`; }; const jokeChoices = currentJoke.type === "single" ? [ { title: makeTitle("Joke", currentJoke.joke), value: "joke", }, ] : [ { title: makeTitle("Setup", currentJoke.setup), value: "setup", }, { title: makeTitle("Delivery", currentJoke.delivery), value: "delivery", }, ]; const choices = [ { title: makeTitle("Category", currentJoke.category), value: "category", }, { title: makeTitle("Type", currentJoke.type), value: "type", }, ...jokeChoices, { title: makeTitle("Flags", extractFlags(currentJoke)), value: "flags", }, { title: makeTitle("Language", currentJoke.lang), value: "lang", }, { title: makeTitle("Safe", currentJoke.safe), value: "safe", }, { title: `${col.green}[Submit]${col.rst}`, value: "submit", }, { title: `${col.red}[Exit]${col.rst}`, value: "exit", }, ]; process.stdout.write("\n"); const { editProperty } = await prompt({ message: "Edit new joke's properties", type: "select", name: "editProperty", hint: "- Use arrow-keys. Return to select. Esc or Ctrl+C to submit.", choices, }); switch(editProperty) { case "category": { const catChoices = settings.jokes.possible.categories.map(cat => ({ title: cat, value: cat })); const { category } = await prompt({ type: "select", message: `Select new category`, name: "category", choices: catChoices, initial: settings.jokes.possible.categories.indexOf("Misc"), }); currentJoke.category = category; break; } case "joke": case "setup": case "delivery": currentJoke[editProperty] = (await prompt({ type: "text", message: `Enter value for '${editProperty}' property`, name: "val", initial: currentJoke[editProperty] || "", validate: (val) => (!isEmpty(val) && val.length >= settings.jokes.submissions.minLength), })).val; break; case "type": currentJoke.type = (await prompt({ type: "select", message: "Select a joke type", choices: [ { title: "Single", value: "single" }, { title: "Two Part", value: "twopart" }, ], name: "type", })).type; break; case "flags": { const flagKeys = Object.keys(currentJoke.flags); const flagChoices = []; flagKeys.forEach(key => { flagChoices.push({ title: key, selected: currentJoke.flags[key] === true, }); }); const { newFlags } = await prompt({ type: "multiselect", message: "Edit joke flags", choices: flagChoices, name: "newFlags", instructions: false, hint: "- arrow-keys to move, space to toggle, return to submit", }); Object.keys(currentJoke.flags).forEach(key => { currentJoke.flags[key] = false; }); newFlags.forEach(setFlagIdx => { const key = flagKeys[setFlagIdx]; currentJoke.flags[key] = true; }); break; } case "lang": currentJoke.lang = (await prompt({ type: "text", message: "Enter joke language", initial: currentJoke.lang, name: "lang", validate: ((val) => languages.isValidLang(val, "en") === true), })).lang; break; case "safe": currentJoke.safe = (await prompt({ type: "confirm", message: "Is this joke safe?", initial: true, name: "safe", })).safe; break; case "submit": return res(currentJoke); case "exit": { const { confirmExit } = await prompt({ type: "confirm", message: "Do you really want to exit?", name: "confirmExit", initial: true, }); confirmExit && exit(0); break; } default: return exitError(new Error(`Selected invalid option '${editProperty}'`)); } return res(await promptJoke(currentJoke)); } catch(err) { const e = new Error(`Error while prompting for joke: ${err.message}`).stack += err.stack; return rej(e); } }); } //#MARKER other /** * Adds a joke to its language file * @param {AddJoke} joke * @returns {Promise} */ function addJoke(joke) { return new Promise(async (res, rej) => { try { const initialJoke = reserialize(joke); const { lang } = joke; const jokeFilePath = join(settings.jokes.jokesFolderPath, `jokes-${lang}.json`); const templatePath = join(settings.jokes.jokesFolderPath, settings.jokes.jokesTemplateFile); if(!(await filesystem.exists(jokeFilePath))) await copyFile(templatePath, jokeFilePath); /** @type {JokesFile} */ const currentJokesFile = JSON.parse((await readFile(jokeFilePath)).toString()); /** @type {any} */ const currentJokes = reserialize(currentJokesFile.jokes); const lastId = currentJokes[currentJokes.length - 1].id; const validationRes = validateSingle(joke, lang); // ensure props match and strip extraneous props joke.id = lastId + 1; joke.lang && delete joke.lang; joke.formatVersion && delete joke.formatVersion; joke = reformatJoke(joke); if(Array.isArray(validationRes)) { console.error(`\n${col.red}Joke is invalid:${col.rst}\n - ${validationRes.join("\n - ")}\n`); const { retry } = await prompt({ type: "confirm", message: "Do you want to retry?", name: "retry", initial: true, }); if(retry) return promptJoke(initialJoke); exit(0); } else { currentJokes.push(joke); currentJokesFile.jokes = currentJokes; await writeFile(jokeFilePath, JSON.stringify(currentJokesFile, undefined, 4)); return res(); } } catch(err) { const e = new Error(`Couldn't save joke: ${err.message}`).stack += err.stack; return rej(e); } }); } //#SECTION prompt deps /** * Extracts flags of a joke submission, returning a string representation * @param {JokeSubmission} joke * @returns {string} Returns flags delimited with `, ` or "none" if no flags are set */ function extractFlags(joke) { /** @type {JokeFlags[]} */ const flags = []; Object.keys(joke.flags).forEach(key => { if(joke.flags[key] === true) flags.push(key); }); return flags.length > 0 ? flags.join(", ") : "none"; } //#SECTION other deps /** * Returns a joke where everything is set to a default but empty value * @returns {NullableProps} */ function createEmptyJoke() { return { formatVersion: 3, category: undefined, type: "single", joke: undefined, flags: { nsfw: false, religious: false, political: false, racist: false, sexist: false, explicit: false, }, lang: "en", safe: false, }; } /** * Inserts a blank line (or more if `amount` is set) * @param {number} [amount=1] */ function blankLine(amount = 1) { if(typeof amount !== "number" || isNaN(amount)) throw new TypeError(`Parameter 'amount' is ${isNaN(amount) ? "NaN" : "not of type number"}`); let lfChars = ""; for(let u = 0; u < amount; u++) lfChars += "\n"; process.stdout.write(lfChars); }