← Назад
const fs = require('fs').promises; const path = require('path'); const { Mutex } = require('async-mutex'); require('dotenv').config(); class TaskManager { constructor(dataDir) { this.mutexes = new Map(); // Store mutex per project this.defaultDataDir = dataDir || path.join(__dirname, 'data'); } // Helper to ensure directory exists async ensureDir(dirPath) { try { await fs.mkdir(dirPath, { recursive: true }); } catch (err) { if (err.code !== 'EEXIST') throw err; } } // Get or create a mutex for a specific project getMutex(projectId) { if (!this.mutexes.has(projectId)) { this.mutexes.set(projectId, new Mutex()); } return this.mutexes.get(projectId); } // Get path for project task file getFilePath(projectId) { // Look up the specific folder path from the .env configuration const envKey = `PROJECT_PATH_${projectId.toUpperCase().replace(/-/g, '_')}`; const projectDir = process.env[envKey] || this.defaultDataDir; // fallback return path.join(projectDir, `tasks.json`); } // Get a default structure if file does not exist getDefaultData(projectId) { return { project: projectId, tasks: [], stats: { total: 0, proposed: 0, approved: 0, done: 0, verified: 0 } }; } // Read tasks for a project async getTasks(projectId) { const filePath = this.getFilePath(projectId); await this.ensureDir(path.dirname(filePath)); const mutex = this.getMutex(projectId); // Acquire read lock (using same mutex to prevent reading during write) const release = await mutex.acquire(); try { const data = await fs.readFile(filePath, 'utf8'); return JSON.parse(data); } catch (err) { if (err.code === 'ENOENT') { // File doesn't exist, return default data return this.getDefaultData(projectId); } console.error(`Error reading tasks for ${projectId}:`, err); throw new Error(`Could not read tasks for project: ${projectId}`); } finally { release(); } } // Write tasks for a project safely async saveTasks(projectId, data) { const filePath = this.getFilePath(projectId); await this.ensureDir(path.dirname(filePath)); const mutex = this.getMutex(projectId); // Calculate stats before saving data.stats = { total: data.tasks.length, proposed: data.tasks.filter(t => t.status === 'proposed').length, approved: data.tasks.filter(t => t.status === 'approved').length, done: data.tasks.filter(t => t.status === 'done').length, verified: data.tasks.filter(t => t.status === 'verified').length }; // Acquire lock for writing const release = await mutex.acquire(); try { // Write to a temporary file first (atomic write pattern) const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8'); // Rename temp file to actual file (atomic operation on most OS) await fs.rename(tempPath, filePath); return data; } catch (err) { console.error(`Error saving tasks for ${projectId}:`, err); throw new Error(`Could not save tasks for project: ${projectId}`); } finally { release(); } } // Update a single task's status async updateTaskStatus(projectId, taskId, newStatus) { // We use a high-level lock to read, modify, and write const mutex = this.getMutex(projectId); const release = await mutex.acquire(); try { const filePath = this.getFilePath(projectId); let data; try { const fileContent = await fs.readFile(filePath, 'utf8'); data = JSON.parse(fileContent); } catch (err) { if (err.code === 'ENOENT') { data = this.getDefaultData(projectId); } else { throw err; } } const taskIndex = data.tasks.findIndex(t => t.id === taskId); if (taskIndex === -1) { throw new Error(`Task ${taskId} not found`); } // Update status data.tasks[taskIndex].status = newStatus; data.tasks[taskIndex].updatedAt = new Date().toISOString(); // Recalculate stats data.stats = { total: data.tasks.length, proposed: data.tasks.filter(t => t.status === 'proposed').length, approved: data.tasks.filter(t => t.status === 'approved').length, done: data.tasks.filter(t => t.status === 'done').length, verified: data.tasks.filter(t => t.status === 'verified').length }; // Atomic write const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8'); await fs.rename(tempPath, filePath); return data.tasks[taskIndex]; } finally { release(); } } // Delete a task permanently async deleteTask(projectId, taskId) { const mutex = this.getMutex(projectId); const release = await mutex.acquire(); try { const filePath = this.getFilePath(projectId); let data; try { const fileContent = await fs.readFile(filePath, 'utf8'); data = JSON.parse(fileContent); } catch (err) { if (err.code === 'ENOENT') { data = this.getDefaultData(projectId); } else { throw err; } } const taskIndex = data.tasks.findIndex(t => t.id === taskId); if (taskIndex === -1) { throw new Error(`Task ${taskId} not found`); } data.tasks.splice(taskIndex, 1); // Recalculate stats data.stats = { total: data.tasks.length, proposed: data.tasks.filter(t => t.status === 'proposed').length, approved: data.tasks.filter(t => t.status === 'approved').length, done: data.tasks.filter(t => t.status === 'done').length, verified: data.tasks.filter(t => t.status === 'verified').length }; // Atomic write const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8'); await fs.rename(tempPath, filePath); return { deleted: taskId }; } finally { release(); } } // Archive a task (move to archivedTasks array) async archiveTask(projectId, taskId) { const mutex = this.getMutex(projectId); const release = await mutex.acquire(); try { const filePath = this.getFilePath(projectId); let data; try { const fileContent = await fs.readFile(filePath, 'utf8'); data = JSON.parse(fileContent); } catch (err) { if (err.code === 'ENOENT') { data = this.getDefaultData(projectId); } else { throw err; } } const taskIndex = data.tasks.findIndex(t => t.id === taskId); if (taskIndex === -1) { throw new Error(`Task ${taskId} not found`); } const [task] = data.tasks.splice(taskIndex, 1); task.archivedAt = new Date().toISOString(); if (!Array.isArray(data.archivedTasks)) { data.archivedTasks = []; } data.archivedTasks.push(task); // Recalculate stats data.stats = { total: data.tasks.length, proposed: data.tasks.filter(t => t.status === 'proposed').length, approved: data.tasks.filter(t => t.status === 'approved').length, done: data.tasks.filter(t => t.status === 'done').length, verified: data.tasks.filter(t => t.status === 'verified').length }; // Atomic write const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8'); await fs.rename(tempPath, filePath); return { archived: taskId }; } finally { release(); } } // Get archived tasks async getArchivedTasks(projectId) { const filePath = this.getFilePath(projectId); const mutex = this.getMutex(projectId); const release = await mutex.acquire(); try { const fileContent = await fs.readFile(filePath, 'utf8'); const data = JSON.parse(fileContent); return data.archivedTasks || []; } catch (err) { if (err.code === 'ENOENT') return []; throw err; } finally { release(); } } // Restore a task from archive back to proposed async restoreTask(projectId, taskId) { const mutex = this.getMutex(projectId); const release = await mutex.acquire(); try { const filePath = this.getFilePath(projectId); let data; try { const fileContent = await fs.readFile(filePath, 'utf8'); data = JSON.parse(fileContent); } catch (err) { if (err.code === 'ENOENT') data = this.getDefaultData(projectId); else throw err; } if (!Array.isArray(data.archivedTasks)) throw new Error(`Task ${taskId} not found in archive`); const idx = data.archivedTasks.findIndex(t => t.id === taskId); if (idx === -1) throw new Error(`Task ${taskId} not found in archive`); const [task] = data.archivedTasks.splice(idx, 1); delete task.archivedAt; task.status = 'proposed'; task.updatedAt = new Date().toISOString(); data.tasks.push(task); data.stats = { total: data.tasks.length, proposed: data.tasks.filter(t => t.status === 'proposed').length, approved: data.tasks.filter(t => t.status === 'approved').length, done: data.tasks.filter(t => t.status === 'done').length, verified: data.tasks.filter(t => t.status === 'verified').length }; const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8'); await fs.rename(tempPath, filePath); return task; } finally { release(); } } // Update task fields (title, description, priority, deadline) async updateTask(projectId, taskId, updates) { const mutex = this.getMutex(projectId); const release = await mutex.acquire(); try { const filePath = this.getFilePath(projectId); let data; try { const fileContent = await fs.readFile(filePath, 'utf8'); data = JSON.parse(fileContent); } catch (err) { if (err.code === 'ENOENT') data = this.getDefaultData(projectId); else throw err; } const idx = data.tasks.findIndex(t => t.id === taskId); if (idx === -1) throw new Error(`Task ${taskId} not found`); const allowed = ['title', 'description', 'priority', 'deadline', 'tags']; for (const key of allowed) { if (key in updates) data.tasks[idx][key] = updates[key]; } data.tasks[idx].updatedAt = new Date().toISOString(); const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8'); await fs.rename(tempPath, filePath); return data.tasks[idx]; } finally { release(); } } // Create a new task async createTask(projectId, taskInput) { const mutex = this.getMutex(projectId); const release = await mutex.acquire(); try { const filePath = this.getFilePath(projectId); let data; try { const fileContent = await fs.readFile(filePath, 'utf8'); data = JSON.parse(fileContent); } catch (err) { if (err.code === 'ENOENT') { data = this.getDefaultData(projectId); } else { throw err; } } const newTask = { id: `task_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, title: taskInput.title, description: taskInput.description, priority: taskInput.priority || 'medium', status: taskInput.status || 'proposed', tags: taskInput.tags || [], deadline: taskInput.deadline || undefined, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; data.tasks.push(newTask); // Recalculate stats data.stats = { total: data.tasks.length, proposed: data.tasks.filter(t => t.status === 'proposed').length, approved: data.tasks.filter(t => t.status === 'approved').length, done: data.tasks.filter(t => t.status === 'done').length, verified: data.tasks.filter(t => t.status === 'verified').length }; // Atomic write const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf8'); await fs.rename(tempPath, filePath); return newTask; } finally { release(); } } } module.exports = TaskManager;