UNPKG

@tosin2013/kanbn

Version:

A CLI Kanban board with AI-powered task management features

385 lines (358 loc) 11.6 kB
const yaml = require('yamljs'); const fm = require('front-matter'); const markdown = require('./lib/markdown'); const validate = require('jsonschema').validate; const parseMarkdown = require('./parse-markdown'); /** * Validate the options object * @param {object} options */ function validateOptions(options) { const result = validate(options, { type: 'object', properties: { 'hiddenColumns': { type: 'array', items: { type: 'string' } }, 'startedColumns': { type: 'array', items: { type: 'string' } }, 'completedColumns': { type: 'array', items: { type: 'string' } }, 'sprints': { type: 'array', items: { type: 'object', properties: { 'start': { type: 'date' }, 'name': { type: 'string' }, 'description': { type: 'string' } }, required: ['start', 'name'] } }, 'defaultTaskWorkload': { type: 'number' }, 'taskWorkloadTags': { type: 'object', patternProperties: { '^[\w ]+$': { type: 'number' } } }, 'columnSorting': { type: 'object', patternProperties: { '^[\w ]+$': { type: 'array', items: { type: 'object', properties: { 'field': { type: 'string' }, 'filter': { type: 'string' }, 'order': { type: 'string', enum: [ 'ascending', 'descending' ] } }, required: ['field'] } } } }, 'taskTemplate': { type: 'string' }, 'dateFormat': { type: 'string' }, 'customFields': { type: 'array', items: { type: 'object', properties: { 'name': { type: 'string' }, 'type': { type: 'string', enum: [ 'boolean', 'string', 'number', 'date' ] }, 'updateDate': { type: 'string', enum: [ 'always', 'once', 'none' ] } }, required: ['name', 'type'] } }, 'views': { type: 'array', items: { type: 'object', properties: { 'name': { type: 'string' }, 'filters': { type: 'object' }, 'columns': { type: 'array', items: { type: 'object', properties: { 'name': { type: 'string' }, 'filters': { type: 'object' }, 'sorters': { type: 'array', items: { type: 'object', properties: { 'field': { type: 'string' }, 'filter': { type: 'string' }, 'order': { type: 'string', enum: [ 'ascending', 'descending' ] } }, required: ['field'] } } }, required: ['name'] }, minItems: 1 }, 'lanes': { type: 'array', items: { type: 'object', properties: { 'name': { type: 'string' }, 'filters': { type: 'object' } }, required: ['name'] } } }, required: ['name'] } } } }); if (result.errors.length) { throw new Error(result.errors.map(error => `\n${error.property} ${error.message}`).join('')); } } /** * Validate the columns object * @param {object} columns */ function validateColumns(columns) { const result = validate(columns, { type: 'object', patternProperties: { '^[\w ]+$': { type: 'array', items: { type: 'string' } } } }); if (result.errors.length) { throw new Error(result.errors.map(error => `${error.property} ${error.message}`).join('\n')); } } module.exports = { /** * Convert markdown into an index object * @param {string} data * @return {object} */ md2json(data) { let name = '', description = '', options = {}, columns = {}; 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: options, body: data } = fm(data)); // Make sure the front matter contains an object if (typeof options !== 'object') { throw new Error('invalid front matter content'); } } // Parse markdown to an object let index = null; try { index = parseMarkdown(data); } catch (error) { throw new Error(`invalid markdown (${error.message})`); } // Check resulting object const indexHeadings = Object.keys(index); if (indexHeadings.length === 0 || indexHeadings[0] === 'raw') { throw new Error('data is missing a name heading'); } // Get name name = indexHeadings[0]; // Get description description = name in index ? index[name].content.trim() : ''; // Parse options // Options will be serialized back to front-matter, this check remains here for backwards-compatibility if ('Options' in index) { try { // Get embedded options and make sure it's an object const embeddedOptions = yaml.parse(index['Options'].content.trim().replace(/```(yaml|yml)?/g, '')); if (typeof embeddedOptions !== 'object') { throw new Error('invalid options content'); } // Merge with front matter options options = Object.assign(options, embeddedOptions); } catch (error) { throw new Error(`invalid options: ${error.message}`); } } try { validateOptions(options); } catch (error) { throw new Error(`invalid options: ${error.message}`); } // Parse columns const columnNames = Object.keys(index).filter(column => ['raw', 'Options', name].indexOf(column) === -1); if (columnNames.length) { try { columns = Object.fromEntries(columnNames.map(columnName => { try { // If the column content is empty or just whitespace, return an empty array if (!index[columnName].content || index[columnName].content.trim() === '') { return [columnName, []]; } // Parse the content with markdown const tokens = markdown.lexer(index[columnName].content); // Check if the first token is a list if (tokens.length > 0 && tokens[0].type === 'list') { try { // Safely extract text from tokens with proper error handling return [ columnName, tokens[0].items.map(item => { try { // Check if the token structure is as expected if (item && item.tokens && item.tokens[0] && item.tokens[0].tokens && item.tokens[0].tokens[0] && typeof item.tokens[0].tokens[0].text === 'string') { return item.tokens[0].tokens[0].text; } else if (item && item.text) { // Fallback to item.text if available return item.text; } else { // Return empty string as a last resort return ''; } } catch (err) { console.warn(`Warning: Error extracting text from list item: ${err.message}`); return ''; } }) ]; } catch (err) { console.warn(`Warning: Error processing list items: ${err.message}`); return [columnName, []]; } } else { // If the content exists but is not a list, return an empty array // This is more forgiving than throwing an error return [columnName, []]; } } catch (error) { // If there's an error parsing the column content, return an empty array // This is more forgiving than throwing an error console.warn(`Warning: column "${columnName}" could not be parsed: ${error.message}`); return [columnName, []]; } })); } catch (error) { throw new Error(`invalid columns: ${error.message}`); } } } catch (error) { throw new Error(`Unable to parse index: ${error.message}`); } // Assemble index object return { name, description, options, columns }; }, /** * Convert an index object into markdown * @param {object} data * @param {boolean} [ignoreOptions=false] * @return {string} */ json2md(data, ignoreOptions = false) { 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 options as front-matter content if present and not ignoring if ('options' in data && data.options !== null && !ignoreOptions) { validateOptions(data.options); if (Object.keys(data.options).length) { result.push( `---\n${yaml.stringify(data.options, 4, 2).trim()}\n---` ); } } // Add name and description result.push(`# ${data.name}`); if ('description' in data) { result.push(data.description); } // Check columns if (!('columns' in data)) { throw new Error('data object is missing columns'); } validateColumns(data.columns); // Add columns for (let column in data.columns) { result.push( `## ${column}`, data.columns[column].length > 0 ? data.columns[column].map(task => { // Ensure task is a simple string without markdown formatting const taskId = task.replace(/[\[\]\(\)]/g, '').split('/').pop().replace(/\.md$/, ''); return `- ${taskId}`; }).join('\n') : '' ); } } catch (error) { throw new Error(`Unable to build index: ${error.message}`); } // Filter empty lines and join into a string return `${result.filter(l => !!l).join('\n\n')}\n`; } };