UNPKG

task-master-sync

Version:

A bidirectional synchronization tool between TaskMaster AI and Monday.com with automatic item recreation

304 lines (257 loc) 10.2 kB
/** * TaskMaster IO Module * Handles reading from and writing to the tasks.json file */ const fs = require('fs-extra'); const path = require('path'); const os = require('os'); const { v4: uuidv4 } = require('uuid'); /** * Creates a new TaskMaster IO instance * @param {Object} options - Configuration options * @returns {Object} - TaskMaster IO module instance */ function createTaskMasterIO(options = {}) { // Default path to tasks.json file const DEFAULT_TASKS_PATH = 'tasks/tasks.json'; // Cache for tasks data to avoid frequent disk reads let tasksCache = new Map(); const CACHE_TTL = options.cacheTTL || 5000; // 5 seconds TTL for cache const CACHE_TTL_MS = CACHE_TTL * 1000; // Convert TTL to milliseconds /** * Gets the absolute path to the tasks.json file * @param {string} tasksPath - Optional custom path to tasks.json file * @returns {string} - Absolute path to tasks.json file */ function getTasksFilePath(tasksPath = DEFAULT_TASKS_PATH) { return path.isAbsolute(tasksPath) ? tasksPath : path.join(process.cwd(), tasksPath); } /** * Reads tasks from the tasks.json file * @param {string|Object} filePathOrTasks - Path to the tasks.json file or object containing tasks * @param {boolean} bypassCache - Whether to bypass the cache * @returns {Promise<Array|Object>} - Array of tasks or object with tasks property */ async function readTasks(filePathOrTasks, bypassCache = false) { // If filePathOrTasks is an object with a tasks property, return it directly if (filePathOrTasks && typeof filePathOrTasks === 'object' && 'tasks' in filePathOrTasks) { return filePathOrTasks.tasks; } // If no path provided, use default const filePath = filePathOrTasks || getTasksFilePath(); // Check cache first if not bypassing if (!bypassCache && tasksCache.has(filePath)) { const cachedData = tasksCache.get(filePath); // Check if cache is still valid if (Date.now() - cachedData.timestamp < CACHE_TTL_MS) { return cachedData.tasks; } // Cache expired, remove it tasksCache.delete(filePath); } try { // Read and parse the tasks file const data = await fs.readFile(filePath, 'utf8'); let parsedData; try { parsedData = JSON.parse(data); } catch (error) { throw new Error(`Error parsing tasks JSON: ${error.message}`); } // Check if the data has a tasks array property if (parsedData && typeof parsedData === 'object' && Array.isArray(parsedData.tasks)) { // Use the tasks array const tasks = parsedData.tasks; // Cache the tasks tasksCache.set(filePath, { tasks, timestamp: Date.now() }); return tasks; } else if (parsedData && Array.isArray(parsedData)) { // If it's already an array, use it directly const tasks = parsedData; // Cache the tasks tasksCache.set(filePath, { tasks, timestamp: Date.now() }); return tasks; } else { throw new Error('Tasks data is not an array'); } } catch (error) { // If the file doesn't exist, return an empty array if (error.code === 'ENOENT') { return []; } // Rethrow with a more descriptive message throw new Error(`Error reading tasks file: ${error.message}`); } } /** * Writes tasks to the tasks.json file * @param {string|Object} filePathOrTasks - Path to the tasks.json file or object with tasks property * @param {Array|undefined} tasksArray - Array of tasks to write (not needed if filePathOrTasks is an object with tasks) * @returns {Promise<void>} */ async function writeTasks(filePathOrTasks, tasksArray) { let filePath; let tasks; // Handle different parameter scenarios if (typeof filePathOrTasks === 'string') { // First param is a file path, second is the tasks array filePath = filePathOrTasks; tasks = tasksArray; } else if (filePathOrTasks && typeof filePathOrTasks === 'object') { // First param is an object with a tasks property filePath = getTasksFilePath(); if ('tasks' in filePathOrTasks && Array.isArray(filePathOrTasks.tasks)) { tasks = filePathOrTasks.tasks; } else if (Array.isArray(filePathOrTasks)) { tasks = filePathOrTasks; } else { throw new Error('Invalid tasks data: expected array or object with tasks array'); } } else { throw new Error('Invalid parameters: expected file path or tasks object'); } if (!Array.isArray(tasks)) { throw new Error('Tasks must be an array'); } // Create a temporary file to write to (for atomic writes) const tempFilePath = path.join(os.tmpdir(), `taskmaster-tasks-${uuidv4()}.json`); try { // Determine if we need to wrap tasks in an object let fileContent; // Check if the original file exists and has the wrapper object format try { const existingData = await fs.readFile(filePath, 'utf8'); const parsedData = JSON.parse(existingData); // If the original file has a tasks property, keep the same format if (parsedData && typeof parsedData === 'object' && 'tasks' in parsedData) { // Preserve the structure but update the tasks array fileContent = JSON.stringify({ ...parsedData, tasks }, null, 2); } else { // No wrapper object in the original file, write the array directly fileContent = JSON.stringify(tasks, null, 2); } // eslint-disable-next-line no-unused-vars } catch (error) { // If the file doesn't exist or has invalid JSON, default to array format fileContent = JSON.stringify(tasks, null, 2); } // Write to the temporary file await fs.writeFile(tempFilePath, fileContent, 'utf8'); // Create the directory if it doesn't exist const dir = path.dirname(filePath); await fs.ensureDir(dir); // Move the temporary file to the actual file (atomic operation) await fs.move(tempFilePath, filePath, { overwrite: true }); // Update cache tasksCache.set(filePath, { tasks, timestamp: Date.now() }); } catch (error) { // Clean up temporary file if it exists try { await fs.unlink(tempFilePath); // eslint-disable-next-line no-unused-vars } catch (unlinkError) { // Ignore errors from unlink } throw new Error(`Error writing tasks file: ${error.message}`); } } /** * Gets a specific task by ID * @param {string|number} taskId - ID of the task to get * @param {string} tasksPath - Optional custom path to tasks.json file * @returns {Promise<Object|null>} - Task object or null if not found */ async function getTaskById(taskId, tasksPath = DEFAULT_TASKS_PATH) { const tasks = await readTasks(getTasksFilePath(tasksPath)); // Convert taskId to string for comparison const id = String(taskId); return tasks.find(task => String(task.id) === id) || null; } /** * Updates a specific task by ID * @param {string|number} taskId - ID of the task to update * @param {Object} updates - Object with fields to update * @param {string} tasksPath - Optional custom path to tasks.json file * @returns {Promise<Object|null>} - Updated task or null if not found */ async function updateTaskById(taskId, updates, tasksPath = DEFAULT_TASKS_PATH) { const tasks = await readTasks(getTasksFilePath(tasksPath)); // Convert taskId to string for comparison const id = String(taskId); // Find task index const taskIndex = tasks.findIndex(task => String(task.id) === id); if (taskIndex === -1) { return null; } // Update task const updatedTask = { ...tasks[taskIndex], ...updates }; tasks[taskIndex] = updatedTask; // Write updated tasks back to file await writeTasks(getTasksFilePath(tasksPath), tasks); return updatedTask; } /** * Adds a Monday.com item ID to a task * @param {string|number} taskId - ID of the task to update * @param {string} mondayItemId - Monday.com item ID to add * @param {string} tasksPath - Optional custom path to tasks.json file * @returns {Promise<Object|null>} - Updated task or null if not found */ async function addMondayItemIdToTask(taskId, mondayItemId, tasksPath = DEFAULT_TASKS_PATH) { return updateTaskById(taskId, { monday_item_id: mondayItemId }, tasksPath); } /** * Gets tasks that have a Monday.com item ID * @param {string} tasksPath - Optional custom path to tasks.json file * @returns {Promise<Array>} - Array of tasks with Monday.com item IDs */ async function getTasksWithMondayItemId(tasksPath = DEFAULT_TASKS_PATH) { const tasks = await readTasks(getTasksFilePath(tasksPath)); return tasks.filter(task => task.monday_item_id); } /** * Gets tasks that don't have a Monday.com item ID * @param {string} tasksPath - Optional custom path to tasks.json file * @returns {Promise<Array>} - Array of tasks without Monday.com item IDs */ async function getTasksWithoutMondayItemId(tasksPath = DEFAULT_TASKS_PATH) { const tasks = await readTasks(getTasksFilePath(tasksPath)); return tasks.filter(task => !task.monday_item_id); } /** * Clears the cache, forcing next read to pull from disk */ function clearCache() { tasksCache.clear(); } // Return public API return { readTasks, writeTasks, getTaskById, updateTaskById, addMondayItemIdToTask, getTasksWithMondayItemId, getTasksWithoutMondayItemId, clearCache, DEFAULT_TASKS_PATH }; } // Create a default instance const defaultInstance = createTaskMasterIO(); // Export the default instance and the factory function module.exports = { ...defaultInstance, createTaskMasterIO };