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
This commit is contained in:
Hiro
2026-03-28 13:07:09 +00:00
parent 9496fc8e36
commit b81e670ce4
7 changed files with 1252 additions and 44 deletions

View File

@@ -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);