Compare commits
1 Commits
main
..
architecture
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e4dd0807 |
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=*
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
+413
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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/
|
||||
```
|
||||
Generated
-1400
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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>`;
|
||||
});
|
||||
}
|
||||
@@ -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"></></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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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>');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* SimpleNote Web - UUID Helper
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export function generateId() {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
export default { generateId };
|
||||
-1139
File diff suppressed because it is too large
Load Diff
-309
@@ -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
File diff suppressed because it is too large
Load Diff
-541
@@ -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 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
Reference in New Issue
Block a user