diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..633a125 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/.gitignore b/.gitignore index ceaea36..80b1786 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dist .yarn/install-state.gz .pnp.* +# application related +todos.json +data/*.json \ No newline at end of file diff --git a/bin/jact b/bin/jact new file mode 100755 index 0000000..3b2664f --- /dev/null +++ b/bin/jact @@ -0,0 +1,2 @@ +#!/usr/bin/bash +node --env-file .env src/index.js \ No newline at end of file diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/example.env b/example.env new file mode 100644 index 0000000..afab6c3 --- /dev/null +++ b/example.env @@ -0,0 +1 @@ +STORAGE_FILE=todos.json \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..debbb33 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "jact", + "version": "1.0.0", + "description": "Just another console todo app. A funny little project to test an interactive gui on the console in javascript.", + "homepage": "https://projects.nisch.codes/nischcodes/JACT#readme", + "author": "nisch.codes ", + "license": "GPL-3.0", + "keywords": [], + "repository": { + "type": "git", + "url": "git@projects.nisch.codes:nischcodes/JACT.git" + }, + "main": "index.js", + "scripts": { + "start": "node --env-file .env index.js" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..c60de5d --- /dev/null +++ b/src/index.js @@ -0,0 +1,475 @@ +// node dependencies +const readline = require('readline'); +const path = require('path'); +const fs = require('fs'); + +// define the path for the data storage +const data_storage_file = path.join(__dirname, process.env.STORAGE_FILE); + +// define a wrapper for the readline interface +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +// define some ANSI-Escape-Sequences for console operations +const ansi = { + clearScreen: '\x1b[2J', + clearLine: '\x1b[2K', + moveCursor: (x,y) => `\x1b[${y};${x}H`, + cursorUp: (n) => `\x1b[${n}A`, + cursorDown: (n) => `\x1b[${n}B`, + hideCursor: '\x1b[?25l', + showCursor: '\x1b[?25h', + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + fg: { + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m' + }, + bg: { + black: '\x1b[40m', + red: '\x1b[41m', + green: '\x1b[42m', + yellow: '\x1b[43m', + blue: '\x1b[44m', + magenta: '\x1b[45m', + cyan: '\x1b[46m', + white: '\x1b[47m' + } +} + +// define the application spcifics +let todos = []; +let startRow = 0; +let uiHeight = 0; + +// define a storage interface +const storage = { + loadTodos() { + try { + // check if the storage file exists + if(fs.existsSync(data_storage_file)) { + const data = fs.readFileSync(data_storage_file, 'utf-8'); + return JSON.parse(data); + } + // otherwise return an empty array + return []; + } catch(err) { + // log the error + console.error(`Error while loading the data: ${err.message}`); + // return an emptry array + return []; + } + }, + saveTodos(todosArray) { + try { + // write the data to the storage file + fs.writeFileSync(data_storage_file, JSON.stringify(todosArray, null, 2), 'utf-8'); + // return true to signal success + return true; + } catch(err) { + // log the error + console.error(`Error while saving the data: ${err.message}`); + // return false to signal an error + return false; + } + } +}; + +// define the console utility +const ConsoleUtil = { + saveCursorPosition: () => process.stdout.write('\x1b[s'), + restoreCursorPosition: () => process.stdout.write('\x1b[u'), + clearFromCursorToEnd: () => process.stdout.write('\x1b[0J'), + clearCurrentLine: () => process.stdout.write(`\r${ansi.clearLine}`), + moveTo: (row) => process.stdout.write(ansi.cursorUp(uiHeight - row)), + + beginLine: (str) => process.stdout.write(str), + endLine: (str) => process.stdout.write(`${str}\n`), + writeLine: (str) => process.stdout.write(`${str}\n`) +}; + +const UiUtil = { + drawSeparator: (num) => { + // clear current line + ConsoleUtil.clearCurrentLine(); + // print a separator + ConsoleUtil.writeLine(`${ansi.dim}${'-'.repeat(num)}${ansi.reset}`); + }, + clearUiArea: () => { + // save the current cursor position + ConsoleUtil.saveCursorPosition(); + + // clear all lines between the 0 and the uiHeight + for(let i = 0; i < uiHeight; i++) { + // clear the current Line + ConsoleUtil.clearCurrentLine(); + // draw an empty line + ConsoleUtil.writeLine(''); + } + + // go back to the start position of the content area + ConsoleUtil.beginLine(ansi.cursorUp(uiHeight)); + // restore the cursor position + ConsoleUtil.restoreCursorPosition(); + } +}; + +// define utility functions for the ui +const drawUI = (title, content, options = null) => { + // save the current position + //ConsoleUtil.saveCursorPosition(); + + // clear current row and draw title + ConsoleUtil.clearCurrentLine(); + ConsoleUtil.writeLine(`${ansi.fg.cyan}${ansi.bold}=== ${title} ===${ansi.reset}`); + + // draw content + if(Array.isArray(content)) { + content.forEach(line => { + // clear current line + ConsoleUtil.clearCurrentLine(); + // print current content line + ConsoleUtil.writeLine(line); + }); + } else { + // clear current line + ConsoleUtil.clearCurrentLine(); + // print content + ConsoleUtil.writeLine(content); + } + + // draw options + if(options) { + // draw a separator + UiUtil.drawSeparator(60); + + // draw the options + options.forEach((option, index) => { + // clear the current line + ConsoleUtil.clearCurrentLine(); + // draw the current option + ConsoleUtil.writeLine(`${ansi.fg.yellow}${index + 1}. ${ansi.reset}${option}`); + }); + } + + // draw another separator + UiUtil.drawSeparator(60); + + // count up to the new ui height, so that we can use it later + uiHeight = 4 + (Array.isArray(content) ? content.length : 1) + (options ? options.length + 1 : 0) + + // restore cursor position + //ConsoleUtil.restoreCursorPosition(); +}; + + +// show the main menu +const showMainMenu = () => { + // clear the ui Area + UiUtil.clearUiArea(); + + // save the current cursor positon + ConsoleUtil.saveCursorPosition(); + + // draw the ui of the main menu + drawUI( + 'Main Menu', + "Choose an option:", + [ + 'Show Todo\'s', + 'Create Todo', + 'Delete Todo', + 'Save Todo\'s', + "Quit" + ] + ); + + // create rl question to wait for user input + rl.question(`${ansi.fg.green}Selection (1-5): ${ansi.reset}`, (input) => { + switch(input) { + case '1': + showTodos(); + break; + case '2': + addTodo(); + break; + case '3': + deleteTodo(); + break; + case '4': + saveTodos(); + break; + case '5': + ConsoleUtil.writeLine('\n'); + rl.close(); + break; + default: + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + break; + } + }); +}; + +// function to show the todos +const showTodos = () => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // prepare the todos to be drawn + const todoContent = todos.length > 0 ? todos.map((todo, index) => `${ansi.fg.green}${index + 1}.${ansi.reset} ${todo}`) : ['No Todo\'s']; + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // draw the todos + drawUI('TODOS', todoContent); + + // wait for user to press enter to return to the main menu + rl.question(`${ansi.fg.cyan}Press ENTER to return...${ansi.reset}`, () => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + }); +}; + +// function to create a new todo +const addTodo = () => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // draw the todos + drawUI('NEW TODO', 'Type in the new todo (or type \'back\' to cancel):'); + + // wait for user to type in his commands + rl.question(`${ansi.fg.cyan}> ${ansi.reset}`, (input) => { + // check if the input is equal to 'back' + if(input.toLocaleLowerCase() === 'back') { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + // return early + return; + } + + // if the user types in anything else than simply "back", add it as a todo + todos.push(input); + + // autosave the new task list + storage.saveTodos(todos); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // draw success message + drawUI('Success', `${ansi.fg.green}Todo "${input}" was added successfully!${ansi.reset}`); + + // set timeout and show main menu + setTimeout(() => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + }, 2000); + }); +}; + +// function to delete a todo +const deleteTodo = () => { + // check if todos having any entry + if(todos.length === 0) { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // draw an error message + drawUI('ERROR', `${ansi.fg.red}No Todos to delete!${ansi.reset}`); + + // set timeout and show main menu + setTimeout(() => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + }, 2000); + } + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // prepare the todos to be drawn + const todoList = todos.map((todo, index) => `${ansi.fg.green}${index + 1}.${ansi.reset} ${todo}`); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // draw the todos + drawUI('DELETE TODOS', todoList); + + // wait for user to type in his commands + rl.question(`${ansi.fg.cyan}> ${ansi.reset}`, (input) => { + // check if the input is equal to 'back' + if(input.toLocaleLowerCase() === 'back') { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + // return early + return; + } + + // if the user types in anything else than simply "back", parse the number + const index = parseInt(input); + + // ckeck if the index is a number or out of array range, than show a error message + if(isNaN(index) || index < 0 || index > todos.length) { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // draw an error message + drawUI('ERROR', `${ansi.fg.red}Invalid input!${ansi.reset}`); + + // set timeout and show main menu + setTimeout(() => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // return to the delete todo function + deleteTodo(); + }, 2000); + + // return early + return; + } + + // if the user gave a valid index, splice the element from the array + const deletedTodo = todos.splice((index-1), 1)[0]; + + // autosave the todos + storage.saveTodos(todos); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // draw an success message + drawUI('ERROR', `${ansi.fg.green}Todo "${deletedTodo}" was successfully deleted!${ansi.reset}`); + + // set timeout and show main menu + setTimeout(() => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + }, 2000); + }); +}; + +// function to save all the todos +const saveTodos = () => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // clear the ui Area + UiUtil.clearUiArea(); + + // try to save todos + const success = storage.saveTodos(todos); + + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + + // check success + if(success) { + // draw success message + drawUI('SAVE', `${ansi.fg.green}All Todos were saved successfully!${ansi.reset}`); + } else { + // draw error message + drawUI('ERROR', `${ansi.fg.red}While saving the todos, an error occured!${ansi.reset}`); + } + + // set timeout and show main menu + setTimeout(() => { + // restore cursor position + ConsoleUtil.restoreCursorPosition(); + // draw the main menu from the beginning + showMainMenu(); + }, 2000); +}; + +// clear the console +console.clear(); + +// start the main application +console.log("Welcome to JACT."); + +// load the todos from storage +todos = storage.loadTodos(); + +// move the UI 2 rows down +startRow = 2; + +// wait a moment and draw the main menu +setTimeout(() => showMainMenu(), 500); + +// define an event function to handle todos saving and process exiting +const saveAndClose = () => { + // save the current todo's + storage.saveTodos(todos); + // notify the user + ConsoleUtil.writeLine(`${ansi.showCursor}${ansi.fg.cyan}Todo's saved successfully.`); + // exit the process + process.exit(0); +}; + +// register event handler to before the application closes +rl.on('close', () => saveAndClose()); + +// register event handler for when the system is quiting the application +process.on('SIGINT', () => saveAndClose()); \ No newline at end of file