1 Commits

Author SHA1 Message Date
Bulma 90e4dd0807 feat(architecture): add complete technical architecture for SimpleNote
- ARCHITECTURE.md: main architecture document
- api-spec.yaml: full OpenAPI 3.0 spec
- folder-structure.md: detailed folder layout
- data-format.md: JSON schemas for .meta.json, .library.json, .tag-index.json
- env-template.md: environment variables documentation
- cli-protocol.md: CLI-to-API communication protocol
2026-03-28 03:18:25 +00:00
57 changed files with 2195 additions and 12515 deletions
-3
View File
@@ -1,3 +0,0 @@
node_modules/
.env
*.log
-14
View File
@@ -1,14 +0,0 @@
# SimpleNote Web - Development Environment
# Server
PORT=3000
NODE_ENV=development
# Auth - generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ADMIN_TOKEN=4f7f6b4326aa2192e7fc1d1df6ba15bb9c43c55d2065f3573c613946e82b3f72
# Data
DATA_ROOT=./data
# CORS (comma-separated origins or * for all)
CORS_ORIGIN=http://localhost:5173,http://localhost:3000
-18
View File
@@ -1,18 +0,0 @@
# ============ 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
-14
View File
@@ -1,14 +0,0 @@
# SimpleNote Web - Production Environment
# Server
PORT=3000
NODE_ENV=production
# Auth
ADMIN_TOKEN=4f7f6b4326aa2192e7fc1d1df6ba15bb9c43c55d2065f3573c613946e82b3f72
# Data
DATA_ROOT=./data
# CORS (comma-separated origins or * for all)
CORS_ORIGIN=*
-4
View File
@@ -1,4 +0,0 @@
node_modules/
data/
.env
*.log
+413
View File
@@ -0,0 +1,413 @@
# SimpleNote - Arquitectura Técnica
## 1. Visión General
Sistema de gestión de documentos basado en archivos Markdown con API REST. Diseñado para reemplazar Joplin con una arquitectura más simple.
```
┌─────────────────┐ ┌──────────────────┐
│ simplenote-cli │◄────►│ simplenote-web │
│ (Commander.js) │ │ (Express + API) │
└─────────────────┘ └────────┬─────────┘
┌────────▼─────────┐
│ File System │
│ (Markdown + JSON)│
└───────────────────┘
```
## 2. Componentes
### 2.1 simplenote-web
- **Rol**: API REST + Frontend web
- **Puerto default**: 3000
- **Estructura interna**: Express.js con routers modulares
### 2.2 simplenote-cli
- **Rol**: Cliente de línea de comandos
- **Conexión**: HTTP al API de simplenote-web
- **Config**: `~/.config/simplenote/config.json`
## 3. Arquitectura de Datos
### 3.1 Estructura de Librerías (Filesystem)
```
data/ # DATA_ROOT configurable
└── libraries/
└── {library-id}/
├── .library.json # Metadata de librería
├── documents/
│ └── {document-id}/
│ ├── index.md # Contenido
│ └── .meta.json # Metadata del documento
└── sub-libraries/
└── {child-lib-id}/... # Anidamiento recursivo
```
### 3.2 JSON Manifests
**`.library.json`** — Metadata de librería:
```json
{
"id": "uuid-v4",
"name": "Nombre de Librería",
"parentId": "parent-uuid | null",
"path": "/libraries/uuid",
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
```
**`.meta.json`** — Metadata de documento:
```json
{
"id": "uuid-v4",
"title": "string",
"tags": ["tag1", "tag2"],
"type": "requirement|note|spec|general",
"status": "draft|approved|implemented",
"priority": "high|medium|low",
"createdBy": "agent-id",
"createdAt": "ISO8601",
"updatedAt": "ISO8601",
"libraryId": "uuid"
}
```
**`.tag-index.json`** — Índice global de tags (en DATA_ROOT):
```json
{
"version": 1,
"updatedAt": "ISO8601",
"tags": {
"backend": ["doc-1", "doc-2"],
"api": ["doc-1", "doc-3"],
"auth": ["doc-2"]
}
}
```
### 3.3 Formato de Documento (Markdown)
```markdown
---
id: REQ-001
title: Título del Requerimiento
type: requirement
priority: high
status: draft
tags: [backend, api]
createdBy: agent-id
createdAt: 2026-03-28
---
# Título
## Descripción
Descripción del requerimiento.
## Criterios de Aceptación
- [ ] Criterio 1
- [ ] Criterio 2
```
## 4. API REST
### 4.1 Base URL
```
/api/v1
```
### 4.2 Autenticación
- **Método**: Token Bearer en header `Authorization`
- **Formato**: `Authorization: Bearer <token>`
- **Admin tokens**: Generados en setup inicial, almacenados en `.auth-tokens.json`
### 4.3 Endpoints
#### Auth
| Method | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/api/v1/auth/token` | Generar token (admin) |
| GET | `/api/v1/auth/verify` | Verificar token válido |
#### Documents
| Method | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/v1/documents` | Listar (filtros: tag, library, type) |
| GET | `/api/v1/documents/:id` | Obtener documento + metadata |
| 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 |
#### Libraries
| Method | Endpoint | Descripción |
|--------|----------|-------------|
| 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 de sublibrerías |
| DELETE | `/api/v1/libraries/:id` | Eliminar librería |
#### Tags
| Method | Endpoint | Descripción |
|--------|----------|-------------|
| GET | `/api/v1/tags` | Listar todos los tags |
| GET | `/api/v1/tags/:tag/documents` | Docs con tag específico |
| POST | `/api/v1/documents/:id/tags` | Agregar tags a documento |
## 5. Middleware de Auth
### 5.1 Flujo de Tokens
1. **Setup inicial**: Se genera admin token (almacenado en `.auth-tokens.json`)
2. **CLI login**: `simplenote auth login <token>` → almacena token en config local
3. **Requests**: Token enviado en header `Authorization: Bearer <token>`
4. **Verificación**: Middleware busca token en `.auth-tokens.json`
### 5.2 Implementación
```javascript
// authMiddleware.js
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
const tokens = await readJSON('.auth-tokens.json');
if (!tokens.valid.includes(token)) {
return res.status(401).json({ error: 'Invalid token' });
}
req.token = token;
next();
}
```
### 5.3 Archivo `.auth-tokens.json`
```json
{
"version": 1,
"tokens": [
{
"token": "snk_xxxxx",
"label": "cli-default",
"createdAt": "ISO8601"
}
]
}
```
## 6. Estrategia de Indexación de Tags
### 6.1write-through
Cuando se crea/actualiza/elimina un documento:
1. Se actualiza su `.meta.json` con los tags
2. Se reconstruye el `.tag-index.json` global
### 6.2 Optimización
- El índice se rebuild en memoria al iniciar (rápido para <10k docs)
- Para sistemas grandes: rebuild incremental (solo afecta docs modificados)
- El índice es un archivo JSON plano para búsqueda O(1) por tag
### 6.3 API de Búsqueda
```javascript
// GET /api/v1/tags/backend
// → Lee .tag-index.json → returns ["doc-1", "doc-2"]
// GET /api/v1/documents?tag=backend
// → Busca en índice → filtra docs en memoria → retorna
```
## 7. CLI API Client
### 7.1 Configuración Local
```json
// ~/.config/simplenote/config.json
{
"apiUrl": "http://localhost:3000/api/v1",
"token": "snk_xxxxx",
"activeLibrary": "default"
}
```
### 7.2 Cliente HTTP
```javascript
// cli/src/api/client.js
class SimpleNoteClient {
constructor(baseUrl, token) {
this.baseUrl = baseUrl;
this.token = token;
}
async request(method, path, body) {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
// Documents
listDocuments(params) { return this.request('GET', '/documents', null); }
getDocument(id) { return this.request('GET', `/documents/${id}`); }
createDocument(data) { return this.request('POST', '/documents', data); }
updateDocument(id, data) { return this.request('PUT', `/documents/${id}`, data); }
deleteDocument(id) { return this.request('DELETE', `/documents/${id}`); }
// Libraries
listLibraries() { return this.request('GET', '/libraries'); }
createLibrary(data) { return this.request('POST', '/libraries', data); }
// Tags
listTags() { return this.request('GET', '/tags'); }
getTagDocuments(tag) { return this.request('GET', `/tags/${tag}/documents`); }
// Auth
verifyToken() { return this.request('GET', '/auth/verify'); }
}
```
## 8. Dependencias NPM
### simplenote-web
```json
{
"dependencies": {
"express": "^4.18.2",
"uuid": "^9.0.0",
"marked": "^11.0.0",
"gray-matter": "^4.0.3"
},
"devDependencies": {
"nodemon": "^3.0.0"
}
}
```
### simplenote-cli
```json
{
"dependencies": {
"commander": "^11.1.0",
"axios": "^1.6.0"
}
}
```
## 9. Variables de Entorno
| Variable | Default | Descripción |
|----------|---------|-------------|
| `PORT` | 3000 | Puerto del servidor |
| `DATA_ROOT` | `./data` | Raíz de documentos |
| `HOST` | `0.0.0.0` | Host de binding |
| `LOG_LEVEL` | `info` | Nivel de logging |
| `CORS_ORIGIN` | `*` | Orígenes CORS permitidos |
## 10. Estructura de Archivos del Proyecto
```
simplenote-web/
├── src/
│ ├── index.js # Entry point
│ ├── app.js # Express setup
│ ├── routes/
│ │ ├── documents.js
│ │ ├── libraries.js
│ │ ├── tags.js
│ │ └── auth.js
│ ├── services/
│ │ ├── documentService.js
│ │ ├── libraryService.js
│ │ └── tagService.js
│ ├── middleware/
│ │ └── auth.js
│ ├── utils/
│ │ ├── markdown.js
│ │ ├── fsHelper.js
│ │ └── uuid.js
│ └── indexers/
│ └── tagIndexer.js
├── data/ # .gitkeep
├── tests/
├── package.json
└── README.md
simplenote-cli/
├── src/
│ ├── index.js # Entry point (Commander)
│ ├── commands/
│ │ ├── doc.js
│ │ ├── lib.js
│ │ └── tag.js
│ ├── api/
│ │ └── client.js
│ └── config/
│ └── loader.js
├── package.json
└── README.md
```
## 11. Flujo de Operaciones
### 11.1 Crear Documento (CLI)
```
simplenote doc create --title "REQ-001" --tags "backend,api"
→ POST /api/v1/documents
→ Service: genera UUID, crea /data/libraries/{lib}/documents/{id}/index.md
→ Service: crea /data/libraries/{lib}/documents/{id}/.meta.json
→ Service: rebuild .tag-index.json
→ Response: { id, path, tags, ... }
```
### 11.2 Crear Documento (Web)
```
Form POST → /api/v1/documents
→ Mismo flujo que CLI
→ Web reconstruye lista con filtros
```
### 11.3 Búsqueda por Tag
```
GET /api/v1/tags/backend
→ tagIndexer: lee .tag-index.json
→ Returns: ["doc-id-1", "doc-id-2"]
GET /api/v1/documents?tag=backend
→ Busca en índice → filtra docs → retorna con metadata
```
## 12. Seguridad
- Tokens con prefijo `snk_` para identificación fácil
- Tokens almacenados en texto plano (sistema multi-usuario confiado)
- Para producción futura: hash bcrypt + salt
- CORS configurable para restringir acceso web
## 13. Líneas de Código Estimadas
| Componente | LOC aprox |
|------------|-----------|
| API REST (Express) | ~600 |
| Services (document, library, tag) | ~400 |
| Middleware auth | ~50 |
| Tag indexer | ~100 |
| CLI client | ~300 |
| CLI commands | ~400 |
| **Total** | ~1850 |
-19
View File
@@ -1,19 +0,0 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
COPY public/ ./public/
COPY data/ ./data/
COPY ui/ ./ui/
COPY .env.example ./
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
CMD ["node", "src/index.js"]
Vendored
-126
View File
@@ -1,126 +0,0 @@
pipeline {
agent {
node {
label 'java-springboot'
}
}
environment {
URL_REGISTRY = 'gitea.danielarroyo.cl'
PROJECT = 'proyectos'
REMOTE_USER = 'root'
REMOTE_HOST = '10.5.0.116'
REMOTE_PATH = '/compose'
}
stages {
stage('Obtener Nombre del Repositorio') {
steps {
script {
sh 'env | sort'
echo "GIT_URL: ${env.GIT_URL}"
echo "GIT_URL_1: ${env.GIT_URL_1}"
def gitUrl = env.GIT_URL ?: env.GIT_URL_1
if (gitUrl) {
def repoName = gitUrl.tokenize('/').last().replace('.git', '')
echo "Nombre extraído del repositorio: ${repoName}"
env.NAME_SERVICE = repoName
echo "El nombre del repositorio asignado a NAME_SERVICE: ${env.NAME_SERVICE}"
} else {
echo "No se pudo obtener la URL del repositorio. GIT_URL y GIT_URL_1 no están definidos."
env.NAME_SERVICE = 'unknown'
}
}
}
}
stage('Build') {
steps {
echo "El nombre del repositorio es: ${env.NAME_SERVICE}"
script {
try {
sh """
docker build \
-t ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER} .
docker tag ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER} \
${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:latest
"""
} catch (Exception e) {
error "Build failed: ${e.message}"
}
}
}
}
stage('Push to Registry') {
steps {
script {
withCredentials([usernamePassword(credentialsId: 'gitea-docker-registry', usernameVariable: 'REG_USR', passwordVariable: 'REG_PSW')]) {
sh """
docker login ${URL_REGISTRY} -u ${REG_USR} -p ${REG_PSW}
docker push ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER}
docker push ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:latest
"""
}
} catch (Exception e) {
error "Push to registry failed: ${e.message}"
}
}
}
}
stage('Deploy') {
steps {
script {
def dockerComposeTemplate = """
services:
${NAME_SERVICE}:
image: ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER}
container_name: ${NAME_SERVICE}
labels:
- "traefik.enable=true"
- "traefik.http.services.${NAME_SERVICE}.loadbalancer.server.port=3000"
- "traefik.http.routers.${NAME_SERVICE}.entrypoints=web"
- "traefik.http.routers.${NAME_SERVICE}.rule=Host(simplenote.danielarroyo.cl)"
environment:
- NODE_ENV=production
- PORT=3000
- ADMIN_TOKEN=${SIMPLENOTE_ADMIN_TOKEN}
- DATA_ROOT=/app/data
- CORS_ORIGIN=${SIMPLENOTE_CORS_ORIGIN:-*}
volumes:
- simplenote-data:/app/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- homelab-net
mem_limit: 128m
mem_reservation: 64m
restart: unless-stopped
networks:
homelab-net:
external: true
"""
writeFile file: 'docker-compose.yaml', text: dockerComposeTemplate
sshagent(credentials: ['ssh-virtual-machine']) {
withCredentials([usernamePassword(credentialsId: 'gitea-docker-registry', usernameVariable: 'REG_USR', passwordVariable: 'REG_PSW')]) {
sh """
ssh -o StrictHostKeyChecking=no \${REMOTE_USER}@\${REMOTE_HOST} "docker login \${URL_REGISTRY} -u \${REG_USR} -p \${REG_PSW}"
ssh -o StrictHostKeyChecking=no \${REMOTE_USER}@\${REMOTE_HOST} "mkdir -p \${REMOTE_PATH}/\${PROJECT}/\${NAME_SERVICE}"
scp docker-compose.yaml \${REMOTE_USER}@\${REMOTE_HOST}:\${REMOTE_PATH}/\${PROJECT}/\${NAME_SERVICE}/docker-compose.yaml
ssh -o StrictHostKeyChecking=no \${REMOTE_USER}@\${REMOTE_HOST} "cd \${REMOTE_PATH}/\${PROJECT}/\${NAME_SERVICE} && docker compose down && docker compose pull && docker compose up -d"
ssh -o StrictHostKeyChecking=no \${REMOTE_USER}@\${REMOTE_HOST} "docker system prune -f"
"""
}
}
}
}
}
}
post {
always {
cleanWs()
}
failure {
echo "Pipeline failed. Check logs for details."
}
}
}
-102
View File
@@ -1,102 +0,0 @@
# SimpleNote Web
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
-213
View File
@@ -1,213 +0,0 @@
# SimpleNote Web - Testing Guide
## Running the Server Locally
```bash
cd simplenote-projects/simplenote-web
npm install
npm start
```
The server starts on `http://localhost:3000` by default.
### Environment Variables
| Variable | Default | Description |
|---------------|-------------------------------|---------------------------|
| `PORT` | `3000` | Server port |
| `HOST` | `0.0.0.0` | Server host |
| `DATA_ROOT` | `./data` | Document storage path |
| `ADMIN_TOKEN` | `snk_initial_admin_token_change_me` | Initial admin token |
| `CORS_ORIGIN` | `*` | CORS allowed origin |
| `API_PREFIX` | `/api/v1` | API route prefix |
## API Endpoints
Base URL: `http://localhost:3000/api/v1`
All endpoints (except `/health` and `/auth/verify`) require:
```
Authorization: Bearer <token>
```
### Health Check
```bash
curl http://localhost:3000/health
```
### Auth Endpoints
**Generate token (admin only):**
```bash
# Using initial admin token
curl -X POST http://localhost:3000/api/v1/auth/token \
-H "Authorization: Bearer snk_initial_admin_token_change_me" \
-H "Content-Type: application/json" \
-d '{"label": "test-token"}'
```
**Verify token:**
```bash
curl http://localhost:3000/api/v1/auth/verify \
-H "Authorization: Bearer <token>"
```
### Library Endpoints
**Create a library:**
```bash
curl -X POST http://localhost:3000/api/v1/libraries \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "My Project"}'
```
**List root libraries:**
```bash
curl http://localhost:3000/api/v1/libraries \
-H "Authorization: Bearer <token>"
```
**Get library contents:**
```bash
curl http://localhost:3000/api/v1/libraries/<library-id> \
-H "Authorization: Bearer <token>"
```
**Get library tree:**
```bash
curl http://localhost:3000/api/v1/libraries/<library-id>/tree \
-H "Authorization: Bearer <token>"
```
**Delete library:**
```bash
curl -X DELETE http://localhost:3000/api/v1/libraries/<library-id> \
-H "Authorization: Bearer <token>"
```
### Document Endpoints
**Create document:**
```bash
curl -X POST http://localhost:3000/api/v1/documents \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"title": "API Requirements",
"libraryId": "<library-id>",
"content": "# API Requirements\n\n## Description\n...",
"tags": ["backend", "api"],
"type": "requirement",
"priority": "high",
"status": "draft"
}'
```
**List documents (with filters):**
```bash
# All documents
curl "http://localhost:3000/api/v1/documents" \
-H "Authorization: Bearer <token>"
# Filter by tag
curl "http://localhost:3000/api/v1/documents?tag=backend" \
-H "Authorization: Bearer <token>"
# Filter by library
curl "http://localhost:3000/api/v1/documents?library=<library-id>" \
-H "Authorization: Bearer <token>"
# Filter by type
curl "http://localhost:3000/api/v1/documents?type=requirement" \
-H "Authorization: Bearer <token>"
# Filter by status
curl "http://localhost:3000/api/v1/documents?status=draft" \
-H "Authorization: Bearer <token>"
# With pagination
curl "http://localhost:3000/api/v1/documents?limit=10&offset=0" \
-H "Authorization: Bearer <token>"
```
**Get document:**
```bash
curl http://localhost:3000/api/v1/documents/<doc-id> \
-H "Authorization: Bearer <token>"
```
**Update document:**
```bash
curl -X PUT http://localhost:3000/api/v1/documents/<doc-id> \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title", "status": "approved"}'
```
**Delete document:**
```bash
curl -X DELETE http://localhost:3000/api/v1/documents/<doc-id> \
-H "Authorization: Bearer <token>"
```
**Export document as markdown:**
```bash
curl http://localhost:3000/api/v1/documents/<doc-id>/export \
-H "Authorization: Bearer <token>"
```
**Add tags to document:**
```bash
curl -X POST http://localhost:3000/api/v1/documents/<doc-id>/tags \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"tags": ["new-tag", "another-tag"]}'
```
### Tag Endpoints
**List all tags:**
```bash
curl http://localhost:3000/api/v1/tags \
-H "Authorization: Bearer <token>"
```
**Get documents with a tag:**
```bash
curl http://localhost:3000/api/v1/tags/backend \
-H "Authorization: Bearer <token>"
```
## Test Cases
### Happy Path
1. Create a library → receive library ID
2. Create a document in that library → receive document ID
3. List documents filtered by that library → document appears
4. Get the document by ID → full content returned
5. Update the document title and status
6. Add tags to the document
7. List tags → new tags appear with correct counts
8. Get documents by tag → document appears
9. Export document → markdown returned
10. Delete document → confirmed deleted
11. Delete library → confirmed deleted
### Edge Cases
- Create document without library ID → 400 error
- Create document with empty title → 400 error
- Get non-existent document → 404 error
- Update non-existent document → 404 error
- Delete non-existent document → 404 error
- List documents with invalid tag → empty list
- Create document with invalid type → defaults to "general"
- Create document with invalid priority → defaults to "medium"
- Unauthorized request (no token) → 401 error
- Invalid token → 401 error
### Security Tests
- Path traversal attempt in library ID → should be handled safely
- Very large content in document → limit enforced (10mb)
- XSS in document title/content → no sanitization (intentional, markdown renderer handles it)
-422
View File
@@ -1,422 +0,0 @@
# SimpleNote Web - API Endpoints
**Base URL:** `/api/v1`
**Auth:** All endpoints require `Authorization: Bearer <token>` header unless noted.
---
## Auth
### `POST /auth/token`
Generate a new API token (admin only).
**Request body:**
```json
{ "label": "my-token-label" }
```
**Response `201`:**
```json
{ "token": "snk_...", "label": "my-token-label", "createdAt": "2026-03-28T..." }
```
---
### `GET /auth/verify`
Verify a token is valid.
**Response `200`:**
```json
{ "valid": true, "token": "snk_..." }
```
---
## Projects
### `GET /projects`
List all projects.
**Response `200`:**
```json
{ "projects": [...] }
```
---
### `POST /projects`
Create a project.
**Request body:**
```json
{ "name": "Project Name", "description": "optional" }
```
**Response `201`:**
```json
{ "id": "...", "name": "...", "description": "...", "createdAt": "...", "updatedAt": "..." }
```
---
### `GET /projects/:id`
Get a single project.
**Response `200`:**
```json
{ "id": "...", "name": "...", "description": "...", "createdAt": "...", "updatedAt": "..." }
```
**Response `404`:**
```json
{ "error": "Project not found", "code": "NOT_FOUND" }
```
---
### `PUT /projects/:id`
Update a project.
**Request body:**
```json
{ "name": "New Name", "description": "New desc" }
```
**Response `200`:**
```json
{ "id": "...", "name": "...", "description": "...", "createdAt": "...", "updatedAt": "..." }
```
---
### `DELETE /projects/:id`
Delete a project.
**Response `200`:**
```json
{ "deleted": true, "id": "..." }
```
---
### `GET /projects/:id/tree`
Get full project tree (folder hierarchy + documents).
**Response `200`:**
```json
{
"project": { "id": "...", "name": "..." },
"documents": [...],
"folders": [...],
"totalDocuments": 5
}
```
---
### `GET /projects/:id/documents`
List documents directly in a project (not in sub-folders).
**Response `200`:**
```json
{ "documents": [...], "total": 3 }
```
---
## Folders
### `GET /folders?projectId=X&parentId=Y`
List folders in a project. `projectId` is required.
**Query params:**
- `projectId` (required): Project ID
- `parentId` (optional): Parent folder ID. Omit for root folders.
**Response `200`:**
```json
{ "folders": [...] }
```
**Response `400`:**
```json
{ "error": "projectId query parameter is required", "code": "VALIDATION_ERROR" }
```
---
### `POST /folders`
Create a folder.
**Request body:**
```json
{ "name": "Folder Name", "projectId": "...", "parentId": "..." }
```
**Response `201`:**
```json
{ "id": "...", "name": "...", "projectId": "...", "parentId": null, "createdAt": "...", "updatedAt": "..." }
```
---
### `GET /folders/:id`
Get a single folder.
**Response `200`:**
```json
{ "id": "...", "name": "...", "projectId": "...", "parentId": null, "createdAt": "...", "updatedAt": "..." }
```
---
### `PUT /folders/:id`
Update a folder (rename).
**Request body:**
```json
{ "name": "New Folder Name" }
```
**Response `200`:**
```json
{ "id": "...", "name": "New Folder Name", ... }
```
---
### `DELETE /folders/:id`
Delete a folder.
**Response `200`:**
```json
{ "deleted": true, "id": "..." }
```
---
### `GET /folders/:id/documents`
List documents directly in a folder.
**Response `200`:**
```json
{ "documents": [...], "total": 2 }
```
---
### `GET /folders/:id/tree`
Get full folder tree (sub-folders + documents recursively).
**Response `200`:**
```json
{
"folder": { "id": "...", "name": "..." },
"documents": [...],
"subFolders": [...],
"totalDocuments": 4
}
```
---
## Documents
### `GET /documents`
List documents with optional filters.
**Query params:**
- `tag` - Filter by tag
- `library` - Filter by library ID
- `project` - Filter by project ID
- `folder` - Filter by folder ID
- `type` - Filter by type (e.g., `requirement`, `general`)
- `status` - Filter by status (e.g., `draft`, `approved`)
- `limit` - Max results (default 50)
- `offset` - Pagination offset (default 0)
**Response `200`:**
```json
{ "documents": [...], "total": 10, "limit": 50, "offset": 0 }
```
---
### `POST /documents`
Create a document.
**Request body:**
```json
{
"title": "Doc Title",
"libraryId": "...",
"projectId": "...",
"folderId": "...",
"content": "# Markdown content",
"tags": ["tag1", "tag2"],
"type": "requirement",
"priority": "high",
"status": "draft"
}
```
**Response `201`:**
```json
{ "id": "...", "title": "...", "content": "...", "tags": [...], "type": "...", "priority": "...", "status": "...", "createdAt": "...", "updatedAt": "..." }
```
---
### `GET /documents/:id`
Get a single document.
**Response `200`:**
```json
{ "id": "...", "title": "...", "content": "...", "tags": [...], "type": "...", "priority": "...", "status": "...", "createdAt": "...", "updatedAt": "..." }
```
---
### `PUT /documents/:id`
Update a document.
**Request body:**
```json
{ "title": "New Title", "content": "...", "tags": [...], "type": "...", "priority": "...", "status": "...", "folderId": "..." }
```
**Response `200`:** Document object.
---
### `DELETE /documents/:id`
Delete a document.
**Response `200`:**
```json
{ "deleted": true, "id": "..." }
```
---
### `GET /documents/:id/export`
Export document as markdown.
**Response `200`:** Raw text/markdown (Content-Type: `text/markdown`).
---
### `POST /documents/:id/tags`
Add tags to a document.
**Request body:**
```json
{ "tags": ["new-tag", "another"] }
```
**Response `200`:** Updated document object.
---
## Tags
### `GET /tags`
List all tags with counts.
**Response `200`:**
```json
{ "tags": [{ "name": "backend", "count": 5 }, ...], "total": 12 }
```
---
### `GET /tags/:tag`
Get all documents with a specific tag.
**Response `200`:**
```json
{ "tag": "backend", "documents": [...], "count": 5 }
```
---
## Libraries
### `GET /libraries`
List root libraries.
**Response `200`:**
```json
{ "libraries": [...] }
```
---
### `POST /libraries`
Create a library.
**Request body:**
```json
{ "name": "Library Name", "parentId": "..." }
```
**Response `201`:**
```json
{ "id": "...", "name": "...", "parentId": null, "createdAt": "...", "updatedAt": "..." }
```
---
### `GET /libraries/:id`
Get library contents.
**Response `200`:**
```json
{ "id": "...", "name": "...", "parentId": null, ... }
```
---
### `GET /libraries/:id/tree`
Get full library tree.
**Response `200`:**
```json
{
"library": { ... },
"documents": [...],
"subLibraries": [...],
"totalDocuments": 3
}
```
---
### `GET /libraries/:id/documents`
List documents in a library.
**Response `200`:**
```json
{ "documents": [...], "total": 3 }
```
---
### `DELETE /libraries/:id`
Delete a library.
**Response `200`:**
```json
{ "deleted": true, "id": "..." }
```
---
## Error Format
All errors follow this structure:
```json
{ "error": "Human-readable message", "code": "ERROR_CODE" }
```
Common codes: `VALIDATION_ERROR`, `NOT_FOUND`, `UNAUTHORIZED`, `INTERNAL_ERROR`
+877
View File
@@ -0,0 +1,877 @@
openapi: 3.0.3
info:
title: SimpleNote API
description: REST API for SimpleNote document management system
version: 1.0.0
contact:
name: SimpleNote Team
servers:
- url: http://localhost:3000/api/v1
description: Local development server
tags:
- name: Auth
description: Authentication and token management
- name: Documents
description: Document CRUD operations
- name: Libraries
description: Library (folder) management
- name: Tags
description: Tag-based search and management
paths:
# ============ AUTH ============
/auth/token:
post:
tags: [Auth]
summary: Generate a new API token
description: Admin-only endpoint to generate a new bearer token
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [label]
properties:
label:
type: string
description: Human-readable label for the token
example: "cli-default"
responses:
'200':
description: Token generated successfully
content:
application/json:
schema:
type: object
properties:
token:
type: string
example: "snk_a1b2c3d4e5f6..."
label:
type: string
example: "cli-default"
createdAt:
type: string
format: date-time
'401':
description: Unauthorized - invalid admin token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'400':
description: Missing required fields
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/auth/verify:
get:
tags: [Auth]
summary: Verify current token is valid
security:
- BearerAuth: []
responses:
'200':
description: Token is valid
content:
application/json:
schema:
type: object
properties:
valid:
type: boolean
example: true
token:
type: string
example: "snk_a1b2c3d4e5f6..."
'401':
description: Token is invalid or missing
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ============ DOCUMENTS ============
/documents:
get:
tags: [Documents]
summary: List all documents
security:
- BearerAuth: []
parameters:
- name: tag
in: query
description: Filter by tag
schema:
type: string
example: "backend"
- name: library
in: query
description: Filter by library ID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440000"
- name: type
in: query
description: Filter by document type
schema:
type: string
enum: [requirement, note, spec, general]
example: "requirement"
- name: status
in: query
description: Filter by status
schema:
type: string
enum: [draft, approved, implemented]
example: "draft"
- name: limit
in: query
description: Max results to return
schema:
type: integer
default: 50
example: 20
- name: offset
in: query
description: Skip first N results
schema:
type: integer
default: 0
example: 0
responses:
'200':
description: List of documents
content:
application/json:
schema:
type: object
properties:
documents:
type: array
items:
$ref: '#/components/schemas/Document'
total:
type: integer
example: 42
limit:
type: integer
example: 20
offset:
type: integer
example: 0
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
tags: [Documents]
summary: Create a new document
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title, libraryId]
properties:
title:
type: string
description: Document title
example: "API Authentication Design"
libraryId:
type: string
description: Target library ID
example: "550e8400-e29b-41d4-a716-446655440000"
content:
type: string
description: Markdown content (optional, defaults to template)
example: "# API Authentication\n\n## Description\n..."
tags:
type: array
items:
type: string
example: ["backend", "api", "auth"]
type:
type: string
enum: [requirement, note, spec, general]
default: general
priority:
type: string
enum: [high, medium, low]
default: medium
status:
type: string
enum: [draft, approved, implemented]
default: draft
responses:
'201':
description: Document created
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Library not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/documents/{id}:
get:
tags: [Documents]
summary: Get a document by ID
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Document UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440001"
responses:
'200':
description: Document found
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
'404':
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
put:
tags: [Documents]
summary: Update a document
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Document UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440001"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: "Updated Title"
content:
type: string
example: "# Updated Content\n\nNew markdown..."
tags:
type: array
items:
type: string
example: ["backend", "api"]
type:
type: string
enum: [requirement, note, spec, general]
priority:
type: string
enum: [high, medium, low]
status:
type: string
enum: [draft, approved, implemented]
responses:
'200':
description: Document updated
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
tags: [Documents]
summary: Delete a document
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Document UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440001"
responses:
'200':
description: Document deleted
content:
application/json:
schema:
type: object
properties:
deleted:
type: boolean
example: true
id:
type: string
example: "550e8400-e29b-41d4-a716-446655440001"
'404':
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/documents/{id}/export:
get:
tags: [Documents]
summary: Export document as raw Markdown
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Document UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440001"
responses:
'200':
description: Raw Markdown file
content:
text/markdown:
schema:
type: string
example: |
---
id: REQ-001
title: API Authentication
---
# API Authentication
## Description
...
'404':
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/documents/{id}/tags:
post:
tags: [Tags]
summary: Add tags to a document
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Document UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440001"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tags]
properties:
tags:
type: array
items:
type: string
example: ["new-tag", "another-tag"]
responses:
'200':
description: Tags added
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
'400':
description: Invalid tags array
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ============ LIBRARIES ============
/libraries:
get:
tags: [Libraries]
summary: List root-level libraries
security:
- BearerAuth: []
responses:
'200':
description: List of root libraries
content:
application/json:
schema:
type: object
properties:
libraries:
type: array
items:
$ref: '#/components/schemas/Library'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
tags: [Libraries]
summary: Create a new library
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
description: Library name
example: "Backend Requirements"
parentId:
type: string
nullable: true
description: Parent library ID for nesting (null for root)
example: "550e8400-e29b-41d4-a716-446655440000"
responses:
'201':
description: Library created
content:
application/json:
schema:
$ref: '#/components/schemas/Library'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Parent library not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/libraries/{id}:
get:
tags: [Libraries]
summary: Get library contents (documents and sub-libraries)
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Library UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440000"
responses:
'200':
description: Library contents
content:
application/json:
schema:
type: object
properties:
library:
$ref: '#/components/schemas/Library'
documents:
type: array
items:
$ref: '#/components/schemas/Document'
subLibraries:
type: array
items:
$ref: '#/components/schemas/Library'
'404':
description: Library not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/libraries/{id}/tree:
get:
tags: [Libraries]
summary: Get full subtree of a library
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Library UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440000"
responses:
'200':
description: Full library tree
content:
application/json:
schema:
$ref: '#/components/schemas/LibraryTree'
'404':
description: Library not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/libraries/{id}/documents:
get:
tags: [Documents]
summary: List documents in a specific library
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
description: Library UUID
schema:
type: string
example: "550e8400-e29b-41d4-a716-446655440000"
responses:
'200':
description: List of documents in library
content:
application/json:
schema:
type: object
properties:
libraryId:
type: string
documents:
type: array
items:
$ref: '#/components/schemas/Document'
'404':
description: Library not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ============ TAGS ============
/tags:
get:
tags: [Tags]
summary: List all tags with document counts
security:
- BearerAuth: []
responses:
'200':
description: All tags with counts
content:
application/json:
schema:
type: object
properties:
tags:
type: array
items:
type: object
properties:
name:
type: string
example: "backend"
count:
type: integer
example: 5
total:
type: integer
example: 15
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/tags/{tag}:
get:
tags: [Tags]
summary: Get all documents with a specific tag
security:
- BearerAuth: []
parameters:
- name: tag
in: path
required: true
description: Tag name
schema:
type: string
example: "backend"
responses:
'200':
description: Documents with tag
content:
application/json:
schema:
type: object
properties:
tag:
type: string
example: "backend"
documents:
type: array
items:
$ref: '#/components/schemas/Document'
count:
type: integer
example: 5
'404':
description: Tag not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: API token with `snk_` prefix
schemas:
Error:
type: object
properties:
error:
type: string
example: "Document not found"
code:
type: string
example: "NOT_FOUND"
Document:
type: object
properties:
id:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440001"
title:
type: string
example: "API Authentication Design"
path:
type: string
example: "/libraries/550e8400/.../documents/550e8401/index.md"
content:
type: string
description: Raw markdown content
example: "# API Authentication\n\n## Description\n..."
tags:
type: array
items:
type: string
example: ["backend", "api", "auth"]
type:
type: string
enum: [requirement, note, spec, general]
example: "requirement"
status:
type: string
enum: [draft, approved, implemented]
example: "draft"
priority:
type: string
enum: [high, medium, low]
example: "high"
libraryId:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
createdBy:
type: string
description: Agent or user ID who created the document
example: "agent-001"
createdAt:
type: string
format: date-time
example: "2026-03-28T10:00:00Z"
updatedAt:
type: string
format: date-time
example: "2026-03-28T12:30:00Z"
Library:
type: object
properties:
id:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
name:
type: string
example: "Backend Requirements"
parentId:
type: string
nullable: true
format: uuid
description: Parent library ID, null for root
example: null
path:
type: string
description: Absolute path to library folder
example: "/data/libraries/550e8400"
documentCount:
type: integer
description: Total documents in this library (excludes sub-libraries)
example: 12
createdAt:
type: string
format: date-time
example: "2026-03-28T09:00:00Z"
updatedAt:
type: string
format: date-time
example: "2026-03-28T09:00:00Z"
LibraryTree:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
documents:
type: array
items:
type: object
properties:
id:
type: string
title:
type: string
subLibraries:
type: array
items:
$ref: '#/components/schemas/LibraryTree'
+274
View File
@@ -0,0 +1,274 @@
# SimpleNote CLI - Protocolo de Comunicación
## 1. Overview
El CLI (`simplenote-cli`) se comunica con el servidor (`simplenote-web`) exclusivamente
vía HTTP REST usando el API documentado en `api-spec.yaml`.
No hay comunicación peer-to-peer ni protocolos binarios. Todo es JSON sobre HTTP.
```
┌──────────────────┐ HTTP/REST ┌─────────────────┐
│ simplenote-cli │ ◄──────────────────► │ simplenote-web │
│ (Commander.js) │ Bearer token auth │ (Express.js) │
└──────────────────┘ └─────────────────┘
│ │
└──── ~/.config/simplenote/config.json ──┘
```
## 2. Cliente HTTP
### 2.1 Clase `SimpleNoteClient`
```javascript
// src/api/client.js
const axios = require('axios');
class SimpleNoteClient {
constructor({ baseUrl, token }) {
this.baseUrl = baseUrl.replace(/\/$/, ''); // strip trailing slash
this.token = token;
this._axios = axios.create({
baseURL: this.baseUrl,
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
});
}
_authHeaders() {
return this.token ? { Authorization: `Bearer ${this.token}` } : {};
}
async _request(method, path, data) {
try {
const res = await this._axios.request({
method,
url: path,
data,
headers: this._authHeaders()
});
return res.data;
} catch (err) {
if (err.response) {
const msg = err.response.data?.error || err.message;
throw new Error(`API Error ${err.response.status}: ${msg}`);
}
throw err;
}
}
// Auth
async verifyToken() { return this._request('GET', '/auth/verify'); }
// Documents
async listDocuments(params) {
const qs = new URLSearchParams(params).toString();
return this._request('GET', `/documents${qs ? '?' + qs : ''}`);
}
async getDocument(id) { return this._request('GET', `/documents/${id}`); }
async createDocument(data) { return this._request('POST', '/documents', data); }
async updateDocument(id, data) { return this._request('PUT', `/documents/${id}`, data); }
async deleteDocument(id) { return this._request('DELETE', `/documents/${id}`); }
async exportDocument(id) { return this._request('GET', `/documents/${id}/export`); }
// Documents > Tags
async addTagsToDocument(id, tags) {
return this._request('POST', `/documents/${id}/tags`, { tags });
}
// Libraries
async listLibraries() { return this._request('GET', '/libraries'); }
async getLibrary(id) { return this._request('GET', `/libraries/${id}`); }
async createLibrary(data) { return this._request('POST', '/libraries', data); }
async getLibraryTree(id) { return this._request('GET', `/libraries/${id}/tree`); }
async listLibraryDocuments(id) {
return this._request('GET', `/libraries/${id}/documents`);
}
// Tags
async listTags() { return this._request('GET', '/tags'); }
async getTagDocuments(tag) { return this._request('GET', `/tags/${tag}`); }
}
```
## 3. Flujo de Auth
### 3.1 Login Inicial
```bash
simplenote auth login snk_a1b2c3d4e5f6...
```
Flujo:
1. CLI guarda token en `~/.config/simplenote/config.json`
2. CLI llama `GET /api/v1/auth/verify` para validar
3. Si 200 → login exitoso. Si 401 → token inválido.
### 3.2 Requests Subsecuentes
Todas las requests incluyen:
```
Authorization: Bearer <token>
```
### 3.3 Verificación de Status
```bash
simplenote auth status
```
`GET /auth/verify` → muestra si token es válido.
## 4. Comandos CLI Detallados
### 4.1 Documents
```bash
# Listar con filtros
simplenote doc list --tag backend --library 550e8400... --type requirement
simplenote doc list --tag api --limit 10
# Ver documento
simplenote doc get 770e8400-e29b-41d4-a716-446655440002
# Crear
simplenote doc create \
--title "API Authentication" \
--content "# API Authentication\n\n..." \
--tags "backend,api,auth" \
--type requirement \
--priority high \
--library 550e8400-e29b-41d4-a716-446655440000
# Actualizar
simplenote doc update 770e8400... --title "Nuevo título" --content "..."
simplenote doc update 770e8400... --status approved
# Eliminar
simplenote doc delete 770e8400...
# Exportar como markdown
simplenote doc export 770e8400...
# Agregar tags
simplenote doc add-tags 770e8400... --tags "new-tag,another"
```
### 4.2 Libraries
```bash
# Listar librerías raíz
simplenote lib list
# Listar con padre
simplenote lib list --parent 550e8400...
# Ver contenido
simplenote lib get 550e8400...
# Crear
simplenote lib create --name "API Specs"
simplenote lib create --name "Sub Librería" --parent 550e8400...
# Ver árbol completo
simplenote lib tree 550e8400...
```
### 4.3 Tags
```bash
# Listar todos los tags
simplenote tag list
# Ver docs con tag
simplenote tag docs backend
```
### 4.4 Auth
```bash
# Login con token
simplenote auth login snk_xxxxx
# Verificar status
simplenote auth status
```
## 5. Manejo de Errores
```javascript
// Errores de API se transforman en mensajes claros
try {
await client.getDocument('non-existent-id');
} catch (err) {
console.error(err.message);
// "API Error 404: Document not found"
}
```
Códigos de error CLI:
- `1` — Error general (network, parse, etc)
- `2` — Token inválido / auth fallida
- `3` — Recurso no encontrado (404)
- `4` — Validación de input
## 6. Configuración de Conexión
```javascript
// ~/.config/simplenote/config.json
{
"apiUrl": "http://localhost:3000/api/v1",
"token": "snk_xxxxx",
"activeLibrary": "550e8400-e29b-41d4-a716-446655440000"
}
```
Override por línea de comandos:
```bash
simplenote --api-url http://custom:3000/api/v1 doc list
```
## 7. Dependencias CLI
```json
// package.json
{
"dependencies": {
"commander": "^11.1.0",
"axios": "^1.6.0",
"chalk": "^5.3.0",
"inquirer": "^9.2.0"
}
}
```
## 8. Ejemplo de Sesión Completa
```bash
$ simplenote auth login snk_a1b2c3d4e5f6
✓ Token verified. Logged in.
$ simplenote lib list
[
{ "id": "550e8400...", "name": "Backend Requirements", "documentCount": 5 }
]
$ simplenote doc create \
--title "Token Auth" \
--tags "backend,auth" \
--type requirement \
--library 550e8400...
{
"id": "770e8400...",
"title": "Token Auth",
"tags": ["backend", "auth"],
...
}
$ simplenote tag docs backend
[
{ "id": "770e8400...", "title": "Token Auth", ... }
]
$ simplenote doc get 770e8400...
# Muestra documento formateado con content + metadata
```
+345
View File
@@ -0,0 +1,345 @@
# SimpleNote - Formato de Datos
## 1. Archivos JSON de Metadata
Todos los archivos de metadata son JSON planos, almacenados junto a su contenido en el filesystem.
---
## 2. `.library.json`
Define una librería (equivalente a una carpeta/directorio).
**Ubicación**: `data/libraries/{library-uuid}/.library.json`
**Schema**:
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "name", "path", "createdAt", "updatedAt"],
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "UUID único de la librería"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"description": "Nombre visible de la librería"
},
"parentId": {
"type": ["string", "null"],
"format": "uuid",
"description": "ID del padre directo. Null para root."
},
"path": {
"type": "string",
"description": "Ruta relativa desde DATA_ROOT (ej: libraries/uuid)"
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Timestamp de creación ISO8601"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Timestamp de última modificación ISO8601"
}
}
}
```
**Ejemplo**:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Backend Requirements",
"parentId": null,
"path": "libraries/550e8400",
"createdAt": "2026-03-28T09:00:00Z",
"updatedAt": "2026-03-28T09:00:00Z"
}
```
**Ejemplo anidado**:
```json
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"name": "API Specs",
"parentId": "550e8400-e29b-41d4-a716-446655440000",
"path": "libraries/550e8400/sub-libraries/660e8400",
"createdAt": "2026-03-28T10:00:00Z",
"updatedAt": "2026-03-28T10:00:00Z"
}
```
---
## 3. `.meta.json`
Metadata de un documento individual.
**Ubicación**: `data/libraries/{lib-uuid}/documents/{doc-uuid}/.meta.json`
**Schema**:
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "title", "tags", "type", "libraryId", "createdAt", "updatedAt"],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"tags": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
},
"type": {
"type": "string",
"enum": ["requirement", "note", "spec", "general"]
},
"status": {
"type": "string",
"enum": ["draft", "approved", "implemented"]
},
"priority": {
"type": "string",
"enum": ["high", "medium", "low"]
},
"libraryId": {
"type": "string",
"format": "uuid"
},
"createdBy": {
"type": "string",
"description": "Agent ID o user ID que creó el documento"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
}
}
```
**Ejemplo**:
```json
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"title": "API Authentication Design",
"tags": ["backend", "api", "auth"],
"type": "requirement",
"status": "draft",
"priority": "high",
"libraryId": "550e8400-e29b-41d4-a716-446655440000",
"createdBy": "agent-001",
"createdAt": "2026-03-28T10:00:00Z",
"updatedAt": "2026-03-28T10:00:00Z"
}
```
---
## 4. `.tag-index.json`
Índice global de tags. Rebuild completo o incremental.
**Ubicación**: `data/.tag-index.json`
**Schema**:
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["version", "updatedAt", "tags"],
"properties": {
"version": {
"type": "integer",
"const": 1,
"description": "Versión del formato de índice"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Última rebuild del índice"
},
"tags": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "type": "string", "format": "uuid" },
"uniqueItems": true
},
"description": "Map tag → array de document IDs"
}
}
}
```
**Ejemplo**:
```json
{
"version": 1,
"updatedAt": "2026-03-28T12:00:00Z",
"tags": {
"backend": [
"770e8400-e29b-41d4-a716-446655440002",
"880e8400-e29b-41d4-a716-446655440003"
],
"api": [
"770e8400-e29b-41d4-a716-446655440002"
],
"auth": [
"770e8400-e29b-41d4-a716-446655440002",
"990e8400-e29b-41d4-a716-446655440004"
]
}
}
```
---
## 5. `.auth-tokens.json`
Tokens de API válidos.
**Ubicación**: `data/.auth-tokens.json`
**Schema**:
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["version", "tokens"],
"properties": {
"version": {
"type": "integer",
"const": 1
},
"tokens": {
"type": "array",
"items": {
"type": "object",
"required": ["token", "label", "createdAt"],
"properties": {
"token": {
"type": "string",
"pattern": "^snk_"
},
"label": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}
}
```
**Ejemplo**:
```json
{
"version": 1,
"tokens": [
{
"token": "snk_a1b2c3d4e5f6g7h8i9j0",
"label": "cli-default",
"createdAt": "2026-03-28T00:00:00Z"
},
{
"token": "snk_k9j8i7h6g5f4e3d2c1b",
"label": "hiro-workstation",
"createdAt": "2026-03-28T01:00:00Z"
}
]
}
```
---
## 6. Documento Markdown (`index.md`)
Archivo de contenido con frontmatter YAML.
### 6.1 Frontmatter
```yaml
---
id: REQ-001
title: Título del Requerimiento
type: requirement # requirement | note | spec | general
priority: high # high | medium | low
status: draft # draft | approved | implemented
tags: [backend, api]
createdBy: agent-001
createdAt: 2026-03-28
---
```
### 6.2 Body
Markdown standard con headers, listas, código, etc.
### 6.3 Ejemplo Completo
```markdown
---
id: REQ-001
title: API Authentication Design
type: requirement
priority: high
status: draft
tags: [backend, api, auth]
createdBy: agent-001
createdAt: 2026-03-28
---
# API Authentication Design
## Descripción
El sistema debe soportar autenticación via tokens Bearer para todas las rutas
protegidas bajo `/api/v1/*` excepto `/api/v1/auth/token`.
## Criterios de Aceptación
- [ ] Endpoint POST /api/v1/auth/token acepta credenciales y retorna token
- [ ] Middleware extrae token del header `Authorization: Bearer <token>`
- [ ] Middleware retorna 401 si header ausente o token inválido
- [ ] Token tiene prefijo `snk_` para identificación fácil
## Notas
Tokens en esta versión son secretos compartidos. Para producción se recomienda
OAuth2 o JWT firmados.
```
---
## 7. Tabla Resumen de Archivos
| Archivo | Ubicación | Propósito |
|---------|-----------|-----------|
| `.library.json` | `libraries/{id}/` | Metadata de librería |
| `.meta.json` | `libraries/{lib}/documents/{id}/` | Metadata de documento |
| `index.md` | `libraries/{lib}/documents/{id}/` | Contenido del documento |
| `.tag-index.json` | `DATA_ROOT/` | Índice global tag → docs |
| `.auth-tokens.json` | `DATA_ROOT/` | Tokens API válidos |
| `config.json` | `~/.config/simplenote/` | Config local del CLI |
-23
View File
@@ -1,23 +0,0 @@
services:
web:
build: .
container_name: simplenote-web
restart: unless-stopped
ports:
- "${SIMPLENOTE_PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ADMIN_TOKEN=${ADMIN_TOKEN}
- DATA_ROOT=/app/data
- CORS_ORIGIN=${CORS_ORIGIN:-*}
volumes:
- simplenote-data:/app/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
simplenote-data:
+116
View File
@@ -0,0 +1,116 @@
# SimpleNote - Variables de Entorno
## Template: `.env.example`
Copia este archivo a `.env` en la raíz de `simplenote-web/`.
```env
# ============ SERVER ============
PORT=3000
HOST=0.0.0.0
# ============ DATA ============
# Raíz donde se almacenan documentos y archivos de índice
# Default: ./data (relativo al proyecto)
DATA_ROOT=./data
# ============ AUTH ============
# Token admin inicial (generado en setup). No exponer en cliente.
ADMIN_TOKEN=snk_initial_admin_token_change_me
# ============ LOGGING ============
LOG_LEVEL=info
# Opciones: trace, debug, info, warn, error, fatal
# ============ CORS ============
# Orígenes permitidos para requests cross-origin
# Default: * (permitir todos)
CORS_ORIGIN=*
# Para desarrollo local:
# CORS_ORIGIN=http://localhost:5173
# Para producción:
# CORS_ORIGIN=https://simplenote.example.com
# ============ API ============
# Versión del API en URLs
API_PREFIX=/api/v1
# ============ RATE LIMITING (opcional) ============
# Requests por minuto por IP
# RATE_LIMIT_ENABLED=true
# RATE_LIMIT_WINDOW_MS=60000
# RATE_LIMIT_MAX=100
```
## Detalle de Variables
### `PORT`
- **Tipo**: integer
- **Default**: `3000`
- **Descripción**: Puerto TCP donde Express escucha conexiones entrantes.
### `HOST`
- **Tipo**: string
- **Default**: `0.0.0.0`
- **Descripción**: Host de binding. `0.0.0.0` = todas las interfaces.
### `DATA_ROOT`
- **Tipo**: string (ruta)
- **Default**: `./data`
- **Descripción**: Directorio raíz donde se guardan:
- `libraries/` — estructura de carpetas con documentos
- `.tag-index.json` — índice global de tags
- `.auth-tokens.json` — tokens de API
### `LOG_LEVEL`
- **Tipo**: enum
- **Default**: `info`
- **Opciones**: `trace`, `debug`, `info`, `warn`, `error`, `fatal`
- **Descripción**: Nivel mínimo de logs que se emiten.
### `CORS_ORIGIN`
- **Tipo**: string
- **Default**: `*`
- **Descripción**: Valor del header `Access-Control-Allow-Origin`. Usar dominio
específico en producción para seguridad.
### `API_PREFIX`
- **Tipo**: string
- **Default**: `/api/v1`
- **Descripción**: Prefijo para todas las rutas del API. Cambiar permite versionado.
---
## CLI Config: `~/.config/simplenote/config.json`
```json
{
"apiUrl": "http://localhost:3000/api/v1",
"token": "snk_your_token_here",
"activeLibrary": "550e8400-e29b-41d4-a716-446655440000"
}
```
| Campo | Descripción |
|-------|-------------|
| `apiUrl` | URL base del API (sin trailing slash) |
| `token` | Token Bearer para autenticación |
| `activeLibrary` | ID de librería activa por defecto |
---
## Flags de Línea de Comandos (CLI)
```bash
# Override apiUrl
simplenote --api-url http://custom:3000/api/v1 doc list
# Modo verbose
simplenote --verbose doc list
# Help
simplenote help
simplenote doc help create
```
+170
View File
@@ -0,0 +1,170 @@
# SimpleNote - Estructura de Carpetas
## Estructura General del Repositorio
```
simplenote-web/
├── src/
│ ├── index.js # Entry point (bind port, listen)
│ ├── app.js # Express app setup, middleware, routes
│ ├── config/
│ │ └── index.js # Env vars loader y defaults
│ ├── routes/
│ │ ├── index.js # Router principal (mount /api/v1/*)
│ │ ├── documents.js # /documents routes
│ │ ├── libraries.js # /libraries routes
│ │ ├── tags.js # /tags routes
│ │ └── auth.js # /auth routes
│ ├── services/
│ │ ├── documentService.js # Lógica de documentos
│ │ ├── libraryService.js # Lógica de librerías
│ │ └── tagService.js # Lógica de tags e indexación
│ ├── middleware/
│ │ ├── auth.js # Middleware de autenticación Bearer
│ │ └── errorHandler.js # Handler global de errores
│ ├── utils/
│ │ ├── markdown.js # Parseo y serialización de Markdown
│ │ ├── fsHelper.js # Helpers de filesystem (safe paths, etc)
│ │ ├── uuid.js # Wrapper de uuid/v4
│ │ └── errors.js # Clases de errores custom (NotFound, etc)
│ └── indexers/
│ └── tagIndexer.js # Rebuild y query del .tag-index.json
├── data/ # Raíz de documentos (DATA_ROOT)
│ ├── .tag-index.json # Índice global de tags
│ ├── .auth-tokens.json # Tokens de API válidos
│ └── libraries/
│ └── {uuid}/
│ ├── .library.json
│ ├── documents/
│ │ └── {uuid}/
│ │ ├── index.md
│ │ └── .meta.json
│ └── sub-libraries/
│ └── {child-uuid}/...
├── tests/
│ ├── unit/
│ │ ├── services/
│ │ │ ├── documentService.test.js
│ │ │ ├── libraryService.test.js
│ │ │ └── tagService.test.js
│ │ └── utils/
│ │ └── markdown.test.js
│ └── integration/
│ └── api.test.js
├── scripts/
│ └── init-data.js # Script de inicialización (crea default lib)
├── package.json
├── .env.example
└── README.md
simplenote-cli/
├── src/
│ ├── index.js # Entry point (Commander program)
│ ├── api/
│ │ └── client.js # SimpleNoteClient (axios-based)
│ ├── commands/
│ │ ├── index.js # Registra todos los subcommands
│ │ ├── doc.js # simplenote doc <subcmd>
│ │ ├── lib.js # simplenote lib <subcmd>
│ │ └── tag.js # simplenote tag <subcmd>
│ └── config/
│ ├── loader.js # Carga ~/.config/simplenote/config.json
│ └── errors.js # Errores CLI custom
├── package.json
└── README.md
```
## Estructura de Datos en Disco (DATA_ROOT)
```
data/ # DATA_ROOT (default: ./data)
├── .tag-index.json # Tag index global
├── .auth-tokens.json # Auth tokens
└── libraries/
└── {library-uuid}/
├── .library.json
├── documents/
│ └── {doc-uuid}/
│ ├── index.md # Contenido Markdown
│ └── .meta.json # Metadata (tags, timestamps)
└── sub-libraries/
└── {child-uuid}/
├── .library.json
└── documents/
└── ... (recursivo)
```
## Archivos de Metadata
### `.library.json` (por librería)
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Backend Requirements",
"parentId": null,
"path": "libraries/550e8400",
"createdAt": "2026-03-28T09:00:00Z",
"updatedAt": "2026-03-28T09:00:00Z"
}
```
### `.meta.json` (por documento)
```json
{
"id": "550e8401-e29b-41d4-a716-446655440001",
"title": "API Authentication",
"tags": ["backend", "api", "auth"],
"type": "requirement",
"status": "draft",
"priority": "high",
"libraryId": "550e8400-e29b-41d4-a716-446655440000",
"createdBy": "agent-001",
"createdAt": "2026-03-28T10:00:00Z",
"updatedAt": "2026-03-28T10:00:00Z"
}
```
### `index.md` (contenido)
```markdown
---
id: REQ-001
title: API Authentication
type: requirement
priority: high
status: draft
tags: [backend, api, auth]
createdBy: agent-001
createdAt: 2026-03-28
---
# API Authentication
## Descripción
El sistema debe soportar autenticación via tokens Bearer.
## Criterios de Aceptación
- [ ] Endpoint POST /api/auth/token
- [ ] Middleware valida Bearer token
- [ ] Retorna 401 si token inválido
```
## Archivos de Configuración Local (CLI)
```
~/.config/simplenote/
└── config.json # Config CLI local
{
"apiUrl": "http://localhost:3000/api/v1",
"token": "snk_xxxxx",
"activeLibrary": "default"
}
```
## Archivos de Configuración del Servidor
```
simplenote-web/
├── .env.example # Template de variables de entorno
├── .env # No commitear (contiene secrets)
└── .gitignore # Ignora .env, data/, node_modules/
```
-1400
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
{
"name": "simplenote-web",
"version": "0.1.0",
"description": "SimpleNote Web - Document management system with nested libraries and markdown support",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"init": "node scripts/init-data.js"
},
"keywords": ["documents", "markdown", "api"],
"author": "OpenClaw Team",
"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",
"marked": "^11.1.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}
-2058
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleNote</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<div id="app"></div>
<div id="toast-container"></div>
<script src="/js/app.js" type="module"></script>
</body>
</html>
-209
View File
@@ -1,209 +0,0 @@
// API Client for SimpleNote Web
const API_BASE = '/api/v1';
class ApiClient {
constructor() {
this.token = localStorage.getItem('sn_token');
}
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('sn_token', token);
} else {
localStorage.removeItem('sn_token');
}
}
getHeaders() {
const headers = {
'Content-Type': 'application/json'
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
async request(method, path, body = null) {
const options = {
method,
headers: this.getHeaders()
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE}${path}`, options);
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
get(path) { return this.request('GET', path); }
post(path, body) { return this.request('POST', path, body); }
put(path, body) { return this.request('PUT', path, body); }
delete(path) { return this.request('DELETE', path); }
// ===== Auth =====
async login(token) {
try {
this.setToken(token); // Set token BEFORE making request
const data = await this.get('/auth/verify');
return data;
} catch (e) {
this.setToken(null);
throw e;
}
}
// ===== Documents =====
getDocuments(params = {}) {
const query = new URLSearchParams(params).toString();
return this.get(`/documents${query ? '?' + query : ''}`);
}
getDocument(id) {
return this.get(`/documents/${id}`);
}
createDocument(data) {
return this.post('/documents', data);
}
updateDocument(id, data) {
return this.put(`/documents/${id}`, data);
}
deleteDocument(id) {
return this.delete(`/documents/${id}`);
}
exportDocument(id) {
return fetch(`${API_BASE}/documents/${id}/export`, {
headers: this.getHeaders()
}).then(r => r.text());
}
addDocumentTags(documentId, tags) {
return this.post(`/documents/${documentId}/tags`, { tags });
}
moveDocumentToFolder(documentId, folderId) {
return this.put(`/documents/${documentId}`, { folderId });
}
// ===== Tags =====
getTags() {
return this.get('/tags');
}
// GET /tags/:tag - get documents by tag
getDocumentsByTag(tag) {
return this.get(`/tags/${encodeURIComponent(tag)}`);
}
// ===== Libraries =====
getLibraries() {
return this.get('/libraries');
}
getLibrary(id) {
return this.get(`/libraries/${id}`);
}
createLibrary(data) {
return this.post('/libraries', data);
}
updateLibrary(id, data) {
return this.put(`/libraries/${id}`, data);
}
deleteLibrary(id) {
return this.delete(`/libraries/${id}`);
}
// GET /libraries/:id/tree
getLibraryTree(id) {
return this.get(`/libraries/${id}/tree`);
}
// GET /libraries/:id/documents
getLibraryDocuments(id) {
return this.get(`/libraries/${id}/documents`);
}
// ===== Projects =====
getProjects() {
return this.get('/projects');
}
getProject(id) {
return this.get(`/projects/${id}`);
}
createProject(data) {
return this.post('/projects', data);
}
updateProject(id, data) {
return this.put(`/projects/${id}`, data);
}
deleteProject(id) {
return this.delete(`/projects/${id}`);
}
// GET /projects/:id/tree
getProjectTree(id) {
return this.get(`/projects/${id}/tree`);
}
// GET /projects/:id/documents
getProjectDocuments(id) {
return this.get(`/projects/${id}/documents`);
}
// ===== Folders =====
getFolders(project = null, parentId = null) {
const params = new URLSearchParams();
if (project) params.append('project', project);
if (parentId) params.append('parentId', parentId);
const query = params.toString();
return this.get(`/folders${query ? '?' + query : ''}`);
}
getFolder(id) {
return this.get(`/folders/${id}`);
}
createFolder(data) {
return this.post('/folders', data);
}
updateFolder(id, data) {
return this.put(`/folders/${id}`, data);
}
deleteFolder(id) {
return this.delete(`/folders/${id}`);
}
// GET /folders/:id/documents
getFolderDocuments(id) {
return this.get(`/folders/${id}/documents`);
}
// GET /folders/:id/tree
getFolderTree(id) {
return this.get(`/folders/${id}/tree`);
}
}
export const api = new ApiClient();
-160
View File
@@ -1,160 +0,0 @@
// SimpleNote Web - Main Application
import { api } from './api.js';
import { renderLogin, initLoginHandlers } from './views/login.js';
import { renderProjects } from './views/projects.js';
import { renderProjectView } from './views/projectView.js';
import { renderDashboard } from './views/dashboard.js';
import { renderDocument } from './views/document.js';
import { renderEditor } from './views/editor.js';
class App {
constructor() {
this.currentView = null;
this.state = {
token: localStorage.getItem('sn_token'),
view: 'projects', // Default to projects view
params: {}
};
}
async init() {
if (!this.state.token) {
this.renderLogin();
return;
}
api.setToken(this.state.token);
try {
await api.login(this.state.token);
this.render();
} catch (e) {
this.state.token = null;
localStorage.removeItem('sn_token');
this.renderLogin();
}
}
renderLogin() {
const app = document.getElementById('app');
app.innerHTML = renderLogin();
initLoginHandlers(async (token) => {
try {
await api.login(token);
this.state.token = token;
this.state.view = 'projects';
this.render();
} catch (e) {
return 'Invalid token';
}
});
}
async render() {
const app = document.getElementById('app');
switch (this.state.view) {
case 'projects':
await renderProjects(this);
break;
case 'project':
await renderProjectView(this);
break;
case 'dashboard':
await renderDashboard(this);
break;
case 'document':
await renderDocument(this);
break;
case 'editor':
renderEditor(this);
break;
default:
await renderProjects(this);
}
}
navigate(view, params = {}) {
this.state.view = view;
this.state.params = params;
this.render();
}
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const escapedMessage = this.escapeHtml(message);
toast.innerHTML = `
<span class="toast-message">${escapedMessage}</span>
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
async confirmDelete(message) {
return new Promise((resolve) => {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
const escapedMessage = this.escapeHtml(message);
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header">
<span>⚠️</span>
<h3>Confirm Delete</h3>
</div>
<div class="modal-body">
<p>${escapedMessage}</p>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancel-btn">Cancel</button>
<button class="btn btn-danger" id="confirm-btn">Delete</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
backdrop.querySelector('#cancel-btn').onclick = () => {
backdrop.remove();
resolve(false);
};
backdrop.querySelector('#confirm-btn').onclick = () => {
backdrop.remove();
resolve(true);
};
backdrop.onclick = (e) => {
if (e.target === backdrop) {
backdrop.remove();
resolve(false);
}
};
});
}
}
window.app = new App();
app.init();
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && window.app.state.view === 'editor') {
window.app.navigate('project', { id: window.app.state.params.projectId });
}
if ((e.ctrlKey || e.metaKey) && e.key === 'n' && (window.app.state.view === 'projects' || window.app.state.view === 'project')) {
e.preventDefault();
if (window.app.state.view === 'project') {
window.showNewDocModal(window.app.state.params.id, '');
} else {
window.showNewProjectModal();
}
}
});
-46
View File
@@ -1,46 +0,0 @@
// Modal Component
export function showModal({ title, content, onConfirm, onCancel, confirmText = 'Confirm', cancelText = 'Cancel', danger = false }) {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>${title}</h3>
</div>
<div class="modal-body">
${content}
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="modal-cancel">${cancelText}</button>
<button class="btn ${danger ? 'btn-danger' : 'btn-primary'}" id="modal-confirm">${confirmText}</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
backdrop.querySelector('#modal-cancel').onclick = () => {
backdrop.remove();
if (onCancel) onCancel();
};
backdrop.querySelector('#modal-confirm').onclick = () => {
backdrop.remove();
if (onConfirm) onConfirm();
};
backdrop.onclick = (e) => {
if (e.target === backdrop) {
backdrop.remove();
if (onCancel) onCancel();
}
};
return backdrop;
}
export function hideModal(backdrop) {
if (backdrop && backdrop.parentElement) {
backdrop.remove();
}
}
-66
View File
@@ -1,66 +0,0 @@
// Sidebar Component
export function renderSidebar({ libraries, tags, selectedLibrary, selectedTag, onSelectLibrary, onSelectTag, onHome }) {
const buildLibraryTree = (libs, parentId = null, depth = 0) => {
return libs
.filter(l => l.parentId === parentId)
.map(lib => {
const children = libs.filter(l => l.parentId === lib.id);
const hasChildren = children.length > 0;
const isSelected = selectedLibrary === lib.id;
return `
<div class="tree-node">
<div class="tree-item ${isSelected ? 'active' : ''}" data-action="library" data-library-id="${lib.id}">
<span class="tree-toggle ${hasChildren ? 'expanded' : ''}" style="padding-left:${depth * 12}px">
${hasChildren ? '▶' : ''}
</span>
<span class="icon">📁</span>
<span class="label">${escapeHtml(lib.name)}</span>
</div>
${hasChildren ? `<div class="tree-children">${buildLibraryTree(libraries, lib.id, depth + 1)}</div>` : ''}
</div>
`;
})
.join('');
};
return `
<div class="sidebar-scroll">
<div class="sidebar-section">
<h3>📚 Libraries</h3>
<div class="library-tree">
<div class="tree-item ${!selectedLibrary ? 'active' : ''}" data-action="home">
<span class="icon">🏠</span>
<span class="label">All Documents</span>
</div>
${buildLibraryTree(libraries)}
</div>
</div>
<div class="sidebar-section">
<h3>🏷️ Tags</h3>
<div class="tag-list">
${tags.map(tag => `
<div class="tag-item ${selectedTag === tag.name ? 'active' : ''}" data-action="tag" data-tag="${escapeHtml(tag.name)}">
<span>#${escapeHtml(tag.name)}</span>
<span class="tag-count">${tag.count}</span>
</div>
`).join('')}
</div>
</div>
<div class="quick-links">
<a class="quick-link" data-action="home">📋 All Documents</a>
<a class="quick-link" href="#" onclick="window.app.navigate('projects'); return false;">📂 Projects</a>
<a class="quick-link" href="#" onclick="window.showNewDocModal(); return false;">📄 New Document</a>
<a class="quick-link" href="#" onclick="window.showNewLibraryModal(); return false;">📁 New Library</a>
</div>
</div>
`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
-390
View File
@@ -1,390 +0,0 @@
// Dashboard View
import { api } from '../api.js';
import { renderSidebar } from '../components/sidebar.js';
export async function renderDashboard(app) {
let documents = [];
let libraries = [];
let tags = [];
let searchQuery = '';
let selectedTag = app.state.selectedTag || null;
let selectedLibrary = app.state.selectedLibrary || null;
try {
const [docResult, libResult, tagResult] = await Promise.all([
api.getDocuments(),
api.getLibraries(),
api.getTags()
]);
documents = docResult.documents || [];
libraries = libResult.libraries || [];
tags = tagResult.tags || [];
} catch (e) {
app.showToast('Failed to load data', 'error');
}
const appEl = document.getElementById('app');
function render() {
// Store callbacks for sidebar event handlers
const sidebarCallbacks = {
onSelectLibrary: (id) => {
selectedLibrary = id;
selectedTag = null;
app.state.selectedLibrary = id;
app.state.selectedTag = null;
render();
},
onSelectTag: (tag) => {
selectedTag = tag;
selectedLibrary = null;
app.state.selectedTag = tag;
app.state.selectedLibrary = null;
render();
},
onHome: () => {
selectedTag = null;
selectedLibrary = null;
app.state.selectedTag = null;
app.state.selectedLibrary = null;
render();
}
};
window.__sidebarCallbacks = sidebarCallbacks;
let filteredDocs = documents;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filteredDocs = filteredDocs.filter(d =>
d.title.toLowerCase().includes(q) ||
(d.content && d.content.toLowerCase().includes(q))
);
}
if (selectedTag) {
filteredDocs = filteredDocs.filter(d =>
d.tags && d.tags.includes(selectedTag)
);
}
if (selectedLibrary) {
filteredDocs = filteredDocs.filter(d =>
d.libraryId === selectedLibrary
);
}
appEl.innerHTML = `
<header class="app-header">
<button class="mobile-nav-btn" onclick="toggleMobileSidebar()" title="Menu">☰</button>
<div class="logo">📝 SimpleNote</div>
<div class="search-box">
<span class="icon">🔍</span>
<input type="text" id="search-input" placeholder="Search documents..." value="${searchQuery}">
</div>
<div class="header-actions">
<button class="btn btn-primary" onclick="window.showNewDocModal()">+ New</button>
</div>
</header>
<div class="sidebar-overlay" onclick="closeMobileSidebar()"></div>
<div class="app-layout">
<aside class="sidebar" id="sidebar">
<button class="sidebar-close-btn" onclick="closeMobileSidebar()">✕</button>
${renderSidebar({
libraries,
tags,
selectedLibrary,
selectedTag,
...sidebarCallbacks
})}
</aside>
<main class="main-content">
<div class="content-header">
<h1>${selectedLibrary ? getLibraryName(libraries, selectedLibrary) : selectedTag ? `#${selectedTag}` : 'All Documents'}</h1>
<div class="header-actions">
<button class="btn btn-primary" onclick="window.showNewDocModal()">+ New</button>
</div>
</div>
<div class="content-body">
${filteredDocs.length === 0 ? `
<div class="empty-state">
<div class="icon">📄</div>
<h3>No documents found</h3>
<p>${searchQuery || selectedTag ? 'Try adjusting your filters' : 'Create your first document'}</p>
<button class="btn btn-primary" onclick="window.showNewDocModal()">+ Create Document</button>
</div>
` : `
<div class="doc-grid">
${filteredDocs.map(doc => renderDocCard(doc)).join('')}
</div>
`}
</div>
</main>
</div>
`;
// Mobile sidebar functions
window.toggleMobileSidebar = function() {
const sidebar = document.getElementById('sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) {
sidebar.classList.toggle('mobile-open');
if (overlay) overlay.classList.toggle('active');
}
};
window.closeMobileSidebar = function() {
const sidebar = document.getElementById('sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) {
sidebar.classList.remove('mobile-open');
if (overlay) overlay.classList.remove('active');
}
};
// Event listeners
const searchInput = document.getElementById('search-input');
searchInput.oninput = (e) => {
searchQuery = e.target.value;
render();
};
// Sidebar item listeners
document.querySelectorAll('[data-action="home"]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
sidebarCallbacks.onHome();
closeMobileSidebar();
});
});
document.querySelectorAll('[data-action="library"]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
sidebarCallbacks.onSelectLibrary(el.getAttribute('data-library-id'));
closeMobileSidebar();
});
});
document.querySelectorAll('[data-action="tag"]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
sidebarCallbacks.onSelectTag(el.getAttribute('data-tag'));
closeMobileSidebar();
});
});
}
render();
}
function getLibraryName(libraries, id) {
const lib = libraries.find(l => l.id === id);
return lib ? lib.name : 'Unknown';
}
function renderDocCard(doc) {
const priorityEmoji = { high: '🔴', medium: '🟡', low: '🟢' };
const priority = doc.priority || 'medium';
return `
<div class="doc-card" onclick="window.app.navigate('document', {id: '${doc.id}'})">
<div class="doc-card-header">
<span class="doc-id">${doc.id}</span>
<span class="type-badge ${doc.type || 'general'}">${doc.type || 'general'}</span>
</div>
<h3 class="doc-title">${escapeHtml(doc.title)}</h3>
${doc.tags && doc.tags.length ? `
<div class="doc-tags">
${doc.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
</div>
` : ''}
<div class="doc-meta">
<span class="doc-meta-item">📅 ${formatDate(doc.createdAt)}</span>
<span class="doc-meta-item">👤 ${escapeHtml(doc.author || 'unknown')}</span>
<span class="status-badge ${doc.status || 'draft'}">${doc.status || 'draft'}</span>
<span class="priority-indicator">${priorityEmoji[priority]}</span>
</div>
</div>
`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// Global function: Show modal to create new document (with library selection)
window.showNewDocModal = async function() {
let libraries = [];
try {
const libResult = await api.getLibraries();
libraries = libResult.libraries || [];
} catch (e) {}
let step = 1; // 1 = choose library, 2 = create new library
let newLibraryName = '';
function render() {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
if (step === 1) {
backdrop.innerHTML = `
<div class="modal" style="min-width: 450px;">
<div class="modal-header">
<span>📄</span>
<h3>Create New Document</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<p style="color: var(--color-text-secondary); margin-bottom: 16px;">Choose a library for your document:</p>
<div class="form-group">
<label for="doc-library-select">Library</label>
<select id="doc-library-select" class="form-control">
<option value="">— No Library —</option>
${libraries.map(l => `<option value="${l.id}">📁 ${escapeHtml(l.name)}</option>`).join('')}
<option value="__new__">+ Create New Library</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="doc-next-btn">Next →</button>
</div>
</div>
`;
} else {
backdrop.innerHTML = `
<div class="modal" style="min-width: 450px;">
<div class="modal-header">
<span>📁</span>
<h3>Create New Library</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-library-name">Library Name</label>
<input type="text" id="new-library-name" class="form-control" placeholder="e.g., Backend Requirements" value="${escapeHtml(newLibraryName)}">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="doc-back-btn">← Back</button>
<button class="btn btn-primary" id="doc-create-lib-btn">Create Library</button>
</div>
</div>
`;
}
document.body.appendChild(backdrop);
if (step === 1) {
const select = document.getElementById('doc-library-select');
const nextBtn = document.getElementById('doc-next-btn');
nextBtn.onclick = () => {
const value = select.value;
if (value === '__new__') {
step = 2;
backdrop.remove();
render();
} else {
backdrop.remove();
window.app.navigate('editor', { libraryId: value || null });
}
};
} else {
const backBtn = document.getElementById('doc-back-btn');
const createBtn = document.getElementById('doc-create-lib-btn');
const nameInput = document.getElementById('new-library-name');
backBtn.onclick = () => {
newLibraryName = nameInput.value;
step = 1;
backdrop.remove();
render();
};
createBtn.onclick = async () => {
const name = nameInput.value.trim();
if (!name) {
window.app.showToast('Please enter a library name', 'error');
return;
}
try {
const result = await api.createLibrary({ name });
backdrop.remove();
window.app.showToast('Library created', 'success');
window.app.navigate('editor', { libraryId: result.id });
} catch (e) {
window.app.showToast('Failed to create library: ' + e.message, 'error');
}
};
}
backdrop.onclick = (e) => {
if (e.target === backdrop) backdrop.remove();
};
}
render();
};
// Global function: Show modal to create new library
window.showNewLibraryModal = function() {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal" style="min-width: 450px;">
<div class="modal-header">
<span>📁</span>
<h3>Create New Library</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<p style="color: var(--color-text-secondary); margin-bottom: 16px;">Libraries help you organize your documents.</p>
<div class="form-group">
<label for="new-lib-name">Library Name</label>
<input type="text" id="new-lib-name" class="form-control" placeholder="e.g., Backend Requirements">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="create-lib-btn">Create Library</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const nameInput = document.getElementById('new-lib-name');
const createBtn = document.getElementById('create-lib-btn');
createBtn.onclick = async () => {
const name = nameInput.value.trim();
if (!name) {
window.app.showToast('Please enter a library name', 'error');
return;
}
try {
await api.createLibrary({ name });
backdrop.remove();
window.app.showToast('Library created successfully', 'success');
window.app.navigate('dashboard');
} catch (e) {
window.app.showToast('Failed to create library: ' + e.message, 'error');
}
};
backdrop.onclick = (e) => {
if (e.target === backdrop) backdrop.remove();
};
nameInput.focus();
};
-199
View File
@@ -1,199 +0,0 @@
// Document View
import { api } from '../api.js';
export async function renderDocument(app) {
const { id, projectId } = app.state.params;
let doc;
try {
doc = await api.getDocument(id);
} catch (e) {
app.showToast('Failed to load document', 'error');
app.navigate('projects');
return;
}
window.backToProject = () => {
if (projectId) {
app.navigate('project', { id: projectId });
} else {
app.navigate('projects');
}
};
// Mobile menu functions
window.toggleDocumentMenu = function() {
const menu = document.getElementById('document-menu');
if (menu) menu.classList.toggle('open');
};
window.closeDocumentMenu = function() {
const menu = document.getElementById('document-menu');
if (menu) menu.classList.remove('open');
};
const appEl = document.getElementById('app');
function render() {
const priorityEmoji = { high: '🔴', medium: '🟡', low: '🟢' };
const priority = doc.priority || 'medium';
const renderedContent = renderMarkdown(doc.content || '');
appEl.innerHTML = `
<header class="app-header">
<button type="button" class="mobile-nav-btn" onclick="window.toggleDocumentMenu()" title="Menu">☰</button>
<button type="button" class="btn btn-ghost" onclick="backToProject()">← Back</button>
<div class="breadcrumb-nav">
<span class="breadcrumb-link" onclick="window.app.navigate('projects')">Projects</span>
<span class="breadcrumb-sep">/</span>
${projectId ? `<span class="breadcrumb-link" onclick="window.app.navigate('project', {id: '${projectId}'})">Project</span><span class="breadcrumb-sep">/</span>` : ''}
<span class="breadcrumb-current">${escapeHtml(doc.title || 'Document')}</span>
</div>
<div class="header-actions">
<button type="button" class="btn btn-ghost" onclick="window.app.navigate('editor', {id: '${doc.id}', projectId: '${projectId || ''}'})">✏️ Edit</button>
<button type="button" class="btn btn-ghost" onclick="exportDoc()">📥 Export</button>
<button type="button" class="btn btn-ghost danger" onclick="deleteDoc()">🗑️ Delete</button>
</div>
</header>
<div class="mobile-menu" id="document-menu">
<div class="mobile-menu-header">
<span>Menu</span>
<button onclick="window.closeDocumentMenu()">✕</button>
</div>
<div class="mobile-menu-content">
<a href="#" onclick="backToProject(); return false;">← Back to ${projectId ? 'Project' : 'Projects'}</a>
<a href="#" onclick="window.app.navigate('editor', {id: '${doc.id}', projectId: '${projectId || ''}'}); window.closeDocumentMenu(); return false;">✏️ Edit Document</a>
<a href="#" onclick="window.app.navigate('projects'); window.closeDocumentMenu(); return false;">📋 All Projects</a>
</div>
</div>
<main class="main-content">
<div class="content-body">
<div class="doc-viewer">
<div class="doc-content">
<div class="doc-viewer-header">
<span class="doc-id">${doc.id}</span>
<span class="type-badge ${doc.type || 'general'}">${doc.type || 'general'}</span>
</div>
<div class="prose">${renderedContent}</div>
</div>
<aside class="doc-sidebar">
<div class="meta-section">
<div class="meta-header">Details</div>
<div class="meta-body">
<div class="meta-row">
<span class="meta-label">Status</span>
<span class="status-badge ${doc.status || 'draft'}">${doc.status || 'draft'}</span>
</div>
<div class="meta-row">
<span class="meta-label">Priority</span>
<span class="meta-value">${priorityEmoji[priority]} ${priority}</span>
</div>
<div class="meta-row">
<span class="meta-label">Author</span>
<span class="meta-value">${escapeHtml(doc.author || 'unknown')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Created</span>
<span class="meta-value">${formatDate(doc.createdAt)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Updated</span>
<span class="meta-value">${formatDate(doc.updatedAt)}</span>
</div>
</div>
</div>
${doc.tags && doc.tags.length ? `
<div class="meta-section">
<div class="meta-header">Tags</div>
<div class="meta-body doc-tags">
${doc.tags.map(t => `<span class="tag-pill" onclick="filterByTag('${escapeHtml(t)}'); event.stopPropagation();">${escapeHtml(t)}</span>`).join('')}
</div>
</div>
` : ''}
</aside>
</div>
</div>
</main>
`;
window.filterByTag = (tag) => {
// Store the tag to filter by in app state so dashboard can pick it up
app.state.selectedTag = tag;
app.state.selectedLibrary = null;
backToProject();
};
}
render();
async function exportDoc() {
try {
const markdown = await api.exportDocument(id);
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Sanitize filename to prevent path traversal
const safeFilename = (doc.title || 'untitled')
.replace(/[^a-zA-Z0-9_\-\s]/g, '')
.replace(/\s+/g, '-')
.substring(0, 100);
a.download = `${doc.id}-${safeFilename}.md`;
a.click();
URL.revokeObjectURL(url);
app.showToast('Document exported', 'success');
} catch (e) {
app.showToast('Failed to export', 'error');
}
}
async function deleteDoc() {
const confirmed = await app.confirmDelete(`Delete "${doc.title}"? This cannot be undone.`);
if (confirmed) {
try {
await api.deleteDocument(id);
app.showToast('Document deleted', 'success');
backToProject();
} catch (e) {
app.showToast('Failed to delete', 'error');
}
}
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function renderMarkdown(content) {
// Simple markdown rendering using marked library if available
if (typeof marked !== 'undefined') {
return marked.parse(content);
}
// Fallback simple rendering
return content
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)$/gm, (match) => {
if (match.startsWith('<')) return match;
return `<p>${match}</p>`;
});
}
-309
View File
@@ -1,309 +0,0 @@
// Editor View
import { api } from '../api.js';
export async function renderEditor(app) {
const { id, projectId, libraryId } = app.state.params;
let doc = null;
let libraries = [];
if (id) {
try {
doc = await api.getDocument(id);
} catch (e) {
app.showToast('Failed to load document', 'error');
app.navigate(projectId ? 'project' : 'projects', { id: projectId });
return;
}
}
try {
const libResult = await api.getLibraries();
libraries = libResult.libraries || [];
} catch (e) {}
const isNew = !id;
const appEl = document.getElementById('app');
// Determine back navigation target
const backTarget = projectId
? { view: 'project', params: { id: projectId } }
: { view: 'projects', params: {} };
const backToParent = () => {
if (projectId) {
app.navigate('project', { id: projectId });
} else {
app.navigate('projects');
}
};
// Mobile menu functions
window.toggleEditorMenu = function() {
const menu = document.getElementById('editor-menu');
if (menu) menu.classList.toggle('open');
};
window.closeEditorMenu = function() {
const menu = document.getElementById('editor-menu');
if (menu) menu.classList.remove('open');
};
let formData = {
title: doc?.title || '',
content: doc?.content || '',
tags: doc?.tags?.join(', ') || '',
type: doc?.type || 'general',
priority: doc?.priority || 'medium',
status: doc?.status || 'draft',
libraryId: doc?.libraryId || libraryId || ''
};
let showPreview = false;
let hasChanges = false;
function render() {
appEl.innerHTML = `
<header class="app-header">
<button type="button" class="mobile-nav-btn" onclick="window.toggleEditorMenu()" title="Menu">☰</button>
<button type="button" class="btn btn-ghost" onclick="handleCancel()">Cancel</button>
<div class="breadcrumb-nav">
<span class="breadcrumb-link" onclick="window.app.navigate('projects')">Projects</span>
<span class="breadcrumb-sep">/</span>
${projectId ? `<span class="breadcrumb-link" onclick="window.app.navigate('project', {id: '${projectId}'})">Project</span><span class="breadcrumb-sep">/</span>` : ''}
<span class="breadcrumb-current">${isNew ? 'New Document' : escapeHtml(formData.title)}</span>
</div>
<button type="button" class="btn btn-primary" onclick="handleSave()">Save</button>
</header>
<div class="mobile-menu" id="editor-menu">
<div class="mobile-menu-header">
<span>Menu</span>
<button onclick="window.closeEditorMenu()">✕</button>
</div>
<div class="mobile-menu-content">
<a href="#" onclick="handleCancel(); return false;">← Cancel & Go Back</a>
<a href="#" onclick="handleSave(); window.closeEditorMenu(); return false;">💾 Save Document</a>
<a href="#" onclick="window.app.navigate('projects'); window.closeEditorMenu(); return false;">📋 All Projects</a>
</div>
</div>
<main class="main-content">
<div class="editor-container">
<form class="editor-form" id="editor-form">
<div class="form-row">
<div class="form-group" style="flex:2">
<label for="title">Title</label>
<input type="text" id="title" value="${escapeHtml(formData.title)}" required>
</div>
<div class="form-group">
<label for="libraryId">Library</label>
<select id="libraryId">
<option value="">None</option>
${libraries.map(l => `<option value="${l.id}" ${formData.libraryId === l.id ? 'selected' : ''}>${escapeHtml(l.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="type">Type</label>
<select id="type">
<option value="requirement" ${formData.type === 'requirement' ? 'selected' : ''}>Requirement</option>
<option value="note" ${formData.type === 'note' ? 'selected' : ''}>Note</option>
<option value="spec" ${formData.type === 'spec' ? 'selected' : ''}>Specification</option>
<option value="general" ${formData.type === 'general' ? 'selected' : ''}>General</option>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status">
<option value="draft" ${formData.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="approved" ${formData.status === 'approved' ? 'selected' : ''}>Approved</option>
<option value="implemented" ${formData.status === 'implemented' ? 'selected' : ''}>Implemented</option>
</select>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority">
<option value="high" ${formData.priority === 'high' ? 'selected' : ''}>🔴 High</option>
<option value="medium" ${formData.priority === 'medium' ? 'selected' : ''}>🟡 Medium</option>
<option value="low" ${formData.priority === 'low' ? 'selected' : ''}>🟢 Low</option>
</select>
</div>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" value="${escapeHtml(formData.tags)}" placeholder="backend, api, auth">
</div>
<div class="form-group full-width">
<div class="editor-toolbar">
<button type="button" class="toolbar-btn" onclick="insertFormat('**', '**')" title="Bold">B</button>
<button type="button" class="toolbar-btn" onclick="insertFormat('*', '*')" title="Italic"><em>I</em></button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertLine('# ')" title="Heading 1">H1</button>
<button type="button" class="toolbar-btn" onclick="insertLine('## ')" title="Heading 2">H2</button>
<button type="button" class="toolbar-btn" onclick="insertLine('### ')" title="Heading 3">H3</button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertLine('- ')" title="List">•</button>
<button type="button" class="toolbar-btn" onclick="insertLine('1. ')" title="Numbered List">1.</button>
<button type="button" class="toolbar-btn" onclick="insertLine('- [ ] ')" title="Task">☐</button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertFormat('\`', '\`')" title="Code">&lt;/&gt;</button>
<div class="toolbar-tabs">
<button type="button" class="tab-btn ${!showPreview ? 'active' : ''}" onclick="togglePreview(false)">Write</button>
<button type="button" class="tab-btn ${showPreview ? 'active' : ''}" onclick="togglePreview(true)">Preview</button>
</div>
</div>
<div class="editor-content ${showPreview ? 'show-preview' : ''}" id="editor-content">
<div class="editor-pane">
<textarea id="content" placeholder="Write your content in Markdown...">${escapeHtml(formData.content)}</textarea>
</div>
<div class="preview-pane prose">${renderMarkdown(formData.content)}</div>
</div>
</div>
</form>
</div>
</main>
`;
// Event listeners
const titleInput = document.getElementById('title');
const contentInput = document.getElementById('content');
const tagsInput = document.getElementById('tags');
const typeInput = document.getElementById('type');
const statusInput = document.getElementById('status');
const priorityInput = document.getElementById('priority');
const libraryInput = document.getElementById('libraryId');
const inputs = [titleInput, contentInput, tagsInput, typeInput, statusInput, priorityInput, libraryInput];
inputs.forEach(input => {
if (input) {
input.addEventListener('input', () => {
hasChanges = true;
updateFormData();
if (showPreview) {
document.querySelector('.preview-pane').innerHTML = renderMarkdown(formData.content);
}
});
}
});
window.insertFormat = (before, after) => {
const textarea = document.getElementById('content');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selected = text.substring(start, end);
textarea.value = text.substring(0, start) + before + selected + after + text.substring(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = end + before.length;
hasChanges = true;
};
window.insertLine = (prefix) => {
const textarea = document.getElementById('content');
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart--;
textarea.value = text.substring(0, lineStart) + prefix + text.substring(lineStart);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = lineStart + prefix.length;
hasChanges = true;
};
window.togglePreview = (show) => {
showPreview = show;
const content = document.getElementById('editor-content');
if (show) {
content.classList.add('show-preview');
} else {
content.classList.remove('show-preview');
}
};
window.handleCancel = async () => {
if (hasChanges) {
const confirmed = await app.confirmDelete('You have unsaved changes. Discard?');
if (!confirmed) return;
}
if (projectId) {
app.navigate('project', { id: projectId });
} else {
app.navigate('projects');
}
};
window.handleSave = async () => {
updateFormData();
const data = {
title: formData.title,
content: formData.content,
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
type: formData.type,
priority: formData.priority,
status: formData.status,
libraryId: formData.libraryId || null
};
try {
if (isNew) {
await api.createDocument({...data, projectId});
} else {
await api.updateDocument(id, data);
}
app.showToast('Document saved', 'success');
if (projectId) {
app.navigate('project', { id: projectId });
} else {
app.navigate('projects');
}
} catch (e) {
app.showToast('Failed to save: ' + e.message, 'error');
}
};
function updateFormData() {
formData = {
title: titleInput.value,
content: contentInput.value,
tags: tagsInput.value,
type: typeInput.value,
priority: priorityInput.value,
status: statusInput.value,
libraryId: libraryInput.value
};
}
}
render();
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function renderMarkdown(content) {
if (!content) return '<p style="color:var(--color-text-muted)">Nothing to preview</p>';
if (typeof marked !== 'undefined') {
return marked.parse(content);
}
// Fallback
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n\n/g, '</p><p>');
}
-34
View File
@@ -1,34 +0,0 @@
// Login View
export function renderLogin() {
return `
<div class="login-screen">
<div class="login-card">
<h1>📝 SimpleNote</h1>
<p>Enter your API token to continue</p>
<form class="login-form" id="login-form">
<div class="form-group">
<input type="password" id="token-input" placeholder="API Token" autocomplete="off" required>
</div>
<p class="login-error" id="login-error">Invalid token. Please try again.</p>
<button type="submit" class="btn btn-primary" style="width:100%">Login</button>
</form>
</div>
</div>
`;
}
export function initLoginHandlers(onLogin) {
const form = document.getElementById('login-form');
const errorEl = document.getElementById('login-error');
form.onsubmit = async (e) => {
e.preventDefault();
errorEl.classList.remove('visible');
const token = document.getElementById('token-input').value;
const error = await onLogin(token);
if (error) {
errorEl.classList.add('visible');
}
};
}
-569
View File
@@ -1,569 +0,0 @@
// Project View - Shows a single project with folder tree
import { api } from '../api.js';
import { renderSidebar } from '../components/sidebar.js';
export async function renderProjectView(app) {
const projectId = app.state.params.id;
let project = null;
let folders = [];
let documents = [];
let tags = [];
let selectedFolderId = null;
let selectedTag = null;
let searchQuery = '';
try {
const [projResult, docsResult, tagsResult] = await Promise.all([
api.getProject(projectId),
api.getDocuments({ project: projectId }),
api.getTags()
]);
project = projResult;
documents = docsResult.documents || [];
tags = tagsResult.tags || [];
// Get folders
try {
const foldersResult = await api.getFolders(projectId);
folders = foldersResult.folders || [];
} catch (e) {
folders = [];
}
} catch (e) {
app.showToast('Failed to load project', 'error');
app.navigate('projects');
return;
}
const appEl = document.getElementById('app');
function render() {
// Build folder tree
const folderTree = buildFolderTree(folders, null);
// Filter documents
let filteredDocs = documents;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filteredDocs = filteredDocs.filter(d =>
d.title.toLowerCase().includes(q) ||
(d.content && d.content.toLowerCase().includes(q))
);
}
if (selectedFolderId !== null) {
filteredDocs = filteredDocs.filter(d => d.folderId === selectedFolderId);
}
if (selectedTag) {
filteredDocs = filteredDocs.filter(d =>
d.tags && d.tags.includes(selectedTag)
);
}
// Determine current folder name
let currentFolderName = 'All Documents';
if (selectedFolderId !== null) {
const folder = folders.find(f => f.id === selectedFolderId);
if (folder) currentFolderName = folder.name;
} else if (selectedTag) {
currentFolderName = `#${selectedTag}`;
}
appEl.innerHTML = `
<header class="app-header">
<button class="mobile-nav-btn" onclick="window.toggleMobileSidebar()" title="Menu">☰</button>
<div class="logo">📝 SimpleNote</div>
<div class="breadcrumb-nav">
<span class="breadcrumb-link" onclick="window.app.navigate('projects')">Projects</span>
<span class="breadcrumb-sep">/</span>
<span class="breadcrumb-current">${escapeHtml(project.name)}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" onclick="window.showEditProjectModal('${project.id}')" title="Edit Project">✏️</button>
<button class="btn btn-ghost" onclick="window.confirmDeleteProject('${project.id}')" title="Delete Project">🗑️</button>
</div>
</header>
<div class="sidebar-overlay" onclick="window.closeMobileSidebar()"></div>
<div class="app-layout">
<aside class="sidebar project-sidebar" id="sidebar">
<button class="sidebar-close-btn" onclick="window.closeMobileSidebar()">✕</button>
<div class="sidebar-scroll">
<div class="sidebar-section">
<div class="section-header">
<h3>📁 Folders</h3>
<button class="btn btn-ghost btn-icon-only" onclick="window.showNewFolderModal('${projectId}', null)" title="New Folder">+</button>
</div>
<div class="folder-tree">
<div class="tree-item ${selectedFolderId === null && !selectedTag ? 'active' : ''}" data-action="folder" data-folder-id="">
<span class="icon">📋</span>
<span class="label">All Documents</span>
<span class="count">${documents.length}</span>
</div>
${folderTree}
</div>
</div>
<div class="sidebar-section">
<h3>🏷️ Tags</h3>
<div class="tag-list">
${tags.map(tag => `
<div class="tag-item ${selectedTag === tag.name ? 'active' : ''}" data-action="tag" data-tag="${escapeHtml(tag.name)}">
<span>#${escapeHtml(tag.name)}</span>
<span class="tag-count">${tag.count}</span>
</div>
`).join('')}
</div>
</div>
<div class="quick-links">
<a class="quick-link" href="#" onclick="window.app.navigate('projects'); return false;">📋 All Projects</a>
<a class="quick-link" href="#" onclick="window.showNewDocModal('${projectId}', '${selectedFolderId || ''}'); return false;">📄 New Document</a>
</div>
</div>
</aside>
<main class="main-content">
<div class="content-header">
<div class="content-header-left">
<h1>${escapeHtml(currentFolderName)}</h1>
<span class="doc-count">${filteredDocs.length} document${filteredDocs.length !== 1 ? 's' : ''}</span>
</div>
<div class="content-header-right">
<div class="search-box-inline">
<span class="icon">🔍</span>
<input type="text" id="search-input" placeholder="Search documents..." value="${escapeHtml(searchQuery)}">
</div>
<button class="btn btn-primary" onclick="window.showNewDocModal('${projectId}', '${selectedFolderId || ''}')">+ New</button>
</div>
</div>
<div class="content-body">
${filteredDocs.length === 0 ? `
<div class="empty-state">
<div class="icon">📄</div>
<h3>No documents found</h3>
<p>${searchQuery || selectedTag ? 'Try adjusting your filters' : 'Create your first document in this project'}</p>
${!searchQuery && !selectedTag ? `<button class="btn btn-primary" onclick="window.showNewDocModal('${projectId}', '${selectedFolderId || ''}')">+ Create Document</button>` : ''}
</div>
` : `
<div class="doc-grid">
${filteredDocs.map(doc => renderDocCard(doc, projectId)).join('')}
</div>
`}
</div>
</main>
</div>
`;
// Attach event listeners
attachEventListeners();
}
// Mobile sidebar functions
window.toggleMobileSidebar = function() {
const sidebar = document.getElementById('sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) {
sidebar.classList.toggle('mobile-open');
if (overlay) overlay.classList.toggle('active');
}
};
window.closeMobileSidebar = function() {
const sidebar = document.getElementById('sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) {
sidebar.classList.remove('mobile-open');
if (overlay) overlay.classList.remove('active');
}
};
function attachEventListeners() {
// Search
const searchInput = document.getElementById('search-input');
searchInput.oninput = (e) => {
searchQuery = e.target.value;
render();
};
// Folder selection
document.querySelectorAll('[data-action="folder"]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const folderId = el.getAttribute('data-folder-id');
selectedFolderId = folderId === '' ? null : folderId;
selectedTag = null;
render();
});
});
// Tag selection
document.querySelectorAll('[data-action="tag"]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
selectedTag = el.getAttribute('data-tag');
selectedFolderId = null;
render();
});
});
}
function buildFolderTree(folders, parentId, depth = 0) {
return folders
.filter(f => f.parentId === parentId)
.map(folder => {
const children = folders.filter(f => f.parentId === folder.id);
const hasChildren = children.length > 0;
const isSelected = selectedFolderId === folder.id;
const docCount = documents.filter(d => d.folderId === folder.id).length;
return `
<div class="tree-node">
<div class="tree-item ${isSelected ? 'active' : ''}" data-action="folder" data-folder-id="${folder.id}">
<span class="tree-toggle ${hasChildren ? 'expanded' : ''}" style="padding-left: ${depth * 12}px">
${hasChildren ? '▶' : ''}
</span>
<span class="icon">📁</span>
<span class="label">${escapeHtml(folder.name)}</span>
<span class="count">${docCount}</span>
<button class="tree-action" onclick="event.stopPropagation(); window.showNewFolderModal('${projectId}', '${folder.id}')" title="New subfolder">+</button>
</div>
${hasChildren ? `<div class="tree-children">${buildFolderTree(folders, folder.id, depth + 1)}</div>` : ''}
</div>
`;
})
.join('');
}
render();
}
function renderDocCard(doc, projectId) {
const priorityEmoji = { high: '🔴', medium: '🟡', low: '🟢' };
const priority = doc.priority || 'medium';
return `
<div class="doc-card" onclick="window.app.navigate('document', {id: '${doc.id}', projectId: '${projectId}'})">
<div class="doc-card-header">
<span class="doc-id">${doc.id}</span>
<span class="type-badge ${doc.type || 'general'}">${doc.type || 'general'}</span>
</div>
<h3 class="doc-title">${escapeHtml(doc.title)}</h3>
${doc.tags && doc.tags.length ? `
<div class="doc-tags">
${doc.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
</div>
` : ''}
<div class="doc-meta">
<span class="doc-meta-item">📅 ${formatDate(doc.createdAt)}</span>
<span class="doc-meta-item">👤 ${escapeHtml(doc.author || 'unknown')}</span>
<span class="status-badge ${doc.status || 'draft'}">${doc.status || 'draft'}</span>
<span class="priority-indicator">${priorityEmoji[priority]}</span>
</div>
<div class="doc-card-actions">
<button class="btn btn-ghost btn-icon-only" onclick="event.stopPropagation(); window.showMoveToFolderModal('${doc.id}')" title="Move to folder">📁</button>
</div>
</div>
`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// Global function: Show modal to create new folder
window.showNewFolderModal = async function(projectId, parentId) {
let folders = [];
try {
const result = await api.getFolders(projectId);
folders = result.folders || [];
} catch (e) {}
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header">
<span>📁</span>
<h3>Create New Folder</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-folder-name">Folder Name</label>
<input type="text" id="new-folder-name" class="form-control" placeholder="e.g., API Documentation">
</div>
<div class="form-group" style="margin-top: 16px;">
<label for="new-folder-parent">Parent Folder (optional)</label>
<select id="new-folder-parent" class="form-control">
<option value="">— Root (no parent) —</option>
${folders.map(f => `<option value="${f.id}">📁 ${escapeHtml(f.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="create-folder-btn">Create Folder</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const nameInput = document.getElementById('new-folder-name');
const parentSelect = document.getElementById('new-folder-parent');
const createBtn = document.getElementById('create-folder-btn');
// Pre-select parent if provided
if (parentId) {
parentSelect.value = parentId;
}
createBtn.onclick = async () => {
const name = nameInput.value.trim();
if (!name) {
window.app.showToast('Please enter a folder name', 'error');
return;
}
try {
await api.createFolder({
name,
projectId,
parentId: parentSelect.value || null
});
backdrop.remove();
window.app.showToast('Folder created', 'success');
window.app.navigate('project', { id: projectId });
} catch (e) {
window.app.showToast('Failed to create folder: ' + e.message, 'error');
}
};
backdrop.onclick = (e) => {
if (e.target === backdrop) backdrop.remove();
};
nameInput.focus();
};
// Global function: Edit project modal
window.showEditProjectModal = async function(projectId) {
let project = null;
try {
project = await api.getProject(projectId);
} catch (e) {
window.app.showToast('Failed to load project', 'error');
return;
}
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header">
<span>✏️</span>
<h3>Edit Project</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="edit-project-name">Project Name</label>
<input type="text" id="edit-project-name" class="form-control" value="${escapeHtml(project.name)}">
</div>
<div class="form-group" style="margin-top: 16px;">
<label for="edit-project-description">Description</label>
<textarea id="edit-project-description" class="form-control" rows="3" style="resize: vertical;">${escapeHtml(project.description || '')}</textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="save-project-btn">Save Changes</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const nameInput = document.getElementById('edit-project-name');
const descInput = document.getElementById('edit-project-description');
const saveBtn = document.getElementById('save-project-btn');
saveBtn.onclick = async () => {
const name = nameInput.value.trim();
if (!name) {
window.app.showToast('Please enter a project name', 'error');
return;
}
try {
await api.updateProject(projectId, {
name,
description: descInput.value.trim()
});
backdrop.remove();
window.app.showToast('Project updated', 'success');
window.app.navigate('project', { id: projectId });
} catch (e) {
window.app.showToast('Failed to update project: ' + e.message, 'error');
}
};
backdrop.onclick = (e) => {
if (e.target === backdrop) backdrop.remove();
};
nameInput.focus();
nameInput.select();
};
// Global function: Confirm delete project
window.confirmDeleteProject = async function(projectId) {
const confirmed = await window.app.confirmDelete('Delete this project? All documents and folders will be deleted.');
if (!confirmed) return;
try {
await api.deleteProject(projectId);
window.app.showToast('Project deleted', 'success');
window.app.navigate('projects');
} catch (e) {
window.app.showToast('Failed to delete project: ' + e.message, 'error');
}
};
// Global function: Move document to folder modal
window.showMoveToFolderModal = async function(documentId) {
const currentProjectId = window.app.state.params.id;
let folders = [];
try {
const result = await api.getFolders(currentProjectId);
folders = result.folders || [];
} catch (e) {}
// Also get current document to show its current folder
let currentDoc = null;
try {
currentDoc = await api.getDocument(documentId);
} catch (e) {}
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header">
<span>📁</span>
<h3>Move to Folder</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<p style="color: var(--color-text-secondary); margin-bottom: 16px;">Select a folder for this document:</p>
<div class="form-group">
<select id="move-folder-select" class="form-control">
<option value="">— No Folder (Root) —</option>
${folders.map(f => `<option value="${f.id}">📁 ${escapeHtml(f.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="move-folder-btn">Move</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const select = document.getElementById('move-folder-select');
const moveBtn = document.getElementById('move-folder-btn');
// Pre-select current folder if any
if (currentDoc && currentDoc.folderId) {
select.value = currentDoc.folderId;
}
moveBtn.onclick = async () => {
try {
await api.moveDocumentToFolder(documentId, select.value || null);
backdrop.remove();
window.app.showToast('Document moved', 'success');
window.app.navigate('project', { id: currentProjectId });
} catch (e) {
window.app.showToast('Failed to move document: ' + e.message, 'error');
}
};
backdrop.onclick = (e) => {
if (e.target === backdrop) backdrop.remove();
};
};
// Global function: Show modal to create new document in project
window.showNewDocModal = async function(projectId, folderId = '') {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header">
<span>📄</span>
<h3>Create New Document</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-doc-title">Title</label>
<input type="text" id="new-doc-title" class="form-control" placeholder="Document title">
</div>
<div class="form-group" style="margin-top: 16px;">
<label for="new-doc-type">Type</label>
<select id="new-doc-type" class="form-control">
<option value="general">General</option>
<option value="requirement">Requirement</option>
<option value="spec">Spec</option>
<option value="note">Note</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="create-doc-btn">Create</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const titleInput = document.getElementById('new-doc-title');
const typeSelect = document.getElementById('new-doc-type');
const createBtn = document.getElementById('create-doc-btn');
titleInput.focus();
createBtn.onclick = async () => {
const title = titleInput.value.trim();
if (!title) {
window.app.showToast('Please enter a title', 'error');
return;
}
try {
const doc = await api.createDocument({
title,
type: typeSelect.value,
projectId,
folderId: folderId || null
});
backdrop.remove();
window.app.showToast('Document created', 'success');
window.app.navigate('editor', { id: doc.id, projectId, folderId: folderId || null });
} catch (e) {
window.app.showToast('Failed to create document: ' + e.message, 'error');
}
};
backdrop.onclick = (e) => {
if (e.target === backdrop) backdrop.remove();
};
};
-179
View File
@@ -1,179 +0,0 @@
// Projects List View - Shows all projects
import { api } from '../api.js';
export async function renderProjects(app) {
let projects = [];
try {
const result = await api.getProjects();
projects = result.projects || [];
} catch (e) {
app.showToast('Failed to load projects', 'error');
}
const appEl = document.getElementById('app');
// Mobile sidebar functions - consistent with other views
window.toggleMobileSidebar = function() {
const sidebar = document.getElementById('sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) {
sidebar.classList.toggle('mobile-open');
if (overlay) overlay.classList.toggle('active');
}
};
window.closeMobileSidebar = function() {
const sidebar = document.getElementById('sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) {
sidebar.classList.remove('mobile-open');
if (overlay) overlay.classList.remove('active');
}
};
function render() {
appEl.innerHTML = `
<header class="app-header">
<button type="button" class="mobile-nav-btn" onclick="window.toggleMobileSidebar()" title="Menu">☰</button>
<div class="logo">📝 SimpleNote</div>
<div class="header-actions">
<button class="btn btn-primary" onclick="window.showNewProjectModal()">+ New</button>
</div>
</header>
<div class="sidebar-overlay" onclick="window.closeMobileSidebar()"></div>
<aside class="sidebar" id="sidebar" style="padding-top: 56px;">
<button class="sidebar-close-btn" onclick="window.closeMobileSidebar()">✕</button>
<div class="sidebar-scroll">
<div class="sidebar-section">
<h3>📋 Projects</h3>
<div class="tag-list">
${projects.length === 0 ? '<p style="color:var(--color-text-muted);font-size:0.875rem;">No projects yet</p>' : projects.map(project => `
<div class="tag-item" onclick="window.app.navigate('project', {id: '${project.id}'}); window.closeMobileSidebar();">
<span>📋 ${escapeHtml(project.name)}</span>
</div>
`).join('')}
</div>
</div>
<div class="quick-links">
<a class="quick-link" href="#" onclick="window.showNewProjectModal(); window.closeMobileSidebar(); return false;">+ New Project</a>
</div>
</div>
</aside>
<div class="projects-page">
<div class="projects-header">
<h1>Projects</h1>
<p style="color: var(--color-text-secondary);">Organize your documents into projects and folders</p>
</div>
<div class="projects-grid">
${projects.length === 0 ? `
<div class="empty-state">
<div class="icon">📋</div>
<h3>No projects yet</h3>
<p>Create your first project to get started</p>
<button class="btn btn-primary" onclick="window.showNewProjectModal()">+ Create Project</button>
</div>
` : projects.map(project => renderProjectCard(project)).join('')}
</div>
</div>
`;
}
render();
}
function renderProjectCard(project) {
const createdDate = formatDate(project.createdAt);
const docCount = project.documentCount || 0;
const folderCount = project.folderCount || 0;
return `
<div class="project-card" onclick="window.app.navigate('project', {id: '${project.id}'})">
<div class="project-card-header">
<div class="project-icon">📋</div>
<div class="project-info">
<h3 class="project-name">${escapeHtml(project.name)}</h3>
${project.description ? `<p class="project-description">${escapeHtml(project.description)}</p>` : ''}
</div>
</div>
<div class="project-card-meta">
<span class="meta-item">📄 ${docCount} docs</span>
<span class="meta-item">📁 ${folderCount} folders</span>
<span class="meta-item">📅 ${createdDate}</span>
</div>
</div>
`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// Global function: Show modal to create new project
window.showNewProjectModal = function() {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header">
<span>📋</span>
<h3>Create New Project</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-project-name">Project Name</label>
<input type="text" id="new-project-name" class="form-control" placeholder="e.g., Backend Requirements">
</div>
<div class="form-group" style="margin-top: 16px;">
<label for="new-project-description">Description (optional)</label>
<textarea id="new-project-description" class="form-control" placeholder="Brief description of the project..." rows="3" style="resize: vertical;"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="create-project-btn">Create Project</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const nameInput = document.getElementById('new-project-name');
const descInput = document.getElementById('new-project-description');
const createBtn = document.getElementById('create-project-btn');
createBtn.onclick = async () => {
const name = nameInput.value.trim();
if (!name) {
window.app.showToast('Please enter a project name', 'error');
return;
}
try {
await api.createProject({
name,
description: descInput.value.trim()
});
backdrop.remove();
window.app.showToast('Project created successfully', 'success');
window.app.navigate('projects');
} catch (e) {
window.app.showToast('Failed to create project: ' + e.message, 'error');
}
};
backdrop.onclick = (e) => {
if (e.target === backdrop) backdrop.remove();
};
nameInput.focus();
}
-90
View File
@@ -1,90 +0,0 @@
/**
* 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
View File
@@ -1,27 +0,0 @@
/**
* 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;
-50
View File
@@ -1,50 +0,0 @@
/**
* SimpleNote Web - Entry Point
* Document management API with nested libraries and markdown support
*/
import express from 'express';
import cors from 'cors';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
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();
// Serve static files from public/
app.use(express.static(join(dirname(fileURLToPath(import.meta.url)), '..', 'public')));
// Middleware
app.use(cors({ origin: config.corsOrigin }));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Health check (unprotected)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString(), version: '1.0.0' });
});
// 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(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;
-173
View File
@@ -1,173 +0,0 @@
/**
* SimpleNote Web - Tag Indexer
* Rebuild and query the global tag index
*/
import { readJSON, writeJSON, pathExists, listDir } 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
View File
@@ -1,56 +0,0 @@
/**
* 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 = validToken.isAdmin === true || 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
View File
@@ -1,23 +0,0 @@
/**
* 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
View File
@@ -1,69 +0,0 @@
/**
* 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, isAdmin: true, 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;
-154
View File
@@ -1,154 +0,0 @@
/**
* 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, project, folder, type, status, limit, offset } = req.query;
const docService = getDocumentService();
const result = await docService.listDocuments({
tag,
library,
project,
folder,
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, projectId, folderId, content, tags, type, priority, status } = req.body;
const docService = getDocumentService();
const doc = await docService.createDocument({
title,
libraryId,
projectId,
folderId,
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, folderId } = req.body;
const docService = getDocumentService();
const doc = await docService.updateDocument(req.params.id, {
title,
content,
tags,
type,
priority,
status,
folderId,
});
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;
-135
View File
@@ -1,135 +0,0 @@
/**
* SimpleNote Web - Folders Routes
* CRUD + tree for folders
*/
import { Router } from 'express';
import { authMiddleware } from '../middleware/auth.js';
import { getFolderService } from '../services/folderService.js';
import { NotFoundError, ValidationError } from '../utils/errors.js';
const router = Router();
router.use(authMiddleware);
// GET /folders?project=X&parentId=Y - List folders (project is optional, matches documents API)
router.get('/', async (req, res) => {
try {
const { project, parentId } = req.query;
const folderService = getFolderService();
const folders = await folderService.getFolders(project || null, parentId || null);
res.json({ folders });
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message, code: err.code });
}
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error listing folders:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// POST /folders - Create folder
router.post('/', async (req, res) => {
try {
const { name, projectId, parentId } = req.body;
if (!name) {
throw new ValidationError('name is required');
}
if (!projectId) {
throw new ValidationError('projectId is required');
}
const folderService = getFolderService();
const folder = await folderService.createFolder({ name, projectId, parentId: parentId || null });
res.status(201).json(folder);
} catch (err) {
if (err instanceof ValidationError || err instanceof NotFoundError) {
const status = err instanceof ValidationError ? 400 : 404;
return res.status(status).json({ error: err.message, code: err.code });
}
console.error('Error creating folder:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// GET /folders/:id - Get folder contents
router.get('/:id', async (req, res) => {
try {
const folderService = getFolderService();
const folder = await folderService.getFolder(req.params.id);
res.json(folder);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error getting folder:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// PUT /folders/:id - Update folder
router.put('/:id', async (req, res) => {
try {
const { name } = req.body;
const folderService = getFolderService();
const folder = await folderService.updateFolder(req.params.id, { name });
res.json(folder);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message, code: err.code });
}
console.error('Error updating folder:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// DELETE /folders/:id - Delete folder
router.delete('/:id', async (req, res) => {
try {
const folderService = getFolderService();
const result = await folderService.deleteFolder(req.params.id);
res.json(result);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error deleting folder:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// GET /folders/:id/documents - List documents in folder
router.get('/:id/documents', async (req, res) => {
try {
const folderService = getFolderService();
const result = await folderService.getFolderDocuments(req.params.id);
res.json(result);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error listing folder documents:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// GET /folders/:id/tree - Get full folder tree
router.get('/:id/tree', async (req, res) => {
try {
const folderService = getFolderService();
const tree = await folderService.getFolderTree(req.params.id);
res.json(tree);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error getting folder tree:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
export default router;
-27
View File
@@ -1,27 +0,0 @@
/**
* 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';
import projectsRouter from './projects.js';
import foldersRouter from './folders.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);
router.use('/projects', projectsRouter);
router.use('/folders', foldersRouter);
return router;
}
export default createApiRouter;
-106
View File
@@ -1,106 +0,0 @@
/**
* 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;
-125
View File
@@ -1,125 +0,0 @@
/**
* SimpleNote Web - Projects Routes
* CRUD + tree for projects
*/
import { Router } from 'express';
import { authMiddleware } from '../middleware/auth.js';
import { getProjectService } from '../services/projectService.js';
import { NotFoundError, ValidationError } from '../utils/errors.js';
const router = Router();
router.use(authMiddleware);
// GET /projects - List all projects
router.get('/', async (req, res) => {
try {
const projectService = getProjectService();
const projects = await projectService.getProjects();
res.json({ projects });
} catch (err) {
console.error('Error listing projects:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// POST /projects - Create project
router.post('/', async (req, res) => {
try {
const { name, description } = req.body;
if (!name) {
throw new ValidationError('name is required');
}
const projectService = getProjectService();
const project = await projectService.createProject({ name, description });
res.status(201).json(project);
} catch (err) {
if (err instanceof ValidationError || err instanceof NotFoundError) {
const status = err instanceof ValidationError ? 400 : 404;
return res.status(status).json({ error: err.message, code: err.code });
}
console.error('Error creating project:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// GET /projects/:id - Get project contents
router.get('/:id', async (req, res) => {
try {
const projectService = getProjectService();
const project = await projectService.getProject(req.params.id);
res.json(project);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error getting project:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// PUT /projects/:id - Update project
router.put('/:id', async (req, res) => {
try {
const { name, description } = req.body;
const projectService = getProjectService();
const project = await projectService.updateProject(req.params.id, { name, description });
res.json(project);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message, code: err.code });
}
console.error('Error updating project:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// DELETE /projects/:id - Delete project
router.delete('/:id', async (req, res) => {
try {
const projectService = getProjectService();
const result = await projectService.deleteProject(req.params.id);
res.json(result);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error deleting project:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// GET /projects/:id/tree - Get full project tree
router.get('/:id/tree', async (req, res) => {
try {
const projectService = getProjectService();
const tree = await projectService.getProjectTree(req.params.id);
res.json(tree);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error getting project tree:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
// GET /projects/:id/documents - List documents in project
router.get('/:id/documents', async (req, res) => {
try {
const projectService = getProjectService();
const result = await projectService.getProjectDocuments(req.params.id);
res.json(result);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
console.error('Error listing project documents:', err);
res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' });
}
});
export default router;
-41
View File
@@ -1,41 +0,0 @@
/**
* 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;
-574
View File
@@ -1,574 +0,0 @@
/**
* SimpleNote Web - Document Service
* Document CRUD with markdown storage
* Supports both legacy libraries/ and new projects/ structure
*/
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';
import { getProjectService } from './projectService.js';
import { getFolderService } from './folderService.js';
const LIBRARIES_DIR = 'libraries';
const PROJECTS_DIR = 'projects';
const FOLDERS_DIR = 'folders';
export class DocumentService {
constructor(dataRoot = config.dataRoot) {
this.dataRoot = dataRoot;
this.librariesPath = join(dataRoot, LIBRARIES_DIR);
this.projectsPath = join(dataRoot, PROJECTS_DIR);
this.tagIndexer = getTagIndexer(dataRoot);
}
// ===== Legacy Libraries Paths =====
_docPath(libId, docId) {
return join(this.librariesPath, libId, 'documents', docId);
}
_docIndexPath(libId, docId) {
return join(this._docPath(libId, docId), 'index.md');
}
_docMetaPath(libId, docId) {
return join(this._docPath(libId, docId), '.meta.json');
}
// ===== New Projects/Folders Paths =====
_projectDocPath(projectId, docId) {
return join(this.projectsPath, projectId, 'documents', docId);
}
_projectDocIndexPath(projectId, docId) {
return join(this._projectDocPath(projectId, docId), 'index.md');
}
_projectDocMetaPath(projectId, docId) {
return join(this._projectDocPath(projectId, docId), '.meta.json');
}
_folderDocPath(projectId, folderId, docId) {
return join(this.projectsPath, projectId, FOLDERS_DIR, folderId, 'documents', docId);
}
_folderDocIndexPath(projectId, folderId, docId) {
return join(this._folderDocPath(projectId, folderId, docId), 'index.md');
}
_folderDocMetaPath(projectId, folderId, docId) {
return join(this._folderDocPath(projectId, folderId, docId), '.meta.json');
}
// ===== Find Methods =====
_findDocInLibrary(libId, docId) {
const metaPath = this._docMetaPath(libId, docId);
if (pathExists(metaPath)) {
return { docId, libId, metaPath, indexPath: this._docIndexPath(libId, docId), storageType: 'library' };
}
return null;
}
_findDocInProject(projectId, docId) {
const metaPath = this._projectDocMetaPath(projectId, docId);
if (pathExists(metaPath)) {
return { docId, projectId, metaPath, indexPath: this._projectDocIndexPath(projectId, docId), storageType: 'project' };
}
return null;
}
_findDocInFolder(projectId, folderId, docId) {
const metaPath = this._folderDocMetaPath(projectId, folderId, docId);
if (pathExists(metaPath)) {
return { docId, projectId, folderId, metaPath, indexPath: this._folderDocIndexPath(projectId, folderId, docId), storageType: 'folder' };
}
return null;
}
_findInSubLibs(subLibsPath, docId, libEntry) {
if (!pathExists(subLibsPath)) return null;
const entries = listDir(subLibsPath);
for (const entry of entries) {
const entryPath = join(subLibsPath, entry);
if (!isDirectory(entryPath)) continue;
// Check if doc is here
const metaPath = join(entryPath, 'documents', docId, '.meta.json');
if (pathExists(metaPath)) {
return { docId, libId: entry, metaPath, indexPath: join(entryPath, 'documents', docId, 'index.md'), storageType: 'library' };
}
// Recurse into sub-sub-libraries
const subSubLibsPath = join(entryPath, 'sub-libraries');
const found = this._findInSubLibs(subSubLibsPath, docId, entry);
if (found) return found;
}
return null;
}
_findDocById(docId) {
// First check new projects structure
if (pathExists(this.projectsPath)) {
const projectEntries = listDir(this.projectsPath);
for (const projectEntry of projectEntries) {
const projectPath = join(this.projectsPath, projectEntry);
if (!isDirectory(projectPath)) continue;
// Direct doc in project root
const found = this._findDocInProject(projectEntry, docId);
if (found) return found;
// Doc in folders (search recursively)
const foldersPath = join(projectPath, FOLDERS_DIR);
if (pathExists(foldersPath)) {
const foundInFolder = this._findDocInProjectFolders(foldersPath, projectEntry, docId);
if (foundInFolder) return foundInFolder;
}
}
}
// Fallback: search legacy libraries structure
if (!pathExists(this.librariesPath)) return null;
const libEntries = listDir(this.librariesPath);
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;
}
_findDocInProjectFolders(foldersPath, projectId, docId) {
if (!pathExists(foldersPath)) return null;
const folderEntries = listDir(foldersPath);
for (const folderEntry of folderEntries) {
const folderPath = join(foldersPath, folderEntry);
if (!isDirectory(folderPath)) continue;
// Check if doc is directly in this folder
const found = this._findDocInFolder(projectId, folderEntry, docId);
if (found) return found;
// Recurse into sub-folders
const subFoldersPath = join(folderPath, 'sub-folders');
const foundSub = this._findDocInProjectFolders(subFoldersPath, projectId, docId);
if (foundSub) return foundSub;
}
return null;
}
_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 };
}
// ===== CRUD Operations =====
/**
* Create a document.
* Supports both legacy libraryId (backwards compat) and new projectId/folderId.
* @param {Object} params
* @param {string} params.title - Document title
* @param {string} [params.libraryId] - Legacy library ID (backwards compat)
* @param {string} [params.projectId] - Project ID (new structure)
* @param {string} [params.folderId] - Folder ID within project (new structure)
* @param {string} [params.content] - Document content
* @param {string[]} [params.tags] - Tags
* @param {string} [params.type] - Document type
* @param {string} [params.priority] - Priority
* @param {string} [params.status] - Status
* @param {string} [params.createdBy] - Creator
*/
async createDocument({ title, libraryId, projectId, folderId, content, tags = [], type = 'general', priority = 'medium', status = 'draft', createdBy = null }) {
if (!title || !title.trim()) {
throw new ValidationError('Title is required');
}
// Determine storage location: prefer new project/folder structure, fallback to legacy library
let effectiveProjectId = projectId;
let effectiveFolderId = folderId || null;
let effectiveLibraryId = libraryId;
if (effectiveProjectId) {
// New structure: verify project exists
const projectService = getProjectService(this.dataRoot);
await projectService.getProject(effectiveProjectId);
// If folderId provided, verify folder exists
if (effectiveFolderId) {
const folderService = getFolderService(this.dataRoot);
await folderService.getFolder(effectiveFolderId);
}
// Backwards compat: set libraryId = projectId for legacy code
effectiveLibraryId = effectiveProjectId;
} else if (effectiveLibraryId) {
// Legacy structure: verify library exists
const libService = getLibraryService(this.dataRoot);
await libService.getLibrary(effectiveLibraryId);
// Backwards compat: for old docs, projectId = libraryId
effectiveProjectId = effectiveLibraryId;
} else {
throw new ValidationError('Either libraryId or projectId is required');
}
const docId = generateId();
const now = new Date().toISOString();
let docPath, metaPath, indexPath;
if (effectiveFolderId) {
// Store in folder
docPath = this._folderDocPath(effectiveProjectId, effectiveFolderId, docId);
metaPath = this._folderDocMetaPath(effectiveProjectId, effectiveFolderId, docId);
indexPath = this._folderDocIndexPath(effectiveProjectId, effectiveFolderId, docId);
ensureDir(docPath);
} else if (effectiveProjectId) {
// Store in project root
docPath = this._projectDocPath(effectiveProjectId, docId);
metaPath = this._projectDocMetaPath(effectiveProjectId, docId);
indexPath = this._projectDocIndexPath(effectiveProjectId, docId);
ensureDir(docPath);
} else {
// Legacy: store in library
docPath = this._docPath(effectiveLibraryId, docId);
metaPath = this._docMetaPath(effectiveLibraryId, docId);
indexPath = this._docIndexPath(effectiveLibraryId, docId);
ensureDir(docPath);
}
const metadata = {
id: docId,
title: title.trim(),
tags: tags.filter(t => t),
type,
priority,
status,
// New fields
projectId: effectiveProjectId,
folderId: effectiveFolderId,
// Legacy field (backwards compat - same as projectId)
libraryId: effectiveLibraryId,
createdBy,
createdAt: now,
updatedAt: now,
};
const body = content || buildDefaultContent(title, type);
const markdown = serializeMarkdown(metadata, body);
writeJSON(metaPath, metadata);
writeFileSync(indexPath, markdown, 'utf-8');
// Update tag index
this.tagIndexer.addDocument(docId, metadata.tags);
return {
...metadata,
path: this._getDocPathForResponse(metadata),
content: body,
};
}
_getDocPathForResponse(metadata) {
if (metadata.folderId) {
return `/${PROJECTS_DIR}/${metadata.projectId}/${FOLDERS_DIR}/${metadata.folderId}/documents/${metadata.id}/index.md`;
} else if (metadata.projectId) {
return `/${PROJECTS_DIR}/${metadata.projectId}/documents/${metadata.id}/index.md`;
} else {
return `/${LIBRARIES_DIR}/${metadata.libraryId}/documents/${metadata.id}/index.md`;
}
}
async listDocuments({ tag, library, project, folder, type, status, limit = 50, offset = 0 } = {}) {
let allDocs = [];
// Collect from projects structure (new)
if (pathExists(this.projectsPath)) {
await this._collectDocsFromProjects(allDocs);
}
// Collect from legacy libraries structure
if (pathExists(this.librariesPath)) {
await this._collectDocs(this.librariesPath, allDocs);
}
// Filter by project
if (project) {
allDocs = allDocs.filter(d => d.projectId === project);
}
// Filter by folder
if (folder) {
allDocs = allDocs.filter(d => d.folderId === folder);
}
// Filter by library (backwards compat)
if (library) {
allDocs = allDocs.filter(d => d.libraryId === library || d.projectId === 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 };
return { ...doc, content: meta.content || '' };
});
return { documents: enriched, total, limit, offset };
}
async _collectDocsFromProjects(results) {
if (!pathExists(this.projectsPath)) return;
const projectEntries = listDir(this.projectsPath);
for (const projectEntry of projectEntries) {
const projectPath = join(this.projectsPath, projectEntry);
if (!isDirectory(projectPath)) continue;
// Collect from project root documents
const docsPath = join(projectPath, 'documents');
await this._collectDocsAtPath(docsPath, results);
// Collect from folders recursively
const foldersPath = join(projectPath, FOLDERS_DIR);
if (pathExists(foldersPath)) {
await this._collectDocsFromFolders(foldersPath, projectEntry, results);
}
}
}
async _collectDocsFromFolders(foldersPath, projectId, results) {
if (!pathExists(foldersPath)) return;
const folderEntries = listDir(foldersPath);
for (const folderEntry of folderEntries) {
const folderPath = join(foldersPath, folderEntry);
if (!isDirectory(folderPath)) continue;
// Collect documents in this folder
const docsPath = join(folderPath, 'documents');
await this._collectDocsAtPath(docsPath, results);
// Recurse into sub-folders
const subFoldersPath = join(folderPath, 'sub-folders');
await this._collectDocsFromFolders(subFoldersPath, projectId, results);
}
}
async _collectDocsAtPath(docsPath, results) {
if (!pathExists(docsPath)) return;
const docEntries = listDir(docsPath);
for (const docEntry of docEntries) {
const docMetaPath = join(docsPath, docEntry, '.meta.json');
if (pathExists(docMetaPath)) {
const meta = readJSON(docMetaPath);
if (meta?.id) {
results.push(meta);
}
}
}
}
async _collectDocs(path, results) {
if (!pathExists(path)) return;
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) {
// Ensure backwards compat fields
if (!meta.projectId && meta.libraryId) {
meta.projectId = meta.libraryId;
}
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: this._getDocPathForResponse(meta),
};
}
async updateDocument(docId, { title, content, tags, type, priority, status, folderId }) {
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);
if (folderId !== undefined) meta.folderId = folderId;
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);
// Delete based on storage type
if (found.storageType === 'folder') {
deletePath(this._folderDocPath(found.projectId, found.folderId, docId));
} else if (found.storageType === 'project') {
deletePath(this._projectDocPath(found.projectId, docId));
} else {
deletePath(this._docPath(found.libId, docId));
}
this.tagIndexer.removeDocument(docId);
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;
-437
View File
@@ -1,437 +0,0 @@
/**
* SimpleNote Web - Folder Service
* Hierarchical folder CRUD with filesystem storage
*/
import { join } from 'path';
import config from '../config/index.js';
import { ensureDir, readJSON, writeJSON, pathExists, deletePath, listDir, isDirectory } from '../utils/fsHelper.js';
import { generateId } from '../utils/uuid.js';
import { NotFoundError, ValidationError } from '../utils/errors.js';
import { getProjectService } from './projectService.js';
const PROJECTS_DIR = 'projects';
const FOLDERS_DIR = 'folders';
const FOLDER_META_FILE = '.folder.json';
export class FolderService {
constructor(dataRoot = config.dataRoot) {
this.dataRoot = dataRoot;
this.projectsPath = join(dataRoot, PROJECTS_DIR);
}
_projectFoldersPath(projectId) {
return join(this.projectsPath, projectId, FOLDERS_DIR);
}
_folderPath(projectId, folderId) {
return join(this._projectFoldersPath(projectId), folderId);
}
_folderMetaPath(projectId, folderId) {
return join(this._folderPath(projectId, folderId), FOLDER_META_FILE);
}
_folderDocumentsPath(projectId, folderId) {
return join(this._folderPath(projectId, folderId), 'documents');
}
_folderSubFoldersPath(projectId, folderId) {
return join(this._folderPath(projectId, folderId), 'sub-folders');
}
_resolveFolderMeta(folderId, parentId = null, projectId) {
// If projectId and parentId are provided, search in that context
if (projectId) {
const foldersPath = parentId
? this._folderSubFoldersPath(projectId, parentId)
: this._projectFoldersPath(projectId);
return this._findFolderInPath(foldersPath, folderId);
}
// Fallback: search all projects
return this._findFolderGlobally(folderId);
}
_findFolderInPath(searchPath, folderId) {
if (!pathExists(searchPath)) return null;
const entries = listDir(searchPath);
for (const entry of entries) {
const entryPath = join(searchPath, entry);
if (!isDirectory(entryPath)) continue;
if (entry === folderId) {
const metaPath = join(entryPath, FOLDER_META_FILE);
if (pathExists(metaPath)) {
return { id: folderId, metaPath, folderPath: entryPath };
}
}
// Search in sub-folders recursively
const subFoldersPath = join(entryPath, 'sub-folders');
const found = this._findFolderInPath(subFoldersPath, folderId);
if (found) return found;
}
return null;
}
_findFolderGlobally(folderId) {
if (!pathExists(this.projectsPath)) return null;
const projectEntries = listDir(this.projectsPath);
for (const projectEntry of projectEntries) {
const projectPath = join(this.projectsPath, projectEntry);
if (!isDirectory(projectPath)) continue;
const foldersPath = join(projectPath, FOLDERS_DIR);
const found = this._findFolderInPath(foldersPath, folderId);
if (found) return found;
}
return null;
}
async createFolder({ name, projectId, parentId = null }) {
if (!name || !name.trim()) {
throw new ValidationError('Folder name is required');
}
if (!projectId) {
throw new ValidationError('Project ID is required');
}
// Verify project exists
const projectService = getProjectService(this.dataRoot);
const project = await projectService.getProject(projectId);
const folderId = generateId();
const now = new Date().toISOString();
if (parentId) {
// Verify parent folder exists
const parentMeta = this._resolveFolderMeta(parentId, null, projectId);
if (!parentMeta) {
throw new NotFoundError('Parent folder');
}
const parentMetaData = readJSON(parentMeta.metaPath);
const parentSubFoldersPath = join(parentMeta.folderPath, 'sub-folders');
ensureDir(parentSubFoldersPath);
const folderMeta = {
id: folderId,
name: name.trim(),
projectId,
parentId,
path: `${PROJECTS_DIR}/${projectId}/${FOLDERS_DIR}/${parentId}/sub-folders/${folderId}`,
createdAt: now,
updatedAt: now,
};
const folderPath = join(parentSubFoldersPath, folderId);
ensureDir(folderPath);
ensureDir(join(folderPath, 'documents'));
writeJSON(join(folderPath, FOLDER_META_FILE), folderMeta);
return folderMeta;
} else {
// Create at root level of project
ensureDir(this.projectsPath);
const foldersPath = this._projectFoldersPath(projectId);
ensureDir(foldersPath);
const folderMeta = {
id: folderId,
name: name.trim(),
projectId,
parentId: null,
path: `${PROJECTS_DIR}/${projectId}/${FOLDERS_DIR}/${folderId}`,
createdAt: now,
updatedAt: now,
};
const folderPath = join(foldersPath, folderId);
ensureDir(folderPath);
ensureDir(join(folderPath, 'documents'));
writeJSON(join(folderPath, FOLDER_META_FILE), folderMeta);
return folderMeta;
}
}
async getFolders(projectId = null, parentId = null) {
const folders = [];
// If projectId is provided, verify it exists
if (projectId) {
const projectService = getProjectService(this.dataRoot);
await projectService.getProject(projectId);
}
if (parentId) {
// Get folders within a specific parent
const parentMeta = this._resolveFolderMeta(parentId, null, projectId);
if (!parentMeta) {
throw new NotFoundError('Parent folder');
}
const parentSubFoldersPath = join(parentMeta.folderPath, 'sub-folders');
const entries = listDir(parentSubFoldersPath);
for (const entry of entries) {
const entryPath = join(parentSubFoldersPath, entry);
if (!isDirectory(entryPath)) continue;
const meta = readJSON(join(entryPath, FOLDER_META_FILE));
if (!meta) continue;
folders.push({
...meta,
documentCount: this._countDocuments(join(entryPath, 'documents')),
folderCount: this._countSubFolders(join(entryPath, 'sub-folders')),
});
}
} else if (projectId) {
// Get root folders of a specific project
const rootFoldersPath = this._projectFoldersPath(projectId);
if (!pathExists(rootFoldersPath)) {
return folders;
}
const entries = listDir(rootFoldersPath);
for (const entry of entries) {
const entryPath = join(rootFoldersPath, entry);
if (!isDirectory(entryPath)) continue;
const meta = readJSON(join(entryPath, FOLDER_META_FILE));
if (!meta) continue;
folders.push({
...meta,
documentCount: this._countDocuments(join(entryPath, 'documents')),
folderCount: this._countSubFolders(join(entryPath, 'sub-folders')),
});
}
} else {
// No projectId provided - list folders from all projects
if (!pathExists(this.projectsPath)) {
return folders;
}
const projectEntries = listDir(this.projectsPath);
for (const projectEntry of projectEntries) {
const projectPath = join(this.projectsPath, projectEntry);
if (!isDirectory(projectPath)) continue;
const rootFoldersPath = join(projectPath, FOLDERS_DIR);
if (!pathExists(rootFoldersPath)) continue;
const entries = listDir(rootFoldersPath);
for (const entry of entries) {
const entryPath = join(rootFoldersPath, entry);
if (!isDirectory(entryPath)) continue;
const meta = readJSON(join(entryPath, FOLDER_META_FILE));
if (!meta) continue;
folders.push({
...meta,
documentCount: this._countDocuments(join(entryPath, 'documents')),
folderCount: this._countSubFolders(join(entryPath, 'sub-folders')),
});
}
}
}
return folders;
}
async getFolder(folderId) {
const found = this._findFolderGlobally(folderId);
if (!found) {
throw new NotFoundError('Folder');
}
const meta = readJSON(found.metaPath);
const folderPath = found.folderPath;
const docsPath = join(folderPath, 'documents');
const subFoldersPath = join(folderPath, 'sub-folders');
// List documents
const documents = this._listDocumentsAtPath(docsPath);
// List sub-folders
const subFolders = this._listFoldersAtPath(subFoldersPath);
return {
...meta,
documents,
subFolders,
};
}
async updateFolder(folderId, { name }) {
const found = this._findFolderGlobally(folderId);
if (!found) {
throw new NotFoundError('Folder');
}
const meta = readJSON(found.metaPath);
const now = new Date().toISOString();
if (name !== undefined) {
if (!name || !name.trim()) {
throw new ValidationError('Folder name cannot be empty');
}
meta.name = name.trim();
}
meta.updatedAt = now;
writeJSON(found.metaPath, meta);
return meta;
}
async deleteFolder(folderId) {
const found = this._findFolderGlobally(folderId);
if (!found) {
throw new NotFoundError('Folder');
}
const folderPath = found.folderPath;
deletePath(folderPath);
return { deleted: true, id: folderId };
}
async getFolderDocuments(folderId) {
const found = this._findFolderGlobally(folderId);
if (!found) {
throw new NotFoundError('Folder');
}
const docsPath = join(found.folderPath, 'documents');
const documents = this._listDocumentsAtPath(docsPath);
return { documents, total: documents.length };
}
async getFolderTree(folderId) {
const found = this._findFolderGlobally(folderId);
if (!found) {
throw new NotFoundError('Folder');
}
const meta = readJSON(found.metaPath);
const folderPath = found.folderPath;
const docsPath = join(folderPath, 'documents');
const subFoldersPath = join(folderPath, 'sub-folders');
const buildTree = (currentPath) => {
const documents = this._listDocumentsAtPath(join(currentPath, 'documents'));
const subFolders = [];
const currentSubFoldersPath = join(currentPath, 'sub-folders');
if (pathExists(currentSubFoldersPath)) {
const entries = listDir(currentSubFoldersPath);
for (const entry of entries) {
const entryPath = join(currentSubFoldersPath, entry);
if (!isDirectory(entryPath)) continue;
const entryMeta = readJSON(join(entryPath, FOLDER_META_FILE));
if (!entryMeta) continue;
subFolders.push({
id: entryMeta.id,
name: entryMeta.name,
...buildTree(entryPath),
});
}
}
return {
documents,
folders: subFolders,
};
};
return {
id: meta.id,
name: meta.name,
...buildTree(folderPath),
};
}
_listDocumentsAtPath(docsPath) {
const documents = [];
if (!pathExists(docsPath)) return documents;
const docEntries = listDir(docsPath);
for (const docEntry of docEntries) {
const docMetaPath = join(docsPath, docEntry, '.meta.json');
if (pathExists(docMetaPath)) {
const docMeta = readJSON(docMetaPath);
if (docMeta?.id) {
documents.push({
id: docMeta.id,
title: docMeta.title,
type: docMeta.type,
status: docMeta.status,
tags: docMeta.tags || [],
updatedAt: docMeta.updatedAt,
});
}
}
}
return documents;
}
_countDocuments(docsPath) {
if (!pathExists(docsPath)) return 0;
const entries = listDir(docsPath);
return entries.filter(e => {
const metaPath = join(docsPath, e, '.meta.json');
return pathExists(metaPath);
}).length;
}
_countSubFolders(subFoldersPath) {
if (!pathExists(subFoldersPath)) return 0;
const entries = listDir(subFoldersPath);
return entries.filter(e => {
const metaPath = join(subFoldersPath, e, FOLDER_META_FILE);
return pathExists(metaPath);
}).length;
}
_listFoldersAtPath(foldersPath) {
const folders = [];
if (!pathExists(foldersPath)) return folders;
const entries = listDir(foldersPath);
for (const entry of entries) {
const entryPath = join(foldersPath, entry);
if (!isDirectory(entryPath)) continue;
const meta = readJSON(join(entryPath, FOLDER_META_FILE));
if (!meta) continue;
folders.push({
id: meta.id,
name: meta.name,
parentId: meta.parentId,
documentCount: this._countDocuments(join(entryPath, 'documents')),
folderCount: this._countSubFolders(join(entryPath, 'sub-folders')),
});
}
return folders;
}
}
let globalFolderService = null;
export function getFolderService(dataRoot = config.dataRoot) {
if (!globalFolderService) {
globalFolderService = new FolderService(dataRoot);
}
return globalFolderService;
}
export default FolderService;
-273
View File
@@ -1,273 +0,0 @@
/**
* SimpleNote Web - Library Service
* Library CRUD with filesystem storage
*/
import { join } from 'path';
import config from '../config/index.js';
import { ensureDir, readJSON, writeJSON, pathExists, deletePath, listDir, isDirectory } from '../utils/fsHelper.js';
import { generateId } from '../utils/uuid.js';
import { NotFoundError, ValidationError } from '../utils/errors.js';
const LIBRARIES_DIR = 'libraries';
const LIBRARY_META_FILE = '.library.json';
export class LibraryService {
constructor(dataRoot = config.dataRoot) {
this.dataRoot = dataRoot;
this.librariesPath = join(dataRoot, LIBRARIES_DIR);
}
_libPath(libId) {
return join(this.librariesPath, libId);
}
_libMetaPath(libId) {
return join(this._libPath(libId), LIBRARY_META_FILE);
}
_libDocumentsPath(libId) {
return join(this._libPath(libId), 'documents');
}
_libSubLibrariesPath(libId) {
return join(this._libPath(libId), 'sub-libraries');
}
_readLibMeta(libId) {
const meta = readJSON(this._libMetaPath(libId));
if (!meta) throw new NotFoundError('Library');
return meta;
}
_findLibraryById(libId) {
const metaPath = this._libMetaPath(libId);
if (pathExists(metaPath)) {
return { id: libId, metaPath };
}
return null;
}
async createLibrary({ name, parentId = null }) {
if (!name || !name.trim()) {
throw new ValidationError('Library name is required');
}
const libId = generateId();
const now = new Date().toISOString();
if (parentId) {
// Verify parent exists
const parentMeta = this._readLibMeta(parentId);
const parentSubLibsPath = this._libSubLibrariesPath(parentId);
ensureDir(parentSubLibsPath);
const libMeta = {
id: libId,
name: name.trim(),
parentId,
createdAt: now,
updatedAt: now,
};
const libPath = join(parentSubLibsPath, libId);
ensureDir(libPath);
ensureDir(join(libPath, 'documents'));
writeJSON(this._libMetaPath(libId), libMeta);
return libMeta;
} else {
ensureDir(this.librariesPath);
const libMeta = {
id: libId,
name: name.trim(),
parentId: null,
createdAt: now,
updatedAt: now,
};
const libPath = this._libPath(libId);
ensureDir(libPath);
ensureDir(join(libPath, 'documents'));
writeJSON(this._libMetaPath(libId), libMeta);
return libMeta;
}
}
async listRootLibraries() {
ensureDir(this.librariesPath);
const entries = listDir(this.librariesPath);
const libraries = [];
for (const entry of entries) {
const libPath = join(this.librariesPath, entry);
if (!isDirectory(libPath)) continue;
const meta = readJSON(this._libMetaPath(entry));
if (!meta) continue;
const docCount = this._countDocuments(this._libDocumentsPath(entry));
libraries.push({
...meta,
documentCount: docCount,
});
}
return libraries;
}
_countDocuments(docsPath) {
if (!pathExists(docsPath)) return 0;
const entries = listDir(docsPath);
return entries.filter(e => {
const metaPath = join(docsPath, e, '.meta.json');
return pathExists(metaPath);
}).length;
}
async getLibrary(libId) {
const found = this._findLibraryById(libId);
if (!found) {
throw new NotFoundError('Library');
}
const meta = readJSON(this._libMetaPath(libId));
const docsPath = this._libDocumentsPath(libId);
const subLibsPath = this._libSubLibrariesPath(libId);
// List documents
const documents = [];
if (pathExists(docsPath)) {
const docEntries = listDir(docsPath);
for (const docEntry of docEntries) {
const docMetaPath = join(docsPath, docEntry, '.meta.json');
if (pathExists(docMetaPath)) {
const docMeta = readJSON(docMetaPath);
if (docMeta?.id) {
documents.push({
id: docMeta.id,
title: docMeta.title,
type: docMeta.type,
status: docMeta.status,
tags: docMeta.tags || [],
updatedAt: docMeta.updatedAt,
});
}
}
}
}
// List sub-libraries
const subLibraries = [];
if (pathExists(subLibsPath)) {
const subEntries = listDir(subLibsPath);
for (const subEntry of subEntries) {
const subMetaPath = join(subLibsPath, subEntry, LIBRARY_META_FILE);
if (pathExists(subMetaPath)) {
const subMeta = readJSON(subMetaPath);
if (subMeta?.id) {
const subDocCount = this._countDocuments(join(subLibsPath, subEntry, 'documents'));
subLibraries.push({
id: subMeta.id,
name: subMeta.name,
documentCount: subDocCount,
});
}
}
}
}
return {
library: meta,
documents,
subLibraries,
};
}
async getLibraryTree(libId) {
const found = this._findLibraryById(libId);
if (!found) {
throw new NotFoundError('Library');
}
const meta = readJSON(this._libMetaPath(libId));
const buildTree = (id) => {
const libMeta = readJSON(this._libMetaPath(id));
const docsPath = this._libDocumentsPath(id);
const subLibsPath = this._libSubLibrariesPath(id);
const documents = [];
if (pathExists(docsPath)) {
const docEntries = listDir(docsPath);
for (const docEntry of docEntries) {
const docMetaPath = join(docsPath, docEntry, '.meta.json');
if (pathExists(docMetaPath)) {
const docMeta = readJSON(docMetaPath);
if (docMeta?.id) {
documents.push({
id: docMeta.id,
title: docMeta.title,
type: docMeta.type,
});
}
}
}
}
const subLibraries = [];
if (pathExists(subLibsPath)) {
const subEntries = listDir(subLibsPath);
for (const subEntry of subEntries) {
const subMetaPath = join(subLibsPath, subEntry, LIBRARY_META_FILE);
if (pathExists(subMetaPath)) {
subLibraries.push(buildTree(subEntry));
}
}
}
return {
id: libMeta.id,
name: libMeta.name,
documents,
subLibraries,
};
};
return buildTree(libId);
}
async listDocumentsInLibrary(libId) {
const found = this._findLibraryById(libId);
if (!found) {
throw new NotFoundError('Library');
}
const result = await this.getLibrary(libId);
return { documents: result.documents, total: result.documents.length };
}
async deleteLibrary(libId) {
const found = this._findLibraryById(libId);
if (!found) {
throw new NotFoundError('Library');
}
const libPath = this._libPath(libId);
deletePath(libPath);
return { deleted: true, id: libId };
}
}
let globalLibraryService = null;
export function getLibraryService(dataRoot = config.dataRoot) {
if (!globalLibraryService) {
globalLibraryService = new LibraryService(dataRoot);
}
return globalLibraryService;
}
export default LibraryService;
-289
View File
@@ -1,289 +0,0 @@
/**
* SimpleNote Web - Project Service
* Project CRUD with filesystem storage
*/
import { join } from 'path';
import config from '../config/index.js';
import { ensureDir, readJSON, writeJSON, pathExists, deletePath, listDir, isDirectory } from '../utils/fsHelper.js';
import { generateId } from '../utils/uuid.js';
import { NotFoundError, ValidationError } from '../utils/errors.js';
const PROJECTS_DIR = 'projects';
const PROJECT_META_FILE = '.project.json';
const FOLDERS_DIR = 'folders';
export class ProjectService {
constructor(dataRoot = config.dataRoot) {
this.dataRoot = dataRoot;
this.projectsPath = join(dataRoot, PROJECTS_DIR);
}
_projectPath(projectId) {
return join(this.projectsPath, projectId);
}
_projectMetaPath(projectId) {
return join(this._projectPath(projectId), PROJECT_META_FILE);
}
_projectDocumentsPath(projectId) {
return join(this._projectPath(projectId), 'documents');
}
_projectFoldersPath(projectId) {
return join(this._projectPath(projectId), FOLDERS_DIR);
}
_findProjectById(projectId) {
const metaPath = this._projectMetaPath(projectId);
if (pathExists(metaPath)) {
return { id: projectId, metaPath };
}
return null;
}
async createProject({ name, description = '' }) {
if (!name || !name.trim()) {
throw new ValidationError('Project name is required');
}
const projectId = generateId();
const now = new Date().toISOString();
ensureDir(this.projectsPath);
const projectMeta = {
id: projectId,
name: name.trim(),
description: description.trim ? description.trim() : description,
path: `${PROJECTS_DIR}/${projectId}`,
createdAt: now,
updatedAt: now,
};
const projectPath = this._projectPath(projectId);
ensureDir(projectPath);
ensureDir(join(projectPath, 'documents'));
ensureDir(join(projectPath, FOLDERS_DIR));
writeJSON(this._projectMetaPath(projectId), projectMeta);
return projectMeta;
}
async getProjects() {
ensureDir(this.projectsPath);
const entries = listDir(this.projectsPath);
const projects = [];
for (const entry of entries) {
const projectPath = join(this.projectsPath, entry);
if (!isDirectory(projectPath)) continue;
const meta = readJSON(this._projectMetaPath(entry));
if (!meta) continue;
const docCount = this._countDocuments(this._projectDocumentsPath(entry));
const folderCount = this._countFolders(this._projectFoldersPath(entry));
projects.push({
...meta,
documentCount: docCount,
folderCount,
});
}
return projects;
}
async getProject(projectId) {
const found = this._findProjectById(projectId);
if (!found) {
throw new NotFoundError('Project');
}
const meta = readJSON(this._projectMetaPath(projectId));
const docsPath = this._projectDocumentsPath(projectId);
const foldersPath = this._projectFoldersPath(projectId);
// List documents at project root
const documents = this._listDocumentsAtPath(docsPath);
// List root folders
const folders = this._listFoldersAtPath(foldersPath);
return {
...meta,
documents,
folders,
};
}
async updateProject(projectId, { name, description }) {
const found = this._findProjectById(projectId);
if (!found) {
throw new NotFoundError('Project');
}
const meta = readJSON(this._projectMetaPath(projectId));
const now = new Date().toISOString();
if (name !== undefined) {
if (!name || !name.trim()) {
throw new ValidationError('Project name cannot be empty');
}
meta.name = name.trim();
}
if (description !== undefined) {
meta.description = description.trim ? description.trim() : description;
}
meta.updatedAt = now;
writeJSON(this._projectMetaPath(projectId), meta);
return meta;
}
async deleteProject(projectId) {
const found = this._findProjectById(projectId);
if (!found) {
throw new NotFoundError('Project');
}
const projectPath = this._projectPath(projectId);
deletePath(projectPath);
return { deleted: true, id: projectId };
}
async getProjectTree(projectId) {
const found = this._findProjectById(projectId);
if (!found) {
throw new NotFoundError('Project');
}
const meta = readJSON(this._projectMetaPath(projectId));
const docsPath = this._projectDocumentsPath(projectId);
const foldersPath = this._projectFoldersPath(projectId);
const buildTree = (folderId = null, folderDocsPath, folderSubFoldersPath) => {
const documents = this._listDocumentsAtPath(folderDocsPath);
const subFolders = [];
if (pathExists(folderSubFoldersPath)) {
const folderEntries = listDir(folderSubFoldersPath);
for (const folderEntry of folderEntries) {
const folderEntryPath = join(folderSubFoldersPath, folderEntry);
if (!isDirectory(folderEntryPath)) continue;
const folderMeta = readJSON(join(folderEntryPath, '.folder.json'));
if (!folderMeta) continue;
subFolders.push(buildTree(
folderMeta.id,
join(folderEntryPath, 'documents'),
join(folderEntryPath, 'sub-folders')
));
}
}
return {
id: folderId,
name: folderId ? null : meta.name,
documents,
folders: subFolders,
};
};
return buildTree(null, docsPath, foldersPath);
}
async getProjectDocuments(projectId) {
const found = this._findProjectById(projectId);
if (!found) {
throw new NotFoundError('Project');
}
const docsPath = this._projectDocumentsPath(projectId);
const documents = this._listDocumentsAtPath(docsPath);
return { documents, total: documents.length };
}
_listDocumentsAtPath(docsPath) {
const documents = [];
if (!pathExists(docsPath)) return documents;
const docEntries = listDir(docsPath);
for (const docEntry of docEntries) {
const docMetaPath = join(docsPath, docEntry, '.meta.json');
if (pathExists(docMetaPath)) {
const docMeta = readJSON(docMetaPath);
if (docMeta?.id) {
documents.push({
id: docMeta.id,
title: docMeta.title,
type: docMeta.type,
status: docMeta.status,
tags: docMeta.tags || [],
updatedAt: docMeta.updatedAt,
});
}
}
}
return documents;
}
_countDocuments(docsPath) {
if (!pathExists(docsPath)) return 0;
const entries = listDir(docsPath);
return entries.filter(e => {
const metaPath = join(docsPath, e, '.meta.json');
return pathExists(metaPath);
}).length;
}
_countFolders(foldersPath) {
if (!pathExists(foldersPath)) return 0;
const entries = listDir(foldersPath);
return entries.filter(e => {
const metaPath = join(foldersPath, e, '.folder.json');
return pathExists(metaPath);
}).length;
}
_listFoldersAtPath(foldersPath) {
const folders = [];
if (!pathExists(foldersPath)) return folders;
const folderEntries = listDir(foldersPath);
for (const folderEntry of folderEntries) {
const folderEntryPath = join(foldersPath, folderEntry);
if (!isDirectory(folderEntryPath)) continue;
const folderMeta = readJSON(join(folderEntryPath, '.folder.json'));
if (!folderMeta) continue;
const docCount = this._countDocuments(join(folderEntryPath, 'documents'));
const subFolderCount = this._countFolders(join(folderEntryPath, 'sub-folders'));
folders.push({
id: folderMeta.id,
name: folderMeta.name,
parentId: folderMeta.parentId,
documentCount: docCount,
folderCount: subFolderCount,
});
}
return folders;
}
}
let globalProjectService = null;
export function getProjectService(dataRoot = config.dataRoot) {
if (!globalProjectService) {
globalProjectService = new ProjectService(dataRoot);
}
return globalProjectService;
}
export default ProjectService;
-54
View File
@@ -1,54 +0,0 @@
/**
* 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
View File
@@ -1,35 +0,0 @@
/**
* 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
View File
@@ -1,58 +0,0 @@
/**
* 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
View File
@@ -1,68 +0,0 @@
/**
* 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
View File
@@ -1,11 +0,0 @@
/**
* SimpleNote Web - UUID Helper
*/
import { v4 as uuidv4 } from 'uuid';
export function generateId() {
return uuidv4();
}
export default { generateId };
-1139
View File
File diff suppressed because it is too large Load Diff
-309
View File
@@ -1,309 +0,0 @@
# SimpleNote Web - UI/UX Specification
## 1. Overview
### 1.1 Purpose
SimpleNote Web is the frontend interface for the SimpleNote document management system. It provides a clean, efficient way to browse, create, edit, and manage documents organized in nested libraries with tag-based filtering.
### 1.2 Target Users
- Technical users and agents creating/managing documentation
- Teams using CLI tools to push documents that need visual review
- Anyone preferring a Joplin-like experience with a modern dark-mode interface
### 1.3 Tech Stack
- **Framework**: Vanilla JS with modern ES modules (no framework dependency for simplicity)
- **Styling**: CSS custom properties for theming (dark/light modes)
- **Markdown**: `marked` library for rendering
- **Icons**: Lucide Icons (SVG-based, MIT licensed)
- **HTTP Client**: Native Fetch API
- **State**: Simple pub/sub pattern with localStorage for preferences
---
## 2. Design Principles
### 2.1 Core Principles
1. **Content-First**: Documents are the hero; UI stays out of the way
2. **Keyboard-Friendly**: Common actions accessible via shortcuts
3. **Information Density**: Compact but readable; optimized for power users
4. **Progressive Disclosure**: Show details on demand, not all at once
5. **Dark Mode Primary**: Inspired by Mission Control dashboard aesthetics
### 2.2 Layout Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Header (Logo + Search + User Actions) │
├──────────────┬──────────────────────────────────────────────┤
│ │ │
│ Sidebar │ Main Content Area │
│ (Library │ │
│ Tree + │ - Document List (Dashboard) │
│ Tags) │ - Document Viewer │
│ │ - Document Editor │
│ │ - Library Browser │
│ │ │
├──────────────┴──────────────────────────────────────────────┤
│ Status Bar (optional: sync status, doc count) │
└─────────────────────────────────────────────────────────────┘
```
### 2.3 Responsive Strategy
- **Desktop (>1024px)**: Full sidebar + content layout
- **Tablet (768-1024px)**: Collapsible sidebar, full content
- **Mobile (<768px)**: Bottom navigation, stacked layout, slide-over panels
---
## 3. Views Specification
### 3.1 Dashboard / Main View
**Purpose**: Central hub showing documents with filtering and navigation.
**Components**:
- **Header Bar**: Logo, global search input, theme toggle, settings
- **Sidebar (left)**:
- Library tree (collapsible, nested)
- Quick filters: All Documents, Recent, Favorites
- Tag cloud/list with counts
- **Main Content**:
- Toolbar: View toggle (list/grid), sort options, bulk actions
- Document cards/list with title, tags, type badge, date, status
- Quick actions: New Document, New Library buttons
**URL**: `/` or `/documents`
**Keyboard Shortcuts**:
- `Ctrl/Cmd + K`: Focus search
- `Ctrl/Cmd + N`: New document
- `Ctrl/Cmd + Shift + N`: New library
- `Escape`: Clear filters/search
### 3.2 Document Viewer
**Purpose**: Read-only view of a document with rendered markdown.
**Components**:
- **Header**: Back button, document title, action buttons (Edit, Export, Delete)
- **Metadata Panel** (collapsible sidebar or dropdown):
- Type badge (requirement/note/spec/general)
- Status badge (draft/approved/implemented)
- Priority indicator (high/medium/low with color)
- Tags as clickable pills
- Author
- Created/Updated timestamps
- **Content Area**: Rendered markdown with:
- Syntax highlighting for code blocks
- Styled tables
- Checkbox rendering for criteria lists
- Anchor links for headings
**URL**: `/documents/:id`
**Keyboard Shortcuts**:
- `E`: Edit document
- `X`: Export markdown
- `Delete/Backspace`: Delete (with confirmation)
- `Escape`: Back to list
### 3.3 Document Editor
**Purpose**: Create or edit documents with live preview.
**Components**:
- **Header**: Cancel/Save buttons, document title (editable)
- **Editor Toolbar**:
- Markdown formatting buttons (bold, italic, headers, lists, code, link)
- Insert template dropdown
- Preview toggle (split view or tabbed)
- **Frontmatter Panel**:
- Title input
- Type selector (dropdown)
- Status selector (dropdown)
- Priority selector (dropdown)
- Tags input (chip-style, autocomplete)
- Library selector (dropdown with tree view)
- **Editor Area**:
- Monaco-style textarea with markdown syntax highlighting
- OR CodeMirror/MarkText integration (future)
- **Preview Pane**: Live rendered markdown (toggleable)
- **Auto-save Indicator**: "Saved" / "Saving..." / "Unsaved changes"
**URL**: `/documents/:id/edit` or `/documents/new`
**Keyboard Shortcuts**:
- `Ctrl/Cmd + S`: Save
- `Ctrl/Cmd + B`: Bold
- `Ctrl/Cmd + I`: Italic
- `Ctrl/Cmd + K`: Insert link
- `Ctrl/Cmd + Shift + P`: Toggle preview
- `Escape`: Discard changes (with confirmation)
### 3.4 Library Browser
**Purpose**: Navigate and manage library hierarchy.
**Components**:
- **Breadcrumbs**: Path from root to current library
- **Library Tree** (sidebar): Expandable/collapsible nested view
- **Main Content**:
- Header: Current library name, document count
- Actions: New Document, New Sub-library, Rename, Delete
- Content list: Documents and sub-libraries mixed
- Drag-and-drop reordering (future)
**URL**: `/libraries/:id`
**Actions**:
- Click library → navigate to library view
- Click document → open viewer
- Right-click/long-press → context menu (Rename, Delete, Move)
- "+" buttons for creating new items
**Keyboard Shortcuts**:
- `Ctrl/Cmd + Shift + N`: New sub-library
- `R`: Rename selected
- `Delete`: Delete (with confirmation)
- `Escape`: Go to parent library
---
## 4. Interaction Patterns
### 4.1 Navigation
- Sidebar persists across views (single-page app behavior)
- Breadcrumbs for deep library navigation
- Browser back/forward support via History API
### 4.2 Search
- Real-time filtering as user types (debounced 300ms)
- Search scope: title, content, tags
- Highlight matching terms in results
- Empty state: "No documents found"
### 4.3 Tag Filtering
- Click tag in sidebar → filter documents
- Click tag pill in document → filter by that tag
- Multiple tags = AND filter
- Active filters shown as removable chips
### 4.4 Confirmation Dialogs
- Delete operations: Modal with confirmation
- Unsaved changes: Modal asking to save/discard
- All modals: Escape to cancel, Enter to confirm (when safe)
### 4.5 Notifications/Toasts
- Success: Green, auto-dismiss 3s
- Error: Red, persistent until dismissed
- Info: Blue, auto-dismiss 5s
- Position: Bottom-right
---
## 5. Data Flow
### 5.1 API Integration
All UI data comes from the REST API at `/api/v1`.
**Auth Flow**:
1. User enters token in settings or login page
2. Token stored in localStorage
3. All API requests include `Authorization: Bearer <token>` header
4. 401 response → clear token, show login
**Endpoints Used by UI**:
```
GET /api/v1/documents → Dashboard document list
GET /api/v1/documents/:id → Document viewer
POST /api/v1/documents → Create document
PUT /api/v1/documents/:id → Update document
DELETE /api/v1/documents/:id → Delete document
GET /api/v1/documents/:id/export → Export markdown
GET /api/v1/libraries → Library tree (root)
GET /api/v1/libraries/:id → Library contents
GET /api/v1/libraries/:id/tree → Full library tree
POST /api/v1/libraries → Create library
DELETE /api/v1/libraries/:id → Delete library
GET /api/v1/tags → Tag list with counts
GET /api/v1/tags/:tag/documents → Filtered documents
```
### 5.2 State Management
```javascript
// App state (simple pub/sub)
const state = {
documents: [],
libraries: [],
tags: [],
currentView: 'dashboard', // dashboard|viewer|editor|library
currentDocument: null,
currentLibrary: null,
filters: { tag: null, library: null, search: '' },
preferences: { theme: 'dark' }
};
```
### 5.3 Auto-save Implementation
```javascript
// Debounced auto-save during editing
let saveTimeout;
function onEditorChange(content) {
clearTimeout(saveTimeout);
ui.setSaving();
saveTimeout = setTimeout(() => {
api.updateDocument(id, { content }).then(() => {
ui.setSaved();
}).catch(() => {
ui.setError('Auto-save failed');
});
}, 2000); // 2 second debounce
}
```
---
## 6. Accessibility
### 6.1 Requirements
- All interactive elements keyboard accessible
- Focus visible indicators (custom styled)
- ARIA labels on icons and non-text elements
- Color contrast ratio ≥ 4.5:1 (WCAG AA)
- Screen reader announcements for dynamic content
### 6.2 Keyboard Navigation
- Tab order follows visual layout
- Focus trapped in modals
- Skip-to-content link
- Arrow keys for tree/list navigation
---
## 7. Error Handling
### 7.1 Network Errors
- Show toast notification
- Retry button for failed requests
- Offline indicator in status bar
### 7.2 Validation Errors
- Inline field errors in forms
- Red border on invalid fields
- Error message below field
### 7.3 Empty States
- No documents: Illustration + "Create your first document" CTA
- No search results: "No matches found" + clear filters button
- No libraries: "Create a library to organize your documents"
---
## 8. Future Enhancements (Out of Scope for v1)
- Drag-and-drop document organization
- Document versioning/history
- Collaborative editing
- Full-text search
- Document templates gallery
- Export to PDF/HTML
- Mobile native app
-1029
View File
File diff suppressed because it is too large Load Diff
-541
View File
@@ -1,541 +0,0 @@
# SimpleNote Web - Wireframes
Text-based wireframes for each view. Uses ASCII-style box-drawing to represent layout.
---
## View 1: Dashboard / Main View
### Desktop Layout (>1024px)
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ [≡] SimpleNote 🔍 Search documents... [🌙] [⚙️] [👤] │
├─────────────────┬─────────────────────────────────────────────────────────────┤
│ │ Documents [+ Doc] [+ Lib] [⊞] [≡] │
│ 📁 Libraries ├───────────────────────────────────────────────────────────── │
│ ├─ Backend │ │
│ │ ├─ API │ ┌──────────────────────────────────────────────────────┐ │
│ │ └─ Auth │ │ REQ-001 · API Authentication Design │ │
│ ├─ Frontend │ │ 🏷️ backend 🏷️ api 🏷️ auth │ │
│ └─ DevOps │ │ 📅 Mar 28 · 👤 agent-001 · ✓ draft · 🔴 high │ │
│ │ └──────────────────────────────────────────────────────┘ │
│ ───────────── │ ┌──────────────────────────────────────────────────────┐ │
│ │ │ NOTE-002 · Deployment Checklist │ │
│ 🏷️ Tags │ │ 🏷️ devops 🏷️ deployment │ │
│ ├─ backend (5) │ │ 📅 Mar 27 · 👤 agent-002 · ✓ approved │ │
│ ├─ api (3) │ └──────────────────────────────────────────────────────┘ │
│ ├─ auth (2) │ ┌──────────────────────────────────────────────────────┐ │
│ ├─ devops (4) │ │ SPEC-003 · Database Schema v2 │ │
│ └─ frontend(2) │ │ 🏷️ backend 🏷️ database │ │
│ │ │ 📅 Mar 26 · 👤 agent-001 · ✓ implemented · 🟡 med │ │
│ ───────────── │ └──────────────────────────────────────────────────────┘ │
│ │ │
│ Quick Links │ Showing 12 of 42 documents │
│ ├─ All Docs │ │
│ ├─ Recent (7) │ │
│ └─ Favorites │ │
└─────────────────┴─────────────────────────────────────────────────────────────┘
```
### Mobile Layout (<768px)
```
┌─────────────────────────────┐
│ [≡] SimpleNote [🔍][🌙] │
├─────────────────────────────┤
│ 🔍 Search... │
├─────────────────────────────┤
│ │
│ ┌─────────────────────────┐│
│ │ API Authentication ││
│ │ REQ-001 · 🔴 High ││
│ │ 🏷️ backend · api ││
│ └─────────────────────────┘│
│ ┌─────────────────────────┐│
│ │ Deployment Checklist ││
│ │ NOTE-002 · ✓ Approved ││
│ │ 🏷️ devops ││
│ └─────────────────────────┘│
│ ┌─────────────────────────┐│
│ │ Database Schema v2 ││
│ │ SPEC-003 · ✓ Done ││
│ │ 🏷️ backend · database ││
│ └─────────────────────────┘│
│ │
│ 12 of 42 documents │
│ │
├─────────────────────────────┤
│ [🏠] [📁] [+📝] [⚙️] │
└─────────────────────────────┘
```
### States
**Loading State**:
```
│ ... Loading documents... │
│ ████████████░░░░░░░░░ 60% │
```
**Empty State**:
```
│ │
│ 📄 │
│ No documents yet │
│ │
│ Create your first document │
│ [+ Create Doc] │
│ │
```
**Search Results**:
```
│ 🔍 "authentication" │
│ Clear ✕ │
│ ───────────────────────── │
│ 3 results for "auth..." │
│ ┌─────────────────────────┐│
│ │ API Authentication ││
│ │ ...supports **auth**... ││
│ └─────────────────────────┘│
```
---
## View 2: Document Viewer
### Desktop Layout
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ ← Back API Authentication Design [✏️] [↓] [🗑️] │
├─────────────────────────────────────────────────────────────────────────┬───────┤
│ │ Type │
│ # API Authentication Design │ REQ │
│ ├───────┤
│ ## Descripción │Status │
│ El sistema debe soportar autenticación via tokens Bearer... │ draft │
│ ├───────┤
│ ## Criterios de Aceptación │Priority│
│ - [ ] Endpoint POST /api/v1/auth/token acepta credenciales... │ 🔴 │
│ - [ ] Middleware extrae token del header... │ High │
│ - [ ] Middleware retorna 401 si header ausente... ├───────┤
│ - [ ] Token tiene prefijo `snk_`... │ Tags │
│ │ [backend]│
│ ## Notas │ [api] │
│ Tokens en esta versión son secretos compartidos... │ [auth] │
│ │ │
│ ```javascript ├───────┤
│ // authMiddleware.js │Author │
│ const token = req.headers.authorization?.replace('Bearer ', ''); │agent- │
│ ``` │001 │
│ ├───────┤
│ │Created│
│ │Mar 28 │
│ │10:00 │
│ ├───────┤
│ │Updated│
│ │Mar 28 │
│ │12:30 │
└─────────────────────────────────────────────────────────────────────────┴───────┘
```
### Mobile Layout (Metadata collapses to top)
```
┌─────────────────────────────────┐
│ ← Back [✏️] [↓] [🗑️] │
├─────────────────────────────────┤
│ REQ · draft · 🔴 High │
│ 🏷️ backend 🏷️ api 🏷️ auth │
├─────────────────────────────────┤
│ │
│ # API Authentication Design │
│ │
│ ## Descripción │
│ El sistema debe soportar... │
│ │
│ ## Criterios de Aceptación │
│ - [ ] Endpoint POST... │
│ - [ ] Middleware extrae... │
│ │
│ ## Notas │
│ Tokens en esta versión... │
│ │
│ ```javascript │
│ const token = req.headers... │
│ ``` │
│ │
│ Author: agent-001 │
│ Created: Mar 28, 2026 │
└─────────────────────────────────┘
```
### Delete Confirmation Modal
```
┌─────────────────────────────────────┐
│ ⚠️ Confirm Delete │
│ │
│ Delete "API Authentication"? │
│ │
│ This will permanently remove: │
│ • Document content │
│ • All metadata │
│ • Tag associations │
│ │
│ [Cancel] [Delete] │
└─────────────────────────────────────┘
```
---
## View 3: Document Editor
### Desktop Layout (Split View)
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Cancel Editing: API Authentication [Template ▼] [Save] │
├────────────────────────────────────────────────────────────────┬────────────────┤
│ Title: [API Authentication Design ] │ Auto-saved ✓ │
│ Library: [Backend Requirements ▼] │ │
│ Type: [Requirement ▼] Status: [Draft ▼] Priority: [High ▼] │ │
├────────────────────────────────────────────────────────────────┤ │
│ Tags: [+ Add tag...] │ │
│ [backend ×] [api ×] [auth ×] │ │
├────────────────────────────────────────────────────────────────┴────────────────┤
│ [B] [I] [H1] [H2] [H3] [•] [1.] [☐] [</>] [🔗] [📷] [Table] │ [Preview ▼] │
├────────────────────────────────────────────────────────────────┬────────────────┤
│ │ │
│ ## Descripción │ ## Descripción│
│ El sistema debe soportar autenticación via tokens │ │
│ Bearer para todas las rutas protegidas... │ ## Criterios │
│ │ de Aceptación │
│ ## Criterios de Aceptación │ │
│ - [ ] Endpoint POST /api/v1/auth/token │ • [ ] Criterio│
│ - [ ] Middleware extrae token │ 1 │
│ - [ ] Middleware retorna 401 │ • [ ] Criterio│
│ - [ ] Token tiene prefijo `snk_` │ 2 │
│ │ │
│ ## Notas │ │
│ Tokens en esta versión son secretos compartidos... │ │
│ │ │
│ ```javascript │ │
│ const token = req.headers... │ │
│ ``` │ │
│ │ │
├────────────────────────────────────────────────────────────────┴────────────────┤
│ Write Preview │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### Mobile Layout (Tabbed)
```
┌─────────────────────────────────┐
│ [✕] Edit Document [Save] │
├─────────────────────────────────┤
│ [Write] [Preview] [Meta] │
├─────────────────────────────────┤
│ │
│ Title: │
│ ┌─────────────────────────────┐│
│ │ API Authentication Design ││
│ └─────────────────────────────┘│
│ │
│ Content: │
│ ┌─────────────────────────────┐│
│ │ ## Descripción ││
│ │ El sistema debe soportar... ││
│ │ ││
│ │ ## Criterios de Aceptación ││
│ │ - [ ] Endpoint POST... ││
│ │ ││
│ └─────────────────────────────┘│
│ │
│ [+] Add tag: [____________] │
│ [backend ×] [api ×] [auth ×] │
│ │
│ Type: [Requirement ▼] │
│ Status: [Draft ▼] │
│ Priority: [High ▼] │
│ Library: [Backend ▼] │
│ │
│ Auto-saved ✓ │
└─────────────────────────────────┘
```
### Template Picker Dropdown
```
│ [Template ▼] │
│ ┌─────────────────────────────┐│
│ │ 📋 Requirement Document ││
│ │ 📝 General Note ││
│ │ 📐 Specification ││
│ │ 📄 Blank Document ││
│ └─────────────────────────────┘│
```
### Requirement Template Content (inserted)
```
---
id: REQ-XXX
title: New Requirement
type: requirement
priority: medium
status: draft
tags: []
createdBy: user
createdAt: YYYY-MM-DD
---
# New Requirement
## Descripción
[Descripción clara del requerimiento]
## Criterios de Aceptación
- [ ] Criterio 1
- [ ] Criterio 2
## Notas
[Notas adicionales]
```
### Unsaved Changes Warning Modal
```
┌─────────────────────────────────────┐
│ ⚠️ Unsaved Changes │
│ │
│ You have unsaved changes. │
│ │
│ [Discard] [Save & Close] │
└─────────────────────────────────────┘
```
---
## View 4: Library Browser
### Desktop Layout
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Home / Backend / API [+ Doc] [+ Lib] │
├─────────────────┬─────────────────────────────────────────────────────────────┤
│ │ │
│ 📁 Libraries │ 📁 API 3 docs │
│ ├─ 🔵 Backend │ ┌──────────────────────────────────────────────────────┐ │
│ │ ├─ ⚪ API │ │ 📄 REST API Endpoints REQ-010 │ │
│ │ └─ ⚪ Auth │ │ 🏷️ api · 🏷️ rest · 📅 Mar 28 │ │
│ ├─ 🔵 Frontend│ └──────────────────────────────────────────────────────┘ │
│ └─ 🔵 DevOps │ ┌──────────────────────────────────────────────────────┐ │
│ │ │ 📄 Authentication Flow SPEC-005 │ │
│ ───────────── │ │ 🏷️ api · 🏷️ auth · 📅 Mar 27 │ │
│ │ └──────────────────────────────────────────────────────┘ │
│ 📁 API │ ┌──────────────────────────────────────────────────────┐ │
│ ├─ REST │ │ 📄 Rate Limiting NOTE-012 │ │
│ └─ GraphQL │ │ 🏷️ api · 📅 Mar 26 │ │
│ │ └──────────────────────────────────────────────────────┘ │
│ ───────────── │ │
│ │ ─────────────── Sub-libraries ─────────────── │
│ Quick Links │ ┌──────────────────────────────────────────────────────┐│
│ ├─ All Docs │ │ 📁 REST [+ Doc] [+ Lib] ││
│ └─ Recent │ └──────────────────────────────────────────────────────┘│
│ │ ┌──────────────────────────────────────────────────────┐│
│ │ │ 📁 GraphQL [+ Doc] [+ Lib]││
│ │ └──────────────────────────────────────────────────────┘│
└─────────────────┴─────────────────────────────────────────────────────────────┘
```
### Mobile Layout
```
┌─────────────────────────────────┐
│ ← Home / Backend / API │
├─────────────────────────────────┤
│ │
│ 📁 API │
│ 3 documents · 2 sub-libs │
│ │
│ [+] Doc [+] Lib │
│ │
│ ┌─────────────────────────────┐│
│ │ 📄 REST API Endpoints ││
│ │ REQ-010 · 🏷️ api ││
│ └─────────────────────────────┘│
│ ┌─────────────────────────────┐│
│ │ 📄 Authentication Flow ││
│ │ SPEC-005 · 🏷️ api · 🏷️ auth ││
│ └─────────────────────────────┘│
│ │
│ 📁 Sub-libraries │
│ ├─ 📁 REST │
│ └─ 📁 GraphQL │
│ │
├─────────────────────────────────┤
│ [🏠] [📁] [+📝] [⚙️] │
└─────────────────────────────────┘
```
### Create Library Modal
```
┌─────────────────────────────────────┐
│ 📁 New Library │
│ │
│ Name: │
│ ┌─────────────────────────────┐ │
│ │ My New Library │ │
│ └─────────────────────────────┘ │
│ │
│ Parent: [Backend Requirements ▼] │
│ │
│ [Cancel] [Create] │
└─────────────────────────────────────┘
```
### Rename Library Modal
```
┌─────────────────────────────────────┐
│ ✏️ Rename Library │
│ │
│ Name: │
│ ┌─────────────────────────────┐ │
│ │ API Requirements │ │
│ └─────────────────────────────┘ │
│ │
│ [Cancel] [Rename] │
└─────────────────────────────────────┘
```
### Delete Library Modal
```
┌─────────────────────────────────────┐
│ ⚠️ Confirm Delete │
│ │
│ Delete library "API"? │
│ │
│ ⚠️ This will also delete: │
│ • 3 documents │
│ • 2 sub-libraries │
│ • 5 total documents │
│ │
│ [Cancel] [Delete] │
└─────────────────────────────────────┘
```
---
## Context Menu (Right-click / Long-press)
```
│ 📄 REST API Endpoints │
│ └── [Right-click] → │
│ │
│ ┌─────────────────────────┐ │
│ │ ✏️ Edit │ │
│ │ 📄 Open │ │
│ │ 📋 Duplicate │ │
│ │ 📁 Move to Library │ │
│ │ ─────────────────────── │ │
│ │ 🗑️ Delete │ │
│ └─────────────────────────┘ │
```
---
## Toast Notifications
```
│ ┌─────────────┐│
│ │ ✓ Saved ││
│ └─────────────┘│
│ ┌─────────────────────┐ │
│ │ ✕ Failed to save │ │
│ │ [Retry] │ │
│ └─────────────────────┘ │
```
---
## Settings Panel
```
┌─────────────────────────────────────┐
│ ⚙️ Settings │
├─────────────────────────────────────┤
│ │
│ Appearance │
│ Theme: [Dark ▼] [Light] [System] │
│ │
│ ───────────────────────────────── │
│ │
│ API Configuration │
│ API URL: │
│ ┌─────────────────────────────────┐│
│ │ http://localhost:3000/api/v1 ││
│ └─────────────────────────────────┘│
│ │
│ API Token: │
│ ┌─────────────────────────────────┐│
│ │ snk_xxxxxxxxxxxxxxxxxxxxxxxx ││
│ └─────────────────────────────────┘│
│ │
│ [Test Connection] [Save] │
│ │
│ ───────────────────────────────── │
│ │
│ Data │
│ [Clear Local Cache] │
│ │
│ ───────────────────────────────── │
│ │
│ About │
│ SimpleNote Web v0.1.0 │
│ │
│ [✕] │
└─────────────────────────────────────┘
```
---
## Keyboard Shortcuts Reference
```
┌─────────────────────────────────────────────┐
│ Global │
├─────────────────────────────────────────────┤
│ Ctrl/Cmd + K Focus search │
│ Ctrl/Cmd + N New document │
│ Ctrl/Cmd + Shift + N New library │
│ Escape Close/Clear │
├─────────────────────────────────────────────┤
│ Document Editor │
├─────────────────────────────────────────────┤
│ Ctrl/Cmd + S Save │
│ Ctrl/Cmd + B Bold │
│ Ctrl/Cmd + I Italic │
│ Ctrl/Cmd + K Insert link │
│ Ctrl/Cmd + Shift + P Toggle preview │
├─────────────────────────────────────────────┤
│ Document Viewer │
├─────────────────────────────────────────────┤
│ E Edit document │
│ X Export markdown │
│ Delete Delete (confirm) │
├─────────────────────────────────────────────┤
│ Library Browser │
├─────────────────────────────────────────────┤
│ R Rename │
│ Delete Delete (confirm) │
│ Backspace Go to parent │
└─────────────────────────────────────────────┘
```