UNPKG

taskwerk

Version:

A task management CLI for developers and AI agents working together

220 lines (185 loc) 6.1 kB
import { getDatabase } from '../db/database.js'; /** * Calculate Levenshtein distance between two strings * @param {string} a - First string * @param {string} b - Second string * @returns {number} Distance between strings */ function levenshteinDistance(a, b) { const matrix = []; // If one string is empty, return length of the other if (a.length === 0) { return b.length; } if (b.length === 0) { return a.length; } // Initialize the matrix for (let i = 0; i <= b.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= a.length; j++) { matrix[0][j] = j; } // Fill the matrix for (let i = 1; i <= b.length; i++) { for (let j = 1; j <= a.length; j++) { if (b.charAt(i - 1) === a.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[b.length][a.length]; } /** * Find task IDs that are similar to the input * @param {string} input - User input task ID * @param {number} maxDistance - Maximum Levenshtein distance for suggestions * @param {Object} database - Optional database instance for testing * @returns {Array} Array of similar task IDs with their distances */ export function findSimilarTaskIds(input, maxDistance = 3, database = null) { const db = database || getDatabase(); // Handle both raw SQLite connections and TaskwerkDatabase instances let sqliteDb; if (db && typeof db.isConnected === 'function') { // It's a TaskwerkDatabase instance if (!db.isConnected()) { db.connect(); } sqliteDb = db.db; } else { // It's already a raw SQLite connection sqliteDb = db; } // Normalize input to uppercase for comparison const normalizedInput = input.toUpperCase(); // Get all task IDs const stmt = sqliteDb.prepare('SELECT id FROM tasks ORDER BY id'); const tasks = stmt.all(); const suggestions = []; for (const task of tasks) { const taskIdUpper = task.id.toUpperCase(); // Check for exact match (case-insensitive) if (taskIdUpper === normalizedInput) { return [{ id: task.id, distance: 0 }]; } // Check for partial matches if (taskIdUpper.includes(normalizedInput) || normalizedInput.includes(taskIdUpper)) { suggestions.push({ id: task.id, distance: 0.5, // Partial matches get priority }); continue; } // Calculate Levenshtein distance const distance = levenshteinDistance(normalizedInput, taskIdUpper); if (distance <= maxDistance) { suggestions.push({ id: task.id, distance: distance, }); } } // Sort by distance, then by ID suggestions.sort((a, b) => { if (a.distance !== b.distance) { return a.distance - b.distance; } return a.id.localeCompare(b.id); }); return suggestions; } /** * Try to match a fuzzy task ID input * @param {string} input - User input task ID * @param {Object} database - Optional database instance for testing * @returns {string|null} Matched task ID or null */ export function fuzzyMatchTaskId(input, database = null) { if (!input) { return null; } const db = database || getDatabase(); // Handle both raw SQLite connections and TaskwerkDatabase instances let sqliteDb; if (db && typeof db.isConnected === 'function') { // It's a TaskwerkDatabase instance if (!db.isConnected()) { db.connect(); } sqliteDb = db.db; } else { // It's already a raw SQLite connection sqliteDb = db; } // First try exact match (case-insensitive) const exactStmt = sqliteDb.prepare('SELECT id FROM tasks WHERE UPPER(id) = UPPER(?)'); const exactMatch = exactStmt.get(input); if (exactMatch) { return exactMatch.id; } // Try to handle common variations const normalizedInput = input.toUpperCase(); // Handle missing leading zeros (TASK-1 -> TASK-001) if (normalizedInput.match(/^[A-Z]+-\d+$/)) { const [prefix, number] = normalizedInput.split('-'); const paddedNumber = number.padStart(3, '0'); const paddedId = `${prefix}-${paddedNumber}`; const paddedMatch = exactStmt.get(paddedId); if (paddedMatch) { return paddedMatch.id; } } // Handle subtask variations (TASK-1.1 -> TASK-001.1) if (normalizedInput.match(/^[A-Z]+-\d+\.\d+$/)) { const [mainPart, subPart] = normalizedInput.split('.'); const [prefix, number] = mainPart.split('-'); const paddedNumber = number.padStart(3, '0'); const paddedId = `${prefix}-${paddedNumber}.${subPart}`; const paddedMatch = exactStmt.get(paddedId); if (paddedMatch) { return paddedMatch.id; } } // Try partial prefix match (just the number part) if (normalizedInput.match(/^\d+$/)) { const number = normalizedInput.padStart(3, '0'); // Prioritize TASK- prefix for number-only matches const taskStmt = sqliteDb.prepare('SELECT id FROM tasks WHERE id = ?'); const taskMatch = taskStmt.get(`TASK-${number}`); if (taskMatch) { return taskMatch.id; } // If no TASK- match, try any prefix const partialStmt = sqliteDb.prepare('SELECT id FROM tasks WHERE id LIKE ? ORDER BY id'); const partialMatch = partialStmt.get(`%-${number}`); if (partialMatch) { return partialMatch.id; } } return null; } /** * Format task not found error with suggestions * @param {string} taskId - The task ID that was not found * @param {Object} database - Optional database instance for testing * @returns {string} Formatted error message with suggestions */ export function formatTaskNotFoundError(taskId, database = null) { const suggestions = findSimilarTaskIds(taskId, 2, database); let message = `Task ${taskId} not found`; if (suggestions.length > 0) { message += '\n\nDid you mean:'; suggestions.slice(0, 3).forEach(suggestion => { message += `\n • ${suggestion.id}`; }); } return message; }