import { execSync, exec } from 'child_process'; import fs from 'fs'; import IComment from './IComment'; import { promisify } from 'util'; import ServerApp from '..'; import DBEvents from '../db/DBEvents'; const execAsync = promisify(exec); interface IClassListResult { success: boolean; comments: IComment[] | null; }; enum CommentAction { Create = 'create', Update = 'update', Remove = 'remove' }; interface IQueueComment extends IComment { action: CommentAction; }; enum CommentUpdateStatus { Error = 0, Created = 1, Updated = 2, Deleted = 3, Enqueued = 4 }; class CommentsManager { public static Saving: boolean = false; public static Queue: { comment: IQueueComment, commentPath: string }[] = []; public static CommentsRepoUrl: string = ''; public static CommentsFSRoot: string = ''; // called from index.ts public static init(rootDir: string, repoUrl: string): void { CommentsManager.CommentsFSRoot = rootDir; CommentsManager.CommentsRepoUrl = repoUrl; if(!fs.existsSync(rootDir)) { fs.mkdirSync(rootDir); execSync('git init', { encoding: 'utf8', cwd: rootDir, stdio: [] }); execSync(`git remote add origin ${repoUrl}`, { encoding: 'utf8', cwd: rootDir, stdio: [] }); execSync('git fetch origin', { encoding: 'utf8', cwd: rootDir, stdio: [] }); execSync('git checkout -b master origin/master', { encoding: 'utf8', cwd: rootDir, stdio: [] }); } execSync('git pull', { encoding: 'utf8', cwd: rootDir, stdio: [] }); } private static beginSave(): void { CommentsManager.Saving = true; } private static endSave(): void { CommentsManager.Saving = false; CommentsManager.applyQueue(); } private static isSaving(): boolean { return CommentsManager.Saving; } private static enqueueComment(comment: IComment, action: CommentAction): void { const commentPath = `${CommentsManager.getClassDirPath(comment.root, comment.className)}/${comment.propertyName}`; action = CommentAction.Create; const exists = fs.existsSync(commentPath); if(exists) action = CommentAction.Update; if(exists && comment.text.length === 0) action = CommentAction.Remove; const queueComment: IQueueComment = Object.assign(comment, { action }) as IQueueComment; CommentsManager.Queue.push({ comment: queueComment, commentPath: commentPath }); } private static createClassDir(root: string, className: string): string { const classDirPath = CommentsManager.getClassDirPath(root, className); let currentPath = ''; classDirPath.split('/').forEach(dir => { currentPath += dir + '/'; if(!fs.existsSync(currentPath)) { fs.mkdirSync(currentPath); } }); return classDirPath; } private static getClassDirPath(root: string, className: string): string { return `${CommentsManager.CommentsFSRoot}/${root}/${className.replaceAll('.', '/')}`; } private static async createOrUpdateCommentFile(comment: IComment): Promise<{ commentPath: string, status: CommentUpdateStatus }> { const commentContent = `${comment.author}:${comment.timestamp}:${comment.text}`; const classDirPath = CommentsManager.createClassDir(comment.root, comment.className); const commentPath = `${classDirPath}/${comment.propertyName}`; let status = CommentUpdateStatus.Created; const exists = fs.existsSync(commentPath); if(exists) { const content = fs.readFileSync(commentPath, 'utf-8'); const [_1, _2, ...rest] = content.split(':'); const commentContent = rest.join(':'); status = commentContent.length > 0 ? CommentUpdateStatus.Updated : CommentUpdateStatus.Created; } if(exists && comment.text.length === 0) status = CommentUpdateStatus.Deleted; await fs.promises.writeFile(commentPath, commentContent, { encoding: 'utf8' }); return { commentPath, status }; } private static async createEvent(comments: IQueueComment[]): Promise { const promises: Promise[] = []; for(const comment of comments) { promises.push(DBEvents.createEvent(comment.author, comment.className, comment.propertyName, comment.action, comment.timestamp.toString())); } return Promise.all(promises); } public static async update(comment: IComment): Promise { if(CommentsManager.isSaving()) { CommentsManager.enqueueComment(comment, CommentAction.Create); return CommentUpdateStatus.Enqueued; } CommentsManager.beginSave(); try { const { commentPath, status } = await CommentsManager.createOrUpdateCommentFile(comment); let action = CommentAction.Create; switch(status) { case CommentUpdateStatus.Created: action = CommentAction.Create; break; case CommentUpdateStatus.Updated: action = CommentAction.Update; break; case CommentUpdateStatus.Deleted: action = CommentAction.Remove; break; } const queueComment: IQueueComment = Object.assign(comment, { action: action }) as IQueueComment; await CommentsManager.commit([queueComment], [commentPath]); await CommentsManager.createEvent([queueComment]); CommentsManager.endSave(); return status; } catch(e) { CommentsManager.endSave(); return CommentUpdateStatus.Error; } } public static async getCommentsByClass(root: string, className: string, propertiesList?: string[]): Promise { try { const classDir = CommentsManager.getClassDirPath(root, className); if(!fs.existsSync(classDir)) { return { success: true, comments: [] }; } const commentFiles = fs.readdirSync(classDir).filter(file => file !== '.' && file !== '..'); const comments: IComment[] = []; for(const propertyName of commentFiles) { if(propertiesList && !propertiesList.includes(propertyName)) continue; const filePath = `${classDir}/${propertyName}`; if(fs.lstatSync(filePath).isDirectory()) continue; const [author, timestamp, ...rest] = fs.readFileSync(filePath, 'utf-8').split(':'); const text = rest.join(':'); comments.push({ root: root, className: className, propertyName: propertyName, text: text, author: author, timestamp: Number.parseInt(timestamp) }); } return { success: true, comments: comments }; } catch(e) { return { success: false, comments: null }; } } private static async commit(comments: IQueueComment[], commentsPaths: string[]): Promise { const commitMessages: string[] = []; for(let i = 0; i < comments.length; i++) { const comment = comments[i]; const commentPath = commentsPaths[i]; const message = `"${comment.author} ${comment.action === CommentAction.Create ? 'created' : (comment.action === CommentAction.Update ? 'updated' : 'deleted')} a comment for ${comment.root}/${comment.className}:${comment.propertyName}"`; commitMessages.push(message); await execAsync(`git add ${commentPath}`, { encoding: 'utf8', cwd: CommentsManager.CommentsFSRoot }); } await execAsync(`git commit -m ${commitMessages.join(' -m ')}`, { encoding: 'utf8', cwd: CommentsManager.CommentsFSRoot }); await execAsync('git push', { encoding: 'utf8', cwd: CommentsManager.CommentsFSRoot }); await this.notifySockets(comments); // final action of any update } private static async applyQueue(): Promise { if(CommentsManager.Queue.length === 0 || CommentsManager.isSaving()) return; const queue = [...CommentsManager.Queue]; CommentsManager.Queue = []; CommentsManager.beginSave(); try { const comments = []; const commentsPaths = []; for(const queueItem of queue) { const comment = queueItem.comment; const commentPath = queueItem.commentPath; await CommentsManager.createOrUpdateCommentFile(comment); comments.push(comment); commentsPaths.push(commentPath); } await CommentsManager.commit(comments, commentsPaths); await CommentsManager.createEvent(comments); CommentsManager.endSave(); } catch(e) { CommentsManager.endSave(); return; } } // called after each commit to notify client-side socket about changes private static async notifySockets(comments: IQueueComment[]): Promise { await ServerApp.notifyAllSockets(JSON.stringify(comments)); } } export { CommentsManager, CommentUpdateStatus };