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:
@@ -14,11 +14,13 @@ router.use(authMiddleware);
|
|||||||
// GET /documents - List documents
|
// GET /documents - List documents
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
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 docService = getDocumentService();
|
||||||
const result = await docService.listDocuments({
|
const result = await docService.listDocuments({
|
||||||
tag,
|
tag,
|
||||||
library,
|
library,
|
||||||
|
project,
|
||||||
|
folder,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
limit: limit ? parseInt(limit, 10) : 50,
|
limit: limit ? parseInt(limit, 10) : 50,
|
||||||
@@ -34,11 +36,13 @@ router.get('/', async (req, res) => {
|
|||||||
// POST /documents - Create document
|
// POST /documents - Create document
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
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 docService = getDocumentService();
|
||||||
const doc = await docService.createDocument({
|
const doc = await docService.createDocument({
|
||||||
title,
|
title,
|
||||||
libraryId,
|
libraryId,
|
||||||
|
projectId,
|
||||||
|
folderId,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags,
|
||||||
type,
|
type,
|
||||||
|
|||||||
138
src/routes/folders.js
Normal file
138
src/routes/folders.js
Normal file
@@ -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;
|
||||||
@@ -8,6 +8,8 @@ import documentsRouter from './documents.js';
|
|||||||
import librariesRouter from './libraries.js';
|
import librariesRouter from './libraries.js';
|
||||||
import tagsRouter from './tags.js';
|
import tagsRouter from './tags.js';
|
||||||
import authRouter from './auth.js';
|
import authRouter from './auth.js';
|
||||||
|
import projectsRouter from './projects.js';
|
||||||
|
import foldersRouter from './folders.js';
|
||||||
|
|
||||||
export function createApiRouter(apiPrefix = '/api/v1') {
|
export function createApiRouter(apiPrefix = '/api/v1') {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -16,6 +18,8 @@ export function createApiRouter(apiPrefix = '/api/v1') {
|
|||||||
router.use('/libraries', librariesRouter);
|
router.use('/libraries', librariesRouter);
|
||||||
router.use('/tags', tagsRouter);
|
router.use('/tags', tagsRouter);
|
||||||
router.use('/auth', authRouter);
|
router.use('/auth', authRouter);
|
||||||
|
router.use('/projects', projectsRouter);
|
||||||
|
router.use('/folders', foldersRouter);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/routes/projects.js
Normal file
125
src/routes/projects.js
Normal file
@@ -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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SimpleNote Web - Document Service
|
* SimpleNote Web - Document Service
|
||||||
* Document CRUD with markdown storage
|
* Document CRUD with markdown storage
|
||||||
|
* Supports both legacy libraries/ and new projects/ structure
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
@@ -12,16 +13,23 @@ import { parseMarkdown, serializeMarkdown, buildDefaultContent } from '../utils/
|
|||||||
import { NotFoundError, ValidationError } from '../utils/errors.js';
|
import { NotFoundError, ValidationError } from '../utils/errors.js';
|
||||||
import { getTagIndexer } from '../indexers/tagIndexer.js';
|
import { getTagIndexer } from '../indexers/tagIndexer.js';
|
||||||
import { getLibraryService } from './libraryService.js';
|
import { getLibraryService } from './libraryService.js';
|
||||||
|
import { getProjectService } from './projectService.js';
|
||||||
|
import { getFolderService } from './folderService.js';
|
||||||
|
|
||||||
const LIBRARIES_DIR = 'libraries';
|
const LIBRARIES_DIR = 'libraries';
|
||||||
|
const PROJECTS_DIR = 'projects';
|
||||||
|
const FOLDERS_DIR = 'folders';
|
||||||
|
|
||||||
export class DocumentService {
|
export class DocumentService {
|
||||||
constructor(dataRoot = config.dataRoot) {
|
constructor(dataRoot = config.dataRoot) {
|
||||||
this.dataRoot = dataRoot;
|
this.dataRoot = dataRoot;
|
||||||
this.librariesPath = join(dataRoot, LIBRARIES_DIR);
|
this.librariesPath = join(dataRoot, LIBRARIES_DIR);
|
||||||
|
this.projectsPath = join(dataRoot, PROJECTS_DIR);
|
||||||
this.tagIndexer = getTagIndexer(dataRoot);
|
this.tagIndexer = getTagIndexer(dataRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Legacy Libraries Paths =====
|
||||||
|
|
||||||
_docPath(libId, docId) {
|
_docPath(libId, docId) {
|
||||||
return join(this.librariesPath, libId, 'documents', docId);
|
return join(this.librariesPath, libId, 'documents', docId);
|
||||||
}
|
}
|
||||||
@@ -34,16 +42,102 @@ export class DocumentService {
|
|||||||
return join(this._docPath(libId, docId), '.meta.json');
|
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) {
|
_findDocInLibrary(libId, docId) {
|
||||||
const metaPath = this._docMetaPath(libId, docId);
|
const metaPath = this._docMetaPath(libId, docId);
|
||||||
if (pathExists(metaPath)) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_findDocById(docId) {
|
_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;
|
if (!pathExists(this.librariesPath)) return null;
|
||||||
|
|
||||||
const libEntries = listDir(this.librariesPath);
|
const libEntries = listDir(this.librariesPath);
|
||||||
@@ -65,24 +159,22 @@ export class DocumentService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_findInSubLibs(subLibsPath, docId, parentLibId) {
|
_findDocInProjectFolders(foldersPath, projectId, docId) {
|
||||||
if (!pathExists(subLibsPath)) return null;
|
if (!pathExists(foldersPath)) return null;
|
||||||
|
|
||||||
const entries = listDir(subLibsPath);
|
const folderEntries = listDir(foldersPath);
|
||||||
for (const entry of entries) {
|
for (const folderEntry of folderEntries) {
|
||||||
const entryPath = join(subLibsPath, entry);
|
const folderPath = join(foldersPath, folderEntry);
|
||||||
if (!isDirectory(entryPath)) continue;
|
if (!isDirectory(folderPath)) continue;
|
||||||
|
|
||||||
// Check if doc is here
|
// Check if doc is directly in this folder
|
||||||
const metaPath = join(entryPath, 'documents', docId, '.meta.json');
|
const found = this._findDocInFolder(projectId, folderEntry, docId);
|
||||||
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);
|
|
||||||
if (found) return found;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -100,23 +192,81 @@ export class DocumentService {
|
|||||||
return { meta, content, found };
|
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()) {
|
if (!title || !title.trim()) {
|
||||||
throw new ValidationError('Title is required');
|
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 docId = generateId();
|
||||||
const docPath = this._docPath(libraryId, docId);
|
|
||||||
const now = new Date().toISOString();
|
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 = {
|
const metadata = {
|
||||||
id: docId,
|
id: docId,
|
||||||
@@ -125,7 +275,11 @@ export class DocumentService {
|
|||||||
type,
|
type,
|
||||||
priority,
|
priority,
|
||||||
status,
|
status,
|
||||||
libraryId,
|
// New fields
|
||||||
|
projectId: effectiveProjectId,
|
||||||
|
folderId: effectiveFolderId,
|
||||||
|
// Legacy field (backwards compat - same as projectId)
|
||||||
|
libraryId: effectiveLibraryId,
|
||||||
createdBy,
|
createdBy,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -134,28 +288,55 @@ export class DocumentService {
|
|||||||
const body = content || buildDefaultContent(title, type);
|
const body = content || buildDefaultContent(title, type);
|
||||||
const markdown = serializeMarkdown(metadata, body);
|
const markdown = serializeMarkdown(metadata, body);
|
||||||
|
|
||||||
writeJSON(this._docMetaPath(libraryId, docId), metadata);
|
writeJSON(metaPath, metadata);
|
||||||
writeFileSync(this._docIndexPath(libraryId, docId), markdown, 'utf-8');
|
writeFileSync(indexPath, markdown, 'utf-8');
|
||||||
|
|
||||||
// Update tag index
|
// Update tag index
|
||||||
this.tagIndexer.addDocument(docId, metadata.tags);
|
this.tagIndexer.addDocument(docId, metadata.tags);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
path: `/${LIBRARIES_DIR}/${libraryId}/documents/${docId}/index.md`,
|
path: this._getDocPathForResponse(metadata),
|
||||||
content: body,
|
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 = [];
|
let allDocs = [];
|
||||||
|
|
||||||
// Collect all documents
|
// Collect from projects structure (new)
|
||||||
await this._collectDocs(this.librariesPath, allDocs);
|
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) {
|
if (library) {
|
||||||
allDocs = allDocs.filter(d => d.libraryId === library);
|
allDocs = allDocs.filter(d => d.libraryId === library || d.projectId === library);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by tag
|
// Filter by tag
|
||||||
@@ -180,16 +361,65 @@ export class DocumentService {
|
|||||||
// Enrich with content for each doc
|
// Enrich with content for each doc
|
||||||
const enriched = paginated.map(doc => {
|
const enriched = paginated.map(doc => {
|
||||||
const { meta } = this._readDocRaw(doc.id) || { meta: doc };
|
const { meta } = this._readDocRaw(doc.id) || { meta: doc };
|
||||||
const content = pathExists(join(this.librariesPath, doc.libraryId, 'documents', doc.id, 'index.md'))
|
return { ...doc, content: meta.content || '' };
|
||||||
? readFileSync(join(this.librariesPath, doc.libraryId, 'documents', doc.id, 'index.md'), 'utf-8')
|
|
||||||
: '';
|
|
||||||
const { body } = parseMarkdown(content);
|
|
||||||
return { ...doc, content: body };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { documents: enriched, total, limit, offset };
|
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) {
|
async _collectDocs(path, results) {
|
||||||
if (!pathExists(path)) return;
|
if (!pathExists(path)) return;
|
||||||
|
|
||||||
@@ -201,6 +431,10 @@ export class DocumentService {
|
|||||||
if (pathExists(metaPath)) {
|
if (pathExists(metaPath)) {
|
||||||
const meta = readJSON(metaPath);
|
const meta = readJSON(metaPath);
|
||||||
if (meta?.id) {
|
if (meta?.id) {
|
||||||
|
// Ensure backwards compat fields
|
||||||
|
if (!meta.projectId && meta.libraryId) {
|
||||||
|
meta.projectId = meta.libraryId;
|
||||||
|
}
|
||||||
results.push(meta);
|
results.push(meta);
|
||||||
}
|
}
|
||||||
} else if (isDirectory(entryPath)) {
|
} else if (isDirectory(entryPath)) {
|
||||||
@@ -231,7 +465,7 @@ export class DocumentService {
|
|||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
content: body,
|
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);
|
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);
|
this.tagIndexer.removeDocument(docId);
|
||||||
|
|
||||||
|
|||||||
406
src/services/folderService.js
Normal file
406
src/services/folderService.js
Normal file
@@ -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;
|
||||||
289
src/services/projectService.js
Normal file
289
src/services/projectService.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user