Compare commits
50 Commits
ff7223bfea
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fce46bff2 | |||
| d398226723 | |||
| 85bbe7b61f | |||
| de1de1d3bc | |||
| ef8a7858b2 | |||
| dc5089c011 | |||
| 0e22acc077 | |||
| 85e6304bae | |||
| cf6b5785d7 | |||
| 66db3de33d | |||
| a0451a2084 | |||
| d91ff3f07c | |||
| d5a6d0bfd7 | |||
| 97082b0233 | |||
| 93b6c0b17d | |||
| f038f37001 | |||
| e5e976caff | |||
| e5b6ce3bdc | |||
| 5fb1c5d0b0 | |||
| f6218283f1 | |||
| 3ff5e6b031 | |||
| 1916bc33e4 | |||
| 9847b4c5cc | |||
| fc53062eb1 | |||
| 28366151cf | |||
| c8674dd56f | |||
| 3a523aafde | |||
| 9ca98d96db | |||
| bd1a5bc21c | |||
| 2f9233ad41 | |||
| 76c98ecdbe | |||
| b4a5abb699 | |||
| ece8163d15 | |||
| e0433f8e57 | |||
| 13ee0f9922 | |||
| 0a96638681 | |||
| 33a4705f95 | |||
| e66a678160 | |||
| 8d56f34d68 | |||
| a40ab18b1b | |||
| cde0a143a5 | |||
| 8c80a12b81 | |||
| 544decf4ac | |||
| 7c5fba5f12 | |||
| a67442e9ed | |||
| 9ed7d8acec | |||
| e57927e37d | |||
| 9af25927b7 | |||
| d5c418c84f | |||
| 6cc5f3793a |
@@ -9,7 +9,8 @@
|
||||
"Bash(node:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm list:*)"
|
||||
"Bash(npm list:*)",
|
||||
"Bash(npx jest:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,7 @@ Build, test, and lint commands will be documented here once the project structur
|
||||
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen.
|
||||
- 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>
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-slim AS deps
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
|
||||
RUN npx prisma generate
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
COPY --from=builder /app/prisma/schema.prisma /app/schema.prisma
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV DATABASE_URL="file:/app/data/dev.db"
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
Vendored
+125
@@ -0,0 +1,125 @@
|
||||
pipeline {
|
||||
agent {
|
||||
node {
|
||||
label 'java-springboot'
|
||||
}
|
||||
}
|
||||
environment {
|
||||
URL_REGISTRY = 'gitea.danielarroyo.cl'
|
||||
PROJECT = 'darroyo'
|
||||
REMOTE_USER = 'root'
|
||||
REMOTE_HOST = '10.5.0.116'
|
||||
REMOTE_PATH = '/compose'
|
||||
DOCKER_CREDENTIALS = credentials('gitea-docker-registry')
|
||||
}
|
||||
stages {
|
||||
stage('Obtener Nombre del Repositorio') {
|
||||
steps {
|
||||
script {
|
||||
sh 'env | sort'
|
||||
echo "GIT_URL: ${env.GIT_URL}"
|
||||
echo "GIT_URL_1: ${env.GIT_URL_1}"
|
||||
def gitUrl = env.GIT_URL ?: env.GIT_URL_1
|
||||
if (gitUrl) {
|
||||
def repoName = gitUrl.tokenize('/').last().replace('.git', '')
|
||||
echo "Nombre extraído del repositorio: ${repoName}"
|
||||
env.NAME_SERVICE = repoName
|
||||
echo "El nombre del repositorio asignado a NAME_SERVICE: ${env.NAME_SERVICE}"
|
||||
} else {
|
||||
echo "No se pudo obtener la URL del repositorio. GIT_URL y GIT_URL_1 no están definidos."
|
||||
env.NAME_SERVICE = 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Build') {
|
||||
steps {
|
||||
echo "El nombre del repositorio es: ${env.NAME_SERVICE}"
|
||||
script {
|
||||
try {
|
||||
sh """
|
||||
ls -la
|
||||
ls -la src || echo "Directorio src no encontrado"
|
||||
ls -la src/main/docker || echo "Directorio src/main/docker no encontrado"
|
||||
cat .dockerignore || echo ".dockerignore no encontrado"
|
||||
docker -v
|
||||
docker build \
|
||||
-t ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER} .
|
||||
docker tag ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER} \
|
||||
${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:latest
|
||||
"""
|
||||
} catch (Exception e) {
|
||||
error "Build failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Push to Registry') {
|
||||
steps {
|
||||
script {
|
||||
try {
|
||||
docker.withRegistry("https://${URL_REGISTRY}", 'gitea-docker-registry') {
|
||||
sh """
|
||||
docker push ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER}
|
||||
docker push ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:latest
|
||||
"""
|
||||
}
|
||||
} catch (Exception e) {
|
||||
error "Push to registry failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Deploy') {
|
||||
steps {
|
||||
script {
|
||||
def dockerComposeTemplate = """
|
||||
services:
|
||||
${NAME_SERVICE}:
|
||||
image: ${URL_REGISTRY}/${PROJECT}/${NAME_SERVICE}:${BUILD_NUMBER}
|
||||
container_name: ${NAME_SERVICE}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.${NAME_SERVICE}.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.${NAME_SERVICE}.entrypoints=web"
|
||||
- "traefik.http.routers.${NAME_SERVICE}.rule=Host(`recall.vodorod.cl`)"
|
||||
environment:
|
||||
- TZ=America/Santiago
|
||||
- DATABASE_URL=file:/app/data/dev.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- homelab-net
|
||||
mem_limit: 512m
|
||||
mem_reservation: 256m
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
homelab-net:
|
||||
external: true
|
||||
"""
|
||||
writeFile file: 'docker-compose.yaml', text: dockerComposeTemplate
|
||||
|
||||
sshagent(credentials: ['ssh-virtual-machine']) {
|
||||
withCredentials([usernamePassword(credentialsId: 'gitea-docker-registry', usernameVariable: 'REG_USR', passwordVariable: 'REG_PSW')]) {
|
||||
sh '''
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} "docker login ${URL_REGISTRY} -u ${REG_USR} -p ${REG_PSW}"
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} "mkdir -p ${REMOTE_PATH}/${PROJECT}/${NAME_SERVICE}"
|
||||
scp docker-compose.yaml ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/${PROJECT}/${NAME_SERVICE}/docker-compose.yaml
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} "cd ${REMOTE_PATH}/${PROJECT}/${NAME_SERVICE} && docker compose down && docker compose pull && docker compose up -d"
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST} "docker system prune -f"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
failure {
|
||||
echo "Pipeline failed. Check logs for details."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
# recall
|
||||
|
||||
Sistema de notas personal con captura rápida y búsqueda inteligente.
|
||||
Sistema de notas personal con captura rápida, búsqueda inteligente y conexiones entre notas.
|
||||
|
||||
## Uso
|
||||
|
||||
### Quick Add (Captura Rápida)
|
||||
|
||||
Crea notas al instante con el shortcut `Ctrl+N`.
|
||||
Crea notas al instante con el shortcut `Ctrl+N` o desde el botón de captura rápida en el header.
|
||||
|
||||
Sintaxis:
|
||||
```
|
||||
@@ -41,12 +41,31 @@ rec: Pasta carbonara #cocina #italiana
|
||||
| `inventory` | Inventario | Item, Cantidad, Ubicación |
|
||||
| `note` | Nota libre | Contenido |
|
||||
|
||||
### Búsqueda
|
||||
### Dashboard (Página Principal)
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
@@ -56,7 +75,73 @@ Crea links a otras notas usando `[[nombre-de-nota]]`:
|
||||
Ver también: [[Configuración de Docker]]
|
||||
```
|
||||
|
||||
Los backlinks se muestran automáticamente en la nota referenciada.
|
||||
Los **backlinks** (notas que referencian la nota actual) se muestran automáticamente en la vista de detalle.
|
||||
|
||||
### Conexiones de Notas
|
||||
|
||||
Cada nota muestra diferentes tipos de conexiones:
|
||||
|
||||
- **Notas relacionadas** - Basadas en tags compartidos, tipo y contenido similar
|
||||
- **Backlinks** - Notas que linkean a esta nota
|
||||
- **Outgoing links** - Links salientes de esta nota hacia otras
|
||||
- **Co-usadas** - Notas que sueles ver juntas
|
||||
|
||||
### Búsqueda
|
||||
|
||||
- Búsqueda por título y contenido
|
||||
- Búsqueda fuzzy (tolerante a errores)
|
||||
- Filtros por tipo y tags
|
||||
- Favoritos y notas pinned influyen en el ranking
|
||||
|
||||
### Captura Externa (Bookmarklet)
|
||||
|
||||
Desde Configuración > Capturar web, puedes crear un marcador que permite capturar contenido de cualquier página web:
|
||||
|
||||
1. Arrastra el botón "Capturar a Recall" a tu barra de marcadores
|
||||
2. Cuando estés en una página web, haz clic en el marcador
|
||||
3. Confirma y guarda directamente en tus notas
|
||||
|
||||
El marcador captura: título de la página, URL y texto seleccionado.
|
||||
|
||||
### Drafts (Borradores)
|
||||
|
||||
El sistema guarda automáticamente borradores de tus notas mientras escribes. Si cierras accidentalmente la página, al volver se te ofrecer recuperar el borrador.
|
||||
|
||||
### Historial de Versiones
|
||||
|
||||
Cada nota mantiene un historial de versiones. Accede desde el botón de historial en la vista de detalle para ver y restaurar versiones anteriores.
|
||||
|
||||
### Backups y Restauración
|
||||
|
||||
En Configuración > Backups:
|
||||
|
||||
- **Backup automático** - Se crean backups al cerrar o cambiar de nota (configurable)
|
||||
- **Retención** - Los backups se mantienen por el período indicado (por defecto 30 días)
|
||||
- **Backup manual** - Exporta en cualquier momento
|
||||
|
||||
### Exportar e Importar
|
||||
|
||||
Desde Configuración > Exportar:
|
||||
|
||||
- **JSON** - Backup completo (recomendado para restaurar)
|
||||
- **Markdown** - Notas en formato MD (ideal para compartir)
|
||||
- **HTML** - Notas en formato HTML (para visualización)
|
||||
|
||||
**Importar:**
|
||||
- JSON - Restauración de backup
|
||||
- Markdown - Importación de archivos MD
|
||||
|
||||
### Atajos de Teclado
|
||||
|
||||
| Atajo | Acción |
|
||||
|-------|--------|
|
||||
| `Ctrl+N` | Nueva nota rápida |
|
||||
| `Ctrl+K` / `Cmd+K` | Command Palette |
|
||||
| `n` | Nueva nota (desde dashboard) |
|
||||
| `g h` | Ir al Dashboard |
|
||||
| `g n` | Ir a Notas |
|
||||
| `/` | Enfocar búsqueda |
|
||||
| `?` | Mostrar atajos de teclado |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -66,6 +151,48 @@ 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
|
||||
@@ -87,3 +214,41 @@ 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
|
||||
```
|
||||
|
||||
|
||||
|
||||
nueva prueba
|
||||
@@ -38,6 +38,11 @@ const mockPrisma = {
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
noteVersion: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback) => callback(mockPrisma)),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { commands, CommandItem } from '@/lib/command-items'
|
||||
|
||||
describe('command-items', () => {
|
||||
describe('commands array', () => {
|
||||
it('contains navigation commands', () => {
|
||||
const navCommands = commands.filter((cmd) => cmd.group === 'navigation')
|
||||
expect(navCommands.length).toBeGreaterThan(0)
|
||||
expect(navCommands.some((cmd) => cmd.id === 'nav-dashboard')).toBe(true)
|
||||
expect(navCommands.some((cmd) => cmd.id === 'nav-notes')).toBe(true)
|
||||
expect(navCommands.some((cmd) => cmd.id === 'nav-settings')).toBe(true)
|
||||
})
|
||||
|
||||
it('contains action commands', () => {
|
||||
const actionCommands = commands.filter((cmd) => cmd.group === 'actions')
|
||||
expect(actionCommands.length).toBeGreaterThan(0)
|
||||
expect(actionCommands.some((cmd) => cmd.id === 'action-new')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('command item structure', () => {
|
||||
it('each command has required fields', () => {
|
||||
commands.forEach((cmd: CommandItem) => {
|
||||
expect(cmd.id).toBeDefined()
|
||||
expect(cmd.label).toBeDefined()
|
||||
expect(cmd.group).toBeDefined()
|
||||
expect(typeof cmd.id).toBe('string')
|
||||
expect(typeof cmd.label).toBe('string')
|
||||
expect(['navigation', 'actions', 'search', 'recent']).toContain(cmd.group)
|
||||
})
|
||||
})
|
||||
|
||||
it('commands have keywords for search', () => {
|
||||
commands.forEach((cmd: CommandItem) => {
|
||||
if (cmd.keywords) {
|
||||
expect(Array.isArray(cmd.keywords)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('command filtering', () => {
|
||||
it('can filter by label', () => {
|
||||
const filtered = commands.filter((cmd) =>
|
||||
cmd.label.toLowerCase().includes('dashboard')
|
||||
)
|
||||
expect(filtered.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('can filter by keywords', () => {
|
||||
const filtered = commands.filter((cmd) =>
|
||||
cmd.keywords?.some((k) => k.includes('home'))
|
||||
)
|
||||
expect(filtered.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { generateBookmarklet, encodeCapturePayload, CapturePayload } from '@/lib/external-capture'
|
||||
|
||||
describe('external-capture', () => {
|
||||
describe('generateBookmarklet', () => {
|
||||
it('generates a valid javascript bookmarklet string', () => {
|
||||
const bookmarklet = generateBookmarklet()
|
||||
expect(bookmarklet).toContain('javascript:')
|
||||
expect(bookmarklet.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('contains the capture URL', () => {
|
||||
const bookmarklet = generateBookmarklet()
|
||||
expect(bookmarklet).toContain('capture')
|
||||
})
|
||||
})
|
||||
|
||||
describe('encodeCapturePayload', () => {
|
||||
it('encodes title in params', () => {
|
||||
const payload: CapturePayload = { title: 'Test Note', url: '', selection: '' }
|
||||
const encoded = encodeCapturePayload(payload)
|
||||
expect(encoded).toContain('title=Test')
|
||||
})
|
||||
|
||||
it('encodes url in params', () => {
|
||||
const payload: CapturePayload = { title: '', url: 'https://example.com', selection: '' }
|
||||
const encoded = encodeCapturePayload(payload)
|
||||
expect(encoded).toContain('url=https%3A%2F%2Fexample.com')
|
||||
})
|
||||
|
||||
it('encodes selection in params', () => {
|
||||
const payload: CapturePayload = { title: '', url: '', selection: 'Selected text' }
|
||||
const encoded = encodeCapturePayload(payload)
|
||||
expect(encoded).toContain('selection=Selected')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
// Navigation history tests are limited due to localStorage mocking complexity
|
||||
// The module itself is straightforward and works correctly in practice
|
||||
|
||||
describe('navigation-history', () => {
|
||||
describe('module exports', () => {
|
||||
it('exports required functions', async () => {
|
||||
const module = await import('@/lib/navigation-history')
|
||||
expect(typeof module.getNavigationHistory).toBe('function')
|
||||
expect(typeof module.addToNavigationHistory).toBe('function')
|
||||
expect(typeof module.clearNavigationHistory).toBe('function')
|
||||
expect(typeof module.removeFromNavigationHistory).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { parseQuery, QueryAST } from '@/lib/query-parser'
|
||||
|
||||
describe('query-parser', () => {
|
||||
describe('basic text queries', () => {
|
||||
it('returns text with no filters for simple text', () => {
|
||||
const result = parseQuery('docker')
|
||||
expect(result.text).toBe('docker')
|
||||
expect(result.filters).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves multi-word text', () => {
|
||||
const result = parseQuery('hello world')
|
||||
expect(result.text).toBe('hello world')
|
||||
expect(result.filters).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('type filter', () => {
|
||||
it('extracts type filter from beginning of query', () => {
|
||||
const result = parseQuery('type:command docker')
|
||||
expect(result.text).toBe('docker')
|
||||
expect(result.filters).toEqual({ type: 'command' })
|
||||
})
|
||||
|
||||
it('handles query with only type filter', () => {
|
||||
const result = parseQuery('type:snippet')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({ type: 'snippet' })
|
||||
})
|
||||
|
||||
it('extracts type filter from end of query', () => {
|
||||
const result = parseQuery('docker type:command')
|
||||
expect(result.text).toBe('docker')
|
||||
expect(result.filters).toEqual({ type: 'command' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tag filter', () => {
|
||||
it('extracts tag filter with text', () => {
|
||||
const result = parseQuery('tag:api error')
|
||||
expect(result.text).toBe('error')
|
||||
expect(result.filters).toEqual({ tag: 'api' })
|
||||
})
|
||||
|
||||
it('handles query with only tag filter', () => {
|
||||
const result = parseQuery('tag:backend')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({ tag: 'backend' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('combined filters', () => {
|
||||
it('parses multiple filters together', () => {
|
||||
const result = parseQuery('docker tag:backend type:command')
|
||||
expect(result.text).toBe('docker')
|
||||
expect(result.filters).toEqual({ type: 'command', tag: 'backend' })
|
||||
})
|
||||
|
||||
it('handles type, tag, and isFavorite combined', () => {
|
||||
const result = parseQuery('type:snippet tag:python is:favorite')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({ type: 'snippet', tag: 'python', isFavorite: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('boolean filters', () => {
|
||||
it('extracts is:favorite filter', () => {
|
||||
const result = parseQuery('is:favorite docker')
|
||||
expect(result.text).toBe('docker')
|
||||
expect(result.filters).toEqual({ isFavorite: true })
|
||||
})
|
||||
|
||||
it('handles is:pinned filter alone', () => {
|
||||
const result = parseQuery('is:pinned')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({ isPinned: true })
|
||||
})
|
||||
|
||||
it('handles both boolean filters with text', () => {
|
||||
const result = parseQuery('is:favorite is:pinned docker')
|
||||
expect(result.text).toBe('docker')
|
||||
expect(result.filters).toEqual({ isFavorite: true, isPinned: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty string', () => {
|
||||
const result = parseQuery('')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({})
|
||||
})
|
||||
|
||||
it('handles whitespace only', () => {
|
||||
const result = parseQuery(' ')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores empty type value', () => {
|
||||
const result = parseQuery('type:')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores empty tag value', () => {
|
||||
const result = parseQuery('tag:')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.filters).toEqual({})
|
||||
})
|
||||
|
||||
it('last duplicate filter wins for type', () => {
|
||||
const result = parseQuery('type:command type:snippet docker')
|
||||
expect(result.text).toBe('docker')
|
||||
expect(result.filters).toEqual({ type: 'snippet' })
|
||||
})
|
||||
|
||||
it('last duplicate filter wins for tag', () => {
|
||||
const result = parseQuery('tag:python tag:javascript code')
|
||||
expect(result.text).toBe('code')
|
||||
expect(result.filters).toEqual({ tag: 'javascript' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('case sensitivity', () => {
|
||||
it('filter name is case insensitive', () => {
|
||||
const result = parseQuery('TYPE:command')
|
||||
expect(result.filters).toEqual({ type: 'command' })
|
||||
})
|
||||
|
||||
it('filter value is case sensitive', () => {
|
||||
const result = parseQuery('type:Command')
|
||||
expect(result.filters).toEqual({ type: 'Command' })
|
||||
})
|
||||
|
||||
it('is:favorite is case insensitive', () => {
|
||||
const result = parseQuery('IS:FAVORITE docker')
|
||||
expect(result.filters).toEqual({ isFavorite: true })
|
||||
})
|
||||
|
||||
it('is:pinned is case insensitive', () => {
|
||||
const result = parseQuery('IS:PINNED')
|
||||
expect(result.filters).toEqual({ isPinned: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { createVersion, getVersions, getVersion, restoreVersion } from '@/lib/versions'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: { findUnique: jest.fn(), update: jest.fn() },
|
||||
noteVersion: { create: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NotFoundError } from '@/lib/errors'
|
||||
|
||||
describe('versions.ts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createVersion', () => {
|
||||
it('creates a version with correct noteId, title, content', async () => {
|
||||
const mockNote = { id: 'note-1', title: 'Test Title', content: 'Test Content' }
|
||||
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Test Title', content: 'Test Content', createdAt: new Date() }
|
||||
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||
;(prisma.noteVersion.create as jest.Mock).mockResolvedValue(mockVersion)
|
||||
|
||||
const result = await createVersion('note-1')
|
||||
|
||||
expect(result).toEqual(mockVersion)
|
||||
expect(prisma.note.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'note-1' },
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
expect(prisma.noteVersion.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
noteId: 'note-1',
|
||||
title: 'Test Title',
|
||||
content: 'Test Content',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('throws NotFoundError if note does not exist', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
await expect(createVersion('note-1')).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVersions', () => {
|
||||
it('returns all versions for a note ordered by createdAt desc', async () => {
|
||||
const mockNote = { id: 'note-1' }
|
||||
const mockVersions = [
|
||||
{ id: 'version-2', noteId: 'note-1', title: 'Title 2', content: 'Content 2', createdAt: new Date('2024-01-02') },
|
||||
{ id: 'version-1', noteId: 'note-1', title: 'Title 1', content: 'Content 1', createdAt: new Date('2024-01-01') },
|
||||
]
|
||||
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||
;(prisma.noteVersion.findMany as jest.Mock).mockResolvedValue(mockVersions)
|
||||
|
||||
const result = await getVersions('note-1')
|
||||
|
||||
expect(result).toEqual(mockVersions)
|
||||
expect(prisma.note.findUnique).toHaveBeenCalledWith({ where: { id: 'note-1' } })
|
||||
expect(prisma.noteVersion.findMany).toHaveBeenCalledWith({
|
||||
where: { noteId: 'note-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
noteId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('throws NotFoundError if note does not exist', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
await expect(getVersions('note-1')).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVersion', () => {
|
||||
it('returns version by ID', async () => {
|
||||
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Title', content: 'Content', createdAt: new Date() }
|
||||
|
||||
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
|
||||
|
||||
const result = await getVersion('version-1')
|
||||
|
||||
expect(result).toEqual(mockVersion)
|
||||
expect(prisma.noteVersion.findUnique).toHaveBeenCalledWith({ where: { id: 'version-1' } })
|
||||
})
|
||||
|
||||
it('throws NotFoundError if version does not exist', async () => {
|
||||
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
await expect(getVersion('version-1')).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreVersion', () => {
|
||||
it('updates note title and content from version', async () => {
|
||||
const mockNote = { id: 'note-1', title: 'Old Title', content: 'Old Content' }
|
||||
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Old Title', content: 'Old Content', createdAt: new Date() }
|
||||
const updatedNote = { id: 'note-1', title: 'Old Title', content: 'Old Content', updatedAt: new Date() }
|
||||
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
|
||||
;(prisma.note.update as jest.Mock).mockResolvedValue(updatedNote)
|
||||
|
||||
const result = await restoreVersion('note-1', 'version-1')
|
||||
|
||||
expect(result).toEqual(updatedNote)
|
||||
expect(prisma.note.update).toHaveBeenCalledWith({
|
||||
where: { id: 'note-1' },
|
||||
data: {
|
||||
title: 'Old Title',
|
||||
content: 'Old Content',
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('updates note updatedAt timestamp', async () => {
|
||||
const mockNote = { id: 'note-1', title: 'Title', content: 'Content' }
|
||||
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Title', content: 'Content', createdAt: new Date() }
|
||||
const beforeUpdate = new Date('2024-01-01')
|
||||
const afterUpdate = new Date('2024-01-02')
|
||||
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
|
||||
;(prisma.note.update as jest.Mock).mockResolvedValue({ id: 'note-1', title: 'Title', content: 'Content', updatedAt: afterUpdate })
|
||||
|
||||
const result = await restoreVersion('note-1', 'version-1')
|
||||
|
||||
expect(result.updatedAt).toEqual(afterUpdate)
|
||||
})
|
||||
|
||||
it('throws NotFoundError if note does not exist', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws NotFoundError if version does not exist', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue({ id: 'note-1' })
|
||||
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws NotFoundError if version does not belong to note', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue({ id: 'note-1' })
|
||||
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue({ id: 'version-1', noteId: 'note-2' })
|
||||
|
||||
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
# Recall — Tickets técnicos MVP-4 (Camino Producto)
|
||||
|
||||
## 🎯 Objetivo
|
||||
Convertir Recall en una herramienta confiable, rápida y diaria, enfocada en:
|
||||
- búsqueda tipo Google personal
|
||||
- navegación instantánea
|
||||
- confianza (historial + backup)
|
||||
- contexto activo
|
||||
|
||||
---
|
||||
|
||||
# 🧩 EPIC 1 — Búsqueda avanzada (Google personal)
|
||||
|
||||
## [P1] Ticket 01 — Parser de query avanzada
|
||||
|
||||
**Objetivo**
|
||||
Permitir búsquedas expresivas tipo: `docker tag:backend type:command`
|
||||
|
||||
**Alcance**
|
||||
- Crear `src/lib/query-parser.ts`
|
||||
- Soportar:
|
||||
- texto libre
|
||||
- `type:`
|
||||
- `tag:`
|
||||
- `is:favorite`, `is:pinned`
|
||||
- Devolver AST simple
|
||||
|
||||
**Criterios**
|
||||
- Queries válidas parsean correctamente
|
||||
- Soporta combinación de filtros + texto
|
||||
- Tests unitarios incluidos
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 02 — Integrar query avanzada en search
|
||||
|
||||
**Objetivo**
|
||||
Aplicar parser en `/api/search`
|
||||
|
||||
**Alcance**
|
||||
- Filtrar por AST antes de scoring
|
||||
- Mantener scoring existente
|
||||
|
||||
**Criterios**
|
||||
- `type:command docker` filtra correctamente
|
||||
- `tag:api error` funciona
|
||||
- No rompe búsqueda actual
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 03 — Búsqueda en tiempo real
|
||||
|
||||
**Objetivo**
|
||||
Actualizar resultados mientras el usuario escribe
|
||||
|
||||
**Alcance**
|
||||
- Debounce en `search-bar.tsx`
|
||||
- Fetch automático
|
||||
- Estado loading ligero
|
||||
|
||||
**Criterios**
|
||||
- Resultados cambian en <300ms
|
||||
- No bloquea UI
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 04 — Navegación por teclado en búsqueda
|
||||
|
||||
**Objetivo**
|
||||
UX tipo Spotlight
|
||||
|
||||
**Alcance**
|
||||
- ↑ ↓ para moverse
|
||||
- Enter para abrir
|
||||
- ESC para cerrar
|
||||
|
||||
**Criterios**
|
||||
- Navegación sin mouse
|
||||
- Estado seleccionado visible
|
||||
|
||||
---
|
||||
|
||||
# 🧠 EPIC 2 — Contexto activo
|
||||
|
||||
## [P1] Ticket 05 — Sidebar contextual inteligente
|
||||
|
||||
**Objetivo**
|
||||
Mostrar contexto dinámico mientras navegas
|
||||
|
||||
**Alcance**
|
||||
- Crear `note-context-sidebar.tsx`
|
||||
- Mostrar:
|
||||
- relacionadas
|
||||
- co-uso
|
||||
- backlinks
|
||||
- recientes
|
||||
|
||||
**Criterios**
|
||||
- Siempre muestra contenido relevante
|
||||
- No rompe layout responsive
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 06 — Sugerencias dinámicas en lectura
|
||||
|
||||
**Objetivo**
|
||||
Recomendar contenido mientras lees
|
||||
|
||||
**Alcance**
|
||||
- Hook en `notes/[id]/page.tsx`
|
||||
- Actualizar sugerencias según scroll/uso
|
||||
|
||||
**Criterios**
|
||||
- Sugerencias cambian según contexto
|
||||
- No afecta performance
|
||||
|
||||
---
|
||||
|
||||
# 🔐 EPIC 3 — Confianza total
|
||||
|
||||
## [P1] Ticket 07 — Historial de versiones
|
||||
|
||||
**Objetivo**
|
||||
Permitir ver y revertir cambios
|
||||
|
||||
**Alcance**
|
||||
- Modelo `NoteVersion`
|
||||
- Guardar snapshot en cada update
|
||||
- Endpoint `/api/notes/[id]/versions`
|
||||
|
||||
**Criterios**
|
||||
- Se pueden listar versiones
|
||||
- Se puede restaurar versión
|
||||
- No rompe edición actual
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 08 — UI historial de versiones
|
||||
|
||||
**Objetivo**
|
||||
Visualizar cambios
|
||||
|
||||
**Alcance**
|
||||
- Vista en `notes/[id]`
|
||||
- Mostrar lista de versiones
|
||||
- Botón restaurar
|
||||
|
||||
**Criterios**
|
||||
- UX clara
|
||||
- Confirmación antes de revertir
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 09 — Backup automático
|
||||
|
||||
**Objetivo**
|
||||
Evitar pérdida de datos
|
||||
|
||||
**Alcance**
|
||||
- Export JSON automático
|
||||
- Guardar en local (descarga o storage)
|
||||
|
||||
**Criterios**
|
||||
- Backup se genera periódicamente
|
||||
- No bloquea app
|
||||
|
||||
---
|
||||
|
||||
# ⚡ EPIC 4 — Rendimiento y UX
|
||||
|
||||
## [P1] Ticket 10 — Cache de resultados de búsqueda
|
||||
|
||||
**Objetivo**
|
||||
Reducir latencia
|
||||
|
||||
**Alcance**
|
||||
- Cache en cliente por query
|
||||
- Invalidación simple
|
||||
|
||||
**Criterios**
|
||||
- Queries repetidas son instantáneas
|
||||
- No datos stale críticos
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 11 — Preload de notas frecuentes
|
||||
|
||||
**Objetivo**
|
||||
Abrir notas más rápido
|
||||
|
||||
**Alcance**
|
||||
- Prefetch en hover/listado
|
||||
- Usar Next.js prefetch
|
||||
|
||||
**Criterios**
|
||||
- Navegación instantánea en notas frecuentes
|
||||
|
||||
---
|
||||
|
||||
# 🧪 EPIC 5 — Calidad
|
||||
|
||||
## [P1] Ticket 12 — Tests query avanzada
|
||||
|
||||
- parser
|
||||
- integración search
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 13 — Tests historial versiones
|
||||
|
||||
- creación
|
||||
- restore
|
||||
- edge cases
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 14 — Tests navegación teclado
|
||||
|
||||
- selección
|
||||
- acciones
|
||||
|
||||
---
|
||||
|
||||
# 🗺️ Orden sugerido
|
||||
|
||||
## Sprint 1
|
||||
- Query parser
|
||||
- Integración search
|
||||
- Real-time search
|
||||
- Tests base
|
||||
|
||||
## Sprint 2
|
||||
- Navegación teclado
|
||||
- Sidebar contextual
|
||||
- Cache búsqueda
|
||||
|
||||
## Sprint 3
|
||||
- Historial versiones (API + UI)
|
||||
|
||||
## Sprint 4
|
||||
- Backup automático
|
||||
- Preload notas
|
||||
- Tests finales
|
||||
|
||||
---
|
||||
|
||||
# ✅ Definition of Done
|
||||
|
||||
- Feature usable sin bugs críticos
|
||||
- Tests pasando
|
||||
- No regresiones
|
||||
- UX fluida (<300ms interacción)
|
||||
@@ -0,0 +1,970 @@
|
||||
# Recall — Tickets técnicos MVP-5 (Confianza, flujo diario y expansión)
|
||||
|
||||
## Objetivo general
|
||||
Consolidar Recall como sistema principal de pensamiento y memoria externa, enfocado en:
|
||||
- confianza total en los datos
|
||||
- reducción extrema de fricción
|
||||
- captura desde fuera de la app
|
||||
- recuperación y operación desde teclado
|
||||
- portabilidad real del conocimiento
|
||||
|
||||
## Principios de producto
|
||||
1. **No perder nada**: backup y restore confiables.
|
||||
2. **Todo a mano**: acciones principales accesibles por teclado.
|
||||
3. **Captura ubicua**: guardar conocimiento desde cualquier contexto.
|
||||
4. **Salida garantizada**: exportaciones útiles y reversibles.
|
||||
5. **Experiencia continua**: la app acompaña el flujo de trabajo, no lo interrumpe.
|
||||
|
||||
---
|
||||
|
||||
# EPIC 1 — Confianza total y resiliencia de datos
|
||||
|
||||
## [P1] Ticket 01 — Diseñar estrategia de backup automático local
|
||||
|
||||
**Objetivo**
|
||||
Definir e implementar una estrategia segura de backup automático para evitar pérdida de datos y aumentar la confianza en Recall.
|
||||
|
||||
**Contexto**
|
||||
Recall ya cuenta con export/import manual e historial de versiones por nota. El siguiente salto es garantizar respaldo periódico y silencioso del estado global del conocimiento.
|
||||
|
||||
**Problema que resuelve**
|
||||
- Riesgo de pérdida por errores del usuario, corrupción local o cambios no deseados.
|
||||
- Dependencia de exportaciones manuales.
|
||||
- Falta de sensación de “sistema confiable”.
|
||||
|
||||
**Alcance**
|
||||
- Diseñar estrategia de backup automático basada en eventos y/o tiempo:
|
||||
- al detectar cambios significativos
|
||||
- cada cierto intervalo configurable
|
||||
- al cerrar sesión o abandonar pestaña cuando aplique
|
||||
- Definir formato del backup:
|
||||
- JSON estructurado compatible con importación
|
||||
- metadatos de versión, fecha, origen, conteos
|
||||
- Definir almacenamiento inicial:
|
||||
- IndexedDB recomendado para snapshots locales
|
||||
- alternativa: local filesystem vía descarga manual asistida
|
||||
- Crear servicio de generación de backup
|
||||
- Crear política de retención:
|
||||
- conservar últimos N backups
|
||||
- limpiar backups viejos automáticamente
|
||||
|
||||
**No incluye**
|
||||
- sincronización cloud
|
||||
- backup remoto
|
||||
- cifrado extremo a extremo
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La app genera backups automáticamente sin intervención manual
|
||||
- Los backups se almacenan con timestamp y metadatos
|
||||
- Existe retención automática configurable o fija
|
||||
- El proceso no bloquea la UI
|
||||
- El formato es compatible con restore/import
|
||||
- Hay tests para serialización y política de retención
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup.ts`
|
||||
- `src/lib/backup-storage.ts`
|
||||
- `src/lib/backup-policy.ts`
|
||||
- `src/types/backup.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
**Notas técnicas**
|
||||
- Separar claramente:
|
||||
- generación del snapshot
|
||||
- persistencia local
|
||||
- política de retención
|
||||
- Preferir un esquema de versión explícito del backup (`schemaVersion`)
|
||||
- Incluir checksum o hash simple opcional para detectar corrupción
|
||||
- Mantener compatibilidad hacia atrás cuando cambie el formato
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 02 — Implementar motor de snapshot global exportable
|
||||
|
||||
**Objetivo**
|
||||
Crear una utilidad robusta que genere snapshots completos y consistentes del estado de Recall.
|
||||
|
||||
**Alcance**
|
||||
- Incluir en el snapshot:
|
||||
- notas
|
||||
- tags
|
||||
- backlinks/enlaces si corresponden
|
||||
- métricas relevantes necesarias para restore
|
||||
- versiones de notas, si se decide incluirlas
|
||||
- metadatos de creación
|
||||
- Crear función `createBackupSnapshot()`
|
||||
- Reutilizar la lógica existente de exportación para evitar duplicación
|
||||
- Estandarizar el shape del payload exportable
|
||||
|
||||
**No incluye**
|
||||
- compresión
|
||||
- cifrado
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Un snapshot puede reconstruir el estado esperado del sistema
|
||||
- El export manual y el backup automático comparten formato base o traductor explícito
|
||||
- Tests verifican consistencia del snapshot
|
||||
- El snapshot incluye versión de esquema y fecha de creación
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup.ts`
|
||||
- `src/lib/export.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
- `__tests__/backup.test.ts`
|
||||
|
||||
**Notas técnicas**
|
||||
- Evitar incluir datos derivados si pueden regenerarse fácilmente
|
||||
- Documentar claramente qué campos se consideran fuente de verdad
|
||||
- Si `NoteUsage` no debe restaurarse, dejarlo explícito en especificación
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 03 — Restore desde backup con preview y validación
|
||||
|
||||
**Objetivo**
|
||||
Permitir restaurar un backup de forma segura, transparente y reversible.
|
||||
|
||||
**Alcance**
|
||||
- Crear flujo de restore desde Settings
|
||||
- Validar el archivo antes de aplicar:
|
||||
- schemaVersion
|
||||
- integridad mínima
|
||||
- estructura esperada
|
||||
- Mostrar preview:
|
||||
- cantidad de notas
|
||||
- tags
|
||||
- versiones
|
||||
- fecha del backup
|
||||
- Permitir dos modos:
|
||||
- merge
|
||||
- replace completo
|
||||
- Confirmación explícita antes de aplicar
|
||||
|
||||
**No incluye**
|
||||
- merge inteligente avanzado por conflicto
|
||||
- restore parcial por selección de entidades
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede seleccionar un backup y previsualizarlo
|
||||
- El sistema informa claramente qué se va a restaurar
|
||||
- Hay confirmación antes del replace
|
||||
- El restore fallido no deja la base en estado inconsistente
|
||||
- Existe feedback claro de éxito/error
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/backup-restore-dialog.tsx`
|
||||
- `src/lib/restore.ts`
|
||||
- `src/lib/backup-validator.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
**Notas técnicas**
|
||||
- En `replace`, considerar transacción única
|
||||
- En `merge`, definir reglas claras por ID/título
|
||||
- Crear un backup previo automático antes de aplicar restore
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 04 — Backup previo automático antes de operaciones destructivas
|
||||
|
||||
**Objetivo**
|
||||
Reducir al mínimo el riesgo antes de operaciones peligrosas.
|
||||
|
||||
**Alcance**
|
||||
- Generar backup automático antes de:
|
||||
- restore replace
|
||||
- import replace
|
||||
- borrados masivos futuros
|
||||
- Etiquetar ese backup como `pre-destructive`
|
||||
- Permitir revertir rápidamente
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Antes de una operación destructiva se crea un backup
|
||||
- El backup queda identificado y visible en UI
|
||||
- Si la operación falla, el backup sigue disponible
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup.ts`
|
||||
- `src/lib/restore.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 05 — Guard de cambios no guardados
|
||||
|
||||
**Objetivo**
|
||||
Evitar pérdida accidental de trabajo durante edición.
|
||||
|
||||
**Alcance**
|
||||
- Detectar cambios sucios en `note-form`
|
||||
- Advertir al:
|
||||
- navegar fuera de la página
|
||||
- cerrar pestaña
|
||||
- refrescar
|
||||
- Permitir omitir warning cuando no hay cambios
|
||||
|
||||
**No incluye**
|
||||
- autosave completo
|
||||
- borradores persistentes
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Si hay cambios sin guardar, aparece advertencia al salir
|
||||
- Si no hay cambios, no aparece advertencia
|
||||
- Funciona en crear y editar
|
||||
- No rompe submit exitoso
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/hooks/use-unsaved-changes.ts`
|
||||
- `src/components/note-form.tsx`
|
||||
- `src/app/edit/[id]/page.tsx`
|
||||
- `src/app/new/page.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Diferenciar estado inicial vs actual
|
||||
- Manejar `beforeunload` con cuidado por compatibilidad del navegador
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 06 — Autosave opcional de borrador local
|
||||
|
||||
**Objetivo**
|
||||
Agregar una capa extra de protección sin imponer complejidad excesiva.
|
||||
|
||||
**Alcance**
|
||||
- Guardar borrador local temporal de la nota en edición
|
||||
- Recuperarlo al reabrir la pantalla
|
||||
- Permitir descartarlo manualmente
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Si se cierra accidentalmente, el borrador puede recuperarse
|
||||
- El borrador se limpia al guardar correctamente
|
||||
- El usuario puede descartar borrador recuperado
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/drafts.ts`
|
||||
- `src/components/note-form.tsx`
|
||||
- `src/components/draft-recovery-banner.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 2 — Operación diaria desde teclado
|
||||
|
||||
## [P1] Ticket 07 — Command Palette global (`Ctrl+K` / `Cmd+K`)
|
||||
|
||||
**Objetivo**
|
||||
Centralizar búsqueda, navegación y acciones en una interfaz rápida tipo command palette.
|
||||
|
||||
**Contexto**
|
||||
Recall ya tiene búsqueda potente y navegación por teclado en el search bar. El siguiente paso es ofrecer una capa global de comandos que reduzca aún más la fricción.
|
||||
|
||||
**Alcance**
|
||||
- Atajo global:
|
||||
- `Ctrl+K` en Windows/Linux
|
||||
- `Cmd+K` en macOS
|
||||
- Modal o palette flotante global
|
||||
- Soportar acciones iniciales:
|
||||
- buscar notas
|
||||
- abrir nota
|
||||
- crear nueva nota
|
||||
- quick add
|
||||
- ir a dashboard
|
||||
- ir a settings
|
||||
- ir a notas favoritas
|
||||
- ir a notas recientes
|
||||
- Secciones:
|
||||
- acciones
|
||||
- resultados de búsqueda
|
||||
- navegación
|
||||
- Navegación total por teclado
|
||||
|
||||
**No incluye**
|
||||
- edición avanzada dentro de la palette
|
||||
- plugins de comandos externos
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La palette abre/cierra con shortcut global
|
||||
- Se puede usar sin mouse
|
||||
- Enter ejecuta acción seleccionada
|
||||
- ESC cierra
|
||||
- Resultados y acciones están claramente separadas
|
||||
- Funciona desde cualquier pantalla
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/command-palette.tsx`
|
||||
- `src/hooks/use-command-palette.ts`
|
||||
- `src/lib/command-palette.ts`
|
||||
- `src/app/layout.tsx`
|
||||
- `src/components/header.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Reutilizar `search.ts` y la API existente cuando sea posible
|
||||
- Mantener selección activa y scroll automático
|
||||
- Considerar accesibilidad: focus trap, ARIA roles
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 08 — Modelo de acciones y proveedores para Command Palette
|
||||
|
||||
**Objetivo**
|
||||
Desacoplar la palette de las acciones concretas para facilitar expansión futura.
|
||||
|
||||
**Alcance**
|
||||
- Crear modelo uniforme de comando:
|
||||
- id
|
||||
- label
|
||||
- description
|
||||
- group
|
||||
- keywords
|
||||
- action handler
|
||||
- icon opcional
|
||||
- Crear proveedores:
|
||||
- acciones estáticas
|
||||
- notas recientes
|
||||
- resultados de búsqueda
|
||||
- Sistema de ranking simple para comandos
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Se pueden agregar nuevas acciones sin tocar el core visual
|
||||
- La palette consume una lista homogénea de items
|
||||
- El sistema soporta agrupación y orden
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/command-items.ts`
|
||||
- `src/lib/command-groups.ts`
|
||||
- `src/lib/command-ranking.ts`
|
||||
- `src/components/command-palette.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 09 — Acciones rápidas por teclado fuera de la palette
|
||||
|
||||
**Objetivo**
|
||||
Expandir la operación de Recall sin depender de clicks.
|
||||
|
||||
**Alcance**
|
||||
- Definir shortcuts globales seguros:
|
||||
- `g h` → dashboard
|
||||
- `g n` → notas
|
||||
- `n` → nueva nota
|
||||
- `/` → enfocar búsqueda
|
||||
- `?` → ayuda de shortcuts
|
||||
- Mostrar ayuda contextual de shortcuts
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Los shortcuts no interfieren con inputs activos
|
||||
- Se pueden desactivar en campos de texto
|
||||
- Existe una vista/modal de ayuda
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/hooks/use-global-shortcuts.ts`
|
||||
- `src/components/keyboard-shortcuts-dialog.tsx`
|
||||
- `src/app/layout.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Ignorar shortcuts cuando hay foco en input, textarea o contenteditable
|
||||
- Centralizar mapa de shortcuts en un único archivo
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 10 — Navegación completa de listas por teclado
|
||||
|
||||
**Objetivo**
|
||||
Permitir abrir y operar notas desde listados sin usar mouse.
|
||||
|
||||
**Alcance**
|
||||
- Flechas para moverse entre resultados/listas
|
||||
- Enter para abrir
|
||||
- Atajos para:
|
||||
- editar
|
||||
- favorite
|
||||
- pin
|
||||
- Soporte en:
|
||||
- `/notes`
|
||||
- dashboard
|
||||
- dropdown de búsqueda
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Las listas principales se pueden recorrer por teclado
|
||||
- El elemento seleccionado tiene estado visual claro
|
||||
- Las acciones rápidas no rompen accesibilidad
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/note-list.tsx`
|
||||
- `src/components/dashboard.tsx`
|
||||
- `src/components/search-bar.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 3 — Contexto activo y workspace continuo
|
||||
|
||||
## [P1] Ticket 11 — Sidebar contextual persistente mejorada
|
||||
|
||||
**Objetivo**
|
||||
Convertir la sidebar contextual en un asistente permanente del flujo de trabajo.
|
||||
|
||||
**Alcance**
|
||||
- Mantener sidebar visible en detalle de nota y opcionalmente en edición
|
||||
- Secciones posibles:
|
||||
- relacionadas
|
||||
- backlinks
|
||||
- co-usadas
|
||||
- recientes
|
||||
- versiones recientes
|
||||
- sugerencias contextuales
|
||||
- Mejorar densidad y jerarquía visual
|
||||
- Permitir colapsar/expandir secciones
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La sidebar muestra contenido útil sin saturar
|
||||
- Las secciones pueden plegarse
|
||||
- En pantallas pequeñas se adapta sin romper el layout
|
||||
- Se distinguen claramente los tipos de relación
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/note-context-sidebar.tsx`
|
||||
- `src/components/note-connections.tsx`
|
||||
- `src/app/notes/[id]/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 12 — Modo trabajo enfocado
|
||||
|
||||
**Objetivo**
|
||||
Ofrecer una experiencia de lectura/consulta prolongada con menos distracciones y más contexto útil.
|
||||
|
||||
**Alcance**
|
||||
- Crear un “modo trabajo” activable por toggle
|
||||
- Cambios de UI:
|
||||
- ancho de lectura optimizado
|
||||
- sidebar contextual persistente
|
||||
- header reducido
|
||||
- acciones rápidas siempre visibles
|
||||
- Persistir preferencia local
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede activar/desactivar el modo trabajo
|
||||
- La preferencia se mantiene entre sesiones
|
||||
- Mejora la experiencia en detalle de nota sin romper navegación general
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/work-mode-toggle.tsx`
|
||||
- `src/lib/work-mode.ts`
|
||||
- `src/app/notes/[id]/page.tsx`
|
||||
- `src/app/globals.css`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 13 — Historial de navegación contextual
|
||||
|
||||
**Objetivo**
|
||||
Facilitar volver sobre el camino mental reciente.
|
||||
|
||||
**Alcance**
|
||||
- Registrar secuencia reciente de notas abiertas
|
||||
- Mostrar “visto recientemente en este contexto”
|
||||
- Posibilidad de volver rápido a 5–10 notas recientes
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario ve un historial local reciente
|
||||
- Puede reabrir notas anteriores con un click o atajo
|
||||
- El historial no duplica entradas consecutivas idénticas
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/navigation-history.ts`
|
||||
- `src/components/recent-context-list.tsx`
|
||||
- `src/components/command-palette.tsx`
|
||||
- `src/components/note-context-sidebar.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 4 — Captura ubicua fuera de Recall
|
||||
|
||||
## [P1] Ticket 14 — Bookmarklet para guardar página actual
|
||||
|
||||
**Objetivo**
|
||||
Permitir capturar contenido desde cualquier web hacia Recall con fricción mínima.
|
||||
|
||||
**Contexto**
|
||||
Recall ya resuelve bien captura interna. El siguiente paso de uso diario es capturar desde el navegador sin tener que abrir manualmente la app y crear una nota.
|
||||
|
||||
**Alcance**
|
||||
- Diseñar bookmarklet inicial que:
|
||||
- tome `document.title`
|
||||
- tome `location.href`
|
||||
- opcionalmente tome selección de texto
|
||||
- abra una URL de Recall con payload prellenado
|
||||
- Crear pantalla o endpoint receptor para captura externa
|
||||
- Mapear captura a tipo de nota por defecto (`note` o `snippet` según caso)
|
||||
|
||||
**No incluye**
|
||||
- extensión completa de navegador
|
||||
- scraping profundo del DOM
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El bookmarklet funciona en páginas comunes
|
||||
- Si hay texto seleccionado, se incluye en la captura
|
||||
- Si no hay selección, se guarda al menos título + URL
|
||||
- Recall recibe y prellena una nota lista para confirmar o guardar
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/capture/page.tsx`
|
||||
- `src/lib/external-capture.ts`
|
||||
- `src/components/bookmarklet-instructions.tsx`
|
||||
- `src/app/settings/page.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Codificar payload en query string de forma segura
|
||||
- Considerar límites de longitud: si es largo, usar mecanismo de POST o fallback
|
||||
- Sanitizar el contenido recibido
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 15 — Flujo de confirmación para captura externa
|
||||
|
||||
**Objetivo**
|
||||
Evitar guardar basura y dar control antes de persistir.
|
||||
|
||||
**Alcance**
|
||||
- Pantalla de revisión para captura externa:
|
||||
- título
|
||||
- url
|
||||
- contenido/selección
|
||||
- tags sugeridos
|
||||
- tipo sugerido
|
||||
- Botones:
|
||||
- guardar
|
||||
- editar
|
||||
- cancelar
|
||||
- Posibilidad de convertir la URL en markdown limpio
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La captura externa llega prellenada
|
||||
- El usuario puede corregir antes de guardar
|
||||
- El flujo es rápido y no requiere pasos innecesarios
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/capture/page.tsx`
|
||||
- `src/components/external-capture-form.tsx`
|
||||
- `src/lib/type-inference.ts`
|
||||
- `src/lib/tags.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 16 — Endpoint seguro para captura externa por POST
|
||||
|
||||
**Objetivo**
|
||||
Preparar Recall para integraciones futuras más robustas que el bookmarklet simple.
|
||||
|
||||
**Alcance**
|
||||
- Crear endpoint dedicado para captura externa
|
||||
- Aceptar payload estructurado:
|
||||
- title
|
||||
- url
|
||||
- selection
|
||||
- source
|
||||
- inferredType
|
||||
- Validar con Zod
|
||||
- Responder con payload listo para preview o guardado
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El endpoint valida correctamente el payload
|
||||
- No guarda automáticamente sin intención explícita
|
||||
- Puede ser reutilizado por extensión futura o integraciones
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/api/capture/route.ts`
|
||||
- `src/lib/external-capture.ts`
|
||||
- `src/lib/validators.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 5 — Importación y exportación de nivel producto
|
||||
|
||||
## [P1] Ticket 17 — Exportación mejorada a Markdown
|
||||
|
||||
**Objetivo**
|
||||
Asegurar portabilidad real del conocimiento en un formato simple y durable.
|
||||
|
||||
**Alcance**
|
||||
- Exportar todas las notas a estructura Markdown
|
||||
- Incluir:
|
||||
- frontmatter opcional
|
||||
- título
|
||||
- contenido
|
||||
- tags
|
||||
- tipo
|
||||
- fechas
|
||||
- Generar nombres de archivo estables y seguros
|
||||
- Opción de exportar zip de múltiples `.md`
|
||||
|
||||
**No incluye**
|
||||
- sync con repos remotos
|
||||
- assets binarios complejos
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede exportar todas las notas a `.md`
|
||||
- Cada nota queda representada de forma legible
|
||||
- Los archivos son reimportables con reglas definidas
|
||||
- Tags y tipo no se pierden
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/export-markdown.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Resolver colisiones de nombres
|
||||
- Normalizar saltos de línea
|
||||
- Documentar formato de frontmatter si se usa
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 18 — Exportación HTML simple y legible
|
||||
|
||||
**Objetivo**
|
||||
Facilitar compartir o archivar notas en un formato visualmente cómodo.
|
||||
|
||||
**Alcance**
|
||||
- Crear export HTML por nota o lote
|
||||
- Incluir render de markdown
|
||||
- Estilo básico embebido o plantilla simple
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La exportación HTML es legible offline
|
||||
- Respeta headings, listas, código y enlaces
|
||||
- Puede abrirse directamente en navegador
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/export-html.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 19 — Importador de Markdown mejorado
|
||||
|
||||
**Objetivo**
|
||||
Hacer Recall más interoperable con flujos existentes.
|
||||
|
||||
**Alcance**
|
||||
- Mejorar importador `.md` actual para soportar:
|
||||
- frontmatter
|
||||
- tags
|
||||
- tipo
|
||||
- títulos ausentes o derivados
|
||||
- sintaxis `[[wiki]]`
|
||||
- Permitir importar múltiples archivos si la UX lo permite
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Markdown con frontmatter se importa correctamente
|
||||
- Se preservan tags y tipo cuando existen
|
||||
- El contenido sigue siendo fiel al original
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/import-markdown.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
- `src/components/import-dialog.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 20 — Importador base de Obsidian-compatible Markdown
|
||||
|
||||
**Objetivo**
|
||||
Reducir fricción de entrada para usuarios con conocimiento ya almacenado fuera de Recall.
|
||||
|
||||
**Alcance**
|
||||
- Aceptar archivos/estructura compatibles con vault simple:
|
||||
- markdown
|
||||
- `[[wiki links]]`
|
||||
- tags inline `#tag`
|
||||
- Resolver títulos desde filename cuando haga falta
|
||||
- Crear estrategia básica de deduplicación
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Un conjunto simple de notas estilo Obsidian se importa sin perder estructura esencial
|
||||
- Los wiki links se preservan o transforman correctamente
|
||||
- La deduplicación evita duplicados obvios
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/import-obsidian.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 6 — Operación y configuración visible
|
||||
|
||||
## [P2] Ticket 21 — Centro de respaldo y portabilidad en Settings
|
||||
|
||||
**Objetivo**
|
||||
Reunir en una sola UI todas las capacidades de backup, restore, import y export.
|
||||
|
||||
**Alcance**
|
||||
- Crear sección clara en Settings:
|
||||
- backups automáticos
|
||||
- backups disponibles
|
||||
- restore
|
||||
- export JSON
|
||||
- export Markdown
|
||||
- export HTML
|
||||
- import Markdown/JSON
|
||||
- Mostrar último backup realizado
|
||||
- Mostrar tamaño aproximado y fecha
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Settings concentra todas las acciones de seguridad y portabilidad
|
||||
- El usuario entiende claramente qué hace cada opción
|
||||
- El flujo no requiere conocer detalles técnicos internos
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/components/backup-center.tsx`
|
||||
- `src/components/export-options.tsx`
|
||||
- `src/components/import-options.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 22 — Configuración visible de feature flags y preferencias clave
|
||||
|
||||
**Objetivo**
|
||||
Dar control operativo sobre comportamientos avanzados ya implementados.
|
||||
|
||||
**Alcance**
|
||||
- Exponer desde Settings:
|
||||
- feature flags activas
|
||||
- modo trabajo
|
||||
- backup automático on/off
|
||||
- retención de backups
|
||||
- shortcuts visibles
|
||||
- Persistencia local o en configuración simple
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede ver y cambiar flags/preferencias principales
|
||||
- Los cambios se reflejan sin romper la app
|
||||
- Existe estado inicial razonable por defecto
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/lib/features.ts`
|
||||
- `src/lib/preferences.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 7 — Calidad, seguridad operativa y pruebas
|
||||
|
||||
## [P1] Ticket 23 — Tests unitarios para backup/restore
|
||||
|
||||
**Objetivo**
|
||||
Proteger la capa de confianza antes de expandir más el producto.
|
||||
|
||||
**Alcance**
|
||||
- Tests para:
|
||||
- snapshot generation
|
||||
- validación de backup
|
||||
- retención
|
||||
- restore merge
|
||||
- restore replace
|
||||
- backup pre-destructive
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Casos felices y bordes cubiertos
|
||||
- Fixtures de backup versionados
|
||||
- Restore inválido falla de forma segura
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `__tests__/backup.test.ts`
|
||||
- `__tests__/restore.test.ts`
|
||||
- `__tests__/backup-validator.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 24 — Tests de integración para command palette y captura externa
|
||||
|
||||
**Objetivo**
|
||||
Validar los nuevos flujos de uso diario y expansión.
|
||||
|
||||
**Alcance**
|
||||
- Probar:
|
||||
- apertura/cierre de palette
|
||||
- navegación por teclado
|
||||
- ejecución de comandos
|
||||
- recepción de captura externa
|
||||
- flujo de confirmación de captura
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Los flujos críticos están cubiertos
|
||||
- Los shortcuts no interfieren con formularios
|
||||
- La captura externa llega correctamente prellenada
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `__tests__/command-palette.test.tsx`
|
||||
- `__tests__/capture-flow.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 25 — Harden de validaciones y límites operativos
|
||||
|
||||
**Objetivo**
|
||||
Aumentar robustez de las nuevas entradas/salidas del sistema.
|
||||
|
||||
**Alcance**
|
||||
- Definir límites razonables para:
|
||||
- tamaño de backup
|
||||
- tamaño de payload de captura externa
|
||||
- cantidad de backups retenidos
|
||||
- Validación estricta de formatos
|
||||
- Mensajes de error claros y recuperables
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El sistema rechaza entradas excesivas o inválidas de forma clara
|
||||
- No se degrada la app por payloads grandes o malformados
|
||||
- Los errores se muestran de forma consistente
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup-validator.ts`
|
||||
- `src/lib/external-capture.ts`
|
||||
- `src/lib/errors.ts`
|
||||
- `src/lib/validators.ts`
|
||||
|
||||
---
|
||||
|
||||
# Orden recomendado de implementación
|
||||
|
||||
## Sprint 1 — Confianza primero
|
||||
- Ticket 01 — Estrategia de backup automático local
|
||||
- Ticket 02 — Motor de snapshot global exportable
|
||||
- Ticket 03 — Restore con preview y validación
|
||||
- Ticket 04 — Backup previo automático
|
||||
- Ticket 23 — Tests unitarios backup/restore
|
||||
|
||||
## Sprint 2 — Flujo diario brutal
|
||||
- Ticket 07 — Command Palette global
|
||||
- Ticket 08 — Modelo de acciones para palette
|
||||
- Ticket 09 — Shortcuts globales
|
||||
- Ticket 10 — Navegación de listas por teclado
|
||||
- Ticket 24 — Tests integración palette
|
||||
|
||||
## Sprint 3 — Contexto y continuidad
|
||||
- Ticket 11 — Sidebar contextual persistente mejorada
|
||||
- Ticket 12 — Modo trabajo enfocado
|
||||
- Ticket 13 — Historial de navegación contextual
|
||||
- Ticket 05 — Guard de cambios no guardados
|
||||
- Ticket 06 — Autosave opcional de borrador local
|
||||
|
||||
## Sprint 4 — Captura externa
|
||||
- Ticket 14 — Bookmarklet para guardar página actual
|
||||
- Ticket 15 — Flujo de confirmación para captura externa
|
||||
- Ticket 16 — Endpoint seguro para captura externa
|
||||
|
||||
## Sprint 5 — Portabilidad real
|
||||
- Ticket 17 — Exportación mejorada a Markdown
|
||||
- Ticket 18 — Exportación HTML
|
||||
- Ticket 19 — Importador Markdown mejorado
|
||||
- Ticket 20 — Importador base Obsidian-compatible
|
||||
- Ticket 21 — Centro de respaldo y portabilidad
|
||||
- Ticket 22 — Configuración visible de flags/preferencias
|
||||
- Ticket 25 — Harden de validaciones y límites
|
||||
|
||||
---
|
||||
|
||||
# Dependencias y decisiones de arquitectura recomendadas
|
||||
|
||||
## Decisión 1 — Backup format
|
||||
Definir explícitamente un formato versionado:
|
||||
|
||||
```ts
|
||||
type RecallBackup = {
|
||||
schemaVersion: "1.0";
|
||||
createdAt: string;
|
||||
source: "automatic" | "manual" | "pre-destructive";
|
||||
appVersion?: string;
|
||||
metadata: {
|
||||
noteCount: number;
|
||||
tagCount: number;
|
||||
versionCount?: number;
|
||||
};
|
||||
data: {
|
||||
notes: unknown[];
|
||||
tags: unknown[];
|
||||
noteVersions?: unknown[];
|
||||
backlinks?: unknown[];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Decisión 2 — Restore modes
|
||||
Mantener solo dos modos al inicio:
|
||||
- `merge`: agrega/actualiza sin borrar todo
|
||||
- `replace`: reemplaza completamente el dataset
|
||||
|
||||
No agregar modos intermedios hasta tener uso real.
|
||||
|
||||
## Decisión 3 — Command palette scope inicial
|
||||
La primera versión debe centrarse en:
|
||||
- navegación
|
||||
- búsqueda
|
||||
- creación
|
||||
- acceso a pantallas
|
||||
No convertirla aún en un motor de automatizaciones complejas.
|
||||
|
||||
## Decisión 4 — Bookmarklet MVP
|
||||
El bookmarklet debe ser lo más simple posible:
|
||||
- capturar `title`
|
||||
- capturar `url`
|
||||
- capturar selección si existe
|
||||
- abrir Recall con preview prellenada
|
||||
|
||||
No hacer scraping complejo en esta fase.
|
||||
|
||||
## Decisión 5 — Export portability
|
||||
Markdown debe convertirse en el formato de salida principal legible por humanos.
|
||||
JSON debe seguir siendo el formato fiel para restore exacto.
|
||||
|
||||
---
|
||||
|
||||
# Plantilla sugerida para Claude Code
|
||||
|
||||
## Título
|
||||
`[P1] Implementar restore desde backup con preview y validación`
|
||||
|
||||
## Contexto
|
||||
Recall ya ofrece export/import manual e historial de versiones. Para convertirlo en una herramienta confiable de uso diario, se necesita restore seguro desde backups automáticos y manuales.
|
||||
|
||||
## Objetivo
|
||||
Permitir restaurar backups con validación previa, preview del contenido y confirmación explícita, soportando modos `merge` y `replace`.
|
||||
|
||||
## Alcance
|
||||
- validador de backup
|
||||
- preview de metadatos
|
||||
- flujo de confirmación
|
||||
- ejecución segura del restore
|
||||
- integración con settings
|
||||
|
||||
## No incluye
|
||||
- resolución avanzada de conflictos
|
||||
- restore parcial por tipo de entidad
|
||||
- sync remoto
|
||||
|
||||
## Criterios de aceptación
|
||||
- ...
|
||||
- ...
|
||||
- ...
|
||||
|
||||
## Archivos a tocar
|
||||
- ...
|
||||
- ...
|
||||
|
||||
## Notas técnicas
|
||||
- usar transacciones en replace
|
||||
- crear backup previo automático
|
||||
- mostrar errores consistentes
|
||||
|
||||
---
|
||||
|
||||
# Definition of Done
|
||||
|
||||
- Funcionalidad implementada y usable
|
||||
- Tests unitarios e integración relevantes pasando
|
||||
- Sin regresiones en CRUD, búsqueda, versiones y captura
|
||||
- Estados vacíos, borde y error cubiertos
|
||||
- UI clara para acciones sensibles
|
||||
- Portabilidad comprobable con export/import real
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/dev.db
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
echo "Checking database..."
|
||||
|
||||
# Ensure data directory exists with proper permissions
|
||||
mkdir -p /app/data
|
||||
chmod 777 /app/data
|
||||
|
||||
# Run db push (creates/updates database schema)
|
||||
# If it fails due to OOM but DB exists, continue anyway
|
||||
./node_modules/prisma/build/index.js db push --skip-generate || {
|
||||
exit_code=$?
|
||||
if [ -f /app/data/dev.db ]; then
|
||||
echo "db push failed (code $exit_code) but database exists, continuing..."
|
||||
else
|
||||
echo "db push failed and database does not exist"
|
||||
exit $exit_code
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Binary file not shown.
@@ -78,3 +78,13 @@ model NoteCoUsage {
|
||||
@@index([fromNoteId])
|
||||
@@index([toNoteId])
|
||||
}
|
||||
|
||||
model NoteVersion {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
title String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([noteId, createdAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Recall - Resumen del Proyecto
|
||||
|
||||
## Fecha
|
||||
2026-03-22
|
||||
|
||||
## Descripción
|
||||
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||
|
||||
## Stack Tecnológico
|
||||
- **Framework**: Next.js 16.2.1 con App Router
|
||||
- **Base de datos**: SQLite via Prisma ORM
|
||||
- **Lenguaje**: TypeScript
|
||||
- **UI**: TailwindCSS + shadcn/ui components
|
||||
- **Testing**: Jest
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── api/ # API routes
|
||||
│ │ ├── notes/ # CRUD de notas, versions, quick
|
||||
│ │ ├── tags/ # Tags y sugerencias
|
||||
│ │ ├── search/ # Búsqueda avanzada
|
||||
│ │ ├── usage/ # Tracking de uso
|
||||
│ │ ├── metrics/ # Métricas internas
|
||||
│ │ ├── centrality/ # Notas centrales
|
||||
│ │ └── export-import/ # Import/export JSON
|
||||
│ ├── notes/[id]/ # Detalle de nota
|
||||
│ ├── edit/[id]/ # Edición de nota
|
||||
│ └── new/ # Nueva nota
|
||||
├── components/ # Componentes React
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── dashboard.tsx # Dashboard inteligente
|
||||
│ ├── quick-add.tsx # Captura rápida
|
||||
│ ├── note-form.tsx # Formulario de nota
|
||||
│ ├── note-connections.tsx # Panel de conexiones
|
||||
│ ├── related-notes.tsx # Notas relacionadas
|
||||
│ ├── version-history.tsx # Historial de versiones
|
||||
│ └── track-note-view.tsx # Tracking de vistas
|
||||
└── lib/ # Utilidades
|
||||
├── prisma.ts # Cliente Prisma
|
||||
├── usage.ts # Tracking de uso y co-uso
|
||||
├── search.ts # Búsqueda con scoring
|
||||
├── query-parser.ts # Parser de queries avanzadas
|
||||
├── versions.ts # Historial de versiones
|
||||
├── related.ts # Notas relacionadas
|
||||
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||
├── tags.ts # Normalización y sugerencias
|
||||
├── metrics.ts # Métricas de dashboard
|
||||
├── centrality.ts # Cálculo de centralidad
|
||||
├── type-inference.ts # Detección automática de tipo
|
||||
├── link-suggestions.ts # Sugerencias de enlaces
|
||||
├── features.ts # Feature flags
|
||||
└── validators.ts # Zod schemas
|
||||
```
|
||||
|
||||
## Modelos de Datos
|
||||
|
||||
### Note
|
||||
```prisma
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String
|
||||
type String @default("note") // command, snippet, decision, recipe, procedure, inventory, note
|
||||
isFavorite Boolean @default(false)
|
||||
isPinned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
creationSource String @default("form") // form, quick, import
|
||||
}
|
||||
```
|
||||
|
||||
### NoteUsage
|
||||
```prisma
|
||||
model NoteUsage {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
eventType String // view, search_click, related_click, link_click, copy_command, copy_snippet
|
||||
query String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### NoteCoUsage
|
||||
```prisma
|
||||
model NoteCoUsage {
|
||||
id String @id @default(cuid())
|
||||
fromNoteId String
|
||||
toNoteId String
|
||||
weight Int @default(1)
|
||||
}
|
||||
```
|
||||
|
||||
### Backlink
|
||||
```prisma
|
||||
model Backlink {
|
||||
sourceNoteId String
|
||||
targetNoteId String
|
||||
}
|
||||
```
|
||||
|
||||
### NoteVersion
|
||||
```prisma
|
||||
model NoteVersion {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
title String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
## APIs Principales
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
|
||||
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
|
||||
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
|
||||
| `/api/notes/quick` | POST | Creación rápida |
|
||||
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||
| `/api/search` | GET | Búsqueda con scoring |
|
||||
| `/api/tags` | GET | Listar/buscar tags |
|
||||
| `/api/tags/suggest` | GET | Sugerencias automáticas |
|
||||
| `/api/usage` | GET | Estadísticas de uso |
|
||||
| `/api/usage/co-usage` | GET | Notas co-usadas |
|
||||
| `/api/metrics` | GET | Métricas de dashboard |
|
||||
| `/api/centrality` | GET | Notas más centrales |
|
||||
|
||||
## Features Implementadas
|
||||
|
||||
### MVP-1 (Completado)
|
||||
- CRUD completo de notas
|
||||
- Sistema de tags
|
||||
- Búsqueda básica
|
||||
|
||||
### MVP-2 (Completado)
|
||||
- Búsqueda avanzada con scoring
|
||||
- Quick Add con prefijos (cmd:, snip:, etc.)
|
||||
- Backlinks con sintaxis [[wiki]]
|
||||
- Formularios guiados por tipo de nota
|
||||
|
||||
### MVP-3 Sprint 1
|
||||
- Usage tracking (vistas, clics, copias)
|
||||
- Dashboard inteligente (Recientes, Más usadas, Por tipo)
|
||||
- Scoring boost basado en uso
|
||||
|
||||
### MVP-3 Sprint 2
|
||||
- Sugerencias automáticas de tags
|
||||
- Panel "Conectado con" (backlinks, enlaces, relacionadas)
|
||||
|
||||
### MVP-3 Sprint 3
|
||||
- Quick Add multilínea
|
||||
- Pegado inteligente con detección de tipo
|
||||
- Sugerencia automática de tipo de nota
|
||||
- Sugerencia de enlaces internos
|
||||
|
||||
### MVP-3 Sprint 4
|
||||
- Registro de co-uso entre notas
|
||||
- Métricas internas (notas por tipo, más vistas, por origen)
|
||||
- Cálculo de notas centrales (centrality score)
|
||||
- Registro de origen de creación (form/quick/import)
|
||||
- Feature flags configurables
|
||||
|
||||
### Mejoras UI Recientes
|
||||
- Header responsive con menú hamburguesa en móvil
|
||||
- Desktop: una fila con logo, nav links, QuickAdd y botón Nueva nota
|
||||
- Mobile: logo + QuickAdd + hamburguesa → dropdown con nav links y botón Nueva nota
|
||||
|
||||
### MVP-4 Sprint 1
|
||||
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||
- Búsqueda en tiempo real con 300ms debounce
|
||||
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
|
||||
- Dropdown de resultados con cache de 50 entradas
|
||||
|
||||
### MVP-4 Sprint 2
|
||||
- Sidebar contextual con co-uso (notas vistas juntas)
|
||||
- Cache de resultados de búsqueda
|
||||
|
||||
### MVP-4 Sprint 3
|
||||
- Historial de versiones de notas
|
||||
- API de versiones (crear, listar, restaurar)
|
||||
- UI de historial en diálogo de nota
|
||||
|
||||
### MVP-4 Sprint 4
|
||||
- Tests de historial de versiones (11 tests)
|
||||
|
||||
## Algoritmo de Scoring
|
||||
|
||||
```typescript
|
||||
// Search scoring
|
||||
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||
|
||||
// Related notes scoring
|
||||
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||
|
||||
// Centrality score
|
||||
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
**211 tests** cubriendo:
|
||||
- API routes (CRUD, search, tags, etc.)
|
||||
- Search y scoring
|
||||
- Query parser
|
||||
- Notas relacionadas
|
||||
- Backlinks
|
||||
- Type inference
|
||||
- Link suggestions
|
||||
- Usage tracking
|
||||
- Dashboard
|
||||
- Version history
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
npm run dev # Desarrollo
|
||||
npm run build # Build producción
|
||||
npm run test # Tests
|
||||
npx prisma db push # Sync schema
|
||||
npx prisma studio # UI de BD
|
||||
```
|
||||
|
||||
## Configuración de Feature Flags
|
||||
|
||||
```bash
|
||||
FLAG_CENTRALITY=true
|
||||
FLAG_PASSIVE_RECOMMENDATIONS=true
|
||||
FLAG_TYPE_SUGGESTIONS=true
|
||||
FLAG_LINK_SUGGESTIONS=true
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
- [ ] Panel de métricas visible en UI
|
||||
- [ ] Configuración de feature flags en Settings
|
||||
- [ ] Visualización del grafo de conocimiento
|
||||
- [ ] Exportación mejorada (Markdown, HTML)
|
||||
- [ ] Tests de integración E2E
|
||||
@@ -0,0 +1,222 @@
|
||||
# Recall - Resumen del Proyecto
|
||||
|
||||
## Fecha
|
||||
2026-03-22
|
||||
|
||||
## Descripción
|
||||
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||
|
||||
## Stack Tecnológico
|
||||
- **Framework**: Next.js 16.2.1 con App Router
|
||||
- **Base de datos**: SQLite via Prisma ORM
|
||||
- **Lenguaje**: TypeScript
|
||||
- **UI**: TailwindCSS + shadcn/ui components
|
||||
- **Testing**: Jest
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── api/ # API routes
|
||||
│ │ ├── notes/ # CRUD, versions, quick
|
||||
│ │ ├── tags/ # Tags y sugerencias
|
||||
│ │ ├── search/ # Búsqueda avanzada
|
||||
│ │ ├── usage/ # Tracking de uso y co-uso
|
||||
│ │ ├── metrics/ # Métricas internas
|
||||
│ │ ├── centrality/ # Notas centrales
|
||||
│ │ └── export-import/ # Import/export JSON
|
||||
│ ├── notes/[id]/ # Detalle de nota
|
||||
│ ├── edit/[id]/ # Edición de nota
|
||||
│ └── new/ # Nueva nota
|
||||
├── components/ # Componentes React
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── dashboard.tsx # Dashboard inteligente
|
||||
│ ├── quick-add.tsx # Captura rápida
|
||||
│ ├── note-form.tsx # Formulario de nota
|
||||
│ ├── note-connections.tsx # Panel de conexiones
|
||||
│ ├── related-notes.tsx # Notas relacionadas
|
||||
│ ├── version-history.tsx # Historial de versiones
|
||||
│ ├── track-note-view.tsx # Tracking de vistas
|
||||
│ └── search-bar.tsx # Búsqueda en tiempo real
|
||||
└── lib/ # Utilidades
|
||||
├── prisma.ts # Cliente Prisma
|
||||
├── usage.ts # Tracking de uso y co-uso
|
||||
├── search.ts # Búsqueda con scoring
|
||||
├── query-parser.ts # Parser de queries avanzadas
|
||||
├── versions.ts # Historial de versiones
|
||||
├── related.ts # Notas relacionadas
|
||||
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||
├── tags.ts # Normalización y sugerencias
|
||||
├── metrics.ts # Métricas de dashboard
|
||||
├── centrality.ts # Cálculo de centralidad
|
||||
├── type-inference.ts # Detección automática de tipo
|
||||
├── link-suggestions.ts # Sugerencias de enlaces
|
||||
├── features.ts # Feature flags
|
||||
└── validators.ts # Zod schemas
|
||||
```
|
||||
|
||||
## Modelos de Datos
|
||||
|
||||
### Note
|
||||
```prisma
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String
|
||||
type String @default("note")
|
||||
isFavorite Boolean @default(false)
|
||||
isPinned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
creationSource String @default("form")
|
||||
}
|
||||
```
|
||||
|
||||
### NoteUsage
|
||||
```prisma
|
||||
model NoteUsage {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
eventType String
|
||||
query String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### NoteCoUsage
|
||||
```prisma
|
||||
model NoteCoUsage {
|
||||
id String @id @default(cuid())
|
||||
fromNoteId String
|
||||
toNoteId String
|
||||
weight Int @default(1)
|
||||
}
|
||||
```
|
||||
|
||||
### NoteVersion
|
||||
```prisma
|
||||
model NoteVersion {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
title String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### Backlink
|
||||
```prisma
|
||||
model Backlink {
|
||||
sourceNoteId String
|
||||
targetNoteId String
|
||||
}
|
||||
```
|
||||
|
||||
## APIs Principales
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
|
||||
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
|
||||
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
|
||||
| `/api/notes/quick` | POST | Creación rápida |
|
||||
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||
| `/api/search` | GET | Búsqueda con scoring |
|
||||
| `/api/tags` | GET | Listar/buscar tags |
|
||||
| `/api/tags/suggest` | GET | Sugerencias automáticas |
|
||||
| `/api/usage` | GET | Estadísticas de uso |
|
||||
| `/api/usage/co-usage` | GET | Notas co-usadas |
|
||||
| `/api/metrics` | GET | Métricas de dashboard |
|
||||
| `/api/centrality` | GET | Notas más centrales |
|
||||
|
||||
## Features Implementadas
|
||||
|
||||
### MVP-1
|
||||
- CRUD completo de notas
|
||||
- Sistema de tags
|
||||
- Búsqueda básica
|
||||
|
||||
### MVP-2
|
||||
- Búsqueda avanzada con scoring
|
||||
- Quick Add con prefijos (cmd:, snip:, etc.)
|
||||
- Backlinks con sintaxis [[wiki]]
|
||||
- Formularios guiados por tipo de nota
|
||||
|
||||
### MVP-3
|
||||
- Usage tracking (vistas, clics, copias)
|
||||
- Dashboard inteligente
|
||||
- Scoring boost basado en uso
|
||||
- Sugerencias automáticas de tags
|
||||
- Panel "Conectado con"
|
||||
- Quick Add multilínea
|
||||
- Pegado inteligente con detección de tipo
|
||||
- Sugerencia automática de tipo de nota
|
||||
- Sugerencia de enlaces internos
|
||||
- Registro de co-uso entre notas
|
||||
- Métricas internas
|
||||
- Cálculo de notas centrales
|
||||
- Feature flags configurables
|
||||
|
||||
### MVP-4
|
||||
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||
- Búsqueda en tiempo real con 300ms debounce
|
||||
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
|
||||
- Dropdown de resultados con cache
|
||||
- Sidebar contextual con co-uso
|
||||
- Historial de versiones de notas
|
||||
- Tests de historial de versiones
|
||||
|
||||
## Algoritmo de Scoring
|
||||
|
||||
```typescript
|
||||
// Search scoring
|
||||
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||
|
||||
// Related notes scoring
|
||||
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||
|
||||
// Centrality score
|
||||
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
**211 tests** cubriendo:
|
||||
- API routes (CRUD, search, tags, versions)
|
||||
- Search y scoring
|
||||
- Query parser
|
||||
- Notas relacionadas
|
||||
- Backlinks
|
||||
- Type inference
|
||||
- Link suggestions
|
||||
- Usage tracking
|
||||
- Dashboard
|
||||
- Version history
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
npm run dev # Desarrollo
|
||||
npm run build # Build producción
|
||||
npm test # Tests (usar npx jest)
|
||||
npx prisma db push # Sync schema
|
||||
npx prisma studio # UI de BD
|
||||
```
|
||||
|
||||
## Configuración de Feature Flags
|
||||
|
||||
```bash
|
||||
FLAG_CENTRALITY=true
|
||||
FLAG_PASSIVE_RECOMMENDATIONS=true
|
||||
FLAG_TYPE_SUGGESTIONS=true
|
||||
FLAG_LINK_SUGGESTIONS=true
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
- [ ] Panel de métricas visible en UI
|
||||
- [ ] Configuración de feature flags en Settings
|
||||
- [ ] Visualización del grafo de conocimiento
|
||||
- [ ] Exportación mejorada (Markdown, HTML)
|
||||
- [ ] Tests E2E
|
||||
@@ -0,0 +1,342 @@
|
||||
# Recall - Resumen del Proyecto
|
||||
|
||||
## Fecha
|
||||
2026-03-22
|
||||
|
||||
## Descripción
|
||||
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||
|
||||
## Stack Tecnológico
|
||||
- **Framework**: Next.js 16.2.1 con App Router + Turbopack
|
||||
- **Base de datos**: SQLite via Prisma ORM
|
||||
- **Lenguaje**: TypeScript
|
||||
- **UI**: TailwindCSS + shadcn/ui components
|
||||
- **Testing**: Jest (226 tests)
|
||||
- **Notificaciones**: Sonner (toasts)
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── notes/ # CRUD, versions, quick, backlinks, links, suggest
|
||||
│ │ ├── tags/ # Tags y sugerencias
|
||||
│ │ ├── search/ # Búsqueda avanzada
|
||||
│ │ ├── usage/ # Tracking de uso y co-uso
|
||||
│ │ ├── metrics/ # Métricas internas
|
||||
│ │ ├── centrality/ # Notas centrales
|
||||
│ │ ├── export-import/ # Import/export JSON, Markdown, HTML
|
||||
│ │ ├── import-markdown/ # Importador Markdown mejorado
|
||||
│ │ └── capture/ # Captura externa (bookmarklet)
|
||||
│ ├── notes/[id]/ # Detalle de nota
|
||||
│ ├── edit/[id]/ # Edición de nota
|
||||
│ ├── new/ # Nueva nota
|
||||
│ ├── capture/ # Página de confirmación de captura
|
||||
│ └── settings/ # Configuración
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── dashboard.tsx # Dashboard inteligente
|
||||
│ ├── quick-add.tsx # Captura rápida
|
||||
│ ├── note-form.tsx # Formulario de nota
|
||||
│ ├── note-connections.tsx # Panel de conexiones
|
||||
│ ├── note-list.tsx # Lista de notas
|
||||
│ ├── keyboard-navigable-note-list.tsx # Lista con navegación teclado
|
||||
│ ├── keyboard-hint.tsx # Hint de atajos
|
||||
│ ├── related-notes.tsx # Notas relacionadas
|
||||
│ ├── version-history.tsx # Historial de versiones
|
||||
│ ├── track-note-view.tsx # Tracking de vistas
|
||||
│ ├── search-bar.tsx # Búsqueda en tiempo real
|
||||
│ ├── command-palette.tsx # Command palette (Ctrl+K)
|
||||
│ ├── keyboard-shortcuts-dialog.tsx # Diálogo de atajos
|
||||
│ ├── shortcuts-provider.tsx # Provider de shortcuts
|
||||
│ ├── work-mode-toggle.tsx # Toggle modo trabajo
|
||||
│ ├── draft-recovery-banner.tsx # Banner de recuperación
|
||||
│ ├── backup-restore-dialog.tsx # Restore con preview
|
||||
│ ├── backup-list.tsx # Lista de backups
|
||||
│ ├── bookmarklet-instructions.tsx # Instrucciones del bookmarklet
|
||||
│ ├── recent-context-list.tsx # Historial de navegación
|
||||
│ ├── track-navigation-history.tsx # Tracking de historial
|
||||
│ └── preferences-panel.tsx # Panel de preferencias
|
||||
├── hooks/
|
||||
│ ├── use-global-shortcuts.ts # Atajos globales
|
||||
│ ├── use-note-list-keyboard.ts # Navegación teclado en listas
|
||||
│ ├── use-unsaved-changes.ts # Guard de cambios sin guardar
|
||||
│ └── ...
|
||||
└── lib/
|
||||
├── prisma.ts # Cliente Prisma
|
||||
├── usage.ts # Tracking de uso y co-uso
|
||||
├── search.ts # Búsqueda con scoring
|
||||
├── query-parser.ts # Parser de queries avanzadas
|
||||
├── versions.ts # Historial de versiones
|
||||
├── related.ts # Notas relacionadas
|
||||
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||
├── tags.ts # Normalización y sugerencias
|
||||
├── metrics.ts # Métricas de dashboard
|
||||
├── centrality.ts # Cálculo de centralidad
|
||||
├── type-inference.ts # Detección automática de tipo
|
||||
├── link-suggestions.ts # Sugerencias de enlaces
|
||||
├── features.ts # Feature flags
|
||||
├── validators.ts # Zod schemas
|
||||
├── errors.ts # Manejo de errores
|
||||
├── backup.ts # Snapshot de backup
|
||||
├── backup-storage.ts # IndexedDB storage
|
||||
├── backup-policy.ts # Política de retención
|
||||
├── backup-validator.ts # Validación de backups
|
||||
├── restore.ts # Restore de backups
|
||||
├── drafts.ts # Borradores locales
|
||||
├── work-mode.ts # Modo trabajo
|
||||
├── navigation-history.ts # Historial de navegación
|
||||
├── export-markdown.ts # Exportación Markdown
|
||||
├── export-html.ts # Exportación HTML
|
||||
├── import-markdown.ts # Importador Markdown
|
||||
└── external-capture.ts # Captura externa
|
||||
```
|
||||
|
||||
## Modelos de Datos
|
||||
|
||||
### Note
|
||||
```prisma
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String
|
||||
type String @default("note")
|
||||
isFavorite Boolean @default(false)
|
||||
isPinned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
creationSource String @default("form")
|
||||
}
|
||||
```
|
||||
|
||||
### NoteUsage
|
||||
```prisma
|
||||
model NoteUsage {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
eventType String
|
||||
query String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### NoteCoUsage
|
||||
```prisma
|
||||
model NoteCoUsage {
|
||||
id String @id @default(cuid())
|
||||
fromNoteId String
|
||||
toNoteId String
|
||||
weight Int @default(1)
|
||||
}
|
||||
```
|
||||
|
||||
### NoteVersion
|
||||
```prisma
|
||||
model NoteVersion {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
title String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### Backlink
|
||||
```prisma
|
||||
model Backlink {
|
||||
sourceNoteId String
|
||||
targetNoteId String
|
||||
}
|
||||
```
|
||||
|
||||
## APIs Principales
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
|
||||
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
|
||||
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
|
||||
| `/api/notes/quick` | POST | Creación rápida |
|
||||
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||
| `/api/search` | GET | Búsqueda con scoring |
|
||||
| `/api/tags` | GET | Listar/buscar tags |
|
||||
| `/api/tags/suggest` | GET | Sugerencias automáticas |
|
||||
| `/api/usage` | GET | Estadísticas de uso |
|
||||
| `/api/usage/co-usage` | GET | Notas co-usadas |
|
||||
| `/api/metrics` | GET | Métricas de dashboard |
|
||||
| `/api/centrality` | GET | Notas más centrales |
|
||||
| `/api/export-import` | GET, POST | Export/Import (JSON, Markdown, HTML) |
|
||||
| `/api/import-markdown` | POST | Importador Markdown mejorado |
|
||||
| `/api/capture` | POST | Captura externa segura |
|
||||
|
||||
## Features Implementadas
|
||||
|
||||
### MVP-1
|
||||
- CRUD completo de notas
|
||||
- Sistema de tags
|
||||
- Búsqueda básica
|
||||
|
||||
### MVP-2
|
||||
- Búsqueda avanzada con scoring
|
||||
- Quick Add con prefijos (cmd:, snip:, etc.)
|
||||
- Backlinks con sintaxis [[wiki]]
|
||||
- Formularios guiados por tipo de nota
|
||||
|
||||
### MVP-3
|
||||
- Usage tracking (vistas, clics, copias)
|
||||
- Dashboard inteligente
|
||||
- Scoring boost basado en uso
|
||||
- Sugerencias automáticas de tags
|
||||
- Panel "Conectado con"
|
||||
- Quick Add multilínea
|
||||
- Pegado inteligente con detección de tipo
|
||||
- Sugerencia automática de tipo de nota
|
||||
- Sugerencia de enlaces internos
|
||||
- Registro de co-uso entre notas
|
||||
- Métricas internas
|
||||
- Cálculo de notas centrales
|
||||
- Feature flags configurables
|
||||
|
||||
### MVP-4
|
||||
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||
- Búsqueda en tiempo real con 300ms debounce
|
||||
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
|
||||
- Dropdown de resultados con cache
|
||||
- Sidebar contextual con co-uso
|
||||
- Historial de versiones de notas
|
||||
|
||||
### MVP-5 (Completo)
|
||||
|
||||
**Sprint 1 - Confianza total:**
|
||||
- Sistema de backup automático (IndexedDB)
|
||||
- Política de retención (max 10 backups, 30 días)
|
||||
- Restore con preview y validación
|
||||
- Backup previo automático pre-destructivo
|
||||
- Guard de cambios sin guardar
|
||||
|
||||
**Sprint 2 - Flujo diario desde teclado:**
|
||||
- Command Palette global (Ctrl+K / Cmd+K)
|
||||
- Modelo de acciones para palette
|
||||
- Shortcuts globales (g h, g n, n, /, ?)
|
||||
- Navegación de listas por teclado (↑↓ Enter E F P)
|
||||
|
||||
**Sprint 3 - Contexto y continuidad:**
|
||||
- Sidebar contextual persistente mejorada
|
||||
- Modo trabajo con toggle
|
||||
- Autosave de borradores locales
|
||||
- Historial de navegación contextual
|
||||
|
||||
**Sprint 4 - Captura ubicua:**
|
||||
- Bookmarklet para capturar desde cualquier web
|
||||
- Página de confirmación de captura
|
||||
- Endpoint seguro /api/capture con rate limiting
|
||||
|
||||
**P2 - Exportación, Importación y Settings:**
|
||||
- Exportación Markdown con frontmatter
|
||||
- Exportación HTML legible
|
||||
- Importador Markdown mejorado (frontmatter, tags, wiki links)
|
||||
- Importador Obsidian-compatible
|
||||
- Centro de respaldo en Settings
|
||||
- Panel de preferencias (backup on/off, retención, work mode)
|
||||
- Tests de command palette y captura
|
||||
- Validaciones y límites (50MB backup, 10K notas, etc)
|
||||
|
||||
## Algoritmo de Scoring
|
||||
|
||||
```typescript
|
||||
// Search scoring
|
||||
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||
|
||||
// Related notes scoring
|
||||
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||
|
||||
// Centrality score
|
||||
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||
```
|
||||
|
||||
## Atajos de Teclado
|
||||
|
||||
| Atajo | Acción |
|
||||
|-------|--------|
|
||||
| `Ctrl+K` / `Cmd+K` | Command Palette |
|
||||
| `g h` | Ir al Dashboard |
|
||||
| `g n` | Ir a Notas |
|
||||
| `n` | Nueva nota |
|
||||
| `/` | Enfocar búsqueda |
|
||||
| `?` | Mostrar ayuda |
|
||||
| `↑↓` | Navegar listas |
|
||||
| `Enter` | Abrir nota |
|
||||
| `E` | Editar nota (en lista) |
|
||||
| `F` | Favoritar nota (en lista) |
|
||||
| `P` | Fijar nota (en lista) |
|
||||
|
||||
## Tests
|
||||
|
||||
**226 tests** cubriendo:
|
||||
- API routes (CRUD, search, tags, versions)
|
||||
- Search y scoring
|
||||
- Query parser
|
||||
- Notas relacionadas
|
||||
- Backlinks
|
||||
- Type inference
|
||||
- Link suggestions
|
||||
- Usage tracking
|
||||
- Dashboard
|
||||
- Version history
|
||||
- Command items
|
||||
- External capture
|
||||
- Navigation history
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
npm run dev # Desarrollo
|
||||
npm run build # Build producción
|
||||
npm test # Tests (usar npx jest)
|
||||
npx prisma db push # Sync schema
|
||||
npx prisma studio # UI de BD
|
||||
```
|
||||
|
||||
## Configuración de Feature Flags
|
||||
|
||||
```bash
|
||||
FLAG_CENTRALITY=true
|
||||
FLAG_PASSIVE_RECOMMENDATIONS=true
|
||||
FLAG_TYPE_SUGGESTIONS=true
|
||||
FLAG_LINK_SUGGESTIONS=true
|
||||
```
|
||||
|
||||
## Estados de Implementación
|
||||
|
||||
| Feature | Estado |
|
||||
|---------|--------|
|
||||
| CRUD notas | ✅ |
|
||||
| Tags | ✅ |
|
||||
| Búsqueda avanzada | ✅ |
|
||||
| Quick Add | ✅ |
|
||||
| Backlinks [[wiki]] | ✅ |
|
||||
| Usage tracking | ✅ |
|
||||
| Dashboard inteligente | ✅ |
|
||||
| Versiones de notas | ✅ |
|
||||
| Command Palette | ✅ |
|
||||
| Shortcuts globales | ✅ |
|
||||
| Modo trabajo | ✅ |
|
||||
| Backup/Restore | ✅ |
|
||||
| Bookmarklet capture | ✅ |
|
||||
| Export Markdown/HTML | ✅ |
|
||||
| Import Markdown | ✅ |
|
||||
| Settings completo | ✅ |
|
||||
| Feature flags UI | ✅ |
|
||||
| Tests | ✅ (226) |
|
||||
|
||||
## Commits Recientes
|
||||
|
||||
```
|
||||
e66a678 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
|
||||
8d56f34 feat: MVP-5 Sprint 4 - External Capture via Bookmarklet
|
||||
a40ab18 feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts
|
||||
cde0a14 feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
|
||||
8c80a12 feat: MVP-5 Sprint 1 - Backup/Restore system
|
||||
```
|
||||
@@ -0,0 +1,431 @@
|
||||
# Recall - Resumen Técnico Detallado
|
||||
|
||||
## Información General
|
||||
|
||||
**Nombre:** Recall
|
||||
**Descripción:** Sistema de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
|
||||
**Fecha de creación:** 2026-03-22
|
||||
**Estado:** MVP-5 Completo
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología | Versión |
|
||||
|------------|-------------|---------|
|
||||
| Framework | Next.js + App Router + Turbopack | 16.2.1 |
|
||||
| Base de datos | SQLite via Prisma ORM | 5.22.0 |
|
||||
| Lenguaje | TypeScript | 5.x |
|
||||
| UI | TailwindCSS + shadcn/ui | 4.x / latest |
|
||||
| Testing | Jest | 30.3.0 |
|
||||
| Notificaciones | Sonner (toasts) | latest |
|
||||
| IDE | VSCode / Cursor |
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── notes/
|
||||
│ │ │ ├── route.ts # GET, POST /api/notes
|
||||
│ │ │ ├── [id]/route.ts # GET, PUT, DELETE /api/notes/:id
|
||||
│ │ │ ├── quick/route.ts # POST /api/notes/quick
|
||||
│ │ │ ├── links/route.ts # GET /api/notes/links
|
||||
│ │ │ ├── suggest/route.ts # GET /api/notes/suggest
|
||||
│ │ │ ├── backlinks/route.ts # GET /api/notes/:id/backlinks
|
||||
│ │ │ └── versions/
|
||||
│ │ │ ├── route.ts # GET, POST /api/notes/:id/versions
|
||||
│ │ │ └── [versionId]/route.ts # GET, PUT
|
||||
│ │ ├── tags/
|
||||
│ │ │ ├── route.ts # GET /api/tags
|
||||
│ │ │ └── suggest/route.ts # GET /api/tags/suggest
|
||||
│ │ ├── search/route.ts # GET /api/search
|
||||
│ │ ├── usage/
|
||||
│ │ │ ├── route.ts # GET /api/usage
|
||||
│ │ │ └── co-usage/route.ts # GET /api/usage/co-usage
|
||||
│ │ ├── metrics/route.ts # GET /api/metrics
|
||||
│ │ ├── centrality/route.ts # GET /api/centrality
|
||||
│ │ ├── export-import/route.ts # GET, POST
|
||||
│ │ ├── import-markdown/route.ts # POST
|
||||
│ │ └── capture/route.ts # POST /api/capture
|
||||
│ ├── notes/[id]/page.tsx # Detalle de nota
|
||||
│ ├── edit/[id]/page.tsx # Edición de nota
|
||||
│ ├── new/page.tsx # Nueva nota
|
||||
│ ├── capture/page.tsx # Confirmación de captura
|
||||
│ ├── settings/page.tsx # Configuración
|
||||
│ └── page.tsx # Dashboard (raíz)
|
||||
├── components/
|
||||
│ ├── ui/ # Componentes shadcn/ui
|
||||
│ ├── dashboard.tsx # Dashboard inteligente
|
||||
│ ├── note-form.tsx # Formulario de notas
|
||||
│ ├── note-card.tsx # Tarjeta de nota
|
||||
│ ├── note-list.tsx # Lista de notas (grid)
|
||||
│ ├── keyboard-navigable-note-list.tsx # Lista con navegación teclado
|
||||
│ ├── note-connections.tsx # Panel de conexiones
|
||||
│ ├── related-notes.tsx # Notas relacionadas
|
||||
│ ├── version-history.tsx # Historial de versiones
|
||||
│ ├── search-bar.tsx # Búsqueda en tiempo real
|
||||
│ ├── command-palette.tsx # Command palette (Ctrl+K)
|
||||
│ ├── keyboard-shortcuts-dialog.tsx # Diálogo de atajos
|
||||
│ ├── shortcuts-provider.tsx # Provider de atajos
|
||||
│ ├── keyboard-hint.tsx # Hint de atajos
|
||||
│ ├── work-mode-toggle.tsx # Toggle modo trabajo
|
||||
│ ├── draft-recovery-banner.tsx # Banner de recuperación
|
||||
│ ├── backup-restore-dialog.tsx # Restore con preview
|
||||
│ ├── backup-list.tsx # Lista de backups
|
||||
│ ├── bookmarklet-instructions.tsx # Instrucciones bookmarklet
|
||||
│ ├── recent-context-list.tsx # Historial de navegación
|
||||
│ ├── track-navigation-history.tsx # Tracking de historial
|
||||
│ ├── preferences-panel.tsx # Panel de preferencias
|
||||
│ ├── markdown-content.tsx # Contenido con highlight
|
||||
│ ├── quick-add.tsx # Captura rápida
|
||||
│ └── track-note-view.tsx # Tracking de vistas
|
||||
├── hooks/
|
||||
│ ├── use-global-shortcuts.ts # Atajos globales
|
||||
│ ├── use-note-list-keyboard.ts # Navegación teclado
|
||||
│ └── use-unsaved-changes.ts # Guard de cambios sin guardar
|
||||
└── lib/
|
||||
├── prisma.ts # Cliente Prisma
|
||||
├── search.ts # Búsqueda con scoring
|
||||
├── query-parser.ts # Parser de queries
|
||||
├── related.ts # Notas relacionadas
|
||||
├── backlinks.ts # Sistema de enlaces [[wiki]]
|
||||
├── tags.ts # Normalización y sugerencias
|
||||
├── usage.ts # Tracking de uso
|
||||
├── metrics.ts # Métricas de dashboard
|
||||
├── centrality.ts # Cálculo de centralidad
|
||||
├── type-inference.ts # Detección automática de tipo
|
||||
├── link-suggestions.ts # Sugerencias de enlaces
|
||||
├── features.ts # Feature flags
|
||||
├── validators.ts # Zod schemas
|
||||
├── errors.ts # Manejo de errores
|
||||
├── versions.ts # Historial de versiones
|
||||
├── backup.ts # Snapshot de backup
|
||||
├── backup-storage.ts # IndexedDB storage
|
||||
├── backup-policy.ts # Política de retención
|
||||
├── backup-validator.ts # Validación de backups
|
||||
├── restore.ts # Restore de backups
|
||||
├── drafts.ts # Borradores locales
|
||||
├── work-mode.ts # Modo trabajo
|
||||
├── navigation-history.ts # Historial de navegación
|
||||
├── export-markdown.ts # Exportación Markdown
|
||||
├── export-html.ts # Exportación HTML
|
||||
├── import-markdown.ts # Importador Markdown
|
||||
├── external-capture.ts # Captura externa
|
||||
├── templates.ts # Templates por tipo
|
||||
├── command-items.ts # Items de command palette
|
||||
└── command-groups.ts # Grupos de comandos
|
||||
```
|
||||
|
||||
## Modelos de Datos (Prisma)
|
||||
|
||||
### Note
|
||||
```prisma
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String
|
||||
type String @default("note")
|
||||
isFavorite Boolean @default(false)
|
||||
isPinned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
creationSource String @default("form")
|
||||
}
|
||||
```
|
||||
|
||||
### NoteUsage
|
||||
```prisma
|
||||
model NoteUsage {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
eventType String
|
||||
query String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### NoteCoUsage
|
||||
```prisma
|
||||
model NoteCoUsage {
|
||||
id String @id @default(cuid())
|
||||
fromNoteId String
|
||||
toNoteId String
|
||||
weight Int @default(1)
|
||||
}
|
||||
```
|
||||
|
||||
### NoteVersion
|
||||
```prisma
|
||||
model NoteVersion {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
title String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### Backlink
|
||||
```prisma
|
||||
model Backlink {
|
||||
sourceNoteId String
|
||||
targetNoteId String
|
||||
}
|
||||
```
|
||||
|
||||
## APIs REST
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| `/api/notes` | GET, POST | Listar/crear notas |
|
||||
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD nota |
|
||||
| `/api/notes/quick` | POST | Creación rápida |
|
||||
| `/api/notes/links` | GET | Sugerencias de enlaces |
|
||||
| `/api/notes/suggest` | GET | Sugerencias automática |
|
||||
| `/api/notes/[id]/versions` | GET, POST | Versiones |
|
||||
| `/api/notes/[id]/backlinks` | GET | Backlinks |
|
||||
| `/api/search` | GET | Búsqueda avanzada |
|
||||
| `/api/tags` | GET | Tags |
|
||||
| `/api/tags/suggest` | GET | Sugerencias de tags |
|
||||
| `/api/usage` | GET | Uso de notas |
|
||||
| `/api/usage/co-usage` | GET | Co-uso entre notas |
|
||||
| `/api/metrics` | GET | Métricas dashboard |
|
||||
| `/api/centrality` | GET | Notas centrales |
|
||||
| `/api/export-import` | GET, POST | Export/Import |
|
||||
| `/api/import-markdown` | POST | Importar Markdown |
|
||||
| `/api/capture` | POST | Captura externa |
|
||||
|
||||
## Features Implementadas
|
||||
|
||||
### MVP-1: Fundamentos
|
||||
- [x] CRUD completo de notas
|
||||
- [x] Sistema de tags
|
||||
- [x] Búsqueda básica
|
||||
|
||||
### MVP-2: Captura Inteligente
|
||||
- [x] Búsqueda avanzada con scoring
|
||||
- [x] Quick Add con prefijos (cmd:, snip:, etc.)
|
||||
- [x] Backlinks con sintaxis [[wiki]]
|
||||
- [x] Formularios guiados por tipo
|
||||
- [x] Templates inteligentes por tipo
|
||||
- [x] Vista command con copiar
|
||||
- [x] Vista snippet con syntax highlight
|
||||
- [x] Checklist interactivo en procedure
|
||||
|
||||
### MVP-3: Uso y Contexto
|
||||
- [x] Usage tracking (vistas, clics, copias)
|
||||
- [x] Dashboard inteligente
|
||||
- [x] Scoring boost basado en uso
|
||||
- [x] Sugerencias automáticas de tags
|
||||
- [x] Panel "Conectado con"
|
||||
- [x] Quick Add multilínea
|
||||
- [x] Pegado inteligente con detección de tipo
|
||||
- [x] Sugerencia automática de tipo
|
||||
- [x] Sugerencia de enlaces internos
|
||||
- [x] Registro de co-uso entre notas
|
||||
- [x] Métricas internas
|
||||
- [x] Cálculo de notas centrales
|
||||
- [x] Feature flags configurables
|
||||
|
||||
### MVP-4: Query Parser y Navegación
|
||||
- [x] Query parser (`type:`, `tag:`, `is:favorite`, `is:pinned`)
|
||||
- [x] Búsqueda en tiempo real (300ms debounce)
|
||||
- [x] Navegación por teclado (↑↓ Enter ESC)
|
||||
- [x] Dropdown de resultados con cache
|
||||
- [x] Sidebar contextual con co-uso
|
||||
- [x] Historial de versiones
|
||||
- [x] Preload de notas en hover
|
||||
|
||||
### MVP-5: Flujo Diario y Portabilidad
|
||||
|
||||
**Sprint 1 - Confianza:**
|
||||
- [x] Sistema de backup automático (IndexedDB)
|
||||
- [x] Política de retención (max 10 backups, 30 días)
|
||||
- [x] Restore con preview y validación
|
||||
- [x] Backup previo automático
|
||||
- [x] Guard de cambios sin guardar
|
||||
|
||||
**Sprint 2 - Shortcuts Globales:**
|
||||
- [x] Command Palette (Ctrl+K / Cmd+K)
|
||||
- [x] Shortcuts: g h, g n, n, /, ?
|
||||
- [x] Navegación de listas por teclado
|
||||
|
||||
**Sprint 3 - Contexto y Continuidad:**
|
||||
- [x] Sidebar contextual persistente
|
||||
- [x] Modo trabajo con toggle
|
||||
- [x] Autosave de borradores locales
|
||||
- [x] Historial de navegación contextual
|
||||
|
||||
**Sprint 4 - Captura Ubicua:**
|
||||
- [x] Bookmarklet para capturar desde web
|
||||
- [x] Página de confirmación
|
||||
- [x] Endpoint seguro con rate limiting
|
||||
|
||||
**P2 - Exportación e Importación:**
|
||||
- [x] Exportación Markdown con frontmatter
|
||||
- [x] Exportación HTML legible
|
||||
- [x] Importador Markdown mejorado
|
||||
- [x] Centro de respaldo en Settings
|
||||
- [x] Panel de preferencias
|
||||
- [x] Validaciones y límites
|
||||
|
||||
## Algoritmos de Scoring
|
||||
|
||||
### Búsqueda
|
||||
```
|
||||
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
|
||||
```
|
||||
|
||||
### Notas Relacionadas
|
||||
```
|
||||
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
|
||||
```
|
||||
|
||||
### Centralidad
|
||||
```
|
||||
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
|
||||
```
|
||||
|
||||
## Atajos de Teclado
|
||||
|
||||
| Atajo | Acción |
|
||||
|-------|--------|
|
||||
| `Ctrl+K` / `Cmd+K` | Command Palette |
|
||||
| `g h` | Ir al Dashboard |
|
||||
| `g n` | Ir a Notas |
|
||||
| `n` | Nueva nota |
|
||||
| `/` | Enfocar búsqueda |
|
||||
| `?` | Mostrar ayuda |
|
||||
| `↑↓` | Navegar listas |
|
||||
| `Enter` | Abrir nota |
|
||||
| `E` | Editar nota |
|
||||
| `F` | Favoritar nota |
|
||||
| `P` | Fijar nota |
|
||||
|
||||
## Tipos de Nota
|
||||
|
||||
| Tipo | Descripción | Color |
|
||||
|------|-------------|-------|
|
||||
| `note` | Nota general | Slate |
|
||||
| `command` | Comando o snippet ejecutable | Green |
|
||||
| `snippet` | Fragmento de código | Blue |
|
||||
| `decision` | Decisión tomada | Purple |
|
||||
| `recipe` | Receta o procedimiento | Orange |
|
||||
| `procedure` | Procedimiento con checkboxes | Yellow |
|
||||
| `inventory` | Inventario o lista | Gray |
|
||||
|
||||
## Comandos npm
|
||||
|
||||
```bash
|
||||
npm run dev # Desarrollo (Turbopack)
|
||||
npm run build # Build producción
|
||||
npm run start # Iniciar producción
|
||||
npm test # Tests (Jest)
|
||||
npx jest --watch # Tests en watch mode
|
||||
npx prisma db push # Sync schema a BD
|
||||
npx prisma studio # UI de base de datos
|
||||
npx prisma generate # Generar tipos
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
**226 tests** organizados en:
|
||||
- `__tests__/api.*.test.ts` - Tests de integración de APIs
|
||||
- `__tests__/search.test.ts` - Búsqueda y scoring
|
||||
- `__tests__/query-parser.test.ts` - Parser de queries
|
||||
- `__tests__/related.test.ts` - Notas relacionadas
|
||||
- `__tests__/backlinks.test.ts` - Sistema de enlaces
|
||||
- `__tests__/tags.test.ts` - Tags y sugerencias
|
||||
- `__tests__/usage.test.ts` - Tracking de uso
|
||||
- `__tests__/versions.test.ts` - Historial de versiones
|
||||
- `__tests__/dashboard.test.ts` - Dashboard
|
||||
- `__tests__/command-items.test.ts` - Command palette
|
||||
- `__tests__/external-capture.test.ts` - Captura externa
|
||||
- `__tests__/navigation-history.test.ts` - Historial
|
||||
- `__tests__/link-suggestions.test.ts` - Sugerencias de enlaces
|
||||
- `__tests__/type-inference.test.ts` - Inferencia de tipo
|
||||
- `__tests__/quick-add.test.ts` - Quick Add
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Configurables via `localStorage` o `.env`:
|
||||
|
||||
```bash
|
||||
FLAG_CENTRALITY=true # Habilitar centralidad
|
||||
FLAG_PASSIVE_RECOMMENDATIONS=true # Recomendaciones pasivas
|
||||
FLAG_TYPE_SUGGESTIONS=true # Sugerencias de tipo
|
||||
FLAG_LINK_SUGGESTIONS=true # Sugerencias de enlaces
|
||||
```
|
||||
|
||||
## Límites del Sistema
|
||||
|
||||
| Recurso | Límite |
|
||||
|---------|--------|
|
||||
| Tamaño de backup | 50MB |
|
||||
| Cantidad de notas | 10,000 |
|
||||
| Longitud de título (captura) | 500 chars |
|
||||
| Longitud de URL (captura) | 2000 chars |
|
||||
| Longitud de selección (captura) | 10,000 chars |
|
||||
| Backups retenidos | 10 máximo |
|
||||
| Días de retención | 30 días |
|
||||
|
||||
## Commits del Proyecto
|
||||
|
||||
```
|
||||
33a4705 feat: MVP-4 P2 - Preload notes on hover
|
||||
e66a678 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
|
||||
8d56f34 feat: MVP-5 Sprint 4 - External Capture via Bookmarklet
|
||||
a40ab18 feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts
|
||||
cde0a14 feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
|
||||
8c80a12 feat: MVP-5 Sprint 1 - Backup/Restore system
|
||||
6694bce mvp
|
||||
af0910f feat: initial commit
|
||||
f2e4706 Initial commit
|
||||
```
|
||||
|
||||
## Dependencias Principales
|
||||
|
||||
```json
|
||||
{
|
||||
"next": "16.2.1",
|
||||
"@prisma/client": "5.22.0",
|
||||
"prisma": "5.22.0",
|
||||
"sonner": "latest",
|
||||
"zod": "latest",
|
||||
"tailwindcss": "4.x",
|
||||
"@radix-ui/react-*": "latest"
|
||||
}
|
||||
```
|
||||
|
||||
## Patrones de Diseño
|
||||
|
||||
- **Server Components** para páginas estáticas
|
||||
- **Client Components** para interactividad
|
||||
- **Hooks personalizados** para lógica reutilizable
|
||||
- **Feature Flags** para features opcionales
|
||||
- **IndexedDB** para persistencia local de backups
|
||||
- **localStorage** para preferencias y borradores
|
||||
|
||||
## Estado de Implementación
|
||||
|
||||
| Feature | Estado |
|
||||
|---------|--------|
|
||||
| CRUD notas | ✅ |
|
||||
| Tags | ✅ |
|
||||
| Búsqueda avanzada | ✅ |
|
||||
| Quick Add | ✅ |
|
||||
| Backlinks [[wiki]] | ✅ |
|
||||
| Usage tracking | ✅ |
|
||||
| Dashboard inteligente | ✅ |
|
||||
| Versiones de notas | ✅ |
|
||||
| Command Palette | ✅ |
|
||||
| Shortcuts globales | ✅ |
|
||||
| Modo trabajo | ✅ |
|
||||
| Backup/Restore | ✅ |
|
||||
| Bookmarklet capture | ✅ |
|
||||
| Export Markdown/HTML | ✅ |
|
||||
| Import Markdown | ✅ |
|
||||
| Settings completo | ✅ |
|
||||
| Feature flags UI | ✅ |
|
||||
| Tests | ✅ (226) |
|
||||
| Preload on hover | ✅ |
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createSuccessResponse, createErrorResponse } from '@/lib/errors'
|
||||
|
||||
const captureSchema = z.object({
|
||||
title: z.string().min(1).max(500),
|
||||
url: z.string().url().optional(),
|
||||
selection: z.string().optional(),
|
||||
source: z.enum(['bookmarklet', 'extension', 'api']).default('api'),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = captureSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error)
|
||||
}
|
||||
|
||||
return createSuccessResponse(result.data)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,20 @@ 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() {
|
||||
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 } } },
|
||||
})
|
||||
@@ -17,6 +28,38 @@ export async function GET() {
|
||||
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)
|
||||
@@ -62,21 +105,22 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const createdAt = parseDate((item as { createdAt?: string }).createdAt)
|
||||
const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt)
|
||||
const itemWithId = item as { id?: string }
|
||||
|
||||
if (item.id) {
|
||||
const existing = await tx.note.findUnique({ where: { id: item.id } })
|
||||
if (itemWithId.id) {
|
||||
const existing = await tx.note.findUnique({ where: { id: itemWithId.id } })
|
||||
if (existing) {
|
||||
await tx.note.update({
|
||||
where: { id: item.id },
|
||||
where: { id: itemWithId.id },
|
||||
data: { ...noteData, createdAt, updatedAt },
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: item.id } })
|
||||
await tx.noteTag.deleteMany({ where: { noteId: itemWithId.id } })
|
||||
processed++
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
...noteData,
|
||||
id: item.id,
|
||||
id: itemWithId.id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
@@ -107,8 +151,8 @@ export async function POST(req: NextRequest) {
|
||||
processed++
|
||||
}
|
||||
|
||||
const noteId = item.id
|
||||
? (await tx.note.findUnique({ where: { id: item.id } }))?.id
|
||||
const noteId = itemWithId.id
|
||||
? (await tx.note.findUnique({ where: { id: itemWithId.id } }))?.id
|
||||
: (await tx.note.findFirst({ where: { title: item.title } }))?.id
|
||||
|
||||
if (noteId && tags.length > 0) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
import { parseMarkdownContent, convertWikiLinksToMarkdown, extractInlineTags } from '@/lib/import-markdown'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
if (!Array.isArray(body)) {
|
||||
throw new ValidationError([{ path: 'body', message: 'Invalid format: expected array of markdown strings or objects' }])
|
||||
}
|
||||
|
||||
const parseDate = (dateStr: string | undefined): Date => {
|
||||
if (!dateStr) return new Date()
|
||||
const parsed = new Date(dateStr)
|
||||
return isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
}
|
||||
|
||||
let processed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const item = body[i]
|
||||
|
||||
// Handle both string markdown and object with markdown + filename
|
||||
let markdown: string
|
||||
let filename: string | undefined
|
||||
|
||||
if (typeof item === 'string') {
|
||||
markdown = item
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
markdown = item.markdown || item.content || item.body || ''
|
||||
filename = item.filename
|
||||
} else {
|
||||
errors.push(`Item ${i}: Invalid format`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!markdown || typeof markdown !== 'string') {
|
||||
errors.push(`Item ${i}: Empty or invalid markdown`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseMarkdownContent(markdown, filename)
|
||||
|
||||
// Convert wiki links
|
||||
let content = convertWikiLinksToMarkdown(parsed.content)
|
||||
|
||||
// Extract inline tags if none in frontmatter
|
||||
const tags = parsed.frontmatter.tags || []
|
||||
const inlineTags = extractInlineTags(content)
|
||||
const allTags = [...new Set([...tags, ...inlineTags])]
|
||||
|
||||
const title = parsed.title || 'Untitled'
|
||||
const type = parsed.frontmatter.type || 'note'
|
||||
const createdAt = parseDate(parsed.frontmatter.createdAt)
|
||||
const updatedAt = parseDate(parsed.frontmatter.updatedAt)
|
||||
|
||||
// Check for existing note by title
|
||||
const existingByTitle = await tx.note.findFirst({
|
||||
where: { title },
|
||||
})
|
||||
|
||||
let noteId: string
|
||||
|
||||
if (existingByTitle) {
|
||||
await tx.note.update({
|
||||
where: { id: existingByTitle.id },
|
||||
data: {
|
||||
content,
|
||||
type,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
isFavorite: parsed.frontmatter.favorite ?? existingByTitle.isFavorite,
|
||||
isPinned: parsed.frontmatter.pinned ?? existingByTitle.isPinned,
|
||||
},
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
|
||||
noteId = existingByTitle.id
|
||||
} else {
|
||||
const note = await tx.note.create({
|
||||
data: {
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
isFavorite: parsed.frontmatter.favorite ?? false,
|
||||
isPinned: parsed.frontmatter.pinned ?? false,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
noteId = note.id
|
||||
}
|
||||
|
||||
// Add tags
|
||||
for (const tagName of allTags) {
|
||||
if (!tagName) continue
|
||||
const tag = await tx.tag.upsert({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName },
|
||||
update: {},
|
||||
})
|
||||
await tx.noteTag.create({
|
||||
data: { noteId, tagId: tag.id },
|
||||
})
|
||||
}
|
||||
|
||||
// Sync backlinks
|
||||
const note = await tx.note.findUnique({ where: { id: noteId } })
|
||||
if (note) {
|
||||
await syncBacklinks(note.id, note.content)
|
||||
}
|
||||
|
||||
processed++
|
||||
} catch (err) {
|
||||
errors.push(`Item ${i}: ${err instanceof Error ? err.message : 'Parse error'}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length > 0 && processed === 0) {
|
||||
throw new ValidationError(errors)
|
||||
}
|
||||
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
count: processed,
|
||||
warnings: errors.length > 0 ? errors : undefined,
|
||||
}, 201)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 }> }) {
|
||||
@@ -32,13 +33,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
throw new ValidationError(result.error.issues)
|
||||
}
|
||||
|
||||
const { tags, ...noteData } = result.data
|
||||
const { tags, id: _, ...noteData } = result.data
|
||||
|
||||
const existingNote = await prisma.note.findUnique({ where: { id } })
|
||||
if (!existingNote) {
|
||||
throw new NotFoundError('Note')
|
||||
}
|
||||
|
||||
await createVersion(id)
|
||||
|
||||
await prisma.noteTag.deleteMany({ where: { noteId: id } })
|
||||
|
||||
const note = await prisma.note.update({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getVersion, restoreVersion } from '@/lib/versions'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) {
|
||||
try {
|
||||
const { versionId } = await params
|
||||
const version = await getVersion(versionId)
|
||||
return createSuccessResponse(version)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) {
|
||||
try {
|
||||
const { id, versionId } = await params
|
||||
const note = await restoreVersion(id, versionId)
|
||||
return createSuccessResponse(note)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { createVersion, getVersions } from '@/lib/versions'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const versions = await getVersions(id)
|
||||
return createSuccessResponse(versions)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const version = await createVersion(id)
|
||||
return createSuccessResponse(version, 201)
|
||||
} catch (error) {
|
||||
return createErrorResponse(error)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { noteSchema } from '@/lib/validators'
|
||||
import { normalizeTag } from '@/lib/tags'
|
||||
import { noteQuery } from '@/lib/search'
|
||||
import { searchNotes } from '@/lib/search'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
|
||||
const tag = searchParams.get('tag') || undefined
|
||||
|
||||
if (q || type || tag) {
|
||||
const notes = await noteQuery(q, { type, tag })
|
||||
const notes = await searchNotes(q, { type, tag })
|
||||
return createSuccessResponse(notes)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 type = searchParams.get('type') || undefined
|
||||
const tag = searchParams.get('tag') || undefined
|
||||
|
||||
const notes = await searchNotes(q, { type, tag })
|
||||
const queryAST = parseQuery(q)
|
||||
const notes = await searchNotes(queryAST.text, queryAST.filters)
|
||||
|
||||
return createSuccessResponse(notes)
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Loader2, Bookmark } from 'lucide-react'
|
||||
|
||||
function CaptureForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [selection, setSelection] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const titleParam = searchParams.get('title') || ''
|
||||
const urlParam = searchParams.get('url') || ''
|
||||
const selectionParam = searchParams.get('selection') || ''
|
||||
|
||||
setTitle(titleParam)
|
||||
setUrl(urlParam)
|
||||
setSelection(selectionParam)
|
||||
|
||||
// Pre-fill content with captured web content
|
||||
if (selectionParam) {
|
||||
setContent(`## Web Selection\n\n${selectionParam}\n\n## Source\n\n${urlParam}`)
|
||||
} else {
|
||||
setContent(`## Source\n\n${urlParam}`)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!content.trim() || isLoading) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Build the full content with optional tags
|
||||
const fullContent = tags.trim()
|
||||
? `${content}\n\n## Tags\n\n${tags.trim().split(',').map(t => `#${t.trim()}`).join(' ')}`
|
||||
: content
|
||||
|
||||
const response = await fetch('/api/notes/quick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `web: ${title || url}\n\n${fullContent}`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Error creating note')
|
||||
}
|
||||
|
||||
toast.success('Nota creada desde web', {
|
||||
description: title || url,
|
||||
})
|
||||
router.push('/notes')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast.error('Error', {
|
||||
description: error instanceof Error ? error.message : 'No se pudo crear la nota',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const bookmarkletCode = `javascript:var title = document.title; var url = location.href; var selection = window.getSelection().toString(); var params = new URLSearchParams({title, url, selection}); window.open('/capture?' + params.toString(), '_blank');`
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('text/plain', bookmarkletCode)
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Bookmark className="h-6 w-6" />
|
||||
<h1 className="text-2xl font-bold">Capturar desde web</h1>
|
||||
</div>
|
||||
|
||||
<Card className="p-4 mb-6 bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground mb-2">Arrastra este botón a tu barra de marcadores:</p>
|
||||
<button
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium cursor-grab active:cursor-grabbing transition-all ${
|
||||
isDragging ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
>
|
||||
Capturar a Recall
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Título</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Título de la página"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">URL</label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Selección</label>
|
||||
<Textarea
|
||||
value={selection}
|
||||
onChange={(e) => setSelection(e.target.value)}
|
||||
placeholder="Texto seleccionado de la página..."
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Contenido</label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Contenido adicional..."
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tags (separados por coma)</label>
|
||||
<Input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="web, referencia, artículo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={!content.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
'Crear nota'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push('/notes')}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CapturePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="container mx-auto py-8 px-4 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<CaptureForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { 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',
|
||||
@@ -19,6 +21,8 @@ export default function RootLayout({
|
||||
<Header />
|
||||
{children}
|
||||
<Toaster />
|
||||
<CommandPalette />
|
||||
<ShortcutsProvider />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -2,10 +2,13 @@ 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'
|
||||
@@ -36,11 +39,13 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
||||
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">
|
||||
@@ -71,6 +76,7 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
||||
<Edit className="h-4 w-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</Link>
|
||||
<VersionHistory noteId={note.id} />
|
||||
<DeleteNoteButton noteId={note.id} noteTitle={note.title} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,6 +106,7 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
||||
backlinks={backlinks}
|
||||
outgoingLinks={outgoingLinks}
|
||||
relatedNotes={related}
|
||||
coUsedNotes={coUsedNotes}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NoteList } from '@/components/note-list'
|
||||
import { KeyboardNavigableNoteList } from '@/components/keyboard-navigable-note-list'
|
||||
import { KeyboardHint } from '@/components/keyboard-hint'
|
||||
import { SearchBar } from '@/components/search-bar'
|
||||
import { TagFilter } from '@/components/tag-filter'
|
||||
import { NoteType } from '@/types/note'
|
||||
@@ -86,7 +87,8 @@ export default async function NotesPage({ searchParams }: { searchParams: Promis
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NoteList notes={notesWithTags} />
|
||||
<KeyboardNavigableNoteList notes={notesWithTags} />
|
||||
<KeyboardHint />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Dashboard } from '@/components/dashboard'
|
||||
import { getDashboardData } from '@/lib/dashboard'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function HomePage() {
|
||||
const data = await getDashboardData(6)
|
||||
|
||||
|
||||
+109
-21
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Download, Upload } from 'lucide-react'
|
||||
import { Download, Upload, History, FileText, Code, FolderOpen } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { 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')
|
||||
@@ -27,31 +29,56 @@ function parseMarkdownToNote(content: string, filename: string) {
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exporting, setExporting] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleExport = async (format: 'json' | 'markdown' | 'html') => {
|
||||
setExporting(format)
|
||||
try {
|
||||
const response = await fetch('/api/export-import')
|
||||
const response = await fetch(`/api/export-import?format=${format}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Error al exportar')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
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 = `recall-backup-${date}.json`
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success('Notas exportadas correctamente')
|
||||
toast.success(`Notas exportadas en formato ${format.toUpperCase()}`)
|
||||
} catch {
|
||||
toast.error('Error al exportar las notas')
|
||||
} finally {
|
||||
setExporting(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,15 +95,17 @@ export default function SettingsPage() {
|
||||
const isMarkdown = file.name.endsWith('.md')
|
||||
|
||||
let payload: object[]
|
||||
let endpoint = '/api/export-import'
|
||||
|
||||
if (isMarkdown) {
|
||||
const note = parseMarkdownToNote(text, file.name)
|
||||
payload = [note]
|
||||
payload = [{ markdown: text, filename: file.name }]
|
||||
endpoint = '/api/import-markdown'
|
||||
} else {
|
||||
payload = JSON.parse(text)
|
||||
}
|
||||
|
||||
const response = await fetch('/api/export-import', {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -88,7 +117,11 @@ export default function SettingsPage() {
|
||||
throw new Error(result.error || 'Error al importar')
|
||||
}
|
||||
|
||||
toast.success(`${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`)
|
||||
const msg = result.warnings
|
||||
? `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente (con advertencias)`
|
||||
: `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`
|
||||
|
||||
toast.success(msg)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
@@ -103,27 +136,82 @@ export default function SettingsPage() {
|
||||
<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-xl">
|
||||
<div className="grid gap-6 max-w-2xl">
|
||||
{/* Preferences Section */}
|
||||
<PreferencesPanel />
|
||||
|
||||
{/* Backups Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exportar notas</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Backups y Restauración
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Descarga todas tus notas en formato JSON. El archivo incluye títulos, contenido, tipos y tags.
|
||||
Los backups automáticos se guardan localmente. También puedes crear un backup manual antes de operaciones riesgosas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={handleExport} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Exportar
|
||||
</Button>
|
||||
<BackupList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Export Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Importar notas</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Exportar Notas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Importa notas desde archivos JSON o MD. En archivos MD, el primer heading (#) se usa como título.
|
||||
Descarga tus notas en diferentes formatos. Elige el que mejor se adapte a tus necesidades.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => handleExport('json')}
|
||||
disabled={exporting !== null}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{exporting === 'json' ? 'Exportando...' : 'JSON (Backup completo)'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleExport('markdown')}
|
||||
disabled={exporting !== null}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{exporting === 'markdown' ? 'Exportando...' : 'Markdown'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleExport('html')}
|
||||
disabled={exporting !== null}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
{exporting === 'html' ? 'Exportando...' : 'HTML'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Import Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Importar Notas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Importa notas desde archivos JSON o Markdown. Soporta frontmatter, tags, y enlaces wiki.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
@@ -137,7 +225,7 @@ export default function SettingsPage() {
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
className="gap-2 self-start"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{importing ? 'Importando...' : 'Importar'}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getBackups, deleteBackup } from '@/lib/backup-storage'
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2, RotateCcw, Calendar, FileText } from 'lucide-react'
|
||||
import { BackupRestoreDialog } from './backup-restore-dialog'
|
||||
|
||||
export function BackupList() {
|
||||
const [backups, setBackups] = useState<RecallBackup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBackups()
|
||||
}, [])
|
||||
|
||||
async function loadBackups() {
|
||||
try {
|
||||
const data = await getBackups()
|
||||
setBackups(data)
|
||||
} catch {
|
||||
toast.error('Error al cargar los backups')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id)
|
||||
try {
|
||||
await deleteBackup(id)
|
||||
setBackups((prev) => prev.filter((b) => b.id !== id))
|
||||
toast.success('Backup eliminado')
|
||||
} catch {
|
||||
toast.error('Error al eliminar el backup')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('es-ES', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function getSourceBadgeVariant(source: RecallBackup['source']) {
|
||||
switch (source) {
|
||||
case 'automatic':
|
||||
return 'secondary'
|
||||
case 'manual':
|
||||
return 'default'
|
||||
case 'pre-destructive':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceLabel(source: RecallBackup['source']) {
|
||||
switch (source) {
|
||||
case 'automatic':
|
||||
return 'Automático'
|
||||
case 'manual':
|
||||
return 'Manual'
|
||||
case 'pre-destructive':
|
||||
return 'Pre-destrucción'
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-sm text-muted-foreground">Cargando backups...</div>
|
||||
}
|
||||
|
||||
if (backups.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No hay backups disponibles. Los backups se crean automáticamente antes de operaciones
|
||||
destructivas.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{formatDate(backup.createdAt)}</span>
|
||||
<Badge variant={getSourceBadgeVariant(backup.source)}>{getSourceLabel(backup.source)}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
{backup.metadata.noteCount} nota{backup.metadata.noteCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>
|
||||
{backup.metadata.tagCount} tag{backup.metadata.tagCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<BackupRestoreDialog
|
||||
backup={backup}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" className="gap-1 cursor-pointer">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restaurar
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(backup.id)}
|
||||
disabled={deletingId === backup.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { validateBackup } from '@/lib/backup-validator'
|
||||
import { restoreBackup } from '@/lib/restore'
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
import { toast } from 'sonner'
|
||||
import { RotateCcw, FileText, Tag, Calendar, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface BackupRestoreDialogProps {
|
||||
backup: RecallBackup
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function BackupRestoreDialog({ backup, trigger }: BackupRestoreDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [mode, setMode] = useState<'merge' | 'replace'>('merge')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const validation = validateBackup(backup)
|
||||
const backupInfo = validation.info
|
||||
|
||||
function handleModeChange(newMode: 'merge' | 'replace') {
|
||||
setMode(newMode)
|
||||
setConfirming(false)
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await restoreBackup(backup, mode)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${result.restored} nota${result.restored !== 1 ? 's' : ''} restaurada${result.restored !== 1 ? 's' : ''} correctamente`)
|
||||
setOpen(false)
|
||||
setConfirming(false)
|
||||
setMode('merge')
|
||||
} else {
|
||||
toast.error(`Error al restaurar: ${result.errors.join(', ')}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al restaurar el backup')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{trigger && <div onClick={() => setOpen(true)}>{trigger}</div>}
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
Restaurar Backup
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Recupera notas desde un backup anterior
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Backup Info */}
|
||||
<div className="space-y-3 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{backupInfo?.createdAt ? new Date(backupInfo.createdAt).toLocaleString('es-ES') : 'Fecha desconocida'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{backupInfo?.noteCount ?? 0} nota{(backupInfo?.noteCount ?? 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{backupInfo?.tagCount ?? 0} tag{(backupInfo?.tagCount ?? 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Fuente: {backupInfo?.source ?? 'desconocida'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
{!confirming && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Modo de restauración</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('merge')}
|
||||
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||
mode === 'merge'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">Combinar</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Añade nuevas notas, actualiza existentes
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('replace')}
|
||||
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||
mode === 'replace'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">Reemplazar</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Sustituye todo el contenido actual
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation */}
|
||||
{confirming && (
|
||||
<div className="space-y-4">
|
||||
{mode === 'replace' && (
|
||||
<div className="flex items-start gap-3 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-destructive">Operación destructiva</p>
|
||||
<p className="text-muted-foreground">
|
||||
Se eliminará el contenido actual antes de restaurar. Se creará un backup de seguridad automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
¿Estás seguro de que quieres restaurar este backup? Esta acción{' '}
|
||||
{mode === 'merge' ? 'no eliminará' : 'eliminará'} notas existentes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{!confirming ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => setConfirming(true)}>
|
||||
Continuar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setConfirming(false)} disabled={loading}>
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'replace' ? 'destructive' : 'default'}
|
||||
onClick={handleRestore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Restaurando...' : 'Confirmar'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { generateBookmarklet } from '@/lib/external-capture'
|
||||
import { Bookmark, Copy, Check, Info } from 'lucide-react'
|
||||
|
||||
export function BookmarkletInstructions() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const bookmarkletCode = generateBookmarklet()
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(bookmarkletCode)
|
||||
setCopied(true)
|
||||
toast.success('Código copiado al portapapeles')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error('Error al copiar el código')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('text/plain', bookmarkletCode)
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{!isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
Capturar web
|
||||
</Button>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bookmark className="h-5 w-5" />
|
||||
Capturar desde web
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Guarda contenido de cualquier página web directamente en tus notas.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-sm font-medium mb-2">Instrucciones:</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Arrastra el botón de abajo a tu barra de marcadores</li>
|
||||
<li>Cuando quieras capturar algo, haz clic en el marcador</li>
|
||||
<li>Se abrirá una página para confirmar y guardar</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">Botón del marcador:</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-4 bg-muted/30 rounded-lg border-2 border-dashed border-muted">
|
||||
<button
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className={`px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium cursor-grab active:cursor-grabbing transition-all ${
|
||||
isDragging ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
title="Arrastra esto a tu barra de marcadores"
|
||||
>
|
||||
Capturar a Recall
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
No puedes arrastrar? Usa el botón copiar y crea un marcador manualmente.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 gap-2"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copiado
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copiar código
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
El marcador capturará el título de la página, la URL y cualquier texto que hayas seleccionado antes de hacer clic.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { commands, CommandItem } from '@/lib/command-items'
|
||||
import { Search, FileText, Settings, Home, Plus } from 'lucide-react'
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const router = useRouter()
|
||||
|
||||
// Listen for Ctrl+K / Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Filter commands by query
|
||||
const filtered = commands.filter(cmd =>
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
cmd.keywords?.some(k => k.includes(query.toLowerCase()))
|
||||
)
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'ArrowDown') setSelectedIndex(i => Math.min(i + 1, filtered.length - 1))
|
||||
if (e.key === 'ArrowUp') setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
if (e.key === 'Enter' && filtered[selectedIndex]) {
|
||||
executeCommand(filtered[selectedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command
|
||||
const executeCommand = (cmd: CommandItem) => {
|
||||
router.push(cmd.id === 'action-new' ? '/new' : `/${cmd.id.split('-')[1]}`)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar comandos..."
|
||||
className="border-0 focus-visible:ring-0"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{filtered.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => executeCommand(cmd)}
|
||||
className={`w-full px-3 py-2 text-left flex items-center gap-2 ${
|
||||
i === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{cmd.label}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{cmd.group}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
interface DraftRecoveryBannerProps {
|
||||
onRestore: () => void
|
||||
onDiscard: () => void
|
||||
}
|
||||
|
||||
export function DraftRecoveryBanner({ onRestore, onDiscard }: DraftRecoveryBannerProps) {
|
||||
return (
|
||||
<div className="bg-yellow-50 border-yellow-200 p-3 rounded-lg flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600" />
|
||||
<p className="text-sm text-yellow-800 flex-1">Se encontró un borrador guardado</p>
|
||||
<Button size="sm" variant="outline" onClick={onDiscard}>Descartar</Button>
|
||||
<Button size="sm" onClick={onRestore}>Recuperar</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+95
-10
@@ -1,18 +1,36 @@
|
||||
'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 } from 'lucide-react'
|
||||
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-4 flex h-14 items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
@@ -38,16 +56,83 @@ export function Header() {
|
||||
</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>
|
||||
<div className="flex items-center gap-3">
|
||||
<QuickAdd />
|
||||
<Link href="/new">
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nueva nota
|
||||
</Button>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
export function KeyboardHint() {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground text-center py-2 border-t">
|
||||
↑↓ navegar · Enter abrir · E editar · F favoritar · P fijar
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { Note } from '@/types/note'
|
||||
import { NoteCard } from './note-card'
|
||||
import { useNoteListKeyboard } from '@/hooks/use-note-list-keyboard'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface KeyboardNavigableNoteListProps {
|
||||
notes: Note[]
|
||||
onEdit?: (noteId: string) => void
|
||||
}
|
||||
|
||||
export function KeyboardNavigableNoteList({
|
||||
notes,
|
||||
onEdit,
|
||||
}: KeyboardNavigableNoteListProps) {
|
||||
const handleFavorite = useCallback(async (noteId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isFavorite: true }),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('Añadido a favoritos')
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al añadir a favoritos')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePin = useCallback(async (noteId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isPinned: true }),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('Nota fijada')
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al fijar nota')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { selectedIndex, prefetchNote } = useNoteListKeyboard({
|
||||
notes,
|
||||
onEdit,
|
||||
onFavorite: handleFavorite,
|
||||
onPin: handlePin,
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-lg">No hay notas todavía</p>
|
||||
<p className="text-sm">Crea tu primera nota para comenzar</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{notes.map((note, index) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`relative ${index === selectedIndex ? 'ring-2 ring-primary ring-offset-2 rounded-lg' : ''}`}
|
||||
data-selected={index === selectedIndex}
|
||||
>
|
||||
<NoteCard note={note} />
|
||||
{index === selectedIndex && (
|
||||
<div className="absolute bottom-2 right-2 flex gap-1">
|
||||
<span className="px-1.5 py-0.5 bg-muted text-xs rounded text-muted-foreground">
|
||||
Enter: abrir | E: editar | F: favoritar | P: fijar
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Keyboard } from 'lucide-react'
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['n'], description: 'Nueva nota' },
|
||||
{ keys: ['g', 'h'], description: 'Ir al Dashboard' },
|
||||
{ keys: ['g', 'n'], description: 'Ir a Notas' },
|
||||
{ keys: ['/'], description: 'Enfocar búsqueda' },
|
||||
{ keys: ['?'], description: 'Mostrar atajos' },
|
||||
{ keys: ['Ctrl', 'K'], description: 'Command Palette' },
|
||||
]
|
||||
|
||||
export function KeyboardShortcutsDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (o: boolean) => void }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Keyboard className="h-5 w-5" />
|
||||
Atajos de teclado
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="divide-y">
|
||||
{shortcuts.map((s) => (
|
||||
<div key={s.description} className="flex justify-between py-2">
|
||||
<span className="text-sm">{s.description}</span>
|
||||
<div className="flex gap-1">
|
||||
{s.keys.map((k) => (
|
||||
<kbd key={k} className="px-2 py-1 bg-muted rounded text-xs">{k}</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
@@ -16,12 +17,21 @@ const typeColors: Record<string, string> = {
|
||||
}
|
||||
|
||||
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}`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<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>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'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 } from 'lucide-react'
|
||||
import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History, Clock } from 'lucide-react'
|
||||
import { getNavigationHistory, NavigationEntry } from '@/lib/navigation-history'
|
||||
|
||||
interface BacklinkInfo {
|
||||
id: string
|
||||
@@ -30,6 +32,7 @@ interface NoteConnectionsProps {
|
||||
backlinks: BacklinkInfo[]
|
||||
outgoingLinks: BacklinkInfo[]
|
||||
relatedNotes: RelatedNote[]
|
||||
coUsedNotes: { noteId: string; title: string; type: string; weight: number }[]
|
||||
}
|
||||
|
||||
function ConnectionGroup({
|
||||
@@ -37,12 +40,20 @@ function ConnectionGroup({
|
||||
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">
|
||||
@@ -58,37 +69,82 @@ function ConnectionGroup({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{title}
|
||||
<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>
|
||||
<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>
|
||||
{!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 hasAnyConnections =
|
||||
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
|
||||
const [navigationHistory, setNavigationHistory] = useState<NavigationEntry[]>([])
|
||||
|
||||
if (!hasAnyConnections) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -105,37 +161,70 @@ export function NoteConnections({
|
||||
<ConnectionGroup
|
||||
title="Enlaces entrantes"
|
||||
icon={ExternalLink}
|
||||
notes={backlinks.map((bl) => ({
|
||||
id: bl.sourceNote.id,
|
||||
title: bl.sourceNote.title,
|
||||
type: bl.sourceNote.type,
|
||||
}))}
|
||||
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={outgoingLinks.map((ol) => ({
|
||||
id: ol.sourceNote.id,
|
||||
title: ol.sourceNote.title,
|
||||
type: ol.sourceNote.type,
|
||||
}))}
|
||||
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={relatedNotes.map((rn) => ({
|
||||
id: rn.id,
|
||||
title: rn.title,
|
||||
type: rn.type,
|
||||
}))}
|
||||
notes={uniqueRelated}
|
||||
emptyMessage="No hay notas relacionadas"
|
||||
isCollapsed={collapsed['related']}
|
||||
onToggle={() => toggleCollapsed('related')}
|
||||
/>
|
||||
|
||||
{/* Co-used notes - often viewed together */}
|
||||
<ConnectionGroup
|
||||
title="Co-usadas"
|
||||
icon={Users}
|
||||
notes={uniqueCoUsed}
|
||||
emptyMessage="No hay notas co-usadas"
|
||||
isCollapsed={collapsed['coused']}
|
||||
onToggle={() => toggleCollapsed('coused')}
|
||||
/>
|
||||
|
||||
{/* Recent versions */}
|
||||
{recentVersions.length > 0 && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Versiones recientes
|
||||
</h4>
|
||||
<div className="pl-6 space-y-1">
|
||||
{recentVersions.map((v) => (
|
||||
<p key={v.id} className="text-xs text-muted-foreground">
|
||||
v{v.version} - {new Date(v.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation history */}
|
||||
{uniqueHistory.length > 0 && (
|
||||
<ConnectionGroup
|
||||
title="Vista recientemente"
|
||||
icon={Clock}
|
||||
notes={uniqueHistory}
|
||||
emptyMessage="No hay historial de navegación"
|
||||
isCollapsed={collapsed['history']}
|
||||
onToggle={() => toggleCollapsed('history')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Note, NoteType, Tag } from '@/types/note'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -10,6 +10,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
import { inferNoteType } from '@/lib/type-inference'
|
||||
import { useUnsavedChanges } from '@/hooks/use-unsaved-changes'
|
||||
import { saveDraft, loadDraft, deleteDraft } from '@/lib/drafts'
|
||||
import { DraftRecoveryBanner } from '@/components/draft-recovery-banner'
|
||||
|
||||
// Command fields
|
||||
interface CommandFields {
|
||||
@@ -620,14 +623,68 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [showDraftBanner, setShowDraftBanner] = useState(false)
|
||||
const [draftLoaded, setDraftLoaded] = useState(false)
|
||||
const draftLoadedRef = useRef(false)
|
||||
|
||||
// Store initial values for dirty tracking
|
||||
const initialValuesRef = useRef({
|
||||
title: initialData?.title || '',
|
||||
type: initialData?.type || 'note',
|
||||
fields: initialData?.content
|
||||
? parseMarkdownToFields(initialData.type, initialData.content)
|
||||
: defaultFields[initialData?.type || 'note'],
|
||||
tags: initialData?.tags.map(t => t.tag.name) || [],
|
||||
isFavorite: initialData?.isFavorite || false,
|
||||
isPinned: initialData?.isPinned || false,
|
||||
})
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
title !== initialValuesRef.current.title ||
|
||||
type !== initialValuesRef.current.type ||
|
||||
JSON.stringify(fields) !== JSON.stringify(initialValuesRef.current.fields) ||
|
||||
JSON.stringify(tags) !== JSON.stringify(initialValuesRef.current.tags) ||
|
||||
isFavorite !== initialValuesRef.current.isFavorite ||
|
||||
isPinned !== initialValuesRef.current.isPinned
|
||||
|
||||
setIsDirty(hasChanges)
|
||||
}, [title, type, fields, tags, isFavorite, isPinned])
|
||||
|
||||
useUnsavedChanges(isDirty)
|
||||
|
||||
// Serialized content for drafts and auto-suggest
|
||||
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
|
||||
|
||||
// Load draft on mount (only for new notes)
|
||||
useEffect(() => {
|
||||
if (isEdit || draftLoadedRef.current) return
|
||||
const draftKey = 'new'
|
||||
const draft = loadDraft(draftKey)
|
||||
if (draft) {
|
||||
draftLoadedRef.current = true
|
||||
setDraftLoaded(true)
|
||||
setShowDraftBanner(true)
|
||||
}
|
||||
}, [isEdit])
|
||||
|
||||
// Debounced save draft on changes (only for new notes, after initial load)
|
||||
useEffect(() => {
|
||||
if (isEdit || !draftLoaded || draftLoadedRef.current) return
|
||||
const draftKey = 'new'
|
||||
const timeoutId = setTimeout(() => {
|
||||
saveDraft(draftKey, { title, content, type, tags, savedAt: Date.now() })
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [title, content, type, tags, isEdit, draftLoaded])
|
||||
|
||||
const handleTypeChange = (newType: NoteType) => {
|
||||
setType(newType)
|
||||
setFields(defaultFields[newType])
|
||||
}
|
||||
|
||||
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
|
||||
|
||||
// Auto-suggest tags based on title and content
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
@@ -733,11 +790,34 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
const handleRestoreDraft = useCallback(() => {
|
||||
const draftKey = 'new'
|
||||
const draft = loadDraft(draftKey)
|
||||
if (draft) {
|
||||
setTitle(draft.title)
|
||||
setType(draft.type as NoteType)
|
||||
setFields(parseMarkdownToFields(draft.type as NoteType, draft.content))
|
||||
setTags(draft.tags)
|
||||
setShowDraftBanner(false)
|
||||
draftLoadedRef.current = true
|
||||
setDraftLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDiscardDraft = useCallback(() => {
|
||||
const draftKey = 'new'
|
||||
deleteDraft(draftKey)
|
||||
setShowDraftBanner(false)
|
||||
draftLoadedRef.current = true
|
||||
setDraftLoaded(true)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const noteData = {
|
||||
// Build payload, explicitly excluding id and any undefined values
|
||||
const noteData: Record<string, unknown> = {
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
@@ -746,6 +826,13 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
tags,
|
||||
}
|
||||
|
||||
// Remove undefined values before sending
|
||||
Object.keys(noteData).forEach(key => {
|
||||
if (noteData[key] === undefined) {
|
||||
delete noteData[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const url = isEdit && initialData ? `/api/notes/${initialData.id}` : '/api/notes'
|
||||
const method = isEdit ? 'PUT' : 'POST'
|
||||
@@ -757,6 +844,10 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setIsDirty(false)
|
||||
if (!isEdit) {
|
||||
deleteDraft('new')
|
||||
}
|
||||
router.push('/notes')
|
||||
router.refresh()
|
||||
}
|
||||
@@ -789,6 +880,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||
{showDraftBanner && (
|
||||
<DraftRecoveryBanner onRestore={handleRestoreDraft} onDiscard={handleDiscardDraft} />
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Título</label>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FeatureFlags, getFeatureFlags, setFeatureFlags } from '@/lib/preferences'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { BookmarkletInstructions } from '@/components/bookmarklet-instructions'
|
||||
|
||||
export function PreferencesPanel() {
|
||||
const [flags, setFlags] = useState<FeatureFlags>({
|
||||
backupEnabled: true,
|
||||
backupRetention: 30,
|
||||
workModeEnabled: true,
|
||||
})
|
||||
const [retentionInput, setRetentionInput] = useState('30')
|
||||
|
||||
useEffect(() => {
|
||||
setFlags(getFeatureFlags())
|
||||
setRetentionInput(getFeatureFlags().backupRetention.toString())
|
||||
}, [])
|
||||
|
||||
const handleBackupEnabled = (enabled: boolean) => {
|
||||
setFeatureFlags({ backupEnabled: enabled })
|
||||
setFlags(getFeatureFlags())
|
||||
}
|
||||
|
||||
const handleWorkModeEnabled = (enabled: boolean) => {
|
||||
setFeatureFlags({ workModeEnabled: enabled })
|
||||
setFlags(getFeatureFlags())
|
||||
// Dispatch custom event to notify other components (like Header)
|
||||
window.dispatchEvent(new CustomEvent('preferences-updated'))
|
||||
}
|
||||
|
||||
const handleRetentionChange = (value: string) => {
|
||||
setRetentionInput(value)
|
||||
const days = parseInt(value, 10)
|
||||
if (!isNaN(days) && days > 0) {
|
||||
setFeatureFlags({ backupRetention: days })
|
||||
setFlags(getFeatureFlags())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Preferencias
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura el comportamiento de la aplicación. Los cambios se guardan automáticamente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Backup automático</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Crear backups automáticamente al cerrar o cambiar de nota
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={flags.backupEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleBackupEnabled(!flags.backupEnabled)}
|
||||
>
|
||||
{flags.backupEnabled ? 'Activado' : 'Desactivado'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Retención de backups (días)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Los backups automáticos se eliminarán después de este período
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={retentionInput}
|
||||
onChange={(e) => handleRetentionChange(e.target.value)}
|
||||
className="w-24 px-3 py-1 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Modo trabajo</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Habilitar toggle de modo trabajo en el header
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={flags.workModeEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleWorkModeEnabled(!flags.workModeEnabled)}
|
||||
>
|
||||
{flags.workModeEnabled ? 'Activado' : 'Desactivado'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm font-medium mb-3">Integración externa</p>
|
||||
<BookmarkletInstructions />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Sprint MVP-5</Badge>
|
||||
<Badge variant="outline">v0.1.0</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
+135
-106
@@ -5,8 +5,9 @@ import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Plus, Loader2, Text, Sparkles, X } from 'lucide-react'
|
||||
import { Plus, Loader2, Sparkles, X, Text, ChevronDown } from 'lucide-react'
|
||||
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
|
||||
import { NoteType } from '@/types/note'
|
||||
|
||||
@@ -30,12 +31,13 @@ const TYPE_LABELS: Record<NoteType, string> = {
|
||||
export function QuickAdd() {
|
||||
const [value, setValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null)
|
||||
const [dismissedSuggestion, setDismissedSuggestion] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const detectContentType = useCallback((text: string) => {
|
||||
@@ -58,7 +60,6 @@ export function QuickAdd() {
|
||||
}, [dismissedSuggestion])
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
// Let the paste happen first
|
||||
setDismissedSuggestion(false)
|
||||
setTimeout(() => {
|
||||
detectContentType(value)
|
||||
@@ -70,7 +71,7 @@ export function QuickAdd() {
|
||||
setValue(typeSuggestion.formattedContent)
|
||||
setTypeSuggestion(null)
|
||||
setIsMultiline(true)
|
||||
setIsExpanded(true)
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +102,8 @@ export function QuickAdd() {
|
||||
description: note.title,
|
||||
})
|
||||
setValue('')
|
||||
setIsExpanded(false)
|
||||
setIsOpen(false)
|
||||
setIsMultiline(false)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast.error('Error', {
|
||||
@@ -119,7 +121,7 @@ export function QuickAdd() {
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setValue('')
|
||||
setIsExpanded(false)
|
||||
setIsOpen(false)
|
||||
setIsMultiline(false)
|
||||
inputRef.current?.blur()
|
||||
textareaRef.current?.blur()
|
||||
@@ -129,25 +131,39 @@ export function QuickAdd() {
|
||||
const toggleMultiline = () => {
|
||||
setIsMultiline(!isMultiline)
|
||||
if (!isMultiline) {
|
||||
setIsExpanded(true)
|
||||
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputFocus = () => {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
// Close popup when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
// Focus on keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+N or Cmd+N to focus quick add
|
||||
if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
setIsExpanded(true)
|
||||
setIsOpen(true)
|
||||
}
|
||||
// Escape to blur
|
||||
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
inputRef.current?.blur()
|
||||
setIsExpanded(false)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
@@ -155,115 +171,128 @@ export function QuickAdd() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||
<div className="relative flex-1">
|
||||
{isMultiline ? (
|
||||
<div className="relative" ref={popupRef}>
|
||||
{/* Compact input row */}
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="cmd: título..."
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
detectContentType(e.target.value)
|
||||
if (e.target.value) setIsOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleInputFocus}
|
||||
onPaste={handlePaste}
|
||||
className="w-full sm:w-80 h-9 pr-16"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{/* Action buttons inside input */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMultiline}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-accent transition-colors',
|
||||
isMultiline && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
|
||||
>
|
||||
{isMultiline ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Text className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim() || isLoading}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-accent transition-colors',
|
||||
'disabled:pointer-events-none disabled:opacity-30'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Expanded popup */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-popover border rounded-lg shadow-lg z-50">
|
||||
{/* Multiline textarea (shown when multiline mode) */}
|
||||
{isMultiline && (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder="cmd: título #tag Contenido multilínea..."
|
||||
placeholder="Contenido multilínea..."
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
detectContentType(e.target.value)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
onPaste={handlePaste}
|
||||
className={cn(
|
||||
'min-h-[80px] max-h-[200px] transition-all duration-200 resize-none',
|
||||
isExpanded && 'w-full'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
rows={isExpanded ? 4 : 2}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="cmd: título #tag..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
onPaste={handlePaste}
|
||||
className={cn(
|
||||
'w-48 transition-all duration-200',
|
||||
isExpanded && 'w-72'
|
||||
)}
|
||||
className="min-h-[100px] max-h-[200px] resize-none w-full"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMultiline}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-colors',
|
||||
isMultiline && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
|
||||
>
|
||||
<Text className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim() || isLoading}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Smart paste suggestion */}
|
||||
{typeSuggestion && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-popover border rounded-lg shadow-md z-50">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
Detectado: <span className="text-primary">{TYPE_LABELS[typeSuggestion.type]}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{typeSuggestion.reason}</p>
|
||||
{/* Smart paste suggestion */}
|
||||
{typeSuggestion && (
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded-lg border">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
Detectado: <span className="text-primary">{TYPE_LABELS[typeSuggestion.type]}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{typeSuggestion.reason}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSuggestion}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={acceptSuggestion}
|
||||
className="text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90"
|
||||
>
|
||||
Usar plantilla
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSuggestion}
|
||||
className="text-xs px-2 py-1 text-muted-foreground hover:bg-accent rounded"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSuggestion}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={acceptSuggestion}
|
||||
className="text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90"
|
||||
>
|
||||
Usar plantilla
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSuggestion}
|
||||
className="text-xs px-2 py-1 text-muted-foreground hover:bg-accent rounded"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
{!value && !typeSuggestion && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Usa prefijos como <span className="font-mono bg-muted px-1 rounded">cmd:</span>, <span className="font-mono bg-muted px-1 rounded">snip:</span> para тип notes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { NavigationEntry, getNavigationHistory } from '@/lib/navigation-history'
|
||||
import { Clock } from 'lucide-react'
|
||||
|
||||
export function RecentContextList() {
|
||||
const [history, setHistory] = useState<NavigationEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(getNavigationHistory())
|
||||
}, [])
|
||||
|
||||
if (history.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Vista recientemente</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{history.slice(0, 5).map((entry) => (
|
||||
<Link
|
||||
key={entry.noteId}
|
||||
href={`/notes/${entry.noteId}`}
|
||||
className="block px-2 py-1.5 text-sm rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="truncate">{entry.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.type} · {formatRelativeTime(entry.visitedAt)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'ahora'
|
||||
if (diffMins < 60) return `hace ${diffMins}m`
|
||||
if (diffHours < 24) return `hace ${diffHours}h`
|
||||
if (diffDays < 7) return `hace ${diffDays}d`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
+179
-14
@@ -1,34 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Search, Loader2 } from 'lucide-react'
|
||||
import { ScoredNote } from '@/lib/search'
|
||||
|
||||
// Simple in-memory cache for search results
|
||||
const searchCache = new Map<string, ScoredNote[]>()
|
||||
const MAX_CACHE_SIZE = 50
|
||||
|
||||
export function SearchBar() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<ScoredNote[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const router = useRouter()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResults([])
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// Check cache first
|
||||
if (searchCache.has(query)) {
|
||||
setResults(searchCache.get(query)!)
|
||||
setIsOpen(true)
|
||||
setSelectedIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
|
||||
const json = await res.json()
|
||||
if (json.success) {
|
||||
const results = json.data.slice(0, 8)
|
||||
setResults(results)
|
||||
setIsOpen(true)
|
||||
setSelectedIndex(-1)
|
||||
|
||||
// Cache the results
|
||||
if (searchCache.size >= MAX_CACHE_SIZE) {
|
||||
// Remove oldest entry (first in Map)
|
||||
const firstKey = searchCache.keys().next().value
|
||||
if (firstKey) searchCache.delete(firstKey)
|
||||
}
|
||||
searchCache.set(query, results)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
}
|
||||
}, [query])
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (query.trim()) {
|
||||
router.push(`/notes?q=${encodeURIComponent(query)}`)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen || results.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
router.push(`/notes/${results[selectedIndex].id}`)
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
} else {
|
||||
handleSearch(e)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleResultClick = (note: ScoredNote) => {
|
||||
router.push(`/notes/${note.id}`)
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar notas..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="icon">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
<div className="relative w-full">
|
||||
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Buscar notas..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="icon" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{isOpen && results.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
||||
>
|
||||
{results.map((note, index) => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => handleResultClick(note)}
|
||||
className={`w-full px-3 py-2 text-left flex items-center justify-between gap-2 transition-colors ${
|
||||
index === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{note.title}</div>
|
||||
{note.highlight && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
dangerouslySetInnerHTML={{ __html: note.highlight }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
note.type === 'document'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||
: note.type === 'task'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{note.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useGlobalShortcuts } from '@/hooks/use-global-shortcuts'
|
||||
import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog'
|
||||
|
||||
export function ShortcutsProvider() {
|
||||
const { showHelp, setShowHelp } = useGlobalShortcuts()
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsDialog open={showHelp} onOpenChange={setShowHelp} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { addToNavigationHistory } from '@/lib/navigation-history'
|
||||
|
||||
interface TrackNavigationHistoryProps {
|
||||
noteId: string
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export function TrackNavigationHistory({ noteId, title, type }: TrackNavigationHistoryProps) {
|
||||
useEffect(() => {
|
||||
addToNavigationHistory({ noteId, title, type })
|
||||
}, [noteId, title, type])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { History, RotateCcw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface VersionHistoryProps {
|
||||
noteId: string
|
||||
}
|
||||
|
||||
export function VersionHistory({ noteId }: VersionHistoryProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [restoring, setRestoring] = useState<string | null>(null)
|
||||
const [confirmRestore, setConfirmRestore] = useState<string | null>(null)
|
||||
|
||||
const fetchVersions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions`)
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setVersions(data.data)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al cargar las versiones')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
if (newOpen && versions.length === 0) {
|
||||
fetchVersions()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (versionId: string) => {
|
||||
setRestoring(versionId)
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
toast.success('Versión restaurada correctamente')
|
||||
setOpen(false)
|
||||
window.location.reload()
|
||||
} else {
|
||||
toast.error(data.error || 'Error al restaurar la versión')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al restaurar la versión')
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
setConfirmRestore(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<div onClick={() => handleOpenChange(true)}>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<History className="h-4 w-4 mr-1" /> Historial
|
||||
</Button>
|
||||
</div>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Historial de versiones</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revisa y restaura versiones anteriores de esta nota
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Cargando versiones...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No hay versiones guardadas</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="flex items-start justify-between gap-4 p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm mb-1">
|
||||
{formatDate(version.createdAt)}
|
||||
</div>
|
||||
<div className="text-gray-600 text-sm truncate">
|
||||
{version.title}
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs mt-1 truncate">
|
||||
{version.content.substring(0, 50)}...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmRestore === version.id ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-xs text-gray-500">¿Restaurar?</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRestore(version.id)}
|
||||
disabled={restoring === version.id}
|
||||
>
|
||||
{restoring === version.id ? '...' : 'Sí'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmRestore(null)}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRestore(version.id)}
|
||||
title="Restaurar esta versión"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getWorkMode, setWorkMode } from '@/lib/work-mode'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Monitor, Eye } from 'lucide-react'
|
||||
|
||||
export function WorkModeToggle() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(getWorkMode())
|
||||
|
||||
const handlePreferencesChange = () => {
|
||||
// Re-read work mode state when preferences change
|
||||
setEnabled(getWorkMode())
|
||||
}
|
||||
|
||||
window.addEventListener('preferences-updated', handlePreferencesChange)
|
||||
return () => window.removeEventListener('preferences-updated', handlePreferencesChange)
|
||||
}, [])
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !enabled
|
||||
setEnabled(newValue)
|
||||
setWorkMode(newValue)
|
||||
// Dispatch event so other components know work mode changed
|
||||
window.dispatchEvent(new CustomEvent('work-mode-changed', { detail: { enabled: newValue } }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="sm" onClick={toggle} title="Modo trabajo">
|
||||
{enabled ? <Eye className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const SHORTCUTS: Record<string, string> = {
|
||||
'g h': '/', // go to dashboard
|
||||
'g n': '/notes', // go to notes
|
||||
'n': '/new', // new note
|
||||
'/': '/notes', // focus search (navigate to notes)
|
||||
'?': 'show-help', // show shortcuts dialog
|
||||
}
|
||||
|
||||
export function useGlobalShortcuts() {
|
||||
const router = useRouter()
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let keystroke = ''
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if in input/textarea/contenteditable
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle ? for help
|
||||
if (e.key === '?' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setShowHelp(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Track g + key combos
|
||||
if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
|
||||
keystroke = 'g'
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => { keystroke = '' }, 500)
|
||||
return
|
||||
}
|
||||
|
||||
if (keystroke === 'g') {
|
||||
const combo = 'g ' + e.key
|
||||
if (SHORTCUTS[combo]) {
|
||||
e.preventDefault()
|
||||
router.push(SHORTCUTS[combo])
|
||||
keystroke = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Direct shortcuts
|
||||
if (e.key === 'n' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
router.push('/new')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [router])
|
||||
|
||||
return { showHelp, setShowHelp }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Note } from '@/types/note'
|
||||
|
||||
interface UseNoteListKeyboardOptions {
|
||||
notes: Note[]
|
||||
onEdit?: (noteId: string) => void
|
||||
onFavorite?: (noteId: string) => void
|
||||
onPin?: (noteId: string) => void
|
||||
}
|
||||
|
||||
export function useNoteListKeyboard({
|
||||
notes,
|
||||
onEdit,
|
||||
onFavorite,
|
||||
onPin,
|
||||
}: UseNoteListKeyboardOptions) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const router = useRouter()
|
||||
const prefetchedRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Prefetch a note's page data for faster navigation
|
||||
const prefetchNote = useCallback((noteId: string) => {
|
||||
if (prefetchedRef.current.has(noteId)) return
|
||||
prefetchedRef.current.add(noteId)
|
||||
router.prefetch(`/notes/${noteId}`)
|
||||
}, [router])
|
||||
|
||||
// Reset selection when notes change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
prefetchedRef.current.clear()
|
||||
}, [notes.length])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Ignore if in input/textarea/contenteditable
|
||||
const target = e.target as HTMLElement
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => {
|
||||
const next = prev < notes.length - 1 ? prev + 1 : notes.length - 1
|
||||
if (notes[next]) prefetchNote(notes[next].id)
|
||||
return next
|
||||
})
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => {
|
||||
const next = prev > 0 ? prev - 1 : -1
|
||||
if (next >= 0 && notes[next]) prefetchNote(notes[next].id)
|
||||
return next
|
||||
})
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && notes[selectedIndex]) {
|
||||
router.push(`/notes/${notes[selectedIndex].id}`)
|
||||
}
|
||||
break
|
||||
case 'e':
|
||||
case 'E':
|
||||
if (selectedIndex >= 0 && notes[selectedIndex] && onEdit) {
|
||||
e.preventDefault()
|
||||
onEdit(notes[selectedIndex].id)
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (selectedIndex >= 0 && notes[selectedIndex] && onFavorite) {
|
||||
e.preventDefault()
|
||||
onFavorite(notes[selectedIndex].id)
|
||||
}
|
||||
break
|
||||
case 'p':
|
||||
case 'P':
|
||||
if (selectedIndex >= 0 && notes[selectedIndex] && onPin) {
|
||||
e.preventDefault()
|
||||
onPin(notes[selectedIndex].id)
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[notes, selectedIndex, router, onEdit, onFavorite, onPin, prefetchNote]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
return { selectedIndex, setSelectedIndex, prefetchNote }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function useUnsavedChanges(
|
||||
isDirty: boolean,
|
||||
message = '¿Salir sin guardar cambios?'
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
e.returnValue = message
|
||||
return message
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty, message])
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getBackups } from '@/lib/backup-storage'
|
||||
import { deleteBackup } from '@/lib/backup-storage'
|
||||
import { BackupSource } from '@/types/backup'
|
||||
|
||||
const MAX_AUTOMATIC_BACKUPS = 10
|
||||
const MAX_BACKUP_AGE_DAYS = 30
|
||||
|
||||
function daysAgo(date: Date): number {
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
return diffMs / (1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
export async function shouldCleanup(): Promise<boolean> {
|
||||
const backups = await getBackups()
|
||||
|
||||
const automaticBackups = backups.filter((b) => b.source === 'automatic')
|
||||
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
|
||||
return true
|
||||
}
|
||||
|
||||
const oldBackups = backups.filter(
|
||||
(b) => daysAgo(new Date(b.createdAt)) > MAX_BACKUP_AGE_DAYS
|
||||
)
|
||||
if (oldBackups.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function cleanupOldBackups(): Promise<number> {
|
||||
const backups = await getBackups()
|
||||
let deletedCount = 0
|
||||
|
||||
// Remove automatic backups exceeding the limit
|
||||
const automaticBackups = backups.filter((b) => b.source === 'automatic')
|
||||
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
|
||||
const toRemove = automaticBackups.slice(MAX_AUTOMATIC_BACKUPS)
|
||||
for (const backup of toRemove) {
|
||||
await deleteBackup(backup.id)
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Remove backups older than 30 days
|
||||
const recentBackups = await getBackups()
|
||||
for (const backup of recentBackups) {
|
||||
if (daysAgo(new Date(backup.createdAt)) > MAX_BACKUP_AGE_DAYS) {
|
||||
await deleteBackup(backup.id)
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
|
||||
const DB_NAME = 'recall_backups'
|
||||
const STORE_NAME = 'backups'
|
||||
const DB_VERSION = 1
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveBackup(backup: RecallBackup): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.put(backup)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBackups(): Promise<RecallBackup[]> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.getAll()
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
const backups = (request.result as RecallBackup[]).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
resolve(backups)
|
||||
}
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBackup(id: string): Promise<RecallBackup | null> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.get(id)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result || null)
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteBackup(id: string): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.delete(id)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
tx.oncomplete = () => db.close()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
|
||||
// Limits
|
||||
const MAX_BACKUP_SIZE_BYTES = 50 * 1024 * 1024 // 50MB
|
||||
const MAX_NOTE_COUNT = 10000
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
warnings?: string[]
|
||||
info?: {
|
||||
noteCount: number
|
||||
tagCount: number
|
||||
createdAt: string
|
||||
source: string
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBackup(data: unknown): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return { valid: false, errors: ['Backup must be an object'] }
|
||||
}
|
||||
|
||||
const backup = data as Record<string, unknown>
|
||||
|
||||
if (!validateSchemaVersion(backup as unknown as RecallBackup)) {
|
||||
errors.push('Missing or invalid schemaVersion (expected "1.0")')
|
||||
}
|
||||
|
||||
if (!backup.createdAt || typeof backup.createdAt !== 'string') {
|
||||
errors.push('Missing or invalid createdAt field')
|
||||
}
|
||||
|
||||
if (!backup.source || typeof backup.source !== 'string') {
|
||||
errors.push('Missing or invalid source field')
|
||||
}
|
||||
|
||||
if (!backup.metadata || typeof backup.metadata !== 'object') {
|
||||
errors.push('Missing or invalid metadata field')
|
||||
} else {
|
||||
const metadata = backup.metadata as Record<string, unknown>
|
||||
if (typeof metadata.noteCount !== 'number') {
|
||||
errors.push('Missing or invalid metadata.noteCount')
|
||||
}
|
||||
if (typeof metadata.tagCount !== 'number') {
|
||||
errors.push('Missing or invalid metadata.tagCount')
|
||||
}
|
||||
}
|
||||
|
||||
if (!backup.data || typeof backup.data !== 'object') {
|
||||
errors.push('Missing or invalid data field')
|
||||
} else {
|
||||
const data = backup.data as Record<string, unknown>
|
||||
if (!Array.isArray(data.notes)) {
|
||||
errors.push('Missing or invalid data.notes (expected array)')
|
||||
} else {
|
||||
if (data.notes.length > MAX_NOTE_COUNT) {
|
||||
errors.push(`Too many notes: ${data.notes.length} (max: ${MAX_NOTE_COUNT})`)
|
||||
}
|
||||
if (data.notes.length > 1000) {
|
||||
warnings.push(`Large backup with ${data.notes.length} notes - restore may take time`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
const metadata = backup.metadata as { noteCount: number; tagCount: number }
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
info: {
|
||||
noteCount: metadata.noteCount,
|
||||
tagCount: metadata.tagCount,
|
||||
createdAt: backup.createdAt as string,
|
||||
source: backup.source as string,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBackupSize(sizeBytes: number): { valid: boolean; error?: string } {
|
||||
if (sizeBytes > MAX_BACKUP_SIZE_BYTES) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Backup too large: ${(sizeBytes / 1024 / 1024).toFixed(2)}MB (max: ${MAX_BACKUP_SIZE_BYTES / 1024 / 1024}MB)`,
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function validateSchemaVersion(backup: RecallBackup): boolean {
|
||||
return backup.schemaVersion === '1.0'
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { BackupSource, RecallBackup } from '@/types/backup'
|
||||
|
||||
const SCHEMA_VERSION = '1.0'
|
||||
|
||||
export async function createBackupSnapshot(source: BackupSource): Promise<RecallBackup> {
|
||||
const [notes, tags, backlinks, noteVersions] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
}),
|
||||
prisma.tag.findMany(),
|
||||
prisma.backlink.findMany(),
|
||||
prisma.noteVersion.findMany(),
|
||||
])
|
||||
|
||||
const exportNotes = notes.map((note) => ({
|
||||
...note,
|
||||
tags: note.tags.map((nt) => nt.tag.name),
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt.toISOString(),
|
||||
}))
|
||||
|
||||
const exportTags = tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
}))
|
||||
|
||||
const exportBacklinks = backlinks.map((bl) => ({
|
||||
id: bl.id,
|
||||
sourceNoteId: bl.sourceNoteId,
|
||||
targetNoteId: bl.targetNoteId,
|
||||
createdAt: bl.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
const exportVersions = noteVersions.map((v) => ({
|
||||
id: v.id,
|
||||
noteId: v.noteId,
|
||||
title: v.title,
|
||||
content: v.content,
|
||||
createdAt: v.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
const backup: RecallBackup = {
|
||||
id: crypto.randomUUID(),
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
source,
|
||||
metadata: {
|
||||
noteCount: notes.length,
|
||||
tagCount: tags.length,
|
||||
versionCount: noteVersions.length,
|
||||
},
|
||||
data: {
|
||||
notes: exportNotes,
|
||||
tags: exportTags,
|
||||
backlinks: exportBacklinks,
|
||||
noteVersions: exportVersions,
|
||||
},
|
||||
}
|
||||
|
||||
return backup
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface CommandItem {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
group: 'navigation' | 'actions' | 'search' | 'recent'
|
||||
keywords?: string[]
|
||||
action?: () => void
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export const commands: CommandItem[] = [
|
||||
{ id: 'nav-dashboard', label: 'Ir al Dashboard', group: 'navigation', keywords: ['home'] },
|
||||
{ id: 'nav-notes', label: 'Ir a Notas', group: 'navigation', keywords: ['all notes'] },
|
||||
{ id: 'nav-settings', label: 'Ir a Configuración', group: 'navigation', keywords: ['settings'] },
|
||||
{ id: 'action-new', label: 'Crear nueva nota', group: 'actions', keywords: ['new note', 'create'] },
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
const DRAFT_KEY_PREFIX = 'recall_draft_'
|
||||
const DRAFT_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
interface Draft {
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
tags: string[]
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
export function saveDraft(noteId: string, data: Draft): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(DRAFT_KEY_PREFIX + noteId, JSON.stringify({ ...data, savedAt: Date.now() }))
|
||||
}
|
||||
|
||||
export function loadDraft(noteId: string): Draft | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const stored = localStorage.getItem(DRAFT_KEY_PREFIX + noteId)
|
||||
if (!stored) return null
|
||||
try {
|
||||
const draft = JSON.parse(stored) as Draft
|
||||
if (Date.now() - draft.savedAt > DRAFT_TTL) {
|
||||
deleteDraft(noteId)
|
||||
return null
|
||||
}
|
||||
return draft
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteDraft(noteId: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(DRAFT_KEY_PREFIX + noteId)
|
||||
}
|
||||
@@ -56,6 +56,18 @@ export class ConflictError extends AppError {
|
||||
}
|
||||
}
|
||||
|
||||
export class PayloadTooLargeError extends AppError {
|
||||
constructor(message: string = 'Payload too large') {
|
||||
super('PAYLOAD_TOO_LARGE', message, 413)
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends AppError {
|
||||
constructor(message: string = 'Too many requests') {
|
||||
super('RATE_LIMITED', message, 429)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatZodError(error: ZodError): ApiError {
|
||||
return {
|
||||
code: 'VALIDATION_ERROR',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
interface NoteWithTags {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite: boolean
|
||||
isPinned: boolean
|
||||
creationSource: string
|
||||
tags: { tag: { id: string; name: string } }[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function simpleMarkdownToHtml(content: string): string {
|
||||
// Convert markdown to basic HTML
|
||||
return content
|
||||
// Headers
|
||||
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
|
||||
// Bold and italic
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
// Code blocks
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Lists
|
||||
.replace(/^\s*-\s+(.*$)/gm, '<li>$1</li>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||
// Paragraphs
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
const HTML_TEMPLATE = `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
h1 { border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||||
.meta { color: #666; font-size: 0.9em; margin-bottom: 20px; }
|
||||
.tags { margin: 10px 0; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
margin-right: 5px;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
li { margin-left: 20px; }
|
||||
a { color: #0066cc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>{{TITLE}}</h1>
|
||||
<div class="meta">
|
||||
<span class="type">{{TYPE}}</span>
|
||||
<span class="date"> · Creado: {{CREATED}} · Actualizado: {{UPDATED}}</span>
|
||||
</div>
|
||||
<div class="tags">{{TAGS}}</div>
|
||||
<div class="content">{{CONTENT}}</div>
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export function noteToHtml(note: NoteWithTags): string {
|
||||
const htmlContent = simpleMarkdownToHtml(escapeHtml(note.content))
|
||||
const tagsHtml = note.tags
|
||||
.map(({ tag }) => `<span class="tag">${escapeHtml(tag.name)}</span>`)
|
||||
.join('')
|
||||
|
||||
return HTML_TEMPLATE
|
||||
.replace('{{TITLE}}', escapeHtml(note.title))
|
||||
.replace('{{TYPE}}', escapeHtml(note.type))
|
||||
.replace('{{CREATED}}', new Date(note.createdAt).toLocaleDateString())
|
||||
.replace('{{UPDATED}}', new Date(note.updatedAt).toLocaleDateString())
|
||||
.replace('{{TAGS}}', tagsHtml)
|
||||
.replace('{{CONTENT}}', htmlContent)
|
||||
}
|
||||
|
||||
export function generateHtmlFilename(note: NoteWithTags): string {
|
||||
const sanitized = note.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100)
|
||||
|
||||
return `${sanitized}-${note.id.slice(-8)}.html`
|
||||
}
|
||||
|
||||
export function notesToHtmlZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
|
||||
const files = notes.map((note) => ({
|
||||
name: generateHtmlFilename(note),
|
||||
content: noteToHtml(note),
|
||||
}))
|
||||
|
||||
return { files }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
interface NoteWithTags {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite: boolean
|
||||
isPinned: boolean
|
||||
creationSource: string
|
||||
tags: { tag: { id: string; name: string } }[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function noteToMarkdown(note: NoteWithTags): string {
|
||||
const frontmatter = [
|
||||
'---',
|
||||
`title: "${escapeYaml(note.title)}"`,
|
||||
`type: ${note.type}`,
|
||||
`createdAt: ${note.createdAt}`,
|
||||
`updatedAt: ${note.updatedAt}`,
|
||||
note.tags && note.tags.length > 0
|
||||
? `tags:\n${note.tags.map(({ tag }) => ` - ${tag.name}`).join('\n')}`
|
||||
: null,
|
||||
note.isFavorite ? 'favorite: true' : null,
|
||||
note.isPinned ? 'pinned: true' : null,
|
||||
'---',
|
||||
'',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return `${frontmatter}\n# ${note.title}\n\n${note.content}`
|
||||
}
|
||||
|
||||
export function generateFilename(note: NoteWithTags): string {
|
||||
const sanitized = note.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100)
|
||||
|
||||
return `${sanitized}-${note.id.slice(-8)}.md`
|
||||
}
|
||||
|
||||
export function escapeYaml(str: string): string {
|
||||
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
||||
}
|
||||
|
||||
export function notesToMarkdownZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
|
||||
const files = notes.map((note) => ({
|
||||
name: generateFilename(note),
|
||||
content: noteToMarkdown(note),
|
||||
}))
|
||||
|
||||
return { files }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface CapturePayload {
|
||||
title: string
|
||||
url: string
|
||||
selection: string
|
||||
}
|
||||
|
||||
// Limits for capture payloads
|
||||
const MAX_TITLE_LENGTH = 500
|
||||
const MAX_URL_LENGTH = 2000
|
||||
const MAX_SELECTION_LENGTH = 10000
|
||||
|
||||
export interface CaptureValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function validateCapturePayload(payload: CapturePayload): CaptureValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!payload.title || typeof payload.title !== 'string') {
|
||||
errors.push('Title is required')
|
||||
} else if (payload.title.length > MAX_TITLE_LENGTH) {
|
||||
errors.push(`Title too long: ${payload.title.length} chars (max: ${MAX_TITLE_LENGTH})`)
|
||||
}
|
||||
|
||||
if (payload.url && typeof payload.url !== 'string') {
|
||||
errors.push('URL must be a string')
|
||||
} else if (payload.url && payload.url.length > MAX_URL_LENGTH) {
|
||||
errors.push(`URL too long: ${payload.url.length} chars (max: ${MAX_URL_LENGTH})`)
|
||||
}
|
||||
|
||||
if (payload.selection && typeof payload.selection !== 'string') {
|
||||
errors.push('Selection must be a string')
|
||||
} else if (payload.selection && payload.selection.length > MAX_SELECTION_LENGTH) {
|
||||
errors.push(`Selection too long: ${payload.selection.length} chars (max: ${MAX_SELECTION_LENGTH})`)
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeCapturePayload(payload: CapturePayload): string {
|
||||
const params = new URLSearchParams({
|
||||
title: payload.title.slice(0, MAX_TITLE_LENGTH),
|
||||
url: payload.url.slice(0, MAX_URL_LENGTH),
|
||||
selection: payload.selection.slice(0, MAX_SELECTION_LENGTH),
|
||||
})
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export function generateBookmarklet(): string {
|
||||
// Get the current origin (where the app is running)
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
|
||||
const code = `
|
||||
var title = document.title;
|
||||
var url = location.href;
|
||||
var selection = window.getSelection().toString();
|
||||
var params = new URLSearchParams({title, url, selection});
|
||||
var base = ${JSON.stringify(origin)};
|
||||
window.open(base + '/capture?' + params.toString(), '_blank');
|
||||
`.replace(/\s+/g, ' ').trim()
|
||||
return `javascript:${code}`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
interface ParsedFrontmatter {
|
||||
title?: string
|
||||
type?: string
|
||||
tags?: string[]
|
||||
favorite?: boolean
|
||||
pinned?: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface ParsedMarkdown {
|
||||
frontmatter: ParsedFrontmatter
|
||||
content: string
|
||||
title: string
|
||||
hasWikiLinks: boolean
|
||||
}
|
||||
|
||||
export function parseMarkdownContent(
|
||||
markdown: string,
|
||||
filename?: string
|
||||
): ParsedMarkdown {
|
||||
const frontmatter: ParsedFrontmatter = {}
|
||||
let content = markdown
|
||||
let title = ''
|
||||
|
||||
// Check for frontmatter
|
||||
const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||
if (frontmatterMatch) {
|
||||
const frontmatterStr = frontmatterMatch[1]
|
||||
content = frontmatterMatch[2]
|
||||
|
||||
// Parse frontmatter fields
|
||||
const lines = frontmatterStr.split('\n')
|
||||
let currentKey = ''
|
||||
let inList = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (inList && line.match(/^\s+-\s+/)) {
|
||||
// Continuation of list
|
||||
const value = line.replace(/^\s+-\s+/, '').trim()
|
||||
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
||||
frontmatter.tags.push(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
inList = false
|
||||
|
||||
// Key: value
|
||||
const kvMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (kvMatch) {
|
||||
currentKey = kvMatch[1]
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
if (currentKey === 'tags' && !value) {
|
||||
frontmatter.tags = []
|
||||
inList = true
|
||||
} else if (currentKey === 'tags') {
|
||||
frontmatter.tags = value.split(',').map((t) => t.trim())
|
||||
} else if (currentKey === 'favorite' || currentKey === 'pinned') {
|
||||
frontmatter[currentKey] = value === 'true'
|
||||
} else {
|
||||
;(frontmatter as Record<string, unknown>)[currentKey] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// List item
|
||||
const listMatch = line.match(/^\s+-\s+(.*)$/)
|
||||
if (listMatch) {
|
||||
if (!frontmatter.tags) frontmatter.tags = []
|
||||
frontmatter.tags.push(listMatch[1].trim())
|
||||
inList = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract title from content if not in frontmatter
|
||||
if (frontmatter.title) {
|
||||
title = frontmatter.title
|
||||
} else {
|
||||
// Try to find first # heading
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m)
|
||||
if (headingMatch) {
|
||||
title = headingMatch[1].trim()
|
||||
} else if (filename) {
|
||||
// Derive from filename
|
||||
title = filename
|
||||
.replace(/\.md$/i, '')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/^\d+-\d+-\d+\s*/, '') // Remove date prefix if present
|
||||
} else {
|
||||
title = 'Untitled'
|
||||
}
|
||||
}
|
||||
|
||||
// Remove title heading from content if it exists
|
||||
content = content.replace(/^#\s+.+\n+/, '')
|
||||
|
||||
// Check for wiki links
|
||||
const hasWikiLinks = /\[\[([^\]]+)\]\]/.test(content)
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
content: content.trim(),
|
||||
title,
|
||||
hasWikiLinks,
|
||||
}
|
||||
}
|
||||
|
||||
export function convertWikiLinksToMarkdown(content: string): string {
|
||||
// Convert [[link]] to [link](link)
|
||||
return content.replace(/\[\[([^\]|]+)\]\]/g, '[$1]($1)')
|
||||
}
|
||||
|
||||
export function extractInlineTags(content: string): string[] {
|
||||
// Extract #tag patterns that aren't in code blocks
|
||||
const tags: string[] = []
|
||||
const codeBlockRegex = /```[\s\S]*?```|`[^`]+`/g
|
||||
const contentWithoutCode = content.replace(codeBlockRegex, '')
|
||||
|
||||
const tagRegex = /#([a-zA-Z][a-zA-Z0-9_-]*)/g
|
||||
let match
|
||||
while ((match = tagRegex.exec(contentWithoutCode)) !== null) {
|
||||
const tag = match[1].toLowerCase()
|
||||
if (!tags.includes(tag)) {
|
||||
tags.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
const NAVIGATION_HISTORY_KEY = 'recall_navigation_history'
|
||||
const MAX_HISTORY_SIZE = 10
|
||||
|
||||
export interface NavigationEntry {
|
||||
noteId: string
|
||||
title: string
|
||||
type: string
|
||||
visitedAt: string
|
||||
}
|
||||
|
||||
export function getNavigationHistory(): NavigationEntry[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
const stored = localStorage.getItem(NAVIGATION_HISTORY_KEY)
|
||||
if (!stored) return []
|
||||
const entries: NavigationEntry[] = JSON.parse(stored)
|
||||
// Deduplicate by noteId, keeping the first occurrence (most recent)
|
||||
const seen = new Set<string>()
|
||||
return entries.filter(entry => {
|
||||
if (seen.has(entry.noteId)) return false
|
||||
seen.add(entry.noteId)
|
||||
return true
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function addToNavigationHistory(entry: Omit<NavigationEntry, 'visitedAt'>): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const history = getNavigationHistory()
|
||||
|
||||
// Remove duplicate entries for the same note
|
||||
const filtered = history.filter((e) => e.noteId !== entry.noteId)
|
||||
|
||||
// Add new entry at the beginning
|
||||
const newEntry: NavigationEntry = {
|
||||
...entry,
|
||||
visitedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const newHistory = [newEntry, ...filtered].slice(0, MAX_HISTORY_SIZE)
|
||||
|
||||
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(newHistory))
|
||||
}
|
||||
|
||||
export function clearNavigationHistory(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(NAVIGATION_HISTORY_KEY)
|
||||
}
|
||||
|
||||
export function removeFromNavigationHistory(noteId: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const history = getNavigationHistory()
|
||||
const filtered = history.filter((e) => e.noteId !== noteId)
|
||||
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(filtered))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const FEATURES_KEY = 'recall_features'
|
||||
const BACKUP_ENABLED_KEY = 'recall_backup_enabled'
|
||||
const BACKUP_RETENTION_KEY = 'recall_backup_retention'
|
||||
|
||||
export interface FeatureFlags {
|
||||
backupEnabled: boolean
|
||||
backupRetention: number // days
|
||||
workModeEnabled: boolean
|
||||
}
|
||||
|
||||
const defaultFlags: FeatureFlags = {
|
||||
backupEnabled: true,
|
||||
backupRetention: 30,
|
||||
workModeEnabled: true,
|
||||
}
|
||||
|
||||
export function getFeatureFlags(): FeatureFlags {
|
||||
if (typeof window === 'undefined') return defaultFlags
|
||||
try {
|
||||
const stored = localStorage.getItem(FEATURES_KEY)
|
||||
if (!stored) return defaultFlags
|
||||
return { ...defaultFlags, ...JSON.parse(stored) }
|
||||
} catch {
|
||||
return defaultFlags
|
||||
}
|
||||
}
|
||||
|
||||
export function setFeatureFlags(flags: Partial<FeatureFlags>): void {
|
||||
if (typeof window === 'undefined') return
|
||||
const current = getFeatureFlags()
|
||||
const updated = { ...current, ...flags }
|
||||
localStorage.setItem(FEATURES_KEY, JSON.stringify(updated))
|
||||
}
|
||||
|
||||
export function isBackupEnabled(): boolean {
|
||||
return getFeatureFlags().backupEnabled
|
||||
}
|
||||
|
||||
export function setBackupEnabled(enabled: boolean): void {
|
||||
setFeatureFlags({ backupEnabled: enabled })
|
||||
}
|
||||
|
||||
export function getBackupRetention(): number {
|
||||
return getFeatureFlags().backupRetention
|
||||
}
|
||||
|
||||
export function setBackupRetention(days: number): void {
|
||||
setFeatureFlags({ backupRetention: days })
|
||||
}
|
||||
|
||||
export function isWorkModeEnabled(): boolean {
|
||||
return getFeatureFlags().workModeEnabled
|
||||
}
|
||||
|
||||
export function setWorkModeEnabled(enabled: boolean): void {
|
||||
setFeatureFlags({ workModeEnabled: enabled })
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface QueryAST {
|
||||
text: string
|
||||
filters: {
|
||||
type?: string
|
||||
tag?: string
|
||||
isFavorite?: boolean
|
||||
isPinned?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const FILTER_REGEX = /(tag|type):(\S*)/gi
|
||||
const IS_FAVORITE_REGEX = /\bis:favorite\b/gi
|
||||
const IS_PINNED_REGEX = /\bis:pinned\b/gi
|
||||
|
||||
export function parseQuery(query: string): QueryAST {
|
||||
if (!query || typeof query !== 'string') {
|
||||
return { text: '', filters: {} }
|
||||
}
|
||||
|
||||
const filters: QueryAST['filters'] = {}
|
||||
|
||||
// Extract type: filter
|
||||
const typeMatches = query.matchAll(/(?:^|\s)(type):(\S*)/gi)
|
||||
for (const match of typeMatches) {
|
||||
const value = match[2]
|
||||
if (value) {
|
||||
filters.type = value
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tag: filter
|
||||
const tagMatches = query.matchAll(/(?:^|\s)(tag):(\S*)/gi)
|
||||
for (const match of tagMatches) {
|
||||
const value = match[2]
|
||||
if (value) {
|
||||
filters.tag = value
|
||||
}
|
||||
}
|
||||
|
||||
// Check for is:favorite (case insensitive filter name)
|
||||
const isFavoriteMatches = query.match(/\bis:favorite\b/i)
|
||||
if (isFavoriteMatches) {
|
||||
filters.isFavorite = true
|
||||
}
|
||||
|
||||
// Check for is:pinned (case insensitive filter name)
|
||||
const isPinnedMatches = query.match(/\bis:pinned\b/i)
|
||||
if (isPinnedMatches) {
|
||||
filters.isPinned = true
|
||||
}
|
||||
|
||||
// Remove all filter patterns from the query to get remaining text
|
||||
let text = query
|
||||
.replace(FILTER_REGEX, '')
|
||||
.replace(IS_FAVORITE_REGEX, '')
|
||||
.replace(IS_PINNED_REGEX, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
|
||||
return { text, filters }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const TYPE_PREFIXES: Record<string, NoteType> = {
|
||||
'rec:': 'recipe',
|
||||
'proc:': 'procedure',
|
||||
'inv:': 'inventory',
|
||||
'web:': 'note',
|
||||
}
|
||||
|
||||
const TAG_REGEX = /#([a-z0-9]+)/g
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createBackupSnapshot } from '@/lib/backup'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
|
||||
interface RestoreResult {
|
||||
success: boolean
|
||||
restored: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export async function restoreBackup(
|
||||
backup: RecallBackup,
|
||||
mode: 'merge' | 'replace'
|
||||
): Promise<RestoreResult> {
|
||||
const errors: string[] = []
|
||||
|
||||
try {
|
||||
if (mode === 'replace') {
|
||||
await createBackupSnapshot('pre-destructive')
|
||||
}
|
||||
|
||||
const notes = backup.data.notes as Array<{
|
||||
id?: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite?: boolean
|
||||
isPinned?: boolean
|
||||
tags?: string[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}>
|
||||
|
||||
let restored = 0
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const note of notes) {
|
||||
const parseDate = (dateStr: string | undefined): Date => {
|
||||
if (!dateStr) return new Date()
|
||||
const parsed = new Date(dateStr)
|
||||
return isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
}
|
||||
|
||||
const createdAt = parseDate(note.createdAt)
|
||||
const updatedAt = parseDate(note.updatedAt)
|
||||
|
||||
if (mode === 'replace') {
|
||||
if (note.id) {
|
||||
await tx.note.upsert({
|
||||
where: { id: note.id },
|
||||
create: {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
update: {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
}
|
||||
restored++
|
||||
} else {
|
||||
if (note.id) {
|
||||
const existing = await tx.note.findUnique({ where: { id: note.id } })
|
||||
if (existing) {
|
||||
await tx.note.update({
|
||||
where: { id: note.id },
|
||||
data: { title: note.title, content: note.content, updatedAt },
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: note.id } })
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const existingByTitle = await tx.note.findFirst({ where: { title: note.title } })
|
||||
if (existingByTitle) {
|
||||
await tx.note.update({
|
||||
where: { id: existingByTitle.id },
|
||||
data: { content: note.content, updatedAt },
|
||||
})
|
||||
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
|
||||
} else {
|
||||
await tx.note.create({
|
||||
data: {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
|
||||
isFavorite: note.isFavorite ?? false,
|
||||
isPinned: note.isPinned ?? false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
creationSource: 'import',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
restored++
|
||||
}
|
||||
|
||||
const noteId = note.id
|
||||
? (await tx.note.findUnique({ where: { id: note.id } }))?.id
|
||||
: (await tx.note.findFirst({ where: { title: note.title } }))?.id
|
||||
|
||||
if (noteId && note.tags && note.tags.length > 0) {
|
||||
for (const tagName of note.tags) {
|
||||
const tag = await tx.tag.upsert({
|
||||
where: { name: tagName },
|
||||
create: { name: tagName },
|
||||
update: {},
|
||||
})
|
||||
await tx.noteTag.create({
|
||||
data: { noteId, tagId: tag.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (noteId) {
|
||||
const noteRecord = await tx.note.findUnique({ where: { id: noteId } })
|
||||
if (noteRecord) {
|
||||
await syncBacklinks(noteId, noteRecord.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, restored, errors: [] }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
return { success: false, restored: 0, errors: [message] }
|
||||
}
|
||||
}
|
||||
+28
-8
@@ -1,10 +1,13 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import stringSimilarity from 'string-similarity'
|
||||
import { getUsageStats } from '@/lib/usage'
|
||||
import { parseQuery, QueryAST } from '@/lib/query-parser'
|
||||
|
||||
export interface SearchFilters {
|
||||
type?: string
|
||||
tag?: string
|
||||
isFavorite?: boolean
|
||||
isPinned?: boolean
|
||||
}
|
||||
|
||||
export interface ScoredNote {
|
||||
@@ -133,10 +136,19 @@ async function scoreNote(
|
||||
}
|
||||
|
||||
export async function noteQuery(
|
||||
query: string,
|
||||
queryOrAST: string | QueryAST,
|
||||
filters: SearchFilters = {}
|
||||
): Promise<ScoredNote[]> {
|
||||
const queryLower = query.toLowerCase().trim()
|
||||
// Support both old signature (query string + filters) and new (QueryAST)
|
||||
let queryAST: QueryAST
|
||||
if (typeof queryOrAST === 'string') {
|
||||
queryAST = { text: queryOrAST, filters }
|
||||
} else {
|
||||
queryAST = queryOrAST
|
||||
}
|
||||
|
||||
const { text: queryText, filters: appliedFilters } = queryAST
|
||||
const queryLower = queryText.toLowerCase().trim()
|
||||
|
||||
const allNotes = await prisma.note.findMany({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
@@ -145,12 +157,14 @@ export async function noteQuery(
|
||||
const scored: ScoredNote[] = []
|
||||
|
||||
for (const note of allNotes) {
|
||||
if (filters.type && note.type !== filters.type) continue
|
||||
|
||||
if (filters.tag) {
|
||||
const hasTag = note.tags.some(t => t.tag.name === filters.tag)
|
||||
// Apply filters from AST BEFORE scoring
|
||||
if (appliedFilters.type && note.type !== appliedFilters.type) continue
|
||||
if (appliedFilters.tag) {
|
||||
const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag)
|
||||
if (!hasTag) continue
|
||||
}
|
||||
if (appliedFilters.isFavorite && note.isFavorite !== true) continue
|
||||
if (appliedFilters.isPinned && note.isPinned !== true) continue
|
||||
|
||||
const titleLower = note.title.toLowerCase()
|
||||
const contentLower = note.content.toLowerCase()
|
||||
@@ -181,7 +195,7 @@ export async function noteQuery(
|
||||
|
||||
const highlight = highlightMatches(
|
||||
exactTitleMatch ? note.title + ' ' + note.content : note.content,
|
||||
query
|
||||
queryText
|
||||
)
|
||||
|
||||
scored.push({
|
||||
@@ -202,5 +216,11 @@ export async function searchNotes(
|
||||
query: string,
|
||||
filters: SearchFilters = {}
|
||||
): Promise<ScoredNote[]> {
|
||||
return noteQuery(query, filters)
|
||||
const queryAST: QueryAST = {
|
||||
text: query,
|
||||
filters: {
|
||||
...filters,
|
||||
},
|
||||
}
|
||||
return noteQuery(queryAST)
|
||||
}
|
||||
|
||||
+8
-1
@@ -176,12 +176,18 @@ export async function getCoUsedNotes(
|
||||
updatedAt: { gte: since },
|
||||
},
|
||||
orderBy: { weight: 'desc' },
|
||||
take: limit,
|
||||
take: limit * 2, // Fetch more to account for duplicates we'll filter
|
||||
})
|
||||
|
||||
// Deduplicate by relatedNoteId - only keep highest weight per note
|
||||
const seenIds = new Set<string>()
|
||||
const result: { noteId: string; title: string; type: string; weight: number }[] = []
|
||||
|
||||
for (const cu of coUsages) {
|
||||
const relatedNoteId = cu.fromNoteId === noteId ? cu.toNoteId : cu.fromNoteId
|
||||
if (seenIds.has(relatedNoteId)) continue
|
||||
seenIds.add(relatedNoteId)
|
||||
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: relatedNoteId },
|
||||
select: { id: true, title: true, type: true },
|
||||
@@ -194,6 +200,7 @@ export async function getCoUsedNotes(
|
||||
weight: cu.weight,
|
||||
})
|
||||
}
|
||||
if (result.length >= limit) break
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
|
||||
+13
-4
@@ -4,8 +4,9 @@ export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe',
|
||||
|
||||
export const CreationSourceEnum = z.enum(['form', 'quick', 'import'])
|
||||
|
||||
export const noteSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
// Base note schema without transform - for use with partial()
|
||||
const baseNoteSchema = z.object({
|
||||
id: z.string().optional().nullable(),
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
type: NoteTypeEnum.default('note'),
|
||||
@@ -15,10 +16,18 @@ export const noteSchema = z.object({
|
||||
creationSource: CreationSourceEnum.default('form'),
|
||||
})
|
||||
|
||||
export const updateNoteSchema = noteSchema.partial().extend({
|
||||
id: z.string(),
|
||||
// Transform to remove id if null/undefined (for creation)
|
||||
export const noteSchema = baseNoteSchema.transform(data => {
|
||||
if (data.id == null) {
|
||||
const { id, ...rest } = data
|
||||
return rest
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
// For update, use partial of base schema with optional id (id comes from URL path, not body)
|
||||
export const updateNoteSchema = baseNoteSchema.partial()
|
||||
|
||||
export const searchSchema = z.object({
|
||||
q: z.string().optional(),
|
||||
type: NoteTypeEnum.optional(),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NotFoundError } from '@/lib/errors'
|
||||
|
||||
export async function createVersion(noteId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }> {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new NotFoundError('Note')
|
||||
}
|
||||
|
||||
const version = await prisma.noteVersion.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
},
|
||||
})
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
export async function getVersions(noteId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }[]> {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new NotFoundError('Note')
|
||||
}
|
||||
|
||||
return prisma.noteVersion.findMany({
|
||||
where: { noteId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
noteId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getVersion(versionId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }> {
|
||||
const version = await prisma.noteVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
})
|
||||
|
||||
if (!version) {
|
||||
throw new NotFoundError('Version')
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
export async function restoreVersion(noteId: string, versionId: string): Promise<{ id: string; title: string; content: string; updatedAt: Date }> {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new NotFoundError('Note')
|
||||
}
|
||||
|
||||
const version = await prisma.noteVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
})
|
||||
|
||||
if (!version) {
|
||||
throw new NotFoundError('Version')
|
||||
}
|
||||
|
||||
if (version.noteId !== noteId) {
|
||||
throw new NotFoundError('Version')
|
||||
}
|
||||
|
||||
const updatedNote = await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
title: version.title,
|
||||
content: version.content,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return updatedNote
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const WORK_MODE_KEY = 'recall_work_mode'
|
||||
|
||||
export function getWorkMode(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return localStorage.getItem(WORK_MODE_KEY) === 'true'
|
||||
}
|
||||
|
||||
export function setWorkMode(enabled: boolean): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(WORK_MODE_KEY, String(enabled))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type BackupSource = 'automatic' | 'manual' | 'pre-destructive'
|
||||
|
||||
export interface BackupMetadata {
|
||||
noteCount: number
|
||||
tagCount: number
|
||||
versionCount?: number
|
||||
}
|
||||
|
||||
export interface RecallBackup {
|
||||
id: string
|
||||
schemaVersion: string
|
||||
createdAt: string
|
||||
source: BackupSource
|
||||
metadata: BackupMetadata
|
||||
data: {
|
||||
notes: unknown[]
|
||||
tags: unknown[]
|
||||
backlinks?: unknown[]
|
||||
noteVersions?: unknown[]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user