@tosin2013/kanbn
Version:
A CLI Kanban board with AI-powered task management features
493 lines (474 loc) • 12.5 kB
JavaScript
const Kanbn = require('../main');
const utility = require('../utility');
const inquirer = require('inquirer');
const chrono = require('chrono-node');
const yaml = require('yamljs');
inquirer.registerPrompt('recursive', require('inquirer-recursive'));
const searchFields = [
{
name: 'Id',
field: 'id',
type: 'string'
},
{
name: 'Name',
field: 'name',
type: 'string'
},
{
name: 'Description',
field: 'description',
type: 'string'
},
{
name: 'Column',
field: 'column',
type: 'string'
},
{
name: 'Created',
field: 'created',
type: 'date'
},
{
name: 'Updated',
field: 'updated',
type: 'date'
},
{
name: 'Started',
field: 'started',
type: 'date'
},
{
name: 'Completed',
field: 'completed',
type: 'date'
},
{
name: 'Due',
field: 'due',
type: 'date'
},
{
name: 'Progress',
field: 'progress',
type: 'number'
},
{
name: 'Sub-tasks',
field: 'sub-task',
type: 'string'
},
{
name: 'Count sub-tasks',
field: 'count-sub-tasks',
type: 'number'
},
{
name: 'Tags',
field: 'tag',
type: 'string'
},
{
name: 'Count tags',
field: 'count-tags',
type: 'number'
},
{
name: 'Relations',
field: 'relation',
type: 'string'
},
{
name: 'Count relations',
field: 'count-relations',
type: 'number'
},
{
name: 'Comments',
field: 'comment',
type: 'string'
},
{
name: 'Count comments',
field: 'count-comments',
type: 'number'
},
{
name: 'Assigned',
field: 'assigned',
type: 'string'
}
];
/**
* Build search filters interactively
* @return {Promise<any>}
*/
async function interactive() {
return await inquirer.prompt([
{
type: 'recursive',
name: 'filters',
initialMessage: 'Add a filter?',
message: 'Add another filter?',
default: true,
prompts: [
{
type: 'rawlist',
name: 'type',
message: 'Filter type:',
default: searchFields[0].name,
choices: [
...searchFields.map(s => s.name),
new inquirer.Separator(),
'None'
]
},
{
type: 'input',
name: 'value',
message: 'Filter value:',
default: '',
when: answers => searchFields
.filter(s => s.type === 'string')
.map(s => s.name)
.indexOf(answers.type) !== -1,
validate: async value => {
if (!value) {
return 'Filter value cannot be empty';
}
return true;
}
},
{
type: 'input',
name: 'value',
message: 'Filter value:',
default: '',
when: answers => searchFields
.filter(s => s.type === 'date')
.map(s => s.name)
.indexOf(answers.type) !== -1,
validate: async value => {
if (!value) {
return 'Filter value cannot be empty';
}
if (chrono.parseDate(value) === null) {
return 'Unable to parse date';
}
return true;
}
},
{
type: 'input',
name: 'value',
message: 'Filter value:',
default: '',
when: answers => searchFields
.filter(s => s.type === 'number')
.map(s => s.name)
.indexOf(answers.type) !== -1,
validate: async value => {
if (!value) {
return 'Filter value cannot be empty';
}
if (isNaN(value)) {
return 'Filter value must be numeric';
}
return true;
}
},
{
type: 'confirm',
name: 'value',
message: 'Filter value:',
default: true,
when: answers => searchFields
.filter(s => s.type === 'boolean')
.map(s => s.name)
.indexOf(answers.type) !== -1
}
]
}
]);
}
/**
* Search tasks
* @param {object} filters
* @param {boolean} quiet
* @param {boolean} json
*/
function findTasks(filters, quiet, json) {
// Create a Kanbn instance
const kanbn = Kanbn();
const removeEmptyProperties = o => Object.fromEntries(Object.entries(o).filter(
([k, v]) => !(Array.isArray(v) && v.length == 0) && !!v
));
kanbn
.search(filters, quiet)
.then(results => {
if (quiet) {
console.log(json ? JSON.stringify(results, null, 2) : results.join('\n'));
} else {
if (json) {
console.log(JSON.stringify(results, null, 2));
} else {
console.log(`Found ${results.length} task${results.length === 1 ? '' : 's'}`);
if (results.length > 0) {
console.log('---');
}
console.log(results.map(result => yaml.stringify(removeEmptyProperties(result), 4, 2).trim()).join('\n---\n'));
}
}
})
.catch(error => {
utility.error(error);
});
}
/**
* Add a filter to the filters object without over-writing any existing filters
* @param {object} filters The current filter object
* @param {string} filterName The property name for the filter
* @param {string} filterValue The filter value
*/
function addFilterValue(filters, filterName, filterValue) {
if (filters[filterName]) {
if (Array.isArray(filters[filterName])) {
filters[filterName].push(filterValue);
} else {
filters[filterName] = [filters[filterName], filterValue];
}
} else {
filters[filterName] = filterValue;
}
}
/**
* Convert a filter or array of filters to numeric values
* @param {object} filters The current filter object
* @param {string} filterName The property name for the filter
* @return {boolean} True if all values could be converted
*/
function convertNumericFilters(filters, filterName) {
if (Array.isArray(filters[filterName])) {
for (let i = 0; i < filters[filterName].length; i++) {
const numericValue = parseInt(filters[filterName][i]);
if (isNaN(numericValue)) {
return false;
}
filters[filterName][i] = numericValue;
}
} else {
const numericValue = parseInt(filters[filterName]);
if (isNaN(numericValue)) {
return false;
}
filters[filterName] = numericValue;
}
return true;
}
/**
* Convert a filter or array of filters to date values
* @param {object} filters The current filter object
* @param {string} filterName The property name for the filter
* @return {boolean} True if all values could be converted
*/
function convertDateFilters(filters, filterName) {
if (Array.isArray(filters[filterName])) {
for (let i = 0; i < filters[filterName].length; i++) {
const dateValue = chrono.parseDate(filters[filterName][i]);
if (dateValue === null) {
return false;
}
filters[filterName][i] = dateValue;
}
} else {
const dateValue = chrono.parseDate(filters[filterName]);
if (dateValue === null) {
return false;
}
filters[filterName] = dateValue;
}
return true;
}
module.exports = async args => {
// Create a Kanbn instance
const kanbn = Kanbn();
// Make sure kanbn has been initialised
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 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;
}
// Add custom fields to search fields
if ('customFields' in index.options) {
for (let customField of index.options.customFields) {
searchFields.push({
name: customField.name,
field: customField.name,
type: customField.type
});
}
}
// Get filters from args
const filters = {};
for (let filterProperty of searchFields.map(s => s.field)) {
if (args[filterProperty]) {
filters[filterProperty] = args[filterProperty];
}
}
// Check and convert numeric filters
if ('count-sub-tasks' in filters) {
if (!convertNumericFilters(filters, 'count-sub-tasks')) {
utility.error('Count sub-tasks filter value must be numeric');
return;
}
}
if ('count-tags' in filters) {
if (!convertNumericFilters(filters, 'count-tags')) {
utility.error('Count tags filter value must be numeric');
return;
}
}
if ('count-relations' in filters) {
if (!convertNumericFilters(filters, 'count-relations')) {
utility.error('Count relations filter value must be numeric');
return;
}
}
if ('count-comments' in filters) {
if (!convertNumericFilters(filters, 'count-comments')) {
utility.error('Count comments filter value must be numeric');
return;
}
}
if ('workload' in filters){
if (!convertNumericFilters(filters, 'workoad')) {
utility.error('Workload filter value must be numeric');
return;
}
}
if ('progress' in filters){
if (!convertNumericFilters(filters, 'progress')) {
utility.error('Progress filter value must be numeric');
return;
}
}
// Check date filters
if ('created' in filters) {
if (!convertDateFilters(filters, 'created')) {
utility.error('Unable to parse created date');
return;
}
}
if ('updated' in filters) {
if (!convertDateFilters(filters, 'updated')) {
utility.error('Unable to parse updated date');
return;
}
}
if ('started' in filters) {
if (!convertDateFilters(filters, 'started')) {
utility.error('Unable to parse started date');
return;
}
}
if ('completed' in filters) {
if (!convertDateFilters(filters, 'completed')) {
utility.error('Unable to parse completed date');
return;
}
}
if ('due' in filters) {
if (!convertDateFilters(filters, 'due')) {
utility.error('Unable to parse due date');
return;
}
}
// Check and add custom field filters
if ('customFields' in index.options) {
for (let customField of index.options.customFields) {
if (customField.name in args) {
filters[customField.name] = args[customField.name];
switch (customField.type) {
case 'boolean':
if (typeof filters[customField.name] !== 'boolean') {
utility.error(`Custom field "${customField.name}" value is not a boolean`);
return;
}
break;
case 'number':
if (!convertNumericFilters(filters, customField.name)) {
utility.error(`Custom field "${customField.name}" value is not a number`);
return;
}
break;
case 'date':
if (!convertDateFilters(filters, customField.name)) {
utility.error(`Unable to parse date for custom field "${customField.name}"`);
return;
}
break;
default: break;
}
}
}
}
// Build search filters interactively
if (args.interactive) {
interactive()
.then(answers => {
inquirer
.prompt([
{
type: 'confirm',
name: 'nonquiet',
message: 'Show full task details in results?',
default: !args.quiet
},
{
type: 'confirm',
name: 'json',
message: 'Show results in JSON format?',
default: !!args.json
}
])
.then(otherAnswers => {
for (let filter of answers.filters) {
addFilterValue(
filters,
searchFields.find(s => s.name === filter.type).field,
filter.value
);
}
findTasks(filters, !otherAnswers.nonquiet, otherAnswers.json);
})
.catch(error => {
utility.error(error);
});
})
.catch(error => {
utility.error(error);
});
// Otherwise create task non-interactively
} else {
findTasks(filters, args.quiet, args.json);
}
};