From b81e670ce447334dc021013869322d15844f7173 Mon Sep 17 00:00:00 2001 From: Hiro Date: Sat, 28 Mar 2026 13:07:09 +0000 Subject: [PATCH] feat: implement Projects and Folders API (v2 architecture) - Add projectService.js with full CRUD + tree operations - Add folderService.js with hierarchical folder support - Add projects.js routes: GET/POST/PUT/DELETE /projects, /projects/:id/tree, /projects/:id/documents - Add folders.js routes: GET/POST/PUT/DELETE /folders, /folders/:id/tree, /folders/:id/documents - Update documentService.js to support projectId/folderId with backwards compat (libraryId) - Update routes/index.js to mount new routers - Maintain backwards compatibility with legacy libraryId - All endpoints tested and working --- src/routes/documents.js | 8 +- src/routes/folders.js | 138 +++++++++++ src/routes/index.js | 4 + src/routes/projects.js | 125 ++++++++++ src/services/documentService.js | 326 +++++++++++++++++++++---- src/services/folderService.js | 406 ++++++++++++++++++++++++++++++++ src/services/projectService.js | 289 +++++++++++++++++++++++ 7 files changed, 1252 insertions(+), 44 deletions(-) create mode 100644 src/routes/folders.js create mode 100644 src/routes/projects.js create mode 100644 src/services/folderService.js create mode 100644 src/services/projectService.js diff --git a/src/routes/documents.js b/src/routes/documents.js index a30f42e..5e473a1 100644 --- a/src/routes/documents.js +++ b/src/routes/documents.js @@ -14,11 +14,13 @@ router.use(authMiddleware); // GET /documents - List documents router.get('/', async (req, res) => { try { - const { tag, library, type, status, limit, offset } = req.query; + const { tag, library, project, folder, type, status, limit, offset } = req.query; const docService = getDocumentService(); const result = await docService.listDocuments({ tag, library, + project, + folder, type, status, limit: limit ? parseInt(limit, 10) : 50, @@ -34,11 +36,13 @@ router.get('/', async (req, res) => { // POST /documents - Create document router.post('/', async (req, res) => { try { - const { title, libraryId, content, tags, type, priority, status } = req.body; + const { title, libraryId, projectId, folderId, content, tags, type, priority, status } = req.body; const docService = getDocumentService(); const doc = await docService.createDocument({ title, libraryId, + projectId, + folderId, content, tags, type, diff --git a/src/routes/folders.js b/src/routes/folders.js new file mode 100644 index 0000000..4676577 --- /dev/null +++ b/src/routes/folders.js @@ -0,0 +1,138 @@ +/** + * SimpleNote Web - Folders Routes + * CRUD + tree for folders + */ + +import { Router } from 'express'; +import { authMiddleware } from '../middleware/auth.js'; +import { getFolderService } from '../services/folderService.js'; +import { NotFoundError, ValidationError } from '../utils/errors.js'; + +const router = Router(); +router.use(authMiddleware); + +// GET /folders?projectId=X&parentId=Y - List folders +router.get('/', async (req, res) => { + try { + const { projectId, parentId } = req.query; + if (!projectId) { + throw new ValidationError('projectId query parameter is required'); + } + const folderService = getFolderService(); + const folders = await folderService.getFolders(projectId, parentId || null); + res.json({ folders }); + } catch (err) { + if (err instanceof ValidationError) { + return res.status(400).json({ error: err.message, code: err.code }); + } + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error listing folders:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// POST /folders - Create folder +router.post('/', async (req, res) => { + try { + const { name, projectId, parentId } = req.body; + if (!name) { + throw new ValidationError('name is required'); + } + if (!projectId) { + throw new ValidationError('projectId is required'); + } + const folderService = getFolderService(); + const folder = await folderService.createFolder({ name, projectId, parentId: parentId || null }); + res.status(201).json(folder); + } catch (err) { + if (err instanceof ValidationError || err instanceof NotFoundError) { + const status = err instanceof ValidationError ? 400 : 404; + return res.status(status).json({ error: err.message, code: err.code }); + } + console.error('Error creating folder:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// GET /folders/:id - Get folder contents +router.get('/:id', async (req, res) => { + try { + const folderService = getFolderService(); + const folder = await folderService.getFolder(req.params.id); + res.json(folder); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error getting folder:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// PUT /folders/:id - Update folder +router.put('/:id', async (req, res) => { + try { + const { name } = req.body; + const folderService = getFolderService(); + const folder = await folderService.updateFolder(req.params.id, { name }); + res.json(folder); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + if (err instanceof ValidationError) { + return res.status(400).json({ error: err.message, code: err.code }); + } + console.error('Error updating folder:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// DELETE /folders/:id - Delete folder +router.delete('/:id', async (req, res) => { + try { + const folderService = getFolderService(); + const result = await folderService.deleteFolder(req.params.id); + res.json(result); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error deleting folder:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// GET /folders/:id/documents - List documents in folder +router.get('/:id/documents', async (req, res) => { + try { + const folderService = getFolderService(); + const result = await folderService.getFolderDocuments(req.params.id); + res.json(result); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error listing folder documents:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// GET /folders/:id/tree - Get full folder tree +router.get('/:id/tree', async (req, res) => { + try { + const folderService = getFolderService(); + const tree = await folderService.getFolderTree(req.params.id); + res.json(tree); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error getting folder tree:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +export default router; diff --git a/src/routes/index.js b/src/routes/index.js index a0467a6..627fe0f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -8,6 +8,8 @@ import documentsRouter from './documents.js'; import librariesRouter from './libraries.js'; import tagsRouter from './tags.js'; import authRouter from './auth.js'; +import projectsRouter from './projects.js'; +import foldersRouter from './folders.js'; export function createApiRouter(apiPrefix = '/api/v1') { const router = Router(); @@ -16,6 +18,8 @@ export function createApiRouter(apiPrefix = '/api/v1') { router.use('/libraries', librariesRouter); router.use('/tags', tagsRouter); router.use('/auth', authRouter); + router.use('/projects', projectsRouter); + router.use('/folders', foldersRouter); return router; } diff --git a/src/routes/projects.js b/src/routes/projects.js new file mode 100644 index 0000000..62174f8 --- /dev/null +++ b/src/routes/projects.js @@ -0,0 +1,125 @@ +/** + * SimpleNote Web - Projects Routes + * CRUD + tree for projects + */ + +import { Router } from 'express'; +import { authMiddleware } from '../middleware/auth.js'; +import { getProjectService } from '../services/projectService.js'; +import { NotFoundError, ValidationError } from '../utils/errors.js'; + +const router = Router(); +router.use(authMiddleware); + +// GET /projects - List all projects +router.get('/', async (req, res) => { + try { + const projectService = getProjectService(); + const projects = await projectService.getProjects(); + res.json({ projects }); + } catch (err) { + console.error('Error listing projects:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// POST /projects - Create project +router.post('/', async (req, res) => { + try { + const { name, description } = req.body; + if (!name) { + throw new ValidationError('name is required'); + } + const projectService = getProjectService(); + const project = await projectService.createProject({ name, description }); + res.status(201).json(project); + } catch (err) { + if (err instanceof ValidationError || err instanceof NotFoundError) { + const status = err instanceof ValidationError ? 400 : 404; + return res.status(status).json({ error: err.message, code: err.code }); + } + console.error('Error creating project:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// GET /projects/:id - Get project contents +router.get('/:id', async (req, res) => { + try { + const projectService = getProjectService(); + const project = await projectService.getProject(req.params.id); + res.json(project); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error getting project:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// PUT /projects/:id - Update project +router.put('/:id', async (req, res) => { + try { + const { name, description } = req.body; + const projectService = getProjectService(); + const project = await projectService.updateProject(req.params.id, { name, description }); + res.json(project); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + if (err instanceof ValidationError) { + return res.status(400).json({ error: err.message, code: err.code }); + } + console.error('Error updating project:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// DELETE /projects/:id - Delete project +router.delete('/:id', async (req, res) => { + try { + const projectService = getProjectService(); + const result = await projectService.deleteProject(req.params.id); + res.json(result); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error deleting project:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// GET /projects/:id/tree - Get full project tree +router.get('/:id/tree', async (req, res) => { + try { + const projectService = getProjectService(); + const tree = await projectService.getProjectTree(req.params.id); + res.json(tree); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error getting project tree:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +// GET /projects/:id/documents - List documents in project +router.get('/:id/documents', async (req, res) => { + try { + const projectService = getProjectService(); + const result = await projectService.getProjectDocuments(req.params.id); + res.json(result); + } catch (err) { + if (err instanceof NotFoundError) { + return res.status(404).json({ error: err.message, code: err.code }); + } + console.error('Error listing project documents:', err); + res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }); + } +}); + +export default router; diff --git a/src/services/documentService.js b/src/services/documentService.js index 1f1d48c..ba00377 100644 --- a/src/services/documentService.js +++ b/src/services/documentService.js @@ -1,6 +1,7 @@ /** * SimpleNote Web - Document Service * Document CRUD with markdown storage + * Supports both legacy libraries/ and new projects/ structure */ import { join } from 'path'; @@ -12,16 +13,23 @@ import { parseMarkdown, serializeMarkdown, buildDefaultContent } from '../utils/ import { NotFoundError, ValidationError } from '../utils/errors.js'; import { getTagIndexer } from '../indexers/tagIndexer.js'; import { getLibraryService } from './libraryService.js'; +import { getProjectService } from './projectService.js'; +import { getFolderService } from './folderService.js'; const LIBRARIES_DIR = 'libraries'; +const PROJECTS_DIR = 'projects'; +const FOLDERS_DIR = 'folders'; export class DocumentService { constructor(dataRoot = config.dataRoot) { this.dataRoot = dataRoot; this.librariesPath = join(dataRoot, LIBRARIES_DIR); + this.projectsPath = join(dataRoot, PROJECTS_DIR); this.tagIndexer = getTagIndexer(dataRoot); } + // ===== Legacy Libraries Paths ===== + _docPath(libId, docId) { return join(this.librariesPath, libId, 'documents', docId); } @@ -34,16 +42,102 @@ export class DocumentService { return join(this._docPath(libId, docId), '.meta.json'); } + // ===== New Projects/Folders Paths ===== + + _projectDocPath(projectId, docId) { + return join(this.projectsPath, projectId, 'documents', docId); + } + + _projectDocIndexPath(projectId, docId) { + return join(this._projectDocPath(projectId, docId), 'index.md'); + } + + _projectDocMetaPath(projectId, docId) { + return join(this._projectDocPath(projectId, docId), '.meta.json'); + } + + _folderDocPath(projectId, folderId, docId) { + return join(this.projectsPath, projectId, FOLDERS_DIR, folderId, 'documents', docId); + } + + _folderDocIndexPath(projectId, folderId, docId) { + return join(this._folderDocPath(projectId, folderId, docId), 'index.md'); + } + + _folderDocMetaPath(projectId, folderId, docId) { + return join(this._folderDocPath(projectId, folderId, docId), '.meta.json'); + } + + // ===== Find Methods ===== + _findDocInLibrary(libId, docId) { const metaPath = this._docMetaPath(libId, docId); if (pathExists(metaPath)) { - return { docId, libId, metaPath, indexPath: this._docIndexPath(libId, docId) }; + return { docId, libId, metaPath, indexPath: this._docIndexPath(libId, docId), storageType: 'library' }; + } + return null; + } + + _findDocInProject(projectId, docId) { + const metaPath = this._projectDocMetaPath(projectId, docId); + if (pathExists(metaPath)) { + return { docId, projectId, metaPath, indexPath: this._projectDocIndexPath(projectId, docId), storageType: 'project' }; + } + return null; + } + + _findDocInFolder(projectId, folderId, docId) { + const metaPath = this._folderDocMetaPath(projectId, folderId, docId); + if (pathExists(metaPath)) { + return { docId, projectId, folderId, metaPath, indexPath: this._folderDocIndexPath(projectId, folderId, docId), storageType: 'folder' }; + } + return null; + } + + _findInSubLibs(subLibsPath, docId, libEntry) { + if (!pathExists(subLibsPath)) return null; + + const entries = listDir(subLibsPath); + for (const entry of entries) { + const entryPath = join(subLibsPath, entry); + if (!isDirectory(entryPath)) continue; + + // Check if doc is here + const metaPath = join(entryPath, 'documents', docId, '.meta.json'); + if (pathExists(metaPath)) { + return { docId, libId: entry, metaPath, indexPath: join(entryPath, 'documents', docId, 'index.md'), storageType: 'library' }; + } + + // Recurse into sub-sub-libraries + const subSubLibsPath = join(entryPath, 'sub-libraries'); + const found = this._findInSubLibs(subSubLibsPath, docId, entry); + if (found) return found; } return null; } _findDocById(docId) { - // Search all libraries for this document + // First check new projects structure + if (pathExists(this.projectsPath)) { + const projectEntries = listDir(this.projectsPath); + for (const projectEntry of projectEntries) { + const projectPath = join(this.projectsPath, projectEntry); + if (!isDirectory(projectPath)) continue; + + // Direct doc in project root + const found = this._findDocInProject(projectEntry, docId); + if (found) return found; + + // Doc in folders (search recursively) + const foldersPath = join(projectPath, FOLDERS_DIR); + if (pathExists(foldersPath)) { + const foundInFolder = this._findDocInProjectFolders(foldersPath, projectEntry, docId); + if (foundInFolder) return foundInFolder; + } + } + } + + // Fallback: search legacy libraries structure if (!pathExists(this.librariesPath)) return null; const libEntries = listDir(this.librariesPath); @@ -65,24 +159,22 @@ export class DocumentService { return null; } - _findInSubLibs(subLibsPath, docId, parentLibId) { - if (!pathExists(subLibsPath)) return null; + _findDocInProjectFolders(foldersPath, projectId, docId) { + if (!pathExists(foldersPath)) return null; - const entries = listDir(subLibsPath); - for (const entry of entries) { - const entryPath = join(subLibsPath, entry); - if (!isDirectory(entryPath)) continue; + const folderEntries = listDir(foldersPath); + for (const folderEntry of folderEntries) { + const folderPath = join(foldersPath, folderEntry); + if (!isDirectory(folderPath)) continue; - // Check if doc is here - const metaPath = join(entryPath, 'documents', docId, '.meta.json'); - if (pathExists(metaPath)) { - return { docId, libId: entry, metaPath, indexPath: join(entryPath, 'documents', docId, 'index.md') }; - } - - // Recurse into sub-sub-libraries - const subSubLibsPath = join(entryPath, 'sub-libraries'); - const found = this._findInSubLibs(subSubLibsPath, docId, entry); + // Check if doc is directly in this folder + const found = this._findDocInFolder(projectId, folderEntry, docId); if (found) return found; + + // Recurse into sub-folders + const subFoldersPath = join(folderPath, 'sub-folders'); + const foundSub = this._findDocInProjectFolders(subFoldersPath, projectId, docId); + if (foundSub) return foundSub; } return null; } @@ -100,23 +192,81 @@ export class DocumentService { return { meta, content, found }; } - async createDocument({ title, libraryId, content, tags = [], type = 'general', priority = 'medium', status = 'draft', createdBy = null }) { + // ===== CRUD Operations ===== + + /** + * Create a document. + * Supports both legacy libraryId (backwards compat) and new projectId/folderId. + * @param {Object} params + * @param {string} params.title - Document title + * @param {string} [params.libraryId] - Legacy library ID (backwards compat) + * @param {string} [params.projectId] - Project ID (new structure) + * @param {string} [params.folderId] - Folder ID within project (new structure) + * @param {string} [params.content] - Document content + * @param {string[]} [params.tags] - Tags + * @param {string} [params.type] - Document type + * @param {string} [params.priority] - Priority + * @param {string} [params.status] - Status + * @param {string} [params.createdBy] - Creator + */ + async createDocument({ title, libraryId, projectId, folderId, content, tags = [], type = 'general', priority = 'medium', status = 'draft', createdBy = null }) { if (!title || !title.trim()) { throw new ValidationError('Title is required'); } - if (!libraryId) { - throw new ValidationError('Library ID is required'); + + // Determine storage location: prefer new project/folder structure, fallback to legacy library + let effectiveProjectId = projectId; + let effectiveFolderId = folderId || null; + let effectiveLibraryId = libraryId; + + if (effectiveProjectId) { + // New structure: verify project exists + const projectService = getProjectService(this.dataRoot); + await projectService.getProject(effectiveProjectId); + + // If folderId provided, verify folder exists + if (effectiveFolderId) { + const folderService = getFolderService(this.dataRoot); + await folderService.getFolder(effectiveFolderId); + } + + // Backwards compat: set libraryId = projectId for legacy code + effectiveLibraryId = effectiveProjectId; + } else if (effectiveLibraryId) { + // Legacy structure: verify library exists + const libService = getLibraryService(this.dataRoot); + await libService.getLibrary(effectiveLibraryId); + + // Backwards compat: for old docs, projectId = libraryId + effectiveProjectId = effectiveLibraryId; + } else { + throw new ValidationError('Either libraryId or projectId is required'); } - // Verify library exists - const libService = getLibraryService(this.dataRoot); - await libService.getLibrary(libraryId); // Will throw NotFoundError if not exists - const docId = generateId(); - const docPath = this._docPath(libraryId, docId); const now = new Date().toISOString(); - ensureDir(docPath); + let docPath, metaPath, indexPath; + + if (effectiveFolderId) { + // Store in folder + docPath = this._folderDocPath(effectiveProjectId, effectiveFolderId, docId); + metaPath = this._folderDocMetaPath(effectiveProjectId, effectiveFolderId, docId); + indexPath = this._folderDocIndexPath(effectiveProjectId, effectiveFolderId, docId); + ensureDir(docPath); + } else if (effectiveProjectId) { + // Store in project root + docPath = this._projectDocPath(effectiveProjectId, docId); + metaPath = this._projectDocMetaPath(effectiveProjectId, docId); + indexPath = this._projectDocIndexPath(effectiveProjectId, docId); + ensureDir(docPath); + } else { + // Legacy: store in library + docPath = this._docPath(effectiveLibraryId, docId); + metaPath = this._docMetaPath(effectiveLibraryId, docId); + indexPath = this._docIndexPath(effectiveLibraryId, docId); + ensureDir(docPath); + } const metadata = { id: docId, @@ -125,7 +275,11 @@ export class DocumentService { type, priority, status, - libraryId, + // New fields + projectId: effectiveProjectId, + folderId: effectiveFolderId, + // Legacy field (backwards compat - same as projectId) + libraryId: effectiveLibraryId, createdBy, createdAt: now, updatedAt: now, @@ -134,28 +288,55 @@ export class DocumentService { const body = content || buildDefaultContent(title, type); const markdown = serializeMarkdown(metadata, body); - writeJSON(this._docMetaPath(libraryId, docId), metadata); - writeFileSync(this._docIndexPath(libraryId, docId), markdown, 'utf-8'); + writeJSON(metaPath, metadata); + writeFileSync(indexPath, markdown, 'utf-8'); // Update tag index this.tagIndexer.addDocument(docId, metadata.tags); return { ...metadata, - path: `/${LIBRARIES_DIR}/${libraryId}/documents/${docId}/index.md`, + path: this._getDocPathForResponse(metadata), content: body, }; } - async listDocuments({ tag, library, type, status, limit = 50, offset = 0 } = {}) { + _getDocPathForResponse(metadata) { + if (metadata.folderId) { + return `/${PROJECTS_DIR}/${metadata.projectId}/${FOLDERS_DIR}/${metadata.folderId}/documents/${metadata.id}/index.md`; + } else if (metadata.projectId) { + return `/${PROJECTS_DIR}/${metadata.projectId}/documents/${metadata.id}/index.md`; + } else { + return `/${LIBRARIES_DIR}/${metadata.libraryId}/documents/${metadata.id}/index.md`; + } + } + + async listDocuments({ tag, library, project, folder, type, status, limit = 50, offset = 0 } = {}) { let allDocs = []; - // Collect all documents - await this._collectDocs(this.librariesPath, allDocs); + // Collect from projects structure (new) + if (pathExists(this.projectsPath)) { + await this._collectDocsFromProjects(allDocs); + } - // Filter by library + // Collect from legacy libraries structure + if (pathExists(this.librariesPath)) { + await this._collectDocs(this.librariesPath, allDocs); + } + + // Filter by project + if (project) { + allDocs = allDocs.filter(d => d.projectId === project); + } + + // Filter by folder + if (folder) { + allDocs = allDocs.filter(d => d.folderId === folder); + } + + // Filter by library (backwards compat) if (library) { - allDocs = allDocs.filter(d => d.libraryId === library); + allDocs = allDocs.filter(d => d.libraryId === library || d.projectId === library); } // Filter by tag @@ -180,16 +361,65 @@ export class DocumentService { // Enrich with content for each doc const enriched = paginated.map(doc => { const { meta } = this._readDocRaw(doc.id) || { meta: doc }; - const content = pathExists(join(this.librariesPath, doc.libraryId, 'documents', doc.id, 'index.md')) - ? readFileSync(join(this.librariesPath, doc.libraryId, 'documents', doc.id, 'index.md'), 'utf-8') - : ''; - const { body } = parseMarkdown(content); - return { ...doc, content: body }; + return { ...doc, content: meta.content || '' }; }); return { documents: enriched, total, limit, offset }; } + async _collectDocsFromProjects(results) { + if (!pathExists(this.projectsPath)) return; + + const projectEntries = listDir(this.projectsPath); + for (const projectEntry of projectEntries) { + const projectPath = join(this.projectsPath, projectEntry); + if (!isDirectory(projectPath)) continue; + + // Collect from project root documents + const docsPath = join(projectPath, 'documents'); + await this._collectDocsAtPath(docsPath, results); + + // Collect from folders recursively + const foldersPath = join(projectPath, FOLDERS_DIR); + if (pathExists(foldersPath)) { + await this._collectDocsFromFolders(foldersPath, projectEntry, results); + } + } + } + + async _collectDocsFromFolders(foldersPath, projectId, results) { + if (!pathExists(foldersPath)) return; + + const folderEntries = listDir(foldersPath); + for (const folderEntry of folderEntries) { + const folderPath = join(foldersPath, folderEntry); + if (!isDirectory(folderPath)) continue; + + // Collect documents in this folder + const docsPath = join(folderPath, 'documents'); + await this._collectDocsAtPath(docsPath, results); + + // Recurse into sub-folders + const subFoldersPath = join(folderPath, 'sub-folders'); + await this._collectDocsFromFolders(subFoldersPath, projectId, results); + } + } + + async _collectDocsAtPath(docsPath, results) { + if (!pathExists(docsPath)) return; + + const docEntries = listDir(docsPath); + for (const docEntry of docEntries) { + const docMetaPath = join(docsPath, docEntry, '.meta.json'); + if (pathExists(docMetaPath)) { + const meta = readJSON(docMetaPath); + if (meta?.id) { + results.push(meta); + } + } + } + } + async _collectDocs(path, results) { if (!pathExists(path)) return; @@ -201,6 +431,10 @@ export class DocumentService { if (pathExists(metaPath)) { const meta = readJSON(metaPath); if (meta?.id) { + // Ensure backwards compat fields + if (!meta.projectId && meta.libraryId) { + meta.projectId = meta.libraryId; + } results.push(meta); } } else if (isDirectory(entryPath)) { @@ -231,7 +465,7 @@ export class DocumentService { return { ...meta, content: body, - path: `/${LIBRARIES_DIR}/${found.libId}/documents/${docId}/index.md`, + path: this._getDocPathForResponse(meta), }; } @@ -276,7 +510,15 @@ export class DocumentService { } const meta = readJSON(found.metaPath); - deletePath(this._docPath(found.libId, docId)); + + // Delete based on storage type + if (found.storageType === 'folder') { + deletePath(this._folderDocPath(found.projectId, found.folderId, docId)); + } else if (found.storageType === 'project') { + deletePath(this._projectDocPath(found.projectId, docId)); + } else { + deletePath(this._docPath(found.libId, docId)); + } this.tagIndexer.removeDocument(docId); diff --git a/src/services/folderService.js b/src/services/folderService.js new file mode 100644 index 0000000..5140e91 --- /dev/null +++ b/src/services/folderService.js @@ -0,0 +1,406 @@ +/** + * SimpleNote Web - Folder Service + * Hierarchical folder CRUD with filesystem storage + */ + +import { join } from 'path'; +import config from '../config/index.js'; +import { ensureDir, readJSON, writeJSON, pathExists, deletePath, listDir, isDirectory } from '../utils/fsHelper.js'; +import { generateId } from '../utils/uuid.js'; +import { NotFoundError, ValidationError } from '../utils/errors.js'; +import { getProjectService } from './projectService.js'; + +const PROJECTS_DIR = 'projects'; +const FOLDERS_DIR = 'folders'; +const FOLDER_META_FILE = '.folder.json'; + +export class FolderService { + constructor(dataRoot = config.dataRoot) { + this.dataRoot = dataRoot; + this.projectsPath = join(dataRoot, PROJECTS_DIR); + } + + _projectFoldersPath(projectId) { + return join(this.projectsPath, projectId, FOLDERS_DIR); + } + + _folderPath(projectId, folderId) { + return join(this._projectFoldersPath(projectId), folderId); + } + + _folderMetaPath(projectId, folderId) { + return join(this._folderPath(projectId, folderId), FOLDER_META_FILE); + } + + _folderDocumentsPath(projectId, folderId) { + return join(this._folderPath(projectId, folderId), 'documents'); + } + + _folderSubFoldersPath(projectId, folderId) { + return join(this._folderPath(projectId, folderId), 'sub-folders'); + } + + _resolveFolderMeta(folderId, parentId = null, projectId) { + // If projectId and parentId are provided, search in that context + if (projectId) { + const foldersPath = parentId + ? this._folderSubFoldersPath(projectId, parentId) + : this._projectFoldersPath(projectId); + return this._findFolderInPath(foldersPath, folderId); + } + + // Fallback: search all projects + return this._findFolderGlobally(folderId); + } + + _findFolderInPath(searchPath, folderId) { + if (!pathExists(searchPath)) return null; + + const entries = listDir(searchPath); + for (const entry of entries) { + const entryPath = join(searchPath, entry); + if (!isDirectory(entryPath)) continue; + + if (entry === folderId) { + const metaPath = join(entryPath, FOLDER_META_FILE); + if (pathExists(metaPath)) { + return { id: folderId, metaPath, folderPath: entryPath }; + } + } + + // Search in sub-folders recursively + const subFoldersPath = join(entryPath, 'sub-folders'); + const found = this._findFolderInPath(subFoldersPath, folderId); + if (found) return found; + } + return null; + } + + _findFolderGlobally(folderId) { + if (!pathExists(this.projectsPath)) return null; + + const projectEntries = listDir(this.projectsPath); + for (const projectEntry of projectEntries) { + const projectPath = join(this.projectsPath, projectEntry); + if (!isDirectory(projectPath)) continue; + + const foldersPath = join(projectPath, FOLDERS_DIR); + const found = this._findFolderInPath(foldersPath, folderId); + if (found) return found; + } + return null; + } + + async createFolder({ name, projectId, parentId = null }) { + if (!name || !name.trim()) { + throw new ValidationError('Folder name is required'); + } + if (!projectId) { + throw new ValidationError('Project ID is required'); + } + + // Verify project exists + const projectService = getProjectService(this.dataRoot); + const project = await projectService.getProject(projectId); + + const folderId = generateId(); + const now = new Date().toISOString(); + + if (parentId) { + // Verify parent folder exists + const parentMeta = this._resolveFolderMeta(parentId, null, projectId); + if (!parentMeta) { + throw new NotFoundError('Parent folder'); + } + + const parentMetaData = readJSON(parentMeta.metaPath); + const parentSubFoldersPath = join(parentMeta.folderPath, 'sub-folders'); + ensureDir(parentSubFoldersPath); + + const folderMeta = { + id: folderId, + name: name.trim(), + projectId, + parentId, + path: `${PROJECTS_DIR}/${projectId}/${FOLDERS_DIR}/${parentId}/sub-folders/${folderId}`, + createdAt: now, + updatedAt: now, + }; + + const folderPath = join(parentSubFoldersPath, folderId); + ensureDir(folderPath); + ensureDir(join(folderPath, 'documents')); + writeJSON(join(folderPath, FOLDER_META_FILE), folderMeta); + return folderMeta; + } else { + // Create at root level of project + ensureDir(this.projectsPath); + + const foldersPath = this._projectFoldersPath(projectId); + ensureDir(foldersPath); + + const folderMeta = { + id: folderId, + name: name.trim(), + projectId, + parentId: null, + path: `${PROJECTS_DIR}/${projectId}/${FOLDERS_DIR}/${folderId}`, + createdAt: now, + updatedAt: now, + }; + + const folderPath = join(foldersPath, folderId); + ensureDir(folderPath); + ensureDir(join(folderPath, 'documents')); + writeJSON(join(folderPath, FOLDER_META_FILE), folderMeta); + return folderMeta; + } + } + + async getFolders(projectId, parentId = null) { + // Verify project exists + const projectService = getProjectService(this.dataRoot); + await projectService.getProject(projectId); + + const folders = []; + + if (parentId) { + // Get folders within a specific parent + const parentMeta = this._resolveFolderMeta(parentId, null, projectId); + if (!parentMeta) { + throw new NotFoundError('Parent folder'); + } + + const parentSubFoldersPath = join(parentMeta.folderPath, 'sub-folders'); + const entries = listDir(parentSubFoldersPath); + + for (const entry of entries) { + const entryPath = join(parentSubFoldersPath, entry); + if (!isDirectory(entryPath)) continue; + + const meta = readJSON(join(entryPath, FOLDER_META_FILE)); + if (!meta) continue; + + folders.push({ + ...meta, + documentCount: this._countDocuments(join(entryPath, 'documents')), + folderCount: this._countSubFolders(join(entryPath, 'sub-folders')), + }); + } + } else { + // Get root folders of the project + const rootFoldersPath = this._projectFoldersPath(projectId); + if (!pathExists(rootFoldersPath)) { + return folders; + } + + const entries = listDir(rootFoldersPath); + for (const entry of entries) { + const entryPath = join(rootFoldersPath, entry); + if (!isDirectory(entryPath)) continue; + + const meta = readJSON(join(entryPath, FOLDER_META_FILE)); + if (!meta) continue; + + folders.push({ + ...meta, + documentCount: this._countDocuments(join(entryPath, 'documents')), + folderCount: this._countSubFolders(join(entryPath, 'sub-folders')), + }); + } + } + + return folders; + } + + async getFolder(folderId) { + const found = this._findFolderGlobally(folderId); + if (!found) { + throw new NotFoundError('Folder'); + } + + const meta = readJSON(found.metaPath); + const folderPath = found.folderPath; + const docsPath = join(folderPath, 'documents'); + const subFoldersPath = join(folderPath, 'sub-folders'); + + // List documents + const documents = this._listDocumentsAtPath(docsPath); + + // List sub-folders + const subFolders = this._listFoldersAtPath(subFoldersPath); + + return { + ...meta, + documents, + subFolders, + }; + } + + async updateFolder(folderId, { name }) { + const found = this._findFolderGlobally(folderId); + if (!found) { + throw new NotFoundError('Folder'); + } + + const meta = readJSON(found.metaPath); + const now = new Date().toISOString(); + + if (name !== undefined) { + if (!name || !name.trim()) { + throw new ValidationError('Folder name cannot be empty'); + } + meta.name = name.trim(); + } + meta.updatedAt = now; + + writeJSON(found.metaPath, meta); + return meta; + } + + async deleteFolder(folderId) { + const found = this._findFolderGlobally(folderId); + if (!found) { + throw new NotFoundError('Folder'); + } + + const folderPath = found.folderPath; + deletePath(folderPath); + + return { deleted: true, id: folderId }; + } + + async getFolderDocuments(folderId) { + const found = this._findFolderGlobally(folderId); + if (!found) { + throw new NotFoundError('Folder'); + } + + const docsPath = join(found.folderPath, 'documents'); + const documents = this._listDocumentsAtPath(docsPath); + + return { documents, total: documents.length }; + } + + async getFolderTree(folderId) { + const found = this._findFolderGlobally(folderId); + if (!found) { + throw new NotFoundError('Folder'); + } + + const meta = readJSON(found.metaPath); + const folderPath = found.folderPath; + const docsPath = join(folderPath, 'documents'); + const subFoldersPath = join(folderPath, 'sub-folders'); + + const buildTree = (currentPath) => { + const documents = this._listDocumentsAtPath(join(currentPath, 'documents')); + const subFolders = []; + const currentSubFoldersPath = join(currentPath, 'sub-folders'); + + if (pathExists(currentSubFoldersPath)) { + const entries = listDir(currentSubFoldersPath); + for (const entry of entries) { + const entryPath = join(currentSubFoldersPath, entry); + if (!isDirectory(entryPath)) continue; + + const entryMeta = readJSON(join(entryPath, FOLDER_META_FILE)); + if (!entryMeta) continue; + + subFolders.push({ + id: entryMeta.id, + name: entryMeta.name, + ...buildTree(entryPath), + }); + } + } + + return { + documents, + folders: subFolders, + }; + }; + + return { + id: meta.id, + name: meta.name, + ...buildTree(folderPath), + }; + } + + _listDocumentsAtPath(docsPath) { + const documents = []; + if (!pathExists(docsPath)) return documents; + + const docEntries = listDir(docsPath); + for (const docEntry of docEntries) { + const docMetaPath = join(docsPath, docEntry, '.meta.json'); + if (pathExists(docMetaPath)) { + const docMeta = readJSON(docMetaPath); + if (docMeta?.id) { + documents.push({ + id: docMeta.id, + title: docMeta.title, + type: docMeta.type, + status: docMeta.status, + tags: docMeta.tags || [], + updatedAt: docMeta.updatedAt, + }); + } + } + } + return documents; + } + + _countDocuments(docsPath) { + if (!pathExists(docsPath)) return 0; + const entries = listDir(docsPath); + return entries.filter(e => { + const metaPath = join(docsPath, e, '.meta.json'); + return pathExists(metaPath); + }).length; + } + + _countSubFolders(subFoldersPath) { + if (!pathExists(subFoldersPath)) return 0; + const entries = listDir(subFoldersPath); + return entries.filter(e => { + const metaPath = join(subFoldersPath, e, FOLDER_META_FILE); + return pathExists(metaPath); + }).length; + } + + _listFoldersAtPath(foldersPath) { + const folders = []; + if (!pathExists(foldersPath)) return folders; + + const entries = listDir(foldersPath); + for (const entry of entries) { + const entryPath = join(foldersPath, entry); + if (!isDirectory(entryPath)) continue; + + const meta = readJSON(join(entryPath, FOLDER_META_FILE)); + if (!meta) continue; + + folders.push({ + id: meta.id, + name: meta.name, + parentId: meta.parentId, + documentCount: this._countDocuments(join(entryPath, 'documents')), + folderCount: this._countSubFolders(join(entryPath, 'sub-folders')), + }); + } + return folders; + } +} + +let globalFolderService = null; + +export function getFolderService(dataRoot = config.dataRoot) { + if (!globalFolderService) { + globalFolderService = new FolderService(dataRoot); + } + return globalFolderService; +} + +export default FolderService; diff --git a/src/services/projectService.js b/src/services/projectService.js new file mode 100644 index 0000000..efb1a17 --- /dev/null +++ b/src/services/projectService.js @@ -0,0 +1,289 @@ +/** + * SimpleNote Web - Project Service + * Project CRUD with filesystem storage + */ + +import { join } from 'path'; +import config from '../config/index.js'; +import { ensureDir, readJSON, writeJSON, pathExists, deletePath, listDir, isDirectory } from '../utils/fsHelper.js'; +import { generateId } from '../utils/uuid.js'; +import { NotFoundError, ValidationError } from '../utils/errors.js'; + +const PROJECTS_DIR = 'projects'; +const PROJECT_META_FILE = '.project.json'; +const FOLDERS_DIR = 'folders'; + +export class ProjectService { + constructor(dataRoot = config.dataRoot) { + this.dataRoot = dataRoot; + this.projectsPath = join(dataRoot, PROJECTS_DIR); + } + + _projectPath(projectId) { + return join(this.projectsPath, projectId); + } + + _projectMetaPath(projectId) { + return join(this._projectPath(projectId), PROJECT_META_FILE); + } + + _projectDocumentsPath(projectId) { + return join(this._projectPath(projectId), 'documents'); + } + + _projectFoldersPath(projectId) { + return join(this._projectPath(projectId), FOLDERS_DIR); + } + + _findProjectById(projectId) { + const metaPath = this._projectMetaPath(projectId); + if (pathExists(metaPath)) { + return { id: projectId, metaPath }; + } + return null; + } + + async createProject({ name, description = '' }) { + if (!name || !name.trim()) { + throw new ValidationError('Project name is required'); + } + + const projectId = generateId(); + const now = new Date().toISOString(); + + ensureDir(this.projectsPath); + + const projectMeta = { + id: projectId, + name: name.trim(), + description: description.trim ? description.trim() : description, + path: `${PROJECTS_DIR}/${projectId}`, + createdAt: now, + updatedAt: now, + }; + + const projectPath = this._projectPath(projectId); + ensureDir(projectPath); + ensureDir(join(projectPath, 'documents')); + ensureDir(join(projectPath, FOLDERS_DIR)); + writeJSON(this._projectMetaPath(projectId), projectMeta); + + return projectMeta; + } + + async getProjects() { + ensureDir(this.projectsPath); + + const entries = listDir(this.projectsPath); + const projects = []; + + for (const entry of entries) { + const projectPath = join(this.projectsPath, entry); + if (!isDirectory(projectPath)) continue; + + const meta = readJSON(this._projectMetaPath(entry)); + if (!meta) continue; + + const docCount = this._countDocuments(this._projectDocumentsPath(entry)); + const folderCount = this._countFolders(this._projectFoldersPath(entry)); + projects.push({ + ...meta, + documentCount: docCount, + folderCount, + }); + } + + return projects; + } + + async getProject(projectId) { + const found = this._findProjectById(projectId); + if (!found) { + throw new NotFoundError('Project'); + } + + const meta = readJSON(this._projectMetaPath(projectId)); + const docsPath = this._projectDocumentsPath(projectId); + const foldersPath = this._projectFoldersPath(projectId); + + // List documents at project root + const documents = this._listDocumentsAtPath(docsPath); + + // List root folders + const folders = this._listFoldersAtPath(foldersPath); + + return { + ...meta, + documents, + folders, + }; + } + + async updateProject(projectId, { name, description }) { + const found = this._findProjectById(projectId); + if (!found) { + throw new NotFoundError('Project'); + } + + const meta = readJSON(this._projectMetaPath(projectId)); + const now = new Date().toISOString(); + + if (name !== undefined) { + if (!name || !name.trim()) { + throw new ValidationError('Project name cannot be empty'); + } + meta.name = name.trim(); + } + if (description !== undefined) { + meta.description = description.trim ? description.trim() : description; + } + meta.updatedAt = now; + + writeJSON(this._projectMetaPath(projectId), meta); + return meta; + } + + async deleteProject(projectId) { + const found = this._findProjectById(projectId); + if (!found) { + throw new NotFoundError('Project'); + } + + const projectPath = this._projectPath(projectId); + deletePath(projectPath); + + return { deleted: true, id: projectId }; + } + + async getProjectTree(projectId) { + const found = this._findProjectById(projectId); + if (!found) { + throw new NotFoundError('Project'); + } + + const meta = readJSON(this._projectMetaPath(projectId)); + const docsPath = this._projectDocumentsPath(projectId); + const foldersPath = this._projectFoldersPath(projectId); + + const buildTree = (folderId = null, folderDocsPath, folderSubFoldersPath) => { + const documents = this._listDocumentsAtPath(folderDocsPath); + const subFolders = []; + + if (pathExists(folderSubFoldersPath)) { + const folderEntries = listDir(folderSubFoldersPath); + for (const folderEntry of folderEntries) { + const folderEntryPath = join(folderSubFoldersPath, folderEntry); + if (!isDirectory(folderEntryPath)) continue; + + const folderMeta = readJSON(join(folderEntryPath, '.folder.json')); + if (!folderMeta) continue; + + subFolders.push(buildTree( + folderMeta.id, + join(folderEntryPath, 'documents'), + join(folderEntryPath, 'sub-folders') + )); + } + } + + return { + id: folderId, + name: folderId ? null : meta.name, + documents, + folders: subFolders, + }; + }; + + return buildTree(null, docsPath, foldersPath); + } + + async getProjectDocuments(projectId) { + const found = this._findProjectById(projectId); + if (!found) { + throw new NotFoundError('Project'); + } + + const docsPath = this._projectDocumentsPath(projectId); + const documents = this._listDocumentsAtPath(docsPath); + + return { documents, total: documents.length }; + } + + _listDocumentsAtPath(docsPath) { + const documents = []; + if (!pathExists(docsPath)) return documents; + + const docEntries = listDir(docsPath); + for (const docEntry of docEntries) { + const docMetaPath = join(docsPath, docEntry, '.meta.json'); + if (pathExists(docMetaPath)) { + const docMeta = readJSON(docMetaPath); + if (docMeta?.id) { + documents.push({ + id: docMeta.id, + title: docMeta.title, + type: docMeta.type, + status: docMeta.status, + tags: docMeta.tags || [], + updatedAt: docMeta.updatedAt, + }); + } + } + } + return documents; + } + + _countDocuments(docsPath) { + if (!pathExists(docsPath)) return 0; + const entries = listDir(docsPath); + return entries.filter(e => { + const metaPath = join(docsPath, e, '.meta.json'); + return pathExists(metaPath); + }).length; + } + + _countFolders(foldersPath) { + if (!pathExists(foldersPath)) return 0; + const entries = listDir(foldersPath); + return entries.filter(e => { + const metaPath = join(foldersPath, e, '.folder.json'); + return pathExists(metaPath); + }).length; + } + + _listFoldersAtPath(foldersPath) { + const folders = []; + if (!pathExists(foldersPath)) return folders; + + const folderEntries = listDir(foldersPath); + for (const folderEntry of folderEntries) { + const folderEntryPath = join(foldersPath, folderEntry); + if (!isDirectory(folderEntryPath)) continue; + + const folderMeta = readJSON(join(folderEntryPath, '.folder.json')); + if (!folderMeta) continue; + + const docCount = this._countDocuments(join(folderEntryPath, 'documents')); + const subFolderCount = this._countFolders(join(folderEntryPath, 'sub-folders')); + folders.push({ + id: folderMeta.id, + name: folderMeta.name, + parentId: folderMeta.parentId, + documentCount: docCount, + folderCount: subFolderCount, + }); + } + return folders; + } +} + +let globalProjectService = null; + +export function getProjectService(dataRoot = config.dataRoot) { + if (!globalProjectService) { + globalProjectService = new ProjectService(dataRoot); + } + return globalProjectService; +} + +export default ProjectService;