add-joke.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. const prompt = require("prompts");
  2. const { colors, Errors, isEmpty, filesystem, reserialize } = require("svcorelib");
  3. const { writeFile, copyFile, readFile } = require("fs-extra");
  4. const { join } = require("path");
  5. const languages = require("../src/languages");
  6. const translate = require("../src/translate");
  7. const parseJokes = require("../src/parseJokes");
  8. const { validateSingle } = parseJokes;
  9. const { reformatJoke } = require("../src/jokeSubmission");
  10. const settings = require("../settings");
  11. const col = colors.fg;
  12. const { exit } = process;
  13. /** Global data that persists until the process exits */
  14. const data = {
  15. /** Whether the init() function has been called yet */
  16. initialized: false,
  17. };
  18. //#SECTION types
  19. /** @typedef {import("tsdef").NullableProps} NullableProps */
  20. /** @typedef {import("./types").AddJoke} AddJoke */
  21. /** @typedef {import("./types").Keypress} Keypress */
  22. /** @typedef {import("../src/types/jokes").Joke} Joke */
  23. /** @typedef {import("../src/types/jokes").JokeSubmission} JokeSubmission */
  24. //#MARKER init
  25. //#SECTION on execute
  26. try
  27. {
  28. if(!process.stdin.isTTY)
  29. throw new Errors.NoStdinError("The process doesn't have an stdin channel to read input from");
  30. else
  31. run();
  32. }
  33. catch(err)
  34. {
  35. exitError(err);
  36. }
  37. /**
  38. * Prints an error and instantly queues exit with status 1 (all async tasks are immediately canceled)
  39. * @param {Error} err
  40. */
  41. function exitError(err)
  42. {
  43. if(!(err instanceof Error))
  44. {
  45. console.error(`\n${col.red}${err.toString()}${col.rst}\n`);
  46. exit(1);
  47. }
  48. const stackLines = err.stack.toString().split(/\n/g);
  49. stackLines.shift();
  50. const stackStr = stackLines.join("\n");
  51. console.error(`\n${col.red}${err.message.match(/(E|e)rror/) ? "" : "Error: "}${err.message}${col.rst}\n${stackStr}\n`);
  52. exit(1);
  53. }
  54. /**
  55. * Runs this tool
  56. * @param {AddJoke} [incompleteJoke]
  57. */
  58. async function run(incompleteJoke = undefined)
  59. {
  60. try
  61. {
  62. if(!data.initialized)
  63. await init();
  64. data.initialized = true;
  65. const joke = await promptJoke(incompleteJoke);
  66. await addJoke(joke);
  67. blankLine();
  68. const { another } = await prompt({
  69. type: "confirm",
  70. message: "Add another joke?",
  71. name: "another",
  72. initial: false,
  73. });
  74. if(another)
  75. {
  76. blankLine(2);
  77. return run();
  78. }
  79. blankLine();
  80. exit(0);
  81. }
  82. catch(err)
  83. {
  84. exitError(err);
  85. }
  86. }
  87. /**
  88. * Initializes the add-joke script
  89. * @returns {Promise<void, Error>}
  90. */
  91. function init()
  92. {
  93. return new Promise(async (res, rej) => {
  94. try
  95. {
  96. await languages.init();
  97. await translate.init();
  98. await parseJokes.init();
  99. return res();
  100. }
  101. catch(err)
  102. {
  103. const e = new Error(`Couldn't initialize: ${err.message}`).stack += err.stack;
  104. return rej(e);
  105. }
  106. });
  107. }
  108. //#MARKER prompts
  109. /**
  110. * Prompts the user to enter all joke properties
  111. * @param {Joke} currentJoke
  112. * @returns {Promise<Joke, Error>}
  113. */
  114. function promptJoke(currentJoke)
  115. {
  116. return new Promise(async (res, rej) => {
  117. try
  118. {
  119. if(!currentJoke)
  120. currentJoke = createEmptyJoke();
  121. /**
  122. * Makes a title for the prompt below
  123. * @param {string} propName Name of the property (case sensitive)
  124. * @param {string} curProp The current value of the property to display
  125. * @returns {string}
  126. */
  127. const makeTitle = (propName, curProp) => {
  128. const truncateLength = 64;
  129. if(typeof curProp === "string" && curProp.length > truncateLength)
  130. curProp = `${curProp.substr(0, truncateLength)}…`;
  131. const boolDeco = typeof curProp === "boolean" ? (curProp === true ? ` ${col.green}✔ ` : ` ${col.red}✘ `) : "";
  132. const propCol = curProp != null ? col.green : col.magenta;
  133. return `${propName}${col.rst} ${propCol}(${col.rst}${curProp}${col.rst}${boolDeco}${propCol})${col.rst}`;
  134. };
  135. const jokeChoices = currentJoke.type === "single" ? [
  136. {
  137. title: makeTitle("Joke", currentJoke.joke),
  138. value: "joke",
  139. },
  140. ] : [
  141. {
  142. title: makeTitle("Setup", currentJoke.setup),
  143. value: "setup",
  144. },
  145. {
  146. title: makeTitle("Delivery", currentJoke.delivery),
  147. value: "delivery",
  148. },
  149. ];
  150. const choices = [
  151. {
  152. title: makeTitle("Category", currentJoke.category),
  153. value: "category",
  154. },
  155. {
  156. title: makeTitle("Type", currentJoke.type),
  157. value: "type",
  158. },
  159. ...jokeChoices,
  160. {
  161. title: makeTitle("Flags", extractFlags(currentJoke)),
  162. value: "flags",
  163. },
  164. {
  165. title: makeTitle("Language", currentJoke.lang),
  166. value: "lang",
  167. },
  168. {
  169. title: makeTitle("Safe", currentJoke.safe),
  170. value: "safe",
  171. },
  172. {
  173. title: `${col.green}[Submit]${col.rst}`,
  174. value: "submit",
  175. },
  176. {
  177. title: `${col.red}[Exit]${col.rst}`,
  178. value: "exit",
  179. },
  180. ];
  181. process.stdout.write("\n");
  182. const { editProperty } = await prompt({
  183. message: "Edit new joke's properties",
  184. type: "select",
  185. name: "editProperty",
  186. hint: "- Use arrow-keys. Return to select. Esc or Ctrl+C to submit.",
  187. choices,
  188. });
  189. switch(editProperty)
  190. {
  191. case "category":
  192. {
  193. const catChoices = settings.jokes.possible.categories.map(cat => ({ title: cat, value: cat }));
  194. const { category } = await prompt({
  195. type: "select",
  196. message: `Select new category`,
  197. name: "category",
  198. choices: catChoices,
  199. initial: settings.jokes.possible.categories.indexOf("Misc"),
  200. });
  201. currentJoke.category = category;
  202. break;
  203. }
  204. case "joke":
  205. case "setup":
  206. case "delivery":
  207. currentJoke[editProperty] = (await prompt({
  208. type: "text",
  209. message: `Enter value for '${editProperty}' property`,
  210. name: "val",
  211. initial: currentJoke[editProperty] || "",
  212. validate: (val) => (!isEmpty(val) && val.length >= settings.jokes.submissions.minLength),
  213. })).val;
  214. break;
  215. case "type":
  216. currentJoke.type = (await prompt({
  217. type: "select",
  218. message: "Select a joke type",
  219. choices: [
  220. { title: "Single", value: "single" },
  221. { title: "Two Part", value: "twopart" },
  222. ],
  223. name: "type",
  224. })).type;
  225. break;
  226. case "flags":
  227. {
  228. const flagKeys = Object.keys(currentJoke.flags);
  229. const flagChoices = [];
  230. flagKeys.forEach(key => {
  231. flagChoices.push({
  232. title: key,
  233. selected: currentJoke.flags[key] === true,
  234. });
  235. });
  236. const { newFlags } = await prompt({
  237. type: "multiselect",
  238. message: "Edit joke flags",
  239. choices: flagChoices,
  240. name: "newFlags",
  241. instructions: false,
  242. hint: "- arrow-keys to move, space to toggle, return to submit",
  243. });
  244. Object.keys(currentJoke.flags).forEach(key => {
  245. currentJoke.flags[key] = false;
  246. });
  247. newFlags.forEach(setFlagIdx => {
  248. const key = flagKeys[setFlagIdx];
  249. currentJoke.flags[key] = true;
  250. });
  251. break;
  252. }
  253. case "lang":
  254. currentJoke.lang = (await prompt({
  255. type: "text",
  256. message: "Enter joke language",
  257. initial: currentJoke.lang,
  258. name: "lang",
  259. validate: ((val) => languages.isValidLang(val, "en") === true),
  260. })).lang;
  261. break;
  262. case "safe":
  263. currentJoke.safe = (await prompt({
  264. type: "confirm",
  265. message: "Is this joke safe?",
  266. initial: true,
  267. name: "safe",
  268. })).safe;
  269. break;
  270. case "submit":
  271. return res(currentJoke);
  272. case "exit":
  273. {
  274. const { confirmExit } = await prompt({
  275. type: "confirm",
  276. message: "Do you really want to exit?",
  277. name: "confirmExit",
  278. initial: true,
  279. });
  280. confirmExit && exit(0);
  281. break;
  282. }
  283. default:
  284. return exitError(new Error(`Selected invalid option '${editProperty}'`));
  285. }
  286. return res(await promptJoke(currentJoke));
  287. }
  288. catch(err)
  289. {
  290. const e = new Error(`Error while prompting for joke: ${err.message}`).stack += err.stack;
  291. return rej(e);
  292. }
  293. });
  294. }
  295. //#MARKER other
  296. /**
  297. * Adds a joke to its language file
  298. * @param {AddJoke} joke
  299. * @returns {Promise<void, (Error)>}
  300. */
  301. function addJoke(joke)
  302. {
  303. return new Promise(async (res, rej) => {
  304. try
  305. {
  306. const initialJoke = reserialize(joke);
  307. const { lang } = joke;
  308. const jokeFilePath = join(settings.jokes.jokesFolderPath, `jokes-${lang}.json`);
  309. const templatePath = join(settings.jokes.jokesFolderPath, settings.jokes.jokesTemplateFile);
  310. if(!(await filesystem.exists(jokeFilePath)))
  311. await copyFile(templatePath, jokeFilePath);
  312. /** @type {JokesFile} */
  313. const currentJokesFile = JSON.parse((await readFile(jokeFilePath)).toString());
  314. /** @type {any} */
  315. const currentJokes = reserialize(currentJokesFile.jokes);
  316. const lastId = currentJokes[currentJokes.length - 1].id;
  317. const validationRes = validateSingle(joke, lang);
  318. // ensure props match and strip extraneous props
  319. joke.id = lastId + 1;
  320. joke.lang && delete joke.lang;
  321. joke.formatVersion && delete joke.formatVersion;
  322. joke = reformatJoke(joke);
  323. if(Array.isArray(validationRes))
  324. {
  325. console.error(`\n${col.red}Joke is invalid:${col.rst}\n - ${validationRes.join("\n - ")}\n`);
  326. const { retry } = await prompt({
  327. type: "confirm",
  328. message: "Do you want to retry?",
  329. name: "retry",
  330. initial: true,
  331. });
  332. if(retry)
  333. return promptJoke(initialJoke);
  334. exit(0);
  335. }
  336. else
  337. {
  338. currentJokes.push(joke);
  339. currentJokesFile.jokes = currentJokes;
  340. await writeFile(jokeFilePath, JSON.stringify(currentJokesFile, undefined, 4));
  341. return res();
  342. }
  343. }
  344. catch(err)
  345. {
  346. const e = new Error(`Couldn't save joke: ${err.message}`).stack += err.stack;
  347. return rej(e);
  348. }
  349. });
  350. }
  351. //#SECTION prompt deps
  352. /**
  353. * Extracts flags of a joke submission, returning a string representation
  354. * @param {JokeSubmission} joke
  355. * @returns {string} Returns flags delimited with `, ` or "none" if no flags are set
  356. */
  357. function extractFlags(joke)
  358. {
  359. /** @type {JokeFlags[]} */
  360. const flags = [];
  361. Object.keys(joke.flags).forEach(key => {
  362. if(joke.flags[key] === true)
  363. flags.push(key);
  364. });
  365. return flags.length > 0 ? flags.join(", ") : "none";
  366. }
  367. //#SECTION other deps
  368. /**
  369. * Returns a joke where everything is set to a default but empty value
  370. * @returns {NullableProps<AddJoke>}
  371. */
  372. function createEmptyJoke()
  373. {
  374. return {
  375. formatVersion: 3,
  376. category: undefined,
  377. type: "single",
  378. joke: undefined,
  379. flags: {
  380. nsfw: false,
  381. religious: false,
  382. political: false,
  383. racist: false,
  384. sexist: false,
  385. explicit: false,
  386. },
  387. lang: "en",
  388. safe: false,
  389. };
  390. }
  391. /**
  392. * Inserts a blank line (or more if `amount` is set)
  393. * @param {number} [amount=1]
  394. */
  395. function blankLine(amount = 1)
  396. {
  397. if(typeof amount !== "number" || isNaN(amount))
  398. throw new TypeError(`Parameter 'amount' is ${isNaN(amount) ? "NaN" : "not of type number"}`);
  399. let lfChars = "";
  400. for(let u = 0; u < amount; u++)
  401. lfChars += "\n";
  402. process.stdout.write(lfChars);
  403. }