develop #1
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx prisma:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm list:*)",
|
||||
"Bash(npx jest:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
33
CLAUDE.md
Normal file
33
CLAUDE.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Status
|
||||
|
||||
This is a new project in its initial state. The repository has been initialized with:
|
||||
- `main` branch (production)
|
||||
- `develop` branch (development)
|
||||
|
||||
No source code, build configuration, or tests exist yet.
|
||||
|
||||
## Architecture
|
||||
|
||||
Once code is added, document:
|
||||
- Tech stack and frameworks
|
||||
- High-level component structure
|
||||
- Key architectural patterns
|
||||
- API design (if applicable)
|
||||
|
||||
## Commands
|
||||
|
||||
Build, test, and lint commands will be documented here once the project structure is established.
|
||||
|
||||
## 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.
|
||||
- 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>
|
||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN npx prisma generate
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
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/prisma/schema.prisma /app/schema.prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
126
Jenkinsfile
vendored
Normal file
126
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
pipeline {
|
||||
agent {
|
||||
node {
|
||||
label 'java-springboot'
|
||||
}
|
||||
}
|
||||
environment {
|
||||
URL_REGISTRY = 'gitea.danielarroyo.cl'
|
||||
PROJECT = 'home-projects'
|
||||
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} \
|
||||
-f .
|
||||
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:./data/dev.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- homelab-net
|
||||
mem_limit: 32m
|
||||
mem_reservation: 16m
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
248
README.md
248
README.md
@@ -1,2 +1,250 @@
|
||||
# recall
|
||||
|
||||
Sistema de notas personal con captura rápida, búsqueda inteligente y conexiones entre notas.
|
||||
|
||||
## Uso
|
||||
|
||||
### Quick Add (Captura Rápida)
|
||||
|
||||
Crea notas al instante con el shortcut `Ctrl+N` o desde el botón de captura rápida en el header.
|
||||
|
||||
Sintaxis:
|
||||
```
|
||||
[tipo:][título] #tag1 #tag2
|
||||
```
|
||||
|
||||
**Tipos disponibles:**
|
||||
- `cmd:` - Comando
|
||||
- `snip:` - Snippet de código
|
||||
- `dec:` - Decisión
|
||||
- `rec:` - Receta
|
||||
- `proc:` - Procedimiento
|
||||
- `inv:` - Inventario
|
||||
|
||||
**Ejemplos:**
|
||||
```
|
||||
cmd: git commit -m 'fix: bug' #git #version-control
|
||||
snip: useState hook #react #hooks
|
||||
dec: usar PostgreSQL #backend #database
|
||||
rec: Pasta carbonara #cocina #italiana
|
||||
```
|
||||
|
||||
### Tipos de Notas
|
||||
|
||||
| Tipo | Descripción | Campos |
|
||||
|------|-------------|--------|
|
||||
| `command` | Comandos CLI | Comando, Descripción, Ejemplo |
|
||||
| `snippet` | Código reutilizable | Lenguaje, Código, Descripción |
|
||||
| `decision` | Decisiones importantes | Contexto, Decisión, Alternativas, Consecuencias |
|
||||
| `recipe` | Recetas | Ingredientes, Pasos, Tiempo |
|
||||
| `procedure` | Procedimientos | Objetivo, Pasos, Requisitos |
|
||||
| `inventory` | Inventario | Item, Cantidad, Ubicación |
|
||||
| `note` | Nota libre | Contenido |
|
||||
|
||||
### Dashboard (Página Principal)
|
||||
|
||||
El dashboard muestra diferentes secciones según tu actividad:
|
||||
|
||||
- **Recientes** - Últimas notas modificadas
|
||||
- **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
|
||||
|
||||
Crea links a otras notas usando `[[nombre-de-nota]]`:
|
||||
|
||||
```
|
||||
Ver también: [[Configuración de Docker]]
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma db push
|
||||
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
|
||||
|
||||
### Quick Add
|
||||
```bash
|
||||
POST /api/notes/quick
|
||||
Content-Type: text/plain
|
||||
|
||||
cmd: mi comando #tag
|
||||
```
|
||||
|
||||
### Buscar
|
||||
```bash
|
||||
GET /api/search?q=docker&type=command
|
||||
```
|
||||
|
||||
### Tags
|
||||
```bash
|
||||
GET /api/tags # Listar todos
|
||||
GET /api/tags?q=python # Filtrar
|
||||
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
|
||||
```
|
||||
|
||||
753
__tests__/api.integration.test.ts
Normal file
753
__tests__/api.integration.test.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* Integration Tests for All API Endpoints
|
||||
*
|
||||
* Tests success and error cases for all routes:
|
||||
* - /api/notes (GET, POST)
|
||||
* - /api/notes/[id] (GET, PUT, DELETE)
|
||||
* - /api/notes/quick (POST)
|
||||
* - /api/tags (GET)
|
||||
* - /api/search (GET)
|
||||
* - /api/export-import (POST)
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// Complete mock Prisma client
|
||||
const mockPrisma = {
|
||||
note: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
tag: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
noteTag: {
|
||||
create: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
backlink: {
|
||||
deleteMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
noteVersion: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback) => callback(mockPrisma)),
|
||||
}
|
||||
|
||||
// Mock prisma before imports
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: mockPrisma,
|
||||
}))
|
||||
|
||||
// Mock string-similarity used in search
|
||||
jest.mock('string-similarity', () => ({
|
||||
compareTwoStrings: jest.fn().mockReturnValue(0.5),
|
||||
}))
|
||||
|
||||
describe('API Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Reset transaction mock to properly chain
|
||||
mockPrisma.$transaction.mockImplementation(async (callback) => {
|
||||
return callback(mockPrisma)
|
||||
})
|
||||
})
|
||||
|
||||
// Helper to parse wrapped response
|
||||
function getData(response: { json: () => Promise<{ data?: unknown; error?: unknown; success: boolean }> }) {
|
||||
return response.json().then((r) => r.data ?? r)
|
||||
}
|
||||
|
||||
function expectSuccess(response: { status: number; json: () => Promise<{ success: boolean }> }) {
|
||||
expect(response.status).toBeLessThan(400)
|
||||
return response.json().then((r) => expect(r.success).toBe(true))
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET /api/notes - List all notes
|
||||
// ============================================
|
||||
describe('GET /api/notes', () => {
|
||||
it('returns empty array when no notes exist', async () => {
|
||||
mockPrisma.note.findMany.mockResolvedValue([])
|
||||
|
||||
const { GET } = await import('@/app/api/notes/route')
|
||||
const response = await GET(new NextRequest('http://localhost/api/notes'))
|
||||
const data = await getData(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all notes with tags', async () => {
|
||||
const mockNotes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Note 1',
|
||||
content: 'Content 1',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
tags: [{ tag: { id: 't1', name: 'javascript' } }],
|
||||
},
|
||||
]
|
||||
mockPrisma.note.findMany.mockResolvedValue(mockNotes)
|
||||
|
||||
const { GET } = await import('@/app/api/notes/route')
|
||||
const response = await GET(new NextRequest('http://localhost/api/notes'))
|
||||
const data = await getData(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Array.isArray(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('filters notes with search query', async () => {
|
||||
mockPrisma.note.findMany.mockResolvedValue([])
|
||||
|
||||
const { GET } = await import('@/app/api/notes/route')
|
||||
const response = await GET(new NextRequest('http://localhost/api/notes?q=test'))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// POST /api/notes - Create note
|
||||
// ============================================
|
||||
describe('POST /api/notes', () => {
|
||||
it('creates a note without tags', async () => {
|
||||
const newNote = {
|
||||
id: '1',
|
||||
title: 'New Note',
|
||||
content: 'Note content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
|
||||
mockPrisma.note.create.mockResolvedValue(newNote)
|
||||
mockPrisma.backlink.deleteMany.mockResolvedValue([])
|
||||
|
||||
const { POST } = await import('@/app/api/notes/route')
|
||||
const request = new NextRequest('http://localhost/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'New Note',
|
||||
content: 'Note content',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.title).toBe('New Note')
|
||||
})
|
||||
|
||||
it('creates a note with tags', async () => {
|
||||
const newNote = {
|
||||
id: '1',
|
||||
title: 'Tagged Note',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [{ tag: { id: '1', name: 'javascript' } }],
|
||||
}
|
||||
|
||||
mockPrisma.note.create.mockResolvedValue(newNote)
|
||||
mockPrisma.tag.upsert.mockResolvedValue({ id: '1', name: 'javascript' })
|
||||
mockPrisma.noteTag.create.mockResolvedValue({ noteId: '1', tagId: '1' })
|
||||
mockPrisma.backlink.deleteMany.mockResolvedValue([])
|
||||
|
||||
const { POST } = await import('@/app/api/notes/route')
|
||||
const request = new NextRequest('http://localhost/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Tagged Note',
|
||||
content: 'Content',
|
||||
tags: ['JavaScript'],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 400 when title is missing', async () => {
|
||||
const { POST } = await import('@/app/api/notes/route')
|
||||
const request = new NextRequest('http://localhost/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: '',
|
||||
content: 'Some content',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when content is missing', async () => {
|
||||
const { POST } = await import('@/app/api/notes/route')
|
||||
const request = new NextRequest('http://localhost/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Valid Title',
|
||||
content: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// GET /api/notes/[id] - Get single note
|
||||
// ============================================
|
||||
describe('GET /api/notes/[id]', () => {
|
||||
it('returns note when found', async () => {
|
||||
const mockNote = {
|
||||
id: '1',
|
||||
title: 'Test Note',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
mockPrisma.note.findUnique.mockResolvedValue(mockNote)
|
||||
|
||||
const { GET } = await import('@/app/api/notes/[id]/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/1')
|
||||
const response = await GET(request, { params: Promise.resolve({ id: '1' }) })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.id).toBe('1')
|
||||
})
|
||||
|
||||
it('returns 404 when note not found', async () => {
|
||||
mockPrisma.note.findUnique.mockResolvedValue(null)
|
||||
|
||||
const { GET } = await import('@/app/api/notes/[id]/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/nonexistent')
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'nonexistent' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// PUT /api/notes/[id] - Update note
|
||||
// ============================================
|
||||
describe('PUT /api/notes/[id]', () => {
|
||||
it('updates note title', async () => {
|
||||
const existingNote = {
|
||||
id: '1',
|
||||
title: 'Old Title',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
const updatedNote = {
|
||||
...existingNote,
|
||||
title: 'New Title',
|
||||
}
|
||||
|
||||
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
|
||||
mockPrisma.note.update.mockResolvedValue(updatedNote)
|
||||
mockPrisma.noteTag.deleteMany.mockResolvedValue([])
|
||||
mockPrisma.backlink.deleteMany.mockResolvedValue([])
|
||||
|
||||
const { PUT } = await import('@/app/api/notes/[id]/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/1', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: '1', title: 'New Title' }),
|
||||
})
|
||||
|
||||
const response = await PUT(request, { params: Promise.resolve({ id: '1' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 404 when note to update not found', async () => {
|
||||
mockPrisma.note.findUnique.mockResolvedValue(null)
|
||||
|
||||
const { PUT } = await import('@/app/api/notes/[id]/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/nonexistent', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: 'nonexistent', title: 'New Title' }),
|
||||
})
|
||||
|
||||
const response = await PUT(request, { params: Promise.resolve({ id: 'nonexistent' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid update data', async () => {
|
||||
const existingNote = {
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
|
||||
|
||||
const { PUT } = await import('@/app/api/notes/[id]/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/1', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: '1', title: '', content: '' }),
|
||||
})
|
||||
|
||||
const response = await PUT(request, { params: Promise.resolve({ id: '1' }) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// DELETE /api/notes/[id] - Delete note
|
||||
// ============================================
|
||||
describe('DELETE /api/notes/[id]', () => {
|
||||
it('deletes note successfully', async () => {
|
||||
const existingNote = {
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
|
||||
mockPrisma.backlink.deleteMany.mockResolvedValue([])
|
||||
mockPrisma.note.delete.mockResolvedValue({ id: '1' })
|
||||
|
||||
const { DELETE } = await import('@/app/api/notes/[id]/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/1', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const response = await DELETE(request, { params: Promise.resolve({ id: '1' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 404 when deleting non-existent note', async () => {
|
||||
mockPrisma.note.findUnique.mockResolvedValue(null)
|
||||
|
||||
const { DELETE } = await import('@/app/api/notes/[id]/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/nonexistent', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const response = await DELETE(request, { params: Promise.resolve({ id: 'nonexistent' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// POST /api/notes/quick - Quick add note
|
||||
// ============================================
|
||||
describe('POST /api/notes/quick', () => {
|
||||
it('creates note from plain text', async () => {
|
||||
const createdNote = {
|
||||
id: '1',
|
||||
title: 'Quick Note',
|
||||
content: 'Quick Note',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
mockPrisma.note.create.mockResolvedValue(createdNote)
|
||||
|
||||
const { POST } = await import('@/app/api/notes/quick/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/quick', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
body: 'Quick Note',
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const json = await response.json()
|
||||
const data = json.data ?? json
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(data).toHaveProperty('title', 'Quick Note')
|
||||
})
|
||||
|
||||
it('creates note with type prefix', async () => {
|
||||
const createdNote = {
|
||||
id: '1',
|
||||
title: 'Deploy script',
|
||||
content: 'deploy script content',
|
||||
type: 'command',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
mockPrisma.note.create.mockResolvedValue(createdNote)
|
||||
|
||||
const { POST } = await import('@/app/api/notes/quick/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/quick', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
body: 'cmd: Deploy script\ndeploy script content',
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const json = await response.json()
|
||||
const data = json.data ?? json
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(data).toHaveProperty('type', 'command')
|
||||
})
|
||||
|
||||
it('returns 400 when text is empty', async () => {
|
||||
const { POST } = await import('@/app/api/notes/quick/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/quick', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
body: '',
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for JSON with missing text', async () => {
|
||||
const { POST } = await import('@/app/api/notes/quick/route')
|
||||
const request = new NextRequest('http://localhost/api/notes/quick', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// GET /api/tags - List tags / suggestions
|
||||
// ============================================
|
||||
describe('GET /api/tags', () => {
|
||||
it('returns all tags when no query', async () => {
|
||||
const mockTags = [
|
||||
{ id: '1', name: 'javascript' },
|
||||
{ id: '2', name: 'python' },
|
||||
{ id: '3', name: 'typescript' },
|
||||
]
|
||||
mockPrisma.tag.findMany.mockResolvedValue(mockTags)
|
||||
|
||||
const { GET } = await import('@/app/api/tags/route')
|
||||
const request = new NextRequest('http://localhost/api/tags')
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data).toEqual(mockTags)
|
||||
})
|
||||
|
||||
it('returns tag suggestions when q param provided', async () => {
|
||||
const mockTags = [
|
||||
{ id: '1', name: 'javascript' },
|
||||
{ id: '2', name: 'java' },
|
||||
]
|
||||
mockPrisma.tag.findMany.mockResolvedValue(mockTags)
|
||||
|
||||
const { GET } = await import('@/app/api/tags/route')
|
||||
const request = new NextRequest('http://localhost/api/tags?q=java')
|
||||
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('returns empty array when no tags exist', async () => {
|
||||
mockPrisma.tag.findMany.mockResolvedValue([])
|
||||
|
||||
const { GET } = await import('@/app/api/tags/route')
|
||||
const request = new NextRequest('http://localhost/api/tags')
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
// ============================================
|
||||
describe('GET /api/search', () => {
|
||||
it('returns search results with query', async () => {
|
||||
const mockNotes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'JavaScript Guide',
|
||||
content: 'Learning JavaScript basics',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
},
|
||||
]
|
||||
mockPrisma.note.findMany.mockResolvedValue(mockNotes)
|
||||
|
||||
const { GET } = await import('@/app/api/search/route')
|
||||
const request = new NextRequest('http://localhost/api/search?q=javascript')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('filters by type', async () => {
|
||||
mockPrisma.note.findMany.mockResolvedValue([])
|
||||
|
||||
const { GET } = await import('@/app/api/search/route')
|
||||
const request = new NextRequest('http://localhost/api/search?type=command')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('filters by tag', async () => {
|
||||
mockPrisma.note.findMany.mockResolvedValue([])
|
||||
|
||||
const { GET } = await import('@/app/api/search/route')
|
||||
const request = new NextRequest('http://localhost/api/search?tag=python')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// POST /api/export-import - Import notes
|
||||
// ============================================
|
||||
describe('POST /api/export-import', () => {
|
||||
it('imports valid notes array', async () => {
|
||||
mockPrisma.note.findUnique.mockResolvedValue(null)
|
||||
mockPrisma.note.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.note.create.mockResolvedValue({
|
||||
id: '1',
|
||||
title: 'Imported Note',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
mockPrisma.tag.upsert.mockResolvedValue({ id: '1', name: 'imported' })
|
||||
mockPrisma.noteTag.create.mockResolvedValue({ noteId: '1', tagId: '1' })
|
||||
mockPrisma.noteTag.deleteMany.mockResolvedValue([])
|
||||
|
||||
const { POST } = await import('@/app/api/export-import/route')
|
||||
const request = new NextRequest('http://localhost/api/export-import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify([
|
||||
{
|
||||
title: 'Imported Note',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
tags: ['imported'],
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
|
||||
it('returns 400 for non-array input', async () => {
|
||||
const { POST } = await import('@/app/api/export-import/route')
|
||||
const request = new NextRequest('http://localhost/api/export-import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Single note' }),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid note in array', async () => {
|
||||
const { POST } = await import('@/app/api/export-import/route')
|
||||
const request = new NextRequest('http://localhost/api/export-import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify([
|
||||
{
|
||||
title: '',
|
||||
content: 'Invalid note',
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('updates existing note when id matches', async () => {
|
||||
const existingNote = {
|
||||
id: '1',
|
||||
title: 'Old Title',
|
||||
content: 'Old content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
mockPrisma.note.findUnique.mockResolvedValue(existingNote)
|
||||
mockPrisma.note.update.mockResolvedValue({
|
||||
...existingNote,
|
||||
title: 'Updated Title',
|
||||
})
|
||||
mockPrisma.noteTag.deleteMany.mockResolvedValue([])
|
||||
|
||||
const { POST } = await import('@/app/api/export-import/route')
|
||||
const request = new NextRequest('http://localhost/api/export-import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Updated Title',
|
||||
content: 'Updated content',
|
||||
type: 'note',
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
})
|
||||
})
|
||||
166
__tests__/api.test.ts.skip
Normal file
166
__tests__/api.test.ts.skip
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* API Integration Tests
|
||||
*
|
||||
* These tests verify the API endpoints work correctly.
|
||||
* They require a test database setup.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// Mock Prisma for API tests
|
||||
const mockPrisma = {
|
||||
note: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
tag: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
noteTag: {
|
||||
create: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: mockPrisma,
|
||||
}))
|
||||
|
||||
describe('API Endpoints', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/notes', () => {
|
||||
it('returns all notes without query params', async () => {
|
||||
const mockNotes = [
|
||||
{ id: '1', title: 'Test Note', content: 'Content', type: 'note', tags: [] },
|
||||
]
|
||||
mockPrisma.note.findMany.mockResolvedValue(mockNotes)
|
||||
|
||||
// Import the route handler
|
||||
const { GET } = await import('@/app/api/notes/route')
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual(mockNotes)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/tags', () => {
|
||||
it('returns all tags', async () => {
|
||||
const mockTags = [
|
||||
{ id: '1', name: 'javascript' },
|
||||
{ id: '2', name: 'python' },
|
||||
]
|
||||
mockPrisma.tag.findMany.mockResolvedValue(mockTags)
|
||||
|
||||
const { GET } = await import('@/app/api/tags/route')
|
||||
const response = await GET(new NextRequest('http://localhost/api/tags'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual(mockTags)
|
||||
})
|
||||
|
||||
it('returns tag suggestions when q param is provided', async () => {
|
||||
const mockTags = [
|
||||
{ id: '1', name: 'javascript' },
|
||||
{ id: '2', name: 'java' },
|
||||
]
|
||||
mockPrisma.tag.findMany.mockResolvedValue(mockTags)
|
||||
|
||||
const { GET } = await import('@/app/api/tags/route')
|
||||
const response = await GET(new NextRequest('http://localhost/api/tags?q=java'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Array.isArray(data)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/notes', () => {
|
||||
it('creates a note with tags', async () => {
|
||||
const newNote = {
|
||||
id: '1',
|
||||
title: 'New Note',
|
||||
content: 'Content',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const createdTag = { id: '1', name: 'javascript' }
|
||||
|
||||
mockPrisma.note.create.mockResolvedValue(newNote)
|
||||
mockPrisma.tag.upsert.mockResolvedValue(createdTag)
|
||||
|
||||
const { POST } = await import('@/app/api/notes/route')
|
||||
const request = new NextRequest('http://localhost/api/notes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'New Note',
|
||||
content: 'Content',
|
||||
tags: ['JavaScript'],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(data.title).toBe('New Note')
|
||||
})
|
||||
|
||||
it('returns 400 for invalid note data', async () => {
|
||||
const { POST } = await import('@/app/api/notes/route')
|
||||
const request = new NextRequest('http://localhost/api/notes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: '', // Invalid: empty title
|
||||
content: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/search', () => {
|
||||
it('returns search results', async () => {
|
||||
const mockScoredNotes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Test Note',
|
||||
content: 'Content about JavaScript',
|
||||
type: 'note',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tags: [],
|
||||
score: 5,
|
||||
matchType: 'exact' as const,
|
||||
},
|
||||
]
|
||||
|
||||
// This would require the actual search module
|
||||
const { GET } = await import('@/app/api/search/route')
|
||||
const response = await GET(new NextRequest('http://localhost/api/search?q=test'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
215
__tests__/backlinks.test.ts
Normal file
215
__tests__/backlinks.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
56
__tests__/command-items.test.ts
Normal file
56
__tests__/command-items.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
293
__tests__/dashboard.test.ts
Normal file
293
__tests__/dashboard.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { getDashboardData, hasVisibleBlocks, DashboardNote, DashboardData } from '@/lib/dashboard'
|
||||
|
||||
// Mock prisma and usage before importing dashboard module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/usage', () => ({
|
||||
getRecentlyUsedNotes: jest.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getRecentlyUsedNotes } from '@/lib/usage'
|
||||
|
||||
describe('dashboard.ts', () => {
|
||||
const mockNotes = [
|
||||
{
|
||||
id: 'note-1',
|
||||
title: 'Docker Commands',
|
||||
content: 'docker build, docker run',
|
||||
type: 'command',
|
||||
isFavorite: true,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
tags: [{ tag: { id: 'tag-1', name: 'docker' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'React Snippet',
|
||||
content: 'useState usage',
|
||||
type: 'snippet',
|
||||
isFavorite: false,
|
||||
isPinned: true,
|
||||
createdAt: new Date('2024-01-02'),
|
||||
updatedAt: new Date('2024-01-14'),
|
||||
tags: [{ tag: { id: 'tag-2', name: 'react' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-3',
|
||||
title: 'Git Commands',
|
||||
content: 'git commit, git push',
|
||||
type: 'command',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-03'),
|
||||
updatedAt: new Date('2024-01-13'),
|
||||
tags: [{ tag: { id: 'tag-3', name: 'git' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-4',
|
||||
title: 'SQL Queries',
|
||||
content: 'SELECT * FROM',
|
||||
type: 'snippet',
|
||||
isFavorite: true,
|
||||
isPinned: true,
|
||||
createdAt: new Date('2024-01-04'),
|
||||
updatedAt: new Date('2024-01-12'),
|
||||
tags: [{ tag: { id: 'tag-4', name: 'sql' } }],
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getDashboardData', () => {
|
||||
it('should return dashboard data with all sections', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 5, lastUsed: new Date() },
|
||||
{ noteId: 'note-2', count: 3, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
expect(result).toHaveProperty('recentNotes')
|
||||
expect(result).toHaveProperty('mostUsedNotes')
|
||||
expect(result).toHaveProperty('recentCommands')
|
||||
expect(result).toHaveProperty('recentSnippets')
|
||||
expect(result).toHaveProperty('activityBasedNotes')
|
||||
expect(result).toHaveProperty('hasActivity')
|
||||
})
|
||||
|
||||
it('should return recent notes ordered by updatedAt', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(2)
|
||||
|
||||
expect(result.recentNotes).toHaveLength(2)
|
||||
expect(result.recentNotes[0].id).toBe('note-1') // most recent
|
||||
expect(result.recentNotes[1].id).toBe('note-2')
|
||||
})
|
||||
|
||||
it('should return most used notes by usage count', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-2', count: 10, lastUsed: new Date() },
|
||||
{ noteId: 'note-1', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
expect(result.mostUsedNotes).toHaveLength(2)
|
||||
expect(result.mostUsedNotes[0].id).toBe('note-2') // highest usage
|
||||
expect(result.mostUsedNotes[0].usageCount).toBe(10)
|
||||
})
|
||||
|
||||
it('should return only command type notes for recentCommands', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(5)
|
||||
|
||||
expect(result.recentCommands).toHaveLength(2)
|
||||
expect(result.recentCommands.every((n: DashboardNote) => n.type === 'command')).toBe(true)
|
||||
expect(result.recentCommands[0].id).toBe('note-1')
|
||||
})
|
||||
|
||||
it('should return only snippet type notes for recentSnippets', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(5)
|
||||
|
||||
expect(result.recentSnippets).toHaveLength(2)
|
||||
expect(result.recentSnippets.every((n: DashboardNote) => n.type === 'snippet')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return activity based notes excluding recent notes', async () => {
|
||||
// mockNotes are sorted by updatedAt desc: note-1, note-2, note-3, note-4
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
// note-4 is in recently used but will be filtered out since recentNotes has first 3
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-4', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
// note-4 is used but not in recentNotes (which contains note-1, note-2, note-3)
|
||||
expect(result.activityBasedNotes).toHaveLength(1)
|
||||
expect(result.activityBasedNotes[0].id).toBe('note-4')
|
||||
})
|
||||
|
||||
it('should set hasActivity based on recently used notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const resultWithActivity = await getDashboardData(3)
|
||||
expect(resultWithActivity.hasActivity).toBe(true)
|
||||
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
const resultWithoutActivity = await getDashboardData(3)
|
||||
expect(resultWithoutActivity.hasActivity).toBe(false)
|
||||
})
|
||||
|
||||
it('should respect the limit parameter', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 10, lastUsed: new Date() },
|
||||
{ noteId: 'note-2', count: 8, lastUsed: new Date() },
|
||||
{ noteId: 'note-3', count: 6, lastUsed: new Date() },
|
||||
{ noteId: 'note-4', count: 4, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(2)
|
||||
|
||||
expect(result.recentNotes).toHaveLength(2)
|
||||
expect(result.mostUsedNotes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should add usageCount to notes from usage map', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([
|
||||
{ noteId: 'note-1', count: 10, lastUsed: new Date() },
|
||||
{ noteId: 'note-2', count: 5, lastUsed: new Date() },
|
||||
])
|
||||
|
||||
const result = await getDashboardData(3)
|
||||
|
||||
const note1 = result.mostUsedNotes.find((n: DashboardNote) => n.id === 'note-1')
|
||||
const note2 = result.mostUsedNotes.find((n: DashboardNote) => n.id === 'note-2')
|
||||
expect(note1?.usageCount).toBe(10)
|
||||
expect(note2?.usageCount).toBe(5)
|
||||
})
|
||||
|
||||
it('should return empty arrays when no notes exist', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData()
|
||||
|
||||
expect(result.recentNotes).toEqual([])
|
||||
expect(result.mostUsedNotes).toEqual([])
|
||||
expect(result.recentCommands).toEqual([])
|
||||
expect(result.recentSnippets).toEqual([])
|
||||
expect(result.activityBasedNotes).toEqual([])
|
||||
expect(result.hasActivity).toBe(false)
|
||||
})
|
||||
|
||||
it('should convert dates to ISO strings', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await getDashboardData(1)
|
||||
|
||||
expect(result.recentNotes[0].createdAt).toBe('2024-01-01T00:00:00.000Z')
|
||||
expect(result.recentNotes[0].updatedAt).toBe('2024-01-15T00:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasVisibleBlocks', () => {
|
||||
it('should return true when recentNotes has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when mostUsedNotes has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [], usageCount: 5 }],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when recentCommands has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [{ id: '1', title: 'Test', content: '', type: 'command' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when recentSnippets has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [{ id: '1', title: 'Test', content: '', type: 'snippet' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when activityBasedNotes has items', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }],
|
||||
hasActivity: true,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when all arrays are empty', () => {
|
||||
const data: DashboardData = {
|
||||
recentNotes: [],
|
||||
mostUsedNotes: [],
|
||||
recentCommands: [],
|
||||
recentSnippets: [],
|
||||
activityBasedNotes: [],
|
||||
hasActivity: false,
|
||||
}
|
||||
expect(hasVisibleBlocks(data)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
36
__tests__/external-capture.test.ts
Normal file
36
__tests__/external-capture.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
171
__tests__/link-suggestions.test.ts
Normal file
171
__tests__/link-suggestions.test.ts
Normal file
@@ -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.')
|
||||
})
|
||||
})
|
||||
})
|
||||
14
__tests__/navigation-history.test.ts
Normal file
14
__tests__/navigation-history.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
145
__tests__/query-parser.test.ts
Normal file
145
__tests__/query-parser.test.ts
Normal file
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
28
__tests__/quick-add.test.ts
Normal file
28
__tests__/quick-add.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Tests for quick-add.ts module
|
||||
*
|
||||
* NOTE: quick-add.ts does not exist yet in src/lib/
|
||||
* These tests use describe.skip and will need to be implemented
|
||||
* once quick-add.ts is created by the quick-add-dev task.
|
||||
*/
|
||||
|
||||
describe.skip('quick-add.ts (to be implemented)', () => {
|
||||
// TODO: Once quick-add.ts is created, implement tests for:
|
||||
// - quickAddNote(title, content, type): Note creation shortcut
|
||||
// - parseQuickAddInput(input: string): Parse "title :: content :: type" format
|
||||
// - Validation of quick add input format
|
||||
|
||||
it('should parse quick add input format', () => {
|
||||
// Will test: parseQuickAddInput("My Note :: Note content :: note")
|
||||
// Expected: { title: "My Note", content: "Note content", type: "note" }
|
||||
})
|
||||
|
||||
it('should create note from quick add input', () => {
|
||||
// Will test: quickAddNote("title :: content")
|
||||
// Should create a note and return it
|
||||
})
|
||||
|
||||
it('should handle type inference from content', () => {
|
||||
// Will test: parseQuickAddInput with inferred type
|
||||
})
|
||||
})
|
||||
246
__tests__/related.test.ts
Normal file
246
__tests__/related.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { getRelatedNotes } from '@/lib/related'
|
||||
|
||||
// Mock prisma and usage before importing related module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/usage', () => ({
|
||||
getUsageStats: jest.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getUsageStats } from '@/lib/usage'
|
||||
|
||||
describe('related.ts', () => {
|
||||
const createMockNote = (overrides = {}) => ({
|
||||
id: 'note-1',
|
||||
title: 'Docker Commands',
|
||||
content: 'docker build and docker run commands',
|
||||
type: 'command',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
tags: [
|
||||
{ tag: { id: 'tag-1', name: 'docker' } },
|
||||
{ tag: { id: 'tag-2', name: 'containers' } },
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockOtherNote = (overrides = {}) => ({
|
||||
id: 'note-other',
|
||||
title: 'Other Note Title',
|
||||
content: 'other content xyz123',
|
||||
type: 'snippet',
|
||||
tags: [] as { tag: { id: string; name: string } }[],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Default usage stats - no usage
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
})
|
||||
|
||||
describe('getRelatedNotes', () => {
|
||||
it('should return empty array when note not found', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const result = await getRelatedNotes('non-existent')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when no related notes found', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-x', title: 'Completely Different', content: 'xyz abc def' }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return related notes sorted by score', async () => {
|
||||
const mockNote = createMockNote()
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker Compose', tags: [{ tag: { id: 't1', name: 'docker' } }] }),
|
||||
createMockOtherNote({ id: 'note-3', title: 'React Hooks', content: 'react hooks' }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// First result should have highest score
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score)
|
||||
}
|
||||
})
|
||||
|
||||
it('should give +3 for same type', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different' }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
expect(note2!.score).toBe(3) // only type match
|
||||
expect(note2!.reason).toContain('Same type')
|
||||
})
|
||||
|
||||
it('should give +3 per shared tag', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({
|
||||
id: 'note-2',
|
||||
type: 'snippet', // different type to isolate tag scoring
|
||||
title: 'XYZ Title',
|
||||
content: 'different content',
|
||||
tags: [
|
||||
{ tag: { id: 'tag-1', name: 'docker' } },
|
||||
{ tag: { id: 'tag-2', name: 'containers' } },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
expect(note2!.score).toBe(6) // 2 tags * 3 = 6
|
||||
expect(note2!.reason).toContain('Tags: docker, containers')
|
||||
})
|
||||
|
||||
it('should cap title keyword boost at +3', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Docker Kubernetes', content: 'different' }))
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({
|
||||
id: 'note-2',
|
||||
type: 'snippet',
|
||||
title: 'Docker Kubernetes Python Ruby', // 4 shared keywords but capped at 3
|
||||
content: 'different',
|
||||
tags: [],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
// Type mismatch = 0, Tags = 0, Title keywords capped at +3
|
||||
expect(note2!.score).toBeLessThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should cap content keyword boost at +2', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Different', content: 'docker kubernetes python ruby' }))
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({
|
||||
id: 'note-2',
|
||||
type: 'snippet',
|
||||
title: 'Title',
|
||||
content: 'docker kubernetes python', // 3 shared but capped at 2
|
||||
tags: [],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
})
|
||||
|
||||
it('should add usage-based boost with view count', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 15, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + floor(15/5)=3 + 2 (recency) = 8
|
||||
expect(note2!.score).toBe(8)
|
||||
})
|
||||
|
||||
it('should cap usage view boost at +3', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 50, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + min(floor(50/5),3)=3 + 2 (recency) = 8
|
||||
expect(note2!.score).toBe(8)
|
||||
})
|
||||
|
||||
it('should add +2 recency boost when note has any views', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 5, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + floor(5/5)=1 + 2 (recency) = 6
|
||||
expect(note2!.score).toBe(6)
|
||||
})
|
||||
|
||||
it('should use relatedClicks for recency boost', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 3 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + 0 (views) + 2 (related clicks recency) = 5
|
||||
expect(note2!.score).toBe(5)
|
||||
})
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }),
|
||||
createMockOtherNote({ id: 'note-3', type: 'command', title: 'Kubernetes', tags: [{ tag: { id: 't2', name: 'kubernetes' } }] }),
|
||||
createMockOtherNote({ id: 'note-4', type: 'command', title: 'Git', tags: [{ tag: { id: 't3', name: 'git' } }] }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1', 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should include reason field', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
expect(result[0].reason).toBeDefined()
|
||||
expect(typeof result[0].reason).toBe('string')
|
||||
expect(result[0].reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
300
__tests__/search.test.ts
Normal file
300
__tests__/search.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { highlightMatches, noteQuery, searchNotes, ScoredNote } from '@/lib/search'
|
||||
|
||||
// Mock prisma and usage before importing search module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/usage', () => ({
|
||||
getUsageStats: jest.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getUsageStats } from '@/lib/usage'
|
||||
|
||||
describe('search.ts', () => {
|
||||
const mockNotes = [
|
||||
{
|
||||
id: 'note-1',
|
||||
title: 'Docker Commands',
|
||||
content: 'docker build and docker run',
|
||||
type: 'command',
|
||||
isFavorite: true,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
tags: [{ tag: { id: 'tag-1', name: 'docker' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'React Hooks',
|
||||
content: 'useState and useEffect hooks',
|
||||
type: 'snippet',
|
||||
isFavorite: false,
|
||||
isPinned: true,
|
||||
createdAt: new Date('2024-01-02'),
|
||||
updatedAt: new Date('2024-01-10'),
|
||||
tags: [{ tag: { id: 'tag-2', name: 'react' } }],
|
||||
},
|
||||
{
|
||||
id: 'note-3',
|
||||
title: 'Git Commands',
|
||||
content: 'git commit and git push',
|
||||
type: 'command',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-03'),
|
||||
updatedAt: new Date('2024-01-05'),
|
||||
tags: [{ tag: { id: 'tag-3', name: 'git' } }],
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Default: no usage
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
})
|
||||
|
||||
describe('highlightMatches', () => {
|
||||
it('returns first 150 characters when query is empty', () => {
|
||||
const text = 'This is a long text that should be truncated to 150 characters. ' +
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor.'
|
||||
const result = highlightMatches(text, '')
|
||||
expect(result.length).toBeLessThanOrEqual(150)
|
||||
})
|
||||
|
||||
it('finds and highlights matching words', () => {
|
||||
const text = 'This is a test document about JavaScript programming.'
|
||||
const result = highlightMatches(text, 'JavaScript')
|
||||
expect(result).toContain('<mark>JavaScript</mark>')
|
||||
})
|
||||
|
||||
it('handles multiple word queries', () => {
|
||||
const text = 'React is a JavaScript library for building user interfaces.'
|
||||
const result = highlightMatches(text, 'JavaScript React')
|
||||
expect(result).toContain('<mark>JavaScript</mark>')
|
||||
expect(result).toContain('<mark>React</mark>')
|
||||
})
|
||||
|
||||
it('escapes regex special characters in query', () => {
|
||||
const text = 'What is $100 + $200?'
|
||||
const result = highlightMatches(text, '$100')
|
||||
expect(result).toContain('<mark>$100</mark>')
|
||||
})
|
||||
|
||||
it('adds ellipsis when match is not at start', () => {
|
||||
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. JavaScript is great.'
|
||||
const result = highlightMatches(text, 'JavaScript')
|
||||
expect(result).toContain('<mark>JavaScript</mark>')
|
||||
})
|
||||
|
||||
it('returns plain text when no match found', () => {
|
||||
const text = 'This is a simple text without the word we are looking for.'
|
||||
const result = highlightMatches(text, 'xyz123')
|
||||
expect(result).not.toContain('<mark>')
|
||||
})
|
||||
|
||||
it('filters out single character words and returns excerpt', () => {
|
||||
const text = 'A B C D E F G H I J K L M N O P Q R S T U V W X Y Z'
|
||||
const result = highlightMatches(text, 'A B C')
|
||||
// Single char words (A, B, C) are filtered, returns 150 chars
|
||||
expect(result.length).toBeLessThanOrEqual(150)
|
||||
})
|
||||
|
||||
it('adds ellipsis when match is far from start', () => {
|
||||
// JavaScript is at position ~26, start = max(0, 26-75) = 0, so no ellipsis needed
|
||||
// We need a longer text where match is more than 75 chars from start
|
||||
const text = 'Lorem ipsum dolor sit amet. '.repeat(10) + 'JavaScript programming language.'
|
||||
const result = highlightMatches(text, 'JavaScript')
|
||||
expect(result).toContain('...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('noteQuery', () => {
|
||||
it('returns empty array when no notes exist', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns notes with exact title match scored highest', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].id).toBe('note-1') // exact title match
|
||||
expect(result[0].matchType).toBe('exact')
|
||||
})
|
||||
|
||||
it('returns notes with content match', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('hooks')
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result.some((n: ScoredNote) => n.id === 'note-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns fuzzy matches when no exact match', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docer') // typo
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0].matchType).toBe('fuzzy')
|
||||
})
|
||||
|
||||
it('excludes notes with no match (low similarity)', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('xyz123nonexistent')
|
||||
|
||||
expect(result.length).toBe(0)
|
||||
})
|
||||
|
||||
it('adds +2 for favorite notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
// note-1 is favorite, should have higher score
|
||||
const dockerNote = result.find((n: ScoredNote) => n.id === 'note-1')
|
||||
expect(dockerNote?.isFavorite).toBe(true)
|
||||
})
|
||||
|
||||
it('adds +1 for pinned notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('hooks')
|
||||
|
||||
// note-2 is pinned
|
||||
const hooksNote = result.find((n: ScoredNote) => n.id === 'note-2')
|
||||
expect(hooksNote?.isPinned).toBe(true)
|
||||
})
|
||||
|
||||
it('adds +1 for recently updated notes (within 7 days)', async () => {
|
||||
const recentNote = {
|
||||
...mockNotes[0],
|
||||
updatedAt: new Date(Date.now() - 1000), // just now
|
||||
}
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([recentNote])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].score).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('filters by type when specified', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('', { type: 'command' })
|
||||
|
||||
expect(result.every((n: ScoredNote) => n.type === 'command')).toBe(true)
|
||||
})
|
||||
|
||||
it('filters by tag when specified', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('', { tag: 'docker' })
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe('note-1')
|
||||
})
|
||||
|
||||
it('returns notes sorted by score descending', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('')
|
||||
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns highlight excerpt for matched notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].highlight).toBeDefined()
|
||||
expect(typeof result[0].highlight).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should be an alias for noteQuery', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await searchNotes('docker')
|
||||
|
||||
expect(result).toEqual(await noteQuery('docker'))
|
||||
})
|
||||
|
||||
it('passes filters to noteQuery', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
||||
|
||||
const result = await searchNotes('', { type: 'snippet' })
|
||||
|
||||
expect(result.every((n: ScoredNote) => n.type === 'snippet')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage-based scoring boost', () => {
|
||||
it('calls getUsageStats for each note', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
await noteQuery('docker')
|
||||
|
||||
expect(getUsageStats).toHaveBeenCalledWith('note-1', 7)
|
||||
})
|
||||
|
||||
it('handles getUsageStats returning zero values', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
// Should return results without error
|
||||
expect(result).toBeDefined()
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScoredNote interface', () => {
|
||||
it('returns correct structure for scored notes', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0]).toHaveProperty('id')
|
||||
expect(result[0]).toHaveProperty('title')
|
||||
expect(result[0]).toHaveProperty('content')
|
||||
expect(result[0]).toHaveProperty('type')
|
||||
expect(result[0]).toHaveProperty('isFavorite')
|
||||
expect(result[0]).toHaveProperty('isPinned')
|
||||
expect(result[0]).toHaveProperty('createdAt')
|
||||
expect(result[0]).toHaveProperty('updatedAt')
|
||||
expect(result[0]).toHaveProperty('tags')
|
||||
expect(result[0]).toHaveProperty('score')
|
||||
expect(result[0]).toHaveProperty('highlight')
|
||||
expect(result[0]).toHaveProperty('matchType')
|
||||
expect(['exact', 'fuzzy']).toContain(result[0].matchType)
|
||||
})
|
||||
|
||||
it('converts Date objects to ISO strings', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
||||
|
||||
const result = await noteQuery('docker')
|
||||
|
||||
expect(result[0].createdAt).toBe('2024-01-01T00:00:00.000Z')
|
||||
expect(result[0].updatedAt).toBe('2024-01-15T00:00:00.000Z')
|
||||
})
|
||||
})
|
||||
})
|
||||
65
__tests__/tags.test.ts
Normal file
65
__tests__/tags.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { normalizeTag, normalizeTags, suggestTags } from '@/lib/tags'
|
||||
|
||||
describe('tags.ts', () => {
|
||||
describe('normalizeTag', () => {
|
||||
it('converts tag to lowercase', () => {
|
||||
expect(normalizeTag('JavaScript')).toBe('javascript')
|
||||
expect(normalizeTag('TYPESCRIPT')).toBe('typescript')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeTag(' javascript ')).toBe('javascript')
|
||||
expect(normalizeTag('\tpython\n')).toBe('python')
|
||||
})
|
||||
|
||||
it('handles combined lowercase and whitespace', () => {
|
||||
expect(normalizeTag(' PYTHON ')).toBe('python')
|
||||
expect(normalizeTag('\t JavaScript \n')).toBe('javascript')
|
||||
})
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeTag('')).toBe('')
|
||||
expect(normalizeTag(' ')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeTags', () => {
|
||||
it('normalizes an array of tags', () => {
|
||||
const input = ['JavaScript', ' PYTHON ', ' ruby']
|
||||
const expected = ['javascript', 'python', 'ruby']
|
||||
expect(normalizeTags(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(normalizeTags([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestTags', () => {
|
||||
it('suggests tags based on title keywords', () => {
|
||||
const suggestions = suggestTags('How to write Python code', '')
|
||||
// 'python' and 'code' are keywords for tag 'code', so 'code' tag is suggested
|
||||
expect(suggestions).toContain('code')
|
||||
})
|
||||
|
||||
it('suggests tags based on content keywords', () => {
|
||||
const suggestions = suggestTags('', 'Docker and Kubernetes deployment')
|
||||
expect(suggestions).toContain('devops')
|
||||
})
|
||||
|
||||
it('returns multiple matching tags', () => {
|
||||
const suggestions = suggestTags('Docker deployment pipeline', 'Setting up CI/CD with Docker')
|
||||
expect(suggestions).toContain('devops')
|
||||
})
|
||||
|
||||
it('limits suggestions to 3 tags', () => {
|
||||
const suggestions = suggestTags('SQL query for database', 'SELECT * FROM table')
|
||||
expect(suggestions.length).toBeLessThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('returns empty array when no keywords match', () => {
|
||||
const suggestions = suggestTags('Random title', 'Random content')
|
||||
expect(suggestions).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
221
__tests__/type-inference.test.ts
Normal file
221
__tests__/type-inference.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
223
__tests__/usage.test.ts
Normal file
223
__tests__/usage.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { trackNoteUsage, getNoteUsageCount, getRecentlyUsedNotes, getUsageStats, UsageEventType } from '@/lib/usage'
|
||||
|
||||
// Mock prisma before importing usage module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
noteUsage: {
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
groupBy: jest.fn(),
|
||||
},
|
||||
note: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
describe('usage.ts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('trackNoteUsage', () => {
|
||||
it('should create a usage event with all fields', async () => {
|
||||
;(prisma.noteUsage.create as jest.Mock).mockResolvedValue({ id: '1' })
|
||||
|
||||
await trackNoteUsage({
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
query: 'docker commands',
|
||||
metadata: { source: 'search' },
|
||||
})
|
||||
|
||||
expect(prisma.noteUsage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
query: 'docker commands',
|
||||
metadata: JSON.stringify({ source: 'search' }),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a usage event without optional fields', async () => {
|
||||
;(prisma.noteUsage.create as jest.Mock).mockResolvedValue({ id: '1' })
|
||||
|
||||
await trackNoteUsage({
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
})
|
||||
|
||||
expect(prisma.noteUsage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
noteId: 'note-1',
|
||||
eventType: 'view',
|
||||
query: null,
|
||||
metadata: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should silently fail on database error', async () => {
|
||||
;(prisma.noteUsage.create as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
trackNoteUsage({ noteId: 'note-1', eventType: 'view' })
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNoteUsageCount', () => {
|
||||
it('should return count for a specific note', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(5)
|
||||
|
||||
const result = await getNoteUsageCount('note-1')
|
||||
|
||||
expect(result).toBe(5)
|
||||
expect(prisma.noteUsage.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ noteId: 'note-1' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter by event type when specified', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(3)
|
||||
|
||||
const result = await getNoteUsageCount('note-1', 'search_click')
|
||||
|
||||
expect(result).toBe(3)
|
||||
expect(prisma.noteUsage.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
noteId: 'note-1',
|
||||
eventType: 'search_click',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply default 30 days filter', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(0)
|
||||
|
||||
await getNoteUsageCount('note-1')
|
||||
|
||||
const call = (prisma.noteUsage.count as jest.Mock).mock.calls[0][0]
|
||||
expect(call.where.createdAt.gte).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should return 0 on database error', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
const result = await getNoteUsageCount('note-1')
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecentlyUsedNotes', () => {
|
||||
it('should return recently used notes with counts', async () => {
|
||||
const mockGroupBy = [
|
||||
{ noteId: 'note-1', _count: { id: 10 } },
|
||||
{ noteId: 'note-2', _count: { id: 5 } },
|
||||
]
|
||||
;(prisma.noteUsage.groupBy as jest.Mock).mockResolvedValue(mockGroupBy)
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: 'note-1', updatedAt: new Date('2024-01-15') },
|
||||
{ id: 'note-2', updatedAt: new Date('2024-01-14') },
|
||||
])
|
||||
|
||||
const result = await getRecentlyUsedNotes('user-1', 10, 30)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({
|
||||
noteId: 'note-1',
|
||||
count: 10,
|
||||
lastUsed: expect.any(Date),
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty array on database error', async () => {
|
||||
;(prisma.noteUsage.groupBy as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
const result = await getRecentlyUsedNotes('user-1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
;(prisma.noteUsage.groupBy as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
await getRecentlyUsedNotes('user-1', 5)
|
||||
|
||||
expect(prisma.noteUsage.groupBy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ take: 5 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsageStats', () => {
|
||||
it('should return usage stats for a note', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock)
|
||||
.mockResolvedValueOnce(15) // views
|
||||
.mockResolvedValueOnce(8) // clicks
|
||||
.mockResolvedValueOnce(3) // relatedClicks
|
||||
|
||||
const result = await getUsageStats('note-1', 30)
|
||||
|
||||
expect(result).toEqual({
|
||||
views: 15,
|
||||
clicks: 8,
|
||||
relatedClicks: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return zeros on database error', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockRejectedValue(new Error('DB error'))
|
||||
|
||||
const result = await getUsageStats('note-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
views: 0,
|
||||
clicks: 0,
|
||||
relatedClicks: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should query with correct date filter', async () => {
|
||||
;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(0)
|
||||
|
||||
await getUsageStats('note-1', 7)
|
||||
|
||||
// All three counts should use the same date filter
|
||||
for (const call of (prisma.noteUsage.count as jest.Mock).mock.calls) {
|
||||
expect(call[0].where.createdAt.gte).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
|
||||
it('should return zeros when any count fails', async () => {
|
||||
// Promise.all will reject on first failure, so we can't get partial results
|
||||
;(prisma.noteUsage.count as jest.Mock)
|
||||
.mockResolvedValueOnce(10) // views
|
||||
.mockRejectedValueOnce(new Error('DB error')) // clicks fails
|
||||
.mockResolvedValueOnce(5) // relatedClicks never called
|
||||
|
||||
const result = await getUsageStats('note-1')
|
||||
|
||||
// All zeros because the whole operation fails
|
||||
expect(result.views).toBe(0)
|
||||
expect(result.clicks).toBe(0)
|
||||
expect(result.relatedClicks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UsageEventType', () => {
|
||||
it('should accept all valid event types', () => {
|
||||
const eventTypes: UsageEventType[] = ['view', 'search_click', 'related_click', 'link_click']
|
||||
|
||||
eventTypes.forEach((eventType) => {
|
||||
expect(['view', 'search_click', 'related_click', 'link_click']).toContain(eventType)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
167
__tests__/versions.test.ts
Normal file
167
__tests__/versions.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
581
backlog/recall-mvp3-tickets-claude-code.md
Normal file
581
backlog/recall-mvp3-tickets-claude-code.md
Normal file
@@ -0,0 +1,581 @@
|
||||
# Recall — Tickets técnicos MVP-3 para Claude Code
|
||||
|
||||
## Objetivo general
|
||||
Convertir Recall en una herramienta indispensable de uso diario, enfocada en:
|
||||
- recuperación pasiva
|
||||
- ranking basado en uso real
|
||||
- sugerencias automáticas
|
||||
- mapa de conocimiento simple
|
||||
- reducción adicional de fricción en captura
|
||||
|
||||
---
|
||||
|
||||
# EPIC 1 — Dashboard y uso diario
|
||||
|
||||
## [P1] Ticket 01 — Rediseñar dashboard para valor inmediato
|
||||
|
||||
**Objetivo**
|
||||
Hacer que la pantalla inicial devuelva valor sin necesidad de buscar.
|
||||
|
||||
**Contexto**
|
||||
Recall ya soporta búsqueda avanzada, quick add, backlinks, notas relacionadas, tags y UX especializada por tipo. El siguiente paso es que el home priorice descubrimiento y reutilización.
|
||||
|
||||
**Alcance**
|
||||
- Reemplazar dashboard actual por bloques orientados a uso:
|
||||
- notas recientes
|
||||
- notas más usadas
|
||||
- comandos recientes
|
||||
- snippets recientes
|
||||
- sugerencias relacionadas a la actividad reciente
|
||||
- Crear endpoint o función de agregación para dashboard
|
||||
- Ordenar visualmente por relevancia, no solo por fecha
|
||||
|
||||
**No incluye**
|
||||
- métricas históricas avanzadas
|
||||
- personalización por usuario
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Al abrir `/`, se ven al menos 4 bloques útiles
|
||||
- “Más usadas” no depende solo de `updatedAt`
|
||||
- Los bloques no rompen con base vacía
|
||||
- El dashboard responde correctamente con 0, pocas o muchas notas
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/page.tsx`
|
||||
- `src/components/dashboard.tsx`
|
||||
- `src/lib/dashboard.ts`
|
||||
- `src/app/api/dashboard/route.ts` (opcional)
|
||||
|
||||
**Notas técnicas**
|
||||
- Reutilizar scoring y modelos existentes
|
||||
- Mantener SSR o server components donde tenga sentido
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 02 — Registrar eventos de uso de notas
|
||||
|
||||
**Objetivo**
|
||||
Capturar señales reales de uso para mejorar ranking y sugerencias.
|
||||
|
||||
**Alcance**
|
||||
- Crear modelo de uso o contador agregado
|
||||
- Registrar eventos mínimos:
|
||||
- apertura de nota
|
||||
- copia de comando
|
||||
- copia de snippet
|
||||
- uso desde quick add relacionado
|
||||
- Exponer utilidades para incrementar métricas
|
||||
|
||||
**No incluye**
|
||||
- tracking externo
|
||||
- analytics de terceros
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Abrir una nota incrementa su contador de uso
|
||||
- Copiar contenido especializado suma señal adicional
|
||||
- La captura falla de forma segura sin romper la UI
|
||||
- Se puede consultar el uso agregado por nota
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `prisma/schema.prisma`
|
||||
- `src/lib/usage.ts`
|
||||
- `src/app/api/notes/[id]/usage/route.ts`
|
||||
- `src/components/note-card.tsx`
|
||||
- `src/components/markdown-content.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Preferir modelo simple:
|
||||
- `NoteUsage` por evento o
|
||||
- campos agregados en `Note`
|
||||
- Si eliges eventos, agregar tarea/helper de agregación futura
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 03 — Mostrar “según tu actividad reciente”
|
||||
|
||||
**Objetivo**
|
||||
Crear recuperación pasiva basada en uso reciente.
|
||||
|
||||
**Alcance**
|
||||
- Detectar últimas notas abiertas/usadas
|
||||
- Sugerir notas relacionadas en dashboard
|
||||
- Crear bloque “Según tu actividad reciente”
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Si hay actividad reciente, aparecen sugerencias relevantes
|
||||
- Si no hay actividad, el bloque se oculta o usa fallback
|
||||
- El bloque muestra por qué se recomienda una nota
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/dashboard.ts`
|
||||
- `src/components/dashboard.tsx`
|
||||
- `src/lib/related.ts`
|
||||
- `src/lib/usage.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 2 — Ranking inteligente y recuperación pasiva
|
||||
|
||||
## [P1] Ticket 04 — Extender scoring con señales de uso real
|
||||
|
||||
**Objetivo**
|
||||
Mejorar la relevancia de búsqueda y recomendaciones con comportamiento real.
|
||||
|
||||
**Alcance**
|
||||
- Extender `search.ts` y/o `related.ts` para incluir:
|
||||
- cantidad de aperturas
|
||||
- copias
|
||||
- recencia de uso
|
||||
- frecuencia de uso
|
||||
- Ajustar pesos de scoring
|
||||
|
||||
**No incluye**
|
||||
- machine learning
|
||||
- embeddings
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Una nota muy usada sube en empates razonables
|
||||
- El ranking sigue priorizando match textual fuerte
|
||||
- El score es explicable y testeable
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/search.ts`
|
||||
- `src/lib/related.ts`
|
||||
- `src/lib/usage.ts`
|
||||
- `__tests__/search.test.ts`
|
||||
- `__tests__/related.test.ts`
|
||||
|
||||
**Notas técnicas**
|
||||
- No permitir que uso alto eclipse resultados textualmente irrelevantes
|
||||
- Mantener scoring determinístico
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 05 — Crear recomendaciones pasivas en detalle de nota
|
||||
|
||||
**Objetivo**
|
||||
Que una nota devuelva otras útiles sin necesidad de nueva búsqueda.
|
||||
|
||||
**Alcance**
|
||||
- En vista de detalle agregar bloque:
|
||||
- “También podrías necesitar”
|
||||
- Basar sugerencias en:
|
||||
- backlinks
|
||||
- related score
|
||||
- uso conjunto
|
||||
- tags compartidos
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El bloque aparece en detalle de nota
|
||||
- Muestra entre 3 y 6 sugerencias
|
||||
- Muestra razón resumida de recomendación
|
||||
- Excluye nota actual y duplicados
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/notes/[id]/page.tsx`
|
||||
- `src/components/related-notes.tsx`
|
||||
- `src/lib/recommendations.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 06 — Registrar co-uso entre notas
|
||||
|
||||
**Objetivo**
|
||||
Detectar notas que suelen usarse juntas.
|
||||
|
||||
**Alcance**
|
||||
- Al abrir una nota, registrar relación con notas abiertas recientemente
|
||||
- Generar señal de co-uso
|
||||
- Exponer helper para recomendar “suelen usarse juntas”
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El sistema puede devolver notas co-usadas
|
||||
- No hay duplicados ni relaciones simétricas inconsistentes
|
||||
- La implementación escala razonablemente para dataset pequeño/medio
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `prisma/schema.prisma`
|
||||
- `src/lib/co-usage.ts`
|
||||
- `src/lib/recommendations.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 3 — Sugerencias automáticas y enriquecimiento
|
||||
|
||||
## [P1] Ticket 07 — Sugerir tags automáticamente al escribir
|
||||
|
||||
**Objetivo**
|
||||
Reducir esfuerzo manual en clasificación.
|
||||
|
||||
**Alcance**
|
||||
- Analizar título y contenido del formulario
|
||||
- Sugerir tags existentes según:
|
||||
- coincidencias de términos
|
||||
- frecuencia histórica
|
||||
- tipo de nota
|
||||
- Mostrar sugerencias no invasivas
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Al escribir contenido aparecen sugerencias útiles
|
||||
- El usuario puede aceptar o ignorar sugerencias
|
||||
- No se agregan tags automáticamente sin acción del usuario
|
||||
- Funciona con debounce
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/api/tags/suggest/route.ts`
|
||||
- `src/lib/tags.ts`
|
||||
- `src/components/note-form.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Priorizar tags existentes para evitar proliferación innecesaria
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 08 — Sugerir tipo de nota automáticamente
|
||||
|
||||
**Objetivo**
|
||||
Acelerar creación cuando el usuario pega contenido ambiguo.
|
||||
|
||||
**Alcance**
|
||||
- Detectar patrones de contenido para sugerir tipo:
|
||||
- bloque de código → `snippet`
|
||||
- comando shell → `command`
|
||||
- lista de pasos → `procedure`
|
||||
- ingredientes/pasos → `recipe`
|
||||
- contexto/decisión → `decision`
|
||||
- Mostrar recomendación editable
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El formulario propone un tipo probable
|
||||
- El usuario puede mantener o cambiar el tipo
|
||||
- No sobrescribe tipo si el usuario ya eligió uno manualmente
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/type-inference.ts`
|
||||
- `src/components/note-form.tsx`
|
||||
- `__tests__/type-inference.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 09 — Sugerir links internos mientras se escribe
|
||||
|
||||
**Objetivo**
|
||||
Fortalecer la red de conocimiento sin depender de memoria del usuario.
|
||||
|
||||
**Alcance**
|
||||
- Analizar contenido y detectar posibles referencias a notas existentes
|
||||
- Sugerir convertir términos en `[[nota]]`
|
||||
- Permitir inserción con un click
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El sistema detecta coincidencias plausibles con títulos existentes
|
||||
- El usuario puede insertar el link sugerido
|
||||
- No genera links automáticos sin confirmación
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backlinks.ts`
|
||||
- `src/lib/link-suggestions.ts`
|
||||
- `src/components/note-form.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 4 — Mapa de conocimiento simple
|
||||
|
||||
## [P1] Ticket 10 — Crear panel “Conectado con”
|
||||
|
||||
**Objetivo**
|
||||
Dar una vista de contexto inmediata sin construir un grafo complejo.
|
||||
|
||||
**Alcance**
|
||||
- En detalle de nota, agregar panel lateral o bloque:
|
||||
- backlinks
|
||||
- links salientes
|
||||
- relacionadas
|
||||
- co-usadas
|
||||
- Agrupar visualmente cada tipo de relación
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La nota muestra claramente su red local
|
||||
- Se distinguen tipos de conexión
|
||||
- Los enlaces navegan correctamente
|
||||
- Con 0 conexiones el bloque no se rompe
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/notes/[id]/page.tsx`
|
||||
- `src/components/note-connections.tsx`
|
||||
- `src/lib/backlinks.ts`
|
||||
- `src/lib/related.ts`
|
||||
- `src/lib/recommendations.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 11 — Identificar notas centrales
|
||||
|
||||
**Objetivo**
|
||||
Detectar nodos importantes del conocimiento personal.
|
||||
|
||||
**Alcance**
|
||||
- Calcular una métrica simple de centralidad usando:
|
||||
- backlinks
|
||||
- links salientes
|
||||
- uso
|
||||
- co-uso
|
||||
- Mostrar bloque “Notas centrales” en dashboard
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El dashboard muestra las notas más centrales
|
||||
- La métrica está documentada y es reproducible
|
||||
- Los resultados cambian al aumentar conexiones reales
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/centrality.ts`
|
||||
- `src/lib/dashboard.ts`
|
||||
- `src/components/dashboard.tsx`
|
||||
- `__tests__/centrality.test.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 5 — Captura aún más rápida
|
||||
|
||||
## [P1] Ticket 12 — Mejorar Quick Add para texto multilinea
|
||||
|
||||
**Objetivo**
|
||||
Permitir capturar cosas más complejas sin abrir el formulario completo.
|
||||
|
||||
**Alcance**
|
||||
- Soportar textarea o modo expandido en `quick-add`
|
||||
- Mantener parseo de prefijos y tags
|
||||
- Permitir pegar bloques de texto/código/listas
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Se puede guardar una nota multilinea desde quick add
|
||||
- El parser mantiene tags y tipo correctamente
|
||||
- No rompe el flujo actual de una sola línea
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/quick-add.tsx`
|
||||
- `src/lib/quick-add.ts`
|
||||
- `src/app/api/notes/quick/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 13 — Pegado inteligente en Quick Add y formulario
|
||||
|
||||
**Objetivo**
|
||||
Inferir estructura útil cuando el usuario pega contenido.
|
||||
|
||||
**Alcance**
|
||||
- Detectar si el pegado parece:
|
||||
- comando
|
||||
- snippet
|
||||
- checklist
|
||||
- markdown
|
||||
- Preformatear el contenido para mejor guardado
|
||||
- Ofrecer sugerencia de tipo o plantilla
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Pegar código sugiere `snippet`
|
||||
- Pegar checklist preserva formato
|
||||
- Pegar comando corto no destruye el flujo rápido
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/paste-analysis.ts`
|
||||
- `src/components/quick-add.tsx`
|
||||
- `src/components/note-form.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 6 — Métricas y observabilidad de producto
|
||||
|
||||
## [P2] Ticket 14 — Crear métricas internas simples
|
||||
|
||||
**Objetivo**
|
||||
Medir qué partes del producto generan valor real.
|
||||
|
||||
**Alcance**
|
||||
- Crear panel o función para obtener:
|
||||
- notas más abiertas
|
||||
- tipos más usados
|
||||
- tags más usados
|
||||
- quick add vs formulario
|
||||
- Exponer datos al dashboard o settings
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Las métricas se calculan sin servicios externos
|
||||
- Se pueden consultar desde UI
|
||||
- No impactan negativamente el rendimiento percibido
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/metrics.ts`
|
||||
- `src/app/settings/page.tsx` o `src/app/page.tsx`
|
||||
- `src/app/api/metrics/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 15 — Instrumentar origen de creación de nota
|
||||
|
||||
**Objetivo**
|
||||
Saber qué flujo usan más los usuarios.
|
||||
|
||||
**Alcance**
|
||||
- Registrar si la nota se creó desde:
|
||||
- quick add
|
||||
- formulario completo
|
||||
- importación
|
||||
- Guardar origen de creación
|
||||
- Exponerlo en métricas
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Cada nota nueva guarda su origen
|
||||
- Las métricas muestran distribución por origen
|
||||
- No rompe notas existentes
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `prisma/schema.prisma`
|
||||
- `src/app/api/notes/route.ts`
|
||||
- `src/app/api/notes/quick/route.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
- `src/lib/metrics.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 7 — Calidad y estabilidad
|
||||
|
||||
## [P1] Ticket 16 — Tests unitarios para ranking y recomendaciones MVP-3
|
||||
|
||||
**Objetivo**
|
||||
Proteger la lógica nueva antes de seguir iterando.
|
||||
|
||||
**Alcance**
|
||||
- Tests para:
|
||||
- `usage.ts`
|
||||
- `dashboard.ts`
|
||||
- `recommendations.ts`
|
||||
- `type-inference.ts`
|
||||
- `centrality.ts`
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Casos felices y bordes cubiertos
|
||||
- Tests reproducibles con datos determinísticos
|
||||
- Los pesos de scoring quedan documentados por test
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 17 — Tests de integración para dashboard y usage tracking
|
||||
|
||||
**Objetivo**
|
||||
Validar flujos reales del MVP-3.
|
||||
|
||||
**Alcance**
|
||||
- Probar:
|
||||
- apertura/uso de nota
|
||||
- dashboard enriquecido
|
||||
- recomendaciones
|
||||
- métricas base
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Existen seeds o fixtures simples
|
||||
- Los endpoints responden con estructura consistente
|
||||
- No hay regresiones en APIs existentes
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 18 — Feature flags internas para MVP-3
|
||||
|
||||
**Objetivo**
|
||||
Introducir features progresivamente sin romper experiencia principal.
|
||||
|
||||
**Alcance**
|
||||
- Crear configuración simple para activar/desactivar:
|
||||
- centralidad
|
||||
- recomendaciones pasivas
|
||||
- sugerencias de tipo
|
||||
- sugerencias de links
|
||||
- Leer flags desde config local o env
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Cada feature nueva puede apagarse sin romper la app
|
||||
- Los componentes respetan flags en servidor y cliente
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/features.ts`
|
||||
- `src/app/layout.tsx`
|
||||
- componentes afectados
|
||||
|
||||
---
|
||||
|
||||
# Orden recomendado de implementación
|
||||
|
||||
## Sprint 1
|
||||
- Ticket 02 — Registrar eventos de uso de notas
|
||||
- Ticket 04 — Extender scoring con señales de uso real
|
||||
- Ticket 01 — Rediseñar dashboard para valor inmediato
|
||||
- Ticket 03 — Mostrar “según tu actividad reciente”
|
||||
- Ticket 16 — Tests unitarios MVP-3 base
|
||||
|
||||
## Sprint 2
|
||||
- Ticket 05 — Recomendaciones pasivas en detalle
|
||||
- Ticket 07 — Sugerir tags automáticamente
|
||||
- Ticket 10 — Panel “Conectado con”
|
||||
- Ticket 17 — Tests de integración
|
||||
|
||||
## Sprint 3
|
||||
- Ticket 12 — Quick Add multilinea
|
||||
- Ticket 13 — Pegado inteligente
|
||||
- Ticket 08 — Sugerir tipo automáticamente
|
||||
- Ticket 09 — Sugerir links internos
|
||||
|
||||
## Sprint 4
|
||||
- Ticket 06 — Registrar co-uso
|
||||
- Ticket 11 — Identificar notas centrales
|
||||
- Ticket 14 — Métricas internas simples
|
||||
- Ticket 15 — Origen de creación
|
||||
- Ticket 18 — Feature flags internas
|
||||
|
||||
---
|
||||
|
||||
# Plantilla sugerida para cada issue en Claude Code
|
||||
|
||||
## Título
|
||||
`[P1] Registrar eventos de uso de notas`
|
||||
|
||||
## Contexto
|
||||
Recall ya cuenta con CRUD, búsqueda con scoring, quick add, backlinks, relaciones y tests. Ahora se necesita capturar señales reales de uso para mejorar ranking y recomendaciones.
|
||||
|
||||
## Objetivo
|
||||
Registrar aperturas y acciones clave sobre notas para alimentar el dashboard, la recuperación pasiva y el ranking inteligente.
|
||||
|
||||
## Alcance
|
||||
- modelo o estructura de persistencia
|
||||
- utilidades de registro
|
||||
- integración mínima con UI y/o endpoints
|
||||
- tests asociados
|
||||
|
||||
## No incluye
|
||||
- analytics de terceros
|
||||
- tracking publicitario
|
||||
- panel complejo de observabilidad
|
||||
|
||||
## Criterios de aceptación
|
||||
- ...
|
||||
- ...
|
||||
- ...
|
||||
|
||||
## Archivos a tocar
|
||||
- ...
|
||||
- ...
|
||||
|
||||
## Notas técnicas
|
||||
- mantener compatibilidad con Prisma + SQLite
|
||||
- implementación segura ante fallos
|
||||
- no romper experiencia actual
|
||||
|
||||
---
|
||||
|
||||
# Definición de Done
|
||||
|
||||
- Código implementado y tipado
|
||||
- Tests pasando
|
||||
- Sin regresiones en CRUD/búsqueda/quick add
|
||||
- UI usable en estados vacío, normal y borde
|
||||
- Lógica desacoplada en `lib/`
|
||||
252
backlog/recall-mvp4-tickets.md
Normal file
252
backlog/recall-mvp4-tickets.md
Normal file
@@ -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)
|
||||
970
backlog/recall-mvp5-tickets-detallado.md
Normal file
970
backlog/recall-mvp5-tickets-detallado.md
Normal file
@@ -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
|
||||
268
backlog/recall_mvp_2_backlog.md
Normal file
268
backlog/recall_mvp_2_backlog.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# 📌 Recall — Backlog MVP-2
|
||||
|
||||
## 🎯 Objetivo
|
||||
Convertir el MVP actual en una herramienta que permita **capturar rápido y recuperar conocimiento en segundos**.
|
||||
|
||||
---
|
||||
|
||||
# 🧩 EPIC 1 — Búsqueda y recuperación
|
||||
|
||||
## [P1] Mejorar ranking de búsqueda
|
||||
**Objetivo:** resultados relevantes ordenados inteligentemente
|
||||
|
||||
**Alcance**
|
||||
- Crear `src/lib/search.ts`
|
||||
- Implementar scoring:
|
||||
- match título exacto/parcial
|
||||
- match contenido
|
||||
- favoritos/pin
|
||||
- recencia
|
||||
- Aplicar en `/api/search` y `/api/notes`
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Coincidencias en título aparecen primero
|
||||
- Favoritos/pin influyen en ranking
|
||||
- Resultados ordenados por score
|
||||
|
||||
**Archivos**
|
||||
- `src/lib/search.ts`
|
||||
- `src/app/api/search/route.ts`
|
||||
- `src/app/api/notes/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P1] Resaltado de términos
|
||||
**Objetivo:** mejorar lectura de resultados
|
||||
|
||||
**Alcance**
|
||||
- Helper `highlightMatches`
|
||||
- Mostrar extracto con contexto
|
||||
|
||||
**Criterios**
|
||||
- Términos resaltados correctamente
|
||||
- Extracto relevante
|
||||
|
||||
---
|
||||
|
||||
## [P1] Búsqueda fuzzy básica
|
||||
**Objetivo:** tolerar errores de escritura
|
||||
|
||||
**Alcance**
|
||||
- Matching parcial y aproximado en título/tags
|
||||
|
||||
**Criterios**
|
||||
- "dokcer" encuentra "docker"
|
||||
- No degrada rendimiento
|
||||
|
||||
---
|
||||
|
||||
## [P1] Unificar lógica de búsqueda
|
||||
**Objetivo:** evitar duplicación
|
||||
|
||||
**Alcance**
|
||||
- Extraer lógica a `note-query.ts`
|
||||
|
||||
**Criterios**
|
||||
- Mismo resultado en ambos endpoints
|
||||
|
||||
---
|
||||
|
||||
# ⚡ EPIC 2 — Captura rápida
|
||||
|
||||
## [P1] Quick Add API
|
||||
**Objetivo:** crear notas desde texto único
|
||||
|
||||
**Alcance**
|
||||
- Endpoint `POST /api/notes/quick`
|
||||
- Parsear tipo y tags
|
||||
|
||||
**Criterios**
|
||||
- Prefijos funcionan (`cmd:` etc)
|
||||
- Tags se crean automáticamente
|
||||
|
||||
---
|
||||
|
||||
## [P1] Parser Quick Add
|
||||
**Objetivo:** separar lógica de parsing
|
||||
|
||||
**Alcance**
|
||||
- `src/lib/quick-add.ts`
|
||||
|
||||
**Criterios**
|
||||
- Tests unitarios
|
||||
- Normalización de tags
|
||||
|
||||
---
|
||||
|
||||
## [P1] UI Quick Add global
|
||||
**Objetivo:** captura instantánea
|
||||
|
||||
**Alcance**
|
||||
- Componente global input
|
||||
- Submit con Enter
|
||||
|
||||
**Criterios**
|
||||
- Disponible en toda la app
|
||||
- Feedback visual
|
||||
|
||||
---
|
||||
|
||||
## [P1] Autocomplete de tags
|
||||
**Objetivo:** evitar duplicados
|
||||
|
||||
**Alcance**
|
||||
- Endpoint `/api/tags`
|
||||
- Sugerencias en formulario
|
||||
|
||||
**Criterios**
|
||||
- Tags sugeridos correctamente
|
||||
- No duplicación
|
||||
|
||||
---
|
||||
|
||||
# 🧠 EPIC 3 — Relación entre notas
|
||||
|
||||
## [P1] Notas relacionadas 2.0
|
||||
**Objetivo:** relaciones útiles
|
||||
|
||||
**Alcance**
|
||||
- Mejorar `related.ts`
|
||||
- Score por tags, tipo, texto
|
||||
|
||||
**Criterios**
|
||||
- Mostrar razón de relación
|
||||
- Top resultados relevantes
|
||||
|
||||
---
|
||||
|
||||
## [P1] Backlinks automáticos
|
||||
**Objetivo:** navegación entre notas
|
||||
|
||||
**Alcance**
|
||||
- Detectar menciones
|
||||
- Endpoint backlinks
|
||||
|
||||
**Criterios**
|
||||
- Relación bidireccional
|
||||
|
||||
---
|
||||
|
||||
## [P1] Links [[nota]]
|
||||
**Objetivo:** crear conocimiento conectado
|
||||
|
||||
**Alcance**
|
||||
- Autocomplete en editor
|
||||
- Render como enlace
|
||||
|
||||
**Criterios**
|
||||
- Navegación funcional
|
||||
|
||||
---
|
||||
|
||||
# 🧩 EPIC 4 — Plantillas
|
||||
|
||||
## [P2] Plantillas inteligentes
|
||||
**Objetivo:** acelerar creación
|
||||
|
||||
**Alcance**
|
||||
- Expandir `templates.ts`
|
||||
|
||||
**Criterios**
|
||||
- Template por tipo
|
||||
- No sobrescribe contenido
|
||||
|
||||
---
|
||||
|
||||
## [P2] Campos asistidos
|
||||
**Objetivo:** mejorar UX
|
||||
|
||||
**Alcance**
|
||||
- Inputs guiados por tipo
|
||||
|
||||
**Criterios**
|
||||
- Serialización a markdown
|
||||
|
||||
---
|
||||
|
||||
# 🧪 EPIC 5 — UX por tipo
|
||||
|
||||
## [P2] Vista command
|
||||
- Botón copiar
|
||||
|
||||
## [P2] Vista snippet
|
||||
- Syntax highlight
|
||||
|
||||
## [P2] Checklist procedure
|
||||
- Check interactivo
|
||||
|
||||
---
|
||||
|
||||
# 🏷️ EPIC 6 — Tags
|
||||
|
||||
## [P1] Normalización de tags
|
||||
**Objetivo:** evitar duplicados
|
||||
|
||||
**Alcance**
|
||||
- lowercase + trim
|
||||
|
||||
**Criterios**
|
||||
- Tags únicos consistentes
|
||||
|
||||
---
|
||||
|
||||
## [P2] Sugerencias de tags
|
||||
**Objetivo:** mejorar captura
|
||||
|
||||
**Alcance**
|
||||
- Endpoint `/api/tags/suggest`
|
||||
|
||||
---
|
||||
|
||||
# 🧪 EPIC 7 — Calidad
|
||||
|
||||
## [P1] Tests unitarios
|
||||
- search
|
||||
- quick-add
|
||||
- tags
|
||||
|
||||
## [P1] Tests integración
|
||||
- APIs principales
|
||||
|
||||
## [P2] Manejo de errores API
|
||||
- formato estándar
|
||||
|
||||
---
|
||||
|
||||
# 🗺️ Orden de ejecución
|
||||
|
||||
## Sprint 1
|
||||
- Ranking búsqueda
|
||||
- Quick Add (API + parser + UI)
|
||||
- Normalización tags
|
||||
- Tests base
|
||||
|
||||
## Sprint 2
|
||||
- Autocomplete tags
|
||||
- Relacionadas
|
||||
- Backlinks
|
||||
- Links [[nota]]
|
||||
|
||||
## Sprint 3
|
||||
- Highlight
|
||||
- Fuzzy search
|
||||
- Templates
|
||||
|
||||
## Sprint 4
|
||||
- UX tipos
|
||||
- Tags suggest
|
||||
- API errors
|
||||
|
||||
---
|
||||
|
||||
# ✅ Definición de Done
|
||||
|
||||
- Código testeado
|
||||
- API consistente
|
||||
- UI usable sin errores
|
||||
- No regresiones en CRUD existente
|
||||
|
||||
25
components.json
Normal file
25
components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=file:./data/dev.db
|
||||
restart: unless-stopped
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
27
jest.config.js
Normal file
27
jest.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': ['ts-jest', {
|
||||
tsconfig: {
|
||||
jsx: 'react-jsx',
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.test.ts',
|
||||
'**/__tests__/**/*.test.tsx',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.test.{ts,tsx}',
|
||||
],
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
761
mvp_recall.md
Normal file
761
mvp_recall.md
Normal file
@@ -0,0 +1,761 @@
|
||||
# MVP — Gestor de conocimiento personal práctico
|
||||
|
||||
Documento de ejecución pensado para usar directamente con Claude Code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo del MVP
|
||||
|
||||
Construir una aplicación web local-first para gestión de conocimiento personal enfocada en **captura rápida**, **relación automática** y **recuperación útil**.
|
||||
|
||||
No es una app de notas genérica. El MVP debe resolver bien estos casos:
|
||||
|
||||
- guardar comandos frecuentes
|
||||
- guardar snippets de código
|
||||
- registrar decisiones técnicas
|
||||
- guardar recetas
|
||||
- guardar trámites o procedimientos
|
||||
- mantener inventario doméstico simple
|
||||
|
||||
La propuesta de valor del MVP es:
|
||||
|
||||
1. **Guardar info rápido** con fricción mínima.
|
||||
2. **Relacionarla sola** usando metadatos, enlaces sugeridos y contenido similar.
|
||||
3. **Devolverla cuando importa** mediante búsqueda potente y vistas útiles.
|
||||
|
||||
---
|
||||
|
||||
## 2. Principios del producto
|
||||
|
||||
### 2.1 Principios funcionales
|
||||
|
||||
- Crear una nota debe tomar menos de 10 segundos.
|
||||
- La búsqueda debe encontrar contenido por título, texto, tags y tipo.
|
||||
- La app debe sugerir relaciones sin exigir organización manual compleja.
|
||||
- El sistema debe ser útil desde el día 1 con pocas notas.
|
||||
- Debe funcionar bien para conocimiento práctico, no solo escritura larga.
|
||||
|
||||
### 2.2 Principios técnicos
|
||||
|
||||
- **Local-first**: los datos viven primero en el dispositivo.
|
||||
- **Simple de desplegar y mantener**.
|
||||
- **Escalable por capas**: empezar pequeño sin bloquear futuras mejoras.
|
||||
- **Estructura tipada**: tipos de contenido claros.
|
||||
- **Extensible** para futuro tagging semántico, embeddings y sincronización.
|
||||
|
||||
---
|
||||
|
||||
## 3. Alcance del MVP
|
||||
|
||||
### 3.1 Incluye
|
||||
|
||||
#### Captura
|
||||
- Crear nota rápida desde un input o modal.
|
||||
- Campos mínimos:
|
||||
- título
|
||||
- contenido
|
||||
- tipo
|
||||
- tags manuales opcionales
|
||||
- Crear desde plantillas simples según tipo.
|
||||
|
||||
#### Tipos de nota del MVP
|
||||
- `command`
|
||||
- `snippet`
|
||||
- `decision`
|
||||
- `recipe`
|
||||
- `procedure`
|
||||
- `inventory`
|
||||
- `note` (genérico)
|
||||
|
||||
#### Organización automática
|
||||
- Extracción automática de:
|
||||
- fecha de creación
|
||||
- fecha de actualización
|
||||
- tipo
|
||||
- tags sugeridos por heurística simple
|
||||
- Detección de notas relacionadas por:
|
||||
- coincidencia de tags
|
||||
- similitud de título
|
||||
- palabras clave compartidas
|
||||
- mismo tipo
|
||||
|
||||
#### Recuperación
|
||||
- Búsqueda full-text.
|
||||
- Filtros por tipo y tags.
|
||||
- Vista de resultados ordenada por relevancia simple.
|
||||
- Vista de “relacionadas” dentro de cada nota.
|
||||
- Vista de “recientes”.
|
||||
|
||||
#### Edición
|
||||
- Editar nota existente.
|
||||
- Eliminar nota.
|
||||
- Marcar favorita.
|
||||
- Pin opcional para destacar notas críticas.
|
||||
|
||||
#### Persistencia
|
||||
- Base local con SQLite.
|
||||
- Exportar/importar JSON.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 No incluye en el MVP
|
||||
|
||||
- colaboración multiusuario
|
||||
- sincronización en la nube
|
||||
- permisos y autenticación compleja
|
||||
- edición en tiempo real
|
||||
- embeddings/vector DB en producción
|
||||
- OCR
|
||||
- app móvil nativa
|
||||
- plugins
|
||||
- automatizaciones complejas
|
||||
- parser avanzado de documentos adjuntos
|
||||
|
||||
---
|
||||
|
||||
## 4. Stack sugerido
|
||||
|
||||
## Frontend
|
||||
- **Next.js 15** con App Router
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS**
|
||||
- **shadcn/ui** para componentes
|
||||
|
||||
## Backend
|
||||
- API routes / server actions de Next.js
|
||||
- **Prisma** como ORM
|
||||
- **SQLite** para MVP
|
||||
|
||||
## Búsqueda
|
||||
- inicialmente con consultas SQL + normalización simple
|
||||
- opcional: SQLite FTS si da tiempo
|
||||
|
||||
## Validación
|
||||
- **Zod**
|
||||
|
||||
## Estado
|
||||
- React server components + estado local mínimo
|
||||
- si hace falta: Zustand muy limitado
|
||||
|
||||
## Testing
|
||||
- **Vitest** para lógica
|
||||
- **Playwright** para flujo principal si alcanza
|
||||
|
||||
## Motivo de esta elección
|
||||
|
||||
Este stack permite:
|
||||
- iterar rápido
|
||||
- mantener una sola codebase
|
||||
- ejecutar localmente fácil
|
||||
- migrar luego a Postgres sin rehacer todo
|
||||
|
||||
---
|
||||
|
||||
## 5. Arquitectura funcional
|
||||
|
||||
### 5.1 Entidades principales
|
||||
|
||||
#### Note
|
||||
Campos sugeridos:
|
||||
- `id`
|
||||
- `title`
|
||||
- `content`
|
||||
- `type`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
- `isFavorite`
|
||||
- `isPinned`
|
||||
|
||||
#### Tag
|
||||
- `id`
|
||||
- `name`
|
||||
|
||||
#### NoteTag
|
||||
- `noteId`
|
||||
- `tagId`
|
||||
|
||||
#### RelatedNote
|
||||
- `id`
|
||||
- `sourceNoteId`
|
||||
- `targetNoteId`
|
||||
- `score`
|
||||
- `reason`
|
||||
|
||||
Opcional en MVP si se prefiere calcular en runtime en vez de persistir.
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Modelo de datos recomendado
|
||||
|
||||
```prisma
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String
|
||||
type NoteType @default(note)
|
||||
isFavorite Boolean @default(false)
|
||||
isPinned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tags NoteTag[]
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
notes NoteTag[]
|
||||
}
|
||||
|
||||
model NoteTag {
|
||||
noteId String
|
||||
tagId String
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([noteId, tagId])
|
||||
}
|
||||
|
||||
enum NoteType {
|
||||
command
|
||||
snippet
|
||||
decision
|
||||
recipe
|
||||
procedure
|
||||
inventory
|
||||
note
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UX mínima del MVP
|
||||
|
||||
### 6.1 Pantallas
|
||||
|
||||
#### Home / Dashboard
|
||||
Debe mostrar:
|
||||
- barra de búsqueda principal
|
||||
- botón “Nueva nota”
|
||||
- sección de notas recientes
|
||||
- sección de favoritas o pineadas
|
||||
- filtros rápidos por tipo
|
||||
|
||||
#### Lista de notas
|
||||
- búsqueda
|
||||
- filtros por tipo
|
||||
- filtros por tags
|
||||
- cards compactas con:
|
||||
- título
|
||||
- tipo
|
||||
- preview corta
|
||||
- tags
|
||||
- fecha de actualización
|
||||
|
||||
#### Detalle de nota
|
||||
- título
|
||||
- tipo
|
||||
- contenido
|
||||
- tags
|
||||
- acciones: editar, borrar, favorita, pin
|
||||
- bloque “Notas relacionadas”
|
||||
|
||||
#### Crear/editar nota
|
||||
- formulario simple
|
||||
- selector de tipo
|
||||
- área de contenido grande
|
||||
- campo de tags opcional
|
||||
- templates por tipo
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Flujo ideal
|
||||
|
||||
1. Usuario abre app.
|
||||
2. Escribe una nota nueva en segundos.
|
||||
3. La nota se guarda localmente.
|
||||
4. El sistema sugiere tags y notas relacionadas.
|
||||
5. Días después el usuario la encuentra por búsqueda, filtro o relación.
|
||||
|
||||
---
|
||||
|
||||
## 7. Lógica de negocio del MVP
|
||||
|
||||
### 7.1 Reglas de creación
|
||||
|
||||
Al crear una nota:
|
||||
- validar que título y contenido no estén vacíos
|
||||
- normalizar espacios
|
||||
- guardar tipo
|
||||
- parsear tags manuales si existen
|
||||
- generar tags sugeridos por heurística opcional
|
||||
|
||||
### 7.2 Heurísticas de tags sugeridos
|
||||
|
||||
Heurística simple inicial:
|
||||
- extraer palabras frecuentes relevantes del título
|
||||
- detectar bloques de código para sugerir `code`, `bash`, `sql`, etc.
|
||||
- detectar patrones:
|
||||
- receta → `cocina`
|
||||
- decisión → `arquitectura`, `backend`, etc. según keywords
|
||||
- inventario → `hogar`
|
||||
|
||||
No hace falta IA real en MVP. Debe ser determinístico y simple.
|
||||
|
||||
### 7.3 Cálculo de relacionadas
|
||||
|
||||
Implementación simple:
|
||||
- +3 puntos si comparten tipo
|
||||
- +2 por cada tag compartido
|
||||
- +1 por palabra relevante compartida en título
|
||||
- +1 si una keyword del contenido aparece en ambas
|
||||
|
||||
Mostrar top 5 relacionadas con score > umbral.
|
||||
|
||||
Se puede calcular:
|
||||
- on-demand en el detalle de la nota, o
|
||||
- al guardar/editar la nota
|
||||
|
||||
Para el MVP, **on-demand** es suficiente.
|
||||
|
||||
### 7.4 Búsqueda
|
||||
|
||||
Primera versión:
|
||||
- buscar en `title`
|
||||
- buscar en `content`
|
||||
- buscar en nombres de tags
|
||||
- permitir filtro por `type`
|
||||
|
||||
Orden sugerido:
|
||||
1. coincidencia en título
|
||||
2. coincidencia en tags
|
||||
3. coincidencia en contenido
|
||||
4. updatedAt desc
|
||||
|
||||
---
|
||||
|
||||
## 8. Templates por tipo
|
||||
|
||||
### command
|
||||
Campos base:
|
||||
- título
|
||||
- comando
|
||||
- explicación
|
||||
- contexto de uso
|
||||
|
||||
Template de contenido sugerido:
|
||||
```md
|
||||
## Comando
|
||||
|
||||
## Qué hace
|
||||
|
||||
## Cuándo usarlo
|
||||
|
||||
## Ejemplo
|
||||
```
|
||||
|
||||
### snippet
|
||||
```md
|
||||
## Snippet
|
||||
|
||||
## Lenguaje
|
||||
|
||||
## Qué resuelve
|
||||
|
||||
## Notas
|
||||
```
|
||||
|
||||
### decision
|
||||
```md
|
||||
## Contexto
|
||||
|
||||
## Decisión
|
||||
|
||||
## Alternativas consideradas
|
||||
|
||||
## Consecuencias
|
||||
```
|
||||
|
||||
### recipe
|
||||
```md
|
||||
## Ingredientes
|
||||
|
||||
## Pasos
|
||||
|
||||
## Tiempo
|
||||
|
||||
## Notas
|
||||
```
|
||||
|
||||
### procedure
|
||||
```md
|
||||
## Objetivo
|
||||
|
||||
## Pasos
|
||||
|
||||
## Requisitos
|
||||
|
||||
## Problemas comunes
|
||||
```
|
||||
|
||||
### inventory
|
||||
```md
|
||||
## Item
|
||||
|
||||
## Cantidad
|
||||
|
||||
## Ubicación
|
||||
|
||||
## Notas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Estructura de carpetas sugerida
|
||||
|
||||
```txt
|
||||
src/
|
||||
app/
|
||||
page.tsx
|
||||
notes/
|
||||
page.tsx
|
||||
[id]/page.tsx
|
||||
new/page.tsx
|
||||
edit/[id]/page.tsx
|
||||
api/
|
||||
notes/route.ts
|
||||
notes/[id]/route.ts
|
||||
search/route.ts
|
||||
components/
|
||||
note-form.tsx
|
||||
note-card.tsx
|
||||
note-list.tsx
|
||||
search-bar.tsx
|
||||
related-notes.tsx
|
||||
filters.tsx
|
||||
dashboard.tsx
|
||||
lib/
|
||||
prisma.ts
|
||||
db.ts
|
||||
search.ts
|
||||
related.ts
|
||||
tags.ts
|
||||
templates.ts
|
||||
validators.ts
|
||||
types/
|
||||
note.ts
|
||||
prisma/
|
||||
schema.prisma
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. API / acciones mínimas
|
||||
|
||||
### CRUD de notas
|
||||
- `GET /api/notes`
|
||||
- `POST /api/notes`
|
||||
- `GET /api/notes/:id`
|
||||
- `PUT /api/notes/:id`
|
||||
- `DELETE /api/notes/:id`
|
||||
|
||||
### búsqueda
|
||||
- `GET /api/search?q=...&type=...&tag=...`
|
||||
|
||||
### export/import
|
||||
- `GET /api/export`
|
||||
- `POST /api/import`
|
||||
|
||||
---
|
||||
|
||||
## 11. Historias de usuario principales
|
||||
|
||||
### HU1 — Crear nota rápida
|
||||
Como usuario,
|
||||
quiero crear una nota en pocos segundos,
|
||||
para no perder información útil.
|
||||
|
||||
Criterios de aceptación:
|
||||
- puedo crear una nota con título, contenido y tipo
|
||||
- queda persistida localmente
|
||||
- aparece en recientes inmediatamente
|
||||
|
||||
### HU2 — Buscar conocimiento guardado
|
||||
Como usuario,
|
||||
quiero encontrar una nota por texto libre,
|
||||
para recuperar información cuando la necesito.
|
||||
|
||||
Criterios:
|
||||
- la búsqueda encuentra coincidencias en título y contenido
|
||||
- puedo filtrar por tipo
|
||||
- resultados aparecen rápido
|
||||
|
||||
### HU3 — Ver contenido relacionado
|
||||
Como usuario,
|
||||
quiero que el sistema me muestre notas relacionadas,
|
||||
para redescubrir información útil.
|
||||
|
||||
Criterios:
|
||||
- cada nota muestra hasta 5 relacionadas
|
||||
- la relación se basa en reglas simples entendibles
|
||||
|
||||
### HU4 — Usar plantillas
|
||||
Como usuario,
|
||||
quiero crear notas con estructura base según el tipo,
|
||||
para capturar mejor cada caso.
|
||||
|
||||
Criterios:
|
||||
- al elegir un tipo puedo cargar un template sugerido
|
||||
- puedo editar el template libremente
|
||||
|
||||
### HU5 — Exportar datos
|
||||
Como usuario,
|
||||
quiero exportar mis datos,
|
||||
para no quedar atado a la app.
|
||||
|
||||
Criterios:
|
||||
- exporta a JSON válido
|
||||
- puedo reimportar ese JSON
|
||||
|
||||
---
|
||||
|
||||
## 12. Roadmap de implementación del MVP
|
||||
|
||||
### Fase 1 — Base funcional
|
||||
Objetivo: tener CRUD y persistencia.
|
||||
|
||||
Entregables:
|
||||
- setup Next.js + Tailwind + Prisma + SQLite
|
||||
- schema Prisma
|
||||
- migraciones
|
||||
- CRUD básico de notas
|
||||
- listado y detalle
|
||||
- formulario crear/editar
|
||||
|
||||
### Fase 2 — Búsqueda y filtros
|
||||
Objetivo: recuperar bien.
|
||||
|
||||
Entregables:
|
||||
- search por título/contenido
|
||||
- filtros por tipo
|
||||
- filtros por tags
|
||||
- home con recientes y favoritas
|
||||
|
||||
### Fase 3 — Relación automática
|
||||
Objetivo: conectar conocimiento.
|
||||
|
||||
Entregables:
|
||||
- heurística de tags sugeridos
|
||||
- cálculo de relacionadas
|
||||
- UI de relacionadas en detalle
|
||||
|
||||
### Fase 4 — Pulido MVP
|
||||
Objetivo: dejarlo presentable y usable.
|
||||
|
||||
Entregables:
|
||||
- templates por tipo
|
||||
- export/import JSON
|
||||
- validaciones
|
||||
- estados vacíos
|
||||
- seed de ejemplo
|
||||
|
||||
---
|
||||
|
||||
## 13. Definición de terminado
|
||||
|
||||
El MVP está listo cuando:
|
||||
- se puede crear, editar y borrar notas
|
||||
- las notas se persisten en SQLite
|
||||
- se puede buscar por texto
|
||||
- se puede filtrar por tipo y tags
|
||||
- cada nota muestra relacionadas
|
||||
- existen templates por tipo
|
||||
- existe export/import JSON
|
||||
- la UI es suficientemente clara para uso diario local
|
||||
|
||||
---
|
||||
|
||||
## 14. Riesgos y cómo reducirlos
|
||||
|
||||
### Riesgo 1: demasiada ambición
|
||||
Mitigación:
|
||||
- no agregar sync ni IA real al MVP
|
||||
- priorizar velocidad de uso y recuperación
|
||||
|
||||
### Riesgo 2: búsqueda pobre
|
||||
Mitigación:
|
||||
- priorizar calidad de búsqueda antes de features cosméticas
|
||||
- evaluar SQLite FTS si la búsqueda simple queda corta
|
||||
|
||||
### Riesgo 3: relaciones poco útiles
|
||||
Mitigación:
|
||||
- mantener heurísticas transparentes
|
||||
- mostrar razones simples del match en una versión futura
|
||||
|
||||
### Riesgo 4: modelo demasiado genérico
|
||||
Mitigación:
|
||||
- usar tipos concretos desde el inicio
|
||||
- mantener `note` genérico solo como fallback
|
||||
|
||||
---
|
||||
|
||||
## 15. Mejoras post-MVP
|
||||
|
||||
- sincronización entre dispositivos
|
||||
- embeddings para similitud semántica real
|
||||
- parser de comandos/snippets automático
|
||||
- extensión de navegador para guardar rápido
|
||||
- captura por share sheet móvil
|
||||
- recordatorios contextuales
|
||||
- grafos de relación
|
||||
- OCR y adjuntos
|
||||
- versionado de notas
|
||||
- vistas especializadas por tipo
|
||||
|
||||
---
|
||||
|
||||
## 16. Prompt maestro para ejecutar en Claude Code
|
||||
|
||||
Usar este prompt como instrucción principal:
|
||||
|
||||
```txt
|
||||
Quiero que construyas un MVP funcional de una aplicación llamada “Gestor de conocimiento personal práctico”.
|
||||
|
||||
Objetivo del producto:
|
||||
- guardar info rápido
|
||||
- relacionarla sola
|
||||
- devolverla cuando importa
|
||||
|
||||
Casos de uso principales:
|
||||
- comandos
|
||||
- snippets
|
||||
- decisiones técnicas
|
||||
- recetas
|
||||
- trámites/procedimientos
|
||||
- inventario doméstico
|
||||
|
||||
Stack requerido:
|
||||
- Next.js 15 con App Router
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- shadcn/ui
|
||||
- Prisma
|
||||
- SQLite
|
||||
- Zod
|
||||
|
||||
Requisitos funcionales del MVP:
|
||||
1. CRUD completo de notas.
|
||||
2. Tipos de nota: command, snippet, decision, recipe, procedure, inventory, note.
|
||||
3. Formulario de creación/edición con título, contenido, tipo y tags opcionales.
|
||||
4. Dashboard con búsqueda, recientes y favoritas/pineadas.
|
||||
5. Lista de notas con filtros por tipo y tags.
|
||||
6. Búsqueda por título, contenido y tags.
|
||||
7. Página de detalle con notas relacionadas.
|
||||
8. Templates simples por tipo.
|
||||
9. Exportar e importar JSON.
|
||||
10. Persistencia local usando SQLite.
|
||||
|
||||
Reglas de relacionadas:
|
||||
- +3 si comparten tipo
|
||||
- +2 por cada tag compartido
|
||||
- +1 por palabra relevante compartida en el título
|
||||
- +1 por keyword compartida en el contenido
|
||||
- mostrar top 5 con score suficiente
|
||||
|
||||
Restricciones:
|
||||
- No implementar autenticación.
|
||||
- No implementar sync cloud.
|
||||
- No implementar IA compleja ni vector DB.
|
||||
- Mantener el código limpio, modular y listo para evolucionar.
|
||||
|
||||
Quiero que generes:
|
||||
1. La estructura inicial del proyecto.
|
||||
2. El schema de Prisma.
|
||||
3. Los componentes principales.
|
||||
4. Las rutas/páginas necesarias.
|
||||
5. Las utilidades para búsqueda, tags y relacionadas.
|
||||
6. Un seed de datos de ejemplo.
|
||||
7. Instrucciones claras para correr el proyecto.
|
||||
|
||||
Además:
|
||||
- usa buenas prácticas
|
||||
- separa responsabilidades
|
||||
- evita sobreingeniería
|
||||
- deja comentarios donde aporten claridad
|
||||
- entrega una primera versión funcional de punta a punta
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Prompt por etapas para Claude Code
|
||||
|
||||
### Etapa 1 — Setup base
|
||||
|
||||
```txt
|
||||
Crea el proyecto base con Next.js 15, TypeScript, Tailwind, Prisma y SQLite. Configura la estructura de carpetas, instala dependencias, crea el schema inicial de Prisma para Note, Tag y NoteTag, genera migración y deja una app corriendo con una página home básica.
|
||||
```
|
||||
|
||||
### Etapa 2 — CRUD
|
||||
|
||||
```txt
|
||||
Implementa el CRUD completo de notas. Debe existir listado, detalle, creación, edición y borrado. Cada nota debe tener title, content, type, tags opcionales, isFavorite e isPinned. Usa validación con Zod.
|
||||
```
|
||||
|
||||
### Etapa 3 — Búsqueda y filtros
|
||||
|
||||
```txt
|
||||
Implementa búsqueda por texto sobre título, contenido y tags. Agrega filtros por tipo y tags. Crea una home con notas recientes, favoritas y acceso rápido a crear nota.
|
||||
```
|
||||
|
||||
### Etapa 4 — Relacionadas
|
||||
|
||||
```txt
|
||||
Implementa la lógica de notas relacionadas usando reglas heurísticas simples. Muestra hasta 5 notas relacionadas en la vista de detalle.
|
||||
```
|
||||
|
||||
### Etapa 5 — Templates y export/import
|
||||
|
||||
```txt
|
||||
Agrega templates por tipo de nota y funciones para exportar e importar datos en JSON. Incluye manejo básico de errores y estados vacíos.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Checklist de validación manual
|
||||
|
||||
- [ ] La app inicia sin errores.
|
||||
- [ ] Se puede crear una nota.
|
||||
- [ ] Se puede editar una nota.
|
||||
- [ ] Se puede eliminar una nota.
|
||||
- [ ] La búsqueda encuentra texto por título.
|
||||
- [ ] La búsqueda encuentra texto por contenido.
|
||||
- [ ] Se puede filtrar por tipo.
|
||||
- [ ] Las tags se guardan y se pueden usar como filtro.
|
||||
- [ ] El detalle muestra relacionadas.
|
||||
- [ ] Se puede exportar JSON.
|
||||
- [ ] Se puede importar JSON.
|
||||
- [ ] Hay seed de ejemplo útil para probar.
|
||||
|
||||
---
|
||||
|
||||
## 19. Criterio de éxito del MVP
|
||||
|
||||
El MVP será exitoso si una persona puede usarlo durante una semana para guardar y recuperar conocimiento práctico sin sentir que necesita otra herramienta para:
|
||||
- recordar comandos
|
||||
- reutilizar snippets
|
||||
- revisar decisiones
|
||||
- seguir procedimientos
|
||||
- consultar inventario o recetas
|
||||
|
||||
---
|
||||
|
||||
## 20. Recomendación final
|
||||
|
||||
Para este MVP conviene optimizar en este orden:
|
||||
|
||||
1. velocidad de captura
|
||||
2. calidad de búsqueda
|
||||
3. utilidad de relacionadas
|
||||
4. claridad visual
|
||||
5. exportabilidad
|
||||
|
||||
No priorizar IA antes de demostrar que la base manual + heurística ya resuelve valor real.
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
13997
package-lock.json
generated
Normal file
13997
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
package.json
Normal file
54
package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "recall",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:seed": "npx tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.2.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"string-similarity": "^4.0.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.19.37",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/string-similarity": "^4.0.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"jest": "^30.3.0",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^4",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
30
prisma/migrations/20260322122659_init/migration.sql
Normal file
30
prisma/migrations/20260322122659_init/migration.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Note" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL DEFAULT 'note',
|
||||
"isFavorite" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoteTag" (
|
||||
"noteId" TEXT NOT NULL,
|
||||
"tagId" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("noteId", "tagId"),
|
||||
CONSTRAINT "NoteTag_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "NoteTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
90
prisma/schema.prisma
Normal file
90
prisma/schema.prisma
Normal file
@@ -0,0 +1,90 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
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") // 'form' | 'quick' | 'import'
|
||||
tags NoteTag[]
|
||||
backlinks Backlink[] @relation("BacklinkTarget")
|
||||
outbound Backlink[] @relation("BacklinkSource")
|
||||
usageEvents NoteUsage[]
|
||||
coUsageFrom NoteCoUsage[] @relation("CoUsageFrom")
|
||||
coUsageTo NoteCoUsage[] @relation("CoUsageTo")
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
notes NoteTag[]
|
||||
}
|
||||
|
||||
model NoteTag {
|
||||
noteId String
|
||||
tagId String
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([noteId, tagId])
|
||||
}
|
||||
|
||||
model Backlink {
|
||||
id String @id @default(cuid())
|
||||
sourceNoteId String
|
||||
targetNoteId String
|
||||
sourceNote Note @relation("BacklinkSource", fields: [sourceNoteId], references: [id], onDelete: Cascade)
|
||||
targetNote Note @relation("BacklinkTarget", fields: [targetNoteId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([sourceNoteId, targetNoteId])
|
||||
}
|
||||
|
||||
model NoteUsage {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
eventType String // 'view' | 'search_click' | 'related_click' | 'link_click'
|
||||
query String?
|
||||
metadata String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([noteId, 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])
|
||||
}
|
||||
118
prisma/seed-examples.ts
Normal file
118
prisma/seed-examples.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Seed script con 50 ejemplos para probar búsquedas
|
||||
* Usage: npx tsx prisma/seed-examples.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const examples = [
|
||||
// Commands
|
||||
{ type: 'command', title: 'Git commit with message', content: '## Comando\n\ngit commit -m "fix: resolve bug"\n\n## Qué hace\n\nCreates a commit with a message\n\n## Ejemplo\n\n```bash\ngit commit -m "feat: add new feature"\n```', tags: ['git', 'version-control'] },
|
||||
{ type: 'command', title: 'Docker remove all containers', content: '## Comando\n\ndocker rm -f $(docker ps -aq)\n\n## Qué hace\n\nForce removes all containers\n\n## Ejemplo\n\n```bash\ndocker rm -f $(docker ps -aq)\n```', tags: ['docker', 'devops'] },
|
||||
{ type: 'command', title: 'Find files modified today', content: '## Comando\n\nfind . -type f -mtime 0\n\n## Qué hace\n\nFinds files modified in the last 24 hours\n\n## Ejemplo\n\n```bash\nfind . -type f -mtime -1\n```', tags: ['bash', 'shell'] },
|
||||
{ type: 'command', title: 'Kill process on port', content: '## Comando\n\nlsof -ti:<port> | xargs kill -9\n\n## Qué hace\n\nKills process running on specified port\n\n## Ejemplo\n\n```bash\nlsof -ti:3000 | xargs kill -9\n```', tags: ['bash', 'network'] },
|
||||
{ type: 'command', title: 'Git undo last commit', content: '## Comando\n\ngit reset --soft HEAD~1\n\n## Qué hace\n\nUndoes the last commit keeping changes staged\n\n## Ejemplo\n\n```bash\ngit reset --soft HEAD~1\n```', tags: ['git'] },
|
||||
{ type: 'command', title: 'NPM install specific version', content: '## Comando\n\nnpm install <package>@<version>\n\n## Qué hace\n\nInstalls a specific version of a package\n\n## Ejemplo\n\n```bash\nnpm install lodash@4.17.21\n```', tags: ['npm', 'node'] },
|
||||
{ type: 'command', title: 'Rsync with ssh', content: '## Comando\n\nrsync -avz -e ssh source/ user@host:dest/\n\n## Qué hace\n\nSyncs files via SSH\n\n## Ejemplo\n\n```bash\nrsync -avz -e ssh ./data/ user@server:/backup/\n```', tags: ['bash', 'network'] },
|
||||
|
||||
// Snippets
|
||||
{ type: 'snippet', title: 'React useState hook', content: '## Snippet\n\n## Lenguaje\n\ntypescript\n\n## Código\n\n```typescript\nconst [state, setState] = useState(initialValue)\n```\n\n## Descripción\n\nBasic React state hook usage', tags: ['react', 'hooks'] },
|
||||
{ type: 'snippet', title: 'Python list comprehension', content: '## Snippet\n\n## Lenguaje\n\npython\n\n## Código\n\n```python\nsquares = [x**2 for x in range(10)]\n```\n\n## Descripción\n\nCreate list of squares using comprehension', tags: ['python'] },
|
||||
{ type: 'snippet', title: 'CSS flexbox centering', content: '## Snippet\n\n## Lenguaje\n\ncss\n\n## Código\n\n```css\ndisplay: flex;\njustify-content: center;\nalign-items: center;\n```\n\n## Descripción\n\nCenter element horizontally and vertically', tags: ['css', 'flexbox'] },
|
||||
{ type: 'snippet', title: 'JavaScript async await', content: '## Snippet\n\n## Lenguaje\n\njavascript\n\n## Código\n\n```javascript\nconst result = await fetchData()\nconsole.log(result)\n```\n\n## Descripción\n\nAsync/await pattern for Promises', tags: ['javascript', 'async'] },
|
||||
{ type: 'snippet', title: 'SQL SELECT with JOIN', content: '## Snippet\n\n## Lenguaje\n\nsql\n\n## Código\n\n```sql\nSELECT n.*, t.name as tag\nFROM notes n\nJOIN note_tags nt ON n.id = nt.note_id\nJOIN tags t ON nt.tag_id = t.id\n```\n\n## Descripción\n\nJoin notes with tags', tags: ['sql', 'database'] },
|
||||
{ type: 'snippet', title: 'Go error handling', content: '## Snippet\n\n## Lenguaje\n\ngo\n\n## Código\n\n```go\nif err != nil {\n return fmt.Errorf("failed: %w", err)\n}\n```\n\n## Descripción\n\nStandard Go error handling with wrapping', tags: ['go'] },
|
||||
{ type: 'snippet', title: 'Bash function', content: '## Snippet\n\n## Lenguaje\n\nbash\n\n## Código\n\n```bash\nfunction greet() {\n echo "Hello, $1!"\n}\n```\n\n## Descripción\n\nBasic bash function with parameter', tags: ['bash', 'shell'] },
|
||||
|
||||
// Decisions
|
||||
{ type: 'decision', title: 'Use PostgreSQL over MySQL', content: '## Contexto\n\nNeed a relational database for the backend API\n\n## Decisión\n\nChose PostgreSQL for its advanced features\n\n## Alternativas consideradas\n\nMySQL - simpler but less features\nSQLite - embedded, not for production\n\n## Consecuencias\n\nBetter data integrity, JSON support, Full-text search built-in', tags: ['database', 'backend', 'postgresql'] },
|
||||
{ type: 'decision', title: 'Use Next.js App Router', content: '## Contexto\n\nStarting a new full-stack project\n\n## Decisión\n\nNext.js with App Router for server components\n\n## Alternativas consideradas\n\nExpress - more control but manual setup\nRemix - good but smaller ecosystem\n\n## Consecuencias\n\nBetter SEO, easier deployment, React Server Components', tags: ['frontend', 'react', 'architecture'] },
|
||||
{ type: 'decision', title: 'Use TypeScript strict mode', content: '## Contexto\n\nSetting up a new TypeScript project\n\n## Decisión\n\nEnable strict mode from the start\n\n## Alternativas consideradas\n\nDisable strict initially - leads to tech debt\n\n## Consecuencias\n\nCatches errors early, better code quality', tags: ['typescript', 'code-quality'] },
|
||||
{ type: 'decision', title: 'Use Tailwind CSS', content: '## Contexto\n\nNeed a styling solution for React project\n\n## Decisión\n\nTailwind CSS for rapid development\n\n## Alternativas consideradas\n\nStyled Components - runtime overhead\nCSS Modules - more setup\n\n## Consecuencias\n\nFaster development, consistent design, smaller bundle', tags: ['css', 'frontend'] },
|
||||
|
||||
// Recipes
|
||||
{ type: 'recipe', title: 'Pasta carbonara', content: '## Ingredientes\n\n- 400g spaghetti\n- 200g guanciale\n- 4 egg yolks\n- 100g pecorino romano\n- Black pepper\n\n## Pasos\n\n1. Cook pasta in salted water\n2. Cut guanciale into cubes\n3. Fry guanciale until crispy\n4. Mix egg yolks with cheese\n5. Combine hot pasta with guanciale\n6. Add egg mixture off heat\n7. Toss until creamy\n\n## Tiempo\n\n20 minutes', tags: ['cocina', 'italiana', 'pasta'] },
|
||||
{ type: 'recipe', title: 'Guacamole', content: '## Ingredientes\n\n- 3 avocados\n- 1 lime\n- 1 onion\n- 2 tomatoes\n- Cilantro\n- Salt\n\n## Pasos\n\n1. Cut avocados and mash\n2. Dice onion and tomatoes finely\n3. Add lime juice\n4. Chop cilantro\n5. Mix all ingredients\n6. Season to taste\n\n## Tiempo\n\n10 minutes', tags: ['cocina', 'mexicana'] },
|
||||
{ type: 'recipe', title: 'Chicken stir fry', content: '## Ingredientes\n\n- 500g chicken breast\n- 2 bell peppers\n- 1 broccoli\n- Soy sauce\n- Ginger\n- Garlic\n\n## Pasos\n\n1. Cut chicken into strips\n2. Chop vegetables\n3. Stir fry chicken until cooked\n4. Add vegetables\n5. Add soy sauce and spices\n6. Serve with rice\n\n## Tiempo\n\n25 minutes', tags: ['cocina', 'asiatica'] },
|
||||
|
||||
// Procedures
|
||||
{ type: 'procedure', title: 'Deploy to Vercel', content: '## Objetivo\n\nDeploy Next.js app to Vercel production\n\n## Pasos\n\n1. Push changes to main branch\n2. Wait for CI to pass\n3. Go to Vercel dashboard\n4. Click Deployments\n5. Select latest deployment\n6. Click Promote to Production\n7. Verify with production URL\n\n## Requisitos\n\n- Vercel account connected\n- GitHub repository linked\n- Production branch protected', tags: ['devops', 'deployment', 'vercel'] },
|
||||
{ type: 'procedure', title: 'Setup Git hooks', content: '## Objetivo\n\nConfigure pre-commit hooks with husky\n\n## Pasos\n\n1. Install husky: npm install husky\n2. Init: npx husky install\n3. Add pre-commit: npx husky add .husky/pre-commit "npx lint-staged"\n4. Configure lint-staged in package.json\n5. Test by committing\n\n## Requisitos\n\n- Git repository initialized\n- Package.json configured', tags: ['git', 'devops'] },
|
||||
{ type: 'procedure', title: 'Database backup', content: '## Objetivo\n\nCreate a backup of the production database\n\n## Pasos\n\n1. Connect to production server\n2. Run pg_dump command\n3. Save to timestamped file\n4. Copy to backup storage\n5. Verify backup integrity\n6. Delete backups older than 30 days\n\n## Requisitos\n\n- SSH access to server\n- Sufficient disk space\n- Backup storage configured', tags: ['database', 'devops', 'backup'] },
|
||||
{ type: 'procedure', title: 'Code review process', content: '## Objetivo\n\nStandardize code review workflow\n\n## Pasos\n\n1. Create feature branch\n2. Write code and tests\n3. Open PR with description\n4. Request review from teammate\n5. Address feedback\n6. Get approval\n7. Squash and merge\n\n## Requisitos\n\n- GitHub configured\n- CI passing\n- At least 1 approval', tags: ['git', 'process'] },
|
||||
|
||||
// Inventory
|
||||
{ type: 'inventory', title: 'Laptop specs', content: '## Item\n\nMacBook Pro 16" 2023\n\n## Cantidad\n\n1\n\n## Ubicación\n\nHome office desk\n\n## Notas\n\n- M3 Pro chip\n- 18GB RAM\n- 512GB SSD\n- Serial: ABC123XYZ', tags: ['laptop', 'hardware'] },
|
||||
{ type: 'inventory', title: 'Cable management', content: '## Item\n\nUSB-C cables\n\n## Cantidad\n\n5\n\n## Ubicación\n\nDesk drawer\n\n## Notas\n\n3x 1m, 2x 2m braided', tags: ['cables', 'hardware'] },
|
||||
{ type: 'inventory', title: 'Office supplies', content: '## Item\n\nNotebooks\n\n## Cantidad\n\n8\n\n## Ubicación\n\nBookshelf\n\n## Notas\n\nVarious sizes, mostly unused', tags: ['office'] },
|
||||
|
||||
// Notes with various topics
|
||||
{ type: 'note', title: 'Project ideas', content: '## Notas\n\n- Build a weather app with geolocation\n- Create a recipe manager\n- Design a habit tracker\n- Develop a markdown editor', tags: ['ideas', 'projects'] },
|
||||
{ type: 'note', title: 'Books to read', content: '## Notas\n\n1. Clean Code - Robert Martin\n2. Design Patterns - Gang of Four\n3. The Pragmatic Programmer\n4. Domain-Driven Design', tags: ['reading', 'books'] },
|
||||
{ type: 'note', title: 'Conference notes', content: '## Notas\n\nKey takeaways from React Conf:\n- Server Components are the future\n- Suspense for streaming\n- Better error boundaries', tags: ['conference', 'react'] },
|
||||
{ type: 'note', title: 'Docker compose template', content: '## Notas\n\nBasic docker-compose.yml structure for web apps:\n- App service\n- Database service\n- Redis service\n- Nginx reverse proxy', tags: ['docker', 'devops'] },
|
||||
{ type: 'note', title: 'API design principles', content: '## Notas\n\n- RESTful conventions\n- Use nouns, not verbs\n- Version your APIs\n- Return proper status codes\n- Pagination for lists', tags: ['api', 'backend'] },
|
||||
{ type: 'note', title: 'Git branching strategy', content: '## Notas\n\n- main: production\n- develop: staging\n- feature/*: new features\n- bugfix/*: fixes\n- hotfix/*: urgent production fixes', tags: ['git', 'process'] },
|
||||
{ type: 'note', title: 'Keyboard shortcuts VSCode', content: '## Notas\n\n- Cmd+D: Select next occurrence\n- Cmd+Shift+L: Select all occurrences\n- Cmd+P: Quick open file\n- Cmd+Shift+P: Command palette', tags: ['vscode', 'productivity'] },
|
||||
{ type: 'note', title: 'CSS Grid cheatsheet', content: '## Notas\n\ngrid-template-columns: repeat(3, 1fr)\ngrid-gap: 1rem\nplace-items: center\ngrid-area: header / sidebar / content / footer', tags: ['css', 'grid'] },
|
||||
{ type: 'note', title: 'React hooks cheatsheet', content: '## Notas\n\n- useState: local state\n- useEffect: side effects\n- useCallback: memoize function\n- useMemo: memoize value\n- useRef: mutable ref', tags: ['react', 'hooks'] },
|
||||
{ type: 'note', title: 'Linux commands cheatsheet', content: '## Notas\n\n- chmod +x: make executable\n- chown user:group: change owner\n- grep -r: recursive search\n- tar -czvf: compress\n- ssh -i key: connect with key', tags: ['bash', 'linux'] },
|
||||
{ type: 'note', title: 'SQL joins explained', content: '## Notas\n\n- INNER: matching in both\n- LEFT: all from left + matching from right\n- RIGHT: all from right + matching from left\n- FULL: all from both\n- CROSS: Cartesian product', tags: ['sql', 'database'] },
|
||||
{ type: 'note', title: 'TypeScript utility types', content: '## Notas\n\n- Partial: all optional\n- Required: all required\n- Pick: select fields\n- Omit: exclude fields\n- Record: key-value object\n- Exclude: remove from union', tags: ['typescript'] },
|
||||
{ type: 'note', title: 'Testing pyramid', content: '## Notas\n\n- Unit tests: base, many\n- Integration tests: some\n- E2E tests: few, slow, expensive', tags: ['testing', 'quality'] },
|
||||
{ type: 'note', title: 'Markdown syntax', content: '## Notas\n\n# Heading\n## Subheading\n**bold** *italic*\n- list\n1. numbered\n[link](url)\n```code block```', tags: ['markdown'] },
|
||||
{ type: 'note', title: 'Docker vs Kubernetes', content: '## Notas\n\nDocker: containerize apps\nKubernetes: orchestrate containers at scale\n\nDocker Compose: local multi-container\nK8s: production container orchestration', tags: ['docker', 'kubernetes', 'devops'] },
|
||||
{ type: 'note', title: 'JWT structure', content: '## Notas\n\nHeader: algorithm, type\nPayload: claims, exp, iss\nSignature: verify authenticity\n\nNever store sensitive data in JWT payload', tags: ['auth', 'security', 'jwt'] },
|
||||
{ type: 'note', title: 'Web security headers', content: '## Notas\n\n- Content-Security-Policy\n- X-Frame-Options\n- X-Content-Type-Options\n- Strict-Transport-Security\n- CORS', tags: ['security', 'web'] },
|
||||
{ type: 'note', title: 'Redis use cases', content: '## Notas\n\n- Session storage\n- Caching\n- Rate limiting\n- Pub/Sub\n- Leaderboards', tags: ['redis', 'database'] },
|
||||
{ type: 'note', title: 'Git squash commits', content: '## Notas\n\n```bash\ngit rebase -i HEAD~3\n```\n\nChange pick to squash for commits to combine', tags: ['git'] },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database with examples...')
|
||||
|
||||
// Clear existing notes
|
||||
await prisma.noteTag.deleteMany()
|
||||
await prisma.note.deleteMany()
|
||||
await prisma.tag.deleteMany()
|
||||
|
||||
for (const note of examples) {
|
||||
// Create or get tags
|
||||
const tagRecords = await Promise.all(
|
||||
note.tags.map(async (tagName) => {
|
||||
return prisma.tag.upsert({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName },
|
||||
update: {},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Create note
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({ tagId: tag.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(` Created: ${note.title}`)
|
||||
}
|
||||
|
||||
console.log(`\nSeeded ${examples.length} notes successfully!`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
87
prisma/seed.ts
Normal file
87
prisma/seed.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
await prisma.noteTag.deleteMany()
|
||||
await prisma.note.deleteMany()
|
||||
await prisma.tag.deleteMany()
|
||||
|
||||
const notes = [
|
||||
{
|
||||
title: 'Install Node.js with nvm',
|
||||
content: '## Comando\n\n```bash\nnvm install node\nnvm use node\n```\n\n## Qué hace\nInstala la última versión de Node.js usando nvm.\n\n## Cuándo usarlo\nCuando necesitas instalar Node.js en un sistema nuevo.',
|
||||
type: 'command',
|
||||
tags: ['bash', 'node', 'devops'],
|
||||
},
|
||||
{
|
||||
title: 'React useEffect cleanup pattern',
|
||||
content: '## Snippet\n\n## Lenguaje\nTypeScript/React\n\n## Qué resuelve\nLimpieza correcta de suscripciones en useEffect.\n\n## Código\n```typescript\nuseEffect(() => {\n const controller = new AbortController()\n return () => controller.abort()\n}, [])\n```',
|
||||
type: 'snippet',
|
||||
tags: ['code', 'react', 'frontend'],
|
||||
},
|
||||
{
|
||||
title: 'Usar PostgreSQL para producción',
|
||||
content: '## Contexto\nEl MVP usa SQLite pero en producción necesitamos más capacidad.\n\n## Decisión\nMigrar a PostgreSQL manteniendo el mismo Prisma ORM.\n\n## Alternativas consideradas\n- MySQL: mejor soporte JSON pero menos popular\n- MongoDB: demasiado flexible\n\n## Consecuencias\n- Mejor concurrencia\n- Migración transparente con Prisma',
|
||||
type: 'decision',
|
||||
tags: ['arquitectura', 'backend'],
|
||||
},
|
||||
{
|
||||
title: 'Pollo al horno con hierbas',
|
||||
content: '## Ingredientes\n- 1 pollo entero (~1.5kg)\n- 4 dientes de ajo\n- Romero fresco\n- Tomillo\n- Aceite de oliva\n- Sal y pimienta\n\n## Pasos\n1. Precalentar horno a 200°C\n2. Limpiar y secar el pollo\n3. Untar con aceite y especias\n4. Hornear 1 hora\n5. Descansar 10 min antes de cortar\n\n## Tiempo\n1h 15min total\n\n## Notas\nQueda muy jugoso si lo vuelves a bañar con sus jugos a mitad de cocción.',
|
||||
type: 'recipe',
|
||||
tags: ['cocina'],
|
||||
},
|
||||
{
|
||||
title: 'Renovar pasaporte argentino',
|
||||
content: '## Objetivo\nRenovar el pasaporte argentino vencido.\n\n## Pasos\n1. Sacar turno online en turno.gob.ar\n2. Llevar DNI original\n3. Llevar pasaporte anterior\n4. Pagar tasa de renovación\n5. Esperar ~15 días hábiles\n\n## Requisitos\n- DNI vigente\n- Pasaporte anterior\n\n## Problemas comunes\n- Los turnos se agotan rápido',
|
||||
type: 'procedure',
|
||||
tags: ['trámite', 'hogar'],
|
||||
},
|
||||
{
|
||||
title: 'Inventario cocina',
|
||||
content: '## Item | Cantidad | Ubicación\nArroz | 2kg | Alacena\nFideos | 5 paquetes | Alacena\nLentejas | 1kg | Alacena\nAceite | 2L | Bajo mesada\nSal | 3 paquetes | Mesa\n\n## Notas\nRevisar fechas de vencimiento cada 6 meses.',
|
||||
type: 'inventory',
|
||||
tags: ['hogar', 'inventario'],
|
||||
},
|
||||
{
|
||||
title: 'Ideas para vacaciones 2026',
|
||||
content: '## Opciones\n1. Costa atlántica argentina\n2. Bariloche (invierno)\n3. Viaje a Europa\n\n## Presupuesto estimado\n- Argentina: $500-800 USD\n- Europa: $2000-3000 USD\n\n## Preferencias\n- Prefiero naturaleza sobre ciudades',
|
||||
type: 'note',
|
||||
tags: ['viajes', 'planificación'],
|
||||
},
|
||||
{
|
||||
title: 'Resumen libro: Atomic Habits',
|
||||
content: '## Ideas principales\n- Hábitos compound: pequeños cambios dan grandes resultados\n- No importa si eres mejor o peor, importa tu sistema\n- 1% mejor cada día = 37x mejor al año\n\n## Aplicar\n- Crear morning routine\n- Eliminar malos hábitos con diseño ambiental\n- No perder rachas',
|
||||
type: 'note',
|
||||
tags: ['lectura', 'productividad'],
|
||||
},
|
||||
]
|
||||
|
||||
for (const note of notes) {
|
||||
const { tags, ...noteData } = note
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
...noteData,
|
||||
tags: {
|
||||
create: await Promise.all(
|
||||
tags.map(async (tagName) => {
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName },
|
||||
update: {},
|
||||
})
|
||||
return { tagId: tag.id }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Seed completed')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect())
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
165
resumen/2026-03-22-1229-resumen.md
Normal file
165
resumen/2026-03-22-1229-resumen.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Resumen del Proyecto - 2026-03-22
|
||||
|
||||
## Nombre
|
||||
**Recall** - Sistema de gestión de notas personales
|
||||
|
||||
## Descripción
|
||||
Aplicación web para crear, editar, buscar y organizar notas personales con soporte para tags, tipos de notas, favoritos, y pins. Permite exportar/importar notas en JSON y MD.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Categoría | Tecnología |
|
||||
|-----------|------------|
|
||||
| Framework | Next.js 16.2.1 (React 19.2.4) |
|
||||
| Base UI | @base-ui/react 1.3.0 |
|
||||
| Database | SQLite con Prisma ORM |
|
||||
| Validation | Zod 4.3.6 |
|
||||
| Styling | Tailwind CSS 4 + CSS Variables |
|
||||
| Icons | Lucide React |
|
||||
| Markdown | react-markdown + remark-gfm |
|
||||
| Toast | sonner 2.0.7 |
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── export-import/route.ts # GET (exportar) / POST (importar)
|
||||
│ │ ├── notes/
|
||||
│ │ │ ├── route.ts # GET (listar) / POST (crear)
|
||||
│ │ │ └── [id]/route.ts # GET / PUT / DELETE
|
||||
│ │ └── search/route.ts # Búsqueda full-text
|
||||
│ ├── edit/[id]/page.tsx # Editar nota
|
||||
│ ├── new/page.tsx # Crear nota
|
||||
│ ├── notes/
|
||||
│ │ ├── page.tsx # Lista de notas con filtros
|
||||
│ │ └── [id]/page.tsx # Detalle de nota
|
||||
│ ├── settings/page.tsx # Configuración (export/import)
|
||||
│ ├── layout.tsx
|
||||
│ ├── page.tsx # Dashboard
|
||||
│ └── globals.css
|
||||
├── components/
|
||||
│ ├── ui/ # Componentes base (Button, Card, Dialog, etc.)
|
||||
│ ├── dashboard.tsx
|
||||
│ ├── delete-note-button.tsx # Botón eliminar con modal confirmación
|
||||
│ ├── header.tsx
|
||||
│ ├── markdown-content.tsx
|
||||
│ ├── note-card.tsx
|
||||
│ ├── note-form.tsx
|
||||
│ ├── note-list.tsx
|
||||
│ ├── related-notes.tsx
|
||||
│ ├── search-bar.tsx
|
||||
│ └── tag-filter.tsx
|
||||
├── lib/
|
||||
│ ├── prisma.ts # Cliente Prisma singleton
|
||||
│ ├── related.ts # Algoritmo para notas relacionadas
|
||||
│ ├── tags.ts # Utilidades de tags
|
||||
│ ├── templates.ts # Plantillas para nuevos tipos de nota
|
||||
│ ├── utils.ts # cn() helper
|
||||
│ └── validators.ts # Esquemas Zod
|
||||
└── types/
|
||||
└── note.ts # Tipos TypeScript para NoteType
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modelo de Datos (Prisma)
|
||||
|
||||
### Note
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | String | CUID único |
|
||||
| title | String | Título de la nota |
|
||||
| content | String | Contenido en Markdown |
|
||||
| type | String | Tipo: command, snippet, decision, recipe, procedure, inventory, note |
|
||||
| isFavorite | Boolean | Marcada como favorita |
|
||||
| isPinned | Boolean | Fijada arriba |
|
||||
| createdAt | DateTime | Fecha creación |
|
||||
| updatedAt | DateTime | Última modificación |
|
||||
| tags | NoteTag[] | Relación many-to-many |
|
||||
|
||||
### Tag
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | String | CUID único |
|
||||
| name | String | Nombre único |
|
||||
| notes | NoteTag[] | Relación many-to-many |
|
||||
|
||||
### NoteTag (tabla de unión)
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| noteId | String | FK a Note |
|
||||
| tagId | String | FK a Tag |
|
||||
|
||||
---
|
||||
|
||||
## Rutas de la Aplicación
|
||||
|
||||
| Ruta | Descripción |
|
||||
|------|-------------|
|
||||
| `/` | Dashboard con notas recientes |
|
||||
| `/notes` | Lista de todas las notas con filtros (búsqueda, tipo, tag) |
|
||||
| `/notes/[id]` | Detalle de una nota |
|
||||
| `/new` | Crear nueva nota |
|
||||
| `/edit/[id]` | Editar nota existente |
|
||||
| `/settings` | Configuración: exportar/importar notas |
|
||||
|
||||
---
|
||||
|
||||
## APIs
|
||||
|
||||
### GET/POST `/api/export-import`
|
||||
- **GET**: Exporta todas las notas como JSON
|
||||
- **POST**: Importa notas desde JSON o MD
|
||||
- Soporta `.json` (formato exportado)
|
||||
- Soporta `.md` (usa primer `# Heading` como título)
|
||||
|
||||
### GET/POST `/api/notes`
|
||||
- **GET**: Lista notas (soporta query params: q, type, tag)
|
||||
- **POST**: Crea nueva nota
|
||||
|
||||
### GET/PUT/DELETE `/api/notes/[id]`
|
||||
- **GET**: Obtiene nota por ID
|
||||
- **PUT**: Actualiza nota
|
||||
- **DELETE**: Elimina nota
|
||||
|
||||
### GET `/api/search`
|
||||
- Búsqueda full-text por título y contenido
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades Implementadas
|
||||
|
||||
1. **CRUD de Notas** - Crear, leer, actualizar, eliminar
|
||||
2. **Tipos de Notas** - command, snippet, decision, recipe, procedure, inventory, note
|
||||
3. **Tags** - Sistema de tags con many-to-many
|
||||
4. **Favoritos y Pins** - Marcar notas como favorites/fijadas
|
||||
5. **Búsqueda y Filtros** - Por texto, tipo y tag
|
||||
6. **Exportar/Importar** - Formato JSON y MD
|
||||
7. **Modal de Confirmación** - Al eliminar nota
|
||||
8. **Notas Relacionadas** - Algoritmo de相关性
|
||||
9. **Plantillas** - Para diferentes tipos de notas
|
||||
10. **Dashboard** - Vista general con notas recientes
|
||||
|
||||
---
|
||||
|
||||
## Componentes UI Principales
|
||||
|
||||
- Button, Card, Badge, Dialog, Input, Select, Tabs, Textarea
|
||||
- Avatar, DropdownMenu, Sonner (toasts)
|
||||
|
||||
---
|
||||
|
||||
## Notas Técnicas
|
||||
|
||||
- Uses `app/` router (Next.js 13+ App Router)
|
||||
- Server Components para fetching de datos
|
||||
- Client Components para interactividad (forms, dialogs)
|
||||
- Prisma con SQLite (archivo `dev.db`)
|
||||
- Zod para validación de schemas
|
||||
- CSS Variables para theming con `next-themes`
|
||||
207
resumen/2026-03-22-1435-resumen.md
Normal file
207
resumen/2026-03-22-1435-resumen.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Resumen del Proyecto - 2026-03-22
|
||||
|
||||
## Nombre
|
||||
**Recall** - Sistema de gestión de notas personales
|
||||
|
||||
## Descripción
|
||||
Aplicación web para crear, editar, buscar y organizar notas personales con soporte para tags, tipos de notas, favoritos, pins, captura rápida, backlinks y relaciones entre notas.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Categoría | Tecnología |
|
||||
|-----------|------------|
|
||||
| Framework | Next.js 16.2.1 (React 19.2.4) |
|
||||
| Base UI | @base-ui/react 1.3.0 |
|
||||
| Database | SQLite con Prisma ORM |
|
||||
| Validation | Zod 4.3.6 |
|
||||
| Styling | Tailwind CSS 4 + CSS Variables |
|
||||
| Icons | Lucide React |
|
||||
| Markdown | react-markdown + remark-gfm |
|
||||
| Syntax Highlight | react-syntax-highlighter |
|
||||
| Toast | sonner 2.0.7 |
|
||||
| Testing | Jest |
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── export-import/route.ts
|
||||
│ │ ├── notes/
|
||||
│ │ │ ├── route.ts
|
||||
│ │ │ ├── [id]/route.ts
|
||||
│ │ │ │ ├── backlinks/route.ts
|
||||
│ │ │ │ └── route.ts
|
||||
│ │ │ ├── quick/route.ts
|
||||
│ │ │ └── suggest/route.ts
|
||||
│ │ ├── search/route.ts
|
||||
│ │ └── tags/
|
||||
│ │ ├── route.ts
|
||||
│ │ └── suggest/route.ts
|
||||
│ ├── notes/[id]/page.tsx
|
||||
│ ├── notes/page.tsx
|
||||
│ ├── new/page.tsx
|
||||
│ ├── edit/[id]/page.tsx
|
||||
│ ├── settings/page.tsx
|
||||
│ ├── layout.tsx
|
||||
│ └── page.tsx
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── dashboard.tsx
|
||||
│ ├── header.tsx
|
||||
│ ├── markdown-content.tsx # Markdown con syntax highlight
|
||||
│ ├── note-card.tsx
|
||||
│ ├── note-form.tsx # Form con campos guiados
|
||||
│ ├── note-list.tsx
|
||||
│ ├── quick-add.tsx # Ctrl+N quick add
|
||||
│ ├── related-notes.tsx
|
||||
│ ├── search-bar.tsx
|
||||
│ ├── delete-note-button.tsx
|
||||
│ └── tag-filter.tsx
|
||||
├── lib/
|
||||
│ ├── prisma.ts
|
||||
│ ├── utils.ts
|
||||
│ ├── validators.ts
|
||||
│ ├── errors.ts # Error handling estándar
|
||||
│ ├── search.ts # Búsqueda con scoring
|
||||
│ ├── quick-add.ts # Parser para captura rápida
|
||||
│ ├── tags.ts # Normalización y sugerencias
|
||||
│ ├── templates.ts # Plantillas por tipo
|
||||
│ ├── related.ts # Notas relacionadas
|
||||
│ ├── backlinks.ts # Sistema de backlinks
|
||||
│ └── guided-fields.ts # Campos guiados por tipo
|
||||
└── types/
|
||||
└── note.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modelo de Datos (Prisma)
|
||||
|
||||
### Note
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | String | CUID único |
|
||||
| title | String | Título |
|
||||
| content | String | Contenido en Markdown |
|
||||
| type | String | command, snippet, decision, recipe, procedure, inventory, note |
|
||||
| isFavorite | Boolean | Favorita |
|
||||
| isPinned | Boolean | Fijada |
|
||||
| createdAt | DateTime | Creación |
|
||||
| updatedAt | DateTime | Última modificación |
|
||||
|
||||
### Tag
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | String | CUID único |
|
||||
| name | String | Nombre único (lowercase) |
|
||||
|
||||
### Backlink
|
||||
Relación bidireccional entre notas via `[[nombre-nota]]`
|
||||
|
||||
---
|
||||
|
||||
## APIs
|
||||
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/notes` | Listar notas |
|
||||
| POST | `/api/notes` | Crear nota |
|
||||
| GET | `/api/notes/[id]` | Obtener nota |
|
||||
| PUT | `/api/notes/[id]` | Actualizar nota |
|
||||
| DELETE | `/api/notes/[id]` | Eliminar nota |
|
||||
| GET | `/api/notes/[id]/backlinks` | Backlinks recibidos |
|
||||
| POST | `/api/notes/quick` | Captura rápida |
|
||||
| GET | `/api/notes/suggest` | Sugerencias de notas |
|
||||
| GET | `/api/search` | Búsqueda con scoring |
|
||||
| GET | `/api/tags` | Listar/sugerir tags |
|
||||
| GET | `/api/tags/suggest` | Sugerencias por contenido |
|
||||
| GET/POST | `/api/export-import` | Exportar/importar |
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades Implementadas
|
||||
|
||||
### 1. Captura Rápida (Quick Add)
|
||||
- Shortcut global `Ctrl+N`
|
||||
- Sintaxis: `[tipo:][título] #tag1 #tag2`
|
||||
- Tipos: `cmd:`, `snip:`, `dec:`, `rec:`, `proc:`, `inv:`
|
||||
- API: `POST /api/notes/quick`
|
||||
|
||||
### 2. Búsqueda y Recuperación
|
||||
- Scoring: título exacto > parcial > favoritos > pinned > recencia
|
||||
- Búsqueda fuzzy (tolerante a errores de escritura)
|
||||
- Resaltado de términos con excerpt
|
||||
- Filtros por tipo y tags
|
||||
|
||||
### 3. Relaciones entre Notas
|
||||
- **Backlinks automáticos:** detecta `[[nombre-nota]]`
|
||||
- **Notas relacionadas:** scoring por tags, tipo, palabras
|
||||
- API: `GET /api/notes/[id]/backlinks`
|
||||
|
||||
### 4. Campos Guiados por Tipo
|
||||
- Command: comando, descripción, ejemplo
|
||||
- Snippet: lenguaje, código, descripción
|
||||
- Decision: contexto, decisión, alternativas, consecuencias
|
||||
- Recipe: ingredientes, pasos, tiempo
|
||||
- Procedure: objetivo, pasos, requisitos
|
||||
- Inventory: item, cantidad, ubicación
|
||||
|
||||
### 5. UX por Tipo
|
||||
- **Command:** botón copiar
|
||||
- **Snippet:** syntax highlighting
|
||||
- **Procedure:** checkboxes interactivos
|
||||
|
||||
### 6. Sistema de Tags
|
||||
- Normalización automática (lowercase, trim)
|
||||
- Autocomplete en formularios
|
||||
- Sugerencias basadas en contenido
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npx jest __tests__/
|
||||
# 46 passing, 3 skipped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
npm install # Instalar dependencias
|
||||
npx prisma db push # Sincronizar schema con BD
|
||||
npm run dev # Desarrollo (localhost:3000)
|
||||
npm run build # Build producción
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rutas de la Aplicación
|
||||
|
||||
| Ruta | Descripción |
|
||||
|------|-------------|
|
||||
| `/` | Dashboard |
|
||||
| `/notes` | Lista de notas |
|
||||
| `/notes/[id]` | Detalle de nota |
|
||||
| `/new` | Crear nota |
|
||||
| `/edit/[id]` | Editar nota |
|
||||
| `/settings` | Exportar/importar |
|
||||
|
||||
---
|
||||
|
||||
## Notas Técnicas
|
||||
|
||||
- App Router (Next.js 13+)
|
||||
- Server Components para datos
|
||||
- Client Components para interactividad
|
||||
- Prisma con SQLite
|
||||
- Zod para validación
|
||||
- Errores API con formato `{ success, data, error, timestamp }`
|
||||
242
resumen/2026-03-22-1652-resumen.md
Normal file
242
resumen/2026-03-22-1652-resumen.md
Normal file
@@ -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
|
||||
222
resumen/2026-03-22-1750-resumen.md
Normal file
222
resumen/2026-03-22-1750-resumen.md
Normal file
@@ -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
|
||||
342
resumen/2026-03-22-1942-resumen.md
Normal file
342
resumen/2026-03-22-1942-resumen.md
Normal file
@@ -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
|
||||
```
|
||||
431
resumen/2026-03-22-2000-resumen.md
Normal file
431
resumen/2026-03-22-2000-resumen.md
Normal file
@@ -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 | ✅ |
|
||||
25
src/app/api/capture/route.ts
Normal file
25
src/app/api/capture/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
16
src/app/api/centrality/route.ts
Normal file
16
src/app/api/centrality/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
183
src/app/api/export-import/route.ts
Normal file
183
src/app/api/export-import/route.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { noteSchema, NoteInput } from '@/lib/validators'
|
||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||
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(req: NextRequest) {
|
||||
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({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
const exportData = notes.map(note => ({
|
||||
...note,
|
||||
tags: note.tags.map(nt => nt.tag.name),
|
||||
createdAt: note.createdAt.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)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
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' }])
|
||||
}
|
||||
|
||||
const importedNotes: NoteInput[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const result = noteSchema.safeParse(body[i])
|
||||
if (!result.success) {
|
||||
errors.push(`Item ${i}: ${result.error.issues.map(e => e.message).join(', ')}`)
|
||||
continue
|
||||
}
|
||||
importedNotes.push(result.data)
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new ValidationError(errors)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const item of importedNotes) {
|
||||
const tags = item.tags || []
|
||||
const { tags: _, ...noteData } = item
|
||||
|
||||
const createdAt = parseDate((item as { createdAt?: string }).createdAt)
|
||||
const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt)
|
||||
|
||||
if (item.id) {
|
||||
const existing = await tx.note.findUnique({ where: { id: item.id } })
|
||||
if (existing) {
|
||||
await tx.note.update({
|
||||
where: { id: item.id },
|
||||
data: { ...noteData, createdAt, updatedAt },
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: item.id } })
|
||||
processed++
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
...noteData,
|
||||
id: item.id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
processed++
|
||||
}
|
||||
} else {
|
||||
const existingByTitle = await tx.note.findFirst({
|
||||
where: { title: item.title },
|
||||
})
|
||||
if (existingByTitle) {
|
||||
await tx.note.update({
|
||||
where: { id: existingByTitle.id },
|
||||
data: { ...noteData, updatedAt },
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
...noteData,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
}
|
||||
processed++
|
||||
}
|
||||
|
||||
const noteId = item.id
|
||||
? (await tx.note.findUnique({ where: { id: item.id } }))?.id
|
||||
: (await tx.note.findFirst({ where: { title: item.title } }))?.id
|
||||
|
||||
if (noteId && tags.length > 0) {
|
||||
for (const tagName of 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 note = await tx.note.findUnique({ where: { id: noteId } })
|
||||
if (note) {
|
||||
await syncBacklinks(note.id, note.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return createSuccessResponse({ success: true, count: processed }, 201)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
138
src/app/api/import-markdown/route.ts
Normal file
138
src/app/api/import-markdown/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
16
src/app/api/metrics/route.ts
Normal file
16
src/app/api/metrics/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
24
src/app/api/notes/[id]/backlinks/route.ts
Normal file
24
src/app/api/notes/[id]/backlinks/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const direction = searchParams.get('direction') || 'backlinks'
|
||||
|
||||
if (direction === 'outgoing') {
|
||||
const outgoing = await getOutgoingLinksForNote(id)
|
||||
return createSuccessResponse(outgoing)
|
||||
}
|
||||
|
||||
const backlinks = await getBacklinksForNote(id)
|
||||
return createSuccessResponse(backlinks)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
91
src/app/api/notes/[id]/route.ts
Normal file
91
src/app/api/notes/[id]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { updateNoteSchema } from '@/lib/validators'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
import { createVersion } from '@/lib/versions'
|
||||
import { createErrorResponse, createSuccessResponse, NotFoundError, ValidationError } from '@/lib/errors'
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new NotFoundError('Note')
|
||||
}
|
||||
|
||||
return createSuccessResponse(note)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
const result = updateNoteSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationError(result.error.issues)
|
||||
}
|
||||
|
||||
const { tags, ...noteData } = result.data
|
||||
|
||||
const existingNote = await prisma.note.findUnique({ where: { id } })
|
||||
if (!existingNote) {
|
||||
throw new NotFoundError('Note')
|
||||
}
|
||||
|
||||
await createVersion(id)
|
||||
|
||||
await prisma.noteTag.deleteMany({ where: { noteId: id } })
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...noteData,
|
||||
tags: tags && tags.length > 0 ? {
|
||||
create: await Promise.all(
|
||||
(tags as string[]).map(async (tagName) => {
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName },
|
||||
update: {},
|
||||
})
|
||||
return { tagId: tag.id }
|
||||
})
|
||||
),
|
||||
} : undefined,
|
||||
},
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
if (noteData.content !== undefined) {
|
||||
await syncBacklinks(note.id, noteData.content)
|
||||
}
|
||||
|
||||
return createSuccessResponse(note)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existingNote = await prisma.note.findUnique({ where: { id } })
|
||||
if (!existingNote) {
|
||||
throw new NotFoundError('Note')
|
||||
}
|
||||
|
||||
await prisma.backlink.deleteMany({ where: { OR: [{ sourceNoteId: id }, { targetNoteId: id }] } })
|
||||
await prisma.note.delete({ where: { id } })
|
||||
return createSuccessResponse({ success: true })
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
23
src/app/api/notes/[id]/versions/[versionId]/route.ts
Normal file
23
src/app/api/notes/[id]/versions/[versionId]/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
23
src/app/api/notes/[id]/versions/route.ts
Normal file
23
src/app/api/notes/[id]/versions/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
50
src/app/api/notes/links/route.ts
Normal file
50
src/app/api/notes/links/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
57
src/app/api/notes/quick/route.ts
Normal file
57
src/app/api/notes/quick/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { parseQuickAdd } from '@/lib/quick-add'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
let text: string
|
||||
|
||||
const contentType = req.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await req.json()
|
||||
text = body.text || body.content || ''
|
||||
} else {
|
||||
text = await req.text()
|
||||
}
|
||||
|
||||
if (!text || !text.trim()) {
|
||||
throw new ValidationError([{ path: 'text', message: 'Text is required' }])
|
||||
}
|
||||
|
||||
const { type, tags, content } = parseQuickAdd(text)
|
||||
|
||||
const lines = content.split('\n')
|
||||
const title = lines[0] || content.slice(0, 100)
|
||||
const noteContent = lines.length > 1 ? lines.slice(1).join('\n').trim() : ''
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
content: noteContent || title.trim(),
|
||||
type,
|
||||
creationSource: 'quick',
|
||||
tags: tags.length > 0 ? {
|
||||
create: await Promise.all(
|
||||
tags.map(async (tagName) => {
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName },
|
||||
update: {},
|
||||
})
|
||||
return { tagId: tag.id }
|
||||
})
|
||||
),
|
||||
} : undefined,
|
||||
},
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
await syncBacklinks(note.id, note.content)
|
||||
|
||||
return createSuccessResponse(note, 201)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
69
src/app/api/notes/route.ts
Normal file
69
src/app/api/notes/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { noteSchema } from '@/lib/validators'
|
||||
import { normalizeTag } from '@/lib/tags'
|
||||
import { searchNotes } from '@/lib/search'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const q = searchParams.get('q') || ''
|
||||
const type = searchParams.get('type') || undefined
|
||||
const tag = searchParams.get('tag') || undefined
|
||||
|
||||
if (q || type || tag) {
|
||||
const notes = await searchNotes(q, { type, tag })
|
||||
return createSuccessResponse(notes)
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
return createSuccessResponse(notes)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = noteSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationError(result.error.issues)
|
||||
}
|
||||
|
||||
const { tags, creationSource, ...noteData } = result.data
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
...noteData,
|
||||
creationSource: creationSource || 'form',
|
||||
tags: tags && tags.length > 0 ? {
|
||||
create: await Promise.all(
|
||||
(tags as string[]).map(async (tagName) => {
|
||||
const normalizedTagName = normalizeTag(tagName)
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name: normalizedTagName },
|
||||
create: { name: normalizedTagName },
|
||||
update: {},
|
||||
})
|
||||
return { tagId: tag.id }
|
||||
})
|
||||
),
|
||||
} : undefined,
|
||||
},
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
await syncBacklinks(note.id, note.content)
|
||||
|
||||
return createSuccessResponse(note, 201)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
53
src/app/api/notes/suggest/route.ts
Normal file
53
src/app/api/notes/suggest/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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 query = searchParams.get('q') || ''
|
||||
const limit = parseInt(searchParams.get('limit') || '10', 10)
|
||||
|
||||
if (!query.trim()) {
|
||||
const recentNotes = await prisma.note.findMany({
|
||||
take: limit,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: { id: true, title: true, type: true },
|
||||
})
|
||||
return createSuccessResponse(recentNotes)
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
title: { contains: query },
|
||||
},
|
||||
take: limit,
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ updatedAt: 'desc' },
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
},
|
||||
})
|
||||
|
||||
const exactMatch = notes.find(
|
||||
(n) => n.title.toLowerCase() === queryLower
|
||||
)
|
||||
const otherMatches = notes.filter(
|
||||
(n) => n.title.toLowerCase() !== queryLower
|
||||
)
|
||||
|
||||
if (exactMatch) {
|
||||
return createSuccessResponse([exactMatch, ...otherMatches])
|
||||
}
|
||||
|
||||
return createSuccessResponse(notes)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
18
src/app/api/search/route.ts
Normal file
18
src/app/api/search/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { searchNotes } from '@/lib/search'
|
||||
import { parseQuery } from '@/lib/query-parser'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const q = searchParams.get('q') || ''
|
||||
|
||||
const queryAST = parseQuery(q)
|
||||
const notes = await searchNotes(queryAST.text, queryAST.filters)
|
||||
|
||||
return createSuccessResponse(notes)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
40
src/app/api/tags/route.ts
Normal file
40
src/app/api/tags/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { normalizeTag } from '@/lib/tags'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
|
||||
/**
|
||||
* GET /api/tags - List all existing tags
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const q = searchParams.get('q')
|
||||
|
||||
if (q !== null) {
|
||||
const normalizedQuery = normalizeTag(q)
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: normalizedQuery,
|
||||
},
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: 10,
|
||||
})
|
||||
|
||||
return createSuccessResponse(tags)
|
||||
}
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return createSuccessResponse(tags)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
17
src/app/api/tags/suggest/route.ts
Normal file
17
src/app/api/tags/suggest/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { suggestTags } from '@/lib/tags'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const title = searchParams.get('title') || ''
|
||||
const content = searchParams.get('content') || ''
|
||||
|
||||
const tags = suggestTags(title, content)
|
||||
|
||||
return createSuccessResponse(tags)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
19
src/app/api/usage/co-usage/route.ts
Normal file
19
src/app/api/usage/co-usage/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
30
src/app/api/usage/route.ts
Normal file
30
src/app/api/usage/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
import { trackNoteUsage, type UsageEventType } from '@/lib/usage'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { noteId, eventType, query, metadata } = body
|
||||
|
||||
if (!noteId || !eventType) {
|
||||
return createErrorResponse(new Error('noteId and eventType are required'))
|
||||
}
|
||||
|
||||
const validEventTypes: UsageEventType[] = ['view', 'search_click', 'related_click', 'link_click']
|
||||
if (!validEventTypes.includes(eventType)) {
|
||||
return createErrorResponse(new Error('Invalid eventType'))
|
||||
}
|
||||
|
||||
await trackNoteUsage({
|
||||
noteId,
|
||||
eventType,
|
||||
query,
|
||||
metadata,
|
||||
})
|
||||
|
||||
return createSuccessResponse({ success: true })
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
195
src/app/capture/page.tsx
Normal file
195
src/app/capture/page.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
31
src/app/edit/[id]/page.tsx
Normal file
31
src/app/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { NoteForm } from '@/components/note-form'
|
||||
import { NoteType } from '@/types/note'
|
||||
|
||||
export default async function EditNotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const noteWithTags = {
|
||||
...note,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt.toISOString(),
|
||||
type: note.type as NoteType,
|
||||
tags: note.tags.map(nt => ({ tag: nt.tag })),
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Editar nota</h1>
|
||||
<NoteForm initialData={noteWithTags} isEdit />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
131
src/app/globals.css
Normal file
131
src/app/globals.css
Normal file
@@ -0,0 +1,131 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
29
src/app/layout.tsx
Normal file
29
src/app/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { Header } from '@/components/header'
|
||||
import { CommandPalette } from '@/components/command-palette'
|
||||
import { ShortcutsProvider } from '@/components/shortcuts-provider'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Recall - Gestor de Conocimiento Personal',
|
||||
description: 'Captura rápido, relaciona solo, encuentra cuando importa',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="es">
|
||||
<body className="min-h-screen bg-white">
|
||||
<Header />
|
||||
{children}
|
||||
<Toaster />
|
||||
<CommandPalette />
|
||||
<ShortcutsProvider />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
10
src/app/new/page.tsx
Normal file
10
src/app/new/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NoteForm } from '@/components/note-form'
|
||||
|
||||
export default function NewNotePage() {
|
||||
return (
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Crear nueva nota</h1>
|
||||
<NoteForm />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
14
src/app/not-found.tsx
Normal file
14
src/app/not-found.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="container mx-auto py-16 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">404</h1>
|
||||
<p className="text-gray-600 mb-6">Página no encontrada</p>
|
||||
<Link href="/">
|
||||
<Button>Volver al inicio</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
src/app/notes/[id]/page.tsx
Normal file
114
src/app/notes/[id]/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { notFound } from 'next/navigation'
|
||||
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 { DeleteNoteButton } from '@/components/delete-note-button'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ArrowLeft, Edit, Heart, Pin } from 'lucide-react'
|
||||
import { NoteType } from '@/types/note'
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
command: 'bg-green-100 text-green-800',
|
||||
snippet: 'bg-blue-100 text-blue-800',
|
||||
decision: 'bg-purple-100 text-purple-800',
|
||||
recipe: 'bg-orange-100 text-orange-800',
|
||||
procedure: 'bg-yellow-100 text-yellow-800',
|
||||
inventory: 'bg-gray-100 text-gray-800',
|
||||
note: 'bg-slate-100 text-slate-800',
|
||||
}
|
||||
|
||||
export default async function NoteDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<div className="mb-6">
|
||||
<Link href="/notes">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" /> Volver
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{note.title}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={typeColors[noteType] || typeColors.note}>
|
||||
{noteType}
|
||||
</Badge>
|
||||
{note.isFavorite && <Heart className="h-5 w-5 text-pink-500 fill-pink-500" />}
|
||||
{note.isPinned && <Pin className="h-5 w-5 text-amber-500" />}
|
||||
<span className="text-sm text-gray-500">
|
||||
Actualizada: {new Date(note.updatedAt).toLocaleDateString('en-CA')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/edit/${note.id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</Link>
|
||||
<VersionHistory noteId={note.id} />
|
||||
<DeleteNoteButton noteId={note.id} noteTitle={note.title} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{note.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{note.tags.map(({ tag }) => (
|
||||
<Link key={tag.id} href={`/notes?tag=${tag.name}`}>
|
||||
<Badge variant="outline" className="cursor-pointer hover:bg-gray-100">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<MarkdownContent
|
||||
content={note.content}
|
||||
noteType={noteType}
|
||||
className="bg-gray-50 p-4 rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NoteConnections
|
||||
noteId={note.id}
|
||||
backlinks={backlinks}
|
||||
outgoingLinks={outgoingLinks}
|
||||
relatedNotes={related}
|
||||
coUsedNotes={coUsedNotes}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
94
src/app/notes/page.tsx
Normal file
94
src/app/notes/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { KeyboardNavigableNoteList } from '@/components/keyboard-navigable-note-list'
|
||||
import { KeyboardHint } from '@/components/keyboard-hint'
|
||||
import { SearchBar } from '@/components/search-bar'
|
||||
import { TagFilter } from '@/components/tag-filter'
|
||||
import { NoteType } from '@/types/note'
|
||||
|
||||
const NOTE_TYPES: NoteType[] = ['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note']
|
||||
|
||||
interface SearchParams {
|
||||
q?: string
|
||||
type?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
async function searchNotes(searchParams: SearchParams) {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (searchParams.q) {
|
||||
where.OR = [
|
||||
{ title: { contains: searchParams.q } },
|
||||
{ content: { contains: searchParams.q } },
|
||||
]
|
||||
}
|
||||
|
||||
if (searchParams.type && NOTE_TYPES.includes(searchParams.type as NoteType)) {
|
||||
where.type = searchParams.type
|
||||
}
|
||||
|
||||
if (searchParams.tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
tag: { name: searchParams.tag },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
async function getAllTags() {
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
return tags.map((t) => t.name)
|
||||
}
|
||||
|
||||
export default async function NotesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams
|
||||
const [notes, tags] = await Promise.all([searchNotes(params), getAllTags()])
|
||||
|
||||
const notesWithTags = notes.map(note => ({
|
||||
...note,
|
||||
type: note.type as NoteType,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt.toISOString(),
|
||||
tags: note.tags.map(nt => ({ tag: nt.tag })),
|
||||
}))
|
||||
|
||||
const hasFilters = params.q || params.type || params.tag
|
||||
|
||||
return (
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{hasFilters ? 'Resultados de búsqueda' : 'Todas las notas'}
|
||||
</h1>
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center w-full sm:w-auto">
|
||||
<div className="w-full sm:w-auto">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<TagFilter tags={tags} selectedTag={params.tag || null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{params.q && <span className="text-sm">Búsqueda: "{params.q}"</span>}
|
||||
{params.type && <span className="text-sm">Tipo: {params.type}</span>}
|
||||
{params.tag && <span className="text-sm">Tag: {params.tag}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<KeyboardNavigableNoteList notes={notesWithTags} />
|
||||
<KeyboardHint />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
19
src/app/page.tsx
Normal file
19
src/app/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Dashboard } from '@/components/dashboard'
|
||||
import { getDashboardData } from '@/lib/dashboard'
|
||||
|
||||
export default async function HomePage() {
|
||||
const data = await getDashboardData(6)
|
||||
|
||||
return (
|
||||
<main className="container mx-auto pt-8 px-4">
|
||||
<Dashboard
|
||||
recentNotes={data.recentNotes}
|
||||
mostUsedNotes={data.mostUsedNotes}
|
||||
recentCommands={data.recentCommands}
|
||||
recentSnippets={data.recentSnippets}
|
||||
activityBasedNotes={data.activityBasedNotes}
|
||||
hasActivity={data.hasActivity}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
238
src/app/settings/page.tsx
Normal file
238
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Download, Upload, History, FileText, Code, FolderOpen } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toast } from 'sonner'
|
||||
import { BackupList } from '@/components/backup-list'
|
||||
import { PreferencesPanel } from '@/components/preferences-panel'
|
||||
|
||||
function parseMarkdownToNote(content: string, filename: string) {
|
||||
const lines = content.split('\n')
|
||||
let title = filename.replace(/\.md$/, '')
|
||||
let body = content
|
||||
|
||||
const firstHeadingMatch = content.match(/^#\s+(.+)$/m)
|
||||
if (firstHeadingMatch) {
|
||||
title = firstHeadingMatch[1].trim()
|
||||
const headingIndex = content.indexOf(firstHeadingMatch[0])
|
||||
body = content.slice(headingIndex + firstHeadingMatch[0].length).trim()
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
content: body,
|
||||
type: 'note',
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exporting, setExporting] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleExport = async (format: 'json' | 'markdown' | 'html') => {
|
||||
setExporting(format)
|
||||
try {
|
||||
const response = await fetch(`/api/export-import?format=${format}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Error al exportar')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
let blob: Blob
|
||||
let filename: string
|
||||
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')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success(`Notas exportadas en formato ${format.toUpperCase()}`)
|
||||
} catch {
|
||||
toast.error('Error al exportar las notas')
|
||||
} finally {
|
||||
setExporting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
const file = fileInputRef.current?.files?.[0]
|
||||
if (!file) {
|
||||
toast.error('Selecciona un archivo JSON o MD')
|
||||
return
|
||||
}
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
const text = await file.text()
|
||||
const isMarkdown = file.name.endsWith('.md')
|
||||
|
||||
let payload: object[]
|
||||
let endpoint = '/api/export-import'
|
||||
|
||||
if (isMarkdown) {
|
||||
const note = parseMarkdownToNote(text, file.name)
|
||||
payload = [{ markdown: text, filename: file.name }]
|
||||
endpoint = '/api/import-markdown'
|
||||
} else {
|
||||
payload = JSON.parse(text)
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Error al importar')
|
||||
}
|
||||
|
||||
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) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error al importar las notas')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Configuración</h1>
|
||||
|
||||
<div className="grid gap-6 max-w-2xl">
|
||||
{/* Preferences Section */}
|
||||
<PreferencesPanel />
|
||||
|
||||
{/* Backups Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Backups y Restauración
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Los backups automáticos se guardan localmente. También puedes crear un backup manual antes de operaciones riesgosas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BackupList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Export Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Exportar Notas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md"
|
||||
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border file:border-input file:text-sm file:font-medium file:bg-background hover:file:bg-muted"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
variant="outline"
|
||||
className="gap-2 self-start"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{importing ? 'Importando...' : 'Importar'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
136
src/components/backup-list.tsx
Normal file
136
src/components/backup-list.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
179
src/components/backup-restore-dialog.tsx
Normal file
179
src/components/backup-restore-dialog.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
135
src/components/bookmarklet-instructions.tsx
Normal file
135
src/components/bookmarklet-instructions.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
79
src/components/command-palette.tsx
Normal file
79
src/components/command-palette.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
134
src/components/dashboard.tsx
Normal file
134
src/components/dashboard.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Note } from '@/types/note'
|
||||
import { NoteList } from './note-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchBar } from './search-bar'
|
||||
import { ArrowRight, TrendingUp, Terminal, Code, Zap } from 'lucide-react'
|
||||
|
||||
interface DashboardProps {
|
||||
recentNotes: Note[]
|
||||
mostUsedNotes: Note[]
|
||||
recentCommands: Note[]
|
||||
recentSnippets: Note[]
|
||||
activityBasedNotes: Note[]
|
||||
hasActivity: boolean
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
recentNotes,
|
||||
mostUsedNotes,
|
||||
recentCommands,
|
||||
recentSnippets,
|
||||
activityBasedNotes,
|
||||
hasActivity,
|
||||
}: DashboardProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end mb-3">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* Recientes */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span>Recientes</span>
|
||||
</h2>
|
||||
<Link href="/notes">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{recentNotes.length > 0 ? (
|
||||
<NoteList notes={recentNotes} />
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Más usadas */}
|
||||
{mostUsedNotes.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-orange-500" />
|
||||
<span>Más usadas</span>
|
||||
</h2>
|
||||
<Link href="/notes">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<NoteList notes={mostUsedNotes} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Comandos recientes */}
|
||||
{recentCommands.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-green-500" />
|
||||
<span>Comandos recientes</span>
|
||||
</h2>
|
||||
<Link href="/notes?type=command">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<NoteList notes={recentCommands} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Snippets recientes */}
|
||||
{recentSnippets.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Code className="h-5 w-5 text-blue-500" />
|
||||
<span>Snippets recientes</span>
|
||||
</h2>
|
||||
<Link href="/notes?type=snippet">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<NoteList notes={recentSnippets} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Según tu actividad */}
|
||||
{hasActivity && activityBasedNotes.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<span>Según tu actividad</span>
|
||||
</h2>
|
||||
</div>
|
||||
<NoteList notes={activityBasedNotes} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No hay notas todavía.</p>
|
||||
<Link href="/new">
|
||||
<Button className="mt-4">Crea tu primera nota</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/components/delete-note-button.tsx
Normal file
68
src/components/delete-note-button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface DeleteNoteButtonProps {
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
export function DeleteNoteButton({ noteId, noteTitle }: DeleteNoteButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setOpen(false)
|
||||
router.push('/notes')
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="destructive" size="sm" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Eliminar
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Eliminar nota</DialogTitle>
|
||||
<DialogDescription>
|
||||
¿Estás seguro de que quieres eliminar "{noteTitle}"? Esta acción no se puede deshacer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? 'Eliminando...' : 'Eliminar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
src/components/draft-recovery-banner.tsx
Normal file
19
src/components/draft-recovery-banner.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
139
src/components/header.tsx
Normal file
139
src/components/header.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, FileText, Settings, Menu, X } from 'lucide-react'
|
||||
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() {
|
||||
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 (
|
||||
<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-2 sm:px-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">
|
||||
<span className="text-xl font-bold">Recall</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1">
|
||||
<Link href="/notes">
|
||||
<Button
|
||||
variant={pathname === '/notes' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Notas
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
<Button
|
||||
variant={pathname === '/settings' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuración
|
||||
</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<QuickAdd />
|
||||
<BookmarkletInstructions />
|
||||
{workModeToggleVisible && <WorkModeToggle />}
|
||||
<Link href="/new">
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva nota
|
||||
</Button>
|
||||
</Link>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
9
src/components/keyboard-hint.tsx
Normal file
9
src/components/keyboard-hint.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
86
src/components/keyboard-navigable-note-list.tsx
Normal file
86
src/components/keyboard-navigable-note-list.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
39
src/components/keyboard-shortcuts-dialog.tsx
Normal file
39
src/components/keyboard-shortcuts-dialog.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
175
src/components/markdown-content.tsx
Normal file
175
src/components/markdown-content.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { NoteType } from '@/types/note'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { trackNoteUsage } from '@/lib/usage'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
className?: string
|
||||
noteType?: NoteType
|
||||
noteId?: string
|
||||
}
|
||||
|
||||
function CopyButton({ text, noteId, eventType }: { text: string; noteId?: string; eventType?: 'copy_command' | 'copy_snippet' }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
if (noteId && eventType) {
|
||||
trackNoteUsage({ noteId, eventType })
|
||||
}
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"absolute top-2 right-2 p-2 rounded-md transition-colors",
|
||||
"hover:bg-white/10",
|
||||
"flex items-center gap-1 text-xs"
|
||||
)}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveCheckbox({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mr-2 h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProcedureCheckboxes({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
const [checkedItems, setCheckedItems] = useState<Record<number, boolean>>({})
|
||||
|
||||
const handleToggle = (index: number) => {
|
||||
setCheckedItems(prev => ({ ...prev, [index]: !prev[index] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="procedure-checkboxes">
|
||||
{lines.map((line, index) => {
|
||||
const checkboxMatch = line.match(/^(\s*)-\s*\[([ x])\]\s*(.+)$/)
|
||||
if (checkboxMatch) {
|
||||
const [, indent, state, text] = checkboxMatch
|
||||
const isChecked = checkedItems[index] ?? (state === 'x')
|
||||
return (
|
||||
<div key={index} className={cn("flex items-center py-1", indent && `ml-${indent.length / 2}`)}>
|
||||
<InteractiveCheckbox checked={isChecked} onChange={() => handleToggle(index)} />
|
||||
<span className={isChecked ? 'line-through text-gray-500' : ''}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div key={index}>{line}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className = '', noteType, noteId }: MarkdownContentProps) {
|
||||
if (noteType === 'procedure') {
|
||||
return (
|
||||
<div className={cn("prose max-w-none", className)}>
|
||||
<ProcedureCheckboxes content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("prose max-w-none", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeString = String(children).replace(/\n$/, '')
|
||||
const isInline = !match
|
||||
|
||||
if (noteType === 'snippet' && match) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-lg !bg-gray-900 !mt-4 !mb-4"
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} noteId={noteId} eventType="copy_snippet" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (noteType === 'command' && match?.[1] === 'bash') {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language="bash"
|
||||
PreTag="div"
|
||||
className="rounded-lg !bg-gray-900 !mt-4 !mb-4"
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} noteId={noteId} eventType="copy_command" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-sm font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match?.[1] || 'text'}
|
||||
PreTag="div"
|
||||
className="rounded-lg !bg-gray-900 !mt-4 !mb-4"
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} noteId={noteId} eventType="copy_snippet" />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/components/note-card.tsx
Normal file
66
src/components/note-card.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Note } from '@/types/note'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
command: 'bg-green-100 text-green-800',
|
||||
snippet: 'bg-blue-100 text-blue-800',
|
||||
decision: 'bg-purple-100 text-purple-800',
|
||||
recipe: 'bg-orange-100 text-orange-800',
|
||||
procedure: 'bg-yellow-100 text-yellow-800',
|
||||
inventory: 'bg-gray-100 text-gray-800',
|
||||
note: 'bg-slate-100 text-slate-800',
|
||||
}
|
||||
|
||||
export function NoteCard({ note }: { note: Note }) {
|
||||
const router = useRouter()
|
||||
const preview = note.content.slice(0, 100) + (note.content.length > 100 ? '...' : '')
|
||||
const typeColor = typeColors[note.type] || typeColors.note
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// Prefetch on hover for faster navigation
|
||||
router.prefetch(`/notes/${note.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/notes/${note.id}`} prefetch={true}>
|
||||
<Card
|
||||
className="hover:shadow-md transition-shadow cursor-pointer h-full"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-semibold text-lg line-clamp-1">{note.title}</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{note.isPinned && <span className="text-amber-500">📌</span>}
|
||||
{note.isFavorite && <span className="text-pink-500">❤️</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={typeColor}>{note.type}</Badge>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(note.updatedAt).toLocaleDateString('en-CA')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 line-clamp-2 mb-2">{preview}</p>
|
||||
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{note.tags.map(({ tag }) => (
|
||||
<Badge key={tag.id} variant="outline" className="text-xs">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
231
src/components/note-connections.tsx
Normal file
231
src/components/note-connections.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
1014
src/components/note-form.tsx
Normal file
1014
src/components/note-form.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
src/components/note-list.tsx
Normal file
23
src/components/note-list.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { Note } from '@/types/note'
|
||||
import { NoteCard } from './note-card'
|
||||
|
||||
export function NoteList({ notes }: { notes: Note[] }) {
|
||||
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) => (
|
||||
<NoteCard key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
src/components/preferences-panel.tsx
Normal file
114
src/components/preferences-panel.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user