chore: refactor file storage and fix getTasks by tag
This commit is contained in:
@@ -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<string, Promise<void>> = 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<void> {
|
|
||||||
await this.ensureDirectoryExists();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close storage and cleanup resources
|
|
||||||
*/
|
|
||||||
async close(): Promise<void> {
|
|
||||||
// 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<StorageStats> {
|
|
||||||
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<Task[]> {
|
|
||||||
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<void> {
|
|
||||||
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<boolean> {
|
|
||||||
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<string[]> {
|
|
||||||
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<TaskMetadata | null> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<Task>,
|
|
||||||
tag?: string
|
|
||||||
): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<FileStorageData | null> {
|
|
||||||
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<void> {
|
|
||||||
// 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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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;
|
|
||||||
161
packages/tm-core/src/storage/file-storage/file-operations.ts
Normal file
161
packages/tm-core/src/storage/file-storage/file-operations.ts
Normal file
@@ -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<string, Promise<void>> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse JSON file
|
||||||
|
*/
|
||||||
|
async readJson(filePath: string): Promise<any> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string[]> {
|
||||||
|
return fs.readdir(dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create directory recursively
|
||||||
|
*/
|
||||||
|
async ensureDir(dirPath: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const locks = Array.from(this.fileLocks.values());
|
||||||
|
if (locks.length > 0) {
|
||||||
|
await Promise.all(locks);
|
||||||
|
}
|
||||||
|
this.fileLocks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
378
packages/tm-core/src/storage/file-storage/file-storage.ts
Normal file
378
packages/tm-core/src/storage/file-storage/file-storage.ts
Normal file
@@ -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<void> {
|
||||||
|
await this.fileOps.ensureDir(this.pathResolver.getTasksDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close storage and cleanup resources
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.fileOps.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about the storage
|
||||||
|
*/
|
||||||
|
async getStats(): Promise<StorageStats> {
|
||||||
|
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<Task[]> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
const filePath = this.pathResolver.getTasksPath();
|
||||||
|
return this.fileOps.exists(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available tags from the single tasks.json file
|
||||||
|
*/
|
||||||
|
async getAllTags(): Promise<string[]> {
|
||||||
|
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<TaskMetadata | null> {
|
||||||
|
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<void> {
|
||||||
|
const tasks = await this.loadTasks(tag);
|
||||||
|
await this.saveTasks(tasks, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append tasks to existing storage
|
||||||
|
*/
|
||||||
|
async appendTasks(tasks: Task[], tag?: string): Promise<void> {
|
||||||
|
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<Task>,
|
||||||
|
tag?: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
238
packages/tm-core/src/storage/file-storage/format-handler.ts
Normal file
238
packages/tm-core/src/storage/file-storage/format-handler.ts
Normal file
@@ -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]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/tm-core/src/storage/file-storage/index.ts
Normal file
10
packages/tm-core/src/storage/file-storage/index.ts
Normal file
@@ -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';
|
||||||
42
packages/tm-core/src/storage/file-storage/path-resolver.ts
Normal file
42
packages/tm-core/src/storage/file-storage/path-resolver.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Export storage implementations
|
// 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 { ApiStorage, type ApiStorageConfig } from './api-storage.js';
|
||||||
export { StorageFactory } from './storage-factory.js';
|
export { StorageFactory } from './storage-factory.js';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { IStorage } from '../interfaces/storage.interface.js';
|
import type { IStorage } from '../interfaces/storage.interface.js';
|
||||||
import type { IConfiguration } from '../interfaces/configuration.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 { ApiStorage } from './api-storage.js';
|
||||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user