Implement SimpleNote Web API - full REST API with Express
- Express server with CORS, JSON middleware - Auth middleware (Bearer token) - Document CRUD with markdown storage - Library CRUD with nested support - Tag indexing and search - Error handler middleware - Config from env vars - Init script for data structure
This commit is contained in:
332
src/services/documentService.js
Normal file
332
src/services/documentService.js
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* SimpleNote Web - Document Service
|
||||
* Document CRUD with markdown storage
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
const LIBRARIES_DIR = 'libraries';
|
||||
|
||||
export class DocumentService {
|
||||
constructor(dataRoot = config.dataRoot) {
|
||||
this.dataRoot = dataRoot;
|
||||
this.librariesPath = join(dataRoot, LIBRARIES_DIR);
|
||||
this.tagIndexer = getTagIndexer(dataRoot);
|
||||
}
|
||||
|
||||
_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');
|
||||
}
|
||||
|
||||
_findDocInLibrary(libId, docId) {
|
||||
const metaPath = this._docMetaPath(libId, docId);
|
||||
if (pathExists(metaPath)) {
|
||||
return { docId, libId, metaPath, indexPath: this._docIndexPath(libId, docId) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_findDocById(docId) {
|
||||
// Search all libraries for this document
|
||||
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;
|
||||
}
|
||||
|
||||
_findInSubLibs(subLibsPath, docId, parentLibId) {
|
||||
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') };
|
||||
}
|
||||
|
||||
// Recurse into sub-sub-libraries
|
||||
const subSubLibsPath = join(entryPath, 'sub-libraries');
|
||||
const found = this._findInSubLibs(subSubLibsPath, docId, entry);
|
||||
if (found) return found;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
async createDocument({ title, libraryId, 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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
const metadata = {
|
||||
id: docId,
|
||||
title: title.trim(),
|
||||
tags: tags.filter(t => t),
|
||||
type,
|
||||
priority,
|
||||
status,
|
||||
libraryId,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
// Update tag index
|
||||
this.tagIndexer.addDocument(docId, metadata.tags);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
path: `/${LIBRARIES_DIR}/${libraryId}/documents/${docId}/index.md`,
|
||||
content: body,
|
||||
};
|
||||
}
|
||||
|
||||
async listDocuments({ tag, library, type, status, limit = 50, offset = 0 } = {}) {
|
||||
let allDocs = [];
|
||||
|
||||
// Collect all documents
|
||||
await this._collectDocs(this.librariesPath, allDocs);
|
||||
|
||||
// Filter by library
|
||||
if (library) {
|
||||
allDocs = allDocs.filter(d => d.libraryId === 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 };
|
||||
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 { documents: enriched, total, limit, offset };
|
||||
}
|
||||
|
||||
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) {
|
||||
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: `/${LIBRARIES_DIR}/${found.libId}/documents/${docId}/index.md`,
|
||||
};
|
||||
}
|
||||
|
||||
async updateDocument(docId, { title, content, tags, type, priority, status }) {
|
||||
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);
|
||||
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);
|
||||
deletePath(found.found?.libId ? join(this.librariesPath, found.libId, 'documents', docId) : null);
|
||||
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;
|
||||
54
src/services/tagService.js
Normal file
54
src/services/tagService.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* SimpleNote Web - Tag Service
|
||||
* Tag-based search operations
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import { getTagIndexer } from '../indexers/tagIndexer.js';
|
||||
import { NotFoundError } from '../utils/errors.js';
|
||||
import { getDocumentService } from './documentService.js';
|
||||
|
||||
export class TagService {
|
||||
constructor(dataRoot = config.dataRoot) {
|
||||
this.dataRoot = dataRoot;
|
||||
this.tagIndexer = getTagIndexer(dataRoot);
|
||||
this.docService = getDocumentService(dataRoot);
|
||||
}
|
||||
|
||||
async listTags() {
|
||||
const tags = this.tagIndexer.getAllTags();
|
||||
return { tags, total: tags.length };
|
||||
}
|
||||
|
||||
async getTagDocuments(tagName) {
|
||||
const docIds = this.tagIndexer.getDocIdsForTag(tagName);
|
||||
|
||||
if (docIds.length === 0 && !this.tagIndexer.tagExists(tagName)) {
|
||||
throw new NotFoundError(`Tag '${tagName}'`);
|
||||
}
|
||||
|
||||
const documents = [];
|
||||
for (const docId of docIds) {
|
||||
try {
|
||||
const doc = await this.docService.getDocument(docId);
|
||||
documents.push(doc);
|
||||
} catch (err) {
|
||||
// Doc may have been deleted, skip
|
||||
if (err.name !== 'NotFoundError') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return { tag: tagName, documents, count: documents.length };
|
||||
}
|
||||
}
|
||||
|
||||
let globalTagService = null;
|
||||
|
||||
export function getTagService(dataRoot = config.dataRoot) {
|
||||
if (!globalTagService) {
|
||||
globalTagService = new TagService(dataRoot);
|
||||
}
|
||||
return globalTagService;
|
||||
}
|
||||
|
||||
export default TagService;
|
||||
Reference in New Issue
Block a user