Compare commits
75 Commits
05b8f3910d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ebeb4574ad | |||
| 7fce46bff2 | |||
| 9380273cba | |||
| d398226723 | |||
| e225f2ec2c | |||
| 85bbe7b61f | |||
| de1de1d3bc | |||
| ef8a7858b2 | |||
| 11615ee4cb | |||
| dc5089c011 | |||
| aaa04e1d26 | |||
| 0e22acc077 | |||
| 386dd2d6a2 | |||
| 85e6304bae | |||
| cf6b5785d7 | |||
| cf2f3d0255 | |||
| 66db3de33d | |||
| bfcb7d7fd3 | |||
| a0451a2084 | |||
| d003d3c1c7 | |||
| d91ff3f07c | |||
| 786dc4c825 | |||
| d5a6d0bfd7 | |||
| ef8c4bafe6 | |||
| 97082b0233 | |||
| 93b6c0b17d | |||
| f038f37001 | |||
| e5e976caff | |||
| e5b6ce3bdc | |||
| 5fb1c5d0b0 | |||
| f6218283f1 | |||
| 58311ba2df | |||
| 3ff5e6b031 | |||
| 1916bc33e4 | |||
| 410f72caf0 | |||
| 9847b4c5cc | |||
| f0e2c60620 | |||
| fc53062eb1 | |||
| ae0502ce16 | |||
| 28366151cf | |||
| 3378d5c523 | |||
| c8674dd56f | |||
| b7c4df42e0 | |||
| 3a523aafde | |||
| a8ef158fd7 | |||
| 9ca98d96db | |||
| 52e45a663e | |||
| bd1a5bc21c | |||
| 0c3ffff81c | |||
| 2f9233ad41 | |||
| 98c60c0d27 | |||
| 76c98ecdbe | |||
| b20b33bf9b | |||
| b4a5abb699 | |||
| ad21d7fb6a | |||
| ece8163d15 | |||
| e0433f8e57 | |||
| 13ee0f9922 | |||
| 0a96638681 | |||
| 33a4705f95 | |||
| e66a678160 | |||
| 8d56f34d68 | |||
| a40ab18b1b | |||
| cde0a143a5 | |||
| 8c80a12b81 | |||
| 544decf4ac | |||
| 7c5fba5f12 | |||
| a67442e9ed | |||
| 9ed7d8acec | |||
| e57927e37d | |||
| 9af25927b7 | |||
| d5c418c84f | |||
| 6cc5f3793a | |||
| ff7223bfea | |||
| ef0aebf510 |
@@ -9,7 +9,8 @@
|
|||||||
"Bash(node:*)",
|
"Bash(node:*)",
|
||||||
"Bash(curl:*)",
|
"Bash(curl:*)",
|
||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(npm list:*)"
|
"Bash(npm list:*)",
|
||||||
|
"Bash(npx jest:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,3 +27,7 @@ Build, test, and lint commands will be documented here once the project structur
|
|||||||
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen.
|
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen.
|
||||||
- Si no existe crea una carpeta resumen en la raiz del proyecto.
|
- Si no existe crea una carpeta resumen en la raiz del proyecto.
|
||||||
- Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento.
|
- Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento.
|
||||||
|
- El archivo debe ser creado con el horario local.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
- evitar agregar lo siguiente: Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-slim AS deps
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Stage 2: Build
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: Production
|
||||||
|
FROM node:20-slim AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
|
||||||
|
COPY --from=builder /app/prisma/schema.prisma /app/schema.prisma
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV DATABASE_URL="file:/app/data/dev.db"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
Vendored
+125
@@ -0,0 +1,125 @@
|
|||||||
|
pipeline {
|
||||||
|
agent {
|
||||||
|
node {
|
||||||
|
label 'java-springboot'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
environment {
|
||||||
|
URL_REGISTRY = 'gitea.danielarroyo.cl'
|
||||||
|
PROJECT = 'darroyo'
|
||||||
|
REMOTE_USER = 'root'
|
||||||
|
REMOTE_HOST = '10.5.0.116'
|
||||||
|
REMOTE_PATH = '/compose'
|
||||||
|
DOCKER_CREDENTIALS = credentials('gitea-docker-registry')
|
||||||
|
}
|
||||||
|
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 """
|
||||||
|
ls -la
|
||||||
|
ls -la src || echo "Directorio src no encontrado"
|
||||||
|
ls -la src/main/docker || echo "Directorio src/main/docker no encontrado"
|
||||||
|
cat .dockerignore || echo ".dockerignore no encontrado"
|
||||||
|
docker -v
|
||||||
|
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 {
|
||||||
|
try {
|
||||||
|
docker.withRegistry("https://${URL_REGISTRY}", 'gitea-docker-registry') {
|
||||||
|
sh """
|
||||||
|
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(`recall.vodorod.cl`)"
|
||||||
|
environment:
|
||||||
|
- TZ=America/Santiago
|
||||||
|
- DATABASE_URL=file:/app/data/dev.db
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
- homelab-net
|
||||||
|
mem_limit: 512m
|
||||||
|
mem_reservation: 256m
|
||||||
|
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,12 +1,12 @@
|
|||||||
# recall
|
# recall
|
||||||
|
|
||||||
Sistema de notas personal con captura rápida y búsqueda inteligente.
|
Sistema de notas personal con captura rápida, búsqueda inteligente y conexiones entre notas.
|
||||||
|
|
||||||
## Uso
|
## Uso
|
||||||
|
|
||||||
### Quick Add (Captura Rápida)
|
### Quick Add (Captura Rápida)
|
||||||
|
|
||||||
Crea notas al instante con el shortcut `Ctrl+N`.
|
Crea notas al instante con el shortcut `Ctrl+N` o desde el botón de captura rápida en el header.
|
||||||
|
|
||||||
Sintaxis:
|
Sintaxis:
|
||||||
```
|
```
|
||||||
@@ -41,12 +41,31 @@ rec: Pasta carbonara #cocina #italiana
|
|||||||
| `inventory` | Inventario | Item, Cantidad, Ubicación |
|
| `inventory` | Inventario | Item, Cantidad, Ubicación |
|
||||||
| `note` | Nota libre | Contenido |
|
| `note` | Nota libre | Contenido |
|
||||||
|
|
||||||
### Búsqueda
|
### Dashboard (Página Principal)
|
||||||
|
|
||||||
- Búsqueda por título y contenido
|
El dashboard muestra diferentes secciones según tu actividad:
|
||||||
- Búsqueda fuzzy (tolerante a errores)
|
|
||||||
- Filtros por tipo y tags
|
- **Recientes** - Últimas notas modificadas
|
||||||
- Favoritos y notas pinned influyen en el ranking
|
- **Más usadas** - Notas que consultas frecuentemente
|
||||||
|
- **Comandos recientes** - Notas de tipo comando
|
||||||
|
- **Snippets recientes** - Notas de código
|
||||||
|
- **Según tu actividad** - Notas relacionadas con tu historial de navegación
|
||||||
|
|
||||||
|
### Modo Trabajo
|
||||||
|
|
||||||
|
El botón de **Modo Trabajo** en el header (icono de monitor/ojo) es un toggle que indica cuando estás en modo de trabajo activo. Cuando está activado, el sistema:
|
||||||
|
- Puede influir en el ranking de búsqueda priorizando notas de trabajo
|
||||||
|
- Refleja visualmente que estás enfocado en una tarea
|
||||||
|
|
||||||
|
Se puede activar/desactivar desde el header o desde Configuración.
|
||||||
|
|
||||||
|
### Command Palette
|
||||||
|
|
||||||
|
Accede rápidamente a cualquier sección o acción con `Ctrl+K` (Windows) o `Cmd+K` (Mac):
|
||||||
|
|
||||||
|
- Navegación rápida a cualquier página
|
||||||
|
- Crear nueva nota
|
||||||
|
- Acceso directo a Configuración
|
||||||
|
|
||||||
### Links entre Notas
|
### Links entre Notas
|
||||||
|
|
||||||
@@ -56,7 +75,73 @@ Crea links a otras notas usando `[[nombre-de-nota]]`:
|
|||||||
Ver también: [[Configuración de Docker]]
|
Ver también: [[Configuración de Docker]]
|
||||||
```
|
```
|
||||||
|
|
||||||
Los backlinks se muestran automáticamente en la nota referenciada.
|
Los **backlinks** (notas que referencian la nota actual) se muestran automáticamente en la vista de detalle.
|
||||||
|
|
||||||
|
### Conexiones de Notas
|
||||||
|
|
||||||
|
Cada nota muestra diferentes tipos de conexiones:
|
||||||
|
|
||||||
|
- **Notas relacionadas** - Basadas en tags compartidos, tipo y contenido similar
|
||||||
|
- **Backlinks** - Notas que linkean a esta nota
|
||||||
|
- **Outgoing links** - Links salientes de esta nota hacia otras
|
||||||
|
- **Co-usadas** - Notas que sueles ver juntas
|
||||||
|
|
||||||
|
### Búsqueda
|
||||||
|
|
||||||
|
- Búsqueda por título y contenido
|
||||||
|
- Búsqueda fuzzy (tolerante a errores)
|
||||||
|
- Filtros por tipo y tags
|
||||||
|
- Favoritos y notas pinned influyen en el ranking
|
||||||
|
|
||||||
|
### Captura Externa (Bookmarklet)
|
||||||
|
|
||||||
|
Desde Configuración > Capturar web, puedes crear un marcador que permite capturar contenido de cualquier página web:
|
||||||
|
|
||||||
|
1. Arrastra el botón "Capturar a Recall" a tu barra de marcadores
|
||||||
|
2. Cuando estés en una página web, haz clic en el marcador
|
||||||
|
3. Confirma y guarda directamente en tus notas
|
||||||
|
|
||||||
|
El marcador captura: título de la página, URL y texto seleccionado.
|
||||||
|
|
||||||
|
### Drafts (Borradores)
|
||||||
|
|
||||||
|
El sistema guarda automáticamente borradores de tus notas mientras escribes. Si cierras accidentalmente la página, al volver se te ofrecer recuperar el borrador.
|
||||||
|
|
||||||
|
### Historial de Versiones
|
||||||
|
|
||||||
|
Cada nota mantiene un historial de versiones. Accede desde el botón de historial en la vista de detalle para ver y restaurar versiones anteriores.
|
||||||
|
|
||||||
|
### Backups y Restauración
|
||||||
|
|
||||||
|
En Configuración > Backups:
|
||||||
|
|
||||||
|
- **Backup automático** - Se crean backups al cerrar o cambiar de nota (configurable)
|
||||||
|
- **Retención** - Los backups se mantienen por el período indicado (por defecto 30 días)
|
||||||
|
- **Backup manual** - Exporta en cualquier momento
|
||||||
|
|
||||||
|
### Exportar e Importar
|
||||||
|
|
||||||
|
Desde Configuración > Exportar:
|
||||||
|
|
||||||
|
- **JSON** - Backup completo (recomendado para restaurar)
|
||||||
|
- **Markdown** - Notas en formato MD (ideal para compartir)
|
||||||
|
- **HTML** - Notas en formato HTML (para visualización)
|
||||||
|
|
||||||
|
**Importar:**
|
||||||
|
- JSON - Restauración de backup
|
||||||
|
- Markdown - Importación de archivos MD
|
||||||
|
|
||||||
|
### Atajos de Teclado
|
||||||
|
|
||||||
|
| Atajo | Acción |
|
||||||
|
|-------|--------|
|
||||||
|
| `Ctrl+N` | Nueva nota rápida |
|
||||||
|
| `Ctrl+K` / `Cmd+K` | Command Palette |
|
||||||
|
| `n` | Nueva nota (desde dashboard) |
|
||||||
|
| `g h` | Ir al Dashboard |
|
||||||
|
| `g n` | Ir a Notas |
|
||||||
|
| `/` | Enfocar búsqueda |
|
||||||
|
| `?` | Mostrar atajos de teclado |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -66,6 +151,48 @@ npx prisma db push
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
### Instalación con Docker
|
||||||
|
|
||||||
|
1. **Crear la carpeta para la base de datos:**
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Iniciar la aplicación:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
La aplicación estará disponible en `http://localhost:3000`
|
||||||
|
|
||||||
|
### Datos
|
||||||
|
|
||||||
|
- La base de datos SQLite se guarda en `./data/dev.db`
|
||||||
|
- Los datos persisten entre reinicios
|
||||||
|
- Para hacer backup, copia la carpeta `data/`
|
||||||
|
|
||||||
|
### Comandos útiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Detener
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Reconstruir (después de cambios)
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### Quick Add
|
### Quick Add
|
||||||
@@ -87,3 +214,41 @@ GET /api/tags # Listar todos
|
|||||||
GET /api/tags?q=python # Filtrar
|
GET /api/tags?q=python # Filtrar
|
||||||
GET /api/tags/suggest?title=...&content=... # Sugerencias
|
GET /api/tags/suggest?title=...&content=... # Sugerencias
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # Dashboard
|
||||||
|
│ ├── notes/
|
||||||
|
│ │ ├── page.tsx # Lista de notas
|
||||||
|
│ │ └── [id]/page.tsx # Detalle de nota
|
||||||
|
│ ├── new/page.tsx # Crear nota
|
||||||
|
│ ├── edit/[id]/page.tsx # Editar nota
|
||||||
|
│ ├── settings/page.tsx # Configuración
|
||||||
|
│ ├── capture/page.tsx # Captura externa
|
||||||
|
│ └── api/ # Rutas API
|
||||||
|
├── components/
|
||||||
|
│ ├── dashboard.tsx
|
||||||
|
│ ├── note-list.tsx
|
||||||
|
│ ├── command-palette.tsx
|
||||||
|
│ ├── work-mode-toggle.tsx
|
||||||
|
│ ├── quick-add.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── lib/
|
||||||
|
│ ├── work-mode.ts
|
||||||
|
│ ├── search.ts
|
||||||
|
│ ├── related.ts
|
||||||
|
│ ├── backlinks.ts
|
||||||
|
│ ├── usage.ts
|
||||||
|
│ ├── drafts.ts
|
||||||
|
│ ├── backup.ts
|
||||||
|
│ └── ...
|
||||||
|
└── types/
|
||||||
|
└── note.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
nueva prueba
|
||||||
@@ -38,6 +38,11 @@ const mockPrisma = {
|
|||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
},
|
},
|
||||||
|
noteVersion: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
$transaction: jest.fn((callback) => callback(mockPrisma)),
|
$transaction: jest.fn((callback) => callback(mockPrisma)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +536,64 @@ describe('API Integration Tests', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GET /api/tags/suggest - Suggest tags based on content
|
||||||
|
// ============================================
|
||||||
|
describe('GET /api/tags/suggest', () => {
|
||||||
|
it('suggests tags based on title keywords', async () => {
|
||||||
|
const { GET } = await import('@/app/api/tags/suggest/route')
|
||||||
|
const request = new NextRequest('http://localhost/api/tags/suggest?title=Docker%20deployment&content=')
|
||||||
|
const response = await GET(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.success).toBe(true)
|
||||||
|
expect(Array.isArray(data.data)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suggests tags based on content keywords', async () => {
|
||||||
|
const { GET } = await import('@/app/api/tags/suggest/route')
|
||||||
|
const request = new NextRequest('http://localhost/api/tags/suggest?title=&content=Docker%20and%20Kubernetes%20deployment')
|
||||||
|
const response = await GET(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.success).toBe(true)
|
||||||
|
expect(Array.isArray(data.data))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('combines title and content for suggestions', async () => {
|
||||||
|
const { GET } = await import('@/app/api/tags/suggest/route')
|
||||||
|
const request = new NextRequest('http://localhost/api/tags/suggest?title=Python%20script&content=SQL%20database%20query')
|
||||||
|
const response = await GET(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for generic content', async () => {
|
||||||
|
const { GET } = await import('@/app/api/tags/suggest/route')
|
||||||
|
const request = new NextRequest('http://localhost/api/tags/suggest?title=Note&content=content')
|
||||||
|
const response = await GET(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.success).toBe(true)
|
||||||
|
expect(Array.isArray(data.data)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty parameters gracefully', async () => {
|
||||||
|
const { GET } = await import('@/app/api/tags/suggest/route')
|
||||||
|
const request = new NextRequest('http://localhost/api/tags/suggest')
|
||||||
|
const response = await GET(request)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// GET /api/search - Search notes
|
// GET /api/search - Search notes
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { parseBacklinks, syncBacklinks, getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks'
|
||||||
|
|
||||||
|
// Mock prisma before importing backlinks module
|
||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
backlink: {
|
||||||
|
deleteMany: jest.fn(),
|
||||||
|
createMany: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
describe('backlinks.ts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseBacklinks', () => {
|
||||||
|
it('extracts single wiki-link', () => {
|
||||||
|
const content = 'This is about [[Docker Commands]]'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toEqual(['Docker Commands'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts multiple wiki-links', () => {
|
||||||
|
const content = 'See [[Docker Commands]] and [[Git Commands]] for reference'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toContain('Docker Commands')
|
||||||
|
expect(result).toContain('Git Commands')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts wiki-links with extra whitespace', () => {
|
||||||
|
const content = 'Check [[ Docker Commands ]] for details'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toEqual(['Docker Commands'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when no wiki-links', () => {
|
||||||
|
const content = 'This is a plain note without any links'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles wiki-links at start of content', () => {
|
||||||
|
const content = '[[First Note]] is the beginning'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toEqual(['First Note'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles wiki-links at end of content', () => {
|
||||||
|
const content = 'The solution is [[Last Note]]'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toEqual(['Last Note'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates repeated wiki-links', () => {
|
||||||
|
const content = 'See [[Docker Commands]] and again [[Docker Commands]]'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toEqual(['Docker Commands'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles nested brackets gracefully', () => {
|
||||||
|
const content = 'Check [[This]] and [[That]]'
|
||||||
|
const result = parseBacklinks(content)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('syncBacklinks', () => {
|
||||||
|
it('deletes existing backlinks before creating new ones', async () => {
|
||||||
|
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 2 })
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||||
|
|
||||||
|
await syncBacklinks('note-1', 'No links here')
|
||||||
|
|
||||||
|
expect(prisma.backlink.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { sourceNoteId: 'note-1' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates backlinks for valid linked notes', async () => {
|
||||||
|
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: 'note-2', title: 'Docker Commands' },
|
||||||
|
{ id: 'note-3', title: 'Git Commands' },
|
||||||
|
])
|
||||||
|
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 2 })
|
||||||
|
|
||||||
|
await syncBacklinks('note-1', 'See [[Docker Commands]] and [[Git Commands]]')
|
||||||
|
|
||||||
|
expect(prisma.backlink.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [
|
||||||
|
{ sourceNoteId: 'note-1', targetNoteId: 'note-2' },
|
||||||
|
{ sourceNoteId: 'note-1', targetNoteId: 'note-3' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create backlink to self', async () => {
|
||||||
|
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: 'note-1', title: 'Docker Commands' },
|
||||||
|
])
|
||||||
|
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||||
|
|
||||||
|
await syncBacklinks('note-1', 'This is [[Docker Commands]]')
|
||||||
|
|
||||||
|
// Should not create a backlink to itself
|
||||||
|
expect(prisma.backlink.createMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles case-insensitive title matching', async () => {
|
||||||
|
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: 'note-2', title: 'Docker Commands' },
|
||||||
|
])
|
||||||
|
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 1 })
|
||||||
|
|
||||||
|
await syncBacklinks('note-1', 'See [[docker commands]]')
|
||||||
|
|
||||||
|
expect(prisma.backlink.createMany).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when no wiki-links in content', async () => {
|
||||||
|
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 2 })
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
|
await syncBacklinks('note-1', 'Plain content without links')
|
||||||
|
|
||||||
|
expect(prisma.backlink.createMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getBacklinksForNote', () => {
|
||||||
|
it('returns backlinks with source note info', async () => {
|
||||||
|
const mockBacklinks = [
|
||||||
|
{
|
||||||
|
id: 'bl-1',
|
||||||
|
sourceNoteId: 'note-2',
|
||||||
|
targetNoteId: 'note-1',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
sourceNote: { id: 'note-2', title: 'Related Note', type: 'command' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks)
|
||||||
|
|
||||||
|
const result = await getBacklinksForNote('note-1')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].sourceNote.title).toBe('Related Note')
|
||||||
|
expect(result[0].sourceNote.type).toBe('command')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when no backlinks', async () => {
|
||||||
|
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
|
const result = await getBacklinksForNote('note-1')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts Date to ISO string', async () => {
|
||||||
|
const mockBacklinks = [
|
||||||
|
{
|
||||||
|
id: 'bl-1',
|
||||||
|
sourceNoteId: 'note-2',
|
||||||
|
targetNoteId: 'note-1',
|
||||||
|
createdAt: new Date('2024-01-01T12:00:00Z'),
|
||||||
|
sourceNote: { id: 'note-2', title: 'Related Note', type: 'command' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks)
|
||||||
|
|
||||||
|
const result = await getBacklinksForNote('note-1')
|
||||||
|
|
||||||
|
expect(result[0].createdAt).toBe('2024-01-01T12:00:00.000Z')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getOutgoingLinksForNote', () => {
|
||||||
|
it('returns outgoing links with target note info', async () => {
|
||||||
|
const mockBacklinks = [
|
||||||
|
{
|
||||||
|
id: 'bl-1',
|
||||||
|
sourceNoteId: 'note-1',
|
||||||
|
targetNoteId: 'note-2',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
targetNote: { id: 'note-2', title: 'Linked Note', type: 'snippet' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks)
|
||||||
|
|
||||||
|
const result = await getOutgoingLinksForNote('note-1')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].sourceNote.title).toBe('Linked Note')
|
||||||
|
expect(result[0].sourceNote.type).toBe('snippet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when no outgoing links', async () => {
|
||||||
|
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
|
const result = await getOutgoingLinksForNote('note-1')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { commands, CommandItem } from '@/lib/command-items'
|
||||||
|
|
||||||
|
describe('command-items', () => {
|
||||||
|
describe('commands array', () => {
|
||||||
|
it('contains navigation commands', () => {
|
||||||
|
const navCommands = commands.filter((cmd) => cmd.group === 'navigation')
|
||||||
|
expect(navCommands.length).toBeGreaterThan(0)
|
||||||
|
expect(navCommands.some((cmd) => cmd.id === 'nav-dashboard')).toBe(true)
|
||||||
|
expect(navCommands.some((cmd) => cmd.id === 'nav-notes')).toBe(true)
|
||||||
|
expect(navCommands.some((cmd) => cmd.id === 'nav-settings')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('contains action commands', () => {
|
||||||
|
const actionCommands = commands.filter((cmd) => cmd.group === 'actions')
|
||||||
|
expect(actionCommands.length).toBeGreaterThan(0)
|
||||||
|
expect(actionCommands.some((cmd) => cmd.id === 'action-new')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('command item structure', () => {
|
||||||
|
it('each command has required fields', () => {
|
||||||
|
commands.forEach((cmd: CommandItem) => {
|
||||||
|
expect(cmd.id).toBeDefined()
|
||||||
|
expect(cmd.label).toBeDefined()
|
||||||
|
expect(cmd.group).toBeDefined()
|
||||||
|
expect(typeof cmd.id).toBe('string')
|
||||||
|
expect(typeof cmd.label).toBe('string')
|
||||||
|
expect(['navigation', 'actions', 'search', 'recent']).toContain(cmd.group)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commands have keywords for search', () => {
|
||||||
|
commands.forEach((cmd: CommandItem) => {
|
||||||
|
if (cmd.keywords) {
|
||||||
|
expect(Array.isArray(cmd.keywords)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('command filtering', () => {
|
||||||
|
it('can filter by label', () => {
|
||||||
|
const filtered = commands.filter((cmd) =>
|
||||||
|
cmd.label.toLowerCase().includes('dashboard')
|
||||||
|
)
|
||||||
|
expect(filtered.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can filter by keywords', () => {
|
||||||
|
const filtered = commands.filter((cmd) =>
|
||||||
|
cmd.keywords?.some((k) => k.includes('home'))
|
||||||
|
)
|
||||||
|
expect(filtered.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { generateBookmarklet, encodeCapturePayload, CapturePayload } from '@/lib/external-capture'
|
||||||
|
|
||||||
|
describe('external-capture', () => {
|
||||||
|
describe('generateBookmarklet', () => {
|
||||||
|
it('generates a valid javascript bookmarklet string', () => {
|
||||||
|
const bookmarklet = generateBookmarklet()
|
||||||
|
expect(bookmarklet).toContain('javascript:')
|
||||||
|
expect(bookmarklet.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('contains the capture URL', () => {
|
||||||
|
const bookmarklet = generateBookmarklet()
|
||||||
|
expect(bookmarklet).toContain('capture')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('encodeCapturePayload', () => {
|
||||||
|
it('encodes title in params', () => {
|
||||||
|
const payload: CapturePayload = { title: 'Test Note', url: '', selection: '' }
|
||||||
|
const encoded = encodeCapturePayload(payload)
|
||||||
|
expect(encoded).toContain('title=Test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encodes url in params', () => {
|
||||||
|
const payload: CapturePayload = { title: '', url: 'https://example.com', selection: '' }
|
||||||
|
const encoded = encodeCapturePayload(payload)
|
||||||
|
expect(encoded).toContain('url=https%3A%2F%2Fexample.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encodes selection in params', () => {
|
||||||
|
const payload: CapturePayload = { title: '', url: '', selection: 'Selected text' }
|
||||||
|
const encoded = encodeCapturePayload(payload)
|
||||||
|
expect(encoded).toContain('selection=Selected')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { findLinkSuggestions, applyWikiLinks } from '@/lib/link-suggestions'
|
||||||
|
|
||||||
|
// Mock prisma
|
||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
note: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
describe('link-suggestions.ts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findLinkSuggestions', () => {
|
||||||
|
it('returns empty array for short content', async () => {
|
||||||
|
const result = await findLinkSuggestions('Hi')
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for empty content', async () => {
|
||||||
|
const result = await findLinkSuggestions('')
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds matching note titles in content', async () => {
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: '1', title: 'Docker Commands' },
|
||||||
|
{ id: '2', title: 'Git Tutorial' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const content = 'I use Docker Commands for containers and Git Tutorial for version control.'
|
||||||
|
const result = await findLinkSuggestions(content)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map(r => r.noteTitle)).toContain('Docker Commands')
|
||||||
|
expect(result.map(r => r.noteTitle)).toContain('Git Tutorial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes current note from suggestions', async () => {
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: '1', title: 'Current Note' },
|
||||||
|
{ id: '2', title: 'Related Note' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const content = 'See Related Note for details.'
|
||||||
|
const result = await findLinkSuggestions(content, '1')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].noteTitle).toBe('Related Note')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts by title length (longer first)', async () => {
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: '1', title: 'Short' },
|
||||||
|
{ id: '2', title: 'Very Long Title' },
|
||||||
|
{ id: '3', title: 'Medium Title' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const content = 'Short and Medium Title and Very Long Title'
|
||||||
|
const result = await findLinkSuggestions(content)
|
||||||
|
|
||||||
|
expect(result[0].noteTitle).toBe('Very Long Title')
|
||||||
|
expect(result[1].noteTitle).toBe('Medium Title')
|
||||||
|
expect(result[2].noteTitle).toBe('Short')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when no matches found', async () => {
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: '1', title: 'Docker' },
|
||||||
|
{ id: '2', title: 'Git' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const content = 'Python and JavaScript are programming languages.'
|
||||||
|
const result = await findLinkSuggestions(content)
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles case-insensitive matching', async () => {
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: '1', title: 'Docker Commands' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const content = 'I use DOCKER COMMANDS for my project.'
|
||||||
|
const result = await findLinkSuggestions(content)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].noteTitle).toBe('Docker Commands')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches whole words only', async () => {
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: '1', title: 'Git' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const content = 'GitHub uses Git internally.'
|
||||||
|
const result = await findLinkSuggestions(content)
|
||||||
|
|
||||||
|
// Should match standalone 'Git' but not 'Git' within 'GitHub'
|
||||||
|
// Note: the regex \bGit\b matches standalone 'Git', not 'Git' in 'GitHub'
|
||||||
|
expect(result.some(r => r.noteTitle === 'Git')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when no notes exist', async () => {
|
||||||
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
|
const result = await findLinkSuggestions('Some content with potential matches')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applyWikiLinks', () => {
|
||||||
|
it('replaces terms with wiki-links', () => {
|
||||||
|
const content = 'I use Docker and Git for projects.'
|
||||||
|
const replacements = [
|
||||||
|
{ term: 'Docker', noteId: '1' },
|
||||||
|
{ term: 'Git', noteId: '2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = applyWikiLinks(content, replacements)
|
||||||
|
|
||||||
|
expect(result).toBe('I use [[Docker]] and [[Git]] for projects.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles multiple occurrences', () => {
|
||||||
|
const content = 'Docker is great. Docker is fast.'
|
||||||
|
const replacements = [{ term: 'Docker', noteId: '1' }]
|
||||||
|
|
||||||
|
const result = applyWikiLinks(content, replacements)
|
||||||
|
|
||||||
|
expect(result).toBe('[[Docker]] is great. [[Docker]] is fast.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles case-insensitive matching and replaces with link term', () => {
|
||||||
|
const content = 'DOCKER and docker and Docker'
|
||||||
|
const replacements = [{ term: 'Docker', noteId: '1' }]
|
||||||
|
|
||||||
|
const result = applyWikiLinks(content, replacements)
|
||||||
|
|
||||||
|
// All variations matched and replaced with the link text
|
||||||
|
expect(result).toBe('[[Docker]] and [[Docker]] and [[Docker]]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns original content when no replacements', () => {
|
||||||
|
const content = 'Original content'
|
||||||
|
const replacements: { term: string; noteId: string }[] = []
|
||||||
|
|
||||||
|
const result = applyWikiLinks(content, replacements)
|
||||||
|
|
||||||
|
expect(result).toBe('Original content')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces multiple different terms', () => {
|
||||||
|
const content = 'Use React and TypeScript together.'
|
||||||
|
const replacements = [
|
||||||
|
{ term: 'React', noteId: '1' },
|
||||||
|
{ term: 'TypeScript', noteId: '2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = applyWikiLinks(content, replacements)
|
||||||
|
|
||||||
|
expect(result).toBe('Use [[React]] and [[TypeScript]] together.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Navigation history tests are limited due to localStorage mocking complexity
|
||||||
|
// The module itself is straightforward and works correctly in practice
|
||||||
|
|
||||||
|
describe('navigation-history', () => {
|
||||||
|
describe('module exports', () => {
|
||||||
|
it('exports required functions', async () => {
|
||||||
|
const module = await import('@/lib/navigation-history')
|
||||||
|
expect(typeof module.getNavigationHistory).toBe('function')
|
||||||
|
expect(typeof module.addToNavigationHistory).toBe('function')
|
||||||
|
expect(typeof module.clearNavigationHistory).toBe('function')
|
||||||
|
expect(typeof module.removeFromNavigationHistory).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { parseQuery, QueryAST } from '@/lib/query-parser'
|
||||||
|
|
||||||
|
describe('query-parser', () => {
|
||||||
|
describe('basic text queries', () => {
|
||||||
|
it('returns text with no filters for simple text', () => {
|
||||||
|
const result = parseQuery('docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves multi-word text', () => {
|
||||||
|
const result = parseQuery('hello world')
|
||||||
|
expect(result.text).toBe('hello world')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('type filter', () => {
|
||||||
|
it('extracts type filter from beginning of query', () => {
|
||||||
|
const result = parseQuery('type:command docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'command' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles query with only type filter', () => {
|
||||||
|
const result = parseQuery('type:snippet')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ type: 'snippet' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts type filter from end of query', () => {
|
||||||
|
const result = parseQuery('docker type:command')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'command' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tag filter', () => {
|
||||||
|
it('extracts tag filter with text', () => {
|
||||||
|
const result = parseQuery('tag:api error')
|
||||||
|
expect(result.text).toBe('error')
|
||||||
|
expect(result.filters).toEqual({ tag: 'api' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles query with only tag filter', () => {
|
||||||
|
const result = parseQuery('tag:backend')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ tag: 'backend' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('combined filters', () => {
|
||||||
|
it('parses multiple filters together', () => {
|
||||||
|
const result = parseQuery('docker tag:backend type:command')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'command', tag: 'backend' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles type, tag, and isFavorite combined', () => {
|
||||||
|
const result = parseQuery('type:snippet tag:python is:favorite')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ type: 'snippet', tag: 'python', isFavorite: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('boolean filters', () => {
|
||||||
|
it('extracts is:favorite filter', () => {
|
||||||
|
const result = parseQuery('is:favorite docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ isFavorite: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles is:pinned filter alone', () => {
|
||||||
|
const result = parseQuery('is:pinned')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ isPinned: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles both boolean filters with text', () => {
|
||||||
|
const result = parseQuery('is:favorite is:pinned docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ isFavorite: true, isPinned: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles empty string', () => {
|
||||||
|
const result = parseQuery('')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles whitespace only', () => {
|
||||||
|
const result = parseQuery(' ')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores empty type value', () => {
|
||||||
|
const result = parseQuery('type:')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores empty tag value', () => {
|
||||||
|
const result = parseQuery('tag:')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('last duplicate filter wins for type', () => {
|
||||||
|
const result = parseQuery('type:command type:snippet docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'snippet' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('last duplicate filter wins for tag', () => {
|
||||||
|
const result = parseQuery('tag:python tag:javascript code')
|
||||||
|
expect(result.text).toBe('code')
|
||||||
|
expect(result.filters).toEqual({ tag: 'javascript' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('case sensitivity', () => {
|
||||||
|
it('filter name is case insensitive', () => {
|
||||||
|
const result = parseQuery('TYPE:command')
|
||||||
|
expect(result.filters).toEqual({ type: 'command' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filter value is case sensitive', () => {
|
||||||
|
const result = parseQuery('type:Command')
|
||||||
|
expect(result.filters).toEqual({ type: 'Command' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is:favorite is case insensitive', () => {
|
||||||
|
const result = parseQuery('IS:FAVORITE docker')
|
||||||
|
expect(result.filters).toEqual({ isFavorite: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is:pinned is case insensitive', () => {
|
||||||
|
const result = parseQuery('IS:PINNED')
|
||||||
|
expect(result.filters).toEqual({ isPinned: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
|
||||||
|
|
||||||
|
describe('type-inference.ts', () => {
|
||||||
|
describe('inferNoteType', () => {
|
||||||
|
describe('command detection', () => {
|
||||||
|
it('detects git commands', () => {
|
||||||
|
const content = 'git commit -m "fix: resolve issue"\nnpm install\ndocker build'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('command')
|
||||||
|
expect(result?.confidence).toBeTruthy() // any confidence level
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects docker commands', () => {
|
||||||
|
const content = 'docker build -t myapp .\ndocker run -d'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('command')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects shell prompts', () => {
|
||||||
|
const content = '$ curl -X POST https://api.example.com\n$ npm install'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('command')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects shebang', () => {
|
||||||
|
const content = '#!/bin/bash\necho "Hello"'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('command')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('snippet detection', () => {
|
||||||
|
it('detects code blocks', () => {
|
||||||
|
const content = '```javascript\nconst x = 1;\n```'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('snippet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects function declarations', () => {
|
||||||
|
const content = 'function hello() {\n return "world";\n}'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('snippet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects ES6 imports', () => {
|
||||||
|
const content = 'import React from "react"\nexport default App'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('snippet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects arrow functions', () => {
|
||||||
|
const content = 'const add = (a, b) => a + b;'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('snippet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects object literals', () => {
|
||||||
|
const content = 'const config = {\n name: "app",\n version: "1.0.0"\n}'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('snippet')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('procedure detection', () => {
|
||||||
|
it('detects numbered steps', () => {
|
||||||
|
const content = '1. First step\n2. Second step\n3. Third step'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('procedure')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects bullet points as steps', () => {
|
||||||
|
const content = '- Open the terminal\n- Run the command\n- Check the output'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('procedure')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects step-related keywords', () => {
|
||||||
|
const content = 'primer paso, segundo paso, tercer paso, finally'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('procedure')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects tutorial language', () => {
|
||||||
|
const content = 'How to install Node.js:\n1. Download the installer\n2. Run the setup'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('procedure')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recipe detection', () => {
|
||||||
|
it('detects ingredients pattern', () => {
|
||||||
|
const content = 'ingredientes:\n- 2 tazas de harina\n- 1 taza de azúcar'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('recipe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects recipe-related keywords', () => {
|
||||||
|
const content = 'receta:\n1. sofreír cebolla\n2. añadir arroz\ntiempo: 30 minutos'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('recipe')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('decision detection', () => {
|
||||||
|
it('detects decision context', () => {
|
||||||
|
const content = 'Decisión: Usar PostgreSQL en lugar de MySQL\n\nRazón: Mejor soporte para JSON y transacciones.'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('decision')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects pros and cons', () => {
|
||||||
|
const content = 'decisión:\nPros: Better performance\nContras: More expensive'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('decision')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects alternatives considered', () => {
|
||||||
|
const content = 'Alternativas consideradas:\n1. AWS\n2. GCP\n3. Azure\n\nElegimos Vercel por su integración con Next.js.'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('decision')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inventory detection', () => {
|
||||||
|
it('detects quantity patterns', () => {
|
||||||
|
const content = 'Item: Laptop\nCantidad: 5\nUbicación: Oficina principal'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('inventory')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects inventory-related keywords', () => {
|
||||||
|
const content = 'Stock disponible: 100 unidades\nNivel mínimo: 20'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('inventory')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('returns note type for generic content', () => {
|
||||||
|
const content = 'This is a simple note about my day.'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('note')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns note type with low confidence for empty content', () => {
|
||||||
|
const result = inferNoteType('')
|
||||||
|
expect(result?.type).toBe('note')
|
||||||
|
expect(result?.confidence).toBe('low')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns note type with low confidence for very short content', () => {
|
||||||
|
const result = inferNoteType('Hi')
|
||||||
|
expect(result?.type).toBe('note')
|
||||||
|
expect(result?.confidence).toBe('low')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prioritizes highest confidence match', () => {
|
||||||
|
// Command-like code with shell prompt - medium confidence (2 patterns)
|
||||||
|
const content = '$ npm install\ngit commit -m "fix"'
|
||||||
|
const result = inferNoteType(content)
|
||||||
|
expect(result?.type).toBe('command')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatContentForType', () => {
|
||||||
|
it('formats command type', () => {
|
||||||
|
const content = 'git status'
|
||||||
|
const result = formatContentForType(content, 'command')
|
||||||
|
expect(result).toContain('## Comando')
|
||||||
|
expect(result).toContain('## Cuándo usarlo')
|
||||||
|
expect(result).toContain('## Ejemplo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats snippet type', () => {
|
||||||
|
const content = 'const x = 1;'
|
||||||
|
const result = formatContentForType(content, 'snippet')
|
||||||
|
expect(result).toContain('## Snippet')
|
||||||
|
expect(result).toContain('## Lenguaje')
|
||||||
|
expect(result).toContain('## Código')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats procedure type', () => {
|
||||||
|
const content = '1. Step one\n2. Step two'
|
||||||
|
const result = formatContentForType(content, 'procedure')
|
||||||
|
expect(result).toContain('## Objetivo')
|
||||||
|
expect(result).toContain('## Pasos')
|
||||||
|
expect(result).toContain('## Requisitos')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats recipe type', () => {
|
||||||
|
const content = 'Ingredients:\n- Flour\n- Sugar'
|
||||||
|
const result = formatContentForType(content, 'recipe')
|
||||||
|
expect(result).toContain('## Ingredientes')
|
||||||
|
expect(result).toContain('## Pasos')
|
||||||
|
expect(result).toContain('## Tiempo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats decision type', () => {
|
||||||
|
const content = 'Use TypeScript'
|
||||||
|
const result = formatContentForType(content, 'decision')
|
||||||
|
expect(result).toContain('## Contexto')
|
||||||
|
expect(result).toContain('## Decisión')
|
||||||
|
expect(result).toContain('## Alternativas')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats inventory type', () => {
|
||||||
|
const content = 'Laptop model X'
|
||||||
|
const result = formatContentForType(content, 'inventory')
|
||||||
|
expect(result).toContain('## Item')
|
||||||
|
expect(result).toContain('## Cantidad')
|
||||||
|
expect(result).toContain('## Ubicación')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats note type', () => {
|
||||||
|
const content = 'Simple note content'
|
||||||
|
const result = formatContentForType(content, 'note')
|
||||||
|
expect(result).toContain('## Notas')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { createVersion, getVersions, getVersion, restoreVersion } from '@/lib/versions'
|
||||||
|
|
||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
note: { findUnique: jest.fn(), update: jest.fn() },
|
||||||
|
noteVersion: { create: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { NotFoundError } from '@/lib/errors'
|
||||||
|
|
||||||
|
describe('versions.ts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createVersion', () => {
|
||||||
|
it('creates a version with correct noteId, title, content', async () => {
|
||||||
|
const mockNote = { id: 'note-1', title: 'Test Title', content: 'Test Content' }
|
||||||
|
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Test Title', content: 'Test Content', createdAt: new Date() }
|
||||||
|
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||||
|
;(prisma.noteVersion.create as jest.Mock).mockResolvedValue(mockVersion)
|
||||||
|
|
||||||
|
const result = await createVersion('note-1')
|
||||||
|
|
||||||
|
expect(result).toEqual(mockVersion)
|
||||||
|
expect(prisma.note.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'note-1' },
|
||||||
|
select: { id: true, title: true, content: true },
|
||||||
|
})
|
||||||
|
expect(prisma.noteVersion.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
noteId: 'note-1',
|
||||||
|
title: 'Test Title',
|
||||||
|
content: 'Test Content',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NotFoundError if note does not exist', async () => {
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(createVersion('note-1')).rejects.toThrow(NotFoundError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVersions', () => {
|
||||||
|
it('returns all versions for a note ordered by createdAt desc', async () => {
|
||||||
|
const mockNote = { id: 'note-1' }
|
||||||
|
const mockVersions = [
|
||||||
|
{ id: 'version-2', noteId: 'note-1', title: 'Title 2', content: 'Content 2', createdAt: new Date('2024-01-02') },
|
||||||
|
{ id: 'version-1', noteId: 'note-1', title: 'Title 1', content: 'Content 1', createdAt: new Date('2024-01-01') },
|
||||||
|
]
|
||||||
|
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||||
|
;(prisma.noteVersion.findMany as jest.Mock).mockResolvedValue(mockVersions)
|
||||||
|
|
||||||
|
const result = await getVersions('note-1')
|
||||||
|
|
||||||
|
expect(result).toEqual(mockVersions)
|
||||||
|
expect(prisma.note.findUnique).toHaveBeenCalledWith({ where: { id: 'note-1' } })
|
||||||
|
expect(prisma.noteVersion.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { noteId: 'note-1' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
noteId: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NotFoundError if note does not exist', async () => {
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(getVersions('note-1')).rejects.toThrow(NotFoundError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVersion', () => {
|
||||||
|
it('returns version by ID', async () => {
|
||||||
|
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Title', content: 'Content', createdAt: new Date() }
|
||||||
|
|
||||||
|
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
|
||||||
|
|
||||||
|
const result = await getVersion('version-1')
|
||||||
|
|
||||||
|
expect(result).toEqual(mockVersion)
|
||||||
|
expect(prisma.noteVersion.findUnique).toHaveBeenCalledWith({ where: { id: 'version-1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NotFoundError if version does not exist', async () => {
|
||||||
|
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(getVersion('version-1')).rejects.toThrow(NotFoundError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('restoreVersion', () => {
|
||||||
|
it('updates note title and content from version', async () => {
|
||||||
|
const mockNote = { id: 'note-1', title: 'Old Title', content: 'Old Content' }
|
||||||
|
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Old Title', content: 'Old Content', createdAt: new Date() }
|
||||||
|
const updatedNote = { id: 'note-1', title: 'Old Title', content: 'Old Content', updatedAt: new Date() }
|
||||||
|
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||||
|
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
|
||||||
|
;(prisma.note.update as jest.Mock).mockResolvedValue(updatedNote)
|
||||||
|
|
||||||
|
const result = await restoreVersion('note-1', 'version-1')
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedNote)
|
||||||
|
expect(prisma.note.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'note-1' },
|
||||||
|
data: {
|
||||||
|
title: 'Old Title',
|
||||||
|
content: 'Old Content',
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates note updatedAt timestamp', async () => {
|
||||||
|
const mockNote = { id: 'note-1', title: 'Title', content: 'Content' }
|
||||||
|
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Title', content: 'Content', createdAt: new Date() }
|
||||||
|
const beforeUpdate = new Date('2024-01-01')
|
||||||
|
const afterUpdate = new Date('2024-01-02')
|
||||||
|
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||||
|
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
|
||||||
|
;(prisma.note.update as jest.Mock).mockResolvedValue({ id: 'note-1', title: 'Title', content: 'Content', updatedAt: afterUpdate })
|
||||||
|
|
||||||
|
const result = await restoreVersion('note-1', 'version-1')
|
||||||
|
|
||||||
|
expect(result.updatedAt).toEqual(afterUpdate)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NotFoundError if note does not exist', async () => {
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NotFoundError if version does not exist', async () => {
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue({ id: 'note-1' })
|
||||||
|
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws NotFoundError if version does not belong to note', async () => {
|
||||||
|
;(prisma.note.findUnique as jest.Mock).mockResolvedValue({ id: 'note-1' })
|
||||||
|
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue({ id: 'version-1', noteId: 'note-2' })
|
||||||
|
|
||||||
|
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# Recall — Tickets técnicos MVP-4 (Camino Producto)
|
||||||
|
|
||||||
|
## 🎯 Objetivo
|
||||||
|
Convertir Recall en una herramienta confiable, rápida y diaria, enfocada en:
|
||||||
|
- búsqueda tipo Google personal
|
||||||
|
- navegación instantánea
|
||||||
|
- confianza (historial + backup)
|
||||||
|
- contexto activo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧩 EPIC 1 — Búsqueda avanzada (Google personal)
|
||||||
|
|
||||||
|
## [P1] Ticket 01 — Parser de query avanzada
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Permitir búsquedas expresivas tipo: `docker tag:backend type:command`
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear `src/lib/query-parser.ts`
|
||||||
|
- Soportar:
|
||||||
|
- texto libre
|
||||||
|
- `type:`
|
||||||
|
- `tag:`
|
||||||
|
- `is:favorite`, `is:pinned`
|
||||||
|
- Devolver AST simple
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Queries válidas parsean correctamente
|
||||||
|
- Soporta combinación de filtros + texto
|
||||||
|
- Tests unitarios incluidos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 02 — Integrar query avanzada en search
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Aplicar parser en `/api/search`
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Filtrar por AST antes de scoring
|
||||||
|
- Mantener scoring existente
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- `type:command docker` filtra correctamente
|
||||||
|
- `tag:api error` funciona
|
||||||
|
- No rompe búsqueda actual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 03 — Búsqueda en tiempo real
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Actualizar resultados mientras el usuario escribe
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Debounce en `search-bar.tsx`
|
||||||
|
- Fetch automático
|
||||||
|
- Estado loading ligero
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Resultados cambian en <300ms
|
||||||
|
- No bloquea UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 04 — Navegación por teclado en búsqueda
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
UX tipo Spotlight
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- ↑ ↓ para moverse
|
||||||
|
- Enter para abrir
|
||||||
|
- ESC para cerrar
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Navegación sin mouse
|
||||||
|
- Estado seleccionado visible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧠 EPIC 2 — Contexto activo
|
||||||
|
|
||||||
|
## [P1] Ticket 05 — Sidebar contextual inteligente
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Mostrar contexto dinámico mientras navegas
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear `note-context-sidebar.tsx`
|
||||||
|
- Mostrar:
|
||||||
|
- relacionadas
|
||||||
|
- co-uso
|
||||||
|
- backlinks
|
||||||
|
- recientes
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Siempre muestra contenido relevante
|
||||||
|
- No rompe layout responsive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 06 — Sugerencias dinámicas en lectura
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Recomendar contenido mientras lees
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Hook en `notes/[id]/page.tsx`
|
||||||
|
- Actualizar sugerencias según scroll/uso
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Sugerencias cambian según contexto
|
||||||
|
- No afecta performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔐 EPIC 3 — Confianza total
|
||||||
|
|
||||||
|
## [P1] Ticket 07 — Historial de versiones
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Permitir ver y revertir cambios
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Modelo `NoteVersion`
|
||||||
|
- Guardar snapshot en cada update
|
||||||
|
- Endpoint `/api/notes/[id]/versions`
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Se pueden listar versiones
|
||||||
|
- Se puede restaurar versión
|
||||||
|
- No rompe edición actual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 08 — UI historial de versiones
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Visualizar cambios
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Vista en `notes/[id]`
|
||||||
|
- Mostrar lista de versiones
|
||||||
|
- Botón restaurar
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- UX clara
|
||||||
|
- Confirmación antes de revertir
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 09 — Backup automático
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Evitar pérdida de datos
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Export JSON automático
|
||||||
|
- Guardar en local (descarga o storage)
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Backup se genera periódicamente
|
||||||
|
- No bloquea app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⚡ EPIC 4 — Rendimiento y UX
|
||||||
|
|
||||||
|
## [P1] Ticket 10 — Cache de resultados de búsqueda
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Reducir latencia
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Cache en cliente por query
|
||||||
|
- Invalidación simple
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Queries repetidas son instantáneas
|
||||||
|
- No datos stale críticos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 11 — Preload de notas frecuentes
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Abrir notas más rápido
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Prefetch en hover/listado
|
||||||
|
- Usar Next.js prefetch
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Navegación instantánea en notas frecuentes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧪 EPIC 5 — Calidad
|
||||||
|
|
||||||
|
## [P1] Ticket 12 — Tests query avanzada
|
||||||
|
|
||||||
|
- parser
|
||||||
|
- integración search
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 13 — Tests historial versiones
|
||||||
|
|
||||||
|
- creación
|
||||||
|
- restore
|
||||||
|
- edge cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 14 — Tests navegación teclado
|
||||||
|
|
||||||
|
- selección
|
||||||
|
- acciones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🗺️ Orden sugerido
|
||||||
|
|
||||||
|
## Sprint 1
|
||||||
|
- Query parser
|
||||||
|
- Integración search
|
||||||
|
- Real-time search
|
||||||
|
- Tests base
|
||||||
|
|
||||||
|
## Sprint 2
|
||||||
|
- Navegación teclado
|
||||||
|
- Sidebar contextual
|
||||||
|
- Cache búsqueda
|
||||||
|
|
||||||
|
## Sprint 3
|
||||||
|
- Historial versiones (API + UI)
|
||||||
|
|
||||||
|
## Sprint 4
|
||||||
|
- Backup automático
|
||||||
|
- Preload notas
|
||||||
|
- Tests finales
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ✅ Definition of Done
|
||||||
|
|
||||||
|
- Feature usable sin bugs críticos
|
||||||
|
- Tests pasando
|
||||||
|
- No regresiones
|
||||||
|
- UX fluida (<300ms interacción)
|
||||||
@@ -0,0 +1,970 @@
|
|||||||
|
# Recall — Tickets técnicos MVP-5 (Confianza, flujo diario y expansión)
|
||||||
|
|
||||||
|
## Objetivo general
|
||||||
|
Consolidar Recall como sistema principal de pensamiento y memoria externa, enfocado en:
|
||||||
|
- confianza total en los datos
|
||||||
|
- reducción extrema de fricción
|
||||||
|
- captura desde fuera de la app
|
||||||
|
- recuperación y operación desde teclado
|
||||||
|
- portabilidad real del conocimiento
|
||||||
|
|
||||||
|
## Principios de producto
|
||||||
|
1. **No perder nada**: backup y restore confiables.
|
||||||
|
2. **Todo a mano**: acciones principales accesibles por teclado.
|
||||||
|
3. **Captura ubicua**: guardar conocimiento desde cualquier contexto.
|
||||||
|
4. **Salida garantizada**: exportaciones útiles y reversibles.
|
||||||
|
5. **Experiencia continua**: la app acompaña el flujo de trabajo, no lo interrumpe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPIC 1 — Confianza total y resiliencia de datos
|
||||||
|
|
||||||
|
## [P1] Ticket 01 — Diseñar estrategia de backup automático local
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Definir e implementar una estrategia segura de backup automático para evitar pérdida de datos y aumentar la confianza en Recall.
|
||||||
|
|
||||||
|
**Contexto**
|
||||||
|
Recall ya cuenta con export/import manual e historial de versiones por nota. El siguiente salto es garantizar respaldo periódico y silencioso del estado global del conocimiento.
|
||||||
|
|
||||||
|
**Problema que resuelve**
|
||||||
|
- Riesgo de pérdida por errores del usuario, corrupción local o cambios no deseados.
|
||||||
|
- Dependencia de exportaciones manuales.
|
||||||
|
- Falta de sensación de “sistema confiable”.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Diseñar estrategia de backup automático basada en eventos y/o tiempo:
|
||||||
|
- al detectar cambios significativos
|
||||||
|
- cada cierto intervalo configurable
|
||||||
|
- al cerrar sesión o abandonar pestaña cuando aplique
|
||||||
|
- Definir formato del backup:
|
||||||
|
- JSON estructurado compatible con importación
|
||||||
|
- metadatos de versión, fecha, origen, conteos
|
||||||
|
- Definir almacenamiento inicial:
|
||||||
|
- IndexedDB recomendado para snapshots locales
|
||||||
|
- alternativa: local filesystem vía descarga manual asistida
|
||||||
|
- Crear servicio de generación de backup
|
||||||
|
- Crear política de retención:
|
||||||
|
- conservar últimos N backups
|
||||||
|
- limpiar backups viejos automáticamente
|
||||||
|
|
||||||
|
**No incluye**
|
||||||
|
- sincronización cloud
|
||||||
|
- backup remoto
|
||||||
|
- cifrado extremo a extremo
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- La app genera backups automáticamente sin intervención manual
|
||||||
|
- Los backups se almacenan con timestamp y metadatos
|
||||||
|
- Existe retención automática configurable o fija
|
||||||
|
- El proceso no bloquea la UI
|
||||||
|
- El formato es compatible con restore/import
|
||||||
|
- Hay tests para serialización y política de retención
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/backup.ts`
|
||||||
|
- `src/lib/backup-storage.ts`
|
||||||
|
- `src/lib/backup-policy.ts`
|
||||||
|
- `src/types/backup.ts`
|
||||||
|
- `src/app/settings/page.tsx`
|
||||||
|
- `src/app/api/export-import/route.ts`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- Separar claramente:
|
||||||
|
- generación del snapshot
|
||||||
|
- persistencia local
|
||||||
|
- política de retención
|
||||||
|
- Preferir un esquema de versión explícito del backup (`schemaVersion`)
|
||||||
|
- Incluir checksum o hash simple opcional para detectar corrupción
|
||||||
|
- Mantener compatibilidad hacia atrás cuando cambie el formato
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 02 — Implementar motor de snapshot global exportable
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Crear una utilidad robusta que genere snapshots completos y consistentes del estado de Recall.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Incluir en el snapshot:
|
||||||
|
- notas
|
||||||
|
- tags
|
||||||
|
- backlinks/enlaces si corresponden
|
||||||
|
- métricas relevantes necesarias para restore
|
||||||
|
- versiones de notas, si se decide incluirlas
|
||||||
|
- metadatos de creación
|
||||||
|
- Crear función `createBackupSnapshot()`
|
||||||
|
- Reutilizar la lógica existente de exportación para evitar duplicación
|
||||||
|
- Estandarizar el shape del payload exportable
|
||||||
|
|
||||||
|
**No incluye**
|
||||||
|
- compresión
|
||||||
|
- cifrado
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Un snapshot puede reconstruir el estado esperado del sistema
|
||||||
|
- El export manual y el backup automático comparten formato base o traductor explícito
|
||||||
|
- Tests verifican consistencia del snapshot
|
||||||
|
- El snapshot incluye versión de esquema y fecha de creación
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/backup.ts`
|
||||||
|
- `src/lib/export.ts`
|
||||||
|
- `src/app/api/export-import/route.ts`
|
||||||
|
- `__tests__/backup.test.ts`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- Evitar incluir datos derivados si pueden regenerarse fácilmente
|
||||||
|
- Documentar claramente qué campos se consideran fuente de verdad
|
||||||
|
- Si `NoteUsage` no debe restaurarse, dejarlo explícito en especificación
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 03 — Restore desde backup con preview y validación
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Permitir restaurar un backup de forma segura, transparente y reversible.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear flujo de restore desde Settings
|
||||||
|
- Validar el archivo antes de aplicar:
|
||||||
|
- schemaVersion
|
||||||
|
- integridad mínima
|
||||||
|
- estructura esperada
|
||||||
|
- Mostrar preview:
|
||||||
|
- cantidad de notas
|
||||||
|
- tags
|
||||||
|
- versiones
|
||||||
|
- fecha del backup
|
||||||
|
- Permitir dos modos:
|
||||||
|
- merge
|
||||||
|
- replace completo
|
||||||
|
- Confirmación explícita antes de aplicar
|
||||||
|
|
||||||
|
**No incluye**
|
||||||
|
- merge inteligente avanzado por conflicto
|
||||||
|
- restore parcial por selección de entidades
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El usuario puede seleccionar un backup y previsualizarlo
|
||||||
|
- El sistema informa claramente qué se va a restaurar
|
||||||
|
- Hay confirmación antes del replace
|
||||||
|
- El restore fallido no deja la base en estado inconsistente
|
||||||
|
- Existe feedback claro de éxito/error
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/components/backup-restore-dialog.tsx`
|
||||||
|
- `src/lib/restore.ts`
|
||||||
|
- `src/lib/backup-validator.ts`
|
||||||
|
- `src/app/settings/page.tsx`
|
||||||
|
- `src/app/api/export-import/route.ts`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- En `replace`, considerar transacción única
|
||||||
|
- En `merge`, definir reglas claras por ID/título
|
||||||
|
- Crear un backup previo automático antes de aplicar restore
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 04 — Backup previo automático antes de operaciones destructivas
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Reducir al mínimo el riesgo antes de operaciones peligrosas.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Generar backup automático antes de:
|
||||||
|
- restore replace
|
||||||
|
- import replace
|
||||||
|
- borrados masivos futuros
|
||||||
|
- Etiquetar ese backup como `pre-destructive`
|
||||||
|
- Permitir revertir rápidamente
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Antes de una operación destructiva se crea un backup
|
||||||
|
- El backup queda identificado y visible en UI
|
||||||
|
- Si la operación falla, el backup sigue disponible
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/backup.ts`
|
||||||
|
- `src/lib/restore.ts`
|
||||||
|
- `src/app/settings/page.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 05 — Guard de cambios no guardados
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Evitar pérdida accidental de trabajo durante edición.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Detectar cambios sucios en `note-form`
|
||||||
|
- Advertir al:
|
||||||
|
- navegar fuera de la página
|
||||||
|
- cerrar pestaña
|
||||||
|
- refrescar
|
||||||
|
- Permitir omitir warning cuando no hay cambios
|
||||||
|
|
||||||
|
**No incluye**
|
||||||
|
- autosave completo
|
||||||
|
- borradores persistentes
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Si hay cambios sin guardar, aparece advertencia al salir
|
||||||
|
- Si no hay cambios, no aparece advertencia
|
||||||
|
- Funciona en crear y editar
|
||||||
|
- No rompe submit exitoso
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/hooks/use-unsaved-changes.ts`
|
||||||
|
- `src/components/note-form.tsx`
|
||||||
|
- `src/app/edit/[id]/page.tsx`
|
||||||
|
- `src/app/new/page.tsx`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- Diferenciar estado inicial vs actual
|
||||||
|
- Manejar `beforeunload` con cuidado por compatibilidad del navegador
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 06 — Autosave opcional de borrador local
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Agregar una capa extra de protección sin imponer complejidad excesiva.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Guardar borrador local temporal de la nota en edición
|
||||||
|
- Recuperarlo al reabrir la pantalla
|
||||||
|
- Permitir descartarlo manualmente
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Si se cierra accidentalmente, el borrador puede recuperarse
|
||||||
|
- El borrador se limpia al guardar correctamente
|
||||||
|
- El usuario puede descartar borrador recuperado
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/drafts.ts`
|
||||||
|
- `src/components/note-form.tsx`
|
||||||
|
- `src/components/draft-recovery-banner.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPIC 2 — Operación diaria desde teclado
|
||||||
|
|
||||||
|
## [P1] Ticket 07 — Command Palette global (`Ctrl+K` / `Cmd+K`)
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Centralizar búsqueda, navegación y acciones en una interfaz rápida tipo command palette.
|
||||||
|
|
||||||
|
**Contexto**
|
||||||
|
Recall ya tiene búsqueda potente y navegación por teclado en el search bar. El siguiente paso es ofrecer una capa global de comandos que reduzca aún más la fricción.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Atajo global:
|
||||||
|
- `Ctrl+K` en Windows/Linux
|
||||||
|
- `Cmd+K` en macOS
|
||||||
|
- Modal o palette flotante global
|
||||||
|
- Soportar acciones iniciales:
|
||||||
|
- buscar notas
|
||||||
|
- abrir nota
|
||||||
|
- crear nueva nota
|
||||||
|
- quick add
|
||||||
|
- ir a dashboard
|
||||||
|
- ir a settings
|
||||||
|
- ir a notas favoritas
|
||||||
|
- ir a notas recientes
|
||||||
|
- Secciones:
|
||||||
|
- acciones
|
||||||
|
- resultados de búsqueda
|
||||||
|
- navegación
|
||||||
|
- Navegación total por teclado
|
||||||
|
|
||||||
|
**No incluye**
|
||||||
|
- edición avanzada dentro de la palette
|
||||||
|
- plugins de comandos externos
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- La palette abre/cierra con shortcut global
|
||||||
|
- Se puede usar sin mouse
|
||||||
|
- Enter ejecuta acción seleccionada
|
||||||
|
- ESC cierra
|
||||||
|
- Resultados y acciones están claramente separadas
|
||||||
|
- Funciona desde cualquier pantalla
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/components/command-palette.tsx`
|
||||||
|
- `src/hooks/use-command-palette.ts`
|
||||||
|
- `src/lib/command-palette.ts`
|
||||||
|
- `src/app/layout.tsx`
|
||||||
|
- `src/components/header.tsx`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- Reutilizar `search.ts` y la API existente cuando sea posible
|
||||||
|
- Mantener selección activa y scroll automático
|
||||||
|
- Considerar accesibilidad: focus trap, ARIA roles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 08 — Modelo de acciones y proveedores para Command Palette
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Desacoplar la palette de las acciones concretas para facilitar expansión futura.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear modelo uniforme de comando:
|
||||||
|
- id
|
||||||
|
- label
|
||||||
|
- description
|
||||||
|
- group
|
||||||
|
- keywords
|
||||||
|
- action handler
|
||||||
|
- icon opcional
|
||||||
|
- Crear proveedores:
|
||||||
|
- acciones estáticas
|
||||||
|
- notas recientes
|
||||||
|
- resultados de búsqueda
|
||||||
|
- Sistema de ranking simple para comandos
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Se pueden agregar nuevas acciones sin tocar el core visual
|
||||||
|
- La palette consume una lista homogénea de items
|
||||||
|
- El sistema soporta agrupación y orden
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/command-items.ts`
|
||||||
|
- `src/lib/command-groups.ts`
|
||||||
|
- `src/lib/command-ranking.ts`
|
||||||
|
- `src/components/command-palette.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 09 — Acciones rápidas por teclado fuera de la palette
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Expandir la operación de Recall sin depender de clicks.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Definir shortcuts globales seguros:
|
||||||
|
- `g h` → dashboard
|
||||||
|
- `g n` → notas
|
||||||
|
- `n` → nueva nota
|
||||||
|
- `/` → enfocar búsqueda
|
||||||
|
- `?` → ayuda de shortcuts
|
||||||
|
- Mostrar ayuda contextual de shortcuts
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Los shortcuts no interfieren con inputs activos
|
||||||
|
- Se pueden desactivar en campos de texto
|
||||||
|
- Existe una vista/modal de ayuda
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/hooks/use-global-shortcuts.ts`
|
||||||
|
- `src/components/keyboard-shortcuts-dialog.tsx`
|
||||||
|
- `src/app/layout.tsx`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- Ignorar shortcuts cuando hay foco en input, textarea o contenteditable
|
||||||
|
- Centralizar mapa de shortcuts en un único archivo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 10 — Navegación completa de listas por teclado
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Permitir abrir y operar notas desde listados sin usar mouse.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Flechas para moverse entre resultados/listas
|
||||||
|
- Enter para abrir
|
||||||
|
- Atajos para:
|
||||||
|
- editar
|
||||||
|
- favorite
|
||||||
|
- pin
|
||||||
|
- Soporte en:
|
||||||
|
- `/notes`
|
||||||
|
- dashboard
|
||||||
|
- dropdown de búsqueda
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Las listas principales se pueden recorrer por teclado
|
||||||
|
- El elemento seleccionado tiene estado visual claro
|
||||||
|
- Las acciones rápidas no rompen accesibilidad
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/components/note-list.tsx`
|
||||||
|
- `src/components/dashboard.tsx`
|
||||||
|
- `src/components/search-bar.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPIC 3 — Contexto activo y workspace continuo
|
||||||
|
|
||||||
|
## [P1] Ticket 11 — Sidebar contextual persistente mejorada
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Convertir la sidebar contextual en un asistente permanente del flujo de trabajo.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Mantener sidebar visible en detalle de nota y opcionalmente en edición
|
||||||
|
- Secciones posibles:
|
||||||
|
- relacionadas
|
||||||
|
- backlinks
|
||||||
|
- co-usadas
|
||||||
|
- recientes
|
||||||
|
- versiones recientes
|
||||||
|
- sugerencias contextuales
|
||||||
|
- Mejorar densidad y jerarquía visual
|
||||||
|
- Permitir colapsar/expandir secciones
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- La sidebar muestra contenido útil sin saturar
|
||||||
|
- Las secciones pueden plegarse
|
||||||
|
- En pantallas pequeñas se adapta sin romper el layout
|
||||||
|
- Se distinguen claramente los tipos de relación
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/components/note-context-sidebar.tsx`
|
||||||
|
- `src/components/note-connections.tsx`
|
||||||
|
- `src/app/notes/[id]/page.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 12 — Modo trabajo enfocado
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Ofrecer una experiencia de lectura/consulta prolongada con menos distracciones y más contexto útil.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear un “modo trabajo” activable por toggle
|
||||||
|
- Cambios de UI:
|
||||||
|
- ancho de lectura optimizado
|
||||||
|
- sidebar contextual persistente
|
||||||
|
- header reducido
|
||||||
|
- acciones rápidas siempre visibles
|
||||||
|
- Persistir preferencia local
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El usuario puede activar/desactivar el modo trabajo
|
||||||
|
- La preferencia se mantiene entre sesiones
|
||||||
|
- Mejora la experiencia en detalle de nota sin romper navegación general
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/components/work-mode-toggle.tsx`
|
||||||
|
- `src/lib/work-mode.ts`
|
||||||
|
- `src/app/notes/[id]/page.tsx`
|
||||||
|
- `src/app/globals.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 13 — Historial de navegación contextual
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Facilitar volver sobre el camino mental reciente.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Registrar secuencia reciente de notas abiertas
|
||||||
|
- Mostrar “visto recientemente en este contexto”
|
||||||
|
- Posibilidad de volver rápido a 5–10 notas recientes
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El usuario ve un historial local reciente
|
||||||
|
- Puede reabrir notas anteriores con un click o atajo
|
||||||
|
- El historial no duplica entradas consecutivas idénticas
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/navigation-history.ts`
|
||||||
|
- `src/components/recent-context-list.tsx`
|
||||||
|
- `src/components/command-palette.tsx`
|
||||||
|
- `src/components/note-context-sidebar.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPIC 4 — Captura ubicua fuera de Recall
|
||||||
|
|
||||||
|
## [P1] Ticket 14 — Bookmarklet para guardar página actual
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Permitir capturar contenido desde cualquier web hacia Recall con fricción mínima.
|
||||||
|
|
||||||
|
**Contexto**
|
||||||
|
Recall ya resuelve bien captura interna. El siguiente paso de uso diario es capturar desde el navegador sin tener que abrir manualmente la app y crear una nota.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Diseñar bookmarklet inicial que:
|
||||||
|
- tome `document.title`
|
||||||
|
- tome `location.href`
|
||||||
|
- opcionalmente tome selección de texto
|
||||||
|
- abra una URL de Recall con payload prellenado
|
||||||
|
- Crear pantalla o endpoint receptor para captura externa
|
||||||
|
- Mapear captura a tipo de nota por defecto (`note` o `snippet` según caso)
|
||||||
|
|
||||||
|
**No incluye**
|
||||||
|
- extensión completa de navegador
|
||||||
|
- scraping profundo del DOM
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El bookmarklet funciona en páginas comunes
|
||||||
|
- Si hay texto seleccionado, se incluye en la captura
|
||||||
|
- Si no hay selección, se guarda al menos título + URL
|
||||||
|
- Recall recibe y prellena una nota lista para confirmar o guardar
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/app/capture/page.tsx`
|
||||||
|
- `src/lib/external-capture.ts`
|
||||||
|
- `src/components/bookmarklet-instructions.tsx`
|
||||||
|
- `src/app/settings/page.tsx`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- Codificar payload en query string de forma segura
|
||||||
|
- Considerar límites de longitud: si es largo, usar mecanismo de POST o fallback
|
||||||
|
- Sanitizar el contenido recibido
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 15 — Flujo de confirmación para captura externa
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Evitar guardar basura y dar control antes de persistir.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Pantalla de revisión para captura externa:
|
||||||
|
- título
|
||||||
|
- url
|
||||||
|
- contenido/selección
|
||||||
|
- tags sugeridos
|
||||||
|
- tipo sugerido
|
||||||
|
- Botones:
|
||||||
|
- guardar
|
||||||
|
- editar
|
||||||
|
- cancelar
|
||||||
|
- Posibilidad de convertir la URL en markdown limpio
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- La captura externa llega prellenada
|
||||||
|
- El usuario puede corregir antes de guardar
|
||||||
|
- El flujo es rápido y no requiere pasos innecesarios
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/app/capture/page.tsx`
|
||||||
|
- `src/components/external-capture-form.tsx`
|
||||||
|
- `src/lib/type-inference.ts`
|
||||||
|
- `src/lib/tags.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 16 — Endpoint seguro para captura externa por POST
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Preparar Recall para integraciones futuras más robustas que el bookmarklet simple.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear endpoint dedicado para captura externa
|
||||||
|
- Aceptar payload estructurado:
|
||||||
|
- title
|
||||||
|
- url
|
||||||
|
- selection
|
||||||
|
- source
|
||||||
|
- inferredType
|
||||||
|
- Validar con Zod
|
||||||
|
- Responder con payload listo para preview o guardado
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El endpoint valida correctamente el payload
|
||||||
|
- No guarda automáticamente sin intención explícita
|
||||||
|
- Puede ser reutilizado por extensión futura o integraciones
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/app/api/capture/route.ts`
|
||||||
|
- `src/lib/external-capture.ts`
|
||||||
|
- `src/lib/validators.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPIC 5 — Importación y exportación de nivel producto
|
||||||
|
|
||||||
|
## [P1] Ticket 17 — Exportación mejorada a Markdown
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Asegurar portabilidad real del conocimiento en un formato simple y durable.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Exportar todas las notas a estructura Markdown
|
||||||
|
- Incluir:
|
||||||
|
- frontmatter opcional
|
||||||
|
- título
|
||||||
|
- contenido
|
||||||
|
- tags
|
||||||
|
- tipo
|
||||||
|
- fechas
|
||||||
|
- Generar nombres de archivo estables y seguros
|
||||||
|
- Opción de exportar zip de múltiples `.md`
|
||||||
|
|
||||||
|
**No incluye**
|
||||||
|
- sync con repos remotos
|
||||||
|
- assets binarios complejos
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El usuario puede exportar todas las notas a `.md`
|
||||||
|
- Cada nota queda representada de forma legible
|
||||||
|
- Los archivos son reimportables con reglas definidas
|
||||||
|
- Tags y tipo no se pierden
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/export-markdown.ts`
|
||||||
|
- `src/app/api/export-import/route.ts`
|
||||||
|
- `src/app/settings/page.tsx`
|
||||||
|
|
||||||
|
**Notas técnicas**
|
||||||
|
- Resolver colisiones de nombres
|
||||||
|
- Normalizar saltos de línea
|
||||||
|
- Documentar formato de frontmatter si se usa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 18 — Exportación HTML simple y legible
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Facilitar compartir o archivar notas en un formato visualmente cómodo.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear export HTML por nota o lote
|
||||||
|
- Incluir render de markdown
|
||||||
|
- Estilo básico embebido o plantilla simple
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- La exportación HTML es legible offline
|
||||||
|
- Respeta headings, listas, código y enlaces
|
||||||
|
- Puede abrirse directamente en navegador
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/export-html.ts`
|
||||||
|
- `src/app/api/export-import/route.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 19 — Importador de Markdown mejorado
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Hacer Recall más interoperable con flujos existentes.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Mejorar importador `.md` actual para soportar:
|
||||||
|
- frontmatter
|
||||||
|
- tags
|
||||||
|
- tipo
|
||||||
|
- títulos ausentes o derivados
|
||||||
|
- sintaxis `[[wiki]]`
|
||||||
|
- Permitir importar múltiples archivos si la UX lo permite
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Markdown con frontmatter se importa correctamente
|
||||||
|
- Se preservan tags y tipo cuando existen
|
||||||
|
- El contenido sigue siendo fiel al original
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/import-markdown.ts`
|
||||||
|
- `src/app/api/export-import/route.ts`
|
||||||
|
- `src/components/import-dialog.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 20 — Importador base de Obsidian-compatible Markdown
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Reducir fricción de entrada para usuarios con conocimiento ya almacenado fuera de Recall.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Aceptar archivos/estructura compatibles con vault simple:
|
||||||
|
- markdown
|
||||||
|
- `[[wiki links]]`
|
||||||
|
- tags inline `#tag`
|
||||||
|
- Resolver títulos desde filename cuando haga falta
|
||||||
|
- Crear estrategia básica de deduplicación
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Un conjunto simple de notas estilo Obsidian se importa sin perder estructura esencial
|
||||||
|
- Los wiki links se preservan o transforman correctamente
|
||||||
|
- La deduplicación evita duplicados obvios
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/import-obsidian.ts`
|
||||||
|
- `src/app/api/export-import/route.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPIC 6 — Operación y configuración visible
|
||||||
|
|
||||||
|
## [P2] Ticket 21 — Centro de respaldo y portabilidad en Settings
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Reunir en una sola UI todas las capacidades de backup, restore, import y export.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear sección clara en Settings:
|
||||||
|
- backups automáticos
|
||||||
|
- backups disponibles
|
||||||
|
- restore
|
||||||
|
- export JSON
|
||||||
|
- export Markdown
|
||||||
|
- export HTML
|
||||||
|
- import Markdown/JSON
|
||||||
|
- Mostrar último backup realizado
|
||||||
|
- Mostrar tamaño aproximado y fecha
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Settings concentra todas las acciones de seguridad y portabilidad
|
||||||
|
- El usuario entiende claramente qué hace cada opción
|
||||||
|
- El flujo no requiere conocer detalles técnicos internos
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/app/settings/page.tsx`
|
||||||
|
- `src/components/backup-center.tsx`
|
||||||
|
- `src/components/export-options.tsx`
|
||||||
|
- `src/components/import-options.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 22 — Configuración visible de feature flags y preferencias clave
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Dar control operativo sobre comportamientos avanzados ya implementados.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Exponer desde Settings:
|
||||||
|
- feature flags activas
|
||||||
|
- modo trabajo
|
||||||
|
- backup automático on/off
|
||||||
|
- retención de backups
|
||||||
|
- shortcuts visibles
|
||||||
|
- Persistencia local o en configuración simple
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El usuario puede ver y cambiar flags/preferencias principales
|
||||||
|
- Los cambios se reflejan sin romper la app
|
||||||
|
- Existe estado inicial razonable por defecto
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/app/settings/page.tsx`
|
||||||
|
- `src/lib/features.ts`
|
||||||
|
- `src/lib/preferences.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# EPIC 7 — Calidad, seguridad operativa y pruebas
|
||||||
|
|
||||||
|
## [P1] Ticket 23 — Tests unitarios para backup/restore
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Proteger la capa de confianza antes de expandir más el producto.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Tests para:
|
||||||
|
- snapshot generation
|
||||||
|
- validación de backup
|
||||||
|
- retención
|
||||||
|
- restore merge
|
||||||
|
- restore replace
|
||||||
|
- backup pre-destructive
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Casos felices y bordes cubiertos
|
||||||
|
- Fixtures de backup versionados
|
||||||
|
- Restore inválido falla de forma segura
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `__tests__/backup.test.ts`
|
||||||
|
- `__tests__/restore.test.ts`
|
||||||
|
- `__tests__/backup-validator.test.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 24 — Tests de integración para command palette y captura externa
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Validar los nuevos flujos de uso diario y expansión.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Probar:
|
||||||
|
- apertura/cierre de palette
|
||||||
|
- navegación por teclado
|
||||||
|
- ejecución de comandos
|
||||||
|
- recepción de captura externa
|
||||||
|
- flujo de confirmación de captura
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- Los flujos críticos están cubiertos
|
||||||
|
- Los shortcuts no interfieren con formularios
|
||||||
|
- La captura externa llega correctamente prellenada
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `__tests__/command-palette.test.tsx`
|
||||||
|
- `__tests__/capture-flow.test.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 25 — Harden de validaciones y límites operativos
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Aumentar robustez de las nuevas entradas/salidas del sistema.
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Definir límites razonables para:
|
||||||
|
- tamaño de backup
|
||||||
|
- tamaño de payload de captura externa
|
||||||
|
- cantidad de backups retenidos
|
||||||
|
- Validación estricta de formatos
|
||||||
|
- Mensajes de error claros y recuperables
|
||||||
|
|
||||||
|
**Criterios de aceptación**
|
||||||
|
- El sistema rechaza entradas excesivas o inválidas de forma clara
|
||||||
|
- No se degrada la app por payloads grandes o malformados
|
||||||
|
- Los errores se muestran de forma consistente
|
||||||
|
|
||||||
|
**Archivos sugeridos**
|
||||||
|
- `src/lib/backup-validator.ts`
|
||||||
|
- `src/lib/external-capture.ts`
|
||||||
|
- `src/lib/errors.ts`
|
||||||
|
- `src/lib/validators.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Orden recomendado de implementación
|
||||||
|
|
||||||
|
## Sprint 1 — Confianza primero
|
||||||
|
- Ticket 01 — Estrategia de backup automático local
|
||||||
|
- Ticket 02 — Motor de snapshot global exportable
|
||||||
|
- Ticket 03 — Restore con preview y validación
|
||||||
|
- Ticket 04 — Backup previo automático
|
||||||
|
- Ticket 23 — Tests unitarios backup/restore
|
||||||
|
|
||||||
|
## Sprint 2 — Flujo diario brutal
|
||||||
|
- Ticket 07 — Command Palette global
|
||||||
|
- Ticket 08 — Modelo de acciones para palette
|
||||||
|
- Ticket 09 — Shortcuts globales
|
||||||
|
- Ticket 10 — Navegación de listas por teclado
|
||||||
|
- Ticket 24 — Tests integración palette
|
||||||
|
|
||||||
|
## Sprint 3 — Contexto y continuidad
|
||||||
|
- Ticket 11 — Sidebar contextual persistente mejorada
|
||||||
|
- Ticket 12 — Modo trabajo enfocado
|
||||||
|
- Ticket 13 — Historial de navegación contextual
|
||||||
|
- Ticket 05 — Guard de cambios no guardados
|
||||||
|
- Ticket 06 — Autosave opcional de borrador local
|
||||||
|
|
||||||
|
## Sprint 4 — Captura externa
|
||||||
|
- Ticket 14 — Bookmarklet para guardar página actual
|
||||||
|
- Ticket 15 — Flujo de confirmación para captura externa
|
||||||
|
- Ticket 16 — Endpoint seguro para captura externa
|
||||||
|
|
||||||
|
## Sprint 5 — Portabilidad real
|
||||||
|
- Ticket 17 — Exportación mejorada a Markdown
|
||||||
|
- Ticket 18 — Exportación HTML
|
||||||
|
- Ticket 19 — Importador Markdown mejorado
|
||||||
|
- Ticket 20 — Importador base Obsidian-compatible
|
||||||
|
- Ticket 21 — Centro de respaldo y portabilidad
|
||||||
|
- Ticket 22 — Configuración visible de flags/preferencias
|
||||||
|
- Ticket 25 — Harden de validaciones y límites
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dependencias y decisiones de arquitectura recomendadas
|
||||||
|
|
||||||
|
## Decisión 1 — Backup format
|
||||||
|
Definir explícitamente un formato versionado:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type RecallBackup = {
|
||||||
|
schemaVersion: "1.0";
|
||||||
|
createdAt: string;
|
||||||
|
source: "automatic" | "manual" | "pre-destructive";
|
||||||
|
appVersion?: string;
|
||||||
|
metadata: {
|
||||||
|
noteCount: number;
|
||||||
|
tagCount: number;
|
||||||
|
versionCount?: number;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
notes: unknown[];
|
||||||
|
tags: unknown[];
|
||||||
|
noteVersions?: unknown[];
|
||||||
|
backlinks?: unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisión 2 — Restore modes
|
||||||
|
Mantener solo dos modos al inicio:
|
||||||
|
- `merge`: agrega/actualiza sin borrar todo
|
||||||
|
- `replace`: reemplaza completamente el dataset
|
||||||
|
|
||||||
|
No agregar modos intermedios hasta tener uso real.
|
||||||
|
|
||||||
|
## Decisión 3 — Command palette scope inicial
|
||||||
|
La primera versión debe centrarse en:
|
||||||
|
- navegación
|
||||||
|
- búsqueda
|
||||||
|
- creación
|
||||||
|
- acceso a pantallas
|
||||||
|
No convertirla aún en un motor de automatizaciones complejas.
|
||||||
|
|
||||||
|
## Decisión 4 — Bookmarklet MVP
|
||||||
|
El bookmarklet debe ser lo más simple posible:
|
||||||
|
- capturar `title`
|
||||||
|
- capturar `url`
|
||||||
|
- capturar selección si existe
|
||||||
|
- abrir Recall con preview prellenada
|
||||||
|
|
||||||
|
No hacer scraping complejo en esta fase.
|
||||||
|
|
||||||
|
## Decisión 5 — Export portability
|
||||||
|
Markdown debe convertirse en el formato de salida principal legible por humanos.
|
||||||
|
JSON debe seguir siendo el formato fiel para restore exacto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plantilla sugerida para Claude Code
|
||||||
|
|
||||||
|
## Título
|
||||||
|
`[P1] Implementar restore desde backup con preview y validación`
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Recall ya ofrece export/import manual e historial de versiones. Para convertirlo en una herramienta confiable de uso diario, se necesita restore seguro desde backups automáticos y manuales.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Permitir restaurar backups con validación previa, preview del contenido y confirmación explícita, soportando modos `merge` y `replace`.
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
- validador de backup
|
||||||
|
- preview de metadatos
|
||||||
|
- flujo de confirmación
|
||||||
|
- ejecución segura del restore
|
||||||
|
- integración con settings
|
||||||
|
|
||||||
|
## No incluye
|
||||||
|
- resolución avanzada de conflictos
|
||||||
|
- restore parcial por tipo de entidad
|
||||||
|
- sync remoto
|
||||||
|
|
||||||
|
## Criterios de aceptación
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Archivos a tocar
|
||||||
|
- ...
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Notas técnicas
|
||||||
|
- usar transacciones en replace
|
||||||
|
- crear backup previo automático
|
||||||
|
- mostrar errores consistentes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
- Funcionalidad implementada y usable
|
||||||
|
- Tests unitarios e integración relevantes pasando
|
||||||
|
- Sin regresiones en CRUD, búsqueda, versiones y captura
|
||||||
|
- Estados vacíos, borde y error cubiertos
|
||||||
|
- UI clara para acciones sensibles
|
||||||
|
- Portabilidad comprobable con export/import real
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=file:/app/data/dev.db
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
echo "Checking database..."
|
||||||
|
|
||||||
|
# Ensure data directory exists with proper permissions
|
||||||
|
mkdir -p /app/data
|
||||||
|
chmod 777 /app/data
|
||||||
|
|
||||||
|
# Run db push (creates/updates database schema)
|
||||||
|
# If it fails due to OOM but DB exists, continue anyway
|
||||||
|
./node_modules/prisma/build/index.js db push --skip-generate || {
|
||||||
|
exit_code=$?
|
||||||
|
if [ -f /app/data/dev.db ]; then
|
||||||
|
echo "db push failed (code $exit_code) but database exists, continuing..."
|
||||||
|
else
|
||||||
|
echo "db push failed and database does not exist"
|
||||||
|
exit $exit_code
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
exec "$@"
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: 'standalone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Binary file not shown.
@@ -16,10 +16,13 @@ model Note {
|
|||||||
isPinned Boolean @default(false)
|
isPinned Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
creationSource String @default("form") // 'form' | 'quick' | 'import'
|
||||||
tags NoteTag[]
|
tags NoteTag[]
|
||||||
backlinks Backlink[] @relation("BacklinkTarget")
|
backlinks Backlink[] @relation("BacklinkTarget")
|
||||||
outbound Backlink[] @relation("BacklinkSource")
|
outbound Backlink[] @relation("BacklinkSource")
|
||||||
usageEvents NoteUsage[]
|
usageEvents NoteUsage[]
|
||||||
|
coUsageFrom NoteCoUsage[] @relation("CoUsageFrom")
|
||||||
|
coUsageTo NoteCoUsage[] @relation("CoUsageTo")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
@@ -60,3 +63,28 @@ model NoteUsage {
|
|||||||
@@index([noteId, createdAt])
|
@@index([noteId, createdAt])
|
||||||
@@index([eventType, createdAt])
|
@@index([eventType, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NoteCoUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromNoteId String
|
||||||
|
fromNote Note @relation("CoUsageFrom", fields: [fromNoteId], references: [id], onDelete: Cascade)
|
||||||
|
toNoteId String
|
||||||
|
toNote Note @relation("CoUsageTo", fields: [toNoteId], references: [id], onDelete: Cascade)
|
||||||
|
weight Int @default(1) // times viewed together
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([fromNoteId, toNoteId])
|
||||||
|
@@index([fromNoteId])
|
||||||
|
@@index([toNoteId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NoteVersion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([noteId, createdAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Recall - Resumen del Proyecto
|
||||||
|
|
||||||
|
## Fecha
|
||||||
|
2026-03-22
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
- **Framework**: Next.js 16.2.1 con App Router
|
||||||
|
- **Base de datos**: SQLite via Prisma ORM
|
||||||
|
- **Lenguaje**: TypeScript
|
||||||
|
- **UI**: TailwindCSS + shadcn/ui components
|
||||||
|
- **Testing**: Jest
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── api/ # API routes
|
||||||
|
│ │ ├── notes/ # CRUD de notas, versions, quick
|
||||||
|
│ │ ├── tags/ # Tags y sugerencias
|
||||||
|
│ │ ├── search/ # Búsqueda avanzada
|
||||||
|
│ │ ├── usage/ # Tracking de uso
|
||||||
|
│ │ ├── metrics/ # Métricas internas
|
||||||
|
│ │ ├── centrality/ # Notas centrales
|
||||||
|
│ │ └── export-import/ # Import/export JSON
|
||||||
|
│ ├── notes/[id]/ # Detalle de nota
|
||||||
|
│ ├── edit/[id]/ # Edición de nota
|
||||||
|
│ └── new/ # Nueva nota
|
||||||
|
├── components/ # Componentes React
|
||||||
|
│ ├── ui/ # shadcn/ui components
|
||||||
|
│ ├── dashboard.tsx # Dashboard inteligente
|
||||||
|
│ ├── quick-add.tsx # Captura rápida
|
||||||
|
│ ├── note-form.tsx # Formulario de nota
|
||||||
|
│ ├── note-connections.tsx # Panel de conexiones
|
||||||
|
│ ├── related-notes.tsx # Notas relacionadas
|
||||||
|
│ ├── version-history.tsx # Historial de versiones
|
||||||
|
│ └── track-note-view.tsx # Tracking de vistas
|
||||||
|
└── lib/ # Utilidades
|
||||||
|
├── prisma.ts # Cliente Prisma
|
||||||
|
├── usage.ts # Tracking de uso y co-uso
|
||||||
|
├── search.ts # Búsqueda con scoring
|
||||||
|
├── query-parser.ts # Parser de queries avanzadas
|
||||||
|
├── versions.ts # Historial de versiones
|
||||||
|
├── related.ts # Notas relacionadas
|
||||||
|
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||||
|
├── tags.ts # Normalización y sugerencias
|
||||||
|
├── metrics.ts # Métricas de dashboard
|
||||||
|
├── centrality.ts # Cálculo de centralidad
|
||||||
|
├── type-inference.ts # Detección automática de tipo
|
||||||
|
├── link-suggestions.ts # Sugerencias de enlaces
|
||||||
|
├── features.ts # Feature flags
|
||||||
|
└── validators.ts # Zod schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modelos de Datos
|
||||||
|
|
||||||
|
### Note
|
||||||
|
```prisma
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
type String @default("note") // command, snippet, decision, recipe, procedure, inventory, note
|
||||||
|
isFavorite Boolean @default(false)
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
creationSource String @default("form") // form, quick, import
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteUsage
|
||||||
|
```prisma
|
||||||
|
model NoteUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
eventType String // view, search_click, related_click, link_click, copy_command, copy_snippet
|
||||||
|
query String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteCoUsage
|
||||||
|
```prisma
|
||||||
|
model NoteCoUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromNoteId String
|
||||||
|
toNoteId String
|
||||||
|
weight Int @default(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backlink
|
||||||
|
```prisma
|
||||||
|
model Backlink {
|
||||||
|
sourceNoteId String
|
||||||
|
targetNoteId String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteVersion
|
||||||
|
```prisma
|
||||||
|
model NoteVersion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## APIs Principales
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||||
|
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
|
||||||
|
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
|
||||||
|
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
|
||||||
|
| `/api/notes/quick` | POST | Creación rápida |
|
||||||
|
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||||
|
| `/api/search` | GET | Búsqueda con scoring |
|
||||||
|
| `/api/tags` | GET | Listar/buscar tags |
|
||||||
|
| `/api/tags/suggest` | GET | Sugerencias automáticas |
|
||||||
|
| `/api/usage` | GET | Estadísticas de uso |
|
||||||
|
| `/api/usage/co-usage` | GET | Notas co-usadas |
|
||||||
|
| `/api/metrics` | GET | Métricas de dashboard |
|
||||||
|
| `/api/centrality` | GET | Notas más centrales |
|
||||||
|
|
||||||
|
## Features Implementadas
|
||||||
|
|
||||||
|
### MVP-1 (Completado)
|
||||||
|
- CRUD completo de notas
|
||||||
|
- Sistema de tags
|
||||||
|
- Búsqueda básica
|
||||||
|
|
||||||
|
### MVP-2 (Completado)
|
||||||
|
- Búsqueda avanzada con scoring
|
||||||
|
- Quick Add con prefijos (cmd:, snip:, etc.)
|
||||||
|
- Backlinks con sintaxis [[wiki]]
|
||||||
|
- Formularios guiados por tipo de nota
|
||||||
|
|
||||||
|
### MVP-3 Sprint 1
|
||||||
|
- Usage tracking (vistas, clics, copias)
|
||||||
|
- Dashboard inteligente (Recientes, Más usadas, Por tipo)
|
||||||
|
- Scoring boost basado en uso
|
||||||
|
|
||||||
|
### MVP-3 Sprint 2
|
||||||
|
- Sugerencias automáticas de tags
|
||||||
|
- Panel "Conectado con" (backlinks, enlaces, relacionadas)
|
||||||
|
|
||||||
|
### MVP-3 Sprint 3
|
||||||
|
- Quick Add multilínea
|
||||||
|
- Pegado inteligente con detección de tipo
|
||||||
|
- Sugerencia automática de tipo de nota
|
||||||
|
- Sugerencia de enlaces internos
|
||||||
|
|
||||||
|
### MVP-3 Sprint 4
|
||||||
|
- Registro de co-uso entre notas
|
||||||
|
- Métricas internas (notas por tipo, más vistas, por origen)
|
||||||
|
- Cálculo de notas centrales (centrality score)
|
||||||
|
- Registro de origen de creación (form/quick/import)
|
||||||
|
- Feature flags configurables
|
||||||
|
|
||||||
|
### Mejoras UI Recientes
|
||||||
|
- Header responsive con menú hamburguesa en móvil
|
||||||
|
- Desktop: una fila con logo, nav links, QuickAdd y botón Nueva nota
|
||||||
|
- Mobile: logo + QuickAdd + hamburguesa → dropdown con nav links y botón Nueva nota
|
||||||
|
|
||||||
|
### MVP-4 Sprint 1
|
||||||
|
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||||
|
- Búsqueda en tiempo real con 300ms debounce
|
||||||
|
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
|
||||||
|
- Dropdown de resultados con cache de 50 entradas
|
||||||
|
|
||||||
|
### MVP-4 Sprint 2
|
||||||
|
- Sidebar contextual con co-uso (notas vistas juntas)
|
||||||
|
- Cache de resultados de búsqueda
|
||||||
|
|
||||||
|
### MVP-4 Sprint 3
|
||||||
|
- Historial de versiones de notas
|
||||||
|
- API de versiones (crear, listar, restaurar)
|
||||||
|
- UI de historial en diálogo de nota
|
||||||
|
|
||||||
|
### MVP-4 Sprint 4
|
||||||
|
- Tests de historial de versiones (11 tests)
|
||||||
|
|
||||||
|
## Algoritmo de Scoring
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Search scoring
|
||||||
|
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||||
|
|
||||||
|
// Related notes scoring
|
||||||
|
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||||
|
|
||||||
|
// Centrality score
|
||||||
|
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**211 tests** cubriendo:
|
||||||
|
- API routes (CRUD, search, tags, etc.)
|
||||||
|
- Search y scoring
|
||||||
|
- Query parser
|
||||||
|
- Notas relacionadas
|
||||||
|
- Backlinks
|
||||||
|
- Type inference
|
||||||
|
- Link suggestions
|
||||||
|
- Usage tracking
|
||||||
|
- Dashboard
|
||||||
|
- Version history
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Desarrollo
|
||||||
|
npm run build # Build producción
|
||||||
|
npm run test # Tests
|
||||||
|
npx prisma db push # Sync schema
|
||||||
|
npx prisma studio # UI de BD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración de Feature Flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLAG_CENTRALITY=true
|
||||||
|
FLAG_PASSIVE_RECOMMENDATIONS=true
|
||||||
|
FLAG_TYPE_SUGGESTIONS=true
|
||||||
|
FLAG_LINK_SUGGESTIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
- [ ] Panel de métricas visible en UI
|
||||||
|
- [ ] Configuración de feature flags en Settings
|
||||||
|
- [ ] Visualización del grafo de conocimiento
|
||||||
|
- [ ] Exportación mejorada (Markdown, HTML)
|
||||||
|
- [ ] Tests de integración E2E
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Recall - Resumen del Proyecto
|
||||||
|
|
||||||
|
## Fecha
|
||||||
|
2026-03-22
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
- **Framework**: Next.js 16.2.1 con App Router
|
||||||
|
- **Base de datos**: SQLite via Prisma ORM
|
||||||
|
- **Lenguaje**: TypeScript
|
||||||
|
- **UI**: TailwindCSS + shadcn/ui components
|
||||||
|
- **Testing**: Jest
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── api/ # API routes
|
||||||
|
│ │ ├── notes/ # CRUD, versions, quick
|
||||||
|
│ │ ├── tags/ # Tags y sugerencias
|
||||||
|
│ │ ├── search/ # Búsqueda avanzada
|
||||||
|
│ │ ├── usage/ # Tracking de uso y co-uso
|
||||||
|
│ │ ├── metrics/ # Métricas internas
|
||||||
|
│ │ ├── centrality/ # Notas centrales
|
||||||
|
│ │ └── export-import/ # Import/export JSON
|
||||||
|
│ ├── notes/[id]/ # Detalle de nota
|
||||||
|
│ ├── edit/[id]/ # Edición de nota
|
||||||
|
│ └── new/ # Nueva nota
|
||||||
|
├── components/ # Componentes React
|
||||||
|
│ ├── ui/ # shadcn/ui components
|
||||||
|
│ ├── dashboard.tsx # Dashboard inteligente
|
||||||
|
│ ├── quick-add.tsx # Captura rápida
|
||||||
|
│ ├── note-form.tsx # Formulario de nota
|
||||||
|
│ ├── note-connections.tsx # Panel de conexiones
|
||||||
|
│ ├── related-notes.tsx # Notas relacionadas
|
||||||
|
│ ├── version-history.tsx # Historial de versiones
|
||||||
|
│ ├── track-note-view.tsx # Tracking de vistas
|
||||||
|
│ └── search-bar.tsx # Búsqueda en tiempo real
|
||||||
|
└── lib/ # Utilidades
|
||||||
|
├── prisma.ts # Cliente Prisma
|
||||||
|
├── usage.ts # Tracking de uso y co-uso
|
||||||
|
├── search.ts # Búsqueda con scoring
|
||||||
|
├── query-parser.ts # Parser de queries avanzadas
|
||||||
|
├── versions.ts # Historial de versiones
|
||||||
|
├── related.ts # Notas relacionadas
|
||||||
|
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||||
|
├── tags.ts # Normalización y sugerencias
|
||||||
|
├── metrics.ts # Métricas de dashboard
|
||||||
|
├── centrality.ts # Cálculo de centralidad
|
||||||
|
├── type-inference.ts # Detección automática de tipo
|
||||||
|
├── link-suggestions.ts # Sugerencias de enlaces
|
||||||
|
├── features.ts # Feature flags
|
||||||
|
└── validators.ts # Zod schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modelos de Datos
|
||||||
|
|
||||||
|
### Note
|
||||||
|
```prisma
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
type String @default("note")
|
||||||
|
isFavorite Boolean @default(false)
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
creationSource String @default("form")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteUsage
|
||||||
|
```prisma
|
||||||
|
model NoteUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
eventType String
|
||||||
|
query String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteCoUsage
|
||||||
|
```prisma
|
||||||
|
model NoteCoUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromNoteId String
|
||||||
|
toNoteId String
|
||||||
|
weight Int @default(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteVersion
|
||||||
|
```prisma
|
||||||
|
model NoteVersion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backlink
|
||||||
|
```prisma
|
||||||
|
model Backlink {
|
||||||
|
sourceNoteId String
|
||||||
|
targetNoteId String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## APIs Principales
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||||
|
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
|
||||||
|
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
|
||||||
|
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
|
||||||
|
| `/api/notes/quick` | POST | Creación rápida |
|
||||||
|
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||||
|
| `/api/search` | GET | Búsqueda con scoring |
|
||||||
|
| `/api/tags` | GET | Listar/buscar tags |
|
||||||
|
| `/api/tags/suggest` | GET | Sugerencias automáticas |
|
||||||
|
| `/api/usage` | GET | Estadísticas de uso |
|
||||||
|
| `/api/usage/co-usage` | GET | Notas co-usadas |
|
||||||
|
| `/api/metrics` | GET | Métricas de dashboard |
|
||||||
|
| `/api/centrality` | GET | Notas más centrales |
|
||||||
|
|
||||||
|
## Features Implementadas
|
||||||
|
|
||||||
|
### MVP-1
|
||||||
|
- CRUD completo de notas
|
||||||
|
- Sistema de tags
|
||||||
|
- Búsqueda básica
|
||||||
|
|
||||||
|
### MVP-2
|
||||||
|
- Búsqueda avanzada con scoring
|
||||||
|
- Quick Add con prefijos (cmd:, snip:, etc.)
|
||||||
|
- Backlinks con sintaxis [[wiki]]
|
||||||
|
- Formularios guiados por tipo de nota
|
||||||
|
|
||||||
|
### MVP-3
|
||||||
|
- Usage tracking (vistas, clics, copias)
|
||||||
|
- Dashboard inteligente
|
||||||
|
- Scoring boost basado en uso
|
||||||
|
- Sugerencias automáticas de tags
|
||||||
|
- Panel "Conectado con"
|
||||||
|
- Quick Add multilínea
|
||||||
|
- Pegado inteligente con detección de tipo
|
||||||
|
- Sugerencia automática de tipo de nota
|
||||||
|
- Sugerencia de enlaces internos
|
||||||
|
- Registro de co-uso entre notas
|
||||||
|
- Métricas internas
|
||||||
|
- Cálculo de notas centrales
|
||||||
|
- Feature flags configurables
|
||||||
|
|
||||||
|
### MVP-4
|
||||||
|
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||||
|
- Búsqueda en tiempo real con 300ms debounce
|
||||||
|
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
|
||||||
|
- Dropdown de resultados con cache
|
||||||
|
- Sidebar contextual con co-uso
|
||||||
|
- Historial de versiones de notas
|
||||||
|
- Tests de historial de versiones
|
||||||
|
|
||||||
|
## Algoritmo de Scoring
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Search scoring
|
||||||
|
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||||
|
|
||||||
|
// Related notes scoring
|
||||||
|
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||||
|
|
||||||
|
// Centrality score
|
||||||
|
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**211 tests** cubriendo:
|
||||||
|
- API routes (CRUD, search, tags, versions)
|
||||||
|
- Search y scoring
|
||||||
|
- Query parser
|
||||||
|
- Notas relacionadas
|
||||||
|
- Backlinks
|
||||||
|
- Type inference
|
||||||
|
- Link suggestions
|
||||||
|
- Usage tracking
|
||||||
|
- Dashboard
|
||||||
|
- Version history
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Desarrollo
|
||||||
|
npm run build # Build producción
|
||||||
|
npm test # Tests (usar npx jest)
|
||||||
|
npx prisma db push # Sync schema
|
||||||
|
npx prisma studio # UI de BD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración de Feature Flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLAG_CENTRALITY=true
|
||||||
|
FLAG_PASSIVE_RECOMMENDATIONS=true
|
||||||
|
FLAG_TYPE_SUGGESTIONS=true
|
||||||
|
FLAG_LINK_SUGGESTIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
- [ ] Panel de métricas visible en UI
|
||||||
|
- [ ] Configuración de feature flags en Settings
|
||||||
|
- [ ] Visualización del grafo de conocimiento
|
||||||
|
- [ ] Exportación mejorada (Markdown, HTML)
|
||||||
|
- [ ] Tests E2E
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Recall - Resumen del Proyecto
|
||||||
|
|
||||||
|
## Fecha
|
||||||
|
2026-03-22
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
- **Framework**: Next.js 16.2.1 con App Router + Turbopack
|
||||||
|
- **Base de datos**: SQLite via Prisma ORM
|
||||||
|
- **Lenguaje**: TypeScript
|
||||||
|
- **UI**: TailwindCSS + shadcn/ui components
|
||||||
|
- **Testing**: Jest (226 tests)
|
||||||
|
- **Notificaciones**: Sonner (toasts)
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── notes/ # CRUD, versions, quick, backlinks, links, suggest
|
||||||
|
│ │ ├── tags/ # Tags y sugerencias
|
||||||
|
│ │ ├── search/ # Búsqueda avanzada
|
||||||
|
│ │ ├── usage/ # Tracking de uso y co-uso
|
||||||
|
│ │ ├── metrics/ # Métricas internas
|
||||||
|
│ │ ├── centrality/ # Notas centrales
|
||||||
|
│ │ ├── export-import/ # Import/export JSON, Markdown, HTML
|
||||||
|
│ │ ├── import-markdown/ # Importador Markdown mejorado
|
||||||
|
│ │ └── capture/ # Captura externa (bookmarklet)
|
||||||
|
│ ├── notes/[id]/ # Detalle de nota
|
||||||
|
│ ├── edit/[id]/ # Edición de nota
|
||||||
|
│ ├── new/ # Nueva nota
|
||||||
|
│ ├── capture/ # Página de confirmación de captura
|
||||||
|
│ └── settings/ # Configuración
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui components
|
||||||
|
│ ├── dashboard.tsx # Dashboard inteligente
|
||||||
|
│ ├── quick-add.tsx # Captura rápida
|
||||||
|
│ ├── note-form.tsx # Formulario de nota
|
||||||
|
│ ├── note-connections.tsx # Panel de conexiones
|
||||||
|
│ ├── note-list.tsx # Lista de notas
|
||||||
|
│ ├── keyboard-navigable-note-list.tsx # Lista con navegación teclado
|
||||||
|
│ ├── keyboard-hint.tsx # Hint de atajos
|
||||||
|
│ ├── related-notes.tsx # Notas relacionadas
|
||||||
|
│ ├── version-history.tsx # Historial de versiones
|
||||||
|
│ ├── track-note-view.tsx # Tracking de vistas
|
||||||
|
│ ├── search-bar.tsx # Búsqueda en tiempo real
|
||||||
|
│ ├── command-palette.tsx # Command palette (Ctrl+K)
|
||||||
|
│ ├── keyboard-shortcuts-dialog.tsx # Diálogo de atajos
|
||||||
|
│ ├── shortcuts-provider.tsx # Provider de shortcuts
|
||||||
|
│ ├── work-mode-toggle.tsx # Toggle modo trabajo
|
||||||
|
│ ├── draft-recovery-banner.tsx # Banner de recuperación
|
||||||
|
│ ├── backup-restore-dialog.tsx # Restore con preview
|
||||||
|
│ ├── backup-list.tsx # Lista de backups
|
||||||
|
│ ├── bookmarklet-instructions.tsx # Instrucciones del bookmarklet
|
||||||
|
│ ├── recent-context-list.tsx # Historial de navegación
|
||||||
|
│ ├── track-navigation-history.tsx # Tracking de historial
|
||||||
|
│ └── preferences-panel.tsx # Panel de preferencias
|
||||||
|
├── hooks/
|
||||||
|
│ ├── use-global-shortcuts.ts # Atajos globales
|
||||||
|
│ ├── use-note-list-keyboard.ts # Navegación teclado en listas
|
||||||
|
│ ├── use-unsaved-changes.ts # Guard de cambios sin guardar
|
||||||
|
│ └── ...
|
||||||
|
└── lib/
|
||||||
|
├── prisma.ts # Cliente Prisma
|
||||||
|
├── usage.ts # Tracking de uso y co-uso
|
||||||
|
├── search.ts # Búsqueda con scoring
|
||||||
|
├── query-parser.ts # Parser de queries avanzadas
|
||||||
|
├── versions.ts # Historial de versiones
|
||||||
|
├── related.ts # Notas relacionadas
|
||||||
|
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||||
|
├── tags.ts # Normalización y sugerencias
|
||||||
|
├── metrics.ts # Métricas de dashboard
|
||||||
|
├── centrality.ts # Cálculo de centralidad
|
||||||
|
├── type-inference.ts # Detección automática de tipo
|
||||||
|
├── link-suggestions.ts # Sugerencias de enlaces
|
||||||
|
├── features.ts # Feature flags
|
||||||
|
├── validators.ts # Zod schemas
|
||||||
|
├── errors.ts # Manejo de errores
|
||||||
|
├── backup.ts # Snapshot de backup
|
||||||
|
├── backup-storage.ts # IndexedDB storage
|
||||||
|
├── backup-policy.ts # Política de retención
|
||||||
|
├── backup-validator.ts # Validación de backups
|
||||||
|
├── restore.ts # Restore de backups
|
||||||
|
├── drafts.ts # Borradores locales
|
||||||
|
├── work-mode.ts # Modo trabajo
|
||||||
|
├── navigation-history.ts # Historial de navegación
|
||||||
|
├── export-markdown.ts # Exportación Markdown
|
||||||
|
├── export-html.ts # Exportación HTML
|
||||||
|
├── import-markdown.ts # Importador Markdown
|
||||||
|
└── external-capture.ts # Captura externa
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modelos de Datos
|
||||||
|
|
||||||
|
### Note
|
||||||
|
```prisma
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
type String @default("note")
|
||||||
|
isFavorite Boolean @default(false)
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
creationSource String @default("form")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteUsage
|
||||||
|
```prisma
|
||||||
|
model NoteUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
eventType String
|
||||||
|
query String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteCoUsage
|
||||||
|
```prisma
|
||||||
|
model NoteCoUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromNoteId String
|
||||||
|
toNoteId String
|
||||||
|
weight Int @default(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteVersion
|
||||||
|
```prisma
|
||||||
|
model NoteVersion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backlink
|
||||||
|
```prisma
|
||||||
|
model Backlink {
|
||||||
|
sourceNoteId String
|
||||||
|
targetNoteId String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## APIs Principales
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||||
|
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
|
||||||
|
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
|
||||||
|
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
|
||||||
|
| `/api/notes/quick` | POST | Creación rápida |
|
||||||
|
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||||
|
| `/api/search` | GET | Búsqueda con scoring |
|
||||||
|
| `/api/tags` | GET | Listar/buscar tags |
|
||||||
|
| `/api/tags/suggest` | GET | Sugerencias automáticas |
|
||||||
|
| `/api/usage` | GET | Estadísticas de uso |
|
||||||
|
| `/api/usage/co-usage` | GET | Notas co-usadas |
|
||||||
|
| `/api/metrics` | GET | Métricas de dashboard |
|
||||||
|
| `/api/centrality` | GET | Notas más centrales |
|
||||||
|
| `/api/export-import` | GET, POST | Export/Import (JSON, Markdown, HTML) |
|
||||||
|
| `/api/import-markdown` | POST | Importador Markdown mejorado |
|
||||||
|
| `/api/capture` | POST | Captura externa segura |
|
||||||
|
|
||||||
|
## Features Implementadas
|
||||||
|
|
||||||
|
### MVP-1
|
||||||
|
- CRUD completo de notas
|
||||||
|
- Sistema de tags
|
||||||
|
- Búsqueda básica
|
||||||
|
|
||||||
|
### MVP-2
|
||||||
|
- Búsqueda avanzada con scoring
|
||||||
|
- Quick Add con prefijos (cmd:, snip:, etc.)
|
||||||
|
- Backlinks con sintaxis [[wiki]]
|
||||||
|
- Formularios guiados por tipo de nota
|
||||||
|
|
||||||
|
### MVP-3
|
||||||
|
- Usage tracking (vistas, clics, copias)
|
||||||
|
- Dashboard inteligente
|
||||||
|
- Scoring boost basado en uso
|
||||||
|
- Sugerencias automáticas de tags
|
||||||
|
- Panel "Conectado con"
|
||||||
|
- Quick Add multilínea
|
||||||
|
- Pegado inteligente con detección de tipo
|
||||||
|
- Sugerencia automática de tipo de nota
|
||||||
|
- Sugerencia de enlaces internos
|
||||||
|
- Registro de co-uso entre notas
|
||||||
|
- Métricas internas
|
||||||
|
- Cálculo de notas centrales
|
||||||
|
- Feature flags configurables
|
||||||
|
|
||||||
|
### MVP-4
|
||||||
|
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||||
|
- Búsqueda en tiempo real con 300ms debounce
|
||||||
|
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
|
||||||
|
- Dropdown de resultados con cache
|
||||||
|
- Sidebar contextual con co-uso
|
||||||
|
- Historial de versiones de notas
|
||||||
|
|
||||||
|
### MVP-5 (Completo)
|
||||||
|
|
||||||
|
**Sprint 1 - Confianza total:**
|
||||||
|
- Sistema de backup automático (IndexedDB)
|
||||||
|
- Política de retención (max 10 backups, 30 días)
|
||||||
|
- Restore con preview y validación
|
||||||
|
- Backup previo automático pre-destructivo
|
||||||
|
- Guard de cambios sin guardar
|
||||||
|
|
||||||
|
**Sprint 2 - Flujo diario desde teclado:**
|
||||||
|
- Command Palette global (Ctrl+K / Cmd+K)
|
||||||
|
- Modelo de acciones para palette
|
||||||
|
- Shortcuts globales (g h, g n, n, /, ?)
|
||||||
|
- Navegación de listas por teclado (↑↓ Enter E F P)
|
||||||
|
|
||||||
|
**Sprint 3 - Contexto y continuidad:**
|
||||||
|
- Sidebar contextual persistente mejorada
|
||||||
|
- Modo trabajo con toggle
|
||||||
|
- Autosave de borradores locales
|
||||||
|
- Historial de navegación contextual
|
||||||
|
|
||||||
|
**Sprint 4 - Captura ubicua:**
|
||||||
|
- Bookmarklet para capturar desde cualquier web
|
||||||
|
- Página de confirmación de captura
|
||||||
|
- Endpoint seguro /api/capture con rate limiting
|
||||||
|
|
||||||
|
**P2 - Exportación, Importación y Settings:**
|
||||||
|
- Exportación Markdown con frontmatter
|
||||||
|
- Exportación HTML legible
|
||||||
|
- Importador Markdown mejorado (frontmatter, tags, wiki links)
|
||||||
|
- Importador Obsidian-compatible
|
||||||
|
- Centro de respaldo en Settings
|
||||||
|
- Panel de preferencias (backup on/off, retención, work mode)
|
||||||
|
- Tests de command palette y captura
|
||||||
|
- Validaciones y límites (50MB backup, 10K notas, etc)
|
||||||
|
|
||||||
|
## Algoritmo de Scoring
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Search scoring
|
||||||
|
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||||
|
|
||||||
|
// Related notes scoring
|
||||||
|
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||||
|
|
||||||
|
// Centrality score
|
||||||
|
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Atajos de Teclado
|
||||||
|
|
||||||
|
| Atajo | Acción |
|
||||||
|
|-------|--------|
|
||||||
|
| `Ctrl+K` / `Cmd+K` | Command Palette |
|
||||||
|
| `g h` | Ir al Dashboard |
|
||||||
|
| `g n` | Ir a Notas |
|
||||||
|
| `n` | Nueva nota |
|
||||||
|
| `/` | Enfocar búsqueda |
|
||||||
|
| `?` | Mostrar ayuda |
|
||||||
|
| `↑↓` | Navegar listas |
|
||||||
|
| `Enter` | Abrir nota |
|
||||||
|
| `E` | Editar nota (en lista) |
|
||||||
|
| `F` | Favoritar nota (en lista) |
|
||||||
|
| `P` | Fijar nota (en lista) |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**226 tests** cubriendo:
|
||||||
|
- API routes (CRUD, search, tags, versions)
|
||||||
|
- Search y scoring
|
||||||
|
- Query parser
|
||||||
|
- Notas relacionadas
|
||||||
|
- Backlinks
|
||||||
|
- Type inference
|
||||||
|
- Link suggestions
|
||||||
|
- Usage tracking
|
||||||
|
- Dashboard
|
||||||
|
- Version history
|
||||||
|
- Command items
|
||||||
|
- External capture
|
||||||
|
- Navigation history
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Desarrollo
|
||||||
|
npm run build # Build producción
|
||||||
|
npm test # Tests (usar npx jest)
|
||||||
|
npx prisma db push # Sync schema
|
||||||
|
npx prisma studio # UI de BD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración de Feature Flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLAG_CENTRALITY=true
|
||||||
|
FLAG_PASSIVE_RECOMMENDATIONS=true
|
||||||
|
FLAG_TYPE_SUGGESTIONS=true
|
||||||
|
FLAG_LINK_SUGGESTIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estados de Implementación
|
||||||
|
|
||||||
|
| Feature | Estado |
|
||||||
|
|---------|--------|
|
||||||
|
| CRUD notas | ✅ |
|
||||||
|
| Tags | ✅ |
|
||||||
|
| Búsqueda avanzada | ✅ |
|
||||||
|
| Quick Add | ✅ |
|
||||||
|
| Backlinks [[wiki]] | ✅ |
|
||||||
|
| Usage tracking | ✅ |
|
||||||
|
| Dashboard inteligente | ✅ |
|
||||||
|
| Versiones de notas | ✅ |
|
||||||
|
| Command Palette | ✅ |
|
||||||
|
| Shortcuts globales | ✅ |
|
||||||
|
| Modo trabajo | ✅ |
|
||||||
|
| Backup/Restore | ✅ |
|
||||||
|
| Bookmarklet capture | ✅ |
|
||||||
|
| Export Markdown/HTML | ✅ |
|
||||||
|
| Import Markdown | ✅ |
|
||||||
|
| Settings completo | ✅ |
|
||||||
|
| Feature flags UI | ✅ |
|
||||||
|
| Tests | ✅ (226) |
|
||||||
|
|
||||||
|
## Commits Recientes
|
||||||
|
|
||||||
|
```
|
||||||
|
e66a678 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
|
||||||
|
8d56f34 feat: MVP-5 Sprint 4 - External Capture via Bookmarklet
|
||||||
|
a40ab18 feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts
|
||||||
|
cde0a14 feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
|
||||||
|
8c80a12 feat: MVP-5 Sprint 1 - Backup/Restore system
|
||||||
|
```
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
# Recall - Resumen Técnico Detallado
|
||||||
|
|
||||||
|
## Información General
|
||||||
|
|
||||||
|
**Nombre:** Recall
|
||||||
|
**Descripción:** Sistema de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||||
|
**Fecha de creación:** 2026-03-22
|
||||||
|
**Estado:** MVP-5 Completo
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Tecnología | Versión |
|
||||||
|
|------------|-------------|---------|
|
||||||
|
| Framework | Next.js + App Router + Turbopack | 16.2.1 |
|
||||||
|
| Base de datos | SQLite via Prisma ORM | 5.22.0 |
|
||||||
|
| Lenguaje | TypeScript | 5.x |
|
||||||
|
| UI | TailwindCSS + shadcn/ui | 4.x / latest |
|
||||||
|
| Testing | Jest | 30.3.0 |
|
||||||
|
| Notificaciones | Sonner (toasts) | latest |
|
||||||
|
| IDE | VSCode / Cursor |
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── notes/
|
||||||
|
│ │ │ ├── route.ts # GET, POST /api/notes
|
||||||
|
│ │ │ ├── [id]/route.ts # GET, PUT, DELETE /api/notes/:id
|
||||||
|
│ │ │ ├── quick/route.ts # POST /api/notes/quick
|
||||||
|
│ │ │ ├── links/route.ts # GET /api/notes/links
|
||||||
|
│ │ │ ├── suggest/route.ts # GET /api/notes/suggest
|
||||||
|
│ │ │ ├── backlinks/route.ts # GET /api/notes/:id/backlinks
|
||||||
|
│ │ │ └── versions/
|
||||||
|
│ │ │ ├── route.ts # GET, POST /api/notes/:id/versions
|
||||||
|
│ │ │ └── [versionId]/route.ts # GET, PUT
|
||||||
|
│ │ ├── tags/
|
||||||
|
│ │ │ ├── route.ts # GET /api/tags
|
||||||
|
│ │ │ └── suggest/route.ts # GET /api/tags/suggest
|
||||||
|
│ │ ├── search/route.ts # GET /api/search
|
||||||
|
│ │ ├── usage/
|
||||||
|
│ │ │ ├── route.ts # GET /api/usage
|
||||||
|
│ │ │ └── co-usage/route.ts # GET /api/usage/co-usage
|
||||||
|
│ │ ├── metrics/route.ts # GET /api/metrics
|
||||||
|
│ │ ├── centrality/route.ts # GET /api/centrality
|
||||||
|
│ │ ├── export-import/route.ts # GET, POST
|
||||||
|
│ │ ├── import-markdown/route.ts # POST
|
||||||
|
│ │ └── capture/route.ts # POST /api/capture
|
||||||
|
│ ├── notes/[id]/page.tsx # Detalle de nota
|
||||||
|
│ ├── edit/[id]/page.tsx # Edición de nota
|
||||||
|
│ ├── new/page.tsx # Nueva nota
|
||||||
|
│ ├── capture/page.tsx # Confirmación de captura
|
||||||
|
│ ├── settings/page.tsx # Configuración
|
||||||
|
│ └── page.tsx # Dashboard (raíz)
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # Componentes shadcn/ui
|
||||||
|
│ ├── dashboard.tsx # Dashboard inteligente
|
||||||
|
│ ├── note-form.tsx # Formulario de notas
|
||||||
|
│ ├── note-card.tsx # Tarjeta de nota
|
||||||
|
│ ├── note-list.tsx # Lista de notas (grid)
|
||||||
|
│ ├── keyboard-navigable-note-list.tsx # Lista con navegación teclado
|
||||||
|
│ ├── note-connections.tsx # Panel de conexiones
|
||||||
|
│ ├── related-notes.tsx # Notas relacionadas
|
||||||
|
│ ├── version-history.tsx # Historial de versiones
|
||||||
|
│ ├── search-bar.tsx # Búsqueda en tiempo real
|
||||||
|
│ ├── command-palette.tsx # Command palette (Ctrl+K)
|
||||||
|
│ ├── keyboard-shortcuts-dialog.tsx # Diálogo de atajos
|
||||||
|
│ ├── shortcuts-provider.tsx # Provider de atajos
|
||||||
|
│ ├── keyboard-hint.tsx # Hint de atajos
|
||||||
|
│ ├── work-mode-toggle.tsx # Toggle modo trabajo
|
||||||
|
│ ├── draft-recovery-banner.tsx # Banner de recuperación
|
||||||
|
│ ├── backup-restore-dialog.tsx # Restore con preview
|
||||||
|
│ ├── backup-list.tsx # Lista de backups
|
||||||
|
│ ├── bookmarklet-instructions.tsx # Instrucciones bookmarklet
|
||||||
|
│ ├── recent-context-list.tsx # Historial de navegación
|
||||||
|
│ ├── track-navigation-history.tsx # Tracking de historial
|
||||||
|
│ ├── preferences-panel.tsx # Panel de preferencias
|
||||||
|
│ ├── markdown-content.tsx # Contenido con highlight
|
||||||
|
│ ├── quick-add.tsx # Captura rápida
|
||||||
|
│ └── track-note-view.tsx # Tracking de vistas
|
||||||
|
├── hooks/
|
||||||
|
│ ├── use-global-shortcuts.ts # Atajos globales
|
||||||
|
│ ├── use-note-list-keyboard.ts # Navegación teclado
|
||||||
|
│ └── use-unsaved-changes.ts # Guard de cambios sin guardar
|
||||||
|
└── lib/
|
||||||
|
├── prisma.ts # Cliente Prisma
|
||||||
|
├── search.ts # Búsqueda con scoring
|
||||||
|
├── query-parser.ts # Parser de queries
|
||||||
|
├── related.ts # Notas relacionadas
|
||||||
|
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||||
|
├── tags.ts # Normalización y sugerencias
|
||||||
|
├── usage.ts # Tracking de uso
|
||||||
|
├── metrics.ts # Métricas de dashboard
|
||||||
|
├── centrality.ts # Cálculo de centralidad
|
||||||
|
├── type-inference.ts # Detección automática de tipo
|
||||||
|
├── link-suggestions.ts # Sugerencias de enlaces
|
||||||
|
├── features.ts # Feature flags
|
||||||
|
├── validators.ts # Zod schemas
|
||||||
|
├── errors.ts # Manejo de errores
|
||||||
|
├── versions.ts # Historial de versiones
|
||||||
|
├── backup.ts # Snapshot de backup
|
||||||
|
├── backup-storage.ts # IndexedDB storage
|
||||||
|
├── backup-policy.ts # Política de retención
|
||||||
|
├── backup-validator.ts # Validación de backups
|
||||||
|
├── restore.ts # Restore de backups
|
||||||
|
├── drafts.ts # Borradores locales
|
||||||
|
├── work-mode.ts # Modo trabajo
|
||||||
|
├── navigation-history.ts # Historial de navegación
|
||||||
|
├── export-markdown.ts # Exportación Markdown
|
||||||
|
├── export-html.ts # Exportación HTML
|
||||||
|
├── import-markdown.ts # Importador Markdown
|
||||||
|
├── external-capture.ts # Captura externa
|
||||||
|
├── templates.ts # Templates por tipo
|
||||||
|
├── command-items.ts # Items de command palette
|
||||||
|
└── command-groups.ts # Grupos de comandos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modelos de Datos (Prisma)
|
||||||
|
|
||||||
|
### Note
|
||||||
|
```prisma
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
type String @default("note")
|
||||||
|
isFavorite Boolean @default(false)
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
creationSource String @default("form")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteUsage
|
||||||
|
```prisma
|
||||||
|
model NoteUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
eventType String
|
||||||
|
query String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteCoUsage
|
||||||
|
```prisma
|
||||||
|
model NoteCoUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromNoteId String
|
||||||
|
toNoteId String
|
||||||
|
weight Int @default(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoteVersion
|
||||||
|
```prisma
|
||||||
|
model NoteVersion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
noteId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backlink
|
||||||
|
```prisma
|
||||||
|
model Backlink {
|
||||||
|
sourceNoteId String
|
||||||
|
targetNoteId String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## APIs REST
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||||
|
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD nota |
|
||||||
|
| `/api/notes/quick` | POST | Creación rápida |
|
||||||
|
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||||
|
| `/api/notes/suggest` | GET | Sugerencias automática |
|
||||||
|
| `/api/notes/[id]/versions` | GET, POST | Versiones |
|
||||||
|
| `/api/notes/[id]/backlinks` | GET | Backlinks |
|
||||||
|
| `/api/search` | GET | Búsqueda avanzada |
|
||||||
|
| `/api/tags` | GET | Tags |
|
||||||
|
| `/api/tags/suggest` | GET | Sugerencias de tags |
|
||||||
|
| `/api/usage` | GET | Uso de notas |
|
||||||
|
| `/api/usage/co-usage` | GET | Co-uso entre notas |
|
||||||
|
| `/api/metrics` | GET | Métricas dashboard |
|
||||||
|
| `/api/centrality` | GET | Notas centrales |
|
||||||
|
| `/api/export-import` | GET, POST | Export/Import |
|
||||||
|
| `/api/import-markdown` | POST | Importar Markdown |
|
||||||
|
| `/api/capture` | POST | Captura externa |
|
||||||
|
|
||||||
|
## Features Implementadas
|
||||||
|
|
||||||
|
### MVP-1: Fundamentos
|
||||||
|
- [x] CRUD completo de notas
|
||||||
|
- [x] Sistema de tags
|
||||||
|
- [x] Búsqueda básica
|
||||||
|
|
||||||
|
### MVP-2: Captura Inteligente
|
||||||
|
- [x] Búsqueda avanzada con scoring
|
||||||
|
- [x] Quick Add con prefijos (cmd:, snip:, etc.)
|
||||||
|
- [x] Backlinks con sintaxis [[wiki]]
|
||||||
|
- [x] Formularios guiados por tipo
|
||||||
|
- [x] Templates inteligentes por tipo
|
||||||
|
- [x] Vista command con copiar
|
||||||
|
- [x] Vista snippet con syntax highlight
|
||||||
|
- [x] Checklist interactivo en procedure
|
||||||
|
|
||||||
|
### MVP-3: Uso y Contexto
|
||||||
|
- [x] Usage tracking (vistas, clics, copias)
|
||||||
|
- [x] Dashboard inteligente
|
||||||
|
- [x] Scoring boost basado en uso
|
||||||
|
- [x] Sugerencias automáticas de tags
|
||||||
|
- [x] Panel "Conectado con"
|
||||||
|
- [x] Quick Add multilínea
|
||||||
|
- [x] Pegado inteligente con detección de tipo
|
||||||
|
- [x] Sugerencia automática de tipo
|
||||||
|
- [x] Sugerencia de enlaces internos
|
||||||
|
- [x] Registro de co-uso entre notas
|
||||||
|
- [x] Métricas internas
|
||||||
|
- [x] Cálculo de notas centrales
|
||||||
|
- [x] Feature flags configurables
|
||||||
|
|
||||||
|
### MVP-4: Query Parser y Navegación
|
||||||
|
- [x] Query parser (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||||
|
- [x] Búsqueda en tiempo real (300ms debounce)
|
||||||
|
- [x] Navegación por teclado (↑↓ Enter ESC)
|
||||||
|
- [x] Dropdown de resultados con cache
|
||||||
|
- [x] Sidebar contextual con co-uso
|
||||||
|
- [x] Historial de versiones
|
||||||
|
- [x] Preload de notas en hover
|
||||||
|
|
||||||
|
### MVP-5: Flujo Diario y Portabilidad
|
||||||
|
|
||||||
|
**Sprint 1 - Confianza:**
|
||||||
|
- [x] Sistema de backup automático (IndexedDB)
|
||||||
|
- [x] Política de retención (max 10 backups, 30 días)
|
||||||
|
- [x] Restore con preview y validación
|
||||||
|
- [x] Backup previo automático
|
||||||
|
- [x] Guard de cambios sin guardar
|
||||||
|
|
||||||
|
**Sprint 2 - Shortcuts Globales:**
|
||||||
|
- [x] Command Palette (Ctrl+K / Cmd+K)
|
||||||
|
- [x] Shortcuts: g h, g n, n, /, ?
|
||||||
|
- [x] Navegación de listas por teclado
|
||||||
|
|
||||||
|
**Sprint 3 - Contexto y Continuidad:**
|
||||||
|
- [x] Sidebar contextual persistente
|
||||||
|
- [x] Modo trabajo con toggle
|
||||||
|
- [x] Autosave de borradores locales
|
||||||
|
- [x] Historial de navegación contextual
|
||||||
|
|
||||||
|
**Sprint 4 - Captura Ubicua:**
|
||||||
|
- [x] Bookmarklet para capturar desde web
|
||||||
|
- [x] Página de confirmación
|
||||||
|
- [x] Endpoint seguro con rate limiting
|
||||||
|
|
||||||
|
**P2 - Exportación e Importación:**
|
||||||
|
- [x] Exportación Markdown con frontmatter
|
||||||
|
- [x] Exportación HTML legible
|
||||||
|
- [x] Importador Markdown mejorado
|
||||||
|
- [x] Centro de respaldo en Settings
|
||||||
|
- [x] Panel de preferencias
|
||||||
|
- [x] Validaciones y límites
|
||||||
|
|
||||||
|
## Algoritmos de Scoring
|
||||||
|
|
||||||
|
### Búsqueda
|
||||||
|
```
|
||||||
|
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notas Relacionadas
|
||||||
|
```
|
||||||
|
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Centralidad
|
||||||
|
```
|
||||||
|
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Atajos de Teclado
|
||||||
|
|
||||||
|
| Atajo | Acción |
|
||||||
|
|-------|--------|
|
||||||
|
| `Ctrl+K` / `Cmd+K` | Command Palette |
|
||||||
|
| `g h` | Ir al Dashboard |
|
||||||
|
| `g n` | Ir a Notas |
|
||||||
|
| `n` | Nueva nota |
|
||||||
|
| `/` | Enfocar búsqueda |
|
||||||
|
| `?` | Mostrar ayuda |
|
||||||
|
| `↑↓` | Navegar listas |
|
||||||
|
| `Enter` | Abrir nota |
|
||||||
|
| `E` | Editar nota |
|
||||||
|
| `F` | Favoritar nota |
|
||||||
|
| `P` | Fijar nota |
|
||||||
|
|
||||||
|
## Tipos de Nota
|
||||||
|
|
||||||
|
| Tipo | Descripción | Color |
|
||||||
|
|------|-------------|-------|
|
||||||
|
| `note` | Nota general | Slate |
|
||||||
|
| `command` | Comando o snippet ejecutable | Green |
|
||||||
|
| `snippet` | Fragmento de código | Blue |
|
||||||
|
| `decision` | Decisión tomada | Purple |
|
||||||
|
| `recipe` | Receta o procedimiento | Orange |
|
||||||
|
| `procedure` | Procedimiento con checkboxes | Yellow |
|
||||||
|
| `inventory` | Inventario o lista | Gray |
|
||||||
|
|
||||||
|
## Comandos npm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Desarrollo (Turbopack)
|
||||||
|
npm run build # Build producción
|
||||||
|
npm run start # Iniciar producción
|
||||||
|
npm test # Tests (Jest)
|
||||||
|
npx jest --watch # Tests en watch mode
|
||||||
|
npx prisma db push # Sync schema a BD
|
||||||
|
npx prisma studio # UI de base de datos
|
||||||
|
npx prisma generate # Generar tipos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**226 tests** organizados en:
|
||||||
|
- `__tests__/api.*.test.ts` - Tests de integración de APIs
|
||||||
|
- `__tests__/search.test.ts` - Búsqueda y scoring
|
||||||
|
- `__tests__/query-parser.test.ts` - Parser de queries
|
||||||
|
- `__tests__/related.test.ts` - Notas relacionadas
|
||||||
|
- `__tests__/backlinks.test.ts` - Sistema de enlaces
|
||||||
|
- `__tests__/tags.test.ts` - Tags y sugerencias
|
||||||
|
- `__tests__/usage.test.ts` - Tracking de uso
|
||||||
|
- `__tests__/versions.test.ts` - Historial de versiones
|
||||||
|
- `__tests__/dashboard.test.ts` - Dashboard
|
||||||
|
- `__tests__/command-items.test.ts` - Command palette
|
||||||
|
- `__tests__/external-capture.test.ts` - Captura externa
|
||||||
|
- `__tests__/navigation-history.test.ts` - Historial
|
||||||
|
- `__tests__/link-suggestions.test.ts` - Sugerencias de enlaces
|
||||||
|
- `__tests__/type-inference.test.ts` - Inferencia de tipo
|
||||||
|
- `__tests__/quick-add.test.ts` - Quick Add
|
||||||
|
|
||||||
|
## Feature Flags
|
||||||
|
|
||||||
|
Configurables via `localStorage` o `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLAG_CENTRALITY=true # Habilitar centralidad
|
||||||
|
FLAG_PASSIVE_RECOMMENDATIONS=true # Recomendaciones pasivas
|
||||||
|
FLAG_TYPE_SUGGESTIONS=true # Sugerencias de tipo
|
||||||
|
FLAG_LINK_SUGGESTIONS=true # Sugerencias de enlaces
|
||||||
|
```
|
||||||
|
|
||||||
|
## Límites del Sistema
|
||||||
|
|
||||||
|
| Recurso | Límite |
|
||||||
|
|---------|--------|
|
||||||
|
| Tamaño de backup | 50MB |
|
||||||
|
| Cantidad de notas | 10,000 |
|
||||||
|
| Longitud de título (captura) | 500 chars |
|
||||||
|
| Longitud de URL (captura) | 2000 chars |
|
||||||
|
| Longitud de selección (captura) | 10,000 chars |
|
||||||
|
| Backups retenidos | 10 máximo |
|
||||||
|
| Días de retención | 30 días |
|
||||||
|
|
||||||
|
## Commits del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
33a4705 feat: MVP-4 P2 - Preload notes on hover
|
||||||
|
e66a678 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
|
||||||
|
8d56f34 feat: MVP-5 Sprint 4 - External Capture via Bookmarklet
|
||||||
|
a40ab18 feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts
|
||||||
|
cde0a14 feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
|
||||||
|
8c80a12 feat: MVP-5 Sprint 1 - Backup/Restore system
|
||||||
|
6694bce mvp
|
||||||
|
af0910f feat: initial commit
|
||||||
|
f2e4706 Initial commit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencias Principales
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"next": "16.2.1",
|
||||||
|
"@prisma/client": "5.22.0",
|
||||||
|
"prisma": "5.22.0",
|
||||||
|
"sonner": "latest",
|
||||||
|
"zod": "latest",
|
||||||
|
"tailwindcss": "4.x",
|
||||||
|
"@radix-ui/react-*": "latest"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patrones de Diseño
|
||||||
|
|
||||||
|
- **Server Components** para páginas estáticas
|
||||||
|
- **Client Components** para interactividad
|
||||||
|
- **Hooks personalizados** para lógica reutilizable
|
||||||
|
- **Feature Flags** para features opcionales
|
||||||
|
- **IndexedDB** para persistencia local de backups
|
||||||
|
- **localStorage** para preferencias y borradores
|
||||||
|
|
||||||
|
## Estado de Implementación
|
||||||
|
|
||||||
|
| Feature | Estado |
|
||||||
|
|---------|--------|
|
||||||
|
| CRUD notas | ✅ |
|
||||||
|
| Tags | ✅ |
|
||||||
|
| Búsqueda avanzada | ✅ |
|
||||||
|
| Quick Add | ✅ |
|
||||||
|
| Backlinks [[wiki]] | ✅ |
|
||||||
|
| Usage tracking | ✅ |
|
||||||
|
| Dashboard inteligente | ✅ |
|
||||||
|
| Versiones de notas | ✅ |
|
||||||
|
| Command Palette | ✅ |
|
||||||
|
| Shortcuts globales | ✅ |
|
||||||
|
| Modo trabajo | ✅ |
|
||||||
|
| Backup/Restore | ✅ |
|
||||||
|
| Bookmarklet capture | ✅ |
|
||||||
|
| Export Markdown/HTML | ✅ |
|
||||||
|
| Import Markdown | ✅ |
|
||||||
|
| Settings completo | ✅ |
|
||||||
|
| Feature flags UI | ✅ |
|
||||||
|
| Tests | ✅ (226) |
|
||||||
|
| Preload on hover | ✅ |
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { createSuccessResponse, createErrorResponse } from '@/lib/errors'
|
||||||
|
|
||||||
|
const captureSchema = z.object({
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
url: z.string().url().optional(),
|
||||||
|
selection: z.string().optional(),
|
||||||
|
source: z.enum(['bookmarklet', 'extension', 'api']).default('api'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const result = captureSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return createErrorResponse(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResponse(result.data)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getCentralNotes } from '@/lib/centrality'
|
||||||
|
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '10', 10)
|
||||||
|
|
||||||
|
const centralNotes = await getCentralNotes(limit)
|
||||||
|
|
||||||
|
return createSuccessResponse(centralNotes)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,20 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { noteSchema, NoteInput } from '@/lib/validators'
|
import { noteSchema, NoteInput } from '@/lib/validators'
|
||||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||||
import { syncBacklinks } from '@/lib/backlinks'
|
import { syncBacklinks } from '@/lib/backlinks'
|
||||||
|
import { createBackupSnapshot } from '@/lib/backup'
|
||||||
|
import { notesToMarkdownZip, noteToMarkdown } from '@/lib/export-markdown'
|
||||||
|
import { notesToHtmlZip, noteToHtml } from '@/lib/export-html'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const format = searchParams.get('format')
|
||||||
|
|
||||||
|
if (format === 'backup') {
|
||||||
|
const backup = await createBackupSnapshot('manual')
|
||||||
|
return createSuccessResponse(backup)
|
||||||
|
}
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
include: { tags: { include: { tag: true } } },
|
include: { tags: { include: { tag: true } } },
|
||||||
})
|
})
|
||||||
@@ -17,6 +28,38 @@ export async function GET() {
|
|||||||
updatedAt: note.updatedAt.toISOString(),
|
updatedAt: note.updatedAt.toISOString(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
if (format === 'markdown') {
|
||||||
|
const notesForExport = notes.map(note => ({
|
||||||
|
...note,
|
||||||
|
tags: note.tags,
|
||||||
|
createdAt: note.createdAt.toISOString(),
|
||||||
|
updatedAt: note.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
if (notes.length === 1) {
|
||||||
|
return createSuccessResponse({
|
||||||
|
filename: notesToMarkdownZip(notesForExport).files[0].name,
|
||||||
|
content: noteToMarkdown(notesForExport[0]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return createSuccessResponse(notesToMarkdownZip(notesForExport))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'html') {
|
||||||
|
const notesForExport = notes.map(note => ({
|
||||||
|
...note,
|
||||||
|
tags: note.tags,
|
||||||
|
createdAt: note.createdAt.toISOString(),
|
||||||
|
updatedAt: note.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
if (notes.length === 1) {
|
||||||
|
return createSuccessResponse({
|
||||||
|
filename: notesToHtmlZip(notesForExport).files[0].name,
|
||||||
|
content: noteToHtml(notesForExport[0]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return createSuccessResponse(notesToHtmlZip(notesForExport))
|
||||||
|
}
|
||||||
|
|
||||||
return createSuccessResponse(exportData)
|
return createSuccessResponse(exportData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return createErrorResponse(error)
|
return createErrorResponse(error)
|
||||||
@@ -62,23 +105,25 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const createdAt = parseDate((item as { createdAt?: string }).createdAt)
|
const createdAt = parseDate((item as { createdAt?: string }).createdAt)
|
||||||
const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt)
|
const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt)
|
||||||
|
const itemWithId = item as { id?: string }
|
||||||
|
|
||||||
if (item.id) {
|
if (itemWithId.id) {
|
||||||
const existing = await tx.note.findUnique({ where: { id: item.id } })
|
const existing = await tx.note.findUnique({ where: { id: itemWithId.id } })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await tx.note.update({
|
await tx.note.update({
|
||||||
where: { id: item.id },
|
where: { id: itemWithId.id },
|
||||||
data: { ...noteData, createdAt, updatedAt },
|
data: { ...noteData, createdAt, updatedAt },
|
||||||
})
|
})
|
||||||
await tx.noteTag.deleteMany({ where: { noteId: item.id } })
|
await tx.noteTag.deleteMany({ where: { noteId: itemWithId.id } })
|
||||||
processed++
|
processed++
|
||||||
} else {
|
} else {
|
||||||
await tx.note.create({
|
await tx.note.create({
|
||||||
data: {
|
data: {
|
||||||
...noteData,
|
...noteData,
|
||||||
id: item.id,
|
id: itemWithId.id,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
processed++
|
processed++
|
||||||
@@ -99,14 +144,15 @@ export async function POST(req: NextRequest) {
|
|||||||
...noteData,
|
...noteData,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
processed++
|
processed++
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteId = item.id
|
const noteId = itemWithId.id
|
||||||
? (await tx.note.findUnique({ where: { id: item.id } }))?.id
|
? (await tx.note.findUnique({ where: { id: itemWithId.id } }))?.id
|
||||||
: (await tx.note.findFirst({ where: { title: item.title } }))?.id
|
: (await tx.note.findFirst({ where: { title: item.title } }))?.id
|
||||||
|
|
||||||
if (noteId && tags.length > 0) {
|
if (noteId && tags.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||||
|
import { syncBacklinks } from '@/lib/backlinks'
|
||||||
|
import { parseMarkdownContent, convertWikiLinksToMarkdown, extractInlineTags } from '@/lib/import-markdown'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
if (!Array.isArray(body)) {
|
||||||
|
throw new ValidationError([{ path: 'body', message: 'Invalid format: expected array of markdown strings or objects' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDate = (dateStr: string | undefined): Date => {
|
||||||
|
if (!dateStr) return new Date()
|
||||||
|
const parsed = new Date(dateStr)
|
||||||
|
return isNaN(parsed.getTime()) ? new Date() : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const item = body[i]
|
||||||
|
|
||||||
|
// Handle both string markdown and object with markdown + filename
|
||||||
|
let markdown: string
|
||||||
|
let filename: string | undefined
|
||||||
|
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
markdown = item
|
||||||
|
} else if (typeof item === 'object' && item !== null) {
|
||||||
|
markdown = item.markdown || item.content || item.body || ''
|
||||||
|
filename = item.filename
|
||||||
|
} else {
|
||||||
|
errors.push(`Item ${i}: Invalid format`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!markdown || typeof markdown !== 'string') {
|
||||||
|
errors.push(`Item ${i}: Empty or invalid markdown`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseMarkdownContent(markdown, filename)
|
||||||
|
|
||||||
|
// Convert wiki links
|
||||||
|
let content = convertWikiLinksToMarkdown(parsed.content)
|
||||||
|
|
||||||
|
// Extract inline tags if none in frontmatter
|
||||||
|
const tags = parsed.frontmatter.tags || []
|
||||||
|
const inlineTags = extractInlineTags(content)
|
||||||
|
const allTags = [...new Set([...tags, ...inlineTags])]
|
||||||
|
|
||||||
|
const title = parsed.title || 'Untitled'
|
||||||
|
const type = parsed.frontmatter.type || 'note'
|
||||||
|
const createdAt = parseDate(parsed.frontmatter.createdAt)
|
||||||
|
const updatedAt = parseDate(parsed.frontmatter.updatedAt)
|
||||||
|
|
||||||
|
// Check for existing note by title
|
||||||
|
const existingByTitle = await tx.note.findFirst({
|
||||||
|
where: { title },
|
||||||
|
})
|
||||||
|
|
||||||
|
let noteId: string
|
||||||
|
|
||||||
|
if (existingByTitle) {
|
||||||
|
await tx.note.update({
|
||||||
|
where: { id: existingByTitle.id },
|
||||||
|
data: {
|
||||||
|
content,
|
||||||
|
type,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
isFavorite: parsed.frontmatter.favorite ?? existingByTitle.isFavorite,
|
||||||
|
isPinned: parsed.frontmatter.pinned ?? existingByTitle.isPinned,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
|
||||||
|
noteId = existingByTitle.id
|
||||||
|
} else {
|
||||||
|
const note = await tx.note.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
type,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
isFavorite: parsed.frontmatter.favorite ?? false,
|
||||||
|
isPinned: parsed.frontmatter.pinned ?? false,
|
||||||
|
creationSource: 'import',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
noteId = note.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tags
|
||||||
|
for (const tagName of allTags) {
|
||||||
|
if (!tagName) continue
|
||||||
|
const tag = await tx.tag.upsert({
|
||||||
|
where: { name: tagName },
|
||||||
|
create: { name: tagName },
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
await tx.noteTag.create({
|
||||||
|
data: { noteId, tagId: tag.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync backlinks
|
||||||
|
const note = await tx.note.findUnique({ where: { id: noteId } })
|
||||||
|
if (note) {
|
||||||
|
await syncBacklinks(note.id, note.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
processed++
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Item ${i}: ${err instanceof Error ? err.message : 'Parse error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (errors.length > 0 && processed === 0) {
|
||||||
|
throw new ValidationError(errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSuccessResponse({
|
||||||
|
success: true,
|
||||||
|
count: processed,
|
||||||
|
warnings: errors.length > 0 ? errors : undefined,
|
||||||
|
}, 201)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getDashboardMetrics } from '@/lib/metrics'
|
||||||
|
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const days = parseInt(searchParams.get('days') || '30', 10)
|
||||||
|
|
||||||
|
const metrics = await getDashboardMetrics(days)
|
||||||
|
|
||||||
|
return createSuccessResponse(metrics)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { updateNoteSchema } from '@/lib/validators'
|
import { updateNoteSchema } from '@/lib/validators'
|
||||||
import { syncBacklinks } from '@/lib/backlinks'
|
import { syncBacklinks } from '@/lib/backlinks'
|
||||||
|
import { createVersion } from '@/lib/versions'
|
||||||
import { createErrorResponse, createSuccessResponse, NotFoundError, ValidationError } from '@/lib/errors'
|
import { createErrorResponse, createSuccessResponse, NotFoundError, ValidationError } from '@/lib/errors'
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
@@ -32,13 +33,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
throw new ValidationError(result.error.issues)
|
throw new ValidationError(result.error.issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags, ...noteData } = result.data
|
const { tags, id: _, ...noteData } = result.data
|
||||||
|
|
||||||
const existingNote = await prisma.note.findUnique({ where: { id } })
|
const existingNote = await prisma.note.findUnique({ where: { id } })
|
||||||
if (!existingNote) {
|
if (!existingNote) {
|
||||||
throw new NotFoundError('Note')
|
throw new NotFoundError('Note')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await createVersion(id)
|
||||||
|
|
||||||
await prisma.noteTag.deleteMany({ where: { noteId: id } })
|
await prisma.noteTag.deleteMany({ where: { noteId: id } })
|
||||||
|
|
||||||
const note = await prisma.note.update({
|
const note = await prisma.note.update({
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getVersion, restoreVersion } from '@/lib/versions'
|
||||||
|
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { versionId } = await params
|
||||||
|
const version = await getVersion(versionId)
|
||||||
|
return createSuccessResponse(version)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id, versionId } = await params
|
||||||
|
const note = await restoreVersion(id, versionId)
|
||||||
|
return createSuccessResponse(note)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { createVersion, getVersions } from '@/lib/versions'
|
||||||
|
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const versions = await getVersions(id)
|
||||||
|
return createSuccessResponse(versions)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const version = await createVersion(id)
|
||||||
|
return createSuccessResponse(version, 201)
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const content = searchParams.get('content') || ''
|
||||||
|
const noteId = searchParams.get('noteId') || ''
|
||||||
|
|
||||||
|
if (!content.trim() || content.length < 10) {
|
||||||
|
return createSuccessResponse([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all notes except current one
|
||||||
|
const allNotes = await prisma.note.findMany({
|
||||||
|
where: noteId ? { id: { not: noteId } } : undefined,
|
||||||
|
select: { id: true, title: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allNotes.length === 0) {
|
||||||
|
return createSuccessResponse([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find titles that appear in content
|
||||||
|
const suggestions: { term: string; noteId: string; noteTitle: string }[] = []
|
||||||
|
const contentLower = content.toLowerCase()
|
||||||
|
|
||||||
|
for (const note of allNotes) {
|
||||||
|
const titleLower = note.title.toLowerCase()
|
||||||
|
// Check if title appears as a whole word in content
|
||||||
|
const escaped = titleLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const regex = new RegExp(`\\b${escaped}\\b`, 'i')
|
||||||
|
if (regex.test(content)) {
|
||||||
|
suggestions.push({
|
||||||
|
term: note.title,
|
||||||
|
noteId: note.id,
|
||||||
|
noteTitle: note.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by title length (longer = more specific)
|
||||||
|
suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length)
|
||||||
|
|
||||||
|
return createSuccessResponse(suggestions.slice(0, 10))
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export async function POST(req: NextRequest) {
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: noteContent || title.trim(),
|
content: noteContent || title.trim(),
|
||||||
type,
|
type,
|
||||||
|
creationSource: 'quick',
|
||||||
tags: tags.length > 0 ? {
|
tags: tags.length > 0 ? {
|
||||||
create: await Promise.all(
|
create: await Promise.all(
|
||||||
tags.map(async (tagName) => {
|
tags.map(async (tagName) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { noteSchema } from '@/lib/validators'
|
import { noteSchema } from '@/lib/validators'
|
||||||
import { normalizeTag } from '@/lib/tags'
|
import { normalizeTag } from '@/lib/tags'
|
||||||
import { noteQuery } from '@/lib/search'
|
import { searchNotes } from '@/lib/search'
|
||||||
import { syncBacklinks } from '@/lib/backlinks'
|
import { syncBacklinks } from '@/lib/backlinks'
|
||||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const tag = searchParams.get('tag') || undefined
|
const tag = searchParams.get('tag') || undefined
|
||||||
|
|
||||||
if (q || type || tag) {
|
if (q || type || tag) {
|
||||||
const notes = await noteQuery(q, { type, tag })
|
const notes = await searchNotes(q, { type, tag })
|
||||||
return createSuccessResponse(notes)
|
return createSuccessResponse(notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,11 +37,12 @@ export async function POST(req: NextRequest) {
|
|||||||
throw new ValidationError(result.error.issues)
|
throw new ValidationError(result.error.issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags, ...noteData } = result.data
|
const { tags, creationSource, ...noteData } = result.data
|
||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await prisma.note.create({
|
||||||
data: {
|
data: {
|
||||||
...noteData,
|
...noteData,
|
||||||
|
creationSource: creationSource || 'form',
|
||||||
tags: tags && tags.length > 0 ? {
|
tags: tags && tags.length > 0 ? {
|
||||||
create: await Promise.all(
|
create: await Promise.all(
|
||||||
(tags as string[]).map(async (tagName) => {
|
(tags as string[]).map(async (tagName) => {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { searchNotes } from '@/lib/search'
|
import { searchNotes } from '@/lib/search'
|
||||||
|
import { parseQuery } from '@/lib/query-parser'
|
||||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const q = searchParams.get('q') || ''
|
const q = searchParams.get('q') || ''
|
||||||
const type = searchParams.get('type') || undefined
|
|
||||||
const tag = searchParams.get('tag') || undefined
|
|
||||||
|
|
||||||
const notes = await searchNotes(q, { type, tag })
|
const queryAST = parseQuery(q)
|
||||||
|
const notes = await searchNotes(queryAST.text, queryAST.filters)
|
||||||
|
|
||||||
return createSuccessResponse(notes)
|
return createSuccessResponse(notes)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { trackCoUsage } from '@/lib/usage'
|
||||||
|
import { createErrorResponse } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { fromNoteId, toNoteId } = await req.json()
|
||||||
|
|
||||||
|
if (!fromNoteId || !toNoteId) {
|
||||||
|
return createErrorResponse(new Error('Missing note IDs'))
|
||||||
|
}
|
||||||
|
|
||||||
|
await trackCoUsage(fromNoteId, toNoteId)
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
} catch (error) {
|
||||||
|
return createErrorResponse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Loader2, Bookmark } from 'lucide-react'
|
||||||
|
|
||||||
|
function CaptureForm() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [selection, setSelection] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [tags, setTags] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const titleParam = searchParams.get('title') || ''
|
||||||
|
const urlParam = searchParams.get('url') || ''
|
||||||
|
const selectionParam = searchParams.get('selection') || ''
|
||||||
|
|
||||||
|
setTitle(titleParam)
|
||||||
|
setUrl(urlParam)
|
||||||
|
setSelection(selectionParam)
|
||||||
|
|
||||||
|
// Pre-fill content with captured web content
|
||||||
|
if (selectionParam) {
|
||||||
|
setContent(`## Web Selection\n\n${selectionParam}\n\n## Source\n\n${urlParam}`)
|
||||||
|
} else {
|
||||||
|
setContent(`## Source\n\n${urlParam}`)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!content.trim() || isLoading) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
// Build the full content with optional tags
|
||||||
|
const fullContent = tags.trim()
|
||||||
|
? `${content}\n\n## Tags\n\n${tags.trim().split(',').map(t => `#${t.trim()}`).join(' ')}`
|
||||||
|
: content
|
||||||
|
|
||||||
|
const response = await fetch('/api/notes/quick', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: `web: ${title || url}\n\n${fullContent}`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.error || 'Error creating note')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Nota creada desde web', {
|
||||||
|
description: title || url,
|
||||||
|
})
|
||||||
|
router.push('/notes')
|
||||||
|
router.refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error', {
|
||||||
|
description: error instanceof Error ? error.message : 'No se pudo crear la nota',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarkletCode = `javascript:var title = document.title; var url = location.href; var selection = window.getSelection().toString(); var params = new URLSearchParams({title, url, selection}); window.open('/capture?' + params.toString(), '_blank');`
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
|
e.dataTransfer.setData('text/plain', bookmarkletCode)
|
||||||
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Bookmark className="h-6 w-6" />
|
||||||
|
<h1 className="text-2xl font-bold">Capturar desde web</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-4 mb-6 bg-muted/50">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Arrastra este botón a tu barra de marcadores:</p>
|
||||||
|
<button
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium cursor-grab active:cursor-grabbing transition-all ${
|
||||||
|
isDragging ? 'opacity-50 scale-95' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Capturar a Recall
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Título</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Título de la página"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">URL</label>
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Selección</label>
|
||||||
|
<Textarea
|
||||||
|
value={selection}
|
||||||
|
onChange={(e) => setSelection(e.target.value)}
|
||||||
|
placeholder="Texto seleccionado de la página..."
|
||||||
|
rows={4}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Contenido</label>
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="Contenido adicional..."
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tags (separados por coma)</label>
|
||||||
|
<Input
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder="web, referencia, artículo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={!content.trim() || isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Guardando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Crear nota'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/notes')}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CapturePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="container mx-auto py-8 px-4 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<CaptureForm />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { Metadata } from 'next'
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { Header } from '@/components/header'
|
import { Header } from '@/components/header'
|
||||||
|
import { CommandPalette } from '@/components/command-palette'
|
||||||
|
import { ShortcutsProvider } from '@/components/shortcuts-provider'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Recall - Gestor de Conocimiento Personal',
|
title: 'Recall - Gestor de Conocimiento Personal',
|
||||||
@@ -19,6 +21,8 @@ export default function RootLayout({
|
|||||||
<Header />
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<CommandPalette />
|
||||||
|
<ShortcutsProvider />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { RelatedNotes } from '@/components/related-notes'
|
|
||||||
import { getRelatedNotes } from '@/lib/related'
|
import { getRelatedNotes } from '@/lib/related'
|
||||||
|
import { getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks'
|
||||||
|
import { getCoUsedNotes } from '@/lib/usage'
|
||||||
|
import { NoteConnections } from '@/components/note-connections'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
import { DeleteNoteButton } from '@/components/delete-note-button'
|
import { DeleteNoteButton } from '@/components/delete-note-button'
|
||||||
import { TrackNoteView } from '@/components/track-note-view'
|
import { TrackNoteView } from '@/components/track-note-view'
|
||||||
|
import { TrackNavigationHistory } from '@/components/track-navigation-history'
|
||||||
|
import { VersionHistory } from '@/components/version-history'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -33,11 +37,15 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const related = await getRelatedNotes(id, 5)
|
const related = await getRelatedNotes(id, 5)
|
||||||
|
const backlinks = await getBacklinksForNote(id)
|
||||||
|
const outgoingLinks = await getOutgoingLinksForNote(id)
|
||||||
|
const coUsedNotes = await getCoUsedNotes(id, 5)
|
||||||
const noteType = note.type as NoteType
|
const noteType = note.type as NoteType
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TrackNoteView noteId={note.id} />
|
<TrackNoteView noteId={note.id} />
|
||||||
|
<TrackNavigationHistory noteId={note.id} title={note.title} type={note.type} />
|
||||||
<main className="container mx-auto py-8 px-4 max-w-4xl">
|
<main className="container mx-auto py-8 px-4 max-w-4xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href="/notes">
|
<Link href="/notes">
|
||||||
@@ -68,6 +76,7 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
|||||||
<Edit className="h-4 w-4 mr-1" /> Editar
|
<Edit className="h-4 w-4 mr-1" /> Editar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<VersionHistory noteId={note.id} />
|
||||||
<DeleteNoteButton noteId={note.id} noteTitle={note.title} />
|
<DeleteNoteButton noteId={note.id} noteTitle={note.title} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,9 +101,13 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{related.length > 0 && (
|
<NoteConnections
|
||||||
<RelatedNotes notes={related} />
|
noteId={note.id}
|
||||||
)}
|
backlinks={backlinks}
|
||||||
|
outgoingLinks={outgoingLinks}
|
||||||
|
relatedNotes={related}
|
||||||
|
coUsedNotes={coUsedNotes}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { NoteList } from '@/components/note-list'
|
import { KeyboardNavigableNoteList } from '@/components/keyboard-navigable-note-list'
|
||||||
|
import { KeyboardHint } from '@/components/keyboard-hint'
|
||||||
import { SearchBar } from '@/components/search-bar'
|
import { SearchBar } from '@/components/search-bar'
|
||||||
import { TagFilter } from '@/components/tag-filter'
|
import { TagFilter } from '@/components/tag-filter'
|
||||||
import { NoteType } from '@/types/note'
|
import { NoteType } from '@/types/note'
|
||||||
@@ -86,7 +87,8 @@ export default async function NotesPage({ searchParams }: { searchParams: Promis
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<NoteList notes={notesWithTags} />
|
<KeyboardNavigableNoteList notes={notesWithTags} />
|
||||||
|
<KeyboardHint />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Dashboard } from '@/components/dashboard'
|
import { Dashboard } from '@/components/dashboard'
|
||||||
import { getDashboardData } from '@/lib/dashboard'
|
import { getDashboardData } from '@/lib/dashboard'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const data = await getDashboardData(6)
|
const data = await getDashboardData(6)
|
||||||
|
|
||||||
|
|||||||
+109
-21
@@ -1,10 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Download, Upload } from 'lucide-react'
|
import { Download, Upload, History, FileText, Code, FolderOpen } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { BackupList } from '@/components/backup-list'
|
||||||
|
import { PreferencesPanel } from '@/components/preferences-panel'
|
||||||
|
|
||||||
function parseMarkdownToNote(content: string, filename: string) {
|
function parseMarkdownToNote(content: string, filename: string) {
|
||||||
const lines = content.split('\n')
|
const lines = content.split('\n')
|
||||||
@@ -27,31 +29,56 @@ function parseMarkdownToNote(content: string, filename: string) {
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [exporting, setExporting] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async (format: 'json' | 'markdown' | 'html') => {
|
||||||
|
setExporting(format)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/export-import')
|
const response = await fetch(`/api/export-import?format=${format}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error al exportar')
|
throw new Error('Error al exportar')
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
let blob: Blob
|
||||||
const url = URL.createObjectURL(blob)
|
let filename: string
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0]
|
const date = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
filename = `recall-backup-${date}.json`
|
||||||
|
} else if (format === 'markdown') {
|
||||||
|
if (data.files) {
|
||||||
|
// Multiple files - in the future could be a zip
|
||||||
|
blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n---\n\n')], { type: 'text/markdown' })
|
||||||
|
} else {
|
||||||
|
blob = new Blob([data.content], { type: 'text/markdown' })
|
||||||
|
}
|
||||||
|
filename = `recall-export-${date}.md`
|
||||||
|
} else {
|
||||||
|
if (data.files) {
|
||||||
|
blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n')], { type: 'text/html' })
|
||||||
|
} else {
|
||||||
|
blob = new Blob([data.content], { type: 'text/html' })
|
||||||
|
}
|
||||||
|
filename = `recall-export-${date}.html`
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `recall-backup-${date}.json`
|
a.download = filename
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
toast.success('Notas exportadas correctamente')
|
toast.success(`Notas exportadas en formato ${format.toUpperCase()}`)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error al exportar las notas')
|
toast.error('Error al exportar las notas')
|
||||||
|
} finally {
|
||||||
|
setExporting(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,15 +95,17 @@ export default function SettingsPage() {
|
|||||||
const isMarkdown = file.name.endsWith('.md')
|
const isMarkdown = file.name.endsWith('.md')
|
||||||
|
|
||||||
let payload: object[]
|
let payload: object[]
|
||||||
|
let endpoint = '/api/export-import'
|
||||||
|
|
||||||
if (isMarkdown) {
|
if (isMarkdown) {
|
||||||
const note = parseMarkdownToNote(text, file.name)
|
const note = parseMarkdownToNote(text, file.name)
|
||||||
payload = [note]
|
payload = [{ markdown: text, filename: file.name }]
|
||||||
|
endpoint = '/api/import-markdown'
|
||||||
} else {
|
} else {
|
||||||
payload = JSON.parse(text)
|
payload = JSON.parse(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/export-import', {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -88,7 +117,11 @@ export default function SettingsPage() {
|
|||||||
throw new Error(result.error || 'Error al importar')
|
throw new Error(result.error || 'Error al importar')
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`)
|
const msg = result.warnings
|
||||||
|
? `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente (con advertencias)`
|
||||||
|
: `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`
|
||||||
|
|
||||||
|
toast.success(msg)
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ''
|
fileInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
@@ -103,27 +136,82 @@ export default function SettingsPage() {
|
|||||||
<main className="container mx-auto py-8 px-4">
|
<main className="container mx-auto py-8 px-4">
|
||||||
<h1 className="text-2xl font-bold mb-6">Configuración</h1>
|
<h1 className="text-2xl font-bold mb-6">Configuración</h1>
|
||||||
|
|
||||||
<div className="grid gap-6 max-w-xl">
|
<div className="grid gap-6 max-w-2xl">
|
||||||
|
{/* Preferences Section */}
|
||||||
|
<PreferencesPanel />
|
||||||
|
|
||||||
|
{/* Backups Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Exportar notas</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
Backups y Restauración
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Descarga todas tus notas en formato JSON. El archivo incluye títulos, contenido, tipos y tags.
|
Los backups automáticos se guardan localmente. También puedes crear un backup manual antes de operaciones riesgosas.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button onClick={handleExport} className="gap-2">
|
<BackupList />
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Export Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Importar notas</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
Exportar Notas
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Importa notas desde archivos JSON o MD. En archivos MD, el primer heading (#) se usa como título.
|
Descarga tus notas en diferentes formatos. Elige el que mejor se adapte a tus necesidades.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleExport('json')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
{exporting === 'json' ? 'Exportando...' : 'JSON (Backup completo)'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleExport('markdown')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
{exporting === 'markdown' ? 'Exportando...' : 'Markdown'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleExport('html')}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
{exporting === 'html' ? 'Exportando...' : 'HTML'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Import Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
Importar Notas
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Importa notas desde archivos JSON o Markdown. Soporta frontmatter, tags, y enlaces wiki.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
@@ -137,7 +225,7 @@ export default function SettingsPage() {
|
|||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2"
|
className="gap-2 self-start"
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
{importing ? 'Importando...' : 'Importar'}
|
{importing ? 'Importando...' : 'Importar'}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getBackups, deleteBackup } from '@/lib/backup-storage'
|
||||||
|
import { RecallBackup } from '@/types/backup'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Trash2, RotateCcw, Calendar, FileText } from 'lucide-react'
|
||||||
|
import { BackupRestoreDialog } from './backup-restore-dialog'
|
||||||
|
|
||||||
|
export function BackupList() {
|
||||||
|
const [backups, setBackups] = useState<RecallBackup[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBackups()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadBackups() {
|
||||||
|
try {
|
||||||
|
const data = await getBackups()
|
||||||
|
setBackups(data)
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar los backups')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setDeletingId(id)
|
||||||
|
try {
|
||||||
|
await deleteBackup(id)
|
||||||
|
setBackups((prev) => prev.filter((b) => b.id !== id))
|
||||||
|
toast.success('Backup eliminado')
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al eliminar el backup')
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('es-ES', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceBadgeVariant(source: RecallBackup['source']) {
|
||||||
|
switch (source) {
|
||||||
|
case 'automatic':
|
||||||
|
return 'secondary'
|
||||||
|
case 'manual':
|
||||||
|
return 'default'
|
||||||
|
case 'pre-destructive':
|
||||||
|
return 'destructive'
|
||||||
|
default:
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceLabel(source: RecallBackup['source']) {
|
||||||
|
switch (source) {
|
||||||
|
case 'automatic':
|
||||||
|
return 'Automático'
|
||||||
|
case 'manual':
|
||||||
|
return 'Manual'
|
||||||
|
case 'pre-destructive':
|
||||||
|
return 'Pre-destrucción'
|
||||||
|
default:
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-sm text-muted-foreground">Cargando backups...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No hay backups disponibles. Los backups se crean automáticamente antes de operaciones
|
||||||
|
destructivas.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<div
|
||||||
|
key={backup.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{formatDate(backup.createdAt)}</span>
|
||||||
|
<Badge variant={getSourceBadgeVariant(backup.source)}>{getSourceLabel(backup.source)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
{backup.metadata.noteCount} nota{backup.metadata.noteCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{backup.metadata.tagCount} tag{backup.metadata.tagCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<BackupRestoreDialog
|
||||||
|
backup={backup}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" size="sm" className="gap-1 cursor-pointer">
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Restaurar
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(backup.id)}
|
||||||
|
disabled={deletingId === backup.id}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { validateBackup } from '@/lib/backup-validator'
|
||||||
|
import { restoreBackup } from '@/lib/restore'
|
||||||
|
import { RecallBackup } from '@/types/backup'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { RotateCcw, FileText, Tag, Calendar, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface BackupRestoreDialogProps {
|
||||||
|
backup: RecallBackup
|
||||||
|
trigger?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupRestoreDialog({ backup, trigger }: BackupRestoreDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [mode, setMode] = useState<'merge' | 'replace'>('merge')
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const validation = validateBackup(backup)
|
||||||
|
const backupInfo = validation.info
|
||||||
|
|
||||||
|
function handleModeChange(newMode: 'merge' | 'replace') {
|
||||||
|
setMode(newMode)
|
||||||
|
setConfirming(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await restoreBackup(backup, mode)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`${result.restored} nota${result.restored !== 1 ? 's' : ''} restaurada${result.restored !== 1 ? 's' : ''} correctamente`)
|
||||||
|
setOpen(false)
|
||||||
|
setConfirming(false)
|
||||||
|
setMode('merge')
|
||||||
|
} else {
|
||||||
|
toast.error(`Error al restaurar: ${result.errors.join(', ')}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al restaurar el backup')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
{trigger && <div onClick={() => setOpen(true)}>{trigger}</div>}
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<RotateCcw className="h-5 w-5" />
|
||||||
|
Restaurar Backup
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Recupera notas desde un backup anterior
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Backup Info */}
|
||||||
|
<div className="space-y-3 p-4 bg-muted rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{backupInfo?.createdAt ? new Date(backupInfo.createdAt).toLocaleString('es-ES') : 'Fecha desconocida'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{backupInfo?.noteCount ?? 0} nota{(backupInfo?.noteCount ?? 0) !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{backupInfo?.tagCount ?? 0} tag{(backupInfo?.tagCount ?? 0) !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Fuente: {backupInfo?.source ?? 'desconocida'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
{!confirming && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium">Modo de restauración</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleModeChange('merge')}
|
||||||
|
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||||
|
mode === 'merge'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'hover:border-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">Combinar</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Añade nuevas notas, actualiza existentes
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleModeChange('replace')}
|
||||||
|
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||||
|
mode === 'replace'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'hover:border-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">Reemplazar</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Sustituye todo el contenido actual
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation */}
|
||||||
|
{confirming && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mode === 'replace' && (
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-destructive">Operación destructiva</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Se eliminará el contenido actual antes de restaurar. Se creará un backup de seguridad automáticamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm">
|
||||||
|
¿Estás seguro de que quieres restaurar este backup? Esta acción{' '}
|
||||||
|
{mode === 'merge' ? 'no eliminará' : 'eliminará'} notas existentes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
{!confirming ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setConfirming(true)}>
|
||||||
|
Continuar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setConfirming(false)} disabled={loading}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={mode === 'replace' ? 'destructive' : 'default'}
|
||||||
|
onClick={handleRestore}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Restaurando...' : 'Confirmar'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { generateBookmarklet } from '@/lib/external-capture'
|
||||||
|
import { Bookmark, Copy, Check, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
export function BookmarkletInstructions() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
const bookmarkletCode = generateBookmarklet()
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(bookmarkletCode)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success('Código copiado al portapapeles')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al copiar el código')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
|
e.dataTransfer.setData('text/plain', bookmarkletCode)
|
||||||
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
{!isOpen && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<Bookmark className="h-4 w-4" />
|
||||||
|
Capturar web
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Bookmark className="h-5 w-5" />
|
||||||
|
Capturar desde web
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Guarda contenido de cualquier página web directamente en tus notas.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium mb-2">Instrucciones:</p>
|
||||||
|
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||||
|
<li>Arrastra el botón de abajo a tu barra de marcadores</li>
|
||||||
|
<li>Cuando quieras capturar algo, haz clic en el marcador</li>
|
||||||
|
<li>Se abrirá una página para confirmar y guardar</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">Botón del marcador:</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center p-4 bg-muted/30 rounded-lg border-2 border-dashed border-muted">
|
||||||
|
<button
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
className={`px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium cursor-grab active:cursor-grabbing transition-all ${
|
||||||
|
isDragging ? 'opacity-50 scale-95' : ''
|
||||||
|
}`}
|
||||||
|
title="Arrastra esto a tu barra de marcadores"
|
||||||
|
>
|
||||||
|
Capturar a Recall
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
No puedes arrastrar? Usa el botón copiar y crea un marcador manualmente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 gap-2"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
Copiado
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copiar código
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
El marcador capturará el título de la página, la URL y cualquier texto que hayas seleccionado antes de hacer clic.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { commands, CommandItem } from '@/lib/command-items'
|
||||||
|
import { Search, FileText, Settings, Home, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Listen for Ctrl+K / Cmd+K
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filter commands by query
|
||||||
|
const filtered = commands.filter(cmd =>
|
||||||
|
cmd.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
cmd.keywords?.some(k => k.includes(query.toLowerCase()))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
if (e.key === 'ArrowDown') setSelectedIndex(i => Math.min(i + 1, filtered.length - 1))
|
||||||
|
if (e.key === 'ArrowUp') setSelectedIndex(i => Math.max(i - 1, 0))
|
||||||
|
if (e.key === 'Enter' && filtered[selectedIndex]) {
|
||||||
|
executeCommand(filtered[selectedIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
const executeCommand = (cmd: CommandItem) => {
|
||||||
|
router.push(cmd.id === 'action-new' ? '/new' : `/${cmd.id.split('-')[1]}`)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md p-0 gap-0">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar comandos..."
|
||||||
|
className="border-0 focus-visible:ring-0"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{filtered.map((cmd, i) => (
|
||||||
|
<button
|
||||||
|
key={cmd.id}
|
||||||
|
onClick={() => executeCommand(cmd)}
|
||||||
|
className={`w-full px-3 py-2 text-left flex items-center gap-2 ${
|
||||||
|
i === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{cmd.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto">{cmd.group}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DraftRecoveryBannerProps {
|
||||||
|
onRestore: () => void
|
||||||
|
onDiscard: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraftRecoveryBanner({ onRestore, onDiscard }: DraftRecoveryBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-50 border-yellow-200 p-3 rounded-lg flex items-center gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-yellow-600" />
|
||||||
|
<p className="text-sm text-yellow-800 flex-1">Se encontró un borrador guardado</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={onDiscard}>Descartar</Button>
|
||||||
|
<Button size="sm" onClick={onRestore}>Recuperar</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, FileText, Settings } from 'lucide-react'
|
import { Plus, FileText, Settings, Menu, X } from 'lucide-react'
|
||||||
import { QuickAdd } from '@/components/quick-add'
|
import { QuickAdd } from '@/components/quick-add'
|
||||||
|
import { WorkModeToggle } from '@/components/work-mode-toggle'
|
||||||
|
import { BookmarkletInstructions } from '@/components/bookmarklet-instructions'
|
||||||
|
import { isWorkModeEnabled } from '@/lib/preferences'
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const [workModeToggleVisible, setWorkModeToggleVisible] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWorkModeToggleVisible(isWorkModeEnabled())
|
||||||
|
|
||||||
|
const handlePreferencesChange = () => {
|
||||||
|
setWorkModeToggleVisible(isWorkModeEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('preferences-updated', handlePreferencesChange)
|
||||||
|
return () => window.removeEventListener('preferences-updated', handlePreferencesChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container mx-auto px-4 flex h-14 items-center justify-between">
|
<div className="container mx-auto px-2 sm:px-4">
|
||||||
<div className="flex items-center gap-4">
|
{/* Desktop: single row */}
|
||||||
|
<div className="hidden sm:flex h-14 items-center justify-between gap-2">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<span className="text-xl font-bold">Recall</span>
|
<span className="text-xl font-bold">Recall</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -38,9 +56,10 @@ export function Header() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<QuickAdd />
|
<QuickAdd />
|
||||||
|
<BookmarkletInstructions />
|
||||||
|
{workModeToggleVisible && <WorkModeToggle />}
|
||||||
<Link href="/new">
|
<Link href="/new">
|
||||||
<Button size="sm" className="gap-1.5">
|
<Button size="sm" className="gap-1.5">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -49,6 +68,72 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: hamburger + logo */}
|
||||||
|
<div className="flex sm:hidden h-14 items-center justify-between gap-2">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold">Recall</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<QuickAdd />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile dropdown menu */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="sm:hidden py-3 border-t flex flex-col gap-2">
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
<Link href="/notes" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button
|
||||||
|
variant={pathname === '/notes' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Notas
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button
|
||||||
|
variant={pathname === '/settings' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Configuración
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="border-t pt-2 flex flex-col gap-1">
|
||||||
|
<Link href="/new" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button size="sm" className="w-full justify-start gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nueva nota
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<BookmarkletInstructions />
|
||||||
|
{workModeToggleVisible && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
|
<span className="text-sm">Modo trabajo</span>
|
||||||
|
<WorkModeToggle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function KeyboardHint() {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-2 border-t">
|
||||||
|
↑↓ navegar · Enter abrir · E editar · F favoritar · P fijar
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { Note } from '@/types/note'
|
||||||
|
import { NoteCard } from './note-card'
|
||||||
|
import { useNoteListKeyboard } from '@/hooks/use-note-list-keyboard'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface KeyboardNavigableNoteListProps {
|
||||||
|
notes: Note[]
|
||||||
|
onEdit?: (noteId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardNavigableNoteList({
|
||||||
|
notes,
|
||||||
|
onEdit,
|
||||||
|
}: KeyboardNavigableNoteListProps) {
|
||||||
|
const handleFavorite = useCallback(async (noteId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ isFavorite: true }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Añadido a favoritos')
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al añadir a favoritos')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePin = useCallback(async (noteId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ isPinned: true }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Nota fijada')
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al fijar nota')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { selectedIndex, prefetchNote } = useNoteListKeyboard({
|
||||||
|
notes,
|
||||||
|
onEdit,
|
||||||
|
onFavorite: handleFavorite,
|
||||||
|
onPin: handlePin,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<p className="text-lg">No hay notas todavía</p>
|
||||||
|
<p className="text-sm">Crea tu primera nota para comenzar</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{notes.map((note, index) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className={`relative ${index === selectedIndex ? 'ring-2 ring-primary ring-offset-2 rounded-lg' : ''}`}
|
||||||
|
data-selected={index === selectedIndex}
|
||||||
|
>
|
||||||
|
<NoteCard note={note} />
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<div className="absolute bottom-2 right-2 flex gap-1">
|
||||||
|
<span className="px-1.5 py-0.5 bg-muted text-xs rounded text-muted-foreground">
|
||||||
|
Enter: abrir | E: editar | F: favoritar | P: fijar
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Keyboard } from 'lucide-react'
|
||||||
|
|
||||||
|
const shortcuts = [
|
||||||
|
{ keys: ['n'], description: 'Nueva nota' },
|
||||||
|
{ keys: ['g', 'h'], description: 'Ir al Dashboard' },
|
||||||
|
{ keys: ['g', 'n'], description: 'Ir a Notas' },
|
||||||
|
{ keys: ['/'], description: 'Enfocar búsqueda' },
|
||||||
|
{ keys: ['?'], description: 'Mostrar atajos' },
|
||||||
|
{ keys: ['Ctrl', 'K'], description: 'Command Palette' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function KeyboardShortcutsDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (o: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Keyboard className="h-5 w-5" />
|
||||||
|
Atajos de teclado
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="divide-y">
|
||||||
|
{shortcuts.map((s) => (
|
||||||
|
<div key={s.description} className="flex justify-between py-2">
|
||||||
|
<span className="text-sm">{s.description}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{s.keys.map((k) => (
|
||||||
|
<kbd key={k} className="px-2 py-1 bg-muted rounded text-xs">{k}</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { Note } from '@/types/note'
|
import { Note } from '@/types/note'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -16,12 +17,21 @@ const typeColors: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NoteCard({ note }: { note: Note }) {
|
export function NoteCard({ note }: { note: Note }) {
|
||||||
|
const router = useRouter()
|
||||||
const preview = note.content.slice(0, 100) + (note.content.length > 100 ? '...' : '')
|
const preview = note.content.slice(0, 100) + (note.content.length > 100 ? '...' : '')
|
||||||
const typeColor = typeColors[note.type] || typeColors.note
|
const typeColor = typeColors[note.type] || typeColors.note
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
// Prefetch on hover for faster navigation
|
||||||
|
router.prefetch(`/notes/${note.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/notes/${note.id}`}>
|
<Link href={`/notes/${note.id}`} prefetch={true}>
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
<Card
|
||||||
|
className="hover:shadow-md transition-shadow cursor-pointer h-full"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<h3 className="font-semibold text-lg line-clamp-1">{note.title}</h3>
|
<h3 className="font-semibold text-lg line-clamp-1">{note.title}</h3>
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History, Clock } from 'lucide-react'
|
||||||
|
import { getNavigationHistory, NavigationEntry } from '@/lib/navigation-history'
|
||||||
|
|
||||||
|
interface BacklinkInfo {
|
||||||
|
id: string
|
||||||
|
sourceNoteId: string
|
||||||
|
targetNoteId: string
|
||||||
|
sourceNote: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelatedNote {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
tags: string[]
|
||||||
|
score: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteConnectionsProps {
|
||||||
|
noteId: string
|
||||||
|
backlinks: BacklinkInfo[]
|
||||||
|
outgoingLinks: BacklinkInfo[]
|
||||||
|
relatedNotes: RelatedNote[]
|
||||||
|
coUsedNotes: { noteId: string; title: string; type: string; weight: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionGroup({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
notes,
|
||||||
|
emptyMessage,
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
notes: { id: string; title: string; type: string }[]
|
||||||
|
emptyMessage: string
|
||||||
|
isCollapsed?: boolean
|
||||||
|
onToggle?: () => void
|
||||||
|
}) {
|
||||||
|
if (notes.length === 0 && isCollapsed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground pl-6">{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex items-center gap-2 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
<Badge variant="secondary" className="ml-auto text-xs">
|
||||||
|
{notes.length}
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="pl-6 space-y-1">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<Link
|
||||||
|
key={note.id}
|
||||||
|
href={`/notes/${note.id}`}
|
||||||
|
className="block text-sm text-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{note.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate notes by id, keeping first occurrence
|
||||||
|
function deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return items.filter(item => {
|
||||||
|
if (seen.has(item.id)) return false
|
||||||
|
seen.add(item.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteConnections({
|
||||||
|
noteId,
|
||||||
|
backlinks,
|
||||||
|
outgoingLinks,
|
||||||
|
relatedNotes,
|
||||||
|
coUsedNotes,
|
||||||
|
}: NoteConnectionsProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||||
|
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
|
||||||
|
const [navigationHistory, setNavigationHistory] = useState<NavigationEntry[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/notes/${noteId}/versions`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setRecentVersions(d.data?.slice(0, 3) || []))
|
||||||
|
.catch(() => setRecentVersions([]))
|
||||||
|
}, [noteId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavigationHistory(getNavigationHistory())
|
||||||
|
}, [noteId])
|
||||||
|
|
||||||
|
const hasAnyConnections =
|
||||||
|
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
|
||||||
|
|
||||||
|
// Deduplicate all lists to prevent React key warnings
|
||||||
|
const uniqueBacklinks = deduplicateById(backlinks.map((bl) => ({ id: bl.sourceNote.id, title: bl.sourceNote.title, type: bl.sourceNote.type })))
|
||||||
|
const uniqueOutgoing = deduplicateById(outgoingLinks.map((ol) => ({ id: ol.sourceNote.id, title: ol.sourceNote.title, type: ol.sourceNote.type })))
|
||||||
|
const uniqueRelated = deduplicateById(relatedNotes.map((rn) => ({ id: rn.id, title: rn.title, type: rn.type })))
|
||||||
|
const uniqueCoUsed = deduplicateById(coUsedNotes.map((cu) => ({ id: cu.noteId, title: cu.title, type: cu.type })))
|
||||||
|
const uniqueHistory = deduplicateById(navigationHistory.slice(0, 5).map((entry) => ({ id: entry.noteId, title: entry.title, type: entry.type })))
|
||||||
|
|
||||||
|
const toggleCollapsed = (key: string) => {
|
||||||
|
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAnyConnections && recentVersions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Link2 className="h-5 w-5" />
|
||||||
|
Conectado con
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Backlinks - notes that link TO this note */}
|
||||||
|
<ConnectionGroup
|
||||||
|
title="Enlaces entrantes"
|
||||||
|
icon={ExternalLink}
|
||||||
|
notes={uniqueBacklinks}
|
||||||
|
emptyMessage="Ningún otro documento enlaza a esta nota"
|
||||||
|
isCollapsed={collapsed['backlinks']}
|
||||||
|
onToggle={() => toggleCollapsed('backlinks')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Outgoing links - notes this note links TO */}
|
||||||
|
<ConnectionGroup
|
||||||
|
title="Enlaces salientes"
|
||||||
|
icon={ArrowRight}
|
||||||
|
notes={uniqueOutgoing}
|
||||||
|
emptyMessage="Esta nota no enlaza a ningún otro documento"
|
||||||
|
isCollapsed={collapsed['outgoing']}
|
||||||
|
onToggle={() => toggleCollapsed('outgoing')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Related notes - by content similarity and scoring */}
|
||||||
|
<ConnectionGroup
|
||||||
|
title="Relacionadas"
|
||||||
|
icon={RefreshCw}
|
||||||
|
notes={uniqueRelated}
|
||||||
|
emptyMessage="No hay notas relacionadas"
|
||||||
|
isCollapsed={collapsed['related']}
|
||||||
|
onToggle={() => toggleCollapsed('related')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Co-used notes - often viewed together */}
|
||||||
|
<ConnectionGroup
|
||||||
|
title="Co-usadas"
|
||||||
|
icon={Users}
|
||||||
|
notes={uniqueCoUsed}
|
||||||
|
emptyMessage="No hay notas co-usadas"
|
||||||
|
isCollapsed={collapsed['coused']}
|
||||||
|
onToggle={() => toggleCollapsed('coused')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Recent versions */}
|
||||||
|
{recentVersions.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2 border-t">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
Versiones recientes
|
||||||
|
</h4>
|
||||||
|
<div className="pl-6 space-y-1">
|
||||||
|
{recentVersions.map((v) => (
|
||||||
|
<p key={v.id} className="text-xs text-muted-foreground">
|
||||||
|
v{v.version} - {new Date(v.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation history */}
|
||||||
|
{uniqueHistory.length > 0 && (
|
||||||
|
<ConnectionGroup
|
||||||
|
title="Vista recientemente"
|
||||||
|
icon={Clock}
|
||||||
|
notes={uniqueHistory}
|
||||||
|
emptyMessage="No hay historial de navegación"
|
||||||
|
isCollapsed={collapsed['history']}
|
||||||
|
onToggle={() => toggleCollapsed('history')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Note, NoteType, Tag } from '@/types/note'
|
import { Note, NoteType, Tag } from '@/types/note'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -8,7 +8,11 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { X } from 'lucide-react'
|
import { X, Sparkles } from 'lucide-react'
|
||||||
|
import { inferNoteType } from '@/lib/type-inference'
|
||||||
|
import { useUnsavedChanges } from '@/hooks/use-unsaved-changes'
|
||||||
|
import { saveDraft, loadDraft, deleteDraft } from '@/lib/drafts'
|
||||||
|
import { DraftRecoveryBanner } from '@/components/draft-recovery-banner'
|
||||||
|
|
||||||
// Command fields
|
// Command fields
|
||||||
interface CommandFields {
|
interface CommandFields {
|
||||||
@@ -614,22 +618,206 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
return defaultFields[type]
|
return defaultFields[type]
|
||||||
})
|
})
|
||||||
const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || [])
|
const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || [])
|
||||||
|
const [autoSuggestedTags, setAutoSuggestedTags] = useState<string[]>([])
|
||||||
|
const [autoSuggestedType, setAutoSuggestedType] = useState<NoteType | null>(null)
|
||||||
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
||||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
const [showDraftBanner, setShowDraftBanner] = useState(false)
|
||||||
|
const [draftLoaded, setDraftLoaded] = useState(false)
|
||||||
|
const draftLoadedRef = useRef(false)
|
||||||
|
|
||||||
|
// Store initial values for dirty tracking
|
||||||
|
const initialValuesRef = useRef({
|
||||||
|
title: initialData?.title || '',
|
||||||
|
type: initialData?.type || 'note',
|
||||||
|
fields: initialData?.content
|
||||||
|
? parseMarkdownToFields(initialData.type, initialData.content)
|
||||||
|
: defaultFields[initialData?.type || 'note'],
|
||||||
|
tags: initialData?.tags.map(t => t.tag.name) || [],
|
||||||
|
isFavorite: initialData?.isFavorite || false,
|
||||||
|
isPinned: initialData?.isPinned || false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track dirty state
|
||||||
|
useEffect(() => {
|
||||||
|
const hasChanges =
|
||||||
|
title !== initialValuesRef.current.title ||
|
||||||
|
type !== initialValuesRef.current.type ||
|
||||||
|
JSON.stringify(fields) !== JSON.stringify(initialValuesRef.current.fields) ||
|
||||||
|
JSON.stringify(tags) !== JSON.stringify(initialValuesRef.current.tags) ||
|
||||||
|
isFavorite !== initialValuesRef.current.isFavorite ||
|
||||||
|
isPinned !== initialValuesRef.current.isPinned
|
||||||
|
|
||||||
|
setIsDirty(hasChanges)
|
||||||
|
}, [title, type, fields, tags, isFavorite, isPinned])
|
||||||
|
|
||||||
|
useUnsavedChanges(isDirty)
|
||||||
|
|
||||||
|
// Serialized content for drafts and auto-suggest
|
||||||
|
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
|
||||||
|
|
||||||
|
// Load draft on mount (only for new notes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit || draftLoadedRef.current) return
|
||||||
|
const draftKey = 'new'
|
||||||
|
const draft = loadDraft(draftKey)
|
||||||
|
if (draft) {
|
||||||
|
draftLoadedRef.current = true
|
||||||
|
setDraftLoaded(true)
|
||||||
|
setShowDraftBanner(true)
|
||||||
|
}
|
||||||
|
}, [isEdit])
|
||||||
|
|
||||||
|
// Debounced save draft on changes (only for new notes, after initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit || !draftLoaded || draftLoadedRef.current) return
|
||||||
|
const draftKey = 'new'
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
saveDraft(draftKey, { title, content, type, tags, savedAt: Date.now() })
|
||||||
|
}, 1000)
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [title, content, type, tags, isEdit, draftLoaded])
|
||||||
|
|
||||||
const handleTypeChange = (newType: NoteType) => {
|
const handleTypeChange = (newType: NoteType) => {
|
||||||
setType(newType)
|
setType(newType)
|
||||||
setFields(defaultFields[newType])
|
setFields(defaultFields[newType])
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
|
// Auto-suggest tags based on title and content
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
if (title.trim().length < 3 && content.trim().length < 10) {
|
||||||
|
setAutoSuggestedTags([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/tags/suggest?title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}`
|
||||||
|
)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const suggested: string[] = data.data || data || []
|
||||||
|
// Filter out tags already added
|
||||||
|
setAutoSuggestedTags(suggested.filter((t: string) => !tags.includes(t)))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tag suggestions:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(fetchSuggestions, 500)
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [title, content, tags])
|
||||||
|
|
||||||
|
// Auto-suggest type based on content (only for new notes, not edits)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit || !content.trim()) {
|
||||||
|
setAutoSuggestedType(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only suggest if content is reasonably filled and user hasn't changed type manually
|
||||||
|
const suggestion = inferNoteType(content)
|
||||||
|
if (suggestion && suggestion.confidence === 'high') {
|
||||||
|
setAutoSuggestedType(suggestion.type)
|
||||||
|
} else {
|
||||||
|
setAutoSuggestedType(null)
|
||||||
|
}
|
||||||
|
}, [content, isEdit])
|
||||||
|
|
||||||
|
const acceptSuggestedType = () => {
|
||||||
|
if (autoSuggestedType) {
|
||||||
|
setType(autoSuggestedType)
|
||||||
|
setFields(defaultFields[autoSuggestedType])
|
||||||
|
setAutoSuggestedType(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link suggestions state
|
||||||
|
const [linkSuggestions, setLinkSuggestions] = useState<{ term: string; noteId: string; noteTitle: string }[]>([])
|
||||||
|
|
||||||
|
// Fetch link suggestions based on content
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLinkSuggestions = async () => {
|
||||||
|
if (content.trim().length < 20) {
|
||||||
|
setLinkSuggestions([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ content })
|
||||||
|
if (initialData?.id) {
|
||||||
|
params.set('noteId', initialData.id)
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/notes/links?${params}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setLinkSuggestions(data.data || [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching link suggestions:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(fetchLinkSuggestions, 800)
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [content, initialData?.id])
|
||||||
|
|
||||||
|
const convertToWikiLink = (term: string) => {
|
||||||
|
// Replace the term with [[term]] in the content
|
||||||
|
// This requires modifying fields directly based on the type
|
||||||
|
const newContent = content.replace(new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi'), `[[${term}]]`)
|
||||||
|
// Update the appropriate field based on type
|
||||||
|
if (type === 'note') {
|
||||||
|
setFields({ content: newContent })
|
||||||
|
} else if (type === 'command') {
|
||||||
|
const f = fields as CommandFields
|
||||||
|
setFields({ ...f, example: newContent })
|
||||||
|
} else if (type === 'snippet') {
|
||||||
|
const f = fields as SnippetFields
|
||||||
|
setFields({ ...f, code: newContent })
|
||||||
|
} else {
|
||||||
|
setFields({ content: newContent } as TypeFields)
|
||||||
|
}
|
||||||
|
// Remove from suggestions
|
||||||
|
setLinkSuggestions(prev => prev.filter(s => s.term !== term))
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreDraft = useCallback(() => {
|
||||||
|
const draftKey = 'new'
|
||||||
|
const draft = loadDraft(draftKey)
|
||||||
|
if (draft) {
|
||||||
|
setTitle(draft.title)
|
||||||
|
setType(draft.type as NoteType)
|
||||||
|
setFields(parseMarkdownToFields(draft.type as NoteType, draft.content))
|
||||||
|
setTags(draft.tags)
|
||||||
|
setShowDraftBanner(false)
|
||||||
|
draftLoadedRef.current = true
|
||||||
|
setDraftLoaded(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDiscardDraft = useCallback(() => {
|
||||||
|
const draftKey = 'new'
|
||||||
|
deleteDraft(draftKey)
|
||||||
|
setShowDraftBanner(false)
|
||||||
|
draftLoadedRef.current = true
|
||||||
|
setDraftLoaded(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const noteData = {
|
// Build payload, explicitly excluding id and any undefined values
|
||||||
|
const noteData: Record<string, unknown> = {
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
type,
|
type,
|
||||||
@@ -638,6 +826,13 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove undefined values before sending
|
||||||
|
Object.keys(noteData).forEach(key => {
|
||||||
|
if (noteData[key] === undefined) {
|
||||||
|
delete noteData[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = isEdit && initialData ? `/api/notes/${initialData.id}` : '/api/notes'
|
const url = isEdit && initialData ? `/api/notes/${initialData.id}` : '/api/notes'
|
||||||
const method = isEdit ? 'PUT' : 'POST'
|
const method = isEdit ? 'PUT' : 'POST'
|
||||||
@@ -649,6 +844,10 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
setIsDirty(false)
|
||||||
|
if (!isEdit) {
|
||||||
|
deleteDraft('new')
|
||||||
|
}
|
||||||
router.push('/notes')
|
router.push('/notes')
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
@@ -681,6 +880,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||||
|
{showDraftBanner && (
|
||||||
|
<DraftRecoveryBanner onRestore={handleRestoreDraft} onDiscard={handleDiscardDraft} />
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Título</label>
|
<label className="block text-sm font-medium mb-1">Título</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -705,6 +907,21 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{autoSuggestedType && autoSuggestedType !== type && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||||
|
<Sparkles className="h-3 w-3 text-primary" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
¿Es <span className="text-primary font-medium">{autoSuggestedType}</span>?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={acceptSuggestedType}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Usar tipo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -717,6 +934,54 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
<TagInput value={tags} onChange={setTags} />
|
<TagInput value={tags} onChange={setTags} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{autoSuggestedTags.length > 0 && (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Sugerencias basadas en tu contenido:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{autoSuggestedTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!tags.includes(tag)) {
|
||||||
|
setTags([...tags, tag])
|
||||||
|
}
|
||||||
|
setAutoSuggestedTags(autoSuggestedTags.filter(t => t !== tag))
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-background border rounded-full hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
<span className="text-xs opacity-60">+</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link suggestions */}
|
||||||
|
{linkSuggestions.length > 0 && (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
Enlaces internos detectados:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{linkSuggestions.slice(0, 5).map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.term}
|
||||||
|
type="button"
|
||||||
|
onClick={() => convertToWikiLink(suggestion.term)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-background border rounded-full hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
title={`Convertir "${suggestion.term}" a [[${suggestion.term}]]`}
|
||||||
|
>
|
||||||
|
<span>{suggestion.term}</span>
|
||||||
|
<span className="text-xs opacity-60">[[]]</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { FeatureFlags, getFeatureFlags, setFeatureFlags } from '@/lib/preferences'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { BookmarkletInstructions } from '@/components/bookmarklet-instructions'
|
||||||
|
|
||||||
|
export function PreferencesPanel() {
|
||||||
|
const [flags, setFlags] = useState<FeatureFlags>({
|
||||||
|
backupEnabled: true,
|
||||||
|
backupRetention: 30,
|
||||||
|
workModeEnabled: true,
|
||||||
|
})
|
||||||
|
const [retentionInput, setRetentionInput] = useState('30')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFlags(getFeatureFlags())
|
||||||
|
setRetentionInput(getFeatureFlags().backupRetention.toString())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBackupEnabled = (enabled: boolean) => {
|
||||||
|
setFeatureFlags({ backupEnabled: enabled })
|
||||||
|
setFlags(getFeatureFlags())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWorkModeEnabled = (enabled: boolean) => {
|
||||||
|
setFeatureFlags({ workModeEnabled: enabled })
|
||||||
|
setFlags(getFeatureFlags())
|
||||||
|
// Dispatch custom event to notify other components (like Header)
|
||||||
|
window.dispatchEvent(new CustomEvent('preferences-updated'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetentionChange = (value: string) => {
|
||||||
|
setRetentionInput(value)
|
||||||
|
const days = parseInt(value, 10)
|
||||||
|
if (!isNaN(days) && days > 0) {
|
||||||
|
setFeatureFlags({ backupRetention: days })
|
||||||
|
setFlags(getFeatureFlags())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
Preferencias
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configura el comportamiento de la aplicación. Los cambios se guardan automáticamente.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-medium">Backup automático</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Crear backups automáticamente al cerrar o cambiar de nota
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={flags.backupEnabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBackupEnabled(!flags.backupEnabled)}
|
||||||
|
>
|
||||||
|
{flags.backupEnabled ? 'Activado' : 'Desactivado'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Retención de backups (días)</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Los backups automáticos se eliminarán después de este período
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
value={retentionInput}
|
||||||
|
onChange={(e) => handleRetentionChange(e.target.value)}
|
||||||
|
className="w-24 px-3 py-1 border rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-medium">Modo trabajo</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Habilitar toggle de modo trabajo en el header
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={flags.workModeEnabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleWorkModeEnabled(!flags.workModeEnabled)}
|
||||||
|
>
|
||||||
|
{flags.workModeEnabled ? 'Activado' : 'Desactivado'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<p className="text-sm font-medium mb-3">Integración externa</p>
|
||||||
|
<BookmarkletInstructions />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">Sprint MVP-5</Badge>
|
||||||
|
<Badge variant="outline">v0.1.0</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
+204
-25
@@ -1,19 +1,85 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Plus, Loader2 } from 'lucide-react'
|
import { Plus, Loader2, Sparkles, X, Text, ChevronDown } from 'lucide-react'
|
||||||
|
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
|
||||||
|
import { NoteType } from '@/types/note'
|
||||||
|
|
||||||
|
interface TypeSuggestion {
|
||||||
|
type: NoteType
|
||||||
|
confidence: 'high' | 'medium' | 'low'
|
||||||
|
reason: string
|
||||||
|
formattedContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<NoteType, string> = {
|
||||||
|
command: 'Comando',
|
||||||
|
snippet: 'Snippet',
|
||||||
|
procedure: 'Procedimiento',
|
||||||
|
recipe: 'Receta',
|
||||||
|
decision: 'Decisión',
|
||||||
|
inventory: 'Inventario',
|
||||||
|
note: 'Nota',
|
||||||
|
}
|
||||||
|
|
||||||
export function QuickAdd() {
|
export function QuickAdd() {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [isMultiline, setIsMultiline] = useState(false)
|
||||||
|
const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null)
|
||||||
|
const [dismissedSuggestion, setDismissedSuggestion] = useState(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const detectContentType = useCallback((text: string) => {
|
||||||
|
if (!text.trim() || text.length < 10) {
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dismissedSuggestion) return
|
||||||
|
|
||||||
|
const suggestion = inferNoteType(text)
|
||||||
|
if (suggestion && suggestion.confidence === 'high') {
|
||||||
|
const formatted = formatContentForType(text, suggestion.type)
|
||||||
|
setTypeSuggestion({
|
||||||
|
...suggestion,
|
||||||
|
formattedContent: formatted,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
}
|
||||||
|
}, [dismissedSuggestion])
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
setDismissedSuggestion(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
detectContentType(value)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptSuggestion = () => {
|
||||||
|
if (typeSuggestion) {
|
||||||
|
setValue(typeSuggestion.formattedContent)
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
setIsMultiline(true)
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissSuggestion = () => {
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
setDismissedSuggestion(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
if (!value.trim() || isLoading) return
|
if (!value.trim() || isLoading) return
|
||||||
@@ -36,7 +102,8 @@ export function QuickAdd() {
|
|||||||
description: note.title,
|
description: note.title,
|
||||||
})
|
})
|
||||||
setValue('')
|
setValue('')
|
||||||
setIsExpanded(false)
|
setIsOpen(false)
|
||||||
|
setIsMultiline(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Error', {
|
toast.error('Error', {
|
||||||
@@ -48,31 +115,55 @@ export function QuickAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey && !isMultiline) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setValue('')
|
setValue('')
|
||||||
setIsExpanded(false)
|
setIsOpen(false)
|
||||||
|
setIsMultiline(false)
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
|
textareaRef.current?.blur()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMultiline = () => {
|
||||||
|
setIsMultiline(!isMultiline)
|
||||||
|
if (!isMultiline) {
|
||||||
|
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close popup when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
// Focus on keyboard shortcut
|
// Focus on keyboard shortcut
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
// Ctrl+N or Cmd+N to focus quick add
|
|
||||||
if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
|
if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
inputRef.current?.select()
|
inputRef.current?.select()
|
||||||
setIsExpanded(true)
|
setIsOpen(true)
|
||||||
}
|
}
|
||||||
// Escape to blur
|
|
||||||
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
setIsExpanded(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||||
@@ -80,42 +171,130 @@ export function QuickAdd() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
<div className="relative" ref={popupRef}>
|
||||||
|
{/* Compact input row */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="cmd: título #tag..."
|
placeholder="cmd: título..."
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value)
|
||||||
|
detectContentType(e.target.value)
|
||||||
|
if (e.target.value) setIsOpen(true)
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setIsExpanded(true)}
|
onFocus={handleInputFocus}
|
||||||
className={cn(
|
onPaste={handlePaste}
|
||||||
'w-48 transition-all duration-200',
|
className="w-full sm:w-80 h-9 pr-16"
|
||||||
isExpanded && 'w-72'
|
|
||||||
)}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
{/* Action buttons inside input */}
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMultiline}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded hover:bg-accent transition-colors',
|
||||||
|
isMultiline && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
|
||||||
|
>
|
||||||
|
{isMultiline ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Text className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!value.trim() || isLoading}
|
disabled={!value.trim() || isLoading}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
|
'p-1 rounded hover:bg-accent transition-colors',
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
'disabled:pointer-events-none disabled:opacity-30'
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
'transition-colors'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Expanded popup */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-popover border rounded-lg shadow-lg z-50">
|
||||||
|
{/* Multiline textarea (shown when multiline mode) */}
|
||||||
|
{isMultiline && (
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder="Contenido multilínea..."
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value)
|
||||||
|
detectContentType(e.target.value)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className="min-h-[100px] max-h-[200px] resize-none w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Smart paste suggestion */}
|
||||||
|
{typeSuggestion && (
|
||||||
|
<div className="mt-2 p-2 bg-muted/50 rounded-lg border">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Detectado: <span className="text-primary">{TYPE_LABELS[typeSuggestion.type]}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{typeSuggestion.reason}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismissSuggestion}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={acceptSuggestion}
|
||||||
|
className="text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Usar plantilla
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismissSuggestion}
|
||||||
|
className="text-xs px-2 py-1 text-muted-foreground hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
{!value && !typeSuggestion && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Usa prefijos como <span className="font-mono bg-muted px-1 rounded">cmd:</span>, <span className="font-mono bg-muted px-1 rounded">snip:</span> para тип notes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { NavigationEntry, getNavigationHistory } from '@/lib/navigation-history'
|
||||||
|
import { Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
export function RecentContextList() {
|
||||||
|
const [history, setHistory] = useState<NavigationEntry[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHistory(getNavigationHistory())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Vista recientemente</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.slice(0, 5).map((entry) => (
|
||||||
|
<Link
|
||||||
|
key={entry.noteId}
|
||||||
|
href={`/notes/${entry.noteId}`}
|
||||||
|
className="block px-2 py-1.5 text-sm rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<div className="truncate">{entry.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{entry.type} · {formatRelativeTime(entry.visitedAt)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000)
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'ahora'
|
||||||
|
if (diffMins < 60) return `hace ${diffMins}m`
|
||||||
|
if (diffHours < 24) return `hace ${diffHours}h`
|
||||||
|
if (diffDays < 7) return `hace ${diffDays}d`
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export function RelatedNotes({ notes }: { notes: RelatedNote[] }) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Notas relacionadas</CardTitle>
|
<CardTitle className="text-lg">También podrías necesitar</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1,34 +1,199 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Search } from 'lucide-react'
|
import { Search, Loader2 } from 'lucide-react'
|
||||||
|
import { ScoredNote } from '@/lib/search'
|
||||||
|
|
||||||
|
// Simple in-memory cache for search results
|
||||||
|
const searchCache = new Map<string, ScoredNote[]>()
|
||||||
|
const MAX_CACHE_SIZE = 50
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<ScoredNote[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([])
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
// Check cache first
|
||||||
|
if (searchCache.has(query)) {
|
||||||
|
setResults(searchCache.get(query)!)
|
||||||
|
setIsOpen(true)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.success) {
|
||||||
|
const results = json.data.slice(0, 8)
|
||||||
|
setResults(results)
|
||||||
|
setIsOpen(true)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
if (searchCache.size >= MAX_CACHE_SIZE) {
|
||||||
|
// Remove oldest entry (first in Map)
|
||||||
|
const firstKey = searchCache.keys().next().value
|
||||||
|
if (firstKey) searchCache.delete(firstKey)
|
||||||
|
}
|
||||||
|
searchCache.set(query, results)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
inputRef.current &&
|
||||||
|
!inputRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
router.push(`/notes?q=${encodeURIComponent(query)}`)
|
router.push(`/notes?q=${encodeURIComponent(query)}`)
|
||||||
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || results.length === 0) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev))
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||||
|
router.push(`/notes/${results[selectedIndex].id}`)
|
||||||
|
setIsOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
} else {
|
||||||
|
handleSearch(e)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResultClick = (note: ScoredNote) => {
|
||||||
|
router.push(`/notes/${note.id}`)
|
||||||
|
setIsOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar notas..."
|
placeholder="Buscar notas..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
className="flex-1 min-w-0"
|
className="flex-1 min-w-0"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="secondary" size="icon">
|
<Button type="submit" variant="secondary" size="icon" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{isOpen && results.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{results.map((note, index) => (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => handleResultClick(note)}
|
||||||
|
className={`w-full px-3 py-2 text-left flex items-center justify-between gap-2 transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{note.title}</div>
|
||||||
|
{note.highlight && (
|
||||||
|
<div
|
||||||
|
className="text-sm text-muted-foreground truncate"
|
||||||
|
dangerouslySetInnerHTML={{ __html: note.highlight }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
note.type === 'document'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
|
: note.type === 'task'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{note.type}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useGlobalShortcuts } from '@/hooks/use-global-shortcuts'
|
||||||
|
import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog'
|
||||||
|
|
||||||
|
export function ShortcutsProvider() {
|
||||||
|
const { showHelp, setShowHelp } = useGlobalShortcuts()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardShortcutsDialog open={showHelp} onOpenChange={setShowHelp} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { addToNavigationHistory } from '@/lib/navigation-history'
|
||||||
|
|
||||||
|
interface TrackNavigationHistoryProps {
|
||||||
|
noteId: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackNavigationHistory({ noteId, title, type }: TrackNavigationHistoryProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
addToNavigationHistory({ noteId, title, type })
|
||||||
|
}, [noteId, title, type])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { addToRecentlyViewed } from '@/lib/usage'
|
import { addToRecentlyViewed, getRecentlyViewedIds } from '@/lib/usage'
|
||||||
|
|
||||||
export function TrackNoteView({ noteId }: { noteId: string }) {
|
export function TrackNoteView({ noteId }: { noteId: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
addToRecentlyViewed(noteId)
|
addToRecentlyViewed(noteId)
|
||||||
|
|
||||||
|
// Track co-usage with previously viewed notes
|
||||||
|
const recentIds = getRecentlyViewedIds()
|
||||||
|
// Track co-usage with up to 3 most recent notes (excluding current)
|
||||||
|
const previousNotes = recentIds.filter(id => id !== noteId).slice(0, 3)
|
||||||
|
|
||||||
|
for (const prevNoteId of previousNotes) {
|
||||||
|
fetch('/api/usage/co-usage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fromNoteId: prevNoteId, toNoteId: noteId }),
|
||||||
|
}).catch(() => {}) // Silently fail
|
||||||
|
}
|
||||||
}, [noteId])
|
}, [noteId])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { History, RotateCcw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionHistoryProps {
|
||||||
|
noteId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionHistory({ noteId }: VersionHistoryProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [versions, setVersions] = useState<Version[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [restoring, setRestoring] = useState<string | null>(null)
|
||||||
|
const [confirmRestore, setConfirmRestore] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchVersions = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/versions`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setVersions(data.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar las versiones')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
setOpen(newOpen)
|
||||||
|
if (newOpen && versions.length === 0) {
|
||||||
|
fetchVersions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (versionId: string) => {
|
||||||
|
setRestoring(versionId)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
toast.success('Versión restaurada correctamente')
|
||||||
|
setOpen(false)
|
||||||
|
window.location.reload()
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || 'Error al restaurar la versión')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al restaurar la versión')
|
||||||
|
} finally {
|
||||||
|
setRestoring(null)
|
||||||
|
setConfirmRestore(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<div onClick={() => handleOpenChange(true)}>
|
||||||
|
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||||
|
<History className="h-4 w-4 mr-1" /> Historial
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Historial de versiones</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Revisa y restaura versiones anteriores de esta nota
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">Cargando versiones...</div>
|
||||||
|
) : versions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">No hay versiones guardadas</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{versions.map((version) => (
|
||||||
|
<div
|
||||||
|
key={version.id}
|
||||||
|
className="flex items-start justify-between gap-4 p-3 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-sm mb-1">
|
||||||
|
{formatDate(version.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-sm truncate">
|
||||||
|
{version.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 text-xs mt-1 truncate">
|
||||||
|
{version.content.substring(0, 50)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmRestore === version.id ? (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<span className="text-xs text-gray-500">¿Restaurar?</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleRestore(version.id)}
|
||||||
|
disabled={restoring === version.id}
|
||||||
|
>
|
||||||
|
{restoring === version.id ? '...' : 'Sí'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setConfirmRestore(null)}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmRestore(version.id)}
|
||||||
|
title="Restaurar esta versión"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getWorkMode, setWorkMode } from '@/lib/work-mode'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Monitor, Eye } from 'lucide-react'
|
||||||
|
|
||||||
|
export function WorkModeToggle() {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEnabled(getWorkMode())
|
||||||
|
|
||||||
|
const handlePreferencesChange = () => {
|
||||||
|
// Re-read work mode state when preferences change
|
||||||
|
setEnabled(getWorkMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('preferences-updated', handlePreferencesChange)
|
||||||
|
return () => window.removeEventListener('preferences-updated', handlePreferencesChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const newValue = !enabled
|
||||||
|
setEnabled(newValue)
|
||||||
|
setWorkMode(newValue)
|
||||||
|
// Dispatch event so other components know work mode changed
|
||||||
|
window.dispatchEvent(new CustomEvent('work-mode-changed', { detail: { enabled: newValue } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" onClick={toggle} title="Modo trabajo">
|
||||||
|
{enabled ? <Eye className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
const SHORTCUTS: Record<string, string> = {
|
||||||
|
'g h': '/', // go to dashboard
|
||||||
|
'g n': '/notes', // go to notes
|
||||||
|
'n': '/new', // new note
|
||||||
|
'/': '/notes', // focus search (navigate to notes)
|
||||||
|
'?': 'show-help', // show shortcuts dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalShortcuts() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [showHelp, setShowHelp] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let keystroke = ''
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ignore if in input/textarea/contenteditable
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ? for help
|
||||||
|
if (e.key === '?' && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
setShowHelp(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track g + key combos
|
||||||
|
if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
keystroke = 'g'
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => { keystroke = '' }, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keystroke === 'g') {
|
||||||
|
const combo = 'g ' + e.key
|
||||||
|
if (SHORTCUTS[combo]) {
|
||||||
|
e.preventDefault()
|
||||||
|
router.push(SHORTCUTS[combo])
|
||||||
|
keystroke = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct shortcuts
|
||||||
|
if (e.key === 'n' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
router.push('/new')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return { showHelp, setShowHelp }
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useCallback, useState, useRef } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Note } from '@/types/note'
|
||||||
|
|
||||||
|
interface UseNoteListKeyboardOptions {
|
||||||
|
notes: Note[]
|
||||||
|
onEdit?: (noteId: string) => void
|
||||||
|
onFavorite?: (noteId: string) => void
|
||||||
|
onPin?: (noteId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNoteListKeyboard({
|
||||||
|
notes,
|
||||||
|
onEdit,
|
||||||
|
onFavorite,
|
||||||
|
onPin,
|
||||||
|
}: UseNoteListKeyboardOptions) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
|
const router = useRouter()
|
||||||
|
const prefetchedRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Prefetch a note's page data for faster navigation
|
||||||
|
const prefetchNote = useCallback((noteId: string) => {
|
||||||
|
if (prefetchedRef.current.has(noteId)) return
|
||||||
|
prefetchedRef.current.add(noteId)
|
||||||
|
router.prefetch(`/notes/${noteId}`)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
// Reset selection when notes change
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
prefetchedRef.current.clear()
|
||||||
|
}, [notes.length])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
// Ignore if in input/textarea/contenteditable
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => {
|
||||||
|
const next = prev < notes.length - 1 ? prev + 1 : notes.length - 1
|
||||||
|
if (notes[next]) prefetchNote(notes[next].id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => {
|
||||||
|
const next = prev > 0 ? prev - 1 : -1
|
||||||
|
if (next >= 0 && notes[next]) prefetchNote(notes[next].id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex >= 0 && notes[selectedIndex]) {
|
||||||
|
router.push(`/notes/${notes[selectedIndex].id}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'e':
|
||||||
|
case 'E':
|
||||||
|
if (selectedIndex >= 0 && notes[selectedIndex] && onEdit) {
|
||||||
|
e.preventDefault()
|
||||||
|
onEdit(notes[selectedIndex].id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
if (selectedIndex >= 0 && notes[selectedIndex] && onFavorite) {
|
||||||
|
e.preventDefault()
|
||||||
|
onFavorite(notes[selectedIndex].id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'p':
|
||||||
|
case 'P':
|
||||||
|
if (selectedIndex >= 0 && notes[selectedIndex] && onPin) {
|
||||||
|
e.preventDefault()
|
||||||
|
onPin(notes[selectedIndex].id)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notes, selectedIndex, router, onEdit, onFavorite, onPin, prefetchNote]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleKeyDown])
|
||||||
|
|
||||||
|
return { selectedIndex, setSelectedIndex, prefetchNote }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useUnsavedChanges(
|
||||||
|
isDirty: boolean,
|
||||||
|
message = '¿Salir sin guardar cambios?'
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty) return
|
||||||
|
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = message
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}, [isDirty, message])
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { getBackups } from '@/lib/backup-storage'
|
||||||
|
import { deleteBackup } from '@/lib/backup-storage'
|
||||||
|
import { BackupSource } from '@/types/backup'
|
||||||
|
|
||||||
|
const MAX_AUTOMATIC_BACKUPS = 10
|
||||||
|
const MAX_BACKUP_AGE_DAYS = 30
|
||||||
|
|
||||||
|
function daysAgo(date: Date): number {
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
return diffMs / (1000 * 60 * 60 * 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shouldCleanup(): Promise<boolean> {
|
||||||
|
const backups = await getBackups()
|
||||||
|
|
||||||
|
const automaticBackups = backups.filter((b) => b.source === 'automatic')
|
||||||
|
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldBackups = backups.filter(
|
||||||
|
(b) => daysAgo(new Date(b.createdAt)) > MAX_BACKUP_AGE_DAYS
|
||||||
|
)
|
||||||
|
if (oldBackups.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupOldBackups(): Promise<number> {
|
||||||
|
const backups = await getBackups()
|
||||||
|
let deletedCount = 0
|
||||||
|
|
||||||
|
// Remove automatic backups exceeding the limit
|
||||||
|
const automaticBackups = backups.filter((b) => b.source === 'automatic')
|
||||||
|
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
|
||||||
|
const toRemove = automaticBackups.slice(MAX_AUTOMATIC_BACKUPS)
|
||||||
|
for (const backup of toRemove) {
|
||||||
|
await deleteBackup(backup.id)
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove backups older than 30 days
|
||||||
|
const recentBackups = await getBackups()
|
||||||
|
for (const backup of recentBackups) {
|
||||||
|
if (daysAgo(new Date(backup.createdAt)) > MAX_BACKUP_AGE_DAYS) {
|
||||||
|
await deleteBackup(backup.id)
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { RecallBackup } from '@/types/backup'
|
||||||
|
|
||||||
|
const DB_NAME = 'recall_backups'
|
||||||
|
const STORE_NAME = 'backups'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBackup(backup: RecallBackup): Promise<void> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const request = store.put(backup)
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
tx.oncomplete = () => db.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBackups(): Promise<RecallBackup[]> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const request = store.getAll()
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const backups = (request.result as RecallBackup[]).sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)
|
||||||
|
resolve(backups)
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => db.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBackup(id: string): Promise<RecallBackup | null> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const request = store.get(id)
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => resolve(request.result || null)
|
||||||
|
tx.oncomplete = () => db.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackup(id: string): Promise<void> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const request = store.delete(id)
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
tx.oncomplete = () => db.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { RecallBackup } from '@/types/backup'
|
||||||
|
|
||||||
|
// Limits
|
||||||
|
const MAX_BACKUP_SIZE_BYTES = 50 * 1024 * 1024 // 50MB
|
||||||
|
const MAX_NOTE_COUNT = 10000
|
||||||
|
|
||||||
|
interface ValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
errors: string[]
|
||||||
|
warnings?: string[]
|
||||||
|
info?: {
|
||||||
|
noteCount: number
|
||||||
|
tagCount: number
|
||||||
|
createdAt: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBackup(data: unknown): ValidationResult {
|
||||||
|
const errors: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
if (typeof data !== 'object' || data === null) {
|
||||||
|
return { valid: false, errors: ['Backup must be an object'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = data as Record<string, unknown>
|
||||||
|
|
||||||
|
if (!validateSchemaVersion(backup as unknown as RecallBackup)) {
|
||||||
|
errors.push('Missing or invalid schemaVersion (expected "1.0")')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backup.createdAt || typeof backup.createdAt !== 'string') {
|
||||||
|
errors.push('Missing or invalid createdAt field')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backup.source || typeof backup.source !== 'string') {
|
||||||
|
errors.push('Missing or invalid source field')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backup.metadata || typeof backup.metadata !== 'object') {
|
||||||
|
errors.push('Missing or invalid metadata field')
|
||||||
|
} else {
|
||||||
|
const metadata = backup.metadata as Record<string, unknown>
|
||||||
|
if (typeof metadata.noteCount !== 'number') {
|
||||||
|
errors.push('Missing or invalid metadata.noteCount')
|
||||||
|
}
|
||||||
|
if (typeof metadata.tagCount !== 'number') {
|
||||||
|
errors.push('Missing or invalid metadata.tagCount')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backup.data || typeof backup.data !== 'object') {
|
||||||
|
errors.push('Missing or invalid data field')
|
||||||
|
} else {
|
||||||
|
const data = backup.data as Record<string, unknown>
|
||||||
|
if (!Array.isArray(data.notes)) {
|
||||||
|
errors.push('Missing or invalid data.notes (expected array)')
|
||||||
|
} else {
|
||||||
|
if (data.notes.length > MAX_NOTE_COUNT) {
|
||||||
|
errors.push(`Too many notes: ${data.notes.length} (max: ${MAX_NOTE_COUNT})`)
|
||||||
|
}
|
||||||
|
if (data.notes.length > 1000) {
|
||||||
|
warnings.push(`Large backup with ${data.notes.length} notes - restore may take time`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { valid: false, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = backup.metadata as { noteCount: number; tagCount: number }
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
|
info: {
|
||||||
|
noteCount: metadata.noteCount,
|
||||||
|
tagCount: metadata.tagCount,
|
||||||
|
createdAt: backup.createdAt as string,
|
||||||
|
source: backup.source as string,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBackupSize(sizeBytes: number): { valid: boolean; error?: string } {
|
||||||
|
if (sizeBytes > MAX_BACKUP_SIZE_BYTES) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Backup too large: ${(sizeBytes / 1024 / 1024).toFixed(2)}MB (max: ${MAX_BACKUP_SIZE_BYTES / 1024 / 1024}MB)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSchemaVersion(backup: RecallBackup): boolean {
|
||||||
|
return backup.schemaVersion === '1.0'
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { BackupSource, RecallBackup } from '@/types/backup'
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = '1.0'
|
||||||
|
|
||||||
|
export async function createBackupSnapshot(source: BackupSource): Promise<RecallBackup> {
|
||||||
|
const [notes, tags, backlinks, noteVersions] = await Promise.all([
|
||||||
|
prisma.note.findMany({
|
||||||
|
include: { tags: { include: { tag: true } } },
|
||||||
|
}),
|
||||||
|
prisma.tag.findMany(),
|
||||||
|
prisma.backlink.findMany(),
|
||||||
|
prisma.noteVersion.findMany(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const exportNotes = notes.map((note) => ({
|
||||||
|
...note,
|
||||||
|
tags: note.tags.map((nt) => nt.tag.name),
|
||||||
|
createdAt: note.createdAt.toISOString(),
|
||||||
|
updatedAt: note.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const exportTags = tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const exportBacklinks = backlinks.map((bl) => ({
|
||||||
|
id: bl.id,
|
||||||
|
sourceNoteId: bl.sourceNoteId,
|
||||||
|
targetNoteId: bl.targetNoteId,
|
||||||
|
createdAt: bl.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const exportVersions = noteVersions.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
noteId: v.noteId,
|
||||||
|
title: v.title,
|
||||||
|
content: v.content,
|
||||||
|
createdAt: v.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const backup: RecallBackup = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
source,
|
||||||
|
metadata: {
|
||||||
|
noteCount: notes.length,
|
||||||
|
tagCount: tags.length,
|
||||||
|
versionCount: noteVersions.length,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
notes: exportNotes,
|
||||||
|
tags: exportTags,
|
||||||
|
backlinks: exportBacklinks,
|
||||||
|
noteVersions: exportVersions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return backup
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface CentralNote {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
centralityScore: number
|
||||||
|
backlinks: number
|
||||||
|
outboundLinks: number
|
||||||
|
usageViews: number
|
||||||
|
coUsageWeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate centrality score for all notes.
|
||||||
|
* A note is "central" if it has many connections (backlinks/outbound) and high usage.
|
||||||
|
*/
|
||||||
|
export async function getCentralNotes(limit = 10): Promise<CentralNote[]> {
|
||||||
|
try {
|
||||||
|
// Get all notes with their counts
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
backlinks: { select: { id: true } },
|
||||||
|
outbound: { select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get usage stats for all notes
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
const usageStats = await prisma.noteUsage.groupBy({
|
||||||
|
by: ['noteId'],
|
||||||
|
where: {
|
||||||
|
eventType: 'view',
|
||||||
|
createdAt: { gte: thirtyDaysAgo },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
const usageMap = new Map(usageStats.map((u) => [u.noteId, u._count.id]))
|
||||||
|
|
||||||
|
// Get co-usage stats
|
||||||
|
const coUsageStats = await prisma.noteCoUsage.groupBy({
|
||||||
|
by: ['fromNoteId', 'toNoteId'],
|
||||||
|
_sum: { weight: true },
|
||||||
|
})
|
||||||
|
const coUsageMap = new Map<string, number>()
|
||||||
|
for (const cu of coUsageStats) {
|
||||||
|
coUsageMap.set(cu.fromNoteId, (coUsageMap.get(cu.fromNoteId) || 0) + (cu._sum.weight || 0))
|
||||||
|
coUsageMap.set(cu.toNoteId, (coUsageMap.get(cu.toNoteId) || 0) + (cu._sum.weight || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate centrality score for each note
|
||||||
|
const scoredNotes: CentralNote[] = notes.map((note) => {
|
||||||
|
const backlinks = note.backlinks.length
|
||||||
|
const outboundLinks = note.outbound.length
|
||||||
|
const usageViews = usageMap.get(note.id) || 0
|
||||||
|
const coUsageWeight = coUsageMap.get(note.id) || 0
|
||||||
|
|
||||||
|
// Centrality formula:
|
||||||
|
// - Each backlink = 3 points (incoming connections show importance)
|
||||||
|
// - Each outbound link = 1 point (shows knowledge breadth)
|
||||||
|
// - Each usage view = 0.5 points (shows relevance)
|
||||||
|
// - Each co-usage weight = 2 points (shows related usage patterns)
|
||||||
|
const centralityScore =
|
||||||
|
backlinks * 3 + outboundLinks * 1 + usageViews * 0.5 + coUsageWeight * 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
type: note.type,
|
||||||
|
centralityScore,
|
||||||
|
backlinks,
|
||||||
|
outboundLinks,
|
||||||
|
usageViews,
|
||||||
|
coUsageWeight,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by centrality score descending
|
||||||
|
scoredNotes.sort((a, b) => b.centralityScore - a.centralityScore)
|
||||||
|
|
||||||
|
return scoredNotes.slice(0, limit)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calculating centrality:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface CommandItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
group: 'navigation' | 'actions' | 'search' | 'recent'
|
||||||
|
keywords?: string[]
|
||||||
|
action?: () => void
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commands: CommandItem[] = [
|
||||||
|
{ id: 'nav-dashboard', label: 'Ir al Dashboard', group: 'navigation', keywords: ['home'] },
|
||||||
|
{ id: 'nav-notes', label: 'Ir a Notas', group: 'navigation', keywords: ['all notes'] },
|
||||||
|
{ id: 'nav-settings', label: 'Ir a Configuración', group: 'navigation', keywords: ['settings'] },
|
||||||
|
{ id: 'action-new', label: 'Crear nueva nota', group: 'actions', keywords: ['new note', 'create'] },
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const DRAFT_KEY_PREFIX = 'recall_draft_'
|
||||||
|
const DRAFT_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
type: string
|
||||||
|
tags: string[]
|
||||||
|
savedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDraft(noteId: string, data: Draft): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.setItem(DRAFT_KEY_PREFIX + noteId, JSON.stringify({ ...data, savedAt: Date.now() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDraft(noteId: string): Draft | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const stored = localStorage.getItem(DRAFT_KEY_PREFIX + noteId)
|
||||||
|
if (!stored) return null
|
||||||
|
try {
|
||||||
|
const draft = JSON.parse(stored) as Draft
|
||||||
|
if (Date.now() - draft.savedAt > DRAFT_TTL) {
|
||||||
|
deleteDraft(noteId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return draft
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDraft(noteId: string): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.removeItem(DRAFT_KEY_PREFIX + noteId)
|
||||||
|
}
|
||||||
@@ -56,6 +56,18 @@ export class ConflictError extends AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PayloadTooLargeError extends AppError {
|
||||||
|
constructor(message: string = 'Payload too large') {
|
||||||
|
super('PAYLOAD_TOO_LARGE', message, 413)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimitError extends AppError {
|
||||||
|
constructor(message: string = 'Too many requests') {
|
||||||
|
super('RATE_LIMITED', message, 429)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function formatZodError(error: ZodError): ApiError {
|
export function formatZodError(error: ZodError): ApiError {
|
||||||
return {
|
return {
|
||||||
code: 'VALIDATION_ERROR',
|
code: 'VALIDATION_ERROR',
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
interface NoteWithTags {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
type: string
|
||||||
|
isFavorite: boolean
|
||||||
|
isPinned: boolean
|
||||||
|
creationSource: string
|
||||||
|
tags: { tag: { id: string; name: string } }[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleMarkdownToHtml(content: string): string {
|
||||||
|
// Convert markdown to basic HTML
|
||||||
|
return content
|
||||||
|
// Headers
|
||||||
|
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
|
||||||
|
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
|
||||||
|
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
|
||||||
|
// Bold and italic
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||||
|
// Code blocks
|
||||||
|
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||||
|
// Inline code
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
// Lists
|
||||||
|
.replace(/^\s*-\s+(.*$)/gm, '<li>$1</li>')
|
||||||
|
// Links
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||||
|
// Paragraphs
|
||||||
|
.replace(/\n\n/g, '</p><p>')
|
||||||
|
// Line breaks
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_TEMPLATE = `<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
h1 { border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||||||
|
.meta { color: #666; font-size: 0.9em; margin-bottom: 20px; }
|
||||||
|
.tags { margin: 10px 0; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
pre code { background: none; padding: 0; }
|
||||||
|
li { margin-left: 20px; }
|
||||||
|
a { color: #0066cc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article>
|
||||||
|
<h1>{{TITLE}}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="type">{{TYPE}}</span>
|
||||||
|
<span class="date"> · Creado: {{CREATED}} · Actualizado: {{UPDATED}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tags">{{TAGS}}</div>
|
||||||
|
<div class="content">{{CONTENT}}</div>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
export function noteToHtml(note: NoteWithTags): string {
|
||||||
|
const htmlContent = simpleMarkdownToHtml(escapeHtml(note.content))
|
||||||
|
const tagsHtml = note.tags
|
||||||
|
.map(({ tag }) => `<span class="tag">${escapeHtml(tag.name)}</span>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return HTML_TEMPLATE
|
||||||
|
.replace('{{TITLE}}', escapeHtml(note.title))
|
||||||
|
.replace('{{TYPE}}', escapeHtml(note.type))
|
||||||
|
.replace('{{CREATED}}', new Date(note.createdAt).toLocaleDateString())
|
||||||
|
.replace('{{UPDATED}}', new Date(note.updatedAt).toLocaleDateString())
|
||||||
|
.replace('{{TAGS}}', tagsHtml)
|
||||||
|
.replace('{{CONTENT}}', htmlContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateHtmlFilename(note: NoteWithTags): string {
|
||||||
|
const sanitized = note.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 100)
|
||||||
|
|
||||||
|
return `${sanitized}-${note.id.slice(-8)}.html`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notesToHtmlZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
|
||||||
|
const files = notes.map((note) => ({
|
||||||
|
name: generateHtmlFilename(note),
|
||||||
|
content: noteToHtml(note),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { files }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
interface NoteWithTags {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
type: string
|
||||||
|
isFavorite: boolean
|
||||||
|
isPinned: boolean
|
||||||
|
creationSource: string
|
||||||
|
tags: { tag: { id: string; name: string } }[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteToMarkdown(note: NoteWithTags): string {
|
||||||
|
const frontmatter = [
|
||||||
|
'---',
|
||||||
|
`title: "${escapeYaml(note.title)}"`,
|
||||||
|
`type: ${note.type}`,
|
||||||
|
`createdAt: ${note.createdAt}`,
|
||||||
|
`updatedAt: ${note.updatedAt}`,
|
||||||
|
note.tags && note.tags.length > 0
|
||||||
|
? `tags:\n${note.tags.map(({ tag }) => ` - ${tag.name}`).join('\n')}`
|
||||||
|
: null,
|
||||||
|
note.isFavorite ? 'favorite: true' : null,
|
||||||
|
note.isPinned ? 'pinned: true' : null,
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return `${frontmatter}\n# ${note.title}\n\n${note.content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFilename(note: NoteWithTags): string {
|
||||||
|
const sanitized = note.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 100)
|
||||||
|
|
||||||
|
return `${sanitized}-${note.id.slice(-8)}.md`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeYaml(str: string): string {
|
||||||
|
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notesToMarkdownZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
|
||||||
|
const files = notes.map((note) => ({
|
||||||
|
name: generateFilename(note),
|
||||||
|
content: noteToMarkdown(note),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { files }
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface CapturePayload {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
selection: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits for capture payloads
|
||||||
|
const MAX_TITLE_LENGTH = 500
|
||||||
|
const MAX_URL_LENGTH = 2000
|
||||||
|
const MAX_SELECTION_LENGTH = 10000
|
||||||
|
|
||||||
|
export interface CaptureValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCapturePayload(payload: CapturePayload): CaptureValidationResult {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!payload.title || typeof payload.title !== 'string') {
|
||||||
|
errors.push('Title is required')
|
||||||
|
} else if (payload.title.length > MAX_TITLE_LENGTH) {
|
||||||
|
errors.push(`Title too long: ${payload.title.length} chars (max: ${MAX_TITLE_LENGTH})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.url && typeof payload.url !== 'string') {
|
||||||
|
errors.push('URL must be a string')
|
||||||
|
} else if (payload.url && payload.url.length > MAX_URL_LENGTH) {
|
||||||
|
errors.push(`URL too long: ${payload.url.length} chars (max: ${MAX_URL_LENGTH})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.selection && typeof payload.selection !== 'string') {
|
||||||
|
errors.push('Selection must be a string')
|
||||||
|
} else if (payload.selection && payload.selection.length > MAX_SELECTION_LENGTH) {
|
||||||
|
errors.push(`Selection too long: ${payload.selection.length} chars (max: ${MAX_SELECTION_LENGTH})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeCapturePayload(payload: CapturePayload): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
title: payload.title.slice(0, MAX_TITLE_LENGTH),
|
||||||
|
url: payload.url.slice(0, MAX_URL_LENGTH),
|
||||||
|
selection: payload.selection.slice(0, MAX_SELECTION_LENGTH),
|
||||||
|
})
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBookmarklet(): string {
|
||||||
|
// Get the current origin (where the app is running)
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||||
|
|
||||||
|
const code = `
|
||||||
|
var title = document.title;
|
||||||
|
var url = location.href;
|
||||||
|
var selection = window.getSelection().toString();
|
||||||
|
var params = new URLSearchParams({title, url, selection});
|
||||||
|
var base = ${JSON.stringify(origin)};
|
||||||
|
window.open(base + '/capture?' + params.toString(), '_blank');
|
||||||
|
`.replace(/\s+/g, ' ').trim()
|
||||||
|
return `javascript:${code}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Feature flags for MVP-3 features.
|
||||||
|
* Can be toggled via environment variables or local config.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FeatureFlags {
|
||||||
|
centrality: boolean
|
||||||
|
passiveRecommendations: boolean
|
||||||
|
typeSuggestions: boolean
|
||||||
|
linkSuggestions: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values - all enabled unless explicitly disabled
|
||||||
|
const defaults: FeatureFlags = {
|
||||||
|
centrality: true,
|
||||||
|
passiveRecommendations: true,
|
||||||
|
typeSuggestions: true,
|
||||||
|
linkSuggestions: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variable parsing
|
||||||
|
function parseEnvBool(key: string, defaultValue: boolean): boolean {
|
||||||
|
const envValue = process.env[key]
|
||||||
|
if (envValue === undefined) return defaultValue
|
||||||
|
if (envValue === 'true' || envValue === '1') return true
|
||||||
|
if (envValue === 'false' || envValue === '0') return false
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current feature flags.
|
||||||
|
* Reads from environment variables with defaults.
|
||||||
|
*/
|
||||||
|
export function getFeatureFlags(): FeatureFlags {
|
||||||
|
return {
|
||||||
|
centrality: parseEnvBool('FLAG_CENTRALITY', defaults.centrality),
|
||||||
|
passiveRecommendations: parseEnvBool('FLAG_PASSIVE_RECOMMENDATIONS', defaults.passiveRecommendations),
|
||||||
|
typeSuggestions: parseEnvBool('FLAG_TYPE_SUGGESTIONS', defaults.typeSuggestions),
|
||||||
|
linkSuggestions: parseEnvBool('FLAG_LINK_SUGGESTIONS', defaults.linkSuggestions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific feature is enabled.
|
||||||
|
*/
|
||||||
|
export function isFeatureEnabled(feature: keyof FeatureFlags): boolean {
|
||||||
|
return getFeatureFlags()[feature]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get flags for client-side use (only boolean values).
|
||||||
|
* This is safe to expose to the client.
|
||||||
|
*/
|
||||||
|
export function getClientFeatureFlags(): FeatureFlags {
|
||||||
|
return getFeatureFlags()
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
interface ParsedFrontmatter {
|
||||||
|
title?: string
|
||||||
|
type?: string
|
||||||
|
tags?: string[]
|
||||||
|
favorite?: boolean
|
||||||
|
pinned?: boolean
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedMarkdown {
|
||||||
|
frontmatter: ParsedFrontmatter
|
||||||
|
content: string
|
||||||
|
title: string
|
||||||
|
hasWikiLinks: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarkdownContent(
|
||||||
|
markdown: string,
|
||||||
|
filename?: string
|
||||||
|
): ParsedMarkdown {
|
||||||
|
const frontmatter: ParsedFrontmatter = {}
|
||||||
|
let content = markdown
|
||||||
|
let title = ''
|
||||||
|
|
||||||
|
// Check for frontmatter
|
||||||
|
const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||||
|
if (frontmatterMatch) {
|
||||||
|
const frontmatterStr = frontmatterMatch[1]
|
||||||
|
content = frontmatterMatch[2]
|
||||||
|
|
||||||
|
// Parse frontmatter fields
|
||||||
|
const lines = frontmatterStr.split('\n')
|
||||||
|
let currentKey = ''
|
||||||
|
let inList = false
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (inList && line.match(/^\s+-\s+/)) {
|
||||||
|
// Continuation of list
|
||||||
|
const value = line.replace(/^\s+-\s+/, '').trim()
|
||||||
|
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
||||||
|
frontmatter.tags.push(value)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
inList = false
|
||||||
|
|
||||||
|
// Key: value
|
||||||
|
const kvMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||||
|
if (kvMatch) {
|
||||||
|
currentKey = kvMatch[1]
|
||||||
|
const value = kvMatch[2].trim()
|
||||||
|
|
||||||
|
if (currentKey === 'tags' && !value) {
|
||||||
|
frontmatter.tags = []
|
||||||
|
inList = true
|
||||||
|
} else if (currentKey === 'tags') {
|
||||||
|
frontmatter.tags = value.split(',').map((t) => t.trim())
|
||||||
|
} else if (currentKey === 'favorite' || currentKey === 'pinned') {
|
||||||
|
frontmatter[currentKey] = value === 'true'
|
||||||
|
} else {
|
||||||
|
;(frontmatter as Record<string, unknown>)[currentKey] = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// List item
|
||||||
|
const listMatch = line.match(/^\s+-\s+(.*)$/)
|
||||||
|
if (listMatch) {
|
||||||
|
if (!frontmatter.tags) frontmatter.tags = []
|
||||||
|
frontmatter.tags.push(listMatch[1].trim())
|
||||||
|
inList = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title from content if not in frontmatter
|
||||||
|
if (frontmatter.title) {
|
||||||
|
title = frontmatter.title
|
||||||
|
} else {
|
||||||
|
// Try to find first # heading
|
||||||
|
const headingMatch = content.match(/^#\s+(.+)$/m)
|
||||||
|
if (headingMatch) {
|
||||||
|
title = headingMatch[1].trim()
|
||||||
|
} else if (filename) {
|
||||||
|
// Derive from filename
|
||||||
|
title = filename
|
||||||
|
.replace(/\.md$/i, '')
|
||||||
|
.replace(/[-_]/g, ' ')
|
||||||
|
.replace(/^\d+-\d+-\d+\s*/, '') // Remove date prefix if present
|
||||||
|
} else {
|
||||||
|
title = 'Untitled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove title heading from content if it exists
|
||||||
|
content = content.replace(/^#\s+.+\n+/, '')
|
||||||
|
|
||||||
|
// Check for wiki links
|
||||||
|
const hasWikiLinks = /\[\[([^\]]+)\]\]/.test(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
frontmatter,
|
||||||
|
content: content.trim(),
|
||||||
|
title,
|
||||||
|
hasWikiLinks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertWikiLinksToMarkdown(content: string): string {
|
||||||
|
// Convert [[link]] to [link](link)
|
||||||
|
return content.replace(/\[\[([^\]|]+)\]\]/g, '[$1]($1)')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractInlineTags(content: string): string[] {
|
||||||
|
// Extract #tag patterns that aren't in code blocks
|
||||||
|
const tags: string[] = []
|
||||||
|
const codeBlockRegex = /```[\s\S]*?```|`[^`]+`/g
|
||||||
|
const contentWithoutCode = content.replace(codeBlockRegex, '')
|
||||||
|
|
||||||
|
const tagRegex = /#([a-zA-Z][a-zA-Z0-9_-]*)/g
|
||||||
|
let match
|
||||||
|
while ((match = tagRegex.exec(contentWithoutCode)) !== null) {
|
||||||
|
const tag = match[1].toLowerCase()
|
||||||
|
if (!tags.includes(tag)) {
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface LinkSuggestion {
|
||||||
|
term: string
|
||||||
|
noteId: string
|
||||||
|
noteTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find potential wiki-link suggestions in content.
|
||||||
|
* Returns notes whose titles appear in the content and could be converted to [[links]].
|
||||||
|
*/
|
||||||
|
export async function findLinkSuggestions(
|
||||||
|
content: string,
|
||||||
|
currentNoteId?: string
|
||||||
|
): Promise<LinkSuggestion[]> {
|
||||||
|
if (!content.trim() || content.length < 10) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all note titles except current note
|
||||||
|
const allNotes = await prisma.note.findMany({
|
||||||
|
where: currentNoteId ? { id: { not: currentNoteId } } : undefined,
|
||||||
|
select: { id: true, title: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allNotes.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find titles that appear in content (case-insensitive)
|
||||||
|
const suggestions: LinkSuggestion[] = []
|
||||||
|
const contentLower = content.toLowerCase()
|
||||||
|
|
||||||
|
for (const note of allNotes) {
|
||||||
|
const titleLower = note.title.toLowerCase()
|
||||||
|
// Check if title appears as a whole word in content
|
||||||
|
const regex = new RegExp(`\\b${escapeRegex(titleLower)}\\b`, 'i')
|
||||||
|
if (regex.test(content)) {
|
||||||
|
suggestions.push({
|
||||||
|
term: note.title,
|
||||||
|
noteId: note.id,
|
||||||
|
noteTitle: note.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by title length (longer titles first - more specific matches)
|
||||||
|
return suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace terms in content with wiki-links
|
||||||
|
*/
|
||||||
|
export function applyWikiLinks(
|
||||||
|
content: string,
|
||||||
|
replacements: { term: string; noteId: string }[]
|
||||||
|
): string {
|
||||||
|
let result = content
|
||||||
|
|
||||||
|
for (const { term, noteId } of replacements) {
|
||||||
|
// Replace all occurrences of the term (case-insensitive, whole word only)
|
||||||
|
const regex = new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi')
|
||||||
|
result = result.replace(regex, `[[${term}]]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface DashboardMetrics {
|
||||||
|
totalNotes: number
|
||||||
|
totalTags: number
|
||||||
|
notesByType: Record<string, number>
|
||||||
|
topTags: { name: string; count: number }[]
|
||||||
|
topViewedNotes: { id: string; title: string; type: string; views: number }[]
|
||||||
|
creationSourceStats: { form: number; quick: number; import: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardMetrics(days = 30): Promise<DashboardMetrics> {
|
||||||
|
try {
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Get totals
|
||||||
|
const [totalNotes, totalTags, notesByTypeResult, topTagsResult] = await Promise.all([
|
||||||
|
prisma.note.count(),
|
||||||
|
prisma.tag.count(),
|
||||||
|
prisma.note.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.noteTag.groupBy({
|
||||||
|
by: ['tagId'],
|
||||||
|
_count: { noteId: true },
|
||||||
|
orderBy: { _count: { noteId: 'desc' } },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Get top viewed notes from usage
|
||||||
|
const topUsage = await prisma.noteUsage.groupBy({
|
||||||
|
by: ['noteId'],
|
||||||
|
where: {
|
||||||
|
eventType: 'view',
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
take: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const topViewedNotes = await Promise.all(
|
||||||
|
topUsage.map(async (usage) => {
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: usage.noteId },
|
||||||
|
select: { id: true, title: true, type: true },
|
||||||
|
})
|
||||||
|
return note
|
||||||
|
? {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
type: note.type,
|
||||||
|
views: usage._count.id,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get creation source stats
|
||||||
|
const creationSourceStats = await prisma.note.groupBy({
|
||||||
|
by: ['creationSource'],
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceMap = { form: 0, quick: 0, import: 0 }
|
||||||
|
for (const stat of creationSourceStats) {
|
||||||
|
if (stat.creationSource in sourceMap) {
|
||||||
|
sourceMap[stat.creationSource as keyof typeof sourceMap] = stat._count.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tag names
|
||||||
|
const tagIds = topTagsResult.map((t) => t.tagId)
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
where: { id: { in: tagIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
const tagMap = new Map(tags.map((t) => [t.id, t.name]))
|
||||||
|
|
||||||
|
const topTags = topTagsResult
|
||||||
|
.map((t) => ({
|
||||||
|
name: tagMap.get(t.tagId) || 'unknown',
|
||||||
|
count: t._count.noteId,
|
||||||
|
}))
|
||||||
|
.filter((t) => t.name !== 'unknown')
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNotes,
|
||||||
|
totalTags,
|
||||||
|
notesByType: Object.fromEntries(
|
||||||
|
notesByTypeResult.map((r) => [r.type, r._count.id])
|
||||||
|
),
|
||||||
|
topTags,
|
||||||
|
topViewedNotes: topViewedNotes.filter((n): n is NonNullable<typeof n> => n !== null),
|
||||||
|
creationSourceStats: sourceMap,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting dashboard metrics:', error)
|
||||||
|
return {
|
||||||
|
totalNotes: 0,
|
||||||
|
totalTags: 0,
|
||||||
|
notesByType: {},
|
||||||
|
topTags: [],
|
||||||
|
topViewedNotes: [],
|
||||||
|
creationSourceStats: { form: 0, quick: 0, import: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const NAVIGATION_HISTORY_KEY = 'recall_navigation_history'
|
||||||
|
const MAX_HISTORY_SIZE = 10
|
||||||
|
|
||||||
|
export interface NavigationEntry {
|
||||||
|
noteId: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
visitedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavigationHistory(): NavigationEntry[] {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(NAVIGATION_HISTORY_KEY)
|
||||||
|
if (!stored) return []
|
||||||
|
const entries: NavigationEntry[] = JSON.parse(stored)
|
||||||
|
// Deduplicate by noteId, keeping the first occurrence (most recent)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return entries.filter(entry => {
|
||||||
|
if (seen.has(entry.noteId)) return false
|
||||||
|
seen.add(entry.noteId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToNavigationHistory(entry: Omit<NavigationEntry, 'visitedAt'>): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const history = getNavigationHistory()
|
||||||
|
|
||||||
|
// Remove duplicate entries for the same note
|
||||||
|
const filtered = history.filter((e) => e.noteId !== entry.noteId)
|
||||||
|
|
||||||
|
// Add new entry at the beginning
|
||||||
|
const newEntry: NavigationEntry = {
|
||||||
|
...entry,
|
||||||
|
visitedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHistory = [newEntry, ...filtered].slice(0, MAX_HISTORY_SIZE)
|
||||||
|
|
||||||
|
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(newHistory))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearNavigationHistory(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.removeItem(NAVIGATION_HISTORY_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromNavigationHistory(noteId: string): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const history = getNavigationHistory()
|
||||||
|
const filtered = history.filter((e) => e.noteId !== noteId)
|
||||||
|
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(filtered))
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const FEATURES_KEY = 'recall_features'
|
||||||
|
const BACKUP_ENABLED_KEY = 'recall_backup_enabled'
|
||||||
|
const BACKUP_RETENTION_KEY = 'recall_backup_retention'
|
||||||
|
|
||||||
|
export interface FeatureFlags {
|
||||||
|
backupEnabled: boolean
|
||||||
|
backupRetention: number // days
|
||||||
|
workModeEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFlags: FeatureFlags = {
|
||||||
|
backupEnabled: true,
|
||||||
|
backupRetention: 30,
|
||||||
|
workModeEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureFlags(): FeatureFlags {
|
||||||
|
if (typeof window === 'undefined') return defaultFlags
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(FEATURES_KEY)
|
||||||
|
if (!stored) return defaultFlags
|
||||||
|
return { ...defaultFlags, ...JSON.parse(stored) }
|
||||||
|
} catch {
|
||||||
|
return defaultFlags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFeatureFlags(flags: Partial<FeatureFlags>): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const current = getFeatureFlags()
|
||||||
|
const updated = { ...current, ...flags }
|
||||||
|
localStorage.setItem(FEATURES_KEY, JSON.stringify(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackupEnabled(): boolean {
|
||||||
|
return getFeatureFlags().backupEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBackupEnabled(enabled: boolean): void {
|
||||||
|
setFeatureFlags({ backupEnabled: enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupRetention(): number {
|
||||||
|
return getFeatureFlags().backupRetention
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBackupRetention(days: number): void {
|
||||||
|
setFeatureFlags({ backupRetention: days })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkModeEnabled(): boolean {
|
||||||
|
return getFeatureFlags().workModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkModeEnabled(enabled: boolean): void {
|
||||||
|
setFeatureFlags({ workModeEnabled: enabled })
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface QueryAST {
|
||||||
|
text: string
|
||||||
|
filters: {
|
||||||
|
type?: string
|
||||||
|
tag?: string
|
||||||
|
isFavorite?: boolean
|
||||||
|
isPinned?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_REGEX = /(tag|type):(\S*)/gi
|
||||||
|
const IS_FAVORITE_REGEX = /\bis:favorite\b/gi
|
||||||
|
const IS_PINNED_REGEX = /\bis:pinned\b/gi
|
||||||
|
|
||||||
|
export function parseQuery(query: string): QueryAST {
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
return { text: '', filters: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: QueryAST['filters'] = {}
|
||||||
|
|
||||||
|
// Extract type: filter
|
||||||
|
const typeMatches = query.matchAll(/(?:^|\s)(type):(\S*)/gi)
|
||||||
|
for (const match of typeMatches) {
|
||||||
|
const value = match[2]
|
||||||
|
if (value) {
|
||||||
|
filters.type = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tag: filter
|
||||||
|
const tagMatches = query.matchAll(/(?:^|\s)(tag):(\S*)/gi)
|
||||||
|
for (const match of tagMatches) {
|
||||||
|
const value = match[2]
|
||||||
|
if (value) {
|
||||||
|
filters.tag = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for is:favorite (case insensitive filter name)
|
||||||
|
const isFavoriteMatches = query.match(/\bis:favorite\b/i)
|
||||||
|
if (isFavoriteMatches) {
|
||||||
|
filters.isFavorite = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for is:pinned (case insensitive filter name)
|
||||||
|
const isPinnedMatches = query.match(/\bis:pinned\b/i)
|
||||||
|
if (isPinnedMatches) {
|
||||||
|
filters.isPinned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all filter patterns from the query to get remaining text
|
||||||
|
let text = query
|
||||||
|
.replace(FILTER_REGEX, '')
|
||||||
|
.replace(IS_FAVORITE_REGEX, '')
|
||||||
|
.replace(IS_PINNED_REGEX, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
return { text, filters }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const TYPE_PREFIXES: Record<string, NoteType> = {
|
|||||||
'rec:': 'recipe',
|
'rec:': 'recipe',
|
||||||
'proc:': 'procedure',
|
'proc:': 'procedure',
|
||||||
'inv:': 'inventory',
|
'inv:': 'inventory',
|
||||||
|
'web:': 'note',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_REGEX = /#([a-z0-9]+)/g
|
const TAG_REGEX = /#([a-z0-9]+)/g
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { RecallBackup } from '@/types/backup'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createBackupSnapshot } from '@/lib/backup'
|
||||||
|
import { syncBacklinks } from '@/lib/backlinks'
|
||||||
|
|
||||||
|
interface RestoreResult {
|
||||||
|
success: boolean
|
||||||
|
restored: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBackup(
|
||||||
|
backup: RecallBackup,
|
||||||
|
mode: 'merge' | 'replace'
|
||||||
|
): Promise<RestoreResult> {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'replace') {
|
||||||
|
await createBackupSnapshot('pre-destructive')
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = backup.data.notes as Array<{
|
||||||
|
id?: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
type: string
|
||||||
|
isFavorite?: boolean
|
||||||
|
isPinned?: boolean
|
||||||
|
tags?: string[]
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
let restored = 0
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const note of notes) {
|
||||||
|
const parseDate = (dateStr: string | undefined): Date => {
|
||||||
|
if (!dateStr) return new Date()
|
||||||
|
const parsed = new Date(dateStr)
|
||||||
|
return isNaN(parsed.getTime()) ? new Date() : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = parseDate(note.createdAt)
|
||||||
|
const updatedAt = parseDate(note.updatedAt)
|
||||||
|
|
||||||
|
if (mode === 'replace') {
|
||||||
|
if (note.id) {
|
||||||
|
await tx.note.upsert({
|
||||||
|
where: { id: note.id },
|
||||||
|
create: {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||||
|
isFavorite: note.isFavorite ?? false,
|
||||||
|
isPinned: note.isPinned ?? false,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||||
|
isFavorite: note.isFavorite ?? false,
|
||||||
|
isPinned: note.isPinned ?? false,
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await tx.note.create({
|
||||||
|
data: {
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||||
|
isFavorite: note.isFavorite ?? false,
|
||||||
|
isPinned: note.isPinned ?? false,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
restored++
|
||||||
|
} else {
|
||||||
|
if (note.id) {
|
||||||
|
const existing = await tx.note.findUnique({ where: { id: note.id } })
|
||||||
|
if (existing) {
|
||||||
|
await tx.note.update({
|
||||||
|
where: { id: note.id },
|
||||||
|
data: { title: note.title, content: note.content, updatedAt },
|
||||||
|
})
|
||||||
|
await tx.noteTag.deleteMany({ where: { noteId: note.id } })
|
||||||
|
} else {
|
||||||
|
await tx.note.create({
|
||||||
|
data: {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||||
|
isFavorite: note.isFavorite ?? false,
|
||||||
|
isPinned: note.isPinned ?? false,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const existingByTitle = await tx.note.findFirst({ where: { title: note.title } })
|
||||||
|
if (existingByTitle) {
|
||||||
|
await tx.note.update({
|
||||||
|
where: { id: existingByTitle.id },
|
||||||
|
data: { content: note.content, updatedAt },
|
||||||
|
})
|
||||||
|
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
|
||||||
|
} else {
|
||||||
|
await tx.note.create({
|
||||||
|
data: {
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||||
|
isFavorite: note.isFavorite ?? false,
|
||||||
|
isPinned: note.isPinned ?? false,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restored++
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteId = note.id
|
||||||
|
? (await tx.note.findUnique({ where: { id: note.id } }))?.id
|
||||||
|
: (await tx.note.findFirst({ where: { title: note.title } }))?.id
|
||||||
|
|
||||||
|
if (noteId && note.tags && note.tags.length > 0) {
|
||||||
|
for (const tagName of note.tags) {
|
||||||
|
const tag = await tx.tag.upsert({
|
||||||
|
where: { name: tagName },
|
||||||
|
create: { name: tagName },
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
await tx.noteTag.create({
|
||||||
|
data: { noteId, tagId: tag.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteId) {
|
||||||
|
const noteRecord = await tx.note.findUnique({ where: { id: noteId } })
|
||||||
|
if (noteRecord) {
|
||||||
|
await syncBacklinks(noteId, noteRecord.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, restored, errors: [] }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return { success: false, restored: 0, errors: [message] }
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-8
@@ -1,10 +1,13 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import stringSimilarity from 'string-similarity'
|
import stringSimilarity from 'string-similarity'
|
||||||
import { getUsageStats } from '@/lib/usage'
|
import { getUsageStats } from '@/lib/usage'
|
||||||
|
import { parseQuery, QueryAST } from '@/lib/query-parser'
|
||||||
|
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
type?: string
|
type?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
|
isFavorite?: boolean
|
||||||
|
isPinned?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScoredNote {
|
export interface ScoredNote {
|
||||||
@@ -133,10 +136,19 @@ async function scoreNote(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function noteQuery(
|
export async function noteQuery(
|
||||||
query: string,
|
queryOrAST: string | QueryAST,
|
||||||
filters: SearchFilters = {}
|
filters: SearchFilters = {}
|
||||||
): Promise<ScoredNote[]> {
|
): Promise<ScoredNote[]> {
|
||||||
const queryLower = query.toLowerCase().trim()
|
// Support both old signature (query string + filters) and new (QueryAST)
|
||||||
|
let queryAST: QueryAST
|
||||||
|
if (typeof queryOrAST === 'string') {
|
||||||
|
queryAST = { text: queryOrAST, filters }
|
||||||
|
} else {
|
||||||
|
queryAST = queryOrAST
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text: queryText, filters: appliedFilters } = queryAST
|
||||||
|
const queryLower = queryText.toLowerCase().trim()
|
||||||
|
|
||||||
const allNotes = await prisma.note.findMany({
|
const allNotes = await prisma.note.findMany({
|
||||||
include: { tags: { include: { tag: true } } },
|
include: { tags: { include: { tag: true } } },
|
||||||
@@ -145,12 +157,14 @@ export async function noteQuery(
|
|||||||
const scored: ScoredNote[] = []
|
const scored: ScoredNote[] = []
|
||||||
|
|
||||||
for (const note of allNotes) {
|
for (const note of allNotes) {
|
||||||
if (filters.type && note.type !== filters.type) continue
|
// Apply filters from AST BEFORE scoring
|
||||||
|
if (appliedFilters.type && note.type !== appliedFilters.type) continue
|
||||||
if (filters.tag) {
|
if (appliedFilters.tag) {
|
||||||
const hasTag = note.tags.some(t => t.tag.name === filters.tag)
|
const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag)
|
||||||
if (!hasTag) continue
|
if (!hasTag) continue
|
||||||
}
|
}
|
||||||
|
if (appliedFilters.isFavorite && note.isFavorite !== true) continue
|
||||||
|
if (appliedFilters.isPinned && note.isPinned !== true) continue
|
||||||
|
|
||||||
const titleLower = note.title.toLowerCase()
|
const titleLower = note.title.toLowerCase()
|
||||||
const contentLower = note.content.toLowerCase()
|
const contentLower = note.content.toLowerCase()
|
||||||
@@ -181,7 +195,7 @@ export async function noteQuery(
|
|||||||
|
|
||||||
const highlight = highlightMatches(
|
const highlight = highlightMatches(
|
||||||
exactTitleMatch ? note.title + ' ' + note.content : note.content,
|
exactTitleMatch ? note.title + ' ' + note.content : note.content,
|
||||||
query
|
queryText
|
||||||
)
|
)
|
||||||
|
|
||||||
scored.push({
|
scored.push({
|
||||||
@@ -202,5 +216,11 @@ export async function searchNotes(
|
|||||||
query: string,
|
query: string,
|
||||||
filters: SearchFilters = {}
|
filters: SearchFilters = {}
|
||||||
): Promise<ScoredNote[]> {
|
): Promise<ScoredNote[]> {
|
||||||
return noteQuery(query, filters)
|
const queryAST: QueryAST = {
|
||||||
|
text: query,
|
||||||
|
filters: {
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return noteQuery(queryAST)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { NoteType } from '@/types/note'
|
||||||
|
|
||||||
|
interface TypeSuggestion {
|
||||||
|
type: NoteType
|
||||||
|
confidence: 'high' | 'medium' | 'low'
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns that indicate specific note types
|
||||||
|
const PATTERNS = {
|
||||||
|
command: [
|
||||||
|
/^\s*(git|docker|npm|yarn|node|python|curl|wget|ssh|scp|rsync|kubectl|helm|aws|gcloud|az)\s+/m,
|
||||||
|
/^\$\s*\w+/m,
|
||||||
|
/^>\s*\w+/m,
|
||||||
|
/`{3}(bash|sh|shell|zsh|fish)/m,
|
||||||
|
/#!/m, // shebang
|
||||||
|
],
|
||||||
|
snippet: [
|
||||||
|
/`{3}\w*/m, // code block with language
|
||||||
|
/^(function|const|let|var|class|import|export|def|async|await)\s+/m,
|
||||||
|
/\{\s*[\w\s]*:\s*[\w\s,}]+\}/m, // object literal
|
||||||
|
/=\s*>\s*{/m, // arrow function
|
||||||
|
/if\s*\(.+\)\s*{/m, // if statement
|
||||||
|
],
|
||||||
|
procedure: [
|
||||||
|
/^\d+[\.\)]\s+\w+/m, // numbered steps: 1. Do this
|
||||||
|
/^[-*]\s+\w+/m, // bullet steps: - Do this
|
||||||
|
/primer[oay]|segundo|tercero|cuarto|finalmente|después|antes|mientras|m paso/m,
|
||||||
|
/pasos?|steps?|instructions?|how to|tutorial/i,
|
||||||
|
],
|
||||||
|
recipe: [
|
||||||
|
/ingredientes?:?\s*$/im,
|
||||||
|
/^\s*-\s*\d+\s+\w+/m, // ingredient list: - 2 cups flour
|
||||||
|
/tiempo|horas?|minutos|preparación|cocción|servir/i,
|
||||||
|
/receta|recetas|cocina|cocinar|hornear|hervir/i,
|
||||||
|
],
|
||||||
|
decision: [
|
||||||
|
/decisión?:?\s*/im,
|
||||||
|
/alternativas?:?\s*/im,
|
||||||
|
/pros?\s*y\s*contras?:?\s*/im,
|
||||||
|
/consideramos?|optamos?|decidimos?|elegimos?/i,
|
||||||
|
/porque?|reason|context|vista|motivo/i,
|
||||||
|
],
|
||||||
|
inventory: [
|
||||||
|
/cantidad?:?\s*\d+/im,
|
||||||
|
/ubicación?:?\s*/im,
|
||||||
|
/stock|inventario|existencia|dispoble/i,
|
||||||
|
/^\s*\w+\s+\d+\s+\w+/m, // item quantity location pattern
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferNoteType(content: string): TypeSuggestion | null {
|
||||||
|
const scores: Record<NoteType, { score: number; matchedPatterns: string[] }> = {
|
||||||
|
command: { score: 0, matchedPatterns: [] },
|
||||||
|
snippet: { score: 0, matchedPatterns: [] },
|
||||||
|
procedure: { score: 0, matchedPatterns: [] },
|
||||||
|
recipe: { score: 0, matchedPatterns: [] },
|
||||||
|
decision: { score: 0, matchedPatterns: [] },
|
||||||
|
inventory: { score: 0, matchedPatterns: [] },
|
||||||
|
note: { score: 0, matchedPatterns: [] },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each type's patterns
|
||||||
|
for (const [type, patterns] of Object.entries(PATTERNS)) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
scores[type as NoteType].score += 1
|
||||||
|
scores[type as NoteType].matchedPatterns.push(pattern.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the type with highest score
|
||||||
|
let bestType: NoteType = 'note'
|
||||||
|
let bestScore = 0
|
||||||
|
|
||||||
|
for (const [type, { score }] of Object.entries(scores)) {
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestType = type as NoteType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine confidence based on score
|
||||||
|
let confidence: 'high' | 'medium' | 'low' = 'low'
|
||||||
|
let reason = 'No clear pattern detected'
|
||||||
|
|
||||||
|
if (bestScore >= 3) {
|
||||||
|
confidence = 'high'
|
||||||
|
} else if (bestScore >= 2) {
|
||||||
|
confidence = 'medium'
|
||||||
|
} else if (bestScore >= 1) {
|
||||||
|
confidence = 'low'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestScore > 0) {
|
||||||
|
switch (bestType) {
|
||||||
|
case 'command':
|
||||||
|
reason = 'Shell command patterns detected'
|
||||||
|
break
|
||||||
|
case 'snippet':
|
||||||
|
reason = 'Code block or programming syntax detected'
|
||||||
|
break
|
||||||
|
case 'procedure':
|
||||||
|
reason = 'Step-by-step instructions detected'
|
||||||
|
break
|
||||||
|
case 'recipe':
|
||||||
|
reason = 'Recipe or cooking instructions detected'
|
||||||
|
break
|
||||||
|
case 'decision':
|
||||||
|
reason = 'Decision-making context detected'
|
||||||
|
break
|
||||||
|
case 'inventory':
|
||||||
|
reason = 'Inventory or quantity patterns detected'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: bestType,
|
||||||
|
confidence,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatContentForType(content: string, type: NoteType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'command':
|
||||||
|
return `## Comando\n\n${content.trim()}\n\n## Qué hace\n\n[Descripción]\n\n## Cuándo usarlo\n\n[Cuándo usar este comando]\n\n## Ejemplo\n\n\`\`\`bash\n[ejemplo]\n\`\`\``
|
||||||
|
case 'snippet':
|
||||||
|
return `## Snippet\n\n## Lenguaje\n\n[ Lenguaje ]\n\n## Código\n\n\`\`\`\n${content.trim()}\n\`\`\`\n\n## Qué resuelve\n\n[Descripción del problema que resuelve]\n\n## Notas\n\n[Notas adicionales]`
|
||||||
|
case 'procedure':
|
||||||
|
return `## Objetivo\n\n[Cuál es el objetivo]\n\n## Pasos\n\n${content.trim()}\n\n## Requisitos\n\n[Requisitos necesarios]\n\n## Problemas comunes\n\n[Problemas frecuentes y soluciones]`
|
||||||
|
case 'recipe':
|
||||||
|
return `## Ingredientes\n\n[Lista de ingredientes]\n\n## Pasos\n\n${content.trim()}\n\n## Tiempo\n\n[Tiempo de preparación]\n\n## Notas\n\n[Notas adicionales]`
|
||||||
|
case 'decision':
|
||||||
|
return `## Contexto\n\n[Cuál era la situación]\n\n## Decisión\n\n${content.trim()}\n\n## Alternativas consideradas\n\n[Otras opciones evaluadas]\n\n## Consecuencias\n\n[Impacto de esta decisión]`
|
||||||
|
case 'inventory':
|
||||||
|
return `## Item\n\n[Nombre del item]\n\n## Cantidad\n\n[Cantidad]\n\n## Ubicación\n\n[Ubicación]\n\n## Notas\n\n[Notas adicionales]`
|
||||||
|
default:
|
||||||
|
return `## Notas\n\n${content.trim()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,3 +136,74 @@ export function clearRecentlyViewed(): void {
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Co-usage tracking: record that two notes were viewed together
|
||||||
|
export async function trackCoUsage(fromNoteId: string, toNoteId: string): Promise<void> {
|
||||||
|
if (fromNoteId === toNoteId) return
|
||||||
|
try {
|
||||||
|
await prisma.noteCoUsage.upsert({
|
||||||
|
where: {
|
||||||
|
fromNoteId_toNoteId: { fromNoteId, toNoteId },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
weight: { increment: 1 },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
fromNoteId,
|
||||||
|
toNoteId,
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get notes that are often viewed together with a given note
|
||||||
|
export async function getCoUsedNotes(
|
||||||
|
noteId: string,
|
||||||
|
limit = 5,
|
||||||
|
days = 30
|
||||||
|
): Promise<{ noteId: string; title: string; type: string; weight: number }[]> {
|
||||||
|
try {
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||||
|
const coUsages = await prisma.noteCoUsage.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fromNoteId: noteId },
|
||||||
|
{ toNoteId: noteId },
|
||||||
|
],
|
||||||
|
updatedAt: { gte: since },
|
||||||
|
},
|
||||||
|
orderBy: { weight: 'desc' },
|
||||||
|
take: limit * 2, // Fetch more to account for duplicates we'll filter
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deduplicate by relatedNoteId - only keep highest weight per note
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const result: { noteId: string; title: string; type: string; weight: number }[] = []
|
||||||
|
|
||||||
|
for (const cu of coUsages) {
|
||||||
|
const relatedNoteId = cu.fromNoteId === noteId ? cu.toNoteId : cu.fromNoteId
|
||||||
|
if (seenIds.has(relatedNoteId)) continue
|
||||||
|
seenIds.add(relatedNoteId)
|
||||||
|
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: relatedNoteId },
|
||||||
|
select: { id: true, title: true, type: true },
|
||||||
|
})
|
||||||
|
if (note) {
|
||||||
|
result.push({
|
||||||
|
noteId: note.id,
|
||||||
|
title: note.title,
|
||||||
|
type: note.type,
|
||||||
|
weight: cu.weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.length >= limit) break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-4
@@ -2,20 +2,32 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
|
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
|
||||||
|
|
||||||
export const noteSchema = z.object({
|
export const CreationSourceEnum = z.enum(['form', 'quick', 'import'])
|
||||||
id: z.string().optional(),
|
|
||||||
|
// Base note schema without transform - for use with partial()
|
||||||
|
const baseNoteSchema = z.object({
|
||||||
|
id: z.string().optional().nullable(),
|
||||||
title: z.string().min(1, 'Title is required').max(200),
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
content: z.string().min(1, 'Content is required'),
|
content: z.string().min(1, 'Content is required'),
|
||||||
type: NoteTypeEnum.default('note'),
|
type: NoteTypeEnum.default('note'),
|
||||||
isFavorite: z.boolean().default(false),
|
isFavorite: z.boolean().default(false),
|
||||||
isPinned: z.boolean().default(false),
|
isPinned: z.boolean().default(false),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
|
creationSource: CreationSourceEnum.default('form'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateNoteSchema = noteSchema.partial().extend({
|
// Transform to remove id if null/undefined (for creation)
|
||||||
id: z.string(),
|
export const noteSchema = baseNoteSchema.transform(data => {
|
||||||
|
if (data.id == null) {
|
||||||
|
const { id, ...rest } = data
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// For update, use partial of base schema with optional id (id comes from URL path, not body)
|
||||||
|
export const updateNoteSchema = baseNoteSchema.partial()
|
||||||
|
|
||||||
export const searchSchema = z.object({
|
export const searchSchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
type: NoteTypeEnum.optional(),
|
type: NoteTypeEnum.optional(),
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { NotFoundError } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function createVersion(noteId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }> {
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: noteId },
|
||||||
|
select: { id: true, title: true, content: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
throw new NotFoundError('Note')
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await prisma.noteVersion.create({
|
||||||
|
data: {
|
||||||
|
noteId: note.id,
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersions(noteId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }[]> {
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: noteId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
throw new NotFoundError('Note')
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.noteVersion.findMany({
|
||||||
|
where: { noteId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
noteId: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersion(versionId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }> {
|
||||||
|
const version = await prisma.noteVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
throw new NotFoundError('Version')
|
||||||
|
}
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreVersion(noteId: string, versionId: string): Promise<{ id: string; title: string; content: string; updatedAt: Date }> {
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: noteId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
throw new NotFoundError('Note')
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await prisma.noteVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
throw new NotFoundError('Version')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version.noteId !== noteId) {
|
||||||
|
throw new NotFoundError('Version')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedNote = await prisma.note.update({
|
||||||
|
where: { id: noteId },
|
||||||
|
data: {
|
||||||
|
title: version.title,
|
||||||
|
content: version.content,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return updatedNote
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const WORK_MODE_KEY = 'recall_work_mode'
|
||||||
|
|
||||||
|
export function getWorkMode(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return localStorage.getItem(WORK_MODE_KEY) === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWorkMode(enabled: boolean): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.setItem(WORK_MODE_KEY, String(enabled))
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type BackupSource = 'automatic' | 'manual' | 'pre-destructive'
|
||||||
|
|
||||||
|
export interface BackupMetadata {
|
||||||
|
noteCount: number
|
||||||
|
tagCount: number
|
||||||
|
versionCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecallBackup {
|
||||||
|
id: string
|
||||||
|
schemaVersion: string
|
||||||
|
createdAt: string
|
||||||
|
source: BackupSource
|
||||||
|
metadata: BackupMetadata
|
||||||
|
data: {
|
||||||
|
notes: unknown[]
|
||||||
|
tags: unknown[]
|
||||||
|
backlinks?: unknown[]
|
||||||
|
noteVersions?: unknown[]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user