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:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ============ SERVER ============
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# ============ DATA ============
|
||||||
|
DATA_ROOT=./data
|
||||||
|
|
||||||
|
# ============ AUTH ============
|
||||||
|
ADMIN_TOKEN=snk_initial_admin_token_change_me
|
||||||
|
|
||||||
|
# ============ LOGGING ============
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# ============ CORS ============
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# ============ API ============
|
||||||
|
API_PREFIX=/api/v1
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
103
README.md
103
README.md
@@ -1,3 +1,102 @@
|
|||||||
# simplenote-web
|
# SimpleNote Web
|
||||||
|
|
||||||
SimpleNote Web - Document management system with nested libraries and markdown support
|
REST API para gestión de documentos basada en archivos Markdown con soporte para librerías anidadas y tags.
|
||||||
|
|
||||||
|
## Características
|
||||||
|
|
||||||
|
- API REST completa (Express.js)
|
||||||
|
- Almacenamiento en archivos Markdown + JSON
|
||||||
|
- Soporte para librerías anidadas
|
||||||
|
- Indexación de tags
|
||||||
|
- Autenticación por tokens Bearer
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
Copia `.env.example` a `.env` y ajusta las variables:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
DATA_ROOT=./data
|
||||||
|
ADMIN_TOKEN=snk_your_initial_token
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
API_PREFIX=/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inicialización
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run init
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto crea la estructura inicial de datos y una librería "Default Library".
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producción
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- `POST /api/v1/auth/token` - Generar token (admin)
|
||||||
|
- `GET /api/v1/auth/verify` - Verificar token
|
||||||
|
|
||||||
|
### Documents
|
||||||
|
- `GET /api/v1/documents` - Listar documentos (filtros: tag, library, type, status)
|
||||||
|
- `GET /api/v1/documents/:id` - Obtener documento
|
||||||
|
- `POST /api/v1/documents` - Crear documento
|
||||||
|
- `PUT /api/v1/documents/:id` - Actualizar documento
|
||||||
|
- `DELETE /api/v1/documents/:id` - Eliminar documento
|
||||||
|
- `GET /api/v1/documents/:id/export` - Exportar como Markdown
|
||||||
|
- `POST /api/v1/documents/:id/tags` - Agregar tags
|
||||||
|
|
||||||
|
### Libraries
|
||||||
|
- `GET /api/v1/libraries` - Listar librerías raíz
|
||||||
|
- `GET /api/v1/libraries/:id` - Ver contenido de librería
|
||||||
|
- `POST /api/v1/libraries` - Crear librería
|
||||||
|
- `GET /api/v1/libraries/:id/tree` - Árbol completo
|
||||||
|
- `DELETE /api/v1/libraries/:id` - Eliminar librería
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
- `GET /api/v1/tags` - Listar todos los tags
|
||||||
|
- `GET /api/v1/tags/:tag` - Documentos con tag específico
|
||||||
|
|
||||||
|
## Estructura de Datos
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── .auth-tokens.json # Tokens de API
|
||||||
|
├── .tag-index.json # Índice global de tags
|
||||||
|
└── libraries/
|
||||||
|
└── {id}/
|
||||||
|
├── .library.json
|
||||||
|
├── documents/
|
||||||
|
│ └── {doc-id}/
|
||||||
|
│ ├── index.md
|
||||||
|
│ └── .meta.json
|
||||||
|
└── sub-libraries/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
1387
package-lock.json
generated
Normal file
1387
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -6,9 +6,21 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "node --watch src/index.js",
|
||||||
|
"init": "node scripts/init-data.js"
|
||||||
},
|
},
|
||||||
"keywords": ["documents", "markdown", "api"],
|
"keywords": ["documents", "markdown", "api"],
|
||||||
"author": "OpenClaw Team",
|
"author": "OpenClaw Team",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
scripts/init-data.js
Normal file
90
scripts/init-data.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Init Script
|
||||||
|
* Creates initial data structure and default library
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join, resolve } from 'path';
|
||||||
|
import { ensureDir, writeJSON, pathExists } from '../src/utils/fsHelper.js';
|
||||||
|
import { generateId } from '../src/utils/uuid.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const projectRoot = resolve(__dirname, '..');
|
||||||
|
const dataRoot = resolve(projectRoot, process.env.DATA_ROOT || './data');
|
||||||
|
|
||||||
|
console.log(`[Init] Initializing data at: ${dataRoot}`);
|
||||||
|
|
||||||
|
const DATA_ROOT = dataRoot;
|
||||||
|
const LIBRARIES_DIR = join(DATA_ROOT, 'libraries');
|
||||||
|
const TOKENS_FILE = join(DATA_ROOT, '.auth-tokens.json');
|
||||||
|
const TAG_INDEX_FILE = join(DATA_ROOT, '.tag-index.json');
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Create directories
|
||||||
|
ensureDir(DATA_ROOT);
|
||||||
|
ensureDir(LIBRARIES_DIR);
|
||||||
|
|
||||||
|
// Init auth tokens
|
||||||
|
if (!pathExists(TOKENS_FILE)) {
|
||||||
|
const adminToken = process.env.ADMIN_TOKEN || 'snk_initial_admin_token_change_me';
|
||||||
|
writeJSON(TOKENS_FILE, {
|
||||||
|
version: 1,
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
token: adminToken,
|
||||||
|
label: 'initial-admin',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log(`[Init] Created .auth-tokens.json with admin token: ${adminToken}`);
|
||||||
|
} else {
|
||||||
|
console.log('[Init] .auth-tokens.json already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init tag index
|
||||||
|
if (!pathExists(TAG_INDEX_FILE)) {
|
||||||
|
writeJSON(TAG_INDEX_FILE, {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
tags: {},
|
||||||
|
});
|
||||||
|
console.log('[Init] Created .tag-index.json');
|
||||||
|
} else {
|
||||||
|
console.log('[Init] .tag-index.json already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default library
|
||||||
|
const defaultLibPath = join(LIBRARIES_DIR, 'default');
|
||||||
|
const defaultLibMeta = join(defaultLibPath, '.library.json');
|
||||||
|
|
||||||
|
if (!pathExists(defaultLibMeta)) {
|
||||||
|
const libId = generateId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
ensureDir(join(defaultLibPath, 'documents'));
|
||||||
|
ensureDir(join(defaultLibPath, 'sub-libraries'));
|
||||||
|
|
||||||
|
writeJSON(defaultLibMeta, {
|
||||||
|
id: libId,
|
||||||
|
name: 'Default Library',
|
||||||
|
parentId: null,
|
||||||
|
path: `libraries/${libId}`,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
console.log(`[Init] Created default library: ${libId}`);
|
||||||
|
} else {
|
||||||
|
console.log('[Init] Default library already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Init] Done!');
|
||||||
|
}
|
||||||
|
|
||||||
|
init().catch(err => {
|
||||||
|
console.error('[Init] Error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
27
src/config/index.js
Normal file
27
src/config/index.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Configuration
|
||||||
|
* Environment variables loader with defaults
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join, resolve } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const projectRoot = resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
host: process.env.HOST || '0.0.0.0',
|
||||||
|
dataRoot: resolve(projectRoot, process.env.DATA_ROOT || './data'),
|
||||||
|
adminToken: process.env.ADMIN_TOKEN || 'snk_initial_admin_token_change_me',
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||||
|
apiPrefix: process.env.API_PREFIX || '/api/v1',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
41
src/index.js
41
src/index.js
@@ -4,33 +4,42 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { fileURLToPath } from 'url';
|
import cors from 'cors';
|
||||||
import { dirname, join } from 'path';
|
import config from './config/index.js';
|
||||||
|
import { createApiRouter } from './routes/index.js';
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import { errorHandler } from './middleware/errorHandler.js';
|
||||||
const __dirname = dirname(__filename);
|
import { initTagIndexer } from './indexers/tagIndexer.js';
|
||||||
|
import { ensureDir } from './utils/fsHelper.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(cors({ origin: config.corsOrigin }));
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Health check
|
// Health check (unprotected)
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString(), version: '1.0.0' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Routes
|
// Ensure data directory exists
|
||||||
// - /api/documents
|
ensureDir(config.dataRoot);
|
||||||
// - /api/libraries
|
|
||||||
// - /api/tags
|
// Initialize tag indexer
|
||||||
// - /api/auth
|
console.log(`[SimpleNote] Data root: ${config.dataRoot}`);
|
||||||
|
initTagIndexer(config.dataRoot);
|
||||||
|
|
||||||
|
// Mount API routes
|
||||||
|
app.use(config.apiPrefix, createApiRouter(config.apiPrefix));
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(config.port, config.host, () => {
|
||||||
console.log(`SimpleNote Web running on port ${PORT}`);
|
console.log(`[SimpleNote] Web API running on http://${config.host}:${config.port}`);
|
||||||
|
console.log(`[SimpleNote] API prefix: ${config.apiPrefix}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
173
src/indexers/tagIndexer.js
Normal file
173
src/indexers/tagIndexer.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Tag Indexer
|
||||||
|
* Rebuild and query the global tag index
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readJSON, writeJSON, pathExists, listDir, readJSONFile } from '../utils/fsHelper.js';
|
||||||
|
import { join } from 'path';
|
||||||
|
import config from '../config/index.js';
|
||||||
|
|
||||||
|
const TAG_INDEX_FILE = '.tag-index.json';
|
||||||
|
|
||||||
|
export class TagIndexer {
|
||||||
|
constructor(dataRoot) {
|
||||||
|
this.dataRoot = dataRoot;
|
||||||
|
this.tagIndexPath = join(dataRoot, TAG_INDEX_FILE);
|
||||||
|
this.index = this._loadIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadIndex() {
|
||||||
|
if (!pathExists(this.tagIndexPath)) {
|
||||||
|
return { version: 1, updatedAt: new Date().toISOString(), tags: {} };
|
||||||
|
}
|
||||||
|
return readJSON(this.tagIndexPath) || { version: 1, updatedAt: new Date().toISOString(), tags: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveIndex() {
|
||||||
|
this.index.updatedAt = new Date().toISOString();
|
||||||
|
writeJSON(this.tagIndexPath, this.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getDocIdsInLibrary(libPath) {
|
||||||
|
const docsPath = join(libPath, 'documents');
|
||||||
|
if (!pathExists(docsPath)) return [];
|
||||||
|
|
||||||
|
const docIds = [];
|
||||||
|
const entries = listDir(docsPath);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const metaPath = join(docsPath, entry, '.meta.json');
|
||||||
|
if (pathExists(metaPath)) {
|
||||||
|
const meta = readJSON(metaPath);
|
||||||
|
if (meta?.id) docIds.push(meta.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return docIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild() {
|
||||||
|
this.index = { version: 1, updatedAt: new Date().toISOString(), tags: {} };
|
||||||
|
const libsPath = join(this.dataRoot, 'libraries');
|
||||||
|
|
||||||
|
if (!pathExists(libsPath)) {
|
||||||
|
this._saveIndex();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _scanLibrary = (libPath) => {
|
||||||
|
const docIds = this._getDocIdsInLibrary(libPath);
|
||||||
|
|
||||||
|
for (const docId of docIds) {
|
||||||
|
const docsPath = join(libPath, 'documents', docId);
|
||||||
|
const metaPath = join(docsPath, '.meta.json');
|
||||||
|
if (!pathExists(metaPath)) continue;
|
||||||
|
|
||||||
|
const meta = readJSON(metaPath);
|
||||||
|
if (!meta?.tags?.length) continue;
|
||||||
|
|
||||||
|
for (const tag of meta.tags) {
|
||||||
|
if (!this.index.tags[tag]) {
|
||||||
|
this.index.tags[tag] = [];
|
||||||
|
}
|
||||||
|
if (!this.index.tags[tag].includes(docId)) {
|
||||||
|
this.index.tags[tag].push(docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan sub-libraries
|
||||||
|
const subLibsPath = join(libPath, 'sub-libraries');
|
||||||
|
if (pathExists(subLibsPath)) {
|
||||||
|
const subEntries = listDir(subLibsPath);
|
||||||
|
for (const subEntry of subEntries) {
|
||||||
|
_scanLibrary(join(subLibsPath, subEntry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const libEntries = listDir(libsPath);
|
||||||
|
for (const entry of libEntries) {
|
||||||
|
const libMetaPath = join(libsPath, entry, '.library.json');
|
||||||
|
if (pathExists(libMetaPath)) {
|
||||||
|
_scanLibrary(join(libsPath, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._saveIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
addDocument(docId, tags = []) {
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!this.index.tags[tag]) {
|
||||||
|
this.index.tags[tag] = [];
|
||||||
|
}
|
||||||
|
if (!this.index.tags[tag].includes(docId)) {
|
||||||
|
this.index.tags[tag].push(docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._saveIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDocument(docId) {
|
||||||
|
for (const tag of Object.keys(this.index.tags)) {
|
||||||
|
this.index.tags[tag] = this.index.tags[tag].filter(id => id !== docId);
|
||||||
|
if (this.index.tags[tag].length === 0) {
|
||||||
|
delete this.index.tags[tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._saveIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDocumentTags(docId, oldTags = [], newTags = []) {
|
||||||
|
// Remove from old tags
|
||||||
|
for (const tag of oldTags) {
|
||||||
|
if (!newTags.includes(tag)) {
|
||||||
|
this.index.tags[tag] = this.index.tags[tag]?.filter(id => id !== docId) || [];
|
||||||
|
if (this.index.tags[tag].length === 0) delete this.index.tags[tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to new tags
|
||||||
|
for (const tag of newTags) {
|
||||||
|
if (!oldTags.includes(tag)) {
|
||||||
|
if (!this.index.tags[tag]) this.index.tags[tag] = [];
|
||||||
|
if (!this.index.tags[tag].includes(docId)) {
|
||||||
|
this.index.tags[tag].push(docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._saveIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocIdsForTag(tag) {
|
||||||
|
return this.index.tags[tag] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllTags() {
|
||||||
|
return Object.entries(this.index.tags).map(([name, docIds]) => ({
|
||||||
|
name,
|
||||||
|
count: docIds.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
tagExists(tag) {
|
||||||
|
return tag in this.index.tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let globalIndexer = null;
|
||||||
|
|
||||||
|
export function getTagIndexer(dataRoot = config.dataRoot) {
|
||||||
|
if (!globalIndexer) {
|
||||||
|
globalIndexer = new TagIndexer(dataRoot);
|
||||||
|
}
|
||||||
|
return globalIndexer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTagIndexer(dataRoot = config.dataRoot) {
|
||||||
|
globalIndexer = new TagIndexer(dataRoot);
|
||||||
|
globalIndexer.rebuild();
|
||||||
|
return globalIndexer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TagIndexer;
|
||||||
56
src/middleware/auth.js
Normal file
56
src/middleware/auth.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Auth Middleware
|
||||||
|
* Bearer token authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import config from '../config/index.js';
|
||||||
|
import { readJSON, pathExists } from '../utils/fsHelper.js';
|
||||||
|
import { UnauthorizedError } from '../utils/errors.js';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function authMiddleware(req, res, next) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
throw new UnauthorizedError('Token required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const tokensPath = join(config.dataRoot, '.auth-tokens.json');
|
||||||
|
|
||||||
|
// Si no existe el archivo de tokens aún, verificar contra ADMIN_TOKEN
|
||||||
|
if (!pathExists(tokensPath)) {
|
||||||
|
if (token !== config.adminToken) {
|
||||||
|
throw new UnauthorizedError('Invalid token');
|
||||||
|
}
|
||||||
|
req.isAdmin = token === config.adminToken;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokensData = readJSON(tokensPath);
|
||||||
|
const validToken = tokensData?.tokens?.find(t => t.token === token);
|
||||||
|
|
||||||
|
if (!validToken) {
|
||||||
|
throw new UnauthorizedError('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
req.token = token;
|
||||||
|
req.tokenLabel = validToken.label;
|
||||||
|
req.isAdmin = token === config.adminToken;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) {
|
||||||
|
return res.status(401).json({ error: err.message, code: err.code });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: 'Auth error', code: 'AUTH_ERROR' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminOnly(req, res, next) {
|
||||||
|
if (!req.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required', code: 'FORBIDDEN' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authMiddleware;
|
||||||
23
src/middleware/errorHandler.js
Normal file
23
src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Error Handler Middleware
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AppError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export function errorHandler(err, req, res, next) {
|
||||||
|
console.error(`[ERROR] ${err.name || 'Error'}: ${err.message}`);
|
||||||
|
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
return res.status(err.statusCode).json({
|
||||||
|
error: err.message,
|
||||||
|
code: err.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default errorHandler;
|
||||||
69
src/routes/auth.js
Normal file
69
src/routes/auth.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Auth Routes
|
||||||
|
* POST /api/v1/auth/token - Generate token (admin)
|
||||||
|
* GET /api/v1/auth/verify - Verify token
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import config from '../config/index.js';
|
||||||
|
import { authMiddleware, adminOnly } from '../middleware/auth.js';
|
||||||
|
import { readJSON, writeJSON, pathExists } from '../utils/fsHelper.js';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { generateId } from '../utils/uuid.js';
|
||||||
|
import { ValidationError, UnauthorizedError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const TOKENS_FILE = '.auth-tokens.json';
|
||||||
|
|
||||||
|
function getTokensPath() {
|
||||||
|
return join(config.dataRoot, TOKENS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTokensFile() {
|
||||||
|
const path = getTokensPath();
|
||||||
|
if (!pathExists(path)) {
|
||||||
|
writeJSON(path, { version: 1, tokens: [] });
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTokens() {
|
||||||
|
return readJSON(getTokensPath()) || { version: 1, tokens: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTokens(data) {
|
||||||
|
writeJSON(getTokensPath(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/token - Generate new token (admin only)
|
||||||
|
router.post('/token', authMiddleware, adminOnly, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { label } = req.body;
|
||||||
|
if (!label) {
|
||||||
|
throw new ValidationError('label is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTokensFile();
|
||||||
|
const tokens = readTokens();
|
||||||
|
const token = `snk_${generateId().replace(/-/g, '')}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
tokens.tokens.push({ token, label, createdAt: now });
|
||||||
|
writeTokens(tokens);
|
||||||
|
|
||||||
|
res.status(201).json({ token, label, createdAt: now });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'VALIDATION_ERROR') {
|
||||||
|
return res.status(400).json({ error: err.message, code: err.code });
|
||||||
|
}
|
||||||
|
console.error('Error generating token:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /auth/verify - Verify token
|
||||||
|
router.get('/verify', authMiddleware, (req, res) => {
|
||||||
|
res.json({ valid: true, token: req.token });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
149
src/routes/documents.js
Normal file
149
src/routes/documents.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Documents Routes
|
||||||
|
* CRUD + export for documents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { authMiddleware } from '../middleware/auth.js';
|
||||||
|
import { getDocumentService } from '../services/documentService.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET /documents - List documents
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tag, library, type, status, limit, offset } = req.query;
|
||||||
|
const docService = getDocumentService();
|
||||||
|
const result = await docService.listDocuments({
|
||||||
|
tag,
|
||||||
|
library,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
limit: limit ? parseInt(limit, 10) : 50,
|
||||||
|
offset: offset ? parseInt(offset, 10) : 0,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing documents:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /documents - Create document
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, libraryId, content, tags, type, priority, status } = req.body;
|
||||||
|
const docService = getDocumentService();
|
||||||
|
const doc = await docService.createDocument({
|
||||||
|
title,
|
||||||
|
libraryId,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
type,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
res.status(201).json(doc);
|
||||||
|
} 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 document:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /documents/:id - Get document
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docService = getDocumentService();
|
||||||
|
const doc = await docService.getDocument(req.params.id);
|
||||||
|
res.json(doc);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
return res.status(404).json({ error: err.message, code: err.code });
|
||||||
|
}
|
||||||
|
console.error('Error getting document:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /documents/:id - Update document
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, content, tags, type, priority, status } = req.body;
|
||||||
|
const docService = getDocumentService();
|
||||||
|
const doc = await docService.updateDocument(req.params.id, {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
type,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
res.json(doc);
|
||||||
|
} 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 document:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /documents/:id - Delete document
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docService = getDocumentService();
|
||||||
|
const result = await docService.deleteDocument(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 document:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /documents/:id/export - Export as markdown
|
||||||
|
router.get('/:id/export', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docService = getDocumentService();
|
||||||
|
const result = await docService.exportDocument(req.params.id);
|
||||||
|
res.type('text/markdown').send(result.markdown);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
return res.status(404).json({ error: err.message, code: err.code });
|
||||||
|
}
|
||||||
|
console.error('Error exporting document:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /documents/:id/tags - Add tags to document
|
||||||
|
router.post('/:id/tags', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tags } = req.body;
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
return res.status(400).json({ error: 'tags must be an array', code: 'VALIDATION_ERROR' });
|
||||||
|
}
|
||||||
|
const docService = getDocumentService();
|
||||||
|
const doc = await docService.addTagsToDocument(req.params.id, tags);
|
||||||
|
res.json(doc);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
return res.status(404).json({ error: err.message, code: err.code });
|
||||||
|
}
|
||||||
|
console.error('Error adding tags:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
23
src/routes/index.js
Normal file
23
src/routes/index.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Routes Index
|
||||||
|
* Mount all route modules under /api/v1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import documentsRouter from './documents.js';
|
||||||
|
import librariesRouter from './libraries.js';
|
||||||
|
import tagsRouter from './tags.js';
|
||||||
|
import authRouter from './auth.js';
|
||||||
|
|
||||||
|
export function createApiRouter(apiPrefix = '/api/v1') {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use('/documents', documentsRouter);
|
||||||
|
router.use('/libraries', librariesRouter);
|
||||||
|
router.use('/tags', tagsRouter);
|
||||||
|
router.use('/auth', authRouter);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createApiRouter;
|
||||||
106
src/routes/libraries.js
Normal file
106
src/routes/libraries.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Libraries Routes
|
||||||
|
* CRUD + tree for libraries
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { authMiddleware } from '../middleware/auth.js';
|
||||||
|
import { getLibraryService } from '../services/libraryService.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET /libraries - List root libraries
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const libService = getLibraryService();
|
||||||
|
const libraries = await libService.listRootLibraries();
|
||||||
|
res.json({ libraries });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing libraries:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /libraries - Create library
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, parentId } = req.body;
|
||||||
|
if (!name) {
|
||||||
|
throw new ValidationError('name is required');
|
||||||
|
}
|
||||||
|
const libService = getLibraryService();
|
||||||
|
const lib = await libService.createLibrary({ name, parentId });
|
||||||
|
res.status(201).json(lib);
|
||||||
|
} 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 library:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /libraries/:id - Get library contents
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const libService = getLibraryService();
|
||||||
|
const result = await libService.getLibrary(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 getting library:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /libraries/:id/tree - Get full library tree
|
||||||
|
router.get('/:id/tree', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const libService = getLibraryService();
|
||||||
|
const tree = await libService.getLibraryTree(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 library tree:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /libraries/:id/documents - List documents in library
|
||||||
|
router.get('/:id/documents', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const libService = getLibraryService();
|
||||||
|
const result = await libService.listDocumentsInLibrary(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 library documents:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /libraries/:id - Delete library
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const libService = getLibraryService();
|
||||||
|
const result = await libService.deleteLibrary(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 library:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
41
src/routes/tags.js
Normal file
41
src/routes/tags.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Tags Routes
|
||||||
|
* Tag listing and search
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { authMiddleware } from '../middleware/auth.js';
|
||||||
|
import { getTagService } from '../services/tagService.js';
|
||||||
|
import { NotFoundError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET /tags - List all tags
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tagService = getTagService();
|
||||||
|
const result = await tagService.listTags();
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing tags:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /tags/:tag - Get documents with tag
|
||||||
|
router.get('/:tag', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tagService = getTagService();
|
||||||
|
const result = await tagService.getTagDocuments(req.params.tag);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
return res.status(404).json({ error: err.message, code: err.code });
|
||||||
|
}
|
||||||
|
console.error('Error getting tag documents:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
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;
|
||||||
35
src/utils/errors.js
Normal file
35
src/utils/errors.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Custom Errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(resource = 'Resource') {
|
||||||
|
super(`${resource} not found`, 404, 'NOT_FOUND');
|
||||||
|
this.name = 'NotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends AppError {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(message, 401, 'UNAUTHORIZED');
|
||||||
|
this.name = 'UnauthorizedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message) {
|
||||||
|
super(message, 400, 'VALIDATION_ERROR');
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { AppError, NotFoundError, UnauthorizedError, ValidationError };
|
||||||
58
src/utils/fsHelper.js
Normal file
58
src/utils/fsHelper.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Filesystem Helper
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
|
||||||
|
import { join, resolve, relative, sep } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
export function ensureDir(dirPath) {
|
||||||
|
if (!existsSync(dirPath)) {
|
||||||
|
mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJSON(filePath) {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJSON(filePath, data) {
|
||||||
|
ensureDir(dirname(filePath));
|
||||||
|
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePath(path) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
rmSync(path, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathExists(path) {
|
||||||
|
return existsSync(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDir(dirPath) {
|
||||||
|
if (!existsSync(dirPath)) return [];
|
||||||
|
return readdirSync(dirPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirectory(path) {
|
||||||
|
try {
|
||||||
|
return statSync(path).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSafe(base, target) {
|
||||||
|
const resolved = resolve(base, target);
|
||||||
|
if (!resolved.startsWith(resolve(base))) {
|
||||||
|
throw new Error('Path traversal detected');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { ensureDir, readJSON, writeJSON, deletePath, pathExists, listDir, isDirectory, resolveSafe };
|
||||||
68
src/utils/markdown.js
Normal file
68
src/utils/markdown.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - Markdown Utilities
|
||||||
|
* Helpers for parsing frontmatter and serializing markdown documents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import matter from 'gray-matter';
|
||||||
|
import { generateId } from './uuid.js';
|
||||||
|
|
||||||
|
const VALID_TYPES = ['requirement', 'note', 'spec', 'general'];
|
||||||
|
const VALID_STATUSES = ['draft', 'approved', 'implemented'];
|
||||||
|
const VALID_PRIORITIES = ['high', 'medium', 'low'];
|
||||||
|
|
||||||
|
export function parseMarkdown(content) {
|
||||||
|
try {
|
||||||
|
const { data, content: body } = matter(content);
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
id: data.id || null,
|
||||||
|
title: data.title || 'Untitled',
|
||||||
|
type: VALID_TYPES.includes(data.type) ? data.type : 'general',
|
||||||
|
status: VALID_STATUSES.includes(data.status) ? data.status : 'draft',
|
||||||
|
priority: VALID_PRIORITIES.includes(data.priority) ? data.priority : 'medium',
|
||||||
|
tags: Array.isArray(data.tags) ? data.tags : [],
|
||||||
|
createdBy: data.createdBy || null,
|
||||||
|
createdAt: data.createdAt || new Date().toISOString(),
|
||||||
|
},
|
||||||
|
body: body.trim(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
id: null,
|
||||||
|
title: 'Untitled',
|
||||||
|
type: 'general',
|
||||||
|
status: 'draft',
|
||||||
|
priority: 'medium',
|
||||||
|
tags: [],
|
||||||
|
createdBy: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeMarkdown(metadata, body = '') {
|
||||||
|
const frontmatter = [
|
||||||
|
'---',
|
||||||
|
`id: ${metadata.id || generateId()}`,
|
||||||
|
`title: ${metadata.title || 'Untitled'}`,
|
||||||
|
`type: ${metadata.type || 'general'}`,
|
||||||
|
`priority: ${metadata.priority || 'medium'}`,
|
||||||
|
`status: ${metadata.status || 'draft'}`,
|
||||||
|
`tags: [${(metadata.tags || []).join(', ')}]`,
|
||||||
|
metadata.createdBy ? `createdBy: ${metadata.createdBy}` : null,
|
||||||
|
`createdAt: ${metadata.createdAt || new Date().toISOString().split('T')[0]}`,
|
||||||
|
'---',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
return `${frontmatter}\n\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultContent(title, type = 'general') {
|
||||||
|
const typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
return `# ${title}\n\n## Descripción\nDescripción del ${typeLabel}.\n\n## Criterios de Aceptación\n- [ ] Criterio 1\n- [ ] Criterio 2\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { parseMarkdown, serializeMarkdown, buildDefaultContent };
|
||||||
11
src/utils/uuid.js
Normal file
11
src/utils/uuid.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* SimpleNote Web - UUID Helper
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export function generateId() {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { generateId };
|
||||||
Reference in New Issue
Block a user