- Add folderId parameter to updateDocument service method - Extract folderId from request body in PUT /documents/:id route - Fixes move document to folder functionality
575 lines
18 KiB
JavaScript
575 lines
18 KiB
JavaScript
/**
|
|
* 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;
|