@tosin2013/kanbn
Version:
A CLI Kanban board with AI-powered task management features
1,526 lines (1,364 loc) • 56.2 kB
JavaScript
const fs = require("fs");
const path = require("path");
const glob = require("glob-promise");
const parseIndex = require("./parse-index");
const parseTask = require("./parse-task");
const utility = require("./utility");
const yaml = require("yamljs");
const humanizeDuration = require("humanize-duration");
const rimraf = require("rimraf");
const fileUtils = require("./lib/file-utils");
const taskUtils = require("./lib/task-utils");
const filterUtils = require("./lib/filter-utils");
const indexUtils = require("./lib/index-utils");
const statusUtils = require("./lib/status-utils");
const DEFAULT_FOLDER_NAME = ".kanbn";
const DEFAULT_INDEX_FILE_NAME = "index.md";
const DEFAULT_TASKS_FOLDER_NAME = "tasks";
const DEFAULT_ARCHIVE_FOLDER_NAME = "archive";
// Date normalisation intervals measured in milliseconds
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// Default fallback values for index options
const DEFAULT_TASK_WORKLOAD = 2;
const DEFAULT_TASK_WORKLOAD_TAGS = {
Nothing: 0,
Tiny: 1,
Small: 2,
Medium: 3,
Large: 5,
Huge: 8,
};
const DEFAULT_DATE_FORMAT = "d mmm yy, H:MM";
const DEFAULT_TASK_TEMPLATE = "^+^_${overdue ? '^R' : ''}${name}^: ${created ? ('\\n^-^/' + created) : ''}";
/**
* Default options for the initialise command
*/
const defaultInitialiseOptions = {
name: "Project Name",
description: "",
options: {
startedColumns: ["In Progress"],
completedColumns: ["Done"],
},
columns: ["Backlog", "Todo", "In Progress", "Done"],
};
/**
* Get a list of all tracked task ids
* @param {object} index The index object
* @param {?string} [columnName=null] The optional column name to filter tasks by
* @return {Set} A set of task ids appearing in the index
*/
function getTrackedTaskIds(index, columnName = null) {
return indexUtils.getTrackedTaskIds(index, columnName);
}
/**
* Get a task path from the id
* @param {string} tasksPath The path to the tasks folder
* @param {string} taskId The task id
* @return {string} The task path
*/
function getTaskPath(tasksPath, taskId) {
return fileUtils.getTaskPath(tasksPath, taskId);
}
function addFileExtension(taskId) {
return fileUtils.addFileExtension(taskId);
}
function removeFileExtension(taskId) {
return fileUtils.removeFileExtension(taskId);
}
function taskInIndex(index, taskId) {
return taskUtils.taskInIndex(index, taskId);
}
function findTaskColumn(index, taskId) {
return taskUtils.findTaskColumn(index, taskId);
}
function addTaskToIndex(index, taskId, columnName, position = null) {
return taskUtils.addTaskToIndex(index, taskId, columnName, position);
}
function removeTaskFromIndex(index, taskId) {
return taskUtils.removeTaskFromIndex(index, taskId);
}
function renameTaskInIndex(index, taskId, newTaskId) {
return taskUtils.renameTaskInIndex(index, taskId, newTaskId);
}
function getTaskMetadata(taskData, property) {
return taskUtils.getTaskMetadata(taskData, property);
}
function setTaskMetadata(taskData, property, value) {
return taskUtils.setTaskMetadata(taskData, property, value);
}
function taskCompleted(index, task) {
return taskUtils.taskCompleted(index, task);
}
/**
* Sort a column in the index
* @param {object} index The index object
* @param {object[]} tasks The tasks in the index
* @param {string} columnName The column to sort
* @param {object[]} sorters A list of sorter objects
* @return {object} The modified index object
*/
function sortColumnInIndex(index, tasks, columnName, sorters) {
return indexUtils.sortColumnInIndex(index, tasks, columnName, sorters);
}
/**
* Sort a list of tasks
* @param {object[]} tasks
* @param {object[]} sorters
* @return {object[]} The sorted tasks
*/
function sortTasks(tasks, sorters) {
return indexUtils.sortTasks(tasks, sorters);
}
/**
* Transform a value using a sort filter regular expression
* @param {string} value
* @param {string} filter
* @return {string} The transformed value
*/
function sortFilter(value, filter) {
return indexUtils.sortFilter(value, filter);
}
/**
* Compare two values (supports string, date and number values)
* @param {any} a
* @param {any} b
* @return {number} A positive value if a > b, negative if a < b, otherwise 0
*/
function compareValues(a, b) {
return indexUtils.compareValues(a, b);
}
/**
* Filter a list of tasks using a filters object containing field names and filter values
* @param {object} index
* @param {object[]}} tasks
* @param {object} filters
*/
function filterTasks(index, tasks, filters) {
return indexUtils.filterTasks(index, tasks, filters);
}
/**
* Check if the input string matches the filter regex
* @param {string|string[]} filter A regular expression or array of regular expressions
* @param {string} input The string to match against
* @return {boolean} True if the input matches the string filter
*/
function stringFilter(filter, input) {
return filterUtils.stringFilter(filter, input);
}
/**
* Check if the input date matches a date (ignore time part), or if multiple dates are passed in, check if the
* input date is between the earliest and latest dates
* @param {Date|Date[]} dates A date or list of dates to check against
* @param {Date} input The input date to match against
* @return {boolean} True if the input matches the date filter
*/
function dateFilter(dates, input) {
return filterUtils.dateFilter(dates, input);
}
/**
* Check if the input matches a number, or if multiple numbers are passed in, check if the input is between the
* minimum and maximum numbers
* @param {number|number[]} filter A filter number or array of filter numbers
* @param {number} input The number to match against
* @return {boolean} True if the input matches the number filter
*/
function numberFilter(filter, input) {
return filterUtils.numberFilter(filter, input);
}
/**
* Calculate task workload
* @param {object} index The index object
* @param {object} task The task object
* @return {number} The task workload
*/
function taskWorkload(index, task) {
return indexUtils.taskWorkload(index, task, DEFAULT_TASK_WORKLOAD, DEFAULT_TASK_WORKLOAD_TAGS);
}
/**
* Get task progress amount
* @param {object} index
* @param {object} task
* @return {number} Task progress
*/
function taskProgress(index, task) {
return indexUtils.taskProgress(index, task);
}
/**
* Calculate task workload statistics between a start and end date
* @param {object[]} tasks
* @param {string} metadataProperty
* @param {Date} start
* @param {Date} end
* @return {object} A statistics object
*/
function taskWorkloadInPeriod(tasks, metadataProperty, start, end) {
return indexUtils.taskWorkloadInPeriod(tasks, metadataProperty, start, end);
}
/**
* Get a list of tasks that were started before and/or completed after a date
* @param {object[]} tasks
* @param {Date} date
* @return {object[]} A filtered list of tasks
*/
function getActiveTasksAtDate(tasks, date) {
return indexUtils.getActiveTasksAtDate(tasks, date);
}
/**
* Calculate the total workload at a specific date
* @param {object[]} tasks
* @param {Date} date
* @return {number} The total workload at the specified date
*/
function getWorkloadAtDate(tasks, date) {
return indexUtils.getWorkloadAtDate(tasks, date);
}
/**
* Get the number of tasks that were active at a specific date
* @param {object[]} tasks
* @param {Date} date
* @return {number} The total number of active tasks at the specified date
*/
function countActiveTasksAtDate(tasks, date) {
return indexUtils.countActiveTasksAtDate(tasks, date);
}
/**
* Get a list of tasks that were started or completed on a specific date
* @param {object[]} tasks
* @param {Date} date
* @return {object[]} A list of event objects, with event type and task id
*/
function getTaskEventsAtDate(tasks, date) {
return indexUtils.getTaskEventsAtDate(tasks, date);
}
/**
* Quantize a burndown chart date to 1-hour resolution
* @param {Date} date
* @param {string} resolution One of 'days', 'hours', 'minutes', 'seconds'
* @return {Date} The quantized dates
*/
function normaliseDate(date, resolution = 'minutes') {
return indexUtils.normaliseDate(date, resolution);
}
/**
* If a task's column is linked in the index to a custom field with type date, update the custom field's value
* in the task data with the current date
* @param {object} index
* @param {object} taskData
* @param {string} columnName
* @return {object} The updated task data
*/
function updateColumnLinkedCustomFields(index, taskData, columnName) {
return indexUtils.updateColumnLinkedCustomFields(index, taskData, columnName);
}
/**
* If index options contains a list of columns linked to a custom field name and a task's column matches one
* of the columns in this list, set the task's custom field value to the current date depending on criteria:
* - if 'once', update the value only if it's not currently set
* - if 'always', update the value regardless
* - otherwise, don't update the value
* @param {object} index
* @param {object} taskData
* @param {string} columnName
* @param {string} fieldName
* @param {string} [updateCriteria='none']
*/
function updateColumnLinkedCustomField(index, taskData, columnName, fieldName, updateCriteria = "none") {
return indexUtils.updateColumnLinkedCustomField(index, taskData, columnName, fieldName, updateCriteria);
}
class Kanbn {
ROOT = process.cwd();
CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
// Memoize config
configMemo = null;
constructor(root = null) {
if(root) {
this.ROOT = root
this.CONFIG_YAML = path.join(this.ROOT, "kanbn.yml");
this.CONFIG_JSON = path.join(this.ROOT, "kanbn.json");
}
}
/**
* Check if a separate config file exists
* @returns {Promise<boolean>} True if a config file exists
*/
async configExists() {
return await fileUtils.exists(this.CONFIG_YAML) || await fileUtils.exists(this.CONFIG_JSON);
}
/**
* Save configuration data to a separate config file
*/
async saveConfig(config) {
if (await fileUtils.exists(this.CONFIG_YAML)) {
await fs.promises.writeFile(this.CONFIG_YAML, yaml.stringify(config, 4, 2));
} else {
await fs.promises.writeFile(this.CONFIG_JSON, JSON.stringify(config, null, 4));
}
}
/**
* Get configuration settings from the config file if it exists, otherwise return null
* @return {Promise<Object|null>} Configuration settings or null if there is no separate config file
*/
async getConfig() {
if (this.configMemo === null) {
let config = null;
if (await fileUtils.exists(this.CONFIG_YAML)) {
try {
config = yaml.load(this.CONFIG_YAML);
} catch (error) {
throw new Error(`Couldn't load config file: ${error.message}`);
}
} else if (await fileUtils.exists(this.CONFIG_JSON)) {
try {
config = JSON.parse(await fs.promises.readFile(this.CONFIG_JSON, { encoding: "utf-8" }));
} catch (error) {
throw new Error(`Couldn't load config file: ${error.message}`);
}
}
this.configMemo = config;
}
return this.configMemo;
}
/**
* Clear cached config
*/
clearConfigCache() {
this.configMemo = null;
}
/**
* Get the name of the folder where the index and tasks are stored
* @return {Promise<string>} The kanbn folder name
*/
async getFolderName() {
const config = await this.getConfig();
if (config !== null && 'mainFolder' in config) {
return config.mainFolder;
}
return DEFAULT_FOLDER_NAME;
}
/**
* Get the index filename
* @return {Promise<string>} The index filename
*/
async getIndexFileName() {
const config = await this.getConfig();
if (config !== null && 'indexFile' in config) {
return config.indexFile;
}
return DEFAULT_INDEX_FILE_NAME;
}
/**
* Get the name of the folder where tasks are stored
* @return {Promise<string>} The task folder name
*/
async getTaskFolderName() {
const config = await this.getConfig();
if (config !== null && 'taskFolder' in config) {
return config.taskFolder;
}
return DEFAULT_TASKS_FOLDER_NAME;
}
/**
* Get the name of the archive folder
* @return {Promise<string>} The archive folder name
*/
async getArchiveFolderName() {
const config = await this.getConfig();
if (config !== null && 'archiveFolder' in config) {
return config.archiveFolder;
}
return DEFAULT_ARCHIVE_FOLDER_NAME;
}
/**
* Get the kanbn folder location for the current working directory
* @return {Promise<string>} The kanbn folder path
*/
async getMainFolder() {
return path.join(this.ROOT, await this.getFolderName());
}
/**
* Get the index path
* @return {Promise<string>} The kanbn index path
*/
async getIndexPath() {
return path.join(await this.getMainFolder(), await this.getIndexFileName());
}
/**
* Get the task folder path
* @return {Promise<string>} The kanbn task folder path
*/
async getTaskFolderPath() {
return path.join(await this.getMainFolder(), await this.getTaskFolderName());
}
/**
* Get the archive folder path
* @return {Promise<string>} The kanbn archive folder path
*/
async getArchiveFolderPath() {
return path.join(await this.getMainFolder(), await this.getArchiveFolderName());
}
/**
* Get the index as an object
* @return {Promise<index>} The index
*/
async getIndex() {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
return this.loadIndex();
}
/**
* Get a task as an object
* @param {string} taskId The task id to get
* @return {Promise<task>} The task
*/
async getTask(taskId) {
this.taskExists(taskId);
return this.loadTask(taskId);
}
/**
* Add additional index-based information to a task
* @param {index} index The index object
* @param {task} task The task object
* @return {task} The hydrated task
*/
hydrateTask(index, task) {
const completed = taskCompleted(index, task);
task.column = findTaskColumn(index, task.id);
task.workload = taskWorkload(index, task);
// Add progress information
task.progress = taskProgress(index, task);
task.remainingWorkload = Math.ceil(task.workload * (1 - task.progress));
// Add due information
if ("due" in task.metadata) {
const dueData = {};
// A task is overdue if it's due date is in the past and the task is not in a completed column
// or doesn't have a completed dates
const completedDate = "completed" in task.metadata ? task.metadata.completed : null;
// Get task due delta - this is the difference between now and the due date, or if the task is completed
// this is the difference between the completed and due dates
let delta;
if (completedDate !== null) {
delta = completedDate - task.metadata.due;
} else {
delta = new Date() - task.metadata.due;
}
// Populate due information
dueData.completed = completed;
dueData.completedDate = completedDate;
dueData.dueDate = task.metadata.due;
dueData.overdue = !completed && delta > 0;
dueData.dueDelta = delta;
// Prepare a due message for the task
let dueMessage = "";
if (completed) {
dueMessage += "Completed ";
}
dueMessage += `${humanizeDuration(delta, {
largest: 3,
round: true,
})} ${delta > 0 ? "overdue" : "remaining"}`;
dueData.dueMessage = dueMessage;
task.dueData = dueData;
}
return task;
}
/**
* Return a filtered and sorted list of tasks
* @param {index} index The index object
* @param {task[]} tasks A list of task objects
* @param {object} filters A list of task filters
* @param {object[]} sorters A list of task sorters
* @return {object[]} A filtered and sorted list of tasks
*/
filterAndSortTasks(index, tasks, filters, sorters) {
return sortTasks(filterTasks(index, tasks, filters), sorters);
}
/**
* Overwrite the index file with the specified data
* @param {object} indexData Index data to save
*/
async saveIndex(indexData) {
return indexUtils.saveIndex(
indexData,
this.loadAllTrackedTasks.bind(this),
this.configExists.bind(this),
this.saveConfig.bind(this),
this.getIndexPath.bind(this)
);
}
/**
* Load the index file and parse it to an object
* @return {Promise<object>} The index object
*/
async loadIndex() {
return indexUtils.loadIndex(
this.getIndexPath.bind(this),
this.getConfig.bind(this)
);
}
/**
* Overwrite a task file with the specified data
* @param {string} path The task path
* @param {object} taskData The task data
*/
async saveTask(path, taskData) {
await fs.promises.writeFile(path, parseTask.json2md(taskData));
}
/**
* Load a task file and parse it to an object
* @param {string} taskId The task id
* @return {Promise<object>} The task object
*/
async loadTask(taskId) {
const taskPath = path.join(await this.getTaskFolderPath(), addFileExtension(taskId));
let taskData = "";
try {
taskData = await fs.promises.readFile(taskPath, { encoding: "utf-8" });
} catch (error) {
throw new Error(`Couldn't access task file: ${error.message}`);
}
const task = parseTask.md2json(taskData);
// Add the task ID to the task object for easier reference
task.id = taskId;
return task;
}
/**
* Load all tracked tasks and return an object with both project tasks and system tasks
* @param {object} index The index object
* @param {?string} [columnName=null] The optional column name to filter tasks by
* @return {Promise<object>} Object containing {projectTasks, systemTasks, allTasks}
*/
async loadAllTasksWithSeparation(index, columnName = null) {
const projectTasks = {};
const systemTasks = {};
const allTasks = {};
// Handle empty or undefined index or columns
if (!index || !index.columns) {
console.log('Warning: Index or columns is undefined or empty');
return { projectTasks, systemTasks, allTasks };
}
const trackedTasks = getTrackedTaskIds(index, columnName);
const taskUtils = require('./lib/task-utils');
// Track how many tasks we failed to load
let failedTaskCount = 0;
const totalTaskCount = trackedTasks.size;
for (let taskId of trackedTasks) {
try {
const task = await this.loadTask(taskId);
allTasks[taskId] = task;
// Determine if this is a system task
if (taskUtils.isSystemTask(taskId, task)) {
systemTasks[taskId] = task;
} else {
projectTasks[taskId] = task;
}
} catch (error) {
// Log error but don't fail completely
console.error(`Could not load task ${taskId}: ${error.message}`);
failedTaskCount++;
}
}
// If we failed to load most or all tasks, try filesystem detection as a fallback
if (failedTaskCount > 0 && (totalTaskCount === 0 || failedTaskCount / totalTaskCount > 0.5)) {
console.log(`Failed to load ${failedTaskCount}/${totalTaskCount} tasks, trying filesystem detection...`);
try {
// Look for task files in the tasks directory
const taskFolder = await this.getTaskFolderPath();
const taskFiles = await fs.promises.readdir(taskFolder);
for (const file of taskFiles) {
if (file.endsWith('.md')) {
const taskId = file.replace('.md', '');
// Skip if we already loaded this task successfully
if (taskId in allTasks) {
continue;
}
try {
const taskPath = path.join(taskFolder, file);
const taskContent = await fs.promises.readFile(taskPath, { encoding: 'utf-8' });
const task = parseTask.md2json(taskContent);
// Add the task ID to the task object for easier reference
task.id = taskId;
// Add to the appropriate collection
allTasks[taskId] = task;
if (taskUtils.isSystemTask(taskId, task)) {
systemTasks[taskId] = task;
} else {
projectTasks[taskId] = task;
}
} catch (taskError) {
console.error(`Error loading task file ${file}: ${taskError.message}`);
}
}
}
} catch (fsError) {
console.error(`Error reading task directory: ${fsError.message}`);
}
}
return { projectTasks, systemTasks, allTasks };
}
/**
* Enhanced version of loadAllTrackedTasks that handles errors gracefully
* @param {object} index The index object
* @param {?string} [columnName=null] The optional column name to filter tasks by
* @param {boolean} [includeSystemTasks=false] Whether to include system-generated tasks
* @return {Promise<object>} Object with task data keyed by task ID
*/
async loadAllTrackedTasks(index, columnName = null, includeSystemTasks = false) {
try {
const { projectTasks, systemTasks, allTasks } = await this.loadAllTasksWithSeparation(index, columnName);
return includeSystemTasks ? allTasks : projectTasks;
} catch (error) {
console.error(`Error loading tasks: ${error.message}`);
return {}; // Return empty object instead of failing
}
}
/**
* Load a task file from the archive and parse it to an object
* @param {string} taskId The task id
* @return {Promise<object>} The task object
*/
async loadArchivedTask(taskId) {
const taskPath = path.join(await this.getArchiveFolderPath(), addFileExtension(taskId));
let taskData = "";
try {
taskData = await fs.promises.readFile(taskPath, { encoding: "utf-8" });
} catch (error) {
throw new Error(`Couldn't access archived task file: ${error.message}`);
}
return parseTask.md2json(taskData);
}
/**
* Get the date format defined in the index, or the default date format
* @param {object} index The index object
* @return {string} The date format
*/
getDateFormat(index) {
return "dateFormat" in index.options ? index.options.dateFormat : DEFAULT_DATE_FORMAT;
}
/**
* Get the task template for displaying tasks on the kanbn board from the index, or the default task template
* @param {object} index The index object
* @return {string} The task template
*/
getTaskTemplate(index) {
return "taskTemplate" in index.options ? index.options.taskTemplate : DEFAULT_TASK_TEMPLATE;
}
/**
* Check if the current working directory has been initialised
* @return {Promise<boolean>} True if the current working directory has been initialised, otherwise false
*/
async initialised() {
return await fileUtils.exists(await this.getIndexPath());
}
/**
* Initialise a kanbn board in the current working directory
* @param {object} [options={}] Initial columns and other config options
*/
async initialise(options = {}) {
// Check if a main folder is defined in an existing config file
const mainFolder = await this.getMainFolder();
// Create main folder if it doesn't already exist
if (!(await fileUtils.exists(mainFolder))) {
await fs.promises.mkdir(mainFolder, { recursive: true });
}
// Create tasks folder if it doesn't already exist
const taskFolder = await this.getTaskFolderPath();
if (!(await fileUtils.exists(taskFolder))) {
await fs.promises.mkdir(taskFolder, { recursive: true });
}
// Create index if one doesn't already exist
let index;
if (!(await fileUtils.exists(await this.getIndexPath()))) {
// If config already exists in a separate file, merge it into the options
const config = await this.getConfig();
// Create initial options
const opts = Object.assign({}, defaultInitialiseOptions, options);
index = {
name: opts.name,
description: opts.description,
options: Object.assign({}, opts.options, config || {}),
columns: Object.fromEntries(opts.columns.map((columnName) => [columnName, []])),
};
// Otherwise, if index already exists and we have specified new settings, re-write the index file
} else if (Object.keys(options).length > 0) {
index = await this.loadIndex();
"name" in options && (index.name = options.name);
"description" in options && (index.description = options.description);
"options" in options && (index.options = Object.assign(index.options, options.options));
"columns" in options &&
(index.columns = Object.assign(
index.columns,
Object.fromEntries(
options.columns.map((columnName) => [
columnName,
columnName in index.columns ? index.columns[columnName] : [],
])
)
));
}
// Ensure all columns have proper array representation
if (index && index.columns) {
for (const column in index.columns) {
// Make sure each column has at least an empty array
if (!Array.isArray(index.columns[column])) {
index.columns[column] = [];
}
}
}
await this.saveIndex(index);
}
/**
* Check if a task file exists and is in the index, otherwise throw an error
* @param {string} taskId The task id to check
*/
async taskExists(taskId) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
// Check if the task file exists
if (!(await fileUtils.exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
throw new Error(`No task file found with id "${taskId}"`);
}
// Check that the task is indexed
let index = await this.loadIndex();
if (!taskInIndex(index, taskId)) {
throw new Error(`No task with id "${taskId}" found in the index`);
}
}
/**
* Get the column that a task is in or throw an error if the task doesn't exist or isn't indexed
* @param {string} taskId The task id to find
* @return {Promise<string>} The name of the column the task is in
*/
async findTaskColumn(taskId) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
// Check if taskId is a string
if (typeof taskId !== 'string') {
throw new Error(`Invalid task id: expected string but got ${typeof taskId}`);
}
// Check if the task file exists
if (!(await fileUtils.exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
throw new Error(`No task file found with id "${taskId}"`);
}
// Check that the task is indexed
let index = await this.loadIndex();
if (!taskInIndex(index, taskId)) {
throw new Error(`No task with id "${taskId}" found in the index`);
}
// Find which column the task is in
return findTaskColumn(index, taskId);
}
/**
* Create a task file and add the task to the index
* @param {object} taskData The task object
* @param {string} columnName The name of the column to add the task to
* @return {Promise<string>} The id of the task that was created
*/
async createTask(taskData, columnName) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
// Make sure the task has a name
if (!taskData.name) {
throw new Error("Task name cannot be blank");
}
// Make sure a task doesn't already exist with the same name
const taskId = utility.getTaskId(taskData.name);
const taskPath = getTaskPath(await this.getTaskFolderPath(), taskId);
if (await fileUtils.exists(taskPath)) {
throw new Error(`A task with id "${taskId}" already exists`);
}
// Get index and make sure the column exists
let index = await this.loadIndex();
if (!(columnName in index.columns)) {
throw new Error(`Column "${columnName}" doesn't exist`);
}
// Check that a task with the same id isn't already indexed
if (taskInIndex(index, taskId)) {
throw new Error(`A task with id "${taskId}" is already in the index`);
}
// Set the created date
taskData = setTaskMetadata(taskData, "created", new Date());
// Update task metadata dates
taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
await this.saveTask(taskPath, taskData);
// Add the task to the index
index = addTaskToIndex(index, taskId, columnName);
await this.saveIndex(index);
return taskId;
}
/**
* Add an untracked task to the specified column in the index
* @param {string} taskId The untracked task id
* @param {string} columnName The column to add the task to
* @return {Promise<string>} The id of the task that was added
*/
async addUntrackedTaskToIndex(taskId, columnName) {
const index = await this.loadIndex();
return indexUtils.addUntrackedTaskToIndex(
index,
taskId,
columnName,
this.initialised.bind(this),
this.getTaskFolderPath.bind(this),
this.loadTask.bind(this),
this.saveTask.bind(this),
this.saveIndex.bind(this)
);
}
/**
* Get a list of tracked tasks (i.e. tasks that are listed in the index)
* @param {?string} [columnName=null] The optional column name to filter tasks by
* @return {Promise<Set>} A set of task ids
*/
async findTrackedTasks(columnName = null) {
const index = await this.loadIndex();
return indexUtils.findTrackedTasks(
index,
this.initialised.bind(this),
columnName
);
}
/**
* Get a list of untracked tasks (i.e. markdown files in the tasks folder that aren't listed in the index)
* @return {Promise<Set>} A set of untracked task ids
*/
async findUntrackedTasks() {
const index = await this.loadIndex();
return indexUtils.findUntrackedTasks(
index,
this.initialised.bind(this),
this.getTaskFolderPath.bind(this)
);
}
/**
* Update an existing task
* @param {string} taskId The id of the task to update
* @param {object} taskData The new task data
* @param {?string} [columnName=null] The column name to move this task to, or null if not moving this task
* @return {Promise<string>} The id of the task that was updated
*/
async updateTask(taskId, taskData, columnName = null) {
const index = await this.loadIndex();
return indexUtils.updateTask(
index,
taskId,
taskData,
columnName,
this.initialised.bind(this),
this.getTaskFolderPath.bind(this),
this.loadTask.bind(this),
this.saveTask.bind(this),
this.renameTask.bind(this),
this.moveTask.bind(this),
this.saveIndex.bind(this)
);
}
/**
* Change a task name, rename the task file and update the task id in the index
* @param {string} taskId The id of the task to rename
* @param {string} newTaskName The new task name
* @return {Promise<string>} The new id of the task that was renamed
*/
async renameTask(taskId, newTaskName) {
const index = await this.loadIndex();
return indexUtils.renameTask(
index,
taskId,
newTaskName,
this.initialised.bind(this),
this.getTaskFolderPath.bind(this),
this.loadTask.bind(this),
this.saveTask.bind(this),
this.saveIndex.bind(this)
);
}
/**
* Move a task from one column to another column
* @param {string} taskId The task id to move
* @param {string} columnName The name of the column that the task will be moved to
* @param {?number} [position=null] The position to move the task to within the target column
* @param {boolean} [relative=false] Treat the position argument as relative instead of absolute
* @return {Promise<string>} The id of the task that was moved
*/
async moveTask(taskId, columnName, position = null, relative = false) {
const index = await this.loadIndex();
return indexUtils.moveTask(
index,
taskId,
columnName,
position,
relative,
this.initialised.bind(this),
this.getTaskFolderPath.bind(this),
this.loadTask.bind(this),
this.saveTask.bind(this),
this.saveIndex.bind(this)
);
}
/**
* Remove a task from the index and optionally delete the task file as well
* @param {string} taskId The id of the task to remove
* @param {boolean} [removeFile=false] True if the task file should be removed
* @return {Promise<string>} The id of the task that was deleted
*/
async deleteTask(taskId, removeFile = false) {
const index = await this.loadIndex();
return indexUtils.deleteTask(
index,
taskId,
removeFile,
this.initialised.bind(this),
this.getTaskFolderPath.bind(this),
this.saveIndex.bind(this)
);
}
/**
* Search for indexed tasks
* @param {object} [filters={}] The filters to apply
* @param {boolean} [quiet=false] Only return task ids if true, otherwise return full task details
* @return {Promise<object[]>} A list of tasks that match the filters
*/
async search(filters = {}, quiet = false) {
const index = await this.loadIndex();
return indexUtils.search(
index,
filters,
quiet,
this.initialised.bind(this),
this.loadAllTrackedTasks.bind(this),
this.hydrateTask.bind(this)
);
}
/**
* Output project status information
* @param {boolean} [quiet=false] Output full or partial status information
* @param {boolean} [untracked=false] Show a list of untracked tasks
* @param {boolean} [due=false] Show information about overdue tasks and time remaining
* @param {?string|?number} [sprint=null] The sprint name or number to show stats for, or null for current sprint
* @param {?Date[]} [dates=null] The date(s) to show stats for, or null for no date filter
* @return {Promise<object|string[]>} Project status information as an object, or an array of untracked task filenames
*/
async status(quiet = false, untracked = false, due = false, sprint = null, dates = null) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
// Get index and column names
const index = await this.loadIndex();
const columnNames = Object.keys(index.columns);
// Prepare output
const result = {
name: index.name,
};
// Get un-tracked tasks if required
if (untracked) {
result.untrackedTasks = [...(await this.findUntrackedTasks())].map((taskId) => `${taskId}.md`);
// If output is quiet, output a list of untracked task filenames
if (quiet) {
return result.untrackedTasks;
}
}
// Get basic project status information
result.tasks = columnNames.reduce((a, v) => a + index.columns[v].length, 0);
result.columnTasks = Object.fromEntries(
columnNames.map((columnName) => [columnName, index.columns[columnName].length])
);
if ("startedColumns" in index.options && index.options.startedColumns.length > 0) {
result.startedTasks = Object.entries(index.columns)
.filter((c) => index.options.startedColumns.indexOf(c[0]) > -1)
.reduce((a, c) => a + c[1].length, 0);
}
if ("completedColumns" in index.options && index.options.completedColumns.length > 0) {
result.completedTasks = Object.entries(index.columns)
.filter((c) => index.options.completedColumns.indexOf(c[0]) > -1)
.reduce((a, c) => a + c[1].length, 0);
}
// If required, load more detailed task information
if (!quiet) {
// Load all tracked tasks and hydrate them
const tasks = [...(await this.loadAllTrackedTasks(index))].map((task) => this.hydrateTask(index, task));
// If showing due information, calculate time remaining or overdue time for each task
if (due) {
result.dueTasks = statusUtils.calculateDueTasks(tasks);
}
// Calculate total and per-column workload
const workloadStats = statusUtils.calculateColumnWorkloads(tasks, columnNames);
result.totalWorkload = workloadStats.totalWorkload;
result.totalRemainingWorkload = workloadStats.totalRemainingWorkload;
result.columnWorkloads = workloadStats.columnWorkloads;
result.taskWorkloads = statusUtils.calculateTaskWorkloads(index, tasks);
// Calculate assigned task totals and workloads
const assignedTasks = statusUtils.calculateAssignedTaskStats(tasks);
if (Object.keys(assignedTasks).length > 0) {
result.assigned = assignedTasks;
}
// Calculate AI interaction metrics
const aiMetrics = statusUtils.calculateAIMetrics(tasks);
if (aiMetrics) {
result.aiMetrics = aiMetrics;
}
// Calculate parent-child relationship metrics
const relationMetrics = statusUtils.calculateRelationMetrics(tasks);
if (relationMetrics) {
result.relationMetrics = relationMetrics;
}
// Calculate sprint statistics
const sprintStats = statusUtils.calculateSprintStats(index, tasks, sprint);
if (sprintStats) {
result.sprint = sprintStats;
}
// Calculate period statistics for specified dates
const periodStats = statusUtils.calculatePeriodStats(index, tasks, dates);
if (periodStats) {
result.period = periodStats;
}
}
return result;
}
/**
* Validate the index and task files
* @param {boolean} [save=false] Re-save all files
* @return {Promise<Array>} Empty array if everything validated, otherwise an array of parsing errors
*/
async validate(save = false) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
const errors = [];
// Load & parse index
let index = null;
try {
index = await this.loadIndex();
// Re-save index if required
if (save) {
await this.saveIndex(index);
}
} catch (error) {
// Add the index error to the errors array
errors.push({
task: null,
errors: error.message.includes('Unable to parse index')
? error.message
: `Unable to parse index: ${error.message}`
});
// Exit early if any errors were found in the index
return errors;
}
// Load & parse tasks
const trackedTasks = getTrackedTaskIds(index);
for (let taskId of trackedTasks) {
try {
const task = await this.loadTask(taskId);
// Re-save tasks if required
if (save) {
await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), task);
}
} catch (error) {
errors.push({
task: taskId,
errors: error.message,
});
}
}
// Return a list of errors or empty array if there were no errors
return errors;
}
/**
* Sort a column in the index
* @param {string} columnName The column name to sort
* @param {object[]} sorters A list of objects containing the field to sort by, filters and sort order
* @param {boolean} [save=false] True if the settings should be saved in index
*/
async sort(columnName, sorters, save = false) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
// Get index and make sure the column exists
let index = await this.loadIndex();
if (!(columnName in index.columns)) {
throw new Error(`Column "${columnName}" doesn't exist`);
}
// Save the sorter settings if required (the column will be sorted when saving the index)
if (save) {
if (!("columnSorting" in index.options)) {
index.options.columnSorting = {};
}
index.options.columnSorting[columnName] = sorters;
// Otherwise, remove sorting settings for the specified column and manually sort the column
} else {
if ("columnSorting" in index.options && columnName in index.options.columnSorting) {
delete index.options.columnSorting[columnName];
}
const tasks = await this.loadAllTrackedTasks(index, columnName);
index = sortColumnInIndex(index, tasks, columnName, sorters);
}
await this.saveIndex(index);
}
/**
* Start a sprint
* @param {string} name Sprint name
* @param {string} description Sprint description
* @param {Date} start Sprint start date
* @return {Promise<object>} The sprint object
*/
async sprint(name, description, start) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
// Get index and make sure it has a list of sprints in the options
const index = await this.loadIndex();
if (!("sprints" in index.options)) {
index.options.sprints = [];
}
const sprintNumber = index.options.sprints.length + 1;
const sprint = {
start: start,
};
// If the name is blank, generate a default name
if (!name) {
sprint.name = `Sprint ${sprintNumber}`;
} else {
sprint.name = name;
}
// Add description if one exists
if (description) {
sprint.description = description;
}
// Add sprint and save the index
index.options.sprints.push(sprint);
await this.saveIndex(index);
return sprint;
}
/**
* Output burndown chart data
* @param {?string[]} [sprints=null] The sprint names or numbers to show a chart for, or null for
* the current sprint
* @param {?Date[]} [dates=null] The dates to show a chart for, or null for no date filter
* @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
* @param {?string[]} [columns=null] The columns to filter for, or null for no column filter
* @param {?string} [normalise=null] The date normalisation mode
* @return {Promise<object>} Burndown chart data as an object
*/
async burndown(sprints = null, dates = null, assigned = null, columns = null, normalise = null) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
// Get index and tasks
const index = await this.loadIndex();
const tasks = [...(await this.loadAllTrackedTasks(index))]
.map((task) => {
const created = "created" in task.metadata ? task.metadata.created : new Date(0);
return {
...task,
created,
started:
"started" in task.metadata
? task.metadata.started
: "startedColumns" in index.options && index.options.startedColumns.indexOf(task.column) !== -1
? created
: false,
completed:
"completed" in task.metadata
? task.metadata.completed
: "completedColumns" in index.options && index.options.completedColumns.indexOf(task.column) !== -1
? created
: false,
progress: taskProgress(index, task),
assigned: "assigned" in task.metadata ? task.metadata.assigned : null,
workload: taskWorkload(index, task),
column: findTaskColumn(index, task.id),
};
})
.filter(
(task) =>
(assigned === null || task.assigned === assigned) &&
(columns === null || columns.indexOf(task.column) !== -1)
);
// Get sprints and dates to plot from arguments
const series = [];
const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
if (sprints === null && dates === null) {
if (indexSprints !== null) {
// Show current sprint
const currentSprint = indexSprints.length - 1;
series.push({
sprint: indexSprints[currentSprint],
from: new Date(indexSprints[currentSprint].start),
to: new Date(),
});
} else {
// Show all time
series.push({
from: new Date(
Math.min(
...tasks
.map((t) =>
[
"created" in t.metadata && t.metadata.created,
"started" in t.metadata && t.metadata.started,
"completed" in t.metadata && (t.metadata.completed || new Date(8640000000000000))
].filter((d) => d)
)
.flat()
)
),
to: new Date(),
});
}
} else {
// Show specified sprint
if (sprints !== null) {
if (indexSprints === null) {
throw new Error(`No sprints defined`);
} else {
for (const sprint of sprints) {
let sprintIndex = null;
// Select sprint by number (1-based index)
if (typeof sprint === "number") {
if (sprint < 1 || sprint > indexSprints.length) {
throw new Error(`Sprint ${sprint} does not exist`);
} else {
sprintIndex = sprint - 1;
}
// Or select sprint by name
} else if (typeof sprint === "string") {
sprintIndex = indexSprints.findIndex((s) => s.name === sprint);
if (sprintIndex === -1) {
throw new Error(`No sprint found with name "${sprint}"`);
}
}
if (sprintIndex === null) {
throw new Error(`Invalid sprint "${sprint}"`);
}
// Get sprint start and end
series.push({
sprint: indexSprints[sprintIndex],
from: new Date(indexSprints[sprintIndex].start),
to: sprintIndex < indexSprints.length - 1 ? new Date(indexSprints[sprintIndex + 1].start) : new Date(),
});
}
}
}
// Show specified date range
if (dates !== null) {
series.push({
from: new Date(Math.min(...dates)),
to: dates.length === 1 ? new Date() : new Date(Math.max(...dates)),
});
}
}
// If normalise mode is 'auto', find the most appropriate normalisation mode
if (normalise === 'auto') {
const delta = series[0].to - series[0].from;
if (delta >= DAY * 7) {
normalise = 'days';
} else if (delta >= DAY) {
normalise = 'hours';
} else if (delta >= HOUR ) {
normalise = 'minutes';
} else {
normalise = 'seconds';
}
}
if (normalise !== null) {
// Normalize series from and to dates
series.forEach((s) => {
s.from = normaliseDate(s.from, normalise);
s.to = normaliseDate(s.to, normalise);
});
// Normalise task dates
tasks.forEach((task) => {
if (task.created) {
task.created = normaliseDate(task.created, normalise);
}
if (task.started) {
task.started = normaliseDate(task.started, normalise);
}
if (task.completed) {
task.completed = normaliseDate(task.completed, normalise);
}
});
}
// Get workload datapoints for each period
series.forEach((s) => {
s.dataPoints = [
{
x: s.from,
y: getWorkloadAtDate(tasks, s.from),
count: countActiveTasksAtDate(tasks, s.from),
tasks: getTaskEventsAtDate(tasks, s.from),
},
...tasks
.filter((task) => {
let result = false;
if (task.created && task.created >= s.from && task.created <= s.to) {
result = true;
}
if (task.started && task.started >= s.from && task.started <= s.to) {
result = true;
}
if (task.completed && task.completed >= s.from && task.completed <= s.to) {
result = true;
}
return result;
})
.map((task) => [
task.created,
task.started,
task.completed
])
.flat()
.filter((d) => d)
.map((x) => ({
x,
y: getWorkloadAtDate(tasks, x),
count: countActiveTasksAtDate(tasks, x),
tasks: getTaskEventsAtDate(tasks, x),
})),
{
x: s.to,
y: getWorkloadAtDate(tasks, s.to),
count: countActiveTasksAtDate(tasks, s.to),
tasks: getTaskEventsAtDate(tasks, s.to),
},
].sort((a, b) => a.x.getTime() - b.x.getTime());
});
return { series };
}
/**
* Add a comment to a task
* @param {string} taskId The task id
* @param {string} text The comment text
* @param {string} author The comment author
* @return {Promise<string>} The task id
*/
async comment(taskId, text, author) {
// Check if this folder has been initialised
if (!(await this.initialised())) {
throw new Error("Not initialised in this folder");
}
taskId = removeFileExtension(taskId);
// Make sure the task file exists
if (!(await fileUtils.exists(getTaskPath(await this.getTaskFolderPath(), taskId)))) {
throw new Error(`No task file found with id "${taskId}"`);
}
// Get index and make sure the task is indexed
let index = await this.loadIndex();
if (!taskInIndex(index, taskId)) {
throw new Error(`Task "${taskId}" is not in the index`)