/** * SimpleNote Web - Document Service * Document CRUD with markdown storage * Supports both legacy libraries/ and new projects/ structure */ import { join } from 'path'; import { existsSync, readFileSync, writeFileSync } from 'fs'; 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 { parseMarkdown, serializeMarkdown, buildDefaultContent } from '../utils/markdown.js'; 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); } _docIndexPath(libId, docId) { return join(this._docPath(libId, docId), 'index.md'); } _docMetaPath(libId, docId) { 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), 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) { // 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); for (const libEntry of libEntries) { const libPath = join(this.librariesPath, libEntry); if (!isDirectory(libPath)) continue; // Direct doc const found = this._findDocInLibrary(libEntry, docId); if (found) return found; // Sub-libraries (recursive) const subLibsPath = join(libPath, 'sub-libraries'); if (pathExists(subLibsPath)) { const foundSub = this._findInSubLibs(subLibsPath, docId, libEntry); if (foundSub) return foundSub; } } return null; } _findDocInProjectFolders(foldersPath, projectId, docId) { if (!pathExists(foldersPath)) return null; const folderEntries = listDir(foldersPath); for (const folderEntry of folderEntries) { const folderPath = join(foldersPath, folderEntry); if (!isDirectory(folderPath)) continue; // 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; } _readDocRaw(docId) { const found = this._findDocById(docId); if (!found) return null; const meta = readJSON(found.metaPath); let content = ''; if (pathExists(found.indexPath)) { content = readFileSync(found.indexPath, 'utf-8'); } return { meta, content, found }; } // ===== 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'); } // 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'); } const docId = generateId(); const now = new Date().toISOString(); 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, title: title.trim(), tags: tags.filter(t => t), type, priority, status, // New fields projectId: effectiveProjectId, folderId: effectiveFolderId, // Legacy field (backwards compat - same as projectId) libraryId: effectiveLibraryId, createdBy, createdAt: now, updatedAt: now, }; const body = content || buildDefaultContent(title, type); const markdown = serializeMarkdown(metadata, body); writeJSON(metaPath, metadata); writeFileSync(indexPath, markdown, 'utf-8'); // Update tag index this.tagIndexer.addDocument(docId, metadata.tags); return { ...metadata, path: this._getDocPathForResponse(metadata), content: body, }; } _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 from projects structure (new) if (pathExists(this.projectsPath)) { await this._collectDocsFromProjects(allDocs); } // 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 || d.projectId === library); } // Filter by tag if (tag) { const docIds = this.tagIndexer.getDocIdsForTag(tag); allDocs = allDocs.filter(d => docIds.includes(d.id)); } // Filter by type if (type) { allDocs = allDocs.filter(d => d.type === type); } // Filter by status if (status) { allDocs = allDocs.filter(d => d.status === status); } const total = allDocs.length; const paginated = allDocs.slice(offset, offset + limit); // Enrich with content for each doc const enriched = paginated.map(doc => { const { meta } = this._readDocRaw(doc.id) || { meta: doc }; 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; const entries = listDir(path); for (const entry of entries) { const entryPath = join(path, entry); const metaPath = join(entryPath, '.meta.json'); 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)) { // Could be a library or documents dir const docsDir = join(entryPath, 'documents'); const subLibsDir = join(entryPath, 'sub-libraries'); if (pathExists(docsDir)) { await this._collectDocs(docsDir, results); } if (pathExists(subLibsDir)) { await this._collectDocs(subLibsDir, results); } } } } async getDocument(docId) { const found = this._findDocById(docId); if (!found) { throw new NotFoundError('Document'); } const meta = readJSON(found.metaPath); const indexPath = found.indexPath; const content = pathExists(indexPath) ? readFileSync(indexPath, 'utf-8') : ''; const { body } = parseMarkdown(content); return { ...meta, content: body, path: this._getDocPathForResponse(meta), }; } async updateDocument(docId, { title, content, tags, type, priority, status, folderId }) { const found = this._findDocById(docId); if (!found) { throw new NotFoundError('Document'); } const meta = readJSON(found.metaPath); const oldTags = [...(meta.tags || [])]; const now = new Date().toISOString(); if (title !== undefined) meta.title = title.trim(); if (type !== undefined) meta.type = type; if (priority !== undefined) meta.priority = priority; if (status !== undefined) meta.status = status; if (tags !== undefined) meta.tags = tags.filter(t => t); if (folderId !== undefined) meta.folderId = folderId; meta.updatedAt = now; // Rewrite markdown file const currentContent = pathExists(found.indexPath) ? readFileSync(found.indexPath, 'utf-8') : ''; const { body: existingBody } = parseMarkdown(currentContent); const newBody = content !== undefined ? content : existingBody; const markdown = serializeMarkdown(meta, newBody); writeJSON(found.metaPath, meta); writeFileSync(found.indexPath, markdown, 'utf-8'); // Update tag index if tags changed if (tags !== undefined) { this.tagIndexer.updateDocumentTags(docId, oldTags, meta.tags); } return { ...meta, content: newBody }; } async deleteDocument(docId) { const found = this._findDocById(docId); if (!found) { throw new NotFoundError('Document'); } const meta = readJSON(found.metaPath); // 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); return { deleted: true, id: docId }; } async exportDocument(docId) { const found = this._findDocById(docId); if (!found) { throw new NotFoundError('Document'); } const meta = readJSON(found.metaPath); const content = pathExists(found.indexPath) ? readFileSync(found.indexPath, 'utf-8') : ''; return { id: docId, markdown: content, }; } async addTagsToDocument(docId, tags) { const found = this._findDocById(docId); if (!found) { throw new NotFoundError('Document'); } const meta = readJSON(found.metaPath); const oldTags = [...(meta.tags || [])]; const newTags = [...new Set([...oldTags, ...tags.filter(t => t)])]; meta.tags = newTags; meta.updatedAt = new Date().toISOString(); writeJSON(found.metaPath, meta); // Update tag index this.tagIndexer.updateDocumentTags(docId, oldTags, newTags); return meta; } } let globalDocumentService = null; export function getDocumentService(dataRoot = config.dataRoot) { if (!globalDocumentService) { globalDocumentService = new DocumentService(dataRoot); } return globalDocumentService; } export default DocumentService;