@tosin2013/kanbn
Version:
A CLI Kanban board with AI-powered task management features
868 lines (807 loc) • 24.4 kB
JavaScript
const Kanbn = require('../main');
const utility = require('../utility');
const inquirer = require('inquirer');
const fuzzy = require('fuzzy');
const chrono = require('chrono-node');
const getGitUsername = require('git-user-name');
inquirer.registerPrompt('datepicker', require('inquirer-datepicker'));
inquirer.registerPrompt('recursive', require('inquirer-recursive'));
inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt'));
/**
* Update a task interactively
* @param {object} taskData
* @param {string[]} taskIds
* @param {string} columnName
* @param {string[]} columnNames
* @return {Promise<any>}
*/
async function interactive(taskData, taskIds, columnName, columnNames) {
const dueDateExists = (
'metadata' in taskData &&
'due' in taskData.metadata &&
taskData.metadata.due != null
);
const assignedExists = (
'metadata' in taskData &&
'assigned' in taskData.metadata &&
taskData.metadata.assigned != null
);
return await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Task name:',
default: taskData.name || '',
validate: async value => {
if (!value) {
return 'Task name cannot be empty';
}
return true;
}
},
{
type: 'confirm',
name: 'editDescription',
message: 'Edit description?',
default: false
},
{
type: 'editor',
name: 'description',
message: 'Task description:',
default: taskData.description,
when: answers => answers.editDescription
},
{
type: 'list',
name: 'column',
message: 'Column:',
default: columnName,
choices: columnNames
},
{
type: 'expand',
name: 'editDue',
message: 'Edit or remove due date?',
default: 'none',
when: answers => dueDateExists,
choices: [
{
key: 'e',
name: 'Edit',
value: 'edit'
},
{
key: 'r',
name: 'Remove',
value: 'remove'
},
new inquirer.Separator(),
{
key: 'n',
name: 'Do nothing',
value: 'none'
}
]
},
{
type: 'confirm',
name: 'setDue',
message: 'Set a due date?',
default: false,
when: answers => !dueDateExists
},
{
type: 'datepicker',
name: 'due',
message: 'Due date:',
default: dueDateExists ? taskData.metadata.due : new Date(),
format: ['Y', '/', 'MM', '/', 'DD'],
when: answers => answers.setDue || answers.editDue === 'edit'
},
{
type: 'expand',
name: 'editAssigned',
message: 'Edit or remove assigned user?',
default: 'none',
when: answers => assignedExists,
choices: [
{
key: 'e',
name: 'Edit',
value: 'edit'
},
{
key: 'r',
name: 'Remove',
value: 'remove'
},
new inquirer.Separator(),
{
key: 'n',
name: 'Do nothing',
value: 'none'
}
]
},
{
type: 'confirm',
name: 'setAssigned',
message: 'Assign this task?',
default: false,
when: answers => !assignedExists
},
{
type: 'input',
name: 'assigned',
message: 'Assigned to:',
default: assignedExists ? taskData.metadata.assigned : getGitUsername(),
when: answers => answers.setAssigned || answers.editAssigned === 'edit'
},
{
type: 'recursive',
name: 'addSubTasks',
initialMessage: 'Add a sub-task?',
message: 'Add another sub-task?',
default: false,
prompts: [
{
type: 'input',
name: 'text',
message: 'Sub-task text:',
validate: value => {
if (!value) {
return 'Sub-task text cannot be empty';
}
return true;
}
},
{
type: 'confirm',
name: 'completed',
message: 'Sub-task completed?',
default: false
}
]
},
{
type: 'recursive',
name: 'editSubTasks',
initialMessage: 'Update or remove a sub-task?',
message: 'Update or remove another sub-task?',
default: false,
when: answers => taskData.subTasks.length > 0,
prompts: [
{
type: 'list',
name: 'selectSubTask',
message: 'Which sub-task do you want to update or remove?',
choices: taskData.subTasks.map(subTask => subTask.text)
},
{
type: 'expand',
name: 'editSubTask',
message: 'Edit completion status or remove sub-task?',
default: 'none',
choices: [
{
key: 'e',
name: 'Edit completion status',
value: 'edit'
},
{
key: 'r',
name: 'Remove',
value: 'remove'
},
new inquirer.Separator(),
{
key: 'n',
name: 'Do nothing',
value: 'none'
}
]
},
{
type: 'confirm',
name: 'completed',
message: 'Sub-task completed?',
default: answers => taskData.subTasks.find(subTask => subTask.text === answers.selectSubTask).completed,
when: answers => answers.editSubTask === 'edit'
}
]
},
{
type: 'recursive',
name: 'addTags',
initialMessage: 'Add a tag?',
message: 'Add another tag?',
default: false,
prompts: [
{
type: 'input',
name: 'name',
message: 'Tag name:',
validate: value => {
if (!value) {
return 'Tag name cannot be empty';
}
return true;
}
}
]
},
{
type: 'recursive',
name: 'removeTags',
initialMessage: 'Remove a tag?',
message: 'Remove another tag?',
default: false,
when: answers => (
'metadata' in taskData &&
'tags' in taskData.metadata &&
taskData.metadata.tags.length > 0
),
prompts: [
{
type: 'list',
name: 'selectTag',
message: 'Which tag do you want to remove?',
choices: taskData.metadata.tags
}
]
},
{
type: 'recursive',
name: 'addReferences',
initialMessage: 'Add a reference?',
message: 'Add another reference?',
default: false,
prompts: [
{
type: 'input',
name: 'url',
message: 'Reference URL:',
validate: value => {
if (!value) {
return 'Reference URL cannot be empty';
}
return true;
}
}
]
},
{
type: 'recursive',
name: 'removeReferences',
initialMessage: 'Remove a reference?',
message: 'Remove another reference?',
default: false,
when: answers => (
'metadata' in taskData &&
'references' in taskData.metadata &&
taskData.metadata.references.length > 0
),
prompts: [
{
type: 'list',
name: 'selectReference',
message: 'Which reference do you want to remove?',
choices: taskData.metadata.references
}
]
},
{
type: 'recursive',
name: 'addRelations',
initialMessage: 'Add a relation?',
message: 'Add another relation?',
default: false,
when: answers => taskIds.length > 0,
prompts: [
{
type: 'autocomplete',
name: 'task',
message: 'Related task id:',
source: (answers, input) => {
input = input || '';
const result = fuzzy.filter(input, taskIds);
return new Promise(resolve => {
resolve(result.map(result => result.string));
});
}
},
{
type: 'input',
name: 'type',
message: 'Relation type:'
}
]
},
{
type: 'recursive',
name: 'editRelations',
initialMessage: 'Update or remove a relation?',
message: 'Update or remove another relation?',
default: false,
when: answers => taskData.relations.length > 0,
prompts: [
{
type: 'list',
name: 'selectRelation',
message: 'Which relation do you want to update or remove?',
choices: taskData.relations.map(relation => relation.task)
},
{
type: 'expand',
name: 'editRelation',
message: 'Edit relation type or remove relation?',
default: 'none',
choices: [
{
key: 'e',
name: 'Edit relation type',
value: 'edit'
},
{
key: 'r',
name: 'Remove',
value: 'remove'
},
new inquirer.Separator(),
{
key: 'n',
name: 'Do nothing',
value: 'none'
}
]
},
{
type: 'input',
name: 'type',
message: 'Relation type:',
default: answers => taskData.relations.find(relation => relation.task === answers.selectRelation).task,
when: answers => answers.editRelation === 'edit'
}
]
}
]);
}
/**
* Update a task
* @param {string} taskId
* @param {object} taskData
* @param {?string} columnName
*/
function updateTask(taskId, taskData, columnName) {
const kanbn = Kanbn();
kanbn
.updateTask(taskId, taskData, columnName)
.then(taskId => {
console.log(`Updated task "${taskId}"`);
})
.catch(error => {
utility.error(error);
});
}
module.exports = async args => {
// Make sure kanbn has been initialised
const kanbn = Kanbn();
try {
if (!await kanbn.initialised()) {
utility.warning('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
return;
}
} catch (error) {
utility.warning('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
return;
}
// Get the task that we're editing
const taskId = args._[1];
if (!taskId) {
utility.error('No task id specified\nTry running {b}kanbn edit "task id"{b}');
return;
}
// Make sure the task exists
try {
await kanbn.taskExists(taskId);
} catch (error) {
utility.error(error);
return;
}
// Get the index and make sure it has some columns
let index;
try {
index = await kanbn.getIndex();
} catch (error) {
utility.error(error);
return;
}
const columnNames = Object.keys(index.columns);
if (!columnNames.length) {
utility.error('No columns defined in the index\nTry running {b}kanbn init -c "column name"{b}');
return;
}
// Get the current task data
let taskData;
try {
taskData = await kanbn.getTask(taskId);
} catch (error) {
utility.error(error);
return;
}
// Get column name if specified
const { findTaskColumn } = require('../main');
let currentColumnName = findTaskColumn(index, taskId);
let columnName = currentColumnName;
if (args.column) {
columnName = utility.strArg(args.column);
if (columnNames.indexOf(columnName) === -1) {
utility.error(`Column "${columnName}" doesn't exist`);
return;
}
}
// Get a list of existing task ids
const taskIds = [...await kanbn.findTrackedTasks()];
// Get task settings from arguments
// Name
if (args.name) {
taskData.name = utility.strArg(args.name);
}
// Description
if (args.description) {
taskData.description = utility.strArg(args.description);
}
// Due date
if (args.due) {
if (!('metadata' in taskData)) {
taskData.metadata = {};
}
taskData.metadata.due = chrono.parseDate(utility.strArg(args.due));
if (taskData.metadata.due === null) {
utility.error('Unable to parse due date');
return;
}
}
// Progress
if (args.progress) {
if (!('metadata' in taskData)) {
taskData.metadata = {};
}
const progressValue = parseFloat(utility.strArg(args.progress));
if (isNaN(progressValue)) {
utility.error('Progress value is not a number');
return;
}
taskData.metadata.progress = progressValue;
}
// Assigned
if (args.assigned) {
if (!('metadata' in taskData)) {
taskData.metadata = {};
}
const gitUsername = getGitUsername();
if (args.assigned === true) {
if (gitUsername) {
taskData.metadata.assigned = gitUsername;
}
} else {
taskData.metadata.assigned = utility.strArg(args.assigned);
}
}
// Remove sub-tasks
if (args['remove-sub-task']) {
const removedSubTasks = utility.arrayArg(args['remove-sub-task']);
// Check that the sub-tasks being removed currently exist
for (let removedSubTask of removedSubTasks) {
if (taskData.subTasks.find(subTask => subTask.text === removedSubTask.text) === undefined) {
utility.error(`Sub-task "${removedSubTask.text}" doesn't exist`);
return;
}
}
taskData.subTasks = taskData.subTasks.filter(subTask => removedSubTasks.indexOf(subTask.text) === -1);
}
// Add or update sub-tasks
if (args['sub-task']) {
const newSubTaskInputs = utility.arrayArg(args['sub-task']);
const newSubTasks = newSubTaskInputs.map(subTask => {
const match = subTask.match(/^\[([x ])\] (.*)/);
if (match !== null) {
return {
completed: match[1] === 'x',
text: match[2]
};
}
return {
completed: false,
text: subTask
};
});
// Add or update
for (let newSubTask of newSubTasks) {
// Check if a sub-task already exists in the task with matching text
const foundSubTask = taskData.subTasks.find(subTask => subTask.text === newSubTask.text);
if (foundSubTask === undefined) {
// The sub-task doesn't already exist
taskData.subTasks.push(newSubTask);
// Otherwise, the sub-task already exists so update its completed status
} else {
foundSubTask.completed = newSubTask.completed;
}
}
}
// Remove tags
if (args['remove-tag']) {
const removedTags = utility.arrayArg(args['remove-tag']);
// Check that the task has metadata
if (!('metadata' in taskData) || !('tags' in taskData.metadata) || !Array.isArray(taskData.metadata.tags)) {
utility.error('Task has no tags to remove');
return;
}
// Check that the tags being removed currently exist
for (let removedTag of removedTags) {
if (taskData.metadata.tags.indexOf(removedTag) === -1) {
utility.error(`Tag "${removedSubTask.text}" doesn't exist`);
return;
}
}
taskData.metadata.tags = taskData.metadata.tags.filter(tag => removedTags.indexOf(tag) === -1);
}
// Add tags and overwrite existing tags
if (args.tag) {
const newTags = utility.arrayArg(args.tag);
taskData.metadata.tags = [...new Set([...taskData.metadata.tags || [], ...newTags])];
}
// Add references
if (args.refs) {
if (!('metadata' in taskData)) {
taskData.metadata = {};
}
taskData.metadata.references = utility.arrayArg(args.refs);
}
// Add a single reference
if (args['add-ref']) {
if (!('metadata' in taskData)) {
taskData.metadata = {};
}
if (!('references' in taskData.metadata)) {
taskData.metadata.references = [];
}
const newRefs = utility.arrayArg(args['add-ref']);
taskData.metadata.references = [...new Set([...taskData.metadata.references || [], ...newRefs])];
}
// Remove references
if (args['remove-ref']) {
const removedRefs = utility.arrayArg(args['remove-ref']);
// Check that the task has metadata
if (!('metadata' in taskData) || !('references' in taskData.metadata) || !Array.isArray(taskData.metadata.references)) {
utility.error('Task has no references to remove');
return;
}
// Check that the references being removed currently exist
for (let removedRef of removedRefs) {
if (taskData.metadata.references.indexOf(removedRef) === -1) {
utility.error(`Reference "${removedRef}" doesn't exist`);
return;
}
}
taskData.metadata.references = taskData.metadata.references.filter(ref => removedRefs.indexOf(ref) === -1);
}
// Remove relations
if (args['remove-relation']) {
const removedRelations = utility.arrayArg(args['remove-relation']);
// Check that the relations being removed currently exist
for (let removedRelation of removedRelations) {
if (taskData.relations.find(relation => relation.task === removedRelation.task) === undefined) {
utility.error(`Relation "${removedRelation.task}" doesn't exist`);
return;
}
}
taskData.relations = taskData.relations.filter(relation => removedRelations.indexOf(relation.task) === -1);
}
// Add or update relations
if (args.relation) {
const newRelationInputs = utility.arrayArg(args.relation);
const newRelations = newRelationInputs.map(relation => {
const parts = relation.split(' ');
return parts.length === 1
? {
type: '',
task: parts[0].trim()
}
: {
type: parts[0].trim(),
task: parts[1].trim()
};
});
// Add or update
for (let newRelation of newRelations) {
// Check if a relation already exists in the task with matching task id
const foundRelation = taskData.relations.find(relation => relation.task === newRelation.task);
if (foundRelation === undefined) {
// The relation doesn't already exist
taskData.relations.push(newRelation);
// Otherwise, the relation already exists so update its relation type
} else {
foundRelation.type = newRelation.type;
}
}
}
// Check custom field types
if ('customFields' in index.options) {
if (!('metadata' in taskData)) {
taskData.metadata = {};
}
for (let arg of Object.keys(args)) {
// Check if we're removing a custom field
const removeCustomField = index.options.customFields.find(p => `remove-${p.name}` === arg);
if (removeCustomField !== undefined) {
if (removeCustomField.name in taskData.metadata) {
delete taskData.metadata[removeCustomField.name];
}
}
// Check if we're adding or modifying a custom field
const customField = index.options.customFields.find(p => p.name === arg);
if (customField !== undefined) {
// Check value type
switch (customField.type) {
case 'boolean':
if (typeof args[arg] === 'boolean') {
taskData.metadata[arg] = args[arg];
} else {
utility.error(`Custom field "${arg}" value is not a boolean`);
return;
}
break;
case 'number':
const numberValue = parseFloat(args[arg]);
if (!isNaN(numberValue)) {
taskData.metadata[arg] = numberValue;
} else {
utility.error(`Custom field "${arg}" value is not a number`);
return;
}
break;
case 'string':
if (typeof args[arg] === 'string') {
taskData.metadata[arg] = args[arg];
} else {
utility.error(`Custom field "${fieldName}" value is not a string`);
return;
}
break;
case 'date':
const dateValue = chrono.parseDate(args[arg]);
if (dateValue instanceof Date) {
taskData.metadata[arg] = dateValue;
} else {
utility.error(`Unable to parse date for custom field "${arg}"`);
return;
}
break;
default: break;
}
}
}
}
// Update task interactively
if (args.interactive) {
interactive(taskData, taskIds, columnName, columnNames)
.then(answers => {
// Name
taskData.name = answers.name;
// Description
if ('description' in answers) {
taskData.description = answers.description;
}
// Remove due date
if ('editDue' in answers && answers.editDue === 'remove') {
delete taskData.metadata.due;
}
// Due date
if ('due' in answers) {
taskData.metadata.due = answers.due.toISOString();
}
// Remove assigned
if ('editAssigned' in answers && answers.editAssigned === 'remove') {
delete taskData.metadata.assigned;
}
// Assigned
if ('assigned' in answers) {
taskData.metadata.assigned = answers.assigned;
}
// Edit or remove sub-tasks
if ('editSubTasks' in answers) {
for (editSubTask of answers.editSubTasks) {
const i = taskData.subTasks.findIndex(subTask => subTask.task === editSubTask.selectSubTask);
if (i !== -1) {
switch (editSubTask.editSubTask) {
case 'remove':
taskData.subTasks.splice(i, 1);
break;
case 'edit':
taskData.subTasks[i].completed = editSubTask.completed;
break;
default:
break;
}
}
}
}
// Add sub-tasks
if ('addSubTasks' in answers) {
taskData.subTasks.push(...answers.addSubTasks.map(addSubTask => ({
text: addSubTask.text,
completed: addSubTask.completed
})));
}
// Remove tags
if ('removeTags' in answers && 'metadata' in taskData && 'tags' in taskData.metadata) {
for (removeTag of answers.removeTags) {
const i = taskData.metadata.tags.indexOf(removeTag.name);
if (i !== -1) {
taskData.metadata.tags.splice(i, 1);
}
}
}
// Add tags
if ('addTags' in answers && 'metadata' in taskData && 'tags' in taskData.metadata) {
taskData.metadata.tags.push(...answers.addTags.map(tag => tag.name));
}
// Remove references
if ('removeReferences' in answers && 'metadata' in taskData && 'references' in taskData.metadata) {
for (removeReference of answers.removeReferences) {
const i = taskData.metadata.references.indexOf(removeReference.selectReference);
if (i !== -1) {
taskData.metadata.references.splice(i, 1);
}
}
}
// Add references
if ('addReferences' in answers) {
if (!('metadata' in taskData)) {
taskData.metadata = {};
}
if (!('references' in taskData.metadata)) {
taskData.metadata.references = [];
}
taskData.metadata.references.push(...answers.addReferences.map(ref => ref.url));
}
// Edit or remove relations
if ('editRelations' in answers) {
for (editRelation of answers.editRelations) {
const i = taskData.relations.findIndex(relation => relation.task === editRelation.selectRelation);
if (i !== -1) {
switch (editRelation.editRelation) {
case 'remove':
taskData.relations.splice(i, 1);
break;
case 'edit':
taskData.relations[i].type = editRelation.type;
break;
default:
break;
}
}
}
}
// Add relations
if ('addRelations' in answers) {
taskData.relations.push(...answers.addRelations.map(addRelation => ({
task: addRelation.task,
type: addRelation.type
})));
}
// Update task
columnName = answers.column !== currentColumnName ? answers.column : null;
updateTask(taskId, taskData, columnName);
})
.catch(error => {
utility.error(error);
});
// Otherwise edit task non-interactively
} else {
columnName = columnName !== currentColumnName ? columnName : null;
updateTask(taskId, taskData, columnName);
}
};