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 { 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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user