From d57d17e3c1c7b289d37a56fa5612667a9c654b0e Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Wed, 27 Aug 2025 22:41:58 +0200 Subject: [PATCH] chore: refactor file storage and fix getTasks by tag --- packages/tm-core/src/storage/file-storage.ts | 563 ------------------ .../storage/file-storage/file-operations.ts | 161 +++++ .../src/storage/file-storage/file-storage.ts | 378 ++++++++++++ .../storage/file-storage/format-handler.ts | 238 ++++++++ .../tm-core/src/storage/file-storage/index.ts | 10 + .../src/storage/file-storage/path-resolver.ts | 42 ++ packages/tm-core/src/storage/index.ts | 2 +- .../tm-core/src/storage/storage-factory.ts | 2 +- 8 files changed, 831 insertions(+), 565 deletions(-) delete mode 100644 packages/tm-core/src/storage/file-storage.ts create mode 100644 packages/tm-core/src/storage/file-storage/file-operations.ts create mode 100644 packages/tm-core/src/storage/file-storage/file-storage.ts create mode 100644 packages/tm-core/src/storage/file-storage/format-handler.ts create mode 100644 packages/tm-core/src/storage/file-storage/index.ts create mode 100644 packages/tm-core/src/storage/file-storage/path-resolver.ts diff --git a/packages/tm-core/src/storage/file-storage.ts b/packages/tm-core/src/storage/file-storage.ts deleted file mode 100644 index d715b410..00000000 --- a/packages/tm-core/src/storage/file-storage.ts +++ /dev/null @@ -1,563 +0,0 @@ -/** - * @fileoverview File-based storage implementation for Task Master - */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import type { Task, TaskMetadata } from '../types/index.js'; -import type { - IStorage, - StorageStats -} from '../interfaces/storage.interface.js'; - -/** - * File storage data structure - */ -interface FileStorageData { - tasks: Task[]; - metadata: TaskMetadata; -} - -/** - * File-based storage implementation using JSON files - */ -export class FileStorage implements IStorage { - private readonly basePath: string; - private readonly tasksDir: string; - private fileLocks: Map> = new Map(); - private config = { - autoBackup: false, - maxBackups: 5 - }; - - constructor(projectPath: string) { - this.basePath = path.join(projectPath, '.taskmaster'); - this.tasksDir = path.join(this.basePath, 'tasks'); - } - - /** - * Initialize storage by creating necessary directories - */ - async initialize(): Promise { - await this.ensureDirectoryExists(); - } - - /** - * Close storage and cleanup resources - */ - async close(): Promise { - // Wait for any pending file operations - const locks = Array.from(this.fileLocks.values()); - if (locks.length > 0) { - await Promise.all(locks); - } - this.fileLocks.clear(); - } - - /** - * Get statistics about the storage - */ - async getStats(): Promise { - const tags = await this.getAllTags(); - let totalTasks = 0; - let lastModified = ''; - - for (const tag of tags) { - const filePath = this.getTasksPath(tag); // getTasksPath handles 'master' correctly now - try { - const stats = await fs.stat(filePath); - const data = await this.readJsonFile(filePath); - if (data?.tasks) { - totalTasks += data.tasks.length; - } - if (stats.mtime.toISOString() > lastModified) { - lastModified = stats.mtime.toISOString(); - } - } catch { - // Ignore missing files - } - } - - return { - totalTasks, - totalTags: tags.length, - lastModified: lastModified || new Date().toISOString(), - storageSize: 0, // Could calculate actual file sizes if needed - tagStats: tags.map((tag) => ({ - tag, - taskCount: 0, // Would need to load each tag to get accurate count - lastModified: lastModified || new Date().toISOString() - })) - }; - } - - /** - * Load tasks from file - */ - async loadTasks(tag?: string): Promise { - const filePath = this.getTasksPath(tag); - const resolvedTag = tag || 'master'; - - try { - const rawData = await this.readJsonFile(filePath); - - // Handle legacy format where tasks are wrapped in a tag key - if (rawData && typeof rawData === 'object' && resolvedTag in rawData) { - const tagData = (rawData as any)[resolvedTag]; - return tagData?.tasks || []; - } - - // Handle standard format - return rawData?.tasks || []; - } catch (error: any) { - if (error.code === 'ENOENT') { - return []; // File doesn't exist, return empty array - } - throw new Error(`Failed to load tasks: ${error.message}`); - } - } - - /** - * Save tasks to file - */ - async saveTasks(tasks: Task[], tag?: string): Promise { - const filePath = this.getTasksPath(tag); - const resolvedTag = tag || 'master'; - - // Ensure directory exists - await this.ensureDirectoryExists(); - - // Normalize task IDs to strings (force string IDs everywhere) - const normalizedTasks = tasks.map((task) => ({ - ...task, - id: String(task.id), // Force ID to string - dependencies: task.dependencies?.map((dep) => String(dep)) || [], - subtasks: - task.subtasks?.map((subtask) => ({ - ...subtask, - id: String(subtask.id), - parentId: String(subtask.parentId) - })) || [] - })); - - // Check if we need to use legacy format - let dataToWrite: any; - - try { - const existingData = await this.readJsonFile(filePath); - // If existing file uses legacy format, maintain it - if ( - existingData && - typeof existingData === 'object' && - resolvedTag in existingData - ) { - dataToWrite = { - [resolvedTag]: { - tasks: normalizedTasks, - metadata: { - version: '1.0.0', - lastModified: new Date().toISOString(), - taskCount: normalizedTasks.length, - completedCount: normalizedTasks.filter((t) => t.status === 'done') - .length, - tags: [resolvedTag] - } - } - }; - } else { - // Use standard format for new files - dataToWrite = { - tasks: normalizedTasks, - metadata: { - version: '1.0.0', - lastModified: new Date().toISOString(), - taskCount: normalizedTasks.length, - completedCount: normalizedTasks.filter((t) => t.status === 'done') - .length, - tags: tag ? [tag] : [] - } - }; - } - } catch (error: any) { - // File doesn't exist, use standard format - dataToWrite = { - tasks: normalizedTasks, - metadata: { - version: '1.0.0', - lastModified: new Date().toISOString(), - taskCount: normalizedTasks.length, - completedCount: normalizedTasks.filter((t) => t.status === 'done') - .length, - tags: tag ? [tag] : [] - } - }; - } - - // Write with file locking - await this.writeJsonFile(filePath, dataToWrite); - } - - /** - * Check if tasks file exists - */ - async exists(tag?: string): Promise { - const filePath = this.getTasksPath(tag); - - try { - await fs.access(filePath, fs.constants.F_OK); - return true; - } catch { - return false; - } - } - - /** - * Get all available tags - */ - async getAllTags(): Promise { - try { - await this.ensureDirectoryExists(); - const files = await fs.readdir(this.tasksDir); - - const tags: string[] = []; - - for (const file of files) { - if (file.endsWith('.json')) { - if (file === 'tasks.json') { - tags.push('master'); // Changed from 'default' to 'master' - } else if (!file.includes('.backup.')) { - // Extract tag name from filename (remove .json extension) - tags.push(file.slice(0, -5)); - } - } - } - - return tags; - } catch (error: any) { - if (error.code === 'ENOENT') { - return []; - } - throw new Error(`Failed to get tags: ${error.message}`); - } - } - - /** - * Load metadata from file - */ - async loadMetadata(tag?: string): Promise { - const filePath = this.getTasksPath(tag); - const resolvedTag = tag || 'master'; - - try { - const rawData = await this.readJsonFile(filePath); - - // Handle legacy format where data is wrapped in a tag key - if (rawData && typeof rawData === 'object' && resolvedTag in rawData) { - const tagData = (rawData as any)[resolvedTag]; - // Generate metadata if not present in legacy format - if (!tagData?.metadata && tagData?.tasks) { - return { - version: '1.0.0', - lastModified: new Date().toISOString(), - taskCount: tagData.tasks.length, - completedCount: tagData.tasks.filter( - (t: any) => t.status === 'done' - ).length, - tags: [resolvedTag] - }; - } - return tagData?.metadata || null; - } - - // Handle standard format - return rawData?.metadata || null; - } catch (error: any) { - if (error.code === 'ENOENT') { - return null; - } - throw new Error(`Failed to load metadata: ${error.message}`); - } - } - - /** - * Save metadata (stored with tasks) - */ - async saveMetadata(metadata: TaskMetadata, tag?: string): Promise { - const tasks = await this.loadTasks(tag); - const filePath = this.getTasksPath(tag); - - const data: FileStorageData = { - tasks, - metadata - }; - - await this.writeJsonFile(filePath, data); - } - - /** - * Append tasks to existing storage - */ - async appendTasks(tasks: Task[], tag?: string): Promise { - const existingTasks = await this.loadTasks(tag); - const allTasks = [...existingTasks, ...tasks]; - await this.saveTasks(allTasks, tag); - } - - /** - * Update a specific task - */ - async updateTask( - taskId: string, - updates: Partial, - tag?: string - ): Promise { - const tasks = await this.loadTasks(tag); - const taskIndex = tasks.findIndex((t) => t.id === taskId.toString()); - - if (taskIndex === -1) { - throw new Error(`Task ${taskId} not found`); - } - - tasks[taskIndex] = { - ...tasks[taskIndex], - ...updates, - id: taskId.toString() - }; - await this.saveTasks(tasks, tag); - } - - /** - * Delete a task - */ - async deleteTask(taskId: string, tag?: string): Promise { - const tasks = await this.loadTasks(tag); - const filteredTasks = tasks.filter((t) => t.id !== taskId); - - if (filteredTasks.length === tasks.length) { - throw new Error(`Task ${taskId} not found`); - } - - await this.saveTasks(filteredTasks, tag); - } - - /** - * Delete a tag - */ - async deleteTag(tag: string): Promise { - const filePath = this.getTasksPath(tag); - try { - await fs.unlink(filePath); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw new Error(`Failed to delete tag ${tag}: ${error.message}`); - } - } - } - - /** - * Rename a tag - */ - async renameTag(oldTag: string, newTag: string): Promise { - const oldPath = this.getTasksPath(oldTag); - const newPath = this.getTasksPath(newTag); - - try { - await fs.rename(oldPath, newPath); - } catch (error: any) { - throw new Error( - `Failed to rename tag from ${oldTag} to ${newTag}: ${error.message}` - ); - } - } - - /** - * Copy a tag - */ - async copyTag(sourceTag: string, targetTag: string): Promise { - const tasks = await this.loadTasks(sourceTag); - const metadata = await this.loadMetadata(sourceTag); - - await this.saveTasks(tasks, targetTag); - if (metadata) { - await this.saveMetadata(metadata, targetTag); - } - } - - // ============================================================================ - // Private Helper Methods - // ============================================================================ - - /** - * Sanitize tag name for file system - */ - private sanitizeTag(tag: string): string { - // Replace special characters with underscores - return tag.replace(/[^a-zA-Z0-9-_]/g, '_'); - } - - /** - * Get the file path for tasks based on tag - */ - private getTasksPath(tag?: string): string { - // Handle 'master' as the default tag (maps to tasks.json) - if (!tag || tag === 'master') { - return path.join(this.tasksDir, 'tasks.json'); - } - const sanitizedTag = this.sanitizeTag(tag); - return path.join(this.tasksDir, `${sanitizedTag}.json`); - } - - /** - * Ensure the storage directory structure exists - */ - private async ensureDirectoryExists(): Promise { - try { - await fs.mkdir(this.tasksDir, { recursive: true }); - } catch (error: any) { - throw new Error(`Failed to create storage directory: ${error.message}`); - } - } - - /** - * Read and parse JSON file with error handling - */ - private async readJsonFile( - filePath: string - ): Promise { - try { - const content = await fs.readFile(filePath, 'utf-8'); - return JSON.parse(content); - } catch (error: any) { - if (error.code === 'ENOENT') { - throw error; // Re-throw ENOENT for caller to handle - } - if (error instanceof SyntaxError) { - throw new Error(`Invalid JSON in file ${filePath}: ${error.message}`); - } - throw new Error(`Failed to read file ${filePath}: ${error.message}`); - } - } - - /** - * Write JSON file with atomic operation using temp file - */ - private async writeJsonFile( - filePath: string, - data: FileStorageData | any - ): Promise { - // Use file locking to prevent concurrent writes - const lockKey = filePath; - const existingLock = this.fileLocks.get(lockKey); - - if (existingLock) { - await existingLock; - } - - const lockPromise = this.performWrite(filePath, data); - this.fileLocks.set(lockKey, lockPromise); - - try { - await lockPromise; - } finally { - this.fileLocks.delete(lockKey); - } - } - - /** - * Perform the actual write operation - */ - private async performWrite( - filePath: string, - data: FileStorageData | any - ): Promise { - const tempPath = `${filePath}.tmp`; - - try { - // Write to temp file first - const content = JSON.stringify(data, null, 2); - await fs.writeFile(tempPath, content, 'utf-8'); - - // Create backup if configured - if (this.config.autoBackup && (await this.exists())) { - await this.createBackup(filePath); - } - - // Atomic rename - await fs.rename(tempPath, filePath); - } catch (error: any) { - // Clean up temp file if it exists - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup errors - } - - throw new Error(`Failed to write file ${filePath}: ${error.message}`); - } - } - - /** - * Get backup file path - */ - private getBackupPath(filePath: string, timestamp: string): string { - const dir = path.dirname(filePath); - const base = path.basename(filePath, '.json'); - return path.join(dir, 'backups', `${base}-${timestamp}.json`); - } - - /** - * Create a backup of the file - */ - private async createBackup(filePath: string): Promise { - try { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = this.getBackupPath(filePath, timestamp); - - // Ensure backup directory exists - const backupDir = path.dirname(backupPath); - await fs.mkdir(backupDir, { recursive: true }); - - await fs.copyFile(filePath, backupPath); - - // Clean up old backups if needed - if (this.config.maxBackups) { - await this.cleanupOldBackups(filePath); - } - } catch { - // Backup failures are non-critical - } - } - - /** - * Remove old backup files beyond the max limit - */ - private async cleanupOldBackups(originalPath: string): Promise { - const dir = path.dirname(originalPath); - const basename = path.basename(originalPath, '.json'); - - try { - const files = await fs.readdir(dir); - const backupFiles = files - .filter( - (f) => f.startsWith(`${basename}.backup.`) && f.endsWith('.json') - ) - .sort() - .reverse(); - - // Remove backups beyond the limit - const toRemove = backupFiles.slice(this.config.maxBackups!); - for (const file of toRemove) { - try { - await fs.unlink(path.join(dir, file)); - } catch { - // Ignore individual file deletion errors - } - } - } catch { - // Cleanup failures are non-critical - } - } -} - -// Export as default for convenience -export default FileStorage; diff --git a/packages/tm-core/src/storage/file-storage/file-operations.ts b/packages/tm-core/src/storage/file-storage/file-operations.ts new file mode 100644 index 00000000..14f08085 --- /dev/null +++ b/packages/tm-core/src/storage/file-storage/file-operations.ts @@ -0,0 +1,161 @@ +/** + * @fileoverview File operations with atomic writes and locking + */ + +import { promises as fs } from 'node:fs'; +import type { FileStorageData } from './format-handler.js'; + +/** + * Handles atomic file operations with locking mechanism + */ +export class FileOperations { + private fileLocks: Map> = new Map(); + + /** + * Read and parse JSON file + */ + async readJson(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content); + } catch (error: any) { + if (error.code === 'ENOENT') { + throw error; // Re-throw ENOENT for caller to handle + } + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in file ${filePath}: ${error.message}`); + } + throw new Error(`Failed to read file ${filePath}: ${error.message}`); + } + } + + /** + * Write JSON file with atomic operation and locking + */ + async writeJson(filePath: string, data: FileStorageData | any): Promise { + // Use file locking to prevent concurrent writes + const lockKey = filePath; + const existingLock = this.fileLocks.get(lockKey); + + if (existingLock) { + await existingLock; + } + + const lockPromise = this.performAtomicWrite(filePath, data); + this.fileLocks.set(lockKey, lockPromise); + + try { + await lockPromise; + } finally { + this.fileLocks.delete(lockKey); + } + } + + /** + * Perform atomic write operation using temporary file + */ + private async performAtomicWrite(filePath: string, data: any): Promise { + const tempPath = `${filePath}.tmp`; + + try { + // Write to temp file first + const content = JSON.stringify(data, null, 2); + await fs.writeFile(tempPath, content, 'utf-8'); + + // Atomic rename + await fs.rename(tempPath, filePath); + } catch (error: any) { + // Clean up temp file if it exists + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + + throw new Error(`Failed to write file ${filePath}: ${error.message}`); + } + } + + /** + * Check if file exists + */ + async exists(filePath: string): Promise { + try { + await fs.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } + } + + /** + * Get file stats + */ + async getStats(filePath: string) { + return fs.stat(filePath); + } + + /** + * Read directory contents + */ + async readDir(dirPath: string): Promise { + return fs.readdir(dirPath); + } + + /** + * Create directory recursively + */ + async ensureDir(dirPath: string): Promise { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error: any) { + throw new Error(`Failed to create directory ${dirPath}: ${error.message}`); + } + } + + /** + * Delete file + */ + async deleteFile(filePath: string): Promise { + try { + await fs.unlink(filePath); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw new Error(`Failed to delete file ${filePath}: ${error.message}`); + } + } + } + + /** + * Rename/move file + */ + async moveFile(oldPath: string, newPath: string): Promise { + try { + await fs.rename(oldPath, newPath); + } catch (error: any) { + throw new Error(`Failed to move file from ${oldPath} to ${newPath}: ${error.message}`); + } + } + + /** + * Copy file + */ + async copyFile(srcPath: string, destPath: string): Promise { + try { + await fs.copyFile(srcPath, destPath); + } catch (error: any) { + throw new Error(`Failed to copy file from ${srcPath} to ${destPath}: ${error.message}`); + } + } + + /** + * Clean up all pending file operations + */ + async cleanup(): Promise { + const locks = Array.from(this.fileLocks.values()); + if (locks.length > 0) { + await Promise.all(locks); + } + this.fileLocks.clear(); + } +} \ No newline at end of file diff --git a/packages/tm-core/src/storage/file-storage/file-storage.ts b/packages/tm-core/src/storage/file-storage/file-storage.ts new file mode 100644 index 00000000..0d894da5 --- /dev/null +++ b/packages/tm-core/src/storage/file-storage/file-storage.ts @@ -0,0 +1,378 @@ +/** + * @fileoverview Refactored file-based storage implementation for Task Master + */ + +import type { Task, TaskMetadata } from '../../types/index.js'; +import type { + IStorage, + StorageStats +} from '../../interfaces/storage.interface.js'; +import { FormatHandler } from './format-handler.js'; +import { FileOperations } from './file-operations.js'; +import { PathResolver } from './path-resolver.js'; + +/** + * File-based storage implementation using a single tasks.json file with separated concerns + */ +export class FileStorage implements IStorage { + private formatHandler: FormatHandler; + private fileOps: FileOperations; + private pathResolver: PathResolver; + + constructor(projectPath: string) { + this.formatHandler = new FormatHandler(); + this.fileOps = new FileOperations(); + this.pathResolver = new PathResolver(projectPath); + } + + /** + * Initialize storage by creating necessary directories + */ + async initialize(): Promise { + await this.fileOps.ensureDir(this.pathResolver.getTasksDir()); + } + + /** + * Close storage and cleanup resources + */ + async close(): Promise { + await this.fileOps.cleanup(); + } + + /** + * Get statistics about the storage + */ + async getStats(): Promise { + const filePath = this.pathResolver.getTasksPath(); + + try { + const stats = await this.fileOps.getStats(filePath); + const data = await this.fileOps.readJson(filePath); + const tags = this.formatHandler.extractTags(data); + + let totalTasks = 0; + const tagStats = tags.map((tag) => { + const tasks = this.formatHandler.extractTasks(data, tag); + const taskCount = tasks.length; + totalTasks += taskCount; + + return { + tag, + taskCount, + lastModified: stats.mtime.toISOString() + }; + }); + + return { + totalTasks, + totalTags: tags.length, + lastModified: stats.mtime.toISOString(), + storageSize: 0, // Could calculate actual file sizes if needed + tagStats + }; + } catch (error: any) { + if (error.code === 'ENOENT') { + return { + totalTasks: 0, + totalTags: 0, + lastModified: new Date().toISOString(), + storageSize: 0, + tagStats: [] + }; + } + throw new Error(`Failed to get storage stats: ${error.message}`); + } + } + + /** + * Load tasks from the single tasks.json file for a specific tag + */ + async loadTasks(tag?: string): Promise { + const filePath = this.pathResolver.getTasksPath(); + const resolvedTag = tag || 'master'; + + try { + const rawData = await this.fileOps.readJson(filePath); + return this.formatHandler.extractTasks(rawData, resolvedTag); + } catch (error: any) { + if (error.code === 'ENOENT') { + return []; // File doesn't exist, return empty array + } + throw new Error(`Failed to load tasks: ${error.message}`); + } + } + + /** + * Save tasks for a specific tag in the single tasks.json file + */ + async saveTasks(tasks: Task[], tag?: string): Promise { + const filePath = this.pathResolver.getTasksPath(); + const resolvedTag = tag || 'master'; + + // Ensure directory exists + await this.fileOps.ensureDir(this.pathResolver.getTasksDir()); + + // Get existing data from the file + let existingData: any = {}; + try { + existingData = await this.fileOps.readJson(filePath); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw new Error(`Failed to read existing tasks: ${error.message}`); + } + // File doesn't exist, start with empty data + } + + // Create metadata for this tag + const metadata: TaskMetadata = { + version: '1.0.0', + lastModified: new Date().toISOString(), + taskCount: tasks.length, + completedCount: tasks.filter((t) => t.status === 'done').length, + tags: [resolvedTag] + }; + + // Normalize tasks + const normalizedTasks = this.normalizeTaskIds(tasks); + + // Update the specific tag in the existing data structure + if (this.formatHandler.detectFormat(existingData) === 'legacy' || Object.keys(existingData).some(key => key !== 'tasks' && key !== 'metadata')) { + // Legacy format - update/add the tag + existingData[resolvedTag] = { + tasks: normalizedTasks, + metadata + }; + } else if (resolvedTag === 'master') { + // Standard format for master tag + existingData = { + tasks: normalizedTasks, + metadata + }; + } else { + // Convert to legacy format when adding non-master tags + const masterTasks = existingData.tasks || []; + const masterMetadata = existingData.metadata || metadata; + + existingData = { + master: { + tasks: masterTasks, + metadata: masterMetadata + }, + [resolvedTag]: { + tasks: normalizedTasks, + metadata + } + }; + } + + // Write the updated file + await this.fileOps.writeJson(filePath, existingData); + } + + /** + * Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers + */ + private normalizeTaskIds(tasks: Task[]): Task[] { + return tasks.map((task) => ({ + ...task, + id: String(task.id), // Task IDs are strings + dependencies: task.dependencies?.map((dep) => String(dep)) || [], + subtasks: task.subtasks?.map((subtask) => ({ + ...subtask, + id: Number(subtask.id), // Subtask IDs are numbers + parentId: String(subtask.parentId) // Parent ID is string (Task ID) + })) || [] + })); + } + + /** + * Check if the tasks file exists + */ + async exists(_tag?: string): Promise { + const filePath = this.pathResolver.getTasksPath(); + return this.fileOps.exists(filePath); + } + + /** + * Get all available tags from the single tasks.json file + */ + async getAllTags(): Promise { + try { + const filePath = this.pathResolver.getTasksPath(); + const data = await this.fileOps.readJson(filePath); + return this.formatHandler.extractTags(data); + } catch (error: any) { + if (error.code === 'ENOENT') { + return []; // File doesn't exist + } + throw new Error(`Failed to get tags: ${error.message}`); + } + } + + /** + * Load metadata from the single tasks.json file for a specific tag + */ + async loadMetadata(tag?: string): Promise { + const filePath = this.pathResolver.getTasksPath(); + const resolvedTag = tag || 'master'; + + try { + const rawData = await this.fileOps.readJson(filePath); + return this.formatHandler.extractMetadata(rawData, resolvedTag); + } catch (error: any) { + if (error.code === 'ENOENT') { + return null; + } + throw new Error(`Failed to load metadata: ${error.message}`); + } + } + + /** + * Save metadata (stored with tasks) + */ + async saveMetadata(_metadata: TaskMetadata, tag?: string): Promise { + const tasks = await this.loadTasks(tag); + await this.saveTasks(tasks, tag); + } + + /** + * Append tasks to existing storage + */ + async appendTasks(tasks: Task[], tag?: string): Promise { + const existingTasks = await this.loadTasks(tag); + const allTasks = [...existingTasks, ...tasks]; + await this.saveTasks(allTasks, tag); + } + + /** + * Update a specific task + */ + async updateTask( + taskId: string, + updates: Partial, + tag?: string + ): Promise { + const tasks = await this.loadTasks(tag); + const taskIndex = tasks.findIndex((t) => t.id === taskId.toString()); + + if (taskIndex === -1) { + throw new Error(`Task ${taskId} not found`); + } + + tasks[taskIndex] = { + ...tasks[taskIndex], + ...updates, + id: taskId.toString() + }; + await this.saveTasks(tasks, tag); + } + + /** + * Delete a task + */ + async deleteTask(taskId: string, tag?: string): Promise { + const tasks = await this.loadTasks(tag); + const filteredTasks = tasks.filter((t) => t.id !== taskId); + + if (filteredTasks.length === tasks.length) { + throw new Error(`Task ${taskId} not found`); + } + + await this.saveTasks(filteredTasks, tag); + } + + /** + * Delete a tag from the single tasks.json file + */ + async deleteTag(tag: string): Promise { + const filePath = this.pathResolver.getTasksPath(); + + try { + const existingData = await this.fileOps.readJson(filePath); + + if (this.formatHandler.detectFormat(existingData) === 'legacy') { + // Legacy format - remove the tag key + if (tag in existingData) { + delete existingData[tag]; + await this.fileOps.writeJson(filePath, existingData); + } else { + throw new Error(`Tag ${tag} not found`); + } + } else if (tag === 'master') { + // Standard format - delete the entire file for master tag + await this.fileOps.deleteFile(filePath); + } else { + throw new Error(`Tag ${tag} not found in standard format`); + } + } catch (error: any) { + if (error.code === 'ENOENT') { + throw new Error(`Tag ${tag} not found - file doesn't exist`); + } + throw error; + } + } + + /** + * Rename a tag within the single tasks.json file + */ + async renameTag(oldTag: string, newTag: string): Promise { + const filePath = this.pathResolver.getTasksPath(); + + try { + const existingData = await this.fileOps.readJson(filePath); + + if (this.formatHandler.detectFormat(existingData) === 'legacy') { + // Legacy format - rename the tag key + if (oldTag in existingData) { + existingData[newTag] = existingData[oldTag]; + delete existingData[oldTag]; + + // Update metadata tags array + if (existingData[newTag].metadata) { + existingData[newTag].metadata.tags = [newTag]; + } + + await this.fileOps.writeJson(filePath, existingData); + } else { + throw new Error(`Tag ${oldTag} not found`); + } + } else if (oldTag === 'master') { + // Convert standard format to legacy when renaming master + const masterTasks = existingData.tasks || []; + const masterMetadata = existingData.metadata || {}; + + const newData = { + [newTag]: { + tasks: masterTasks, + metadata: { ...masterMetadata, tags: [newTag] } + } + }; + + await this.fileOps.writeJson(filePath, newData); + } else { + throw new Error(`Tag ${oldTag} not found in standard format`); + } + } catch (error: any) { + if (error.code === 'ENOENT') { + throw new Error(`Tag ${oldTag} not found - file doesn't exist`); + } + throw error; + } + } + + /** + * Copy a tag within the single tasks.json file + */ + async copyTag(sourceTag: string, targetTag: string): Promise { + const tasks = await this.loadTasks(sourceTag); + + if (tasks.length === 0) { + throw new Error(`Source tag ${sourceTag} not found or has no tasks`); + } + + await this.saveTasks(tasks, targetTag); + } +} + +// Export as default for convenience +export default FileStorage; \ No newline at end of file diff --git a/packages/tm-core/src/storage/file-storage/format-handler.ts b/packages/tm-core/src/storage/file-storage/format-handler.ts new file mode 100644 index 00000000..877b1680 --- /dev/null +++ b/packages/tm-core/src/storage/file-storage/format-handler.ts @@ -0,0 +1,238 @@ +/** + * @fileoverview Format handler for task storage files + */ + +import type { Task, TaskMetadata } from '../../types/index.js'; + +export interface FileStorageData { + tasks: Task[]; + metadata: TaskMetadata; +} + +export type FileFormat = 'legacy' | 'standard'; + +/** + * Handles format detection and conversion between legacy and standard task file formats + */ +export class FormatHandler { + /** + * Detect the format of the raw data + */ + detectFormat(data: any): FileFormat { + if (!data || typeof data !== 'object') { + return 'standard'; + } + + const keys = Object.keys(data); + + // Check if this uses the legacy format with tag keys + // Legacy format has keys that are not 'tasks' or 'metadata' + const hasLegacyFormat = keys.some(key => key !== 'tasks' && key !== 'metadata'); + + return hasLegacyFormat ? 'legacy' : 'standard'; + } + + /** + * Extract tasks from data for a specific tag + */ + extractTasks(data: any, tag: string): Task[] { + if (!data) { + return []; + } + + const format = this.detectFormat(data); + + if (format === 'legacy') { + return this.extractTasksFromLegacy(data, tag); + } + + return this.extractTasksFromStandard(data); + } + + /** + * Extract tasks from legacy format + */ + private extractTasksFromLegacy(data: any, tag: string): Task[] { + // First check if the requested tag exists + if (tag in data) { + const tagData = data[tag]; + return tagData?.tasks || []; + } + + // If we're looking for 'master' tag but it doesn't exist, try the first available tag + const availableKeys = Object.keys(data).filter(key => key !== 'tasks' && key !== 'metadata'); + if (tag === 'master' && availableKeys.length > 0) { + const firstTag = availableKeys[0]; + const tagData = data[firstTag]; + return tagData?.tasks || []; + } + + return []; + } + + /** + * Extract tasks from standard format + */ + private extractTasksFromStandard(data: any): Task[] { + return data?.tasks || []; + } + + /** + * Extract metadata from data for a specific tag + */ + extractMetadata(data: any, tag: string): TaskMetadata | null { + if (!data) { + return null; + } + + const format = this.detectFormat(data); + + if (format === 'legacy') { + return this.extractMetadataFromLegacy(data, tag); + } + + return this.extractMetadataFromStandard(data); + } + + /** + * Extract metadata from legacy format + */ + private extractMetadataFromLegacy(data: any, tag: string): TaskMetadata | null { + if (tag in data) { + const tagData = data[tag]; + // Generate metadata if not present in legacy format + if (!tagData?.metadata && tagData?.tasks) { + return this.generateMetadataFromTasks(tagData.tasks, tag); + } + return tagData?.metadata || null; + } + + // If we're looking for 'master' tag but it doesn't exist, try the first available tag + const availableKeys = Object.keys(data).filter(key => key !== 'tasks' && key !== 'metadata'); + if (tag === 'master' && availableKeys.length > 0) { + const firstTag = availableKeys[0]; + const tagData = data[firstTag]; + if (!tagData?.metadata && tagData?.tasks) { + return this.generateMetadataFromTasks(tagData.tasks, firstTag); + } + return tagData?.metadata || null; + } + + return null; + } + + /** + * Extract metadata from standard format + */ + private extractMetadataFromStandard(data: any): TaskMetadata | null { + return data?.metadata || null; + } + + /** + * Extract all available tags from the single tasks.json file + */ + extractTags(data: any): string[] { + if (!data) { + return []; + } + + const format = this.detectFormat(data); + + if (format === 'legacy') { + // Return all tag keys from legacy format + const keys = Object.keys(data); + return keys.filter(key => key !== 'tasks' && key !== 'metadata'); + } + + // Standard format - just has 'master' tag + return ['master']; + } + + /** + * Convert tasks and metadata to the appropriate format for saving + */ + convertToSaveFormat( + tasks: Task[], + metadata: TaskMetadata, + existingData: any, + tag: string + ): any { + const resolvedTag = tag || 'master'; + + // Normalize task IDs to strings + const normalizedTasks = this.normalizeTasks(tasks); + + // Check if existing file uses legacy format + if (existingData && this.detectFormat(existingData) === 'legacy') { + return this.convertToLegacyFormat(normalizedTasks, metadata, resolvedTag); + } + + // Use standard format for new files + return this.convertToStandardFormat(normalizedTasks, metadata, tag); + } + + /** + * Convert to legacy format + */ + private convertToLegacyFormat( + tasks: Task[], + metadata: TaskMetadata, + tag: string + ): any { + return { + [tag]: { + tasks, + metadata: { + ...metadata, + tags: [tag] + } + } + }; + } + + /** + * Convert to standard format + */ + private convertToStandardFormat( + tasks: Task[], + metadata: TaskMetadata, + tag?: string + ): FileStorageData { + return { + tasks, + metadata: { + ...metadata, + tags: tag ? [tag] : [] + } + }; + } + + /** + * Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers + */ + private normalizeTasks(tasks: Task[]): Task[] { + return tasks.map((task) => ({ + ...task, + id: String(task.id), // Task IDs are strings + dependencies: task.dependencies?.map((dep) => String(dep)) || [], + subtasks: task.subtasks?.map((subtask) => ({ + ...subtask, + id: Number(subtask.id), // Subtask IDs are numbers + parentId: String(subtask.parentId) // Parent ID is string (Task ID) + })) || [] + })); + } + + /** + * Generate metadata from tasks when not present + */ + private generateMetadataFromTasks(tasks: Task[], tag: string): TaskMetadata { + return { + version: '1.0.0', + lastModified: new Date().toISOString(), + taskCount: tasks.length, + completedCount: tasks.filter((t: any) => t.status === 'done').length, + tags: [tag] + }; + } +} \ No newline at end of file diff --git a/packages/tm-core/src/storage/file-storage/index.ts b/packages/tm-core/src/storage/file-storage/index.ts new file mode 100644 index 00000000..5c9cd87b --- /dev/null +++ b/packages/tm-core/src/storage/file-storage/index.ts @@ -0,0 +1,10 @@ +/** + * @fileoverview Exports for file storage components + */ + +export { FormatHandler, type FileStorageData, type FileFormat } from './format-handler.js'; +export { FileOperations } from './file-operations.js'; +export { PathResolver } from './path-resolver.js'; + +// Main FileStorage class - primary export +export { FileStorage as default, FileStorage } from './file-storage.js'; \ No newline at end of file diff --git a/packages/tm-core/src/storage/file-storage/path-resolver.ts b/packages/tm-core/src/storage/file-storage/path-resolver.ts new file mode 100644 index 00000000..bf3cc26a --- /dev/null +++ b/packages/tm-core/src/storage/file-storage/path-resolver.ts @@ -0,0 +1,42 @@ +/** + * @fileoverview Path resolution utilities for single tasks.json file + */ + +import path from 'node:path'; + +/** + * Handles path resolution for the single tasks.json file storage + */ +export class PathResolver { + private readonly basePath: string; + private readonly tasksDir: string; + private readonly tasksFilePath: string; + + constructor(projectPath: string) { + this.basePath = path.join(projectPath, '.taskmaster'); + this.tasksDir = path.join(this.basePath, 'tasks'); + this.tasksFilePath = path.join(this.tasksDir, 'tasks.json'); + } + + /** + * Get the base storage directory path + */ + getBasePath(): string { + return this.basePath; + } + + /** + * Get the tasks directory path + */ + getTasksDir(): string { + return this.tasksDir; + } + + /** + * Get the path to the single tasks.json file + * All tags are stored in this one file + */ + getTasksPath(): string { + return this.tasksFilePath; + } +} \ No newline at end of file diff --git a/packages/tm-core/src/storage/index.ts b/packages/tm-core/src/storage/index.ts index aa00dd1b..d7c0f36d 100644 --- a/packages/tm-core/src/storage/index.ts +++ b/packages/tm-core/src/storage/index.ts @@ -4,7 +4,7 @@ */ // Export storage implementations -export { FileStorage } from './file-storage.js'; +export { FileStorage } from './file-storage/index.js'; export { ApiStorage, type ApiStorageConfig } from './api-storage.js'; export { StorageFactory } from './storage-factory.js'; diff --git a/packages/tm-core/src/storage/storage-factory.ts b/packages/tm-core/src/storage/storage-factory.ts index e652ea36..5146ca39 100644 --- a/packages/tm-core/src/storage/storage-factory.ts +++ b/packages/tm-core/src/storage/storage-factory.ts @@ -4,7 +4,7 @@ import type { IStorage } from '../interfaces/storage.interface.js'; import type { IConfiguration } from '../interfaces/configuration.interface.js'; -import { FileStorage } from './file-storage.js'; +import { FileStorage } from './file-storage'; import { ApiStorage } from './api-storage.js'; import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';