const fs = require('fs') const { homedir } = require('os') const https = require('https') const readline = require("readline") const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) rl._writeToOutput = function _writeToOutput(stringToWrite) { if (rl.stdoutMuted) rl.output.write("\x1B[2K\x1B[200D Password: "+('*'.repeat(rl.line.length))) else rl.output.write(stringToWrite); }; const cursor = { hide: () => process.stdout.write('\u001B[?25l'), show: () => process.stdout.write('\u001B[?25h') } process.on('exit',() => { rl.close() cursor.show() cls() cl('Bye!\n\n') }) const exitWithError = (e) => { cls() draw = { t: 'p', v: () => e.map(m => `\x1b[31m${m}\x1b[0m`)} drawApp() setTimeout(() => process.exit(1), 2000) } let conf, notesList = [], active = 0; scroll = 0; draw = { t: 'p', v: () => ['Loading NOTES CLI...'] }; menu = 'Press [h] for help' const cl = (m, c) => console.log(c ? `\x1b[${c}m${m}\x1b[0m` : m) const cls = () => console.clear() const filesPath = `${homedir}/.local/share/notes_cli` const fixedStr = (s, l) => s.length < l ? s.padEnd(l) : s.substring(0, l) const formatDate = d => d.replace('T',' ').replace(/\..*Z/, '') /* Views */ const formatNote = note => { const columns = process.stdout.columns return columns > 72 ? `${fixedStr(note.title, columns - 45)} ${formatDate(note.created_at)} ${formatDate(note.updated_at)}` : fixedStr(note.title, columns - note.title.length-2) } const showHelp = () => { const prev = draw.v draw = { t: 'h', prev, v: () => ['[q] - Quit','[h] - Help','[up/k] - Previous note','[down/j] - Next note', '[enter/o] - Open note','[c] - Create note',"[t] - Change note's title",'','Options not yet added:','[d] - Delete note','[s] - Sort note']}; menu = 'Press any key to exit help' drawApp() } const showList = (notes, status) => { if (status !== 200) { cls() return getEmail('Session error') } notesList = notes draw = { t: 'l', v: () => notes.map(formatNote) } drawApp() } const changeTitle = (title) => { const prev = draw.prev draw = {t: 'p', prev, v: () => ['Changing title...']} drawApp() putTitle(notesList[active], title) } const getNewNoteTitle = (existing = false) => { const v = draw.v rl.clearLine(-1) cls() draw = {t: 'p', prev: v, v: () => ['Please type the new note title', 'or leave empty to cancel'], noMenu: true} drawApp() cursor.show() rl.question(' Title: ', t => { cursor.hide() if (t === '') { draw = {t: 'p', prev: v, v: () => ['Canceled']} drawApp() setTimeout(() => { draw = {t: 'l', v} drawApp() }, 1000) } else { existing ? changeTitle(t) : createTmpFile(t) } }) } const saveTmpFile = (note, { content }) => { const file = `${filesPath}/${note._id}.tmp` fs.writeFile(file, content, function(err) { if(err) { exitWithError(['Error creating tmp file']) } return editTmpFile(note) }) } const readTmpFile = (note, title) => { const file = `${filesPath}/${title ? 'new' : note._id}.tmp` fs.readFile(file, function(err, f){ if (err) { return getEmail() } try { const content = f.toString() fs.unlink(file, () => {}) if (!content) { return getEmail('Error saving note') } title ? createNote(title, content) : putNote(note, content) } catch (e) { return getEmail('Session error') } }) } const noteSaved = () => { draw = {t: 'p', v: () => ['Note saved']} drawApp() fetchList() } const createTmpFile = (title) => { const file = `${filesPath}/new.tmp` const child_process = require('child_process') const editor = process.env.EDITOR || 'vi'; cursor.show() const child = child_process.spawn(editor, [file], { stdio: 'inherit' }); rl.pause() child.on('exit', function (e) { rl.resume() cursor.hide() if (e === 0) { draw = { t: 'p', v: () => ['Saving new note...'] } drawApp() readTmpFile(null, title) } else { draw = { t: 'p', v: () => ['Canceled'] } fs.unlink(file, () => {}) drawApp() fetchList() } }); } const editTmpFile = (note) => { const file = `${filesPath}/${note._id}.tmp` const child_process = require('child_process') const editor = process.env.EDITOR || 'vi'; cursor.show() const child = child_process.spawn(editor, [file], { stdio: 'inherit' }); rl.pause() child.on('exit', function (e) { rl.resume() cursor.hide() if (e === 0) { draw = { t: 'p', v: () => ['Saving changes...'] } readTmpFile(note) } else { draw = { t: 'p', v: () => ['Changes not saved'] } fs.unlink(file, () => {}) drawApp() fetchList() } }); } // =========== DRAW APP ============= const drawApp = () => { const lines = process.stdout.rows const columns = process.stdout.columns const headers = columns > 72 ? `${fixedStr(' Title', columns - 44)} Created at${' '.repeat(10)} Updated at ${' '.repeat(9)}` : ` Title ${' '.repeat(columns - 9)}` cls() for (var i = 0; i < lines - 1; i++) { const dist = t => columns > t + 2 ? [Math.floor((columns - t) / 2), Math.ceil((columns - t) / 2) - 2] : [0, 0] switch(i){ case 0: cl('╔'+'═'.repeat(columns - 2)+'╗') break case 1: conf?.email ? cl('║'+' '.repeat(dist(conf.email.length+12)[0])+`NOTES CLI (${conf.email})`.substring(0, columns-2)+' '.repeat(dist(conf.email.length+12)[1])+'║') : cl('║'+' '.repeat(dist(9)[0])+`NOTES CLI`.substring(0, columns-2)+' '.repeat(dist(9)[1])+'║') break case 2: cl('╠'+'═'.repeat(columns - 2) +'╣') break case 3: cl('║'+(headers || ' '.repeat(columns-2))+'║') break case 4: cl('╟'+'─'.repeat(columns - 2) +'╢') break case lines - 2: cl('╚'+'═'.repeat(columns - 2) +'╝') break default: const max = Math.max(...(draw.v().map(el => el.length))) if (['p','h'].includes(draw.t)) { if (i === (Math.floor((lines - draw.v().length) / 2) - 2)){ cl('║'+' '.repeat(dist(max+4)[0])+'┌'+'─'.repeat(max+2)+'┐'+' '.repeat(dist(max+4)[1])+'║') } else if (i === (Math.floor((lines + draw.v().length) / 2) + 1)) { cl('║'+' '.repeat(dist(max+4)[0])+'└'+'─'.repeat(max+2)+'┘'+' '.repeat(dist(max+4)[1])+'║') } else if (i === (Math.floor((lines - draw.v().length) / 2) - 1) || i === (Math.floor((lines + draw.v().length) / 2))) { cl('║'+' '.repeat(dist(max+4)[0])+'│'+' '.repeat(max+2)+'│'+' '.repeat(dist(max+4)[1])+'║') } else if (i < (Math.floor((lines - draw.v().length) / 2) - 1) || (i > (Math.floor((lines + draw.v().length) / 2)))) { cl('║'+' '.repeat(columns - 2)+'║') } else { const n = i - (Math.floor((lines - draw.v().length) / 2)) const t = draw.v()[n] const tLength = t.indexOf('\x1b') > -1 ? t.length - 9 : t.length const s = [Math.floor((max+2-tLength) / 2), Math.ceil((max+2-tLength) / 2)] draw.t === 'h' ? cl('║'+' '.repeat(dist(max+4)[0])+'│'+' '+t+' '.repeat(s[0]+s[1]-1)+'│'+' '.repeat(dist(max+4)[1])+'║') : cl('║'+' '.repeat(dist(max+4)[0])+'│'+' '.repeat(s[0])+t+' '.repeat(s[1])+'│'+' '.repeat(dist(max+4)[1])+'║') } } else if (draw.t === 'l') { const l = draw.v()[i-5+scroll] && draw.v()[i-5+scroll].substring(0,columns-4) cl(l ? ('║'+(active===i-5+scroll?'\x1b[7m ':' ')+l+' '.repeat(columns-l.length-3)+'\x1b[0m║') : ('║'+' '.repeat(columns - 2)+'║') ) } } } if (!draw.noMenu) { const activeNr = `${active+1}` const notesNr = `${notesList.length}` process.stdout.write(' '+menu+' '.repeat(columns-5-menu.length-activeNr.length-notesNr.length)+`[${activeNr}/${notesNr}]`) } } // ============ GET KEY ================= const getKey = () => { readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.on('keypress', (_, key) => { const lines = process.stdout.rows switch(key.name) { case 'up': case 'k': if (draw.t === 'l') { active > 0 && active-- active < scroll && scroll-- drawApp() } break case 'down': case 'j': if (draw.t === 'l') { active < draw.v().length - 1 && active++ active - scroll > lines - 7 && scroll++ drawApp() } break case 'return': case 'o': if (draw.t === 'l') { fetchNote(notesList[active]) break } case 'h': if (draw.t === 'l') { showHelp() break } case 'c': if (draw.t === 'l') { getNewNoteTitle() } case 't': if (draw.t === 'l') { getNewNoteTitle(true) } case 'q': if (draw.t === 'l') { process.exit() } default: if (draw.t === 'h') { const v = draw.prev draw = { t: 'l', v } menu = 'Press [h] for help' drawApp() } } }) } /* Session */ const setSession = (session) => { if (!fs.existsSync(filesPath)){ fs.mkdirSync(filesPath, { recursive: true }); } fs.writeFile(`${filesPath}/session`, JSON.stringify(session), function(err) { if(err) { exitWithError(['Error writting session file']) } rl.resume() getSession() }) } const getSession = () => { fs.readFile(`${filesPath}/session`, function(err, f){ if (err) { return getEmail() } try { conf = JSON.parse(f.toString()) if (!conf.session || !conf.userId || !conf.email || !conf.list) { return getEmail('Session error') } getKey() fetchList() } catch (e) { return getEmail('Session error') } }) } /* Login */ const loginMsg = (text, err) => ['Login to apps.pruss.it', '', `\x1b[31m${err}\x1b[0m`, '', ...text] const getPass = (e) => { cls() draw = { t: 'p', v: () => loginMsg(['Please type password for', `your apps.pruss.it account`, 'or press [q] to quit'], ''), noMenu: true} drawApp() rl.stdoutMuted = true rl.write() rl.question('', p => { rl.pause() rl.stdoutMuted = false drawApp() if (p === 'q') { process.exit() } post( '/api/login', {"email": e, "password": p}, (o, s, h) => { if (!o || s !== 201) { return getEmail('Could not log in, try again') } const {_id, email, isVerified, noteList} = o const session = h['set-cookie'] if (!_id || !email || !noteList) { exitWithError(['Could not log in']) } else if (!isVerified) { exitWithError(['User not verified.', 'Please first verify the user using apps.pruss.it']) } setSession({userId: _id, email, list: noteList, session}) }) }) rl.history = rl.history.slice(1) } const getEmail = (error = '') => { cls() draw = { t: 'p', v: () => loginMsg(['Please input email for your','apps.pruss.it account or' , 'press [q] to quit'], error), noMenu: true} drawApp() cursor.show() rl.question(' Email: ', e => { cursor.hide() rl.pause() if (e === 'q') { process.exit() } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) { cls() return getEmail('Not a valid email address, try again.') } getPass(e) }) } /* API */ const fetchList = () => { get('/api/notes', showList, conf.session) } const fetchNote = note => { draw = { t: 'p', v: () => ['Loading...'] } drawApp() get(`/api/notes/${note.noteId}`, (content) => saveTmpFile(note, content), conf.session) } const putNote = ({ _id, noteId }, content) => { put(`/api/notes/${_id}`, { noteId, content }, noteSaved, conf.session) } const putTitle = ({ _id, noteId }, title) => { put(`/api/notes/${_id}`, { noteId, title }, noteSaved, conf.session) } const createNote = (title, content) => { post('/api/notes', { title, content }, noteSaved, conf.session) } /* HTTPS METHODS */ const get = (path, callback, cookie = '') => { const options = { hostname: 'apps.pruss.it', port: 443, path, method: 'GET', headers: { 'Cookie': cookie, }, }; const req = https.request(options, res => { let data = ''; res.on('data', chunk => { data += chunk }); res.on('end', () => callback(JSON.parse(data), res.statusCode, res.headers)); }); req.on('error', () => { callback(null) }); req.end(); } const post = (path, data, callback, cookie = '', put = false) => { const dataString = JSON.stringify(data) const options = { hostname: 'apps.pruss.it', port: 443, path, method: put ? 'PUT' : 'POST', headers: put ? { 'Cookie': cookie, } : { 'Content-Type': 'text/plain', 'Content-Length': dataString.length, 'Cookie': cookie, }, timeout: 1000, }; const req = https.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => callback(JSON.parse(data), res.statusCode, res.headers)); }).on("error", () => { callback(null) }); req.write(dataString); req.end(); } const put = (...arg) => post(...arg, true) cls() getSession() process.stdout.on('resize', drawApp); cursor.hide()