From 825dfba2a7d45d90cd56f83d1a6ff9a516f1dfa7 Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 28 Mar 2026 03:27:27 +0000 Subject: [PATCH] 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 --- .env.example | 18 + .gitignore | 4 + README.md | 103 ++- package-lock.json | 1387 +++++++++++++++++++++++++++++++ package.json | 16 +- scripts/init-data.js | 90 ++ src/config/index.js | 27 + src/index.js | 41 +- src/indexers/tagIndexer.js | 173 ++++ src/middleware/auth.js | 56 ++ src/middleware/errorHandler.js | 23 + src/routes/auth.js | 69 ++ src/routes/documents.js | 149 ++++ src/routes/index.js | 23 + src/routes/libraries.js | 106 +++ src/routes/tags.js | 41 + src/services/documentService.js | 332 ++++++++ src/services/tagService.js | 54 ++ src/utils/errors.js | 35 + src/utils/fsHelper.js | 58 ++ src/utils/markdown.js | 68 ++ src/utils/uuid.js | 11 + 22 files changed, 2864 insertions(+), 20 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 scripts/init-data.js create mode 100644 src/config/index.js create mode 100644 src/indexers/tagIndexer.js create mode 100644 src/middleware/auth.js create mode 100644 src/middleware/errorHandler.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/documents.js create mode 100644 src/routes/index.js create mode 100644 src/routes/libraries.js create mode 100644 src/routes/tags.js create mode 100644 src/services/documentService.js create mode 100644 src/services/tagService.js create mode 100644 src/utils/errors.js create mode 100644 src/utils/fsHelper.js create mode 100644 src/utils/markdown.js create mode 100644 src/utils/uuid.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9dd2aa1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97725b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +*.log diff --git a/README.md b/README.md index 2872db0..ce504f5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,102 @@ -# simplenote-web +# SimpleNote Web -SimpleNote Web - Document management system with nested libraries and markdown support \ No newline at end of file +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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c8990f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1387 @@ +{ + "name": "simplenote-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplenote-web", + "version": "0.1.0", + "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" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json index 11edf08..a951523 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,21 @@ "type": "module", "scripts": { "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"], "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" + } } diff --git a/scripts/init-data.js b/scripts/init-data.js new file mode 100644 index 0000000..d46c56e --- /dev/null +++ b/scripts/init-data.js @@ -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); +}); diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..32918f4 --- /dev/null +++ b/src/config/index.js @@ -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; diff --git a/src/index.js b/src/index.js index a2fc36e..0791c73 100644 --- a/src/index.js +++ b/src/index.js @@ -4,33 +4,42 @@ */ import express from 'express'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +import cors from 'cors'; +import config from './config/index.js'; +import { createApiRouter } from './routes/index.js'; +import { errorHandler } from './middleware/errorHandler.js'; +import { initTagIndexer } from './indexers/tagIndexer.js'; +import { ensureDir } from './utils/fsHelper.js'; const app = express(); -const PORT = process.env.PORT || 3000; // Middleware -app.use(express.json()); +app.use(cors({ origin: config.corsOrigin })); +app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); -// Health check +// Health check (unprotected) 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 -// - /api/documents -// - /api/libraries -// - /api/tags -// - /api/auth +// Ensure data directory exists +ensureDir(config.dataRoot); + +// Initialize tag indexer +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 -app.listen(PORT, () => { - console.log(`SimpleNote Web running on port ${PORT}`); +app.listen(config.port, config.host, () => { + console.log(`[SimpleNote] Web API running on http://${config.host}:${config.port}`); + console.log(`[SimpleNote] API prefix: ${config.apiPrefix}`); }); export default app; diff --git a/src/indexers/tagIndexer.js b/src/indexers/tagIndexer.js new file mode 100644 index 0000000..344bb52 --- /dev/null +++ b/src/indexers/tagIndexer.js @@ -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; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..8d7153e --- /dev/null +++ b/src/middleware/auth.js @@ -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; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..d4f8355 --- /dev/null +++ b/src/middleware/errorHandler.js @@ -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; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..536c9fd --- /dev/null +++ b/src/routes/auth.js @@ -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; diff --git a/src/routes/documents.js b/src/routes/documents.js new file mode 100644 index 0000000..a30f42e --- /dev/null +++ b/src/routes/documents.js @@ -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; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..a0467a6 --- /dev/null +++ b/src/routes/index.js @@ -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; diff --git a/src/routes/libraries.js b/src/routes/libraries.js new file mode 100644 index 0000000..9645838 --- /dev/null +++ b/src/routes/libraries.js @@ -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; diff --git a/src/routes/tags.js b/src/routes/tags.js new file mode 100644 index 0000000..62a2d29 --- /dev/null +++ b/src/routes/tags.js @@ -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; diff --git a/src/services/documentService.js b/src/services/documentService.js new file mode 100644 index 0000000..5e41d12 --- /dev/null +++ b/src/services/documentService.js @@ -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; diff --git a/src/services/tagService.js b/src/services/tagService.js new file mode 100644 index 0000000..037c74a --- /dev/null +++ b/src/services/tagService.js @@ -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; diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..56a2fb1 --- /dev/null +++ b/src/utils/errors.js @@ -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 }; diff --git a/src/utils/fsHelper.js b/src/utils/fsHelper.js new file mode 100644 index 0000000..3696786 --- /dev/null +++ b/src/utils/fsHelper.js @@ -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 }; diff --git a/src/utils/markdown.js b/src/utils/markdown.js new file mode 100644 index 0000000..d4e5602 --- /dev/null +++ b/src/utils/markdown.js @@ -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 }; diff --git a/src/utils/uuid.js b/src/utils/uuid.js new file mode 100644 index 0000000..d4a4a7f --- /dev/null +++ b/src/utils/uuid.js @@ -0,0 +1,11 @@ +/** + * SimpleNote Web - UUID Helper + */ + +import { v4 as uuidv4 } from 'uuid'; + +export function generateId() { + return uuidv4(); +} + +export default { generateId };