← Назад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;