UNPKG

teamwork-cli

Version:

Command-line-interface for the Teamwork (https://www.teamwork.com/time-tracking) time entry system.

1,144 lines (971 loc) 31.3 kB
const readline = require('readline-sync'); const dateFormat = require('dateformat'); const htmlToText = require('html-to-text'); const teamwork = require('./teamwork.js'); const userData = require('./user-data.js'); const functions = require('./common-functions.js'); const main = require('./command-mode.js'); /************************************************************************************ * Interactive Mode ************************************************************************************/ const EXIT_COMMANDS = ['exit', 'quit', 'q', ':q', ':wq', 'leave']; const DELIM = '/'; Array.prototype.contains = function (item) { return this.find(i => i === item) !== undefined; }; const prettyJson = (json) => { if (json) { console.log(JSON.stringify(json, null, 2)); } else { console.log('undefined'); } }; const state = { data: { projects: undefined, tasklists: undefined, tasks: undefined, timeEntries: undefined }, selected: { project: null, tasklist: null, task: null, timeEntry: null } }; const projectName = (projectId) => { const {projects} = state.data; const matches = projects.filter(proj => proj.id === projectId); return matches.length > 0 ? matches[0].name : ''; }; const taskListName = (tasklistId) => { const {tasklists} = state.data; const matches = tasklists.filter(tasklist => tasklist.id === tasklistId); return matches.length > 0 ? matches[0].name : teamwork.getTasklist(tasklistId).name; }; /** * Utility function to prompt the user for a value * If no default value: '_prompt_: ', else '_prompt_[_defaultValue_]: ' * * @param prompt Text to present to the user * @param defaultValue returned if user has no input - also is displayed after prompt */ const ask = (prompt, defaultValue) => { if (defaultValue !== null && defaultValue !== undefined) { const val = readline.question(`${prompt}[${defaultValue}]: `); return val.length > 0 ? val : defaultValue; } else { return readline.question(prompt + ': '); } }; /** * Refreshes prompt stored in state based on selected items * * @param state Current state of the terminal */ const getPromptText = () => { const {project, tasklist, task, timeEntry} = state.selected; let prompt = '\nteamwork'; if (project) { prompt = prompt + DELIM + project.name; if (tasklist) { prompt = prompt + DELIM + tasklist.name; if (task) { prompt = prompt + DELIM + task.content; if (timeEntry) { prompt = prompt + DELIM + timeEntry.description; } } } } return '\x1b[1m' + prompt + ' > \x1b[0m'; }; /** * Utility function that gets the current directory level of the state (project, task, etc.) */ const getDirLevel = () => { const {project, tasklist, task, timeEntry} = state.selected; if (timeEntry) { return 'timeEntry'; } if (task) { return 'task'; } if (tasklist) { return 'tasklist'; } if (project) { return 'project'; } return 'top'; }; /** * Utility function that gets the current directory */ const getCurrentDir = () => { const {selected} = state; let dir = DELIM; if (selected.project) { dir = dir + selected.project.id; if (selected.tasklist) { dir = dir + DELIM + selected.tasklist.id; if (selected.task) { dir = dir + DELIM + selected.task.id; if (selected.timeEntry) { dir = dir + DELIM + selected.timeEntry.id; } } } } return dir; }; /** * Lists the current contents of the 'directory' (tasks in tasklist, etc) * * @param args Array of arguments with the first item being the command */ const ls = (args) => { const {data, selected} = state; const differentDir = args.length > 1; let originalDir = '.'; if (differentDir) { originalDir = getCurrentDir(); if (args[1] === '*') { // TODO } else { cd(args); } } if (!selected.project) { console.log('\nProjects:'); data.projects.forEach((p, idx) => console.log(`${idx}) ${p.id}: ${p.name}`)); } else if (!selected.tasklist) { console.log('\nTask Lists:'); data.tasklists.forEach((t, idx) => console.log(`${idx}) ${t.id}: ${t.name}`)); } else if (!selected.task) { console.log('\nTasks:'); data.tasks.forEach((t, idx) => console.log(`${idx}) ${t.id}: ${t.content}`)); } else if (!selected.timeEntry) { console.log('\nTime Entires:'); data.timeEntries.forEach((t, idx) => console.log(`${idx}) ${t.id}: ${dateFormat(new Date(t.date), 'mm/dd/yyyy')} ${t.hours}h ${t.minutes}m - ${t.description}`)); } if (differentDir) { cd(['cd', originalDir]); } }; const findEmpty = () => { const {selected} = state; let entryList = []; if (!selected.project) { entryList = teamwork.getAllEntries(); } else if (!selected.tasklist) { entryList = teamwork.getProjectEntries(selected.project.id); } else if (!selected.task) { entryList = teamwork.getTaskListEntries(selected.tasklist.id); } else if (!selected.timeEntry) { entryList = teamwork.getTaskEntries(selected.task.id); } entryList.filter(l => !l.description || /^\s*$/.test(l.description)) .forEach(entry => { const project = entry['project-id']; const taskList = entry.tasklistId; const task = entry['todo-item-id']; const id = entry.id; const taskName = entry['todo-item-name']; const date = dateFormat(new Date(entry.date), "mm/dd/yyyy"); console.log(`${project}/${taskList}/${task}/${id}: "${taskName}" on ${date}`); }); }; const favorite = (args) => { if (!args || args.length < 2) { console.log('At least one argument required'); return; } const {selected} = state; const differentDir = args.length > 2; let originalDir = '.'; let name; if (differentDir) { originalDir = getCurrentDir(); cd(['cd', args[1]]); name = args[2]; } else { name = args[1]; } switch (getDirLevel()) { case 'task': userData.get().favorites[name] = selected.task.id; userData.save(); break; default: console.log('unsupported'); return; } if (differentDir) { cd(['cd', originalDir]); } }; const search = (args) => { if (!args || args.length < 2) { console.log('At least one argument required'); return; } if (args.length === 2 && args[1] === '-e') { findEmpty(); return; } const {project, tasklist} = state.selected; const searchTerm = args.slice(1).join(' '); const projectId = project ? project.id : null; const tasklistId = tasklist ? tasklist.id : null; const results = functions.searchForTask(searchTerm, projectId, tasklistId); switch (getDirLevel()) { case 'top': (new Set(results.map(t => t.taskListId))).forEach(tl => { const taskList = teamwork.getTasklist(tl); console.log(`\n${projectName(taskList.projectId)} / ${taskList.name}:`); results.filter(t => t.taskListId === tl) .forEach(t => console.log(`${t.projectId}/${t.taskListId}/${t.id}: ${t.name}`)); }); break; case 'project': (new Set(results.map(t => t.taskListId))).forEach(tl => { console.log(`\n${taskListName(tl)}:`); results.filter(t => t.taskListId === tl) .forEach(t => console.log(`${t.taskListId}/${t.id}: ${t.name}`)); }); break; case 'tasklist': results.forEach(t => console.log(`${t.id}: ${t.name}`)); break; default: console.log('unsupported'); return; } }; const sumTime = (args) => { // remove command args = args.slice(1); let total; switch (getDirLevel()) { case 'top': if (args.length) { total = getTotalTime(args, null, teamwork.getProjectTime); } else { const projects = state.data.projects.map(p => p.id); total = getTotalTime(projects, null, teamwork.getProjectTime); } break; case 'project': total = getTotalTime(args, teamwork.getProjectTime, teamwork.getTaskListTime); break; case 'tasklist': total = getTotalTime(args, teamwork.getTaskListTime, teamwork.getTaskTime); break; case 'task': total = getTotalTime(args, teamwork.getTaskTime, getTimeEntryTime); break; case 'timeEntry': total = getTotalTime(null, getTimeEntryTime, null); break; default: console.log('unsupported'); return; } console.log(`Total time: ${total} hours`); }; const getTotalTime = (args, curDirFunc, subDirFunc) => { if (!args || args.length < 1) { const selected = getSelected(); return selected && curDirFunc(selected.id); } let total = 0; if (subDirFunc) { args.forEach(arg => { const item = findCurrentDirItem(arg); if (item) { total += Number(subDirFunc(item.id)); } }); } return total; }; const getTimeEntryTime = (arg) => { const item = findDirItem(state.data.timeEntries, arg); let total = 0; if (item) { const {hours, minutes} = item; if (hours) { total += Number(hours); } if (minutes) { total += Number(minutes) / 60; } } return total; }; /** * Utility function that finds an item in the list given the argument. * First by array index, then by id, then by... ? * * @param list List to search through * @param arg Search parameter */ const findDirItem = (list, arg) => { if (!isNaN(arg)) { if (Number(arg) < list.length) { return list[Number(arg)]; } else { const proj = list.find(p => p.id == arg); if (proj) { return proj; } } } else { // find it somehow by name? } return undefined; }; const findCurrentDirItem = (arg) => { const {projects, tasklists, tasks, timeEntries} = state.data; switch (getDirLevel()) { case 'top': return findDirItem(projects, arg); case 'project': return findDirItem(tasklists, arg); case 'tasklist': return findDirItem(tasks, arg); case 'task': return findDirItem(timeEntries, arg); default: console.log('unsupported'); return; } }; const getSelected = () => { const {project, tasklist, task, timeEntry} = state.selected; switch (getDirLevel()) { case 'top': return null; case 'project': return project; case 'tasklist': return tasklist; case 'task': return task; case 'timeEntry': return timeEntry; default: console.log('unsupported'); return; } }; /** * Changes 'directory' of terminal * * @param state Current state of the terminal * @param args Array of arguments with first being the command */ const cd = (args) => { if (args.length < 2 || args[1] === DELIM || args[1] === '~') { state.selected = { project: null, tasklist: null, task: null, timeEntry: null } } else { let path = args[1]; if (path.startsWith(DELIM)) { path = path.substr(1); cd(['cd']) } if (path.endsWith(DELIM)) { path = path.substr(0, path.length - 1); } const {selected, data} = state; // is a favorite if (path.indexOf('/') < 0 && isNaN(path)) { const taskId = userData.get().favorites[path]; if (taskId) { const tmTask = teamwork.getTask(taskId); const projectId = tmTask['project-id']; const taskListId = tmTask['todo-list-id']; const p = `/${projectId}/${taskListId}/${taskId}`; cd(['cd', p]); return; } } path.split(DELIM).forEach(a => { if (a === '..') { if (selected.timeEntry) { selected.timeEntry = null; } else if (selected.task) { selected.task = null; } else if (selected.tasklist) { selected.tasklist = null; } else if (selected.project) { selected.project = null; } } else { if (!selected.project) { selected.project = findDirItem(data.projects, a); } else if (!selected.tasklist) { selected.tasklist = findDirItem(data.tasklists, a); } else if (!selected.task) { selected.task = findDirItem(data.tasks, a); } else if (!selected.timeEntry) { selected.timeEntry = findDirItem(data.timeEntries, a); } switch (getDirLevel(state)) { case 'top': data.projects = teamwork.getProjects(); break; case 'project': data.tasklists = teamwork.getTasklists(selected.project.id); break; case 'tasklist': data.tasks = teamwork.getTasks(selected.tasklist.id); break; case 'task': data.timeEntries = teamwork.getTaskEntries(selected.task.id); break; default: break; } } }); } userData.get().currentDir = getCurrentDir(); userData.save(); }; /** * Wrapper around 'cd' that allows going back */ let lastDir = '/'; const reversableCd = (args) => { const goBack = args && args.length > 1 && args[1] === '-'; const cdArgs = goBack ? ['cd', lastDir.slice()] : args; lastDir = getCurrentDir(); cd(cdArgs); }; const logTimeInteractive = (task) => { const defaults = { description: '', hours: 8, minutes: 0, date: dateFormat(new Date(), "yyyymmdd"), isbillable: 1 }; // check for a timer - hours/minutes const timer = userData.get().timers[task]; if (timer) { let timerLength = timer.duration; if (timer.running) { timerLength += new Date() - timer.started; } timerLength = Math.floor((timerLength) / 1000 / 60); defaults.hours = Math.floor(timerLength / 60); defaults.minutes = timerLength % 60; } // check if billable const taskId = isNaN(task) ? userData.get().favorites[task] : task; const tmTask = teamwork.getTask(taskId); defaults.isbillable = tmTask['project-name'].startsWith('RTS') ? 0 : 1; // get values from user const description = ask('Description', defaults.description); const hours = ask('Hours', defaults.hours); const minutes = ask('Minutes', defaults.minutes); const date = ask('Date', defaults.date); const isbillable = ask('Is Billable', defaults.isbillable); // send time entry return functions.sendTimeEntry({taskId, description, date, hours, minutes, isbillable}); }; /** * Overrides the terminal to create an time entry */ const logTime = (args) => { switch (getDirLevel()) { case "task": logTimeInteractive(state.selected.task.id); cd(['cd', '.']); break; default: console.log('not supported'); break; } }; /** * updates a time entry */ const editItem = (args) => { let entry; let task; const hasArg = args && args.length > 1; switch (getDirLevel()) { case "timeEntry": entry = state.selected.timeEntry; break; case "task": if (hasArg) { entry = findDirItem(state.data.timeEntries, args[1]); } else { task = state.selected.task; } break; case "tasklist": if (hasArg) { task = findDirItem(state.data.tasks, args[1]); } break; default: console.log('not supported'); return; } if (task) { const alteredTask = askForTaskInfo(task); if (!alteredTask.content) { console.log('Title required. Returning to prompt.'); return; } const resp = teamwork.editTask(task.id, alteredTask); prettyJson(resp); } else if (entry) { const dateStr = dateFormat(new Date(entry.date), "yyyymmdd"); entry.description = ask('Description', entry.description); entry.hours = ask('Hours', entry.hours); entry.minutes = ask('Minutes', entry.minutes); entry.date = ask('Date', dateStr); entry.isbillable = ask('Is Billable', entry.isbillable); teamwork.updateTimeEntry(entry); cd(['cd', '.']); } else { console.log('item not found'); return; } }; /** * moves a time entry */ const moveTimeEntry = (args) => { let entry, taskId; switch (getDirLevel()) { case "task": if (!args || args.length < 3) { console.log('Provide entry id or index and a task to move to'); return; } entry = findDirItem(state.data.timeEntries, args[1]); taskId = args[2]; break; default: console.log('not supported'); return; } if (!entry) { console.log('item not found'); return; } functions.moveTimeEntry(entry, taskId); cd(['cd', '.']); }; const parseTimeEstimate = (str) => { if (str.length === 0) { return null; } // if only number, assume hours let weeks = 0, days = 0, hours = 0, minutes = 0; if (!isNaN(str)) { hours = Number(str); } else { let res = /(\d+)w/.exec(str); if (res) { weeks = Number(res[1]); } res = /(\d+)d/.exec(str); if (res) { days = Number(res[1]); } res = /(\d+)h/.exec(str); if (res) { hours = Number(res[1]); } res = /(\d+)m/.exec(str); if (res) { minutes = Number(res[1]); } } return minutes + (hours + (days + weeks * 5) * 8) * 60; }; const askForTaskInfo = (defaults) => { const task = {}; const getDefault = (val) => { const v = defaults ? defaults[val] : null; return v ? v : ''; }; const content = ask('Title', getDefault('content')); if (content) { task.content = content; } const defEst = getDefault('estimatedMinutes'); const estimate = parseTimeEstimate(ask('Time estimate', defEst.length > 0 ? (defEst + 'm') : '')); if (estimate) { task.estimatedMinutes = estimate; } const description = ask('Description', getDefault('description')); if (description.length > 0) { task.description = description; } let parentTaskId = ask('Parent Task', getDefault('parentTaskId')); if (parentTaskId.length > 0) { if (isNaN(parentTaskId)) { parentTaskId = userData.get().favorites[parentTaskId]; } task.parentTaskId = parentTaskId; } const progress = ask('Progress % (0-90)', getDefault('progress')); if (progress.length > 0 && !isNaN(progress)) { task.progress = Number(progress); } let owner = ask('Owner (id|me)', getDefault('owner')); if (owner.length > 0) { if (owner.toLowerCase() === 'me') { owner = teamwork.getUserId(); } task.owner = owner; } const startDate = ask('Start Date(yyyymmdd)', getDefault('start-date')); if (startDate.length > 0) { task.startDate = startDate; } const dueDate = ask('Due Date(yyyymmdd)', getDefault('due-date')); if (dueDate.length > 0) { task.dueDate = dueDate; } const priority = ask('Priority(low|medium|high)', getDefault('priority')); if (priority.length > 0) { task.priority = priority; } const predecessors = ask('Predecessor Tasks(csv)', getDefault('predecessors')); if (predecessors.length > 0) { task.predecessors = predecessors.split(',') .map(p => p.trim()) .map(p => isNaN(p) ? userData.get().favorites[p] : p) .map(p => { return { id: p, type: 'complete' } }); } const positionAfterTask = ask('Position(-1,0,id)', getDefault('positionAfterTask')); if (positionAfterTask.length > 0) { task.positionAfterTask = isNaN(positionAfterTask) ? userData.get().favorites[positionAfterTask] : positionAfterTask; } const defTags = getDefault('tags'); const tags = ask('Tags(csv)', defTags ? defTags.map(t => t.name).join(',') : ''); if (tags.length > 0) { task.tags = tags.split(',') .map(p => p.trim()) .join(','); } return task; }; /** * Add item to the current directory */ const addItem = (args) => { switch (getDirLevel()) { case "tasklist": const newTask = askForTaskInfo(); if (!newTask.content) { console.log('Title required. Returning to prompt.'); return; } const tasklistId = state.selected.tasklist.id; const resp = teamwork.addTask(tasklistId, newTask); prettyJson(resp); state.data.tasks = teamwork.getTasks(tasklistId); break; case "task": logTime(args); break; default: console.log('not supported'); break; } }; const listNotebooks = (args) => { const {selected} = state; if (getDirLevel() === 'top') { console.log('unsupported'); return; } const selection = (args && args.length > 1) ? args[1] : null; let notebooks = teamwork.getProjectNotebooks(selected.project.id); if (notebooks) { if (selection) { const notebook = teamwork.getNotebook(notebooks[args[1]].id).content; console.log(htmlToText.fromString(notebook)); } else { notebooks.forEach((nb, idx) => { console.log(`${idx}) ${nb.id}: ${nb.name}`); }); } } }; /** * Print usage */ const usage = (args) => { console.log('This mode creates a quasi-terminal with a directory structure setup like teamwork. There is a top level "teamwork" directory containing a folder for each project, each project contains tasklists, and each tasklist contains tasks.'); console.log('\nOnce in a task you can log time. You can also create tasks/tasklists.'); commands.forEach(cmd => { console.log(`\n ${cmd.name.toUpperCase()}: ${cmd.aliases.join(', ')}`); console.log(' ' + cmd.description); }); }; const printInfo = (args) => { if (args && args.length > 1) { if (args[1] === 'hours') { functions.printTimeLogged(); } else if (args[1] === 'logged') { functions.printPreviousTasks() } else if (args[1] === 'today') { functions.printDateEntries(dateFormat(new Date(), 'yyyymmdd')); } else if (args.length > 2 && args[1] === 'on') { functions.printDateEntries(args[2]); } } else { functions.printTimeLogged(); } }; const echoItem = (args) => { if (args.length > 1) { const originalDir = getCurrentDir(); cd(args); echoItem([]); cd(['cd', originalDir]); } else { prettyJson(state.selected[getDirLevel()]); } }; const sureDelete = (description) => { const confirmation = ask('Are you sure you want to delete "' + description + '" [y/N]? ').toLowerCase(); return confirmation === 'y' || confirmation === 'yes'; }; const deleteItem = (args) => { if (!args || args.length < 2) { console.log('Removing requires arguments.'); return; } switch (getDirLevel()) { case 'tasklist': const task = findDirItem(state.data.tasks, args[1]); if (!task) { console.log('Task not found.'); } else if (sureDelete(task.content)) { teamwork.deleteTask(task.id); } break; case 'task': const entry = findDirItem(state.data.timeEntries, args[1]); if (!entry) { console.log('Entry not found.'); } else if (sureDelete(entry.description)) { teamwork.deleteTimeEntry(entry.id); } break; default: console.log('unsupported'); return; } cd(['cd', '.']); }; const copyItem = (args) => { if (!args || args.length < 2) { console.log('Copying requires arguments.'); return; } switch (getDirLevel()) { case 'task': const entry = findDirItem(state.data.timeEntries, args[1]); if (!entry) { console.log('Entry not found.'); } else { functions.sendTimeEntry({ taskId: state.selected.task.id, description: entry.description, date: dateFormat(new Date(), 'yyyymmdd'), hours: entry.hours, minutes: entry.minutes, isbillable: entry.isbillable, tags: entry.tags.map(t => t.name).join(','), }); } break; default: console.log('unsupported'); return; } cd(['cd', '.']); }; const commands = [ { name: 'exit', aliases: EXIT_COMMANDS, action: (args) => { }, description: 'Exit interactive mode.' }, { name: 'list', aliases: ['list', 'ls', 'l', 'll'], action: ls, description: 'List the contents of the item - a projects tasklists for example.' }, { name: 'select', aliases: ['select', 'sel', 'cd', 'c', ':e', 'enter', 'dir'], action: reversableCd, description: 'Select a project, tasklist, or task - aka change directory. You can change to a favorite as well.' }, { name: 'edit', aliases: ['edit'], action: editItem, description: 'Update a time entry' }, { name: 'move', aliases: ['move', 'mv'], action: moveTimeEntry, description: 'Move a time entry to another task' }, { name: 'help', aliases: ['help', 'h', 'pls', 'halp'], action: usage, description: 'Display this information.' }, { name: 'log time', aliases: ['log', 'entry', 'record'], action: logTime, description: 'Log time while in a given task' }, { name: 'create', aliases: ['create', 'mkdir', 'touch', 'make', 'add'], action: addItem, description: 'Create a new item in the entity (new task, tasklist, etc.)' }, { name: 'hours', aliases: ['hours', 'main'], action: (args) => main(['node', ...args], {logTimeInteractive, usage}), description: 'Normal hours command' }, { name: 'path', aliases: ['path', 'pwd'], action: () => console.log(getCurrentDir()), description: 'Display the current path using the Ids.' }, { name: 'echo', aliases: ['echo', 'cat', 'show', 'display'], action: echoItem, description: 'Display the json of the item' }, { name: 'remove', aliases: ['remove', 'rm', 'delete', 'del'], action: deleteItem, description: 'Delete the specified item.' }, { name: 'copy', aliases: ['copy', 'cp', 'duplicate', 'dup'], action: copyItem, description: 'Copy the specified item.' }, { name: 'today', aliases: ['today'], action: () => printInfo(['print', 'today']), description: 'Show logged today' }, { name: 'favorite', aliases: ['favorite', 'fav'], action: favorite, description: 'Mark task as favorite: fav [PATH] name' }, { name: 'favorites', aliases: ['favorites', 'favs', 'faves', 'favesies'], action: (args) => functions.listFavorites(args.indexOf('-v') > 0), description: 'List favorites (use -v for task names)' }, { name: 'clear', aliases: ['clear', 'cle'], action: () => process.stdout.write('\033c'), description: 'Clear screen' }, { name: 'search', aliases: ['search', '/', '?', 'find'], action: search, description: 'Searches for a task. If -e option is provided, then time entries with empty descriptions are listed.' }, { name: 'total', aliases: ['total', 'time', 'sum'], action: sumTime, description: 'Sums the time spent on an item or items' }, { name: 'notebooks', aliases: ['notebooks', 'notes', 'nb', 'books'], action: listNotebooks, description: 'List the notebooks in the current dir' }, ]; /** * Creates an interactive terminal to view and modify teamwork data * * @param startingPath Immediately executes 'cd' on this argument */ const interactiveMode = (startingPath) => { const data = userData.get(); if (data.teamwork.url && data.teamwork.key) { state.data.projects = teamwork.getProjects(); } if (data.currentDir) { reversableCd(['cd', data.currentDir]); } if (startingPath) { reversableCd(['cd', startingPath]); } while (1) { const answer = readline.question(getPromptText()); const args = answer.split(' '); const cmd = args[0].toLowerCase(); const command = commands.find(c => c.aliases.contains(cmd)); if (command) { command.action(args); } if (EXIT_COMMANDS.contains(cmd)) { break; } } }; module.exports = { interactiveMode, logTimeInteractive, usage };