From caa3f3d35a7c2c57c69b4ce1187be9ae926b1a06 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Wed, 15 Apr 2026 09:14:54 +0530 Subject: [PATCH] feat(writing-studio): add project manager with compile and stats Amit Haridas --- .../writing-studio/project-manager.js | 74 ++++++++++++ tests/project-manager.test.js | 106 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/plugins/built-in/writing-studio/project-manager.js create mode 100644 tests/project-manager.test.js diff --git a/src/plugins/built-in/writing-studio/project-manager.js b/src/plugins/built-in/writing-studio/project-manager.js new file mode 100644 index 0000000..d337c74 --- /dev/null +++ b/src/plugins/built-in/writing-studio/project-manager.js @@ -0,0 +1,74 @@ +class ProjectManager { + /** + * @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) } + */ + constructor(fs) { + this.fs = fs; + } + + createProject(dir, opts) { + const project = { + title: opts.title, + type: opts.type || 'manuscript', + target: { words: opts.targetWords || 0, deadline: opts.deadline || null }, + chapters: [], + metadata: opts.metadata || {} + }; + this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); + return project; + } + + loadProject(dir) { + const raw = this.fs.readFile(dir + '/.project.json'); + if (!raw) return null; + return JSON.parse(raw); + } + + _saveProject(dir, project) { + this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); + } + + addChapter(dir, chapter) { + const project = this.loadProject(dir); + if (!project) throw new Error('Project not found'); + project.chapters.push(chapter); + this._saveProject(dir, project); + } + + updateChapter(dir, index, updates) { + const project = this.loadProject(dir); + if (!project) throw new Error('Project not found'); + Object.assign(project.chapters[index], updates); + this._saveProject(dir, project); + } + + compileManuscript(dir) { + const project = this.loadProject(dir); + if (!project) throw new Error('Project not found'); + const parts = []; + for (const ch of project.chapters) { + const content = this.fs.readFile(dir + '/' + ch.file); + if (content) parts.push(content); + } + return parts.join('\n\n---\n\n'); + } + + getStats(dir) { + const project = this.loadProject(dir); + if (!project) throw new Error('Project not found'); + let totalWords = 0; + for (const ch of project.chapters) { + const content = this.fs.readFile(dir + '/' + ch.file); + if (content) totalWords += content.split(/\s+/).filter(Boolean).length; + } + const target = project.target.words || 0; + return { + totalWords, + chapterCount: project.chapters.length, + targetWords: target, + pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0 + }; + } +} + +module.exports = { ProjectManager }; diff --git a/tests/project-manager.test.js b/tests/project-manager.test.js new file mode 100644 index 0000000..0b84fba --- /dev/null +++ b/tests/project-manager.test.js @@ -0,0 +1,106 @@ +const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager'); + +describe('ProjectManager', () => { + let pm; + let files; + + beforeEach(() => { + files = {}; + pm = new ProjectManager({ + readFile: (p) => files[p] || null, + writeFile: (p, c) => { files[p] = c; }, + fileExists: (p) => p in files, + listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1)) + }); + }); + + test('createProject writes .project.json', () => { + const project = pm.createProject('/manuscripts/novel', { + title: 'My Novel', type: 'manuscript', targetWords: 80000 + }); + expect(project.title).toBe('My Novel'); + expect(files['/manuscripts/novel/.project.json']).toBeDefined(); + const parsed = JSON.parse(files['/manuscripts/novel/.project.json']); + expect(parsed.target.words).toBe(80000); + }); + + test('loadProject reads and returns project data', () => { + files['/manuscripts/novel/.project.json'] = JSON.stringify({ + title: 'Test', type: 'manuscript', target: { words: 50000 }, + chapters: [], metadata: {} + }); + const project = pm.loadProject('/manuscripts/novel'); + expect(project.title).toBe('Test'); + }); + + test('loadProject returns null if no project file', () => { + expect(pm.loadProject('/nonexistent')).toBeNull(); + }); + + test('addChapter appends chapter and saves', () => { + files['/manuscripts/novel/.project.json'] = JSON.stringify({ + title: 'Test', type: 'manuscript', target: { words: 50000 }, + chapters: [], metadata: {} + }); + pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' }); + const parsed = JSON.parse(files['/manuscripts/novel/.project.json']); + expect(parsed.chapters.length).toBe(1); + expect(parsed.chapters[0].title).toBe('Chapter One'); + }); + + test('compileManuscript concatenates chapter files', () => { + files['/manuscripts/novel/.project.json'] = JSON.stringify({ + title: 'Test', type: 'manuscript', target: { words: 50000 }, + chapters: [ + { file: '01.md', title: 'One', status: 'draft' }, + { file: '02.md', title: 'Two', status: 'draft' } + ], metadata: {} + }); + files['/manuscripts/novel/01.md'] = 'First chapter content.'; + files['/manuscripts/novel/02.md'] = 'Second chapter content.'; + const result = pm.compileManuscript('/manuscripts/novel'); + expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.'); + }); + + test('compileManuscript skips missing files', () => { + files['/manuscripts/novel/.project.json'] = JSON.stringify({ + title: 'Test', type: 'manuscript', target: { words: 50000 }, + chapters: [ + { file: '01.md', title: 'One', status: 'draft' }, + { file: '02.md', title: 'Two', status: 'draft' } + ], metadata: {} + }); + files['/manuscripts/novel/01.md'] = 'Only chapter one.'; + const result = pm.compileManuscript('/manuscripts/novel'); + expect(result).toBe('Only chapter one.'); + }); + + test('getStats returns total word count across chapters', () => { + files['/manuscripts/novel/.project.json'] = JSON.stringify({ + title: 'Test', type: 'manuscript', target: { words: 50000 }, + chapters: [ + { file: '01.md', title: 'One', status: 'draft' }, + { file: '02.md', title: 'Two', status: 'draft' } + ], metadata: {} + }); + files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim(); + files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim(); + const stats = pm.getStats('/manuscripts/novel'); + expect(stats.totalWords).toBeGreaterThan(0); + expect(stats.chapterCount).toBe(2); + expect(stats.targetWords).toBe(50000); + expect(stats.pctComplete).toBeDefined(); + }); + + test('updateChapter modifies a chapter by index', () => { + files['/manuscripts/novel/.project.json'] = JSON.stringify({ + title: 'Test', type: 'manuscript', target: { words: 50000 }, + chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }], + metadata: {} + }); + pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' }); + const parsed = JSON.parse(files['/manuscripts/novel/.project.json']); + expect(parsed.chapters[0].title).toBe('New Title'); + expect(parsed.chapters[0].status).toBe('revised'); + }); +});