UNPKG

@tosin2013/kanbn

Version:

A CLI Kanban board with AI-powered task management features

488 lines (451 loc) 14.5 kB
const yaml = require('yamljs'); const fm = require('front-matter'); const markdown = require('./lib/markdown'); const utility = require('./utility'); const chrono = require('chrono-node'); const validate = require('jsonschema').validate; const parseMarkdown = require('./parse-markdown'); /** * Compile separate headings together into a task description * @param {object} data */ function compileDescription(data) { const description = []; if ('raw' in data) { description.push(data.raw.content.replace(/[\r\n]{3}/g, '\n\n').trim()); } for (let heading in data) { if (['raw', 'Metadata', 'Sub-tasks', 'Relations', 'Comments'].indexOf(heading) !== -1) { continue; } description.push( /^# (.*)/.test(data[heading].heading) ? '' : data[heading].heading, data[heading].content ); } return description.join('\n\n').trim(); } /** * Validate the metadata object converted from Markdown * @param {object} metadata */ function validateMetadataFromMarkdown(metadata) { const result = validate(metadata, { type: 'object', properties: { 'created': { oneOf: [ { type: 'string' }, { type: 'date'} ] }, 'updated': { oneOf: [ { type: 'string' }, { type: 'date'} ] }, 'started': { oneOf: [ { type: 'string' }, { type: 'date'} ] }, 'completed': { oneOf: [ { type: 'string' }, { type: 'date'} ] }, 'due': { oneOf: [ { type: 'string' }, { type: 'date'} ] }, 'progress': { type: 'number' }, 'tags': { type: 'array', items: { type: 'string' } }, 'references': { type: 'array', items: { type: 'string' } } } }); if (result.errors.length) { throw new Error(result.errors.map(error => `\n${error.property} ${error.message}`).join('')); } } /** * Validate the metadata object converted from JSON * @param {object} metadata */ function validateMetadataFromJSON(metadata) { const result = validate(metadata, { type: 'object', properties: { 'created': { type: 'date'}, 'updated': { type: 'date'}, 'started': { type: 'date'}, 'completed': { type: 'date'}, 'due': { type: 'date'}, 'progress': { type: 'number' }, 'tags': { type: 'array', items: { type: 'string' } }, 'assigned': { type: 'string' }, 'references': { type: 'array', items: { type: 'string' } } } }); if (result.errors.length) { throw new Error(result.errors.map(error => `\n${error.property} ${error.message}`).join('')); } } /** * Validate the sub-tasks object * @param {object} subTasks */ function validateSubTasks(subTasks) { const result = validate(subTasks, { type: 'array', items: { type: 'object', properties: { 'text': { type: 'string' }, 'completed': { type: 'boolean' } } } }); if (result.errors.length) { throw new Error(result.errors.map(error => `\n${error.property} ${error.message}`).join('')); } } /** * Validate the relations object * @param {object} relations */ function validateRelations(relations) { const result = validate(relations, { type: 'array', items: { type: 'object', properties: { 'type': { type: 'string' }, 'task': { type: 'string' } } } }); if (result.errors.length) { throw new Error(result.errors.map(error => `\n${error.property} ${error.message}`).join('')); } } /** * Validate the comments object * @param {object} comments */ function validateComments(comments) { const result = validate(comments, { type: 'array', items: { type: 'object', properties: { 'author': { type: 'string' }, 'date': { type: 'date' }, 'text': { type: 'string' } } } }); if (result.errors.length) { throw new Error(result.errors.map(error => `\n${error.property} ${error.message}`).join('')); } } module.exports = { /** * Convert markdown into a task object * @param {string} data * @return {object} */ md2json(data) { let id = '', name = '', description = '', metadata = {}, subTasks = [], relations = [], comments = []; try { // Check data type if (!data) { throw new Error('data is null or empty'); } if (typeof data !== 'string') { throw new Error('data is not a string'); } // Get YAML front matter if any exists if (fm.test(data)) { ({ attributes: metadata, body: data } = fm(data)); // Make sure the front matter contains an object if (typeof metadata !== 'object') { throw new Error('invalid front matter content'); } } // Parse markdown to an object let task = null; try { task = parseMarkdown(data); } catch (error) { throw new Error(`invalid markdown (${error.message})`); } // Check resulting object const taskHeadings = Object.keys(task); if (taskHeadings.length === 0 || taskHeadings[0] === 'raw') { throw new Error('data is missing a name heading'); } // Get name name = taskHeadings[0]; // Get id from name id = utility.getTaskId(name); // Parse metadata // Metadata will be serialized back to front-matter, this check remains here for backwards compatibility if ('Metadata' in task) { // Get embedded metadata and make sure it's an object const embeddedMetadata = yaml.parse(task['Metadata'].content.trim().replace(/```(yaml|yml)?/g, '')); if (typeof embeddedMetadata !== 'object') { throw new Error('invalid metadata content'); } // Merge with front matter metadata metadata = Object.assign(metadata, embeddedMetadata); delete task['Metadata']; } validateMetadataFromMarkdown(metadata); // Check created/updated/completed/due dates if ('created' in metadata && !(metadata.created instanceof Date)) { const dateValue = chrono.parseDate(metadata.created); if (dateValue === null) { throw new Error('unable to parse created date'); } metadata.created = dateValue; } if ('updated' in metadata && !(metadata.updated instanceof Date)) { const dateValue = chrono.parseDate(metadata.updated); if (dateValue === null) { throw new Error('unable to parse updated date'); } metadata.updated = dateValue; } if ('started' in metadata && !(metadata.started instanceof Date)) { const dateValue = chrono.parseDate(metadata.started); if (dateValue === null) { throw new Error('unable to parse started date'); } metadata.started = dateValue; } if ('completed' in metadata && !(metadata.completed instanceof Date)) { const dateValue = chrono.parseDate(metadata.completed); if (dateValue === null) { throw new Error('unable to parse completed date'); } metadata.completed = dateValue; } if ('due' in metadata && !(metadata.due instanceof Date)) { const dateValue = chrono.parseDate(metadata.due); if (dateValue === null) { throw new Error('unable to parse due date'); } metadata.due = dateValue; } // Check progress value if ('progress' in metadata) { const numberValue = parseFloat(metadata.progress); if (isNaN(numberValue)) { throw new Error('progress value is not numeric'); } metadata.progress = numberValue; } // Parse sub-tasks if ('Sub-tasks' in task) { try { subTasks = markdown.lexer(task['Sub-tasks'].content)[0].items.map(item => ({ text: item.text.trim(), completed: item.checked || false })); } catch (error) { throw new Error('sub-tasks must contain a list'); } delete task['Sub-tasks']; } // Parse relations if ('Relations' in task) { try { relations = markdown.lexer(task['Relations'].content)[0].items.map(item => { const parts = item.tokens[0].tokens[0].text.split(' '); return parts.length === 1 ? { task: parts[0].trim(), type: '' } : { task: parts.pop().trim(), type: parts.join(' ').trim() }; }); } catch (error) { throw new Error('relations must contain a list'); } delete task['Relations']; } // Parse references if ('References' in task) { try { const referenceItems = markdown.lexer(task['References'].content)[0].items; if (!('references' in metadata)) { metadata.references = []; } for (let referenceItem of referenceItems) { metadata.references.push(referenceItem.text.trim()); } } catch (error) { throw new Error('references must contain a list'); } delete task['References']; } // Parse comments if ('Comments' in task) { try { // const commentsHeading = '## Comments'; // const start = data.indexOf(commentsHeading) + commentsHeading.length; // let end = data.substring(start).search(/\n#/); // if (end >= 0) { // end += start; // } else { // end = data.length; // } // const parsedComments = markdown.lexer(data.slice(start, end).trim())[0].items; const parsedComments = markdown.lexer(task['Comments'].content)[0].items; for (let parsedComment of parsedComments) { const comment = { text: [] }; const parts = parsedComment.text.split('\n'); for (let part of parts) { if (part.startsWith('date: ')) { const dateValue = chrono.parseDate(part.substring('date: '.length)); if (dateValue === null) { throw new Error('unable to parse comment date'); } comment.date = dateValue; } else if (part.startsWith('author: ')) { comment.author = part.substring('author: '.length); } else { comment.text.push(part); } } comment.text = comment.text.join('\n').trim(); comments.push(comment); } } catch (error) { throw new Error('comments must contain a list'); } delete task['Comments']; } // Assemble description // const descriptionParts = []; description = compileDescription(task); // description = descriptionParts.join('\n\n'); } catch (error) { throw new Error(`Unable to parse task: ${error.message}`); } // Assemble task object return { id, name, description, metadata, subTasks, relations, comments }; }, /** * Convert a task object into markdown * @param {object} data * @return {string} */ json2md(data) { const result = []; try { // Check data type if (!data) { throw new Error('data is null or empty'); } if (typeof data !== 'object') { throw new Error('data is not an object'); } // Check required fields if (!('name' in data)) { throw new Error('data object is missing name'); } // Add metadata as front-matter content if present if ('metadata' in data && data.metadata !== null) { validateMetadataFromJSON(data.metadata); if (Object.keys(data.metadata).length > 0) { result.push( `---\n${yaml.stringify(data.metadata, 4, 2).trim()}\n---` ); } } // Add name and description result.push(`# ${data.name}`); if ('description' in data) { result.push(data.description); } // Add sub-tasks if present if ('subTasks' in data && data.subTasks !== null) { validateSubTasks(data.subTasks); if (data.subTasks.length > 0) { result.push( '## Sub-tasks', data.subTasks.map(subTask => `- [${subTask.completed ? 'x' : ' '}] ${subTask.text}`).join('\n') ); } } // Add relations if present if ('relations' in data && data.relations !== null) { validateRelations(data.relations); if (data.relations.length > 0) { result.push( '## Relations', data.relations.map( relation => `- [${relation.type ? `${relation.type} ` : ''}${relation.task}](${relation.task}.md)` ).join('\n') ); } } // Add references if present if ('metadata' in data && data.metadata !== null && 'references' in data.metadata && data.metadata.references !== null) { if (data.metadata.references.length > 0) { result.push( '## References', data.metadata.references.map(reference => `- ${reference}`).join('\n') ); } } // Add comments if present if ('comments' in data && data.comments !== null) { validateComments(data.comments); if (data.comments.length > 0) { result.push( '## Comments', data.comments.map(comment => { const commentOutput = []; if ('author' in comment && comment.author) { commentOutput.push(`author: ${comment.author}`); } if ('date' in comment && comment.date) { commentOutput.push(`date: ${comment.date.toISOString()}`); } commentOutput.push(...comment.text.split('\n')); return `- ${commentOutput.map((v, i) => i > 0 ? ` ${v}` : v).join('\n')}`; }).join('\n') ); } } } catch (error) { throw new Error(`Unable to build task: ${error.message}`); } // Filter empty lines and join into a string return `${result.filter(l => !!l).join('\n\n')}\n`; } };