Compare commits

..

75 Commits

Author SHA1 Message Date
darroyo ebeb4574ad Merge pull request 'd' (#23) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #23
2026-03-27 13:56:05 +00:00
darroyo 7fce46bff2 d 2026-03-27 10:55:43 -03:00
darroyo 9380273cba Merge pull request 'j' (#22) from develop into main
Reviewed-on: #22
2026-03-27 13:52:14 +00:00
darroyo d398226723 j 2026-03-27 10:09:44 -03:00
darroyo e225f2ec2c Merge pull request 'develop' (#21) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #21
2026-03-25 02:08:04 +00:00
darroyo 85bbe7b61f fix: Increase memory limit to 512MB for Prisma db push 2026-03-24 23:07:10 -03:00
darroyo de1de1d3bc fix: Run prisma db push but continue if OOM and DB exists 2026-03-24 23:04:26 -03:00
darroyo ef8a7858b2 fix: Only run prisma db push if database doesn't exist to avoid OOM 2026-03-24 23:02:14 -03:00
darroyo 11615ee4cb Merge pull request 'fix: Make entrypoint more robust to handle volume permission issues' (#20) from develop into main
Reviewed-on: #20
2026-03-24 18:22:31 +00:00
darroyo dc5089c011 fix: Make entrypoint more robust to handle volume permission issues 2026-03-24 15:19:21 -03:00
darroyo aaa04e1d26 Merge pull request 'prueba' (#19) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #19
2026-03-24 18:09:18 +00:00
darroyo 0e22acc077 prueba 2026-03-24 15:08:30 -03:00
darroyo 386dd2d6a2 Merge pull request 'develop' (#18) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #18
2026-03-24 17:48:21 +00:00
darroyo 85e6304bae fix: Use absolute path for DATABASE_URL to avoid working directory issues 2026-03-24 14:46:15 -03:00
darroyo cf6b5785d7 fix: Change to /app directory before running prisma and node 2026-03-24 13:13:09 -03:00
darroyo cf2f3d0255 Merge pull request 'fix: Run container as root to avoid volume permission issues' (#17) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #17
2026-03-24 16:04:05 +00:00
darroyo 66db3de33d fix: Run container as root to avoid volume permission issues 2026-03-24 12:59:09 -03:00
darroyo bfcb7d7fd3 Merge pull request 'fix: Add --skip-generate to avoid memory issues in Prisma 5' (#16) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #16
2026-03-24 15:40:05 +00:00
darroyo a0451a2084 fix: Add --skip-generate to avoid memory issues in Prisma 5 2026-03-24 12:39:20 -03:00
darroyo d003d3c1c7 Merge pull request 'fix: Run prisma db push as root, then switch to nextjs user for app' (#15) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #15
2026-03-24 15:10:27 +00:00
darroyo d91ff3f07c fix: Run prisma db push as root, then switch to nextjs user for app 2026-03-24 12:06:58 -03:00
darroyo 786dc4c825 Merge pull request 'fix: Ensure data directory exists and has proper permissions before prisma db push' (#14) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #14
2026-03-24 14:46:41 +00:00
darroyo d5a6d0bfd7 fix: Ensure data directory exists and has proper permissions before prisma db push 2026-03-24 11:44:39 -03:00
darroyo ef8c4bafe6 Merge pull request 'develop' (#13) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #13
2026-03-24 13:25:26 +00:00
darroyo 97082b0233 fix: Copy @prisma modules needed for prisma CLI 2026-03-24 10:21:05 -03:00
darroyo 93b6c0b17d fix: Simplify entrypoint - remove npm cache directories 2026-03-24 10:03:51 -03:00
darroyo f038f37001 fix: Use local Prisma 5 from node_modules instead of downloading Prisma 7 2026-03-24 09:33:31 -03:00
darroyo e5e976caff fix: Remove --skip-generate flag not supported in Prisma 7 2026-03-24 09:14:59 -03:00
darroyo e5b6ce3bdc fix: Use npx with proper HOME and cache directories for prisma 2026-03-24 09:08:24 -03:00
darroyo 5fb1c5d0b0 fix: Copy .bin folder and use local prisma in entrypoint 2026-03-24 09:04:26 -03:00
darroyo f6218283f1 fix: Create home directory for nextjs user to fix npm permissions 2026-03-24 09:02:54 -03:00
darroyo 58311ba2df Merge pull request 'develop' (#12) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #12
2026-03-24 02:55:18 +00:00
darroyo 3ff5e6b031 fix: Improve entrypoint script with better database initialization 2026-03-23 23:54:32 -03:00
darroyo 1916bc33e4 fix: Add entrypoint script to initialize database on container start 2026-03-23 23:53:18 -03:00
darroyo 410f72caf0 Merge pull request 'fix: Force dynamic rendering on home page to avoid Prisma build error' (#11) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #11
2026-03-24 02:48:14 +00:00
darroyo 9847b4c5cc fix: Force dynamic rendering on home page to avoid Prisma build error 2026-03-23 23:47:28 -03:00
darroyo f0e2c60620 Merge pull request 'fix: Add DATABASE_URL env var in builder stage for Prisma' (#10) from develop into main
Personal/recall/pipeline/head This commit looks good
Reviewed-on: #10
2026-03-24 02:32:58 +00:00
darroyo fc53062eb1 fix: Add DATABASE_URL env var in builder stage for Prisma 2026-03-23 23:32:19 -03:00
darroyo ae0502ce16 Merge pull request 'fix: Remove libc6-compat (Alpine package) from Debian-based Dockerfile' (#9) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #9
2026-03-24 02:17:54 +00:00
darroyo 28366151cf fix: Remove libc6-compat (Alpine package) from Debian-based Dockerfile 2026-03-23 23:17:27 -03:00
darroyo 3378d5c523 Merge pull request 'fix: Change Alpine to Debian-based image for Prisma compatibility' (#8) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #8
2026-03-24 02:14:32 +00:00
darroyo c8674dd56f fix: Change Alpine to Debian-based image for Prisma compatibility
Alpine Linux doesn't have libssl1.1 which Prisma needs.
Using node:20-slim instead with proper OpenSSL libraries.
2026-03-23 23:13:45 -03:00
darroyo b7c4df42e0 Merge pull request 'fix: Exclude id field from noteData in update route' (#7) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #7
2026-03-24 02:09:07 +00:00
darroyo 3a523aafde fix: Exclude id field from noteData in update route 2026-03-23 23:08:17 -03:00
darroyo a8ef158fd7 Merge pull request 'fix: TypeScript error in import route - cast item to access id' (#6) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #6
2026-03-24 02:05:45 +00:00
darroyo 9ca98d96db fix: TypeScript error in import route - cast item to access id 2026-03-23 23:05:03 -03:00
darroyo 52e45a663e Merge pull request 'j' (#5) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #5
2026-03-24 01:49:51 +00:00
darroyo bd1a5bc21c j 2026-03-23 22:49:06 -03:00
darroyo 0c3ffff81c Merge pull request 'a' (#4) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #4
2026-03-24 01:44:13 +00:00
darroyo 2f9233ad41 a 2026-03-23 22:43:43 -03:00
darroyo 98c60c0d27 Merge pull request 'a' (#3) from develop into main
Reviewed-on: #3
2026-03-24 01:41:47 +00:00
darroyo 76c98ecdbe a 2026-03-23 22:41:13 -03:00
darroyo b20b33bf9b Merge pull request 'cambio jenkins' (#2) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #2
2026-03-24 01:39:28 +00:00
darroyo b4a5abb699 cambio jenkins 2026-03-23 22:38:46 -03:00
darroyo ad21d7fb6a Merge pull request 'develop' (#1) from develop into main
Personal/recall/pipeline/head There was a failure building this commit
Reviewed-on: #1
2026-03-24 01:31:16 +00:00
darroyo ece8163d15 chore: Various improvements and CI setup
- Add Jenkinsfile for CI/CD pipeline
- Fix keyboard shortcut '?' handling for help dialog
- Update note form and connections components
- Add work mode toggle improvements
- Update navigation history and usage tracking
- Improve validators
- Add session summaries
2026-03-23 22:25:36 -03:00
darroyo e0433f8e57 docs: Add Docker deployment documentation with data folder
- Update docker-compose to use ./data folder for SQLite database
- Add Docker section to README with setup instructions
2026-03-23 22:13:48 -03:00
darroyo 13ee0f9922 feat: Add Dockerfile and docker-compose for deployment
- Multi-stage Dockerfile for optimized image size
- docker-compose.yml for easy local deployment
- Enable standalone output mode for Docker support
2026-03-23 22:04:26 -03:00
darroyo 0a96638681 fix: Bookmarklet improvements and header layout fixes
- Fix bookmarklet URL to use absolute path with window.location.origin
- Change capture prefix from 'rec:' to 'web:' for web captures
- Add BookmarkletInstructions to header and preferences panel
- Redesign QuickAdd as dropdown popup (no header overflow)
- Move capture button and work mode to mobile menu
- Fix isOpen bug in BookmarkletInstructions dialog
2026-03-23 00:03:45 -03:00
darroyo 33a4705f95 feat: MVP-4 P2 - Preload notes on hover
- Add prefetchNote function in useNoteListKeyboard hook
- Prefetch note pages when navigating with arrow keys
- Add hover prefetch in NoteCard using router.prefetch
- Update KeyboardNavigableNoteList to use prefetchNote
2026-03-22 20:22:57 -03:00
darroyo e66a678160 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
- Ticket 10: Navegación completa de listas por teclado (↑↓ Enter E F P)
- Ticket 13: Historial de navegación contextual con recent-context-list
- Ticket 17: Exportación mejorada a Markdown con frontmatter
- Ticket 18: Exportación HTML simple y legible
- Ticket 19: Importador Markdown mejorado con frontmatter, tags, wiki links
- Ticket 20: Importador Obsidian-compatible (wiki links, #tags inline)
- Ticket 21: Centro de respaldo y portabilidad en Settings
- Ticket 22: Configuración visible de feature flags
- Ticket 24: Tests de command palette y captura externa
- Ticket 25: Harden de validaciones y límites (50MB backup, 10K notas, etc)
2026-03-22 19:39:55 -03:00
darroyo 8d56f34d68 feat: MVP-5 Sprint 4 - External Capture via Bookmarklet
- Add bookmarklet for capturing web content from any page
- Add capture confirmation page with edit before save
- Add secure /api/capture endpoint with rate limiting
- Add bookmarklet instructions component with drag-and-drop
2026-03-22 18:35:53 -03:00
darroyo a40ab18b1b feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts
- Enhance note-connections with collapsible sections and recent versions
- Add work mode toggle in header for focused work
- Add draft autosave with 7-day TTL and recovery banner
- Save drafts on changes for new notes
2026-03-22 18:28:14 -03:00
darroyo cde0a143a5 feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
- Add Command Palette (Ctrl+K / Cmd+K) with search and navigation
- Add global keyboard shortcuts: g h (dashboard), g n (notes), n (new note)
- Add keyboard shortcuts help dialog (?)
- Add shortcuts provider component
- Shortcuts ignore inputs/textareas for proper UX
2026-03-22 18:22:28 -03:00
darroyo 8c80a12b81 feat: MVP-5 Sprint 1 - Backup/Restore system
- Add backup types and RecallBackup format
- Create backup snapshot engine (createBackupSnapshot)
- Add IndexedDB storage for local backups
- Implement retention policy (max 10, 30-day cleanup)
- Add backup validation and restore logic (merge/replace modes)
- Add backup restore UI dialog with preview and confirmation
- Add unsaved changes guard hook
- Integrate backups section in Settings
- Add backup endpoint to export-import API
2026-03-22 18:16:36 -03:00
darroyo 544decf4ac docs: add new project summary 2026-03-22 2026-03-22 17:51:05 -03:00
darroyo 7c5fba5f12 docs: update project summary with MVP-4 features
- Add MVP-4 sprints 1-4 features
- Update structure with query-parser.ts, versions.ts
- Add NoteVersion model and versions API
- Update test count to 211
2026-03-22 17:49:49 -03:00
darroyo a67442e9ed test: MVP-4 Sprint 4 - Version history tests
- Add 11 tests for versions.ts (create, get, restore, edge cases)
- Add noteVersion mock to api.integration.test.ts
2026-03-22 17:45:53 -03:00
darroyo 9ed7d8acec feat: MVP-4 Sprint 3 - Version history
- Add NoteVersion model for storing note snapshots
- Add versions API (list, create, get, restore)
- Add version history UI dialog in note detail page
- Create version snapshot before each note update
2026-03-22 17:42:47 -03:00
darroyo e57927e37d feat: MVP-4 Sprint 2 - Co-usage sidebar and search cache
- Add co-usage display to NoteConnections (notas vistas juntas)
- Add search results cache with 50 entry limit and FIFO eviction
- Improve contextual sidebar with usage-based suggestions
2026-03-22 17:37:40 -03:00
darroyo 9af25927b7 feat: MVP-4 Sprint 1 - Query parser, real-time search, keyboard navigation
- Add query-parser.ts with AST for type:, tag:, is:favorite, is:pinned filters
- Integrate parser into search API
- Add real-time search with 300ms debounce in search-bar
- Add keyboard navigation (↑↓ Enter ESC) for Spotlight-like UX
- Add dropdown results display with loading state
- Add 22 tests for query parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:33:17 -03:00
darroyo d5c418c84f fix: responsive header with hamburger menu for mobile
- Desktop: single row with logo, nav links, QuickAdd, New note button
- Mobile: logo + QuickAdd + hamburger icon → dropdown menu
- Improved QuickAdd sizing for small screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:10:25 -03:00
darroyo 6cc5f3793a docs: add project summary 2026-03-22
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:52:53 -03:00
darroyo ff7223bfea feat: MVP-3 Sprint 4 - Co-usage, metrics, centrality, creation source, feature flags
- Add NoteCoUsage model and co-usage tracking when viewing notes
- Add creationSource field to notes (form/quick/import)
- Add dashboard metrics API (/api/metrics)
- Add centrality calculation (/api/centrality)
- Add feature flags system for toggling features
- Add multiline QuickAdd with smart paste type detection
- Add internal link suggestions while editing notes
- Add type inference for automatic note type detection
- Add comprehensive tests for type-inference and link-suggestions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:50:40 -03:00
darroyo ef0aebf510 feat: MVP-3 Sprint 2 - Auto tag suggestions, note connections panel
- Add auto tag suggestions to note form with debounced API calls
- Rename related notes to "También podrías necesitar" in detail view
- Create NoteConnections component showing backlinks, outgoing links, and related notes
- Add tests for backlinks module and tags/suggest API endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:20:39 -03:00
98 changed files with 8795 additions and 156 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
"Bash(node:*)", "Bash(node:*)",
"Bash(curl:*)", "Bash(curl:*)",
"Bash(npx tsc:*)", "Bash(npx tsc:*)",
"Bash(npm list:*)" "Bash(npm list:*)",
"Bash(npx jest:*)"
] ]
} }
} }
+5 -1
View File
@@ -26,4 +26,8 @@ Build, test, and lint commands will be documented here once the project structur
- Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen. - Cuando te pida realizar un resumen del proyecto debes crear un archivo con el siguiente formato de nombre yyyy-mm-dd-HHMM-resumen.md en la carpeta resumen.
- Si no existe crea una carpeta resumen en la raiz del proyecto. - Si no existe crea una carpeta resumen en la raiz del proyecto.
- Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento. - Crearemos resumenes de forma incremental y el primero debe contener todo lo existente hasta el momento.
- El archivo debe ser creado con el horario local.
## Commit
- evitar agregar lo siguiente: Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
+64
View File
@@ -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
View File
@@ -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."
}
}
}
+173 -8
View File
@@ -1,12 +1,12 @@
# recall # recall
Sistema de notas personal con captura rápida y búsqueda inteligente. Sistema de notas personal con captura rápida, búsqueda inteligente y conexiones entre notas.
## Uso ## Uso
### Quick Add (Captura Rápida) ### Quick Add (Captura Rápida)
Crea notas al instante con el shortcut `Ctrl+N`. Crea notas al instante con el shortcut `Ctrl+N` o desde el botón de captura rápida en el header.
Sintaxis: Sintaxis:
``` ```
@@ -41,12 +41,31 @@ rec: Pasta carbonara #cocina #italiana
| `inventory` | Inventario | Item, Cantidad, Ubicación | | `inventory` | Inventario | Item, Cantidad, Ubicación |
| `note` | Nota libre | Contenido | | `note` | Nota libre | Contenido |
### Búsqueda ### Dashboard (Página Principal)
- Búsqueda por título y contenido El dashboard muestra diferentes secciones según tu actividad:
- Búsqueda fuzzy (tolerante a errores)
- Filtros por tipo y tags - **Recientes** - Últimas notas modificadas
- Favoritos y notas pinned influyen en el ranking - **Más usadas** - Notas que consultas frecuentemente
- **Comandos recientes** - Notas de tipo comando
- **Snippets recientes** - Notas de código
- **Según tu actividad** - Notas relacionadas con tu historial de navegación
### Modo Trabajo
El botón de **Modo Trabajo** en el header (icono de monitor/ojo) es un toggle que indica cuando estás en modo de trabajo activo. Cuando está activado, el sistema:
- Puede influir en el ranking de búsqueda priorizando notas de trabajo
- Refleja visualmente que estás enfocado en una tarea
Se puede activar/desactivar desde el header o desde Configuración.
### Command Palette
Accede rápidamente a cualquier sección o acción con `Ctrl+K` (Windows) o `Cmd+K` (Mac):
- Navegación rápida a cualquier página
- Crear nueva nota
- Acceso directo a Configuración
### Links entre Notas ### Links entre Notas
@@ -56,7 +75,73 @@ Crea links a otras notas usando `[[nombre-de-nota]]`:
Ver también: [[Configuración de Docker]] Ver también: [[Configuración de Docker]]
``` ```
Los backlinks se muestran automáticamente en la nota referenciada. Los **backlinks** (notas que referencian la nota actual) se muestran automáticamente en la vista de detalle.
### Conexiones de Notas
Cada nota muestra diferentes tipos de conexiones:
- **Notas relacionadas** - Basadas en tags compartidos, tipo y contenido similar
- **Backlinks** - Notas que linkean a esta nota
- **Outgoing links** - Links salientes de esta nota hacia otras
- **Co-usadas** - Notas que sueles ver juntas
### Búsqueda
- Búsqueda por título y contenido
- Búsqueda fuzzy (tolerante a errores)
- Filtros por tipo y tags
- Favoritos y notas pinned influyen en el ranking
### Captura Externa (Bookmarklet)
Desde Configuración > Capturar web, puedes crear un marcador que permite capturar contenido de cualquier página web:
1. Arrastra el botón "Capturar a Recall" a tu barra de marcadores
2. Cuando estés en una página web, haz clic en el marcador
3. Confirma y guarda directamente en tus notas
El marcador captura: título de la página, URL y texto seleccionado.
### Drafts (Borradores)
El sistema guarda automáticamente borradores de tus notas mientras escribes. Si cierras accidentalmente la página, al volver se te ofrecer recuperar el borrador.
### Historial de Versiones
Cada nota mantiene un historial de versiones. Accede desde el botón de historial en la vista de detalle para ver y restaurar versiones anteriores.
### Backups y Restauración
En Configuración > Backups:
- **Backup automático** - Se crean backups al cerrar o cambiar de nota (configurable)
- **Retención** - Los backups se mantienen por el período indicado (por defecto 30 días)
- **Backup manual** - Exporta en cualquier momento
### Exportar e Importar
Desde Configuración > Exportar:
- **JSON** - Backup completo (recomendado para restaurar)
- **Markdown** - Notas en formato MD (ideal para compartir)
- **HTML** - Notas en formato HTML (para visualización)
**Importar:**
- JSON - Restauración de backup
- Markdown - Importación de archivos MD
### Atajos de Teclado
| Atajo | Acción |
|-------|--------|
| `Ctrl+N` | Nueva nota rápida |
| `Ctrl+K` / `Cmd+K` | Command Palette |
| `n` | Nueva nota (desde dashboard) |
| `g h` | Ir al Dashboard |
| `g n` | Ir a Notas |
| `/` | Enfocar búsqueda |
| `?` | Mostrar atajos de teclado |
## Development ## Development
@@ -66,6 +151,48 @@ npx prisma db push
npm run dev npm run dev
``` ```
## Docker
### Requisitos
- Docker
- Docker Compose
### Instalación con Docker
1. **Crear la carpeta para la base de datos:**
```bash
mkdir -p data
```
2. **Iniciar la aplicación:**
```bash
docker-compose up -d
```
La aplicación estará disponible en `http://localhost:3000`
### Datos
- La base de datos SQLite se guarda en `./data/dev.db`
- Los datos persisten entre reinicios
- Para hacer backup, copia la carpeta `data/`
### Comandos útiles
```bash
# Ver logs
docker-compose logs -f
# Reiniciar
docker-compose restart
# Detener
docker-compose down
# Reconstruir (después de cambios)
docker-compose up -d --build
```
## API ## API
### Quick Add ### Quick Add
@@ -87,3 +214,41 @@ GET /api/tags # Listar todos
GET /api/tags?q=python # Filtrar GET /api/tags?q=python # Filtrar
GET /api/tags/suggest?title=...&content=... # Sugerencias GET /api/tags/suggest?title=...&content=... # Sugerencias
``` ```
## Estructura del Proyecto
```
src/
├── app/
│ ├── page.tsx # Dashboard
│ ├── notes/
│ │ ├── page.tsx # Lista de notas
│ │ └── [id]/page.tsx # Detalle de nota
│ ├── new/page.tsx # Crear nota
│ ├── edit/[id]/page.tsx # Editar nota
│ ├── settings/page.tsx # Configuración
│ ├── capture/page.tsx # Captura externa
│ └── api/ # Rutas API
├── components/
│ ├── dashboard.tsx
│ ├── note-list.tsx
│ ├── command-palette.tsx
│ ├── work-mode-toggle.tsx
│ ├── quick-add.tsx
│ └── ...
├── lib/
│ ├── work-mode.ts
│ ├── search.ts
│ ├── related.ts
│ ├── backlinks.ts
│ ├── usage.ts
│ ├── drafts.ts
│ ├── backup.ts
│ └── ...
└── types/
└── note.ts
```
nueva prueba
+63
View File
@@ -38,6 +38,11 @@ const mockPrisma = {
create: jest.fn(), create: jest.fn(),
createMany: jest.fn(), createMany: jest.fn(),
}, },
noteVersion: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
},
$transaction: jest.fn((callback) => callback(mockPrisma)), $transaction: jest.fn((callback) => callback(mockPrisma)),
} }
@@ -531,6 +536,64 @@ describe('API Integration Tests', () => {
}) })
}) })
// ============================================
// GET /api/tags/suggest - Suggest tags based on content
// ============================================
describe('GET /api/tags/suggest', () => {
it('suggests tags based on title keywords', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=Docker%20deployment&content=')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data)).toBe(true)
})
it('suggests tags based on content keywords', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=&content=Docker%20and%20Kubernetes%20deployment')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data))
})
it('combines title and content for suggestions', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=Python%20script&content=SQL%20database%20query')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
it('returns empty array for generic content', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest?title=Note&content=content')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(Array.isArray(data.data)).toBe(true)
})
it('handles empty parameters gracefully', async () => {
const { GET } = await import('@/app/api/tags/suggest/route')
const request = new NextRequest('http://localhost/api/tags/suggest')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
})
// ============================================ // ============================================
// GET /api/search - Search notes // GET /api/search - Search notes
// ============================================ // ============================================
+215
View File
@@ -0,0 +1,215 @@
import { parseBacklinks, syncBacklinks, getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks'
// Mock prisma before importing backlinks module
jest.mock('@/lib/prisma', () => ({
prisma: {
backlink: {
deleteMany: jest.fn(),
createMany: jest.fn(),
findMany: jest.fn(),
},
note: {
findMany: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
describe('backlinks.ts', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('parseBacklinks', () => {
it('extracts single wiki-link', () => {
const content = 'This is about [[Docker Commands]]'
const result = parseBacklinks(content)
expect(result).toEqual(['Docker Commands'])
})
it('extracts multiple wiki-links', () => {
const content = 'See [[Docker Commands]] and [[Git Commands]] for reference'
const result = parseBacklinks(content)
expect(result).toContain('Docker Commands')
expect(result).toContain('Git Commands')
})
it('extracts wiki-links with extra whitespace', () => {
const content = 'Check [[ Docker Commands ]] for details'
const result = parseBacklinks(content)
expect(result).toEqual(['Docker Commands'])
})
it('returns empty array when no wiki-links', () => {
const content = 'This is a plain note without any links'
const result = parseBacklinks(content)
expect(result).toEqual([])
})
it('handles wiki-links at start of content', () => {
const content = '[[First Note]] is the beginning'
const result = parseBacklinks(content)
expect(result).toEqual(['First Note'])
})
it('handles wiki-links at end of content', () => {
const content = 'The solution is [[Last Note]]'
const result = parseBacklinks(content)
expect(result).toEqual(['Last Note'])
})
it('deduplicates repeated wiki-links', () => {
const content = 'See [[Docker Commands]] and again [[Docker Commands]]'
const result = parseBacklinks(content)
expect(result).toEqual(['Docker Commands'])
})
it('handles nested brackets gracefully', () => {
const content = 'Check [[This]] and [[That]]'
const result = parseBacklinks(content)
expect(result).toHaveLength(2)
})
})
describe('syncBacklinks', () => {
it('deletes existing backlinks before creating new ones', async () => {
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 2 })
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 0 })
await syncBacklinks('note-1', 'No links here')
expect(prisma.backlink.deleteMany).toHaveBeenCalledWith({
where: { sourceNoteId: 'note-1' },
})
})
it('creates backlinks for valid linked notes', async () => {
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: 'note-2', title: 'Docker Commands' },
{ id: 'note-3', title: 'Git Commands' },
])
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 2 })
await syncBacklinks('note-1', 'See [[Docker Commands]] and [[Git Commands]]')
expect(prisma.backlink.createMany).toHaveBeenCalledWith({
data: [
{ sourceNoteId: 'note-1', targetNoteId: 'note-2' },
{ sourceNoteId: 'note-1', targetNoteId: 'note-3' },
],
})
})
it('does not create backlink to self', async () => {
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: 'note-1', title: 'Docker Commands' },
])
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 0 })
await syncBacklinks('note-1', 'This is [[Docker Commands]]')
// Should not create a backlink to itself
expect(prisma.backlink.createMany).not.toHaveBeenCalled()
})
it('handles case-insensitive title matching', async () => {
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: 'note-2', title: 'Docker Commands' },
])
;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 1 })
await syncBacklinks('note-1', 'See [[docker commands]]')
expect(prisma.backlink.createMany).toHaveBeenCalled()
})
it('does nothing when no wiki-links in content', async () => {
;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 2 })
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
await syncBacklinks('note-1', 'Plain content without links')
expect(prisma.backlink.createMany).not.toHaveBeenCalled()
})
})
describe('getBacklinksForNote', () => {
it('returns backlinks with source note info', async () => {
const mockBacklinks = [
{
id: 'bl-1',
sourceNoteId: 'note-2',
targetNoteId: 'note-1',
createdAt: new Date('2024-01-01'),
sourceNote: { id: 'note-2', title: 'Related Note', type: 'command' },
},
]
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks)
const result = await getBacklinksForNote('note-1')
expect(result).toHaveLength(1)
expect(result[0].sourceNote.title).toBe('Related Note')
expect(result[0].sourceNote.type).toBe('command')
})
it('returns empty array when no backlinks', async () => {
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue([])
const result = await getBacklinksForNote('note-1')
expect(result).toEqual([])
})
it('converts Date to ISO string', async () => {
const mockBacklinks = [
{
id: 'bl-1',
sourceNoteId: 'note-2',
targetNoteId: 'note-1',
createdAt: new Date('2024-01-01T12:00:00Z'),
sourceNote: { id: 'note-2', title: 'Related Note', type: 'command' },
},
]
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks)
const result = await getBacklinksForNote('note-1')
expect(result[0].createdAt).toBe('2024-01-01T12:00:00.000Z')
})
})
describe('getOutgoingLinksForNote', () => {
it('returns outgoing links with target note info', async () => {
const mockBacklinks = [
{
id: 'bl-1',
sourceNoteId: 'note-1',
targetNoteId: 'note-2',
createdAt: new Date('2024-01-01'),
targetNote: { id: 'note-2', title: 'Linked Note', type: 'snippet' },
},
]
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks)
const result = await getOutgoingLinksForNote('note-1')
expect(result).toHaveLength(1)
expect(result[0].sourceNote.title).toBe('Linked Note')
expect(result[0].sourceNote.type).toBe('snippet')
})
it('returns empty array when no outgoing links', async () => {
;(prisma.backlink.findMany as jest.Mock).mockResolvedValue([])
const result = await getOutgoingLinksForNote('note-1')
expect(result).toEqual([])
})
})
})
+56
View File
@@ -0,0 +1,56 @@
import { commands, CommandItem } from '@/lib/command-items'
describe('command-items', () => {
describe('commands array', () => {
it('contains navigation commands', () => {
const navCommands = commands.filter((cmd) => cmd.group === 'navigation')
expect(navCommands.length).toBeGreaterThan(0)
expect(navCommands.some((cmd) => cmd.id === 'nav-dashboard')).toBe(true)
expect(navCommands.some((cmd) => cmd.id === 'nav-notes')).toBe(true)
expect(navCommands.some((cmd) => cmd.id === 'nav-settings')).toBe(true)
})
it('contains action commands', () => {
const actionCommands = commands.filter((cmd) => cmd.group === 'actions')
expect(actionCommands.length).toBeGreaterThan(0)
expect(actionCommands.some((cmd) => cmd.id === 'action-new')).toBe(true)
})
})
describe('command item structure', () => {
it('each command has required fields', () => {
commands.forEach((cmd: CommandItem) => {
expect(cmd.id).toBeDefined()
expect(cmd.label).toBeDefined()
expect(cmd.group).toBeDefined()
expect(typeof cmd.id).toBe('string')
expect(typeof cmd.label).toBe('string')
expect(['navigation', 'actions', 'search', 'recent']).toContain(cmd.group)
})
})
it('commands have keywords for search', () => {
commands.forEach((cmd: CommandItem) => {
if (cmd.keywords) {
expect(Array.isArray(cmd.keywords)).toBe(true)
}
})
})
})
describe('command filtering', () => {
it('can filter by label', () => {
const filtered = commands.filter((cmd) =>
cmd.label.toLowerCase().includes('dashboard')
)
expect(filtered.length).toBeGreaterThan(0)
})
it('can filter by keywords', () => {
const filtered = commands.filter((cmd) =>
cmd.keywords?.some((k) => k.includes('home'))
)
expect(filtered.length).toBeGreaterThan(0)
})
})
})
+36
View File
@@ -0,0 +1,36 @@
import { generateBookmarklet, encodeCapturePayload, CapturePayload } from '@/lib/external-capture'
describe('external-capture', () => {
describe('generateBookmarklet', () => {
it('generates a valid javascript bookmarklet string', () => {
const bookmarklet = generateBookmarklet()
expect(bookmarklet).toContain('javascript:')
expect(bookmarklet.length).toBeGreaterThan(0)
})
it('contains the capture URL', () => {
const bookmarklet = generateBookmarklet()
expect(bookmarklet).toContain('capture')
})
})
describe('encodeCapturePayload', () => {
it('encodes title in params', () => {
const payload: CapturePayload = { title: 'Test Note', url: '', selection: '' }
const encoded = encodeCapturePayload(payload)
expect(encoded).toContain('title=Test')
})
it('encodes url in params', () => {
const payload: CapturePayload = { title: '', url: 'https://example.com', selection: '' }
const encoded = encodeCapturePayload(payload)
expect(encoded).toContain('url=https%3A%2F%2Fexample.com')
})
it('encodes selection in params', () => {
const payload: CapturePayload = { title: '', url: '', selection: 'Selected text' }
const encoded = encodeCapturePayload(payload)
expect(encoded).toContain('selection=Selected')
})
})
})
+171
View File
@@ -0,0 +1,171 @@
import { findLinkSuggestions, applyWikiLinks } from '@/lib/link-suggestions'
// Mock prisma
jest.mock('@/lib/prisma', () => ({
prisma: {
note: {
findMany: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
describe('link-suggestions.ts', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('findLinkSuggestions', () => {
it('returns empty array for short content', async () => {
const result = await findLinkSuggestions('Hi')
expect(result).toEqual([])
})
it('returns empty array for empty content', async () => {
const result = await findLinkSuggestions('')
expect(result).toEqual([])
})
it('finds matching note titles in content', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Docker Commands' },
{ id: '2', title: 'Git Tutorial' },
])
const content = 'I use Docker Commands for containers and Git Tutorial for version control.'
const result = await findLinkSuggestions(content)
expect(result).toHaveLength(2)
expect(result.map(r => r.noteTitle)).toContain('Docker Commands')
expect(result.map(r => r.noteTitle)).toContain('Git Tutorial')
})
it('excludes current note from suggestions', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Current Note' },
{ id: '2', title: 'Related Note' },
])
const content = 'See Related Note for details.'
const result = await findLinkSuggestions(content, '1')
expect(result).toHaveLength(1)
expect(result[0].noteTitle).toBe('Related Note')
})
it('sorts by title length (longer first)', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Short' },
{ id: '2', title: 'Very Long Title' },
{ id: '3', title: 'Medium Title' },
])
const content = 'Short and Medium Title and Very Long Title'
const result = await findLinkSuggestions(content)
expect(result[0].noteTitle).toBe('Very Long Title')
expect(result[1].noteTitle).toBe('Medium Title')
expect(result[2].noteTitle).toBe('Short')
})
it('returns empty when no matches found', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Docker' },
{ id: '2', title: 'Git' },
])
const content = 'Python and JavaScript are programming languages.'
const result = await findLinkSuggestions(content)
expect(result).toEqual([])
})
it('handles case-insensitive matching', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Docker Commands' },
])
const content = 'I use DOCKER COMMANDS for my project.'
const result = await findLinkSuggestions(content)
expect(result).toHaveLength(1)
expect(result[0].noteTitle).toBe('Docker Commands')
})
it('matches whole words only', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Git' },
])
const content = 'GitHub uses Git internally.'
const result = await findLinkSuggestions(content)
// Should match standalone 'Git' but not 'Git' within 'GitHub'
// Note: the regex \bGit\b matches standalone 'Git', not 'Git' in 'GitHub'
expect(result.some(r => r.noteTitle === 'Git')).toBe(true)
})
it('returns empty when no notes exist', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
const result = await findLinkSuggestions('Some content with potential matches')
expect(result).toEqual([])
})
})
describe('applyWikiLinks', () => {
it('replaces terms with wiki-links', () => {
const content = 'I use Docker and Git for projects.'
const replacements = [
{ term: 'Docker', noteId: '1' },
{ term: 'Git', noteId: '2' },
]
const result = applyWikiLinks(content, replacements)
expect(result).toBe('I use [[Docker]] and [[Git]] for projects.')
})
it('handles multiple occurrences', () => {
const content = 'Docker is great. Docker is fast.'
const replacements = [{ term: 'Docker', noteId: '1' }]
const result = applyWikiLinks(content, replacements)
expect(result).toBe('[[Docker]] is great. [[Docker]] is fast.')
})
it('handles case-insensitive matching and replaces with link term', () => {
const content = 'DOCKER and docker and Docker'
const replacements = [{ term: 'Docker', noteId: '1' }]
const result = applyWikiLinks(content, replacements)
// All variations matched and replaced with the link text
expect(result).toBe('[[Docker]] and [[Docker]] and [[Docker]]')
})
it('returns original content when no replacements', () => {
const content = 'Original content'
const replacements: { term: string; noteId: string }[] = []
const result = applyWikiLinks(content, replacements)
expect(result).toBe('Original content')
})
it('replaces multiple different terms', () => {
const content = 'Use React and TypeScript together.'
const replacements = [
{ term: 'React', noteId: '1' },
{ term: 'TypeScript', noteId: '2' },
]
const result = applyWikiLinks(content, replacements)
expect(result).toBe('Use [[React]] and [[TypeScript]] together.')
})
})
})
+14
View File
@@ -0,0 +1,14 @@
// Navigation history tests are limited due to localStorage mocking complexity
// The module itself is straightforward and works correctly in practice
describe('navigation-history', () => {
describe('module exports', () => {
it('exports required functions', async () => {
const module = await import('@/lib/navigation-history')
expect(typeof module.getNavigationHistory).toBe('function')
expect(typeof module.addToNavigationHistory).toBe('function')
expect(typeof module.clearNavigationHistory).toBe('function')
expect(typeof module.removeFromNavigationHistory).toBe('function')
})
})
})
+145
View File
@@ -0,0 +1,145 @@
import { parseQuery, QueryAST } from '@/lib/query-parser'
describe('query-parser', () => {
describe('basic text queries', () => {
it('returns text with no filters for simple text', () => {
const result = parseQuery('docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({})
})
it('preserves multi-word text', () => {
const result = parseQuery('hello world')
expect(result.text).toBe('hello world')
expect(result.filters).toEqual({})
})
})
describe('type filter', () => {
it('extracts type filter from beginning of query', () => {
const result = parseQuery('type:command docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'command' })
})
it('handles query with only type filter', () => {
const result = parseQuery('type:snippet')
expect(result.text).toBe('')
expect(result.filters).toEqual({ type: 'snippet' })
})
it('extracts type filter from end of query', () => {
const result = parseQuery('docker type:command')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'command' })
})
})
describe('tag filter', () => {
it('extracts tag filter with text', () => {
const result = parseQuery('tag:api error')
expect(result.text).toBe('error')
expect(result.filters).toEqual({ tag: 'api' })
})
it('handles query with only tag filter', () => {
const result = parseQuery('tag:backend')
expect(result.text).toBe('')
expect(result.filters).toEqual({ tag: 'backend' })
})
})
describe('combined filters', () => {
it('parses multiple filters together', () => {
const result = parseQuery('docker tag:backend type:command')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'command', tag: 'backend' })
})
it('handles type, tag, and isFavorite combined', () => {
const result = parseQuery('type:snippet tag:python is:favorite')
expect(result.text).toBe('')
expect(result.filters).toEqual({ type: 'snippet', tag: 'python', isFavorite: true })
})
})
describe('boolean filters', () => {
it('extracts is:favorite filter', () => {
const result = parseQuery('is:favorite docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ isFavorite: true })
})
it('handles is:pinned filter alone', () => {
const result = parseQuery('is:pinned')
expect(result.text).toBe('')
expect(result.filters).toEqual({ isPinned: true })
})
it('handles both boolean filters with text', () => {
const result = parseQuery('is:favorite is:pinned docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ isFavorite: true, isPinned: true })
})
})
describe('edge cases', () => {
it('handles empty string', () => {
const result = parseQuery('')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('handles whitespace only', () => {
const result = parseQuery(' ')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('ignores empty type value', () => {
const result = parseQuery('type:')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('ignores empty tag value', () => {
const result = parseQuery('tag:')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('last duplicate filter wins for type', () => {
const result = parseQuery('type:command type:snippet docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'snippet' })
})
it('last duplicate filter wins for tag', () => {
const result = parseQuery('tag:python tag:javascript code')
expect(result.text).toBe('code')
expect(result.filters).toEqual({ tag: 'javascript' })
})
})
describe('case sensitivity', () => {
it('filter name is case insensitive', () => {
const result = parseQuery('TYPE:command')
expect(result.filters).toEqual({ type: 'command' })
})
it('filter value is case sensitive', () => {
const result = parseQuery('type:Command')
expect(result.filters).toEqual({ type: 'Command' })
})
it('is:favorite is case insensitive', () => {
const result = parseQuery('IS:FAVORITE docker')
expect(result.filters).toEqual({ isFavorite: true })
})
it('is:pinned is case insensitive', () => {
const result = parseQuery('IS:PINNED')
expect(result.filters).toEqual({ isPinned: true })
})
})
})
+221
View File
@@ -0,0 +1,221 @@
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
describe('type-inference.ts', () => {
describe('inferNoteType', () => {
describe('command detection', () => {
it('detects git commands', () => {
const content = 'git commit -m "fix: resolve issue"\nnpm install\ndocker build'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
expect(result?.confidence).toBeTruthy() // any confidence level
})
it('detects docker commands', () => {
const content = 'docker build -t myapp .\ndocker run -d'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
it('detects shell prompts', () => {
const content = '$ curl -X POST https://api.example.com\n$ npm install'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
it('detects shebang', () => {
const content = '#!/bin/bash\necho "Hello"'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
})
describe('snippet detection', () => {
it('detects code blocks', () => {
const content = '```javascript\nconst x = 1;\n```'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects function declarations', () => {
const content = 'function hello() {\n return "world";\n}'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects ES6 imports', () => {
const content = 'import React from "react"\nexport default App'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects arrow functions', () => {
const content = 'const add = (a, b) => a + b;'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects object literals', () => {
const content = 'const config = {\n name: "app",\n version: "1.0.0"\n}'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
})
describe('procedure detection', () => {
it('detects numbered steps', () => {
const content = '1. First step\n2. Second step\n3. Third step'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
it('detects bullet points as steps', () => {
const content = '- Open the terminal\n- Run the command\n- Check the output'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
it('detects step-related keywords', () => {
const content = 'primer paso, segundo paso, tercer paso, finally'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
it('detects tutorial language', () => {
const content = 'How to install Node.js:\n1. Download the installer\n2. Run the setup'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
})
describe('recipe detection', () => {
it('detects ingredients pattern', () => {
const content = 'ingredientes:\n- 2 tazas de harina\n- 1 taza de azúcar'
const result = inferNoteType(content)
expect(result?.type).toBe('recipe')
})
it('detects recipe-related keywords', () => {
const content = 'receta:\n1. sofreír cebolla\n2. añadir arroz\ntiempo: 30 minutos'
const result = inferNoteType(content)
expect(result?.type).toBe('recipe')
})
})
describe('decision detection', () => {
it('detects decision context', () => {
const content = 'Decisión: Usar PostgreSQL en lugar de MySQL\n\nRazón: Mejor soporte para JSON y transacciones.'
const result = inferNoteType(content)
expect(result?.type).toBe('decision')
})
it('detects pros and cons', () => {
const content = 'decisión:\nPros: Better performance\nContras: More expensive'
const result = inferNoteType(content)
expect(result?.type).toBe('decision')
})
it('detects alternatives considered', () => {
const content = 'Alternativas consideradas:\n1. AWS\n2. GCP\n3. Azure\n\nElegimos Vercel por su integración con Next.js.'
const result = inferNoteType(content)
expect(result?.type).toBe('decision')
})
})
describe('inventory detection', () => {
it('detects quantity patterns', () => {
const content = 'Item: Laptop\nCantidad: 5\nUbicación: Oficina principal'
const result = inferNoteType(content)
expect(result?.type).toBe('inventory')
})
it('detects inventory-related keywords', () => {
const content = 'Stock disponible: 100 unidades\nNivel mínimo: 20'
const result = inferNoteType(content)
expect(result?.type).toBe('inventory')
})
})
describe('edge cases', () => {
it('returns note type for generic content', () => {
const content = 'This is a simple note about my day.'
const result = inferNoteType(content)
expect(result?.type).toBe('note')
})
it('returns note type with low confidence for empty content', () => {
const result = inferNoteType('')
expect(result?.type).toBe('note')
expect(result?.confidence).toBe('low')
})
it('returns note type with low confidence for very short content', () => {
const result = inferNoteType('Hi')
expect(result?.type).toBe('note')
expect(result?.confidence).toBe('low')
})
it('prioritizes highest confidence match', () => {
// Command-like code with shell prompt - medium confidence (2 patterns)
const content = '$ npm install\ngit commit -m "fix"'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
})
})
describe('formatContentForType', () => {
it('formats command type', () => {
const content = 'git status'
const result = formatContentForType(content, 'command')
expect(result).toContain('## Comando')
expect(result).toContain('## Cuándo usarlo')
expect(result).toContain('## Ejemplo')
})
it('formats snippet type', () => {
const content = 'const x = 1;'
const result = formatContentForType(content, 'snippet')
expect(result).toContain('## Snippet')
expect(result).toContain('## Lenguaje')
expect(result).toContain('## Código')
})
it('formats procedure type', () => {
const content = '1. Step one\n2. Step two'
const result = formatContentForType(content, 'procedure')
expect(result).toContain('## Objetivo')
expect(result).toContain('## Pasos')
expect(result).toContain('## Requisitos')
})
it('formats recipe type', () => {
const content = 'Ingredients:\n- Flour\n- Sugar'
const result = formatContentForType(content, 'recipe')
expect(result).toContain('## Ingredientes')
expect(result).toContain('## Pasos')
expect(result).toContain('## Tiempo')
})
it('formats decision type', () => {
const content = 'Use TypeScript'
const result = formatContentForType(content, 'decision')
expect(result).toContain('## Contexto')
expect(result).toContain('## Decisión')
expect(result).toContain('## Alternativas')
})
it('formats inventory type', () => {
const content = 'Laptop model X'
const result = formatContentForType(content, 'inventory')
expect(result).toContain('## Item')
expect(result).toContain('## Cantidad')
expect(result).toContain('## Ubicación')
})
it('formats note type', () => {
const content = 'Simple note content'
const result = formatContentForType(content, 'note')
expect(result).toContain('## Notas')
})
})
})
+167
View File
@@ -0,0 +1,167 @@
import { createVersion, getVersions, getVersion, restoreVersion } from '@/lib/versions'
jest.mock('@/lib/prisma', () => ({
prisma: {
note: { findUnique: jest.fn(), update: jest.fn() },
noteVersion: { create: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
},
}))
import { prisma } from '@/lib/prisma'
import { NotFoundError } from '@/lib/errors'
describe('versions.ts', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('createVersion', () => {
it('creates a version with correct noteId, title, content', async () => {
const mockNote = { id: 'note-1', title: 'Test Title', content: 'Test Content' }
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Test Title', content: 'Test Content', createdAt: new Date() }
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
;(prisma.noteVersion.create as jest.Mock).mockResolvedValue(mockVersion)
const result = await createVersion('note-1')
expect(result).toEqual(mockVersion)
expect(prisma.note.findUnique).toHaveBeenCalledWith({
where: { id: 'note-1' },
select: { id: true, title: true, content: true },
})
expect(prisma.noteVersion.create).toHaveBeenCalledWith({
data: {
noteId: 'note-1',
title: 'Test Title',
content: 'Test Content',
},
})
})
it('throws NotFoundError if note does not exist', async () => {
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
await expect(createVersion('note-1')).rejects.toThrow(NotFoundError)
})
})
describe('getVersions', () => {
it('returns all versions for a note ordered by createdAt desc', async () => {
const mockNote = { id: 'note-1' }
const mockVersions = [
{ id: 'version-2', noteId: 'note-1', title: 'Title 2', content: 'Content 2', createdAt: new Date('2024-01-02') },
{ id: 'version-1', noteId: 'note-1', title: 'Title 1', content: 'Content 1', createdAt: new Date('2024-01-01') },
]
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
;(prisma.noteVersion.findMany as jest.Mock).mockResolvedValue(mockVersions)
const result = await getVersions('note-1')
expect(result).toEqual(mockVersions)
expect(prisma.note.findUnique).toHaveBeenCalledWith({ where: { id: 'note-1' } })
expect(prisma.noteVersion.findMany).toHaveBeenCalledWith({
where: { noteId: 'note-1' },
orderBy: { createdAt: 'desc' },
select: {
id: true,
noteId: true,
title: true,
content: true,
createdAt: true,
},
})
})
it('throws NotFoundError if note does not exist', async () => {
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
await expect(getVersions('note-1')).rejects.toThrow(NotFoundError)
})
})
describe('getVersion', () => {
it('returns version by ID', async () => {
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Title', content: 'Content', createdAt: new Date() }
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
const result = await getVersion('version-1')
expect(result).toEqual(mockVersion)
expect(prisma.noteVersion.findUnique).toHaveBeenCalledWith({ where: { id: 'version-1' } })
})
it('throws NotFoundError if version does not exist', async () => {
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(null)
await expect(getVersion('version-1')).rejects.toThrow(NotFoundError)
})
})
describe('restoreVersion', () => {
it('updates note title and content from version', async () => {
const mockNote = { id: 'note-1', title: 'Old Title', content: 'Old Content' }
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Old Title', content: 'Old Content', createdAt: new Date() }
const updatedNote = { id: 'note-1', title: 'Old Title', content: 'Old Content', updatedAt: new Date() }
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
;(prisma.note.update as jest.Mock).mockResolvedValue(updatedNote)
const result = await restoreVersion('note-1', 'version-1')
expect(result).toEqual(updatedNote)
expect(prisma.note.update).toHaveBeenCalledWith({
where: { id: 'note-1' },
data: {
title: 'Old Title',
content: 'Old Content',
updatedAt: expect.any(Date),
},
select: {
id: true,
title: true,
content: true,
updatedAt: true,
},
})
})
it('updates note updatedAt timestamp', async () => {
const mockNote = { id: 'note-1', title: 'Title', content: 'Content' }
const mockVersion = { id: 'version-1', noteId: 'note-1', title: 'Title', content: 'Content', createdAt: new Date() }
const beforeUpdate = new Date('2024-01-01')
const afterUpdate = new Date('2024-01-02')
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(mockVersion)
;(prisma.note.update as jest.Mock).mockResolvedValue({ id: 'note-1', title: 'Title', content: 'Content', updatedAt: afterUpdate })
const result = await restoreVersion('note-1', 'version-1')
expect(result.updatedAt).toEqual(afterUpdate)
})
it('throws NotFoundError if note does not exist', async () => {
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
})
it('throws NotFoundError if version does not exist', async () => {
;(prisma.note.findUnique as jest.Mock).mockResolvedValue({ id: 'note-1' })
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue(null)
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
})
it('throws NotFoundError if version does not belong to note', async () => {
;(prisma.note.findUnique as jest.Mock).mockResolvedValue({ id: 'note-1' })
;(prisma.noteVersion.findUnique as jest.Mock).mockResolvedValue({ id: 'version-1', noteId: 'note-2' })
await expect(restoreVersion('note-1', 'version-1')).rejects.toThrow(NotFoundError)
})
})
})
+252
View File
@@ -0,0 +1,252 @@
# Recall — Tickets técnicos MVP-4 (Camino Producto)
## 🎯 Objetivo
Convertir Recall en una herramienta confiable, rápida y diaria, enfocada en:
- búsqueda tipo Google personal
- navegación instantánea
- confianza (historial + backup)
- contexto activo
---
# 🧩 EPIC 1 — Búsqueda avanzada (Google personal)
## [P1] Ticket 01 — Parser de query avanzada
**Objetivo**
Permitir búsquedas expresivas tipo: `docker tag:backend type:command`
**Alcance**
- Crear `src/lib/query-parser.ts`
- Soportar:
- texto libre
- `type:`
- `tag:`
- `is:favorite`, `is:pinned`
- Devolver AST simple
**Criterios**
- Queries válidas parsean correctamente
- Soporta combinación de filtros + texto
- Tests unitarios incluidos
---
## [P1] Ticket 02 — Integrar query avanzada en search
**Objetivo**
Aplicar parser en `/api/search`
**Alcance**
- Filtrar por AST antes de scoring
- Mantener scoring existente
**Criterios**
- `type:command docker` filtra correctamente
- `tag:api error` funciona
- No rompe búsqueda actual
---
## [P1] Ticket 03 — Búsqueda en tiempo real
**Objetivo**
Actualizar resultados mientras el usuario escribe
**Alcance**
- Debounce en `search-bar.tsx`
- Fetch automático
- Estado loading ligero
**Criterios**
- Resultados cambian en <300ms
- No bloquea UI
---
## [P1] Ticket 04 — Navegación por teclado en búsqueda
**Objetivo**
UX tipo Spotlight
**Alcance**
- ↑ ↓ para moverse
- Enter para abrir
- ESC para cerrar
**Criterios**
- Navegación sin mouse
- Estado seleccionado visible
---
# 🧠 EPIC 2 — Contexto activo
## [P1] Ticket 05 — Sidebar contextual inteligente
**Objetivo**
Mostrar contexto dinámico mientras navegas
**Alcance**
- Crear `note-context-sidebar.tsx`
- Mostrar:
- relacionadas
- co-uso
- backlinks
- recientes
**Criterios**
- Siempre muestra contenido relevante
- No rompe layout responsive
---
## [P2] Ticket 06 — Sugerencias dinámicas en lectura
**Objetivo**
Recomendar contenido mientras lees
**Alcance**
- Hook en `notes/[id]/page.tsx`
- Actualizar sugerencias según scroll/uso
**Criterios**
- Sugerencias cambian según contexto
- No afecta performance
---
# 🔐 EPIC 3 — Confianza total
## [P1] Ticket 07 — Historial de versiones
**Objetivo**
Permitir ver y revertir cambios
**Alcance**
- Modelo `NoteVersion`
- Guardar snapshot en cada update
- Endpoint `/api/notes/[id]/versions`
**Criterios**
- Se pueden listar versiones
- Se puede restaurar versión
- No rompe edición actual
---
## [P1] Ticket 08 — UI historial de versiones
**Objetivo**
Visualizar cambios
**Alcance**
- Vista en `notes/[id]`
- Mostrar lista de versiones
- Botón restaurar
**Criterios**
- UX clara
- Confirmación antes de revertir
---
## [P2] Ticket 09 — Backup automático
**Objetivo**
Evitar pérdida de datos
**Alcance**
- Export JSON automático
- Guardar en local (descarga o storage)
**Criterios**
- Backup se genera periódicamente
- No bloquea app
---
# ⚡ EPIC 4 — Rendimiento y UX
## [P1] Ticket 10 — Cache de resultados de búsqueda
**Objetivo**
Reducir latencia
**Alcance**
- Cache en cliente por query
- Invalidación simple
**Criterios**
- Queries repetidas son instantáneas
- No datos stale críticos
---
## [P2] Ticket 11 — Preload de notas frecuentes
**Objetivo**
Abrir notas más rápido
**Alcance**
- Prefetch en hover/listado
- Usar Next.js prefetch
**Criterios**
- Navegación instantánea en notas frecuentes
---
# 🧪 EPIC 5 — Calidad
## [P1] Ticket 12 — Tests query avanzada
- parser
- integración search
---
## [P1] Ticket 13 — Tests historial versiones
- creación
- restore
- edge cases
---
## [P2] Ticket 14 — Tests navegación teclado
- selección
- acciones
---
# 🗺️ Orden sugerido
## Sprint 1
- Query parser
- Integración search
- Real-time search
- Tests base
## Sprint 2
- Navegación teclado
- Sidebar contextual
- Cache búsqueda
## Sprint 3
- Historial versiones (API + UI)
## Sprint 4
- Backup automático
- Preload notas
- Tests finales
---
# ✅ Definition of Done
- Feature usable sin bugs críticos
- Tests pasando
- No regresiones
- UX fluida (<300ms interacción)
+970
View File
@@ -0,0 +1,970 @@
# Recall — Tickets técnicos MVP-5 (Confianza, flujo diario y expansión)
## Objetivo general
Consolidar Recall como sistema principal de pensamiento y memoria externa, enfocado en:
- confianza total en los datos
- reducción extrema de fricción
- captura desde fuera de la app
- recuperación y operación desde teclado
- portabilidad real del conocimiento
## Principios de producto
1. **No perder nada**: backup y restore confiables.
2. **Todo a mano**: acciones principales accesibles por teclado.
3. **Captura ubicua**: guardar conocimiento desde cualquier contexto.
4. **Salida garantizada**: exportaciones útiles y reversibles.
5. **Experiencia continua**: la app acompaña el flujo de trabajo, no lo interrumpe.
---
# EPIC 1 — Confianza total y resiliencia de datos
## [P1] Ticket 01 — Diseñar estrategia de backup automático local
**Objetivo**
Definir e implementar una estrategia segura de backup automático para evitar pérdida de datos y aumentar la confianza en Recall.
**Contexto**
Recall ya cuenta con export/import manual e historial de versiones por nota. El siguiente salto es garantizar respaldo periódico y silencioso del estado global del conocimiento.
**Problema que resuelve**
- Riesgo de pérdida por errores del usuario, corrupción local o cambios no deseados.
- Dependencia de exportaciones manuales.
- Falta de sensación de “sistema confiable”.
**Alcance**
- Diseñar estrategia de backup automático basada en eventos y/o tiempo:
- al detectar cambios significativos
- cada cierto intervalo configurable
- al cerrar sesión o abandonar pestaña cuando aplique
- Definir formato del backup:
- JSON estructurado compatible con importación
- metadatos de versión, fecha, origen, conteos
- Definir almacenamiento inicial:
- IndexedDB recomendado para snapshots locales
- alternativa: local filesystem vía descarga manual asistida
- Crear servicio de generación de backup
- Crear política de retención:
- conservar últimos N backups
- limpiar backups viejos automáticamente
**No incluye**
- sincronización cloud
- backup remoto
- cifrado extremo a extremo
**Criterios de aceptación**
- La app genera backups automáticamente sin intervención manual
- Los backups se almacenan con timestamp y metadatos
- Existe retención automática configurable o fija
- El proceso no bloquea la UI
- El formato es compatible con restore/import
- Hay tests para serialización y política de retención
**Archivos sugeridos**
- `src/lib/backup.ts`
- `src/lib/backup-storage.ts`
- `src/lib/backup-policy.ts`
- `src/types/backup.ts`
- `src/app/settings/page.tsx`
- `src/app/api/export-import/route.ts`
**Notas técnicas**
- Separar claramente:
- generación del snapshot
- persistencia local
- política de retención
- Preferir un esquema de versión explícito del backup (`schemaVersion`)
- Incluir checksum o hash simple opcional para detectar corrupción
- Mantener compatibilidad hacia atrás cuando cambie el formato
---
## [P1] Ticket 02 — Implementar motor de snapshot global exportable
**Objetivo**
Crear una utilidad robusta que genere snapshots completos y consistentes del estado de Recall.
**Alcance**
- Incluir en el snapshot:
- notas
- tags
- backlinks/enlaces si corresponden
- métricas relevantes necesarias para restore
- versiones de notas, si se decide incluirlas
- metadatos de creación
- Crear función `createBackupSnapshot()`
- Reutilizar la lógica existente de exportación para evitar duplicación
- Estandarizar el shape del payload exportable
**No incluye**
- compresión
- cifrado
**Criterios de aceptación**
- Un snapshot puede reconstruir el estado esperado del sistema
- El export manual y el backup automático comparten formato base o traductor explícito
- Tests verifican consistencia del snapshot
- El snapshot incluye versión de esquema y fecha de creación
**Archivos sugeridos**
- `src/lib/backup.ts`
- `src/lib/export.ts`
- `src/app/api/export-import/route.ts`
- `__tests__/backup.test.ts`
**Notas técnicas**
- Evitar incluir datos derivados si pueden regenerarse fácilmente
- Documentar claramente qué campos se consideran fuente de verdad
- Si `NoteUsage` no debe restaurarse, dejarlo explícito en especificación
---
## [P1] Ticket 03 — Restore desde backup con preview y validación
**Objetivo**
Permitir restaurar un backup de forma segura, transparente y reversible.
**Alcance**
- Crear flujo de restore desde Settings
- Validar el archivo antes de aplicar:
- schemaVersion
- integridad mínima
- estructura esperada
- Mostrar preview:
- cantidad de notas
- tags
- versiones
- fecha del backup
- Permitir dos modos:
- merge
- replace completo
- Confirmación explícita antes de aplicar
**No incluye**
- merge inteligente avanzado por conflicto
- restore parcial por selección de entidades
**Criterios de aceptación**
- El usuario puede seleccionar un backup y previsualizarlo
- El sistema informa claramente qué se va a restaurar
- Hay confirmación antes del replace
- El restore fallido no deja la base en estado inconsistente
- Existe feedback claro de éxito/error
**Archivos sugeridos**
- `src/components/backup-restore-dialog.tsx`
- `src/lib/restore.ts`
- `src/lib/backup-validator.ts`
- `src/app/settings/page.tsx`
- `src/app/api/export-import/route.ts`
**Notas técnicas**
- En `replace`, considerar transacción única
- En `merge`, definir reglas claras por ID/título
- Crear un backup previo automático antes de aplicar restore
---
## [P1] Ticket 04 — Backup previo automático antes de operaciones destructivas
**Objetivo**
Reducir al mínimo el riesgo antes de operaciones peligrosas.
**Alcance**
- Generar backup automático antes de:
- restore replace
- import replace
- borrados masivos futuros
- Etiquetar ese backup como `pre-destructive`
- Permitir revertir rápidamente
**Criterios de aceptación**
- Antes de una operación destructiva se crea un backup
- El backup queda identificado y visible en UI
- Si la operación falla, el backup sigue disponible
**Archivos sugeridos**
- `src/lib/backup.ts`
- `src/lib/restore.ts`
- `src/app/settings/page.tsx`
---
## [P1] Ticket 05 — Guard de cambios no guardados
**Objetivo**
Evitar pérdida accidental de trabajo durante edición.
**Alcance**
- Detectar cambios sucios en `note-form`
- Advertir al:
- navegar fuera de la página
- cerrar pestaña
- refrescar
- Permitir omitir warning cuando no hay cambios
**No incluye**
- autosave completo
- borradores persistentes
**Criterios de aceptación**
- Si hay cambios sin guardar, aparece advertencia al salir
- Si no hay cambios, no aparece advertencia
- Funciona en crear y editar
- No rompe submit exitoso
**Archivos sugeridos**
- `src/hooks/use-unsaved-changes.ts`
- `src/components/note-form.tsx`
- `src/app/edit/[id]/page.tsx`
- `src/app/new/page.tsx`
**Notas técnicas**
- Diferenciar estado inicial vs actual
- Manejar `beforeunload` con cuidado por compatibilidad del navegador
---
## [P2] Ticket 06 — Autosave opcional de borrador local
**Objetivo**
Agregar una capa extra de protección sin imponer complejidad excesiva.
**Alcance**
- Guardar borrador local temporal de la nota en edición
- Recuperarlo al reabrir la pantalla
- Permitir descartarlo manualmente
**Criterios de aceptación**
- Si se cierra accidentalmente, el borrador puede recuperarse
- El borrador se limpia al guardar correctamente
- El usuario puede descartar borrador recuperado
**Archivos sugeridos**
- `src/lib/drafts.ts`
- `src/components/note-form.tsx`
- `src/components/draft-recovery-banner.tsx`
---
# EPIC 2 — Operación diaria desde teclado
## [P1] Ticket 07 — Command Palette global (`Ctrl+K` / `Cmd+K`)
**Objetivo**
Centralizar búsqueda, navegación y acciones en una interfaz rápida tipo command palette.
**Contexto**
Recall ya tiene búsqueda potente y navegación por teclado en el search bar. El siguiente paso es ofrecer una capa global de comandos que reduzca aún más la fricción.
**Alcance**
- Atajo global:
- `Ctrl+K` en Windows/Linux
- `Cmd+K` en macOS
- Modal o palette flotante global
- Soportar acciones iniciales:
- buscar notas
- abrir nota
- crear nueva nota
- quick add
- ir a dashboard
- ir a settings
- ir a notas favoritas
- ir a notas recientes
- Secciones:
- acciones
- resultados de búsqueda
- navegación
- Navegación total por teclado
**No incluye**
- edición avanzada dentro de la palette
- plugins de comandos externos
**Criterios de aceptación**
- La palette abre/cierra con shortcut global
- Se puede usar sin mouse
- Enter ejecuta acción seleccionada
- ESC cierra
- Resultados y acciones están claramente separadas
- Funciona desde cualquier pantalla
**Archivos sugeridos**
- `src/components/command-palette.tsx`
- `src/hooks/use-command-palette.ts`
- `src/lib/command-palette.ts`
- `src/app/layout.tsx`
- `src/components/header.tsx`
**Notas técnicas**
- Reutilizar `search.ts` y la API existente cuando sea posible
- Mantener selección activa y scroll automático
- Considerar accesibilidad: focus trap, ARIA roles
---
## [P1] Ticket 08 — Modelo de acciones y proveedores para Command Palette
**Objetivo**
Desacoplar la palette de las acciones concretas para facilitar expansión futura.
**Alcance**
- Crear modelo uniforme de comando:
- id
- label
- description
- group
- keywords
- action handler
- icon opcional
- Crear proveedores:
- acciones estáticas
- notas recientes
- resultados de búsqueda
- Sistema de ranking simple para comandos
**Criterios de aceptación**
- Se pueden agregar nuevas acciones sin tocar el core visual
- La palette consume una lista homogénea de items
- El sistema soporta agrupación y orden
**Archivos sugeridos**
- `src/lib/command-items.ts`
- `src/lib/command-groups.ts`
- `src/lib/command-ranking.ts`
- `src/components/command-palette.tsx`
---
## [P1] Ticket 09 — Acciones rápidas por teclado fuera de la palette
**Objetivo**
Expandir la operación de Recall sin depender de clicks.
**Alcance**
- Definir shortcuts globales seguros:
- `g h` → dashboard
- `g n` → notas
- `n` → nueva nota
- `/` → enfocar búsqueda
- `?` → ayuda de shortcuts
- Mostrar ayuda contextual de shortcuts
**Criterios de aceptación**
- Los shortcuts no interfieren con inputs activos
- Se pueden desactivar en campos de texto
- Existe una vista/modal de ayuda
**Archivos sugeridos**
- `src/hooks/use-global-shortcuts.ts`
- `src/components/keyboard-shortcuts-dialog.tsx`
- `src/app/layout.tsx`
**Notas técnicas**
- Ignorar shortcuts cuando hay foco en input, textarea o contenteditable
- Centralizar mapa de shortcuts en un único archivo
---
## [P2] Ticket 10 — Navegación completa de listas por teclado
**Objetivo**
Permitir abrir y operar notas desde listados sin usar mouse.
**Alcance**
- Flechas para moverse entre resultados/listas
- Enter para abrir
- Atajos para:
- editar
- favorite
- pin
- Soporte en:
- `/notes`
- dashboard
- dropdown de búsqueda
**Criterios de aceptación**
- Las listas principales se pueden recorrer por teclado
- El elemento seleccionado tiene estado visual claro
- Las acciones rápidas no rompen accesibilidad
**Archivos sugeridos**
- `src/components/note-list.tsx`
- `src/components/dashboard.tsx`
- `src/components/search-bar.tsx`
---
# EPIC 3 — Contexto activo y workspace continuo
## [P1] Ticket 11 — Sidebar contextual persistente mejorada
**Objetivo**
Convertir la sidebar contextual en un asistente permanente del flujo de trabajo.
**Alcance**
- Mantener sidebar visible en detalle de nota y opcionalmente en edición
- Secciones posibles:
- relacionadas
- backlinks
- co-usadas
- recientes
- versiones recientes
- sugerencias contextuales
- Mejorar densidad y jerarquía visual
- Permitir colapsar/expandir secciones
**Criterios de aceptación**
- La sidebar muestra contenido útil sin saturar
- Las secciones pueden plegarse
- En pantallas pequeñas se adapta sin romper el layout
- Se distinguen claramente los tipos de relación
**Archivos sugeridos**
- `src/components/note-context-sidebar.tsx`
- `src/components/note-connections.tsx`
- `src/app/notes/[id]/page.tsx`
---
## [P2] Ticket 12 — Modo trabajo enfocado
**Objetivo**
Ofrecer una experiencia de lectura/consulta prolongada con menos distracciones y más contexto útil.
**Alcance**
- Crear un “modo trabajo” activable por toggle
- Cambios de UI:
- ancho de lectura optimizado
- sidebar contextual persistente
- header reducido
- acciones rápidas siempre visibles
- Persistir preferencia local
**Criterios de aceptación**
- El usuario puede activar/desactivar el modo trabajo
- La preferencia se mantiene entre sesiones
- Mejora la experiencia en detalle de nota sin romper navegación general
**Archivos sugeridos**
- `src/components/work-mode-toggle.tsx`
- `src/lib/work-mode.ts`
- `src/app/notes/[id]/page.tsx`
- `src/app/globals.css`
---
## [P2] Ticket 13 — Historial de navegación contextual
**Objetivo**
Facilitar volver sobre el camino mental reciente.
**Alcance**
- Registrar secuencia reciente de notas abiertas
- Mostrar “visto recientemente en este contexto”
- Posibilidad de volver rápido a 510 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
View File
Binary file not shown.
+12
View File
@@ -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
+25
View File
@@ -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
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: 'standalone',
}; };
export default nextConfig; export default nextConfig;
BIN
View File
Binary file not shown.
+40 -12
View File
@@ -8,18 +8,21 @@ datasource db {
} }
model Note { model Note {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
content String content String
type String @default("note") type String @default("note")
isFavorite Boolean @default(false) isFavorite Boolean @default(false)
isPinned Boolean @default(false) isPinned Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tags NoteTag[] creationSource String @default("form") // 'form' | 'quick' | 'import'
backlinks Backlink[] @relation("BacklinkTarget") tags NoteTag[]
outbound Backlink[] @relation("BacklinkSource") backlinks Backlink[] @relation("BacklinkTarget")
usageEvents NoteUsage[] outbound Backlink[] @relation("BacklinkSource")
usageEvents NoteUsage[]
coUsageFrom NoteCoUsage[] @relation("CoUsageFrom")
coUsageTo NoteCoUsage[] @relation("CoUsageTo")
} }
model Tag { model Tag {
@@ -60,3 +63,28 @@ model NoteUsage {
@@index([noteId, createdAt]) @@index([noteId, createdAt])
@@index([eventType, createdAt]) @@index([eventType, createdAt])
} }
model NoteCoUsage {
id String @id @default(cuid())
fromNoteId String
fromNote Note @relation("CoUsageFrom", fields: [fromNoteId], references: [id], onDelete: Cascade)
toNoteId String
toNote Note @relation("CoUsageTo", fields: [toNoteId], references: [id], onDelete: Cascade)
weight Int @default(1) // times viewed together
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([fromNoteId, toNoteId])
@@index([fromNoteId])
@@index([toNoteId])
}
model NoteVersion {
id String @id @default(cuid())
noteId String
title String
content String
createdAt DateTime @default(now())
@@index([noteId, createdAt])
}
+242
View File
@@ -0,0 +1,242 @@
# Recall - Resumen del Proyecto
## Fecha
2026-03-22
## Descripción
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
## Stack Tecnológico
- **Framework**: Next.js 16.2.1 con App Router
- **Base de datos**: SQLite via Prisma ORM
- **Lenguaje**: TypeScript
- **UI**: TailwindCSS + shadcn/ui components
- **Testing**: Jest
## Estructura del Proyecto
```
src/
├── app/ # Next.js App Router
│ ├── api/ # API routes
│ │ ├── notes/ # CRUD de notas, versions, quick
│ │ ├── tags/ # Tags y sugerencias
│ │ ├── search/ # Búsqueda avanzada
│ │ ├── usage/ # Tracking de uso
│ │ ├── metrics/ # Métricas internas
│ │ ├── centrality/ # Notas centrales
│ │ └── export-import/ # Import/export JSON
│ ├── notes/[id]/ # Detalle de nota
│ ├── edit/[id]/ # Edición de nota
│ └── new/ # Nueva nota
├── components/ # Componentes React
│ ├── ui/ # shadcn/ui components
│ ├── dashboard.tsx # Dashboard inteligente
│ ├── quick-add.tsx # Captura rápida
│ ├── note-form.tsx # Formulario de nota
│ ├── note-connections.tsx # Panel de conexiones
│ ├── related-notes.tsx # Notas relacionadas
│ ├── version-history.tsx # Historial de versiones
│ └── track-note-view.tsx # Tracking de vistas
└── lib/ # Utilidades
├── prisma.ts # Cliente Prisma
├── usage.ts # Tracking de uso y co-uso
├── search.ts # Búsqueda con scoring
├── query-parser.ts # Parser de queries avanzadas
├── versions.ts # Historial de versiones
├── related.ts # Notas relacionadas
├── backlinks.ts # Sistema de enlaces [[wiki]]
├── tags.ts # Normalización y sugerencias
├── metrics.ts # Métricas de dashboard
├── centrality.ts # Cálculo de centralidad
├── type-inference.ts # Detección automática de tipo
├── link-suggestions.ts # Sugerencias de enlaces
├── features.ts # Feature flags
└── validators.ts # Zod schemas
```
## Modelos de Datos
### Note
```prisma
model Note {
id String @id @default(cuid())
title String
content String
type String @default("note") // command, snippet, decision, recipe, procedure, inventory, note
isFavorite Boolean @default(false)
isPinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creationSource String @default("form") // form, quick, import
}
```
### NoteUsage
```prisma
model NoteUsage {
id String @id @default(cuid())
noteId String
eventType String // view, search_click, related_click, link_click, copy_command, copy_snippet
query String?
createdAt DateTime @default(now())
}
```
### NoteCoUsage
```prisma
model NoteCoUsage {
id String @id @default(cuid())
fromNoteId String
toNoteId String
weight Int @default(1)
}
```
### Backlink
```prisma
model Backlink {
sourceNoteId String
targetNoteId String
}
```
### NoteVersion
```prisma
model NoteVersion {
id String @id @default(cuid())
noteId String
title String
content String
createdAt DateTime @default(now())
}
```
## APIs Principales
| Endpoint | Método | Descripción |
|----------|--------|-------------|
| `/api/notes` | GET, POST | Listar/crear notas |
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
| `/api/notes/quick` | POST | Creación rápida |
| `/api/notes/links` | GET | Sugerencias de enlaces |
| `/api/search` | GET | Búsqueda con scoring |
| `/api/tags` | GET | Listar/buscar tags |
| `/api/tags/suggest` | GET | Sugerencias automáticas |
| `/api/usage` | GET | Estadísticas de uso |
| `/api/usage/co-usage` | GET | Notas co-usadas |
| `/api/metrics` | GET | Métricas de dashboard |
| `/api/centrality` | GET | Notas más centrales |
## Features Implementadas
### MVP-1 (Completado)
- CRUD completo de notas
- Sistema de tags
- Búsqueda básica
### MVP-2 (Completado)
- Búsqueda avanzada con scoring
- Quick Add con prefijos (cmd:, snip:, etc.)
- Backlinks con sintaxis [[wiki]]
- Formularios guiados por tipo de nota
### MVP-3 Sprint 1
- Usage tracking (vistas, clics, copias)
- Dashboard inteligente (Recientes, Más usadas, Por tipo)
- Scoring boost basado en uso
### MVP-3 Sprint 2
- Sugerencias automáticas de tags
- Panel "Conectado con" (backlinks, enlaces, relacionadas)
### MVP-3 Sprint 3
- Quick Add multilínea
- Pegado inteligente con detección de tipo
- Sugerencia automática de tipo de nota
- Sugerencia de enlaces internos
### MVP-3 Sprint 4
- Registro de co-uso entre notas
- Métricas internas (notas por tipo, más vistas, por origen)
- Cálculo de notas centrales (centrality score)
- Registro de origen de creación (form/quick/import)
- Feature flags configurables
### Mejoras UI Recientes
- Header responsive con menú hamburguesa en móvil
- Desktop: una fila con logo, nav links, QuickAdd y botón Nueva nota
- Mobile: logo + QuickAdd + hamburguesa → dropdown con nav links y botón Nueva nota
### MVP-4 Sprint 1
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
- Búsqueda en tiempo real con 300ms debounce
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
- Dropdown de resultados con cache de 50 entradas
### MVP-4 Sprint 2
- Sidebar contextual con co-uso (notas vistas juntas)
- Cache de resultados de búsqueda
### MVP-4 Sprint 3
- Historial de versiones de notas
- API de versiones (crear, listar, restaurar)
- UI de historial en diálogo de nota
### MVP-4 Sprint 4
- Tests de historial de versiones (11 tests)
## Algoritmo de Scoring
```typescript
// Search scoring
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
// Related notes scoring
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
// Centrality score
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
```
## Tests
**211 tests** cubriendo:
- API routes (CRUD, search, tags, etc.)
- Search y scoring
- Query parser
- Notas relacionadas
- Backlinks
- Type inference
- Link suggestions
- Usage tracking
- Dashboard
- Version history
## Comandos
```bash
npm run dev # Desarrollo
npm run build # Build producción
npm run test # Tests
npx prisma db push # Sync schema
npx prisma studio # UI de BD
```
## Configuración de Feature Flags
```bash
FLAG_CENTRALITY=true
FLAG_PASSIVE_RECOMMENDATIONS=true
FLAG_TYPE_SUGGESTIONS=true
FLAG_LINK_SUGGESTIONS=true
```
## Roadmap
- [ ] Panel de métricas visible en UI
- [ ] Configuración de feature flags en Settings
- [ ] Visualización del grafo de conocimiento
- [ ] Exportación mejorada (Markdown, HTML)
- [ ] Tests de integración E2E
+222
View File
@@ -0,0 +1,222 @@
# Recall - Resumen del Proyecto
## Fecha
2026-03-22
## Descripción
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
## Stack Tecnológico
- **Framework**: Next.js 16.2.1 con App Router
- **Base de datos**: SQLite via Prisma ORM
- **Lenguaje**: TypeScript
- **UI**: TailwindCSS + shadcn/ui components
- **Testing**: Jest
## Estructura del Proyecto
```
src/
├── app/ # Next.js App Router
│ ├── api/ # API routes
│ │ ├── notes/ # CRUD, versions, quick
│ │ ├── tags/ # Tags y sugerencias
│ │ ├── search/ # Búsqueda avanzada
│ │ ├── usage/ # Tracking de uso y co-uso
│ │ ├── metrics/ # Métricas internas
│ │ ├── centrality/ # Notas centrales
│ │ └── export-import/ # Import/export JSON
│ ├── notes/[id]/ # Detalle de nota
│ ├── edit/[id]/ # Edición de nota
│ └── new/ # Nueva nota
├── components/ # Componentes React
│ ├── ui/ # shadcn/ui components
│ ├── dashboard.tsx # Dashboard inteligente
│ ├── quick-add.tsx # Captura rápida
│ ├── note-form.tsx # Formulario de nota
│ ├── note-connections.tsx # Panel de conexiones
│ ├── related-notes.tsx # Notas relacionadas
│ ├── version-history.tsx # Historial de versiones
│ ├── track-note-view.tsx # Tracking de vistas
│ └── search-bar.tsx # Búsqueda en tiempo real
└── lib/ # Utilidades
├── prisma.ts # Cliente Prisma
├── usage.ts # Tracking de uso y co-uso
├── search.ts # Búsqueda con scoring
├── query-parser.ts # Parser de queries avanzadas
├── versions.ts # Historial de versiones
├── related.ts # Notas relacionadas
├── backlinks.ts # Sistema de enlaces [[wiki]]
├── tags.ts # Normalización y sugerencias
├── metrics.ts # Métricas de dashboard
├── centrality.ts # Cálculo de centralidad
├── type-inference.ts # Detección automática de tipo
├── link-suggestions.ts # Sugerencias de enlaces
├── features.ts # Feature flags
└── validators.ts # Zod schemas
```
## Modelos de Datos
### Note
```prisma
model Note {
id String @id @default(cuid())
title String
content String
type String @default("note")
isFavorite Boolean @default(false)
isPinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creationSource String @default("form")
}
```
### NoteUsage
```prisma
model NoteUsage {
id String @id @default(cuid())
noteId String
eventType String
query String?
createdAt DateTime @default(now())
}
```
### NoteCoUsage
```prisma
model NoteCoUsage {
id String @id @default(cuid())
fromNoteId String
toNoteId String
weight Int @default(1)
}
```
### NoteVersion
```prisma
model NoteVersion {
id String @id @default(cuid())
noteId String
title String
content String
createdAt DateTime @default(now())
}
```
### Backlink
```prisma
model Backlink {
sourceNoteId String
targetNoteId String
}
```
## APIs Principales
| Endpoint | Método | Descripción |
|----------|--------|-------------|
| `/api/notes` | GET, POST | Listar/crear notas |
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
| `/api/notes/quick` | POST | Creación rápida |
| `/api/notes/links` | GET | Sugerencias de enlaces |
| `/api/search` | GET | Búsqueda con scoring |
| `/api/tags` | GET | Listar/buscar tags |
| `/api/tags/suggest` | GET | Sugerencias automáticas |
| `/api/usage` | GET | Estadísticas de uso |
| `/api/usage/co-usage` | GET | Notas co-usadas |
| `/api/metrics` | GET | Métricas de dashboard |
| `/api/centrality` | GET | Notas más centrales |
## Features Implementadas
### MVP-1
- CRUD completo de notas
- Sistema de tags
- Búsqueda básica
### MVP-2
- Búsqueda avanzada con scoring
- Quick Add con prefijos (cmd:, snip:, etc.)
- Backlinks con sintaxis [[wiki]]
- Formularios guiados por tipo de nota
### MVP-3
- Usage tracking (vistas, clics, copias)
- Dashboard inteligente
- Scoring boost basado en uso
- Sugerencias automáticas de tags
- Panel "Conectado con"
- Quick Add multilínea
- Pegado inteligente con detección de tipo
- Sugerencia automática de tipo de nota
- Sugerencia de enlaces internos
- Registro de co-uso entre notas
- Métricas internas
- Cálculo de notas centrales
- Feature flags configurables
### MVP-4
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
- Búsqueda en tiempo real con 300ms debounce
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
- Dropdown de resultados con cache
- Sidebar contextual con co-uso
- Historial de versiones de notas
- Tests de historial de versiones
## Algoritmo de Scoring
```typescript
// Search scoring
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
// Related notes scoring
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
// Centrality score
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
```
## Tests
**211 tests** cubriendo:
- API routes (CRUD, search, tags, versions)
- Search y scoring
- Query parser
- Notas relacionadas
- Backlinks
- Type inference
- Link suggestions
- Usage tracking
- Dashboard
- Version history
## Comandos
```bash
npm run dev # Desarrollo
npm run build # Build producción
npm test # Tests (usar npx jest)
npx prisma db push # Sync schema
npx prisma studio # UI de BD
```
## Configuración de Feature Flags
```bash
FLAG_CENTRALITY=true
FLAG_PASSIVE_RECOMMENDATIONS=true
FLAG_TYPE_SUGGESTIONS=true
FLAG_LINK_SUGGESTIONS=true
```
## Roadmap
- [ ] Panel de métricas visible en UI
- [ ] Configuración de feature flags en Settings
- [ ] Visualización del grafo de conocimiento
- [ ] Exportación mejorada (Markdown, HTML)
- [ ] Tests E2E
+342
View File
@@ -0,0 +1,342 @@
# Recall - Resumen del Proyecto
## Fecha
2026-03-22
## Descripción
Recall es una aplicación de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
## Stack Tecnológico
- **Framework**: Next.js 16.2.1 con App Router + Turbopack
- **Base de datos**: SQLite via Prisma ORM
- **Lenguaje**: TypeScript
- **UI**: TailwindCSS + shadcn/ui components
- **Testing**: Jest (226 tests)
- **Notificaciones**: Sonner (toasts)
## Estructura del Proyecto
```
src/
├── app/
│ ├── api/
│ │ ├── notes/ # CRUD, versions, quick, backlinks, links, suggest
│ │ ├── tags/ # Tags y sugerencias
│ │ ├── search/ # Búsqueda avanzada
│ │ ├── usage/ # Tracking de uso y co-uso
│ │ ├── metrics/ # Métricas internas
│ │ ├── centrality/ # Notas centrales
│ │ ├── export-import/ # Import/export JSON, Markdown, HTML
│ │ ├── import-markdown/ # Importador Markdown mejorado
│ │ └── capture/ # Captura externa (bookmarklet)
│ ├── notes/[id]/ # Detalle de nota
│ ├── edit/[id]/ # Edición de nota
│ ├── new/ # Nueva nota
│ ├── capture/ # Página de confirmación de captura
│ └── settings/ # Configuración
├── components/
│ ├── ui/ # shadcn/ui components
│ ├── dashboard.tsx # Dashboard inteligente
│ ├── quick-add.tsx # Captura rápida
│ ├── note-form.tsx # Formulario de nota
│ ├── note-connections.tsx # Panel de conexiones
│ ├── note-list.tsx # Lista de notas
│ ├── keyboard-navigable-note-list.tsx # Lista con navegación teclado
│ ├── keyboard-hint.tsx # Hint de atajos
│ ├── related-notes.tsx # Notas relacionadas
│ ├── version-history.tsx # Historial de versiones
│ ├── track-note-view.tsx # Tracking de vistas
│ ├── search-bar.tsx # Búsqueda en tiempo real
│ ├── command-palette.tsx # Command palette (Ctrl+K)
│ ├── keyboard-shortcuts-dialog.tsx # Diálogo de atajos
│ ├── shortcuts-provider.tsx # Provider de shortcuts
│ ├── work-mode-toggle.tsx # Toggle modo trabajo
│ ├── draft-recovery-banner.tsx # Banner de recuperación
│ ├── backup-restore-dialog.tsx # Restore con preview
│ ├── backup-list.tsx # Lista de backups
│ ├── bookmarklet-instructions.tsx # Instrucciones del bookmarklet
│ ├── recent-context-list.tsx # Historial de navegación
│ ├── track-navigation-history.tsx # Tracking de historial
│ └── preferences-panel.tsx # Panel de preferencias
├── hooks/
│ ├── use-global-shortcuts.ts # Atajos globales
│ ├── use-note-list-keyboard.ts # Navegación teclado en listas
│ ├── use-unsaved-changes.ts # Guard de cambios sin guardar
│ └── ...
└── lib/
├── prisma.ts # Cliente Prisma
├── usage.ts # Tracking de uso y co-uso
├── search.ts # Búsqueda con scoring
├── query-parser.ts # Parser de queries avanzadas
├── versions.ts # Historial de versiones
├── related.ts # Notas relacionadas
├── backlinks.ts # Sistema de enlaces [[wiki]]
├── tags.ts # Normalización y sugerencias
├── metrics.ts # Métricas de dashboard
├── centrality.ts # Cálculo de centralidad
├── type-inference.ts # Detección automática de tipo
├── link-suggestions.ts # Sugerencias de enlaces
├── features.ts # Feature flags
├── validators.ts # Zod schemas
├── errors.ts # Manejo de errores
├── backup.ts # Snapshot de backup
├── backup-storage.ts # IndexedDB storage
├── backup-policy.ts # Política de retención
├── backup-validator.ts # Validación de backups
├── restore.ts # Restore de backups
├── drafts.ts # Borradores locales
├── work-mode.ts # Modo trabajo
├── navigation-history.ts # Historial de navegación
├── export-markdown.ts # Exportación Markdown
├── export-html.ts # Exportación HTML
├── import-markdown.ts # Importador Markdown
└── external-capture.ts # Captura externa
```
## Modelos de Datos
### Note
```prisma
model Note {
id String @id @default(cuid())
title String
content String
type String @default("note")
isFavorite Boolean @default(false)
isPinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creationSource String @default("form")
}
```
### NoteUsage
```prisma
model NoteUsage {
id String @id @default(cuid())
noteId String
eventType String
query String?
createdAt DateTime @default(now())
}
```
### NoteCoUsage
```prisma
model NoteCoUsage {
id String @id @default(cuid())
fromNoteId String
toNoteId String
weight Int @default(1)
}
```
### NoteVersion
```prisma
model NoteVersion {
id String @id @default(cuid())
noteId String
title String
content String
createdAt DateTime @default(now())
}
```
### Backlink
```prisma
model Backlink {
sourceNoteId String
targetNoteId String
}
```
## APIs Principales
| Endpoint | Método | Descripción |
|----------|--------|-------------|
| `/api/notes` | GET, POST | Listar/crear notas |
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD de nota |
| `/api/notes/[id]/versions` | GET, POST | Listar/crear versiones |
| `/api/notes/[id]/versions/[vid]` | GET, PUT | Ver/restaurar versión |
| `/api/notes/quick` | POST | Creación rápida |
| `/api/notes/links` | GET | Sugerencias de enlaces |
| `/api/search` | GET | Búsqueda con scoring |
| `/api/tags` | GET | Listar/buscar tags |
| `/api/tags/suggest` | GET | Sugerencias automáticas |
| `/api/usage` | GET | Estadísticas de uso |
| `/api/usage/co-usage` | GET | Notas co-usadas |
| `/api/metrics` | GET | Métricas de dashboard |
| `/api/centrality` | GET | Notas más centrales |
| `/api/export-import` | GET, POST | Export/Import (JSON, Markdown, HTML) |
| `/api/import-markdown` | POST | Importador Markdown mejorado |
| `/api/capture` | POST | Captura externa segura |
## Features Implementadas
### MVP-1
- CRUD completo de notas
- Sistema de tags
- Búsqueda básica
### MVP-2
- Búsqueda avanzada con scoring
- Quick Add con prefijos (cmd:, snip:, etc.)
- Backlinks con sintaxis [[wiki]]
- Formularios guiados por tipo de nota
### MVP-3
- Usage tracking (vistas, clics, copias)
- Dashboard inteligente
- Scoring boost basado en uso
- Sugerencias automáticas de tags
- Panel "Conectado con"
- Quick Add multilínea
- Pegado inteligente con detección de tipo
- Sugerencia automática de tipo de nota
- Sugerencia de enlaces internos
- Registro de co-uso entre notas
- Métricas internas
- Cálculo de notas centrales
- Feature flags configurables
### MVP-4
- Query parser para búsquedas avanzadas (`type:`, `tag:`, `is:favorite`, `is:pinned`)
- Búsqueda en tiempo real con 300ms debounce
- Navegación por teclado (↑↓ Enter ESC) estilo Spotlight
- Dropdown de resultados con cache
- Sidebar contextual con co-uso
- Historial de versiones de notas
### MVP-5 (Completo)
**Sprint 1 - Confianza total:**
- Sistema de backup automático (IndexedDB)
- Política de retención (max 10 backups, 30 días)
- Restore con preview y validación
- Backup previo automático pre-destructivo
- Guard de cambios sin guardar
**Sprint 2 - Flujo diario desde teclado:**
- Command Palette global (Ctrl+K / Cmd+K)
- Modelo de acciones para palette
- Shortcuts globales (g h, g n, n, /, ?)
- Navegación de listas por teclado (↑↓ Enter E F P)
**Sprint 3 - Contexto y continuidad:**
- Sidebar contextual persistente mejorada
- Modo trabajo con toggle
- Autosave de borradores locales
- Historial de navegación contextual
**Sprint 4 - Captura ubicua:**
- Bookmarklet para capturar desde cualquier web
- Página de confirmación de captura
- Endpoint seguro /api/capture con rate limiting
**P2 - Exportación, Importación y Settings:**
- Exportación Markdown con frontmatter
- Exportación HTML legible
- Importador Markdown mejorado (frontmatter, tags, wiki links)
- Importador Obsidian-compatible
- Centro de respaldo en Settings
- Panel de preferencias (backup on/off, retención, work mode)
- Tests de command palette y captura
- Validaciones y límites (50MB backup, 10K notas, etc)
## Algoritmo de Scoring
```typescript
// Search scoring
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
// Related notes scoring
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
// Centrality score
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
```
## Atajos de Teclado
| Atajo | Acción |
|-------|--------|
| `Ctrl+K` / `Cmd+K` | Command Palette |
| `g h` | Ir al Dashboard |
| `g n` | Ir a Notas |
| `n` | Nueva nota |
| `/` | Enfocar búsqueda |
| `?` | Mostrar ayuda |
| `↑↓` | Navegar listas |
| `Enter` | Abrir nota |
| `E` | Editar nota (en lista) |
| `F` | Favoritar nota (en lista) |
| `P` | Fijar nota (en lista) |
## Tests
**226 tests** cubriendo:
- API routes (CRUD, search, tags, versions)
- Search y scoring
- Query parser
- Notas relacionadas
- Backlinks
- Type inference
- Link suggestions
- Usage tracking
- Dashboard
- Version history
- Command items
- External capture
- Navigation history
## Comandos
```bash
npm run dev # Desarrollo
npm run build # Build producción
npm test # Tests (usar npx jest)
npx prisma db push # Sync schema
npx prisma studio # UI de BD
```
## Configuración de Feature Flags
```bash
FLAG_CENTRALITY=true
FLAG_PASSIVE_RECOMMENDATIONS=true
FLAG_TYPE_SUGGESTIONS=true
FLAG_LINK_SUGGESTIONS=true
```
## Estados de Implementación
| Feature | Estado |
|---------|--------|
| CRUD notas | ✅ |
| Tags | ✅ |
| Búsqueda avanzada | ✅ |
| Quick Add | ✅ |
| Backlinks [[wiki]] | ✅ |
| Usage tracking | ✅ |
| Dashboard inteligente | ✅ |
| Versiones de notas | ✅ |
| Command Palette | ✅ |
| Shortcuts globales | ✅ |
| Modo trabajo | ✅ |
| Backup/Restore | ✅ |
| Bookmarklet capture | ✅ |
| Export Markdown/HTML | ✅ |
| Import Markdown | ✅ |
| Settings completo | ✅ |
| Feature flags UI | ✅ |
| Tests | ✅ (226) |
## Commits Recientes
```
e66a678 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
8d56f34 feat: MVP-5 Sprint 4 - External Capture via Bookmarklet
a40ab18 feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts
cde0a14 feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
8c80a12 feat: MVP-5 Sprint 1 - Backup/Restore system
```
+431
View File
@@ -0,0 +1,431 @@
# Recall - Resumen Técnico Detallado
## Información General
**Nombre:** Recall
**Descripción:** Sistema de gestión de conocimiento personal (PKM) para captura y recuperación de notas, comandos, snippets y conocimiento técnico.
**Fecha de creación:** 2026-03-22
**Estado:** MVP-5 Completo
## Stack Tecnológico
| Componente | Tecnología | Versión |
|------------|-------------|---------|
| Framework | Next.js + App Router + Turbopack | 16.2.1 |
| Base de datos | SQLite via Prisma ORM | 5.22.0 |
| Lenguaje | TypeScript | 5.x |
| UI | TailwindCSS + shadcn/ui | 4.x / latest |
| Testing | Jest | 30.3.0 |
| Notificaciones | Sonner (toasts) | latest |
| IDE | VSCode / Cursor |
## Estructura del Proyecto
```
src/
├── app/
│ ├── api/
│ │ ├── notes/
│ │ │ ├── route.ts # GET, POST /api/notes
│ │ │ ├── [id]/route.ts # GET, PUT, DELETE /api/notes/:id
│ │ │ ├── quick/route.ts # POST /api/notes/quick
│ │ │ ├── links/route.ts # GET /api/notes/links
│ │ │ ├── suggest/route.ts # GET /api/notes/suggest
│ │ │ ├── backlinks/route.ts # GET /api/notes/:id/backlinks
│ │ │ └── versions/
│ │ │ ├── route.ts # GET, POST /api/notes/:id/versions
│ │ │ └── [versionId]/route.ts # GET, PUT
│ │ ├── tags/
│ │ │ ├── route.ts # GET /api/tags
│ │ │ └── suggest/route.ts # GET /api/tags/suggest
│ │ ├── search/route.ts # GET /api/search
│ │ ├── usage/
│ │ │ ├── route.ts # GET /api/usage
│ │ │ └── co-usage/route.ts # GET /api/usage/co-usage
│ │ ├── metrics/route.ts # GET /api/metrics
│ │ ├── centrality/route.ts # GET /api/centrality
│ │ ├── export-import/route.ts # GET, POST
│ │ ├── import-markdown/route.ts # POST
│ │ └── capture/route.ts # POST /api/capture
│ ├── notes/[id]/page.tsx # Detalle de nota
│ ├── edit/[id]/page.tsx # Edición de nota
│ ├── new/page.tsx # Nueva nota
│ ├── capture/page.tsx # Confirmación de captura
│ ├── settings/page.tsx # Configuración
│ └── page.tsx # Dashboard (raíz)
├── components/
│ ├── ui/ # Componentes shadcn/ui
│ ├── dashboard.tsx # Dashboard inteligente
│ ├── note-form.tsx # Formulario de notas
│ ├── note-card.tsx # Tarjeta de nota
│ ├── note-list.tsx # Lista de notas (grid)
│ ├── keyboard-navigable-note-list.tsx # Lista con navegación teclado
│ ├── note-connections.tsx # Panel de conexiones
│ ├── related-notes.tsx # Notas relacionadas
│ ├── version-history.tsx # Historial de versiones
│ ├── search-bar.tsx # Búsqueda en tiempo real
│ ├── command-palette.tsx # Command palette (Ctrl+K)
│ ├── keyboard-shortcuts-dialog.tsx # Diálogo de atajos
│ ├── shortcuts-provider.tsx # Provider de atajos
│ ├── keyboard-hint.tsx # Hint de atajos
│ ├── work-mode-toggle.tsx # Toggle modo trabajo
│ ├── draft-recovery-banner.tsx # Banner de recuperación
│ ├── backup-restore-dialog.tsx # Restore con preview
│ ├── backup-list.tsx # Lista de backups
│ ├── bookmarklet-instructions.tsx # Instrucciones bookmarklet
│ ├── recent-context-list.tsx # Historial de navegación
│ ├── track-navigation-history.tsx # Tracking de historial
│ ├── preferences-panel.tsx # Panel de preferencias
│ ├── markdown-content.tsx # Contenido con highlight
│ ├── quick-add.tsx # Captura rápida
│ └── track-note-view.tsx # Tracking de vistas
├── hooks/
│ ├── use-global-shortcuts.ts # Atajos globales
│ ├── use-note-list-keyboard.ts # Navegación teclado
│ └── use-unsaved-changes.ts # Guard de cambios sin guardar
└── lib/
├── prisma.ts # Cliente Prisma
├── search.ts # Búsqueda con scoring
├── query-parser.ts # Parser de queries
├── related.ts # Notas relacionadas
├── backlinks.ts # Sistema de enlaces [[wiki]]
├── tags.ts # Normalización y sugerencias
├── usage.ts # Tracking de uso
├── metrics.ts # Métricas de dashboard
├── centrality.ts # Cálculo de centralidad
├── type-inference.ts # Detección automática de tipo
├── link-suggestions.ts # Sugerencias de enlaces
├── features.ts # Feature flags
├── validators.ts # Zod schemas
├── errors.ts # Manejo de errores
├── versions.ts # Historial de versiones
├── backup.ts # Snapshot de backup
├── backup-storage.ts # IndexedDB storage
├── backup-policy.ts # Política de retención
├── backup-validator.ts # Validación de backups
├── restore.ts # Restore de backups
├── drafts.ts # Borradores locales
├── work-mode.ts # Modo trabajo
├── navigation-history.ts # Historial de navegación
├── export-markdown.ts # Exportación Markdown
├── export-html.ts # Exportación HTML
├── import-markdown.ts # Importador Markdown
├── external-capture.ts # Captura externa
├── templates.ts # Templates por tipo
├── command-items.ts # Items de command palette
└── command-groups.ts # Grupos de comandos
```
## Modelos de Datos (Prisma)
### Note
```prisma
model Note {
id String @id @default(cuid())
title String
content String
type String @default("note")
isFavorite Boolean @default(false)
isPinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creationSource String @default("form")
}
```
### NoteUsage
```prisma
model NoteUsage {
id String @id @default(cuid())
noteId String
eventType String
query String?
createdAt DateTime @default(now())
}
```
### NoteCoUsage
```prisma
model NoteCoUsage {
id String @id @default(cuid())
fromNoteId String
toNoteId String
weight Int @default(1)
}
```
### NoteVersion
```prisma
model NoteVersion {
id String @id @default(cuid())
noteId String
title String
content String
createdAt DateTime @default(now())
}
```
### Backlink
```prisma
model Backlink {
sourceNoteId String
targetNoteId String
}
```
## APIs REST
| Endpoint | Método | Descripción |
|----------|--------|-------------|
| `/api/notes` | GET, POST | Listar/crear notas |
| `/api/notes/[id]` | GET, PUT, DELETE | CRUD nota |
| `/api/notes/quick` | POST | Creación rápida |
| `/api/notes/links` | GET | Sugerencias de enlaces |
| `/api/notes/suggest` | GET | Sugerencias automática |
| `/api/notes/[id]/versions` | GET, POST | Versiones |
| `/api/notes/[id]/backlinks` | GET | Backlinks |
| `/api/search` | GET | Búsqueda avanzada |
| `/api/tags` | GET | Tags |
| `/api/tags/suggest` | GET | Sugerencias de tags |
| `/api/usage` | GET | Uso de notas |
| `/api/usage/co-usage` | GET | Co-uso entre notas |
| `/api/metrics` | GET | Métricas dashboard |
| `/api/centrality` | GET | Notas centrales |
| `/api/export-import` | GET, POST | Export/Import |
| `/api/import-markdown` | POST | Importar Markdown |
| `/api/capture` | POST | Captura externa |
## Features Implementadas
### MVP-1: Fundamentos
- [x] CRUD completo de notas
- [x] Sistema de tags
- [x] Búsqueda básica
### MVP-2: Captura Inteligente
- [x] Búsqueda avanzada con scoring
- [x] Quick Add con prefijos (cmd:, snip:, etc.)
- [x] Backlinks con sintaxis [[wiki]]
- [x] Formularios guiados por tipo
- [x] Templates inteligentes por tipo
- [x] Vista command con copiar
- [x] Vista snippet con syntax highlight
- [x] Checklist interactivo en procedure
### MVP-3: Uso y Contexto
- [x] Usage tracking (vistas, clics, copias)
- [x] Dashboard inteligente
- [x] Scoring boost basado en uso
- [x] Sugerencias automáticas de tags
- [x] Panel "Conectado con"
- [x] Quick Add multilínea
- [x] Pegado inteligente con detección de tipo
- [x] Sugerencia automática de tipo
- [x] Sugerencia de enlaces internos
- [x] Registro de co-uso entre notas
- [x] Métricas internas
- [x] Cálculo de notas centrales
- [x] Feature flags configurables
### MVP-4: Query Parser y Navegación
- [x] Query parser (`type:`, `tag:`, `is:favorite`, `is:pinned`)
- [x] Búsqueda en tiempo real (300ms debounce)
- [x] Navegación por teclado (↑↓ Enter ESC)
- [x] Dropdown de resultados con cache
- [x] Sidebar contextual con co-uso
- [x] Historial de versiones
- [x] Preload de notas en hover
### MVP-5: Flujo Diario y Portabilidad
**Sprint 1 - Confianza:**
- [x] Sistema de backup automático (IndexedDB)
- [x] Política de retención (max 10 backups, 30 días)
- [x] Restore con preview y validación
- [x] Backup previo automático
- [x] Guard de cambios sin guardar
**Sprint 2 - Shortcuts Globales:**
- [x] Command Palette (Ctrl+K / Cmd+K)
- [x] Shortcuts: g h, g n, n, /, ?
- [x] Navegación de listas por teclado
**Sprint 3 - Contexto y Continuidad:**
- [x] Sidebar contextual persistente
- [x] Modo trabajo con toggle
- [x] Autosave de borradores locales
- [x] Historial de navegación contextual
**Sprint 4 - Captura Ubicua:**
- [x] Bookmarklet para capturar desde web
- [x] Página de confirmación
- [x] Endpoint seguro con rate limiting
**P2 - Exportación e Importación:**
- [x] Exportación Markdown con frontmatter
- [x] Exportación HTML legible
- [x] Importador Markdown mejorado
- [x] Centro de respaldo en Settings
- [x] Panel de preferencias
- [x] Validaciones y límites
## Algoritmos de Scoring
### Búsqueda
```
score = baseScore + favoriteBoost(+2) + pinnedBoost(+1) + usageBoost
```
### Notas Relacionadas
```
score = sameType(+3) + sharedTags(×3) + titleKeywords(max+3) + contentKeywords(max+2) + usageBoost
```
### Centralidad
```
centrality = backlinks(×3) + outboundLinks(×1) + usageViews(×0.5) + coUsageWeight(×2)
```
## Atajos de Teclado
| Atajo | Acción |
|-------|--------|
| `Ctrl+K` / `Cmd+K` | Command Palette |
| `g h` | Ir al Dashboard |
| `g n` | Ir a Notas |
| `n` | Nueva nota |
| `/` | Enfocar búsqueda |
| `?` | Mostrar ayuda |
| `↑↓` | Navegar listas |
| `Enter` | Abrir nota |
| `E` | Editar nota |
| `F` | Favoritar nota |
| `P` | Fijar nota |
## Tipos de Nota
| Tipo | Descripción | Color |
|------|-------------|-------|
| `note` | Nota general | Slate |
| `command` | Comando o snippet ejecutable | Green |
| `snippet` | Fragmento de código | Blue |
| `decision` | Decisión tomada | Purple |
| `recipe` | Receta o procedimiento | Orange |
| `procedure` | Procedimiento con checkboxes | Yellow |
| `inventory` | Inventario o lista | Gray |
## Comandos npm
```bash
npm run dev # Desarrollo (Turbopack)
npm run build # Build producción
npm run start # Iniciar producción
npm test # Tests (Jest)
npx jest --watch # Tests en watch mode
npx prisma db push # Sync schema a BD
npx prisma studio # UI de base de datos
npx prisma generate # Generar tipos
```
## Tests
**226 tests** organizados en:
- `__tests__/api.*.test.ts` - Tests de integración de APIs
- `__tests__/search.test.ts` - Búsqueda y scoring
- `__tests__/query-parser.test.ts` - Parser de queries
- `__tests__/related.test.ts` - Notas relacionadas
- `__tests__/backlinks.test.ts` - Sistema de enlaces
- `__tests__/tags.test.ts` - Tags y sugerencias
- `__tests__/usage.test.ts` - Tracking de uso
- `__tests__/versions.test.ts` - Historial de versiones
- `__tests__/dashboard.test.ts` - Dashboard
- `__tests__/command-items.test.ts` - Command palette
- `__tests__/external-capture.test.ts` - Captura externa
- `__tests__/navigation-history.test.ts` - Historial
- `__tests__/link-suggestions.test.ts` - Sugerencias de enlaces
- `__tests__/type-inference.test.ts` - Inferencia de tipo
- `__tests__/quick-add.test.ts` - Quick Add
## Feature Flags
Configurables via `localStorage` o `.env`:
```bash
FLAG_CENTRALITY=true # Habilitar centralidad
FLAG_PASSIVE_RECOMMENDATIONS=true # Recomendaciones pasivas
FLAG_TYPE_SUGGESTIONS=true # Sugerencias de tipo
FLAG_LINK_SUGGESTIONS=true # Sugerencias de enlaces
```
## Límites del Sistema
| Recurso | Límite |
|---------|--------|
| Tamaño de backup | 50MB |
| Cantidad de notas | 10,000 |
| Longitud de título (captura) | 500 chars |
| Longitud de URL (captura) | 2000 chars |
| Longitud de selección (captura) | 10,000 chars |
| Backups retenidos | 10 máximo |
| Días de retención | 30 días |
## Commits del Proyecto
```
33a4705 feat: MVP-4 P2 - Preload notes on hover
e66a678 feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
8d56f34 feat: MVP-5 Sprint 4 - External Capture via Bookmarklet
a40ab18 feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts
cde0a14 feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
8c80a12 feat: MVP-5 Sprint 1 - Backup/Restore system
6694bce mvp
af0910f feat: initial commit
f2e4706 Initial commit
```
## Dependencias Principales
```json
{
"next": "16.2.1",
"@prisma/client": "5.22.0",
"prisma": "5.22.0",
"sonner": "latest",
"zod": "latest",
"tailwindcss": "4.x",
"@radix-ui/react-*": "latest"
}
```
## Patrones de Diseño
- **Server Components** para páginas estáticas
- **Client Components** para interactividad
- **Hooks personalizados** para lógica reutilizable
- **Feature Flags** para features opcionales
- **IndexedDB** para persistencia local de backups
- **localStorage** para preferencias y borradores
## Estado de Implementación
| Feature | Estado |
|---------|--------|
| CRUD notas | ✅ |
| Tags | ✅ |
| Búsqueda avanzada | ✅ |
| Quick Add | ✅ |
| Backlinks [[wiki]] | ✅ |
| Usage tracking | ✅ |
| Dashboard inteligente | ✅ |
| Versiones de notas | ✅ |
| Command Palette | ✅ |
| Shortcuts globales | ✅ |
| Modo trabajo | ✅ |
| Backup/Restore | ✅ |
| Bookmarklet capture | ✅ |
| Export Markdown/HTML | ✅ |
| Import Markdown | ✅ |
| Settings completo | ✅ |
| Feature flags UI | ✅ |
| Tests | ✅ (226) |
| Preload on hover | ✅ |
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { createSuccessResponse, createErrorResponse } from '@/lib/errors'
const captureSchema = z.object({
title: z.string().min(1).max(500),
url: z.string().url().optional(),
selection: z.string().optional(),
source: z.enum(['bookmarklet', 'extension', 'api']).default('api'),
})
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const result = captureSchema.safeParse(body)
if (!result.success) {
return createErrorResponse(result.error)
}
return createSuccessResponse(result.data)
} catch (error) {
return createErrorResponse(error)
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextRequest } from 'next/server'
import { getCentralNotes } from '@/lib/centrality'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const limit = parseInt(searchParams.get('limit') || '10', 10)
const centralNotes = await getCentralNotes(limit)
return createSuccessResponse(centralNotes)
} catch (error) {
return createErrorResponse(error)
}
}
+54 -8
View File
@@ -3,9 +3,20 @@ import { prisma } from '@/lib/prisma'
import { noteSchema, NoteInput } from '@/lib/validators' import { noteSchema, NoteInput } from '@/lib/validators'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors' import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
import { syncBacklinks } from '@/lib/backlinks' import { syncBacklinks } from '@/lib/backlinks'
import { createBackupSnapshot } from '@/lib/backup'
import { notesToMarkdownZip, noteToMarkdown } from '@/lib/export-markdown'
import { notesToHtmlZip, noteToHtml } from '@/lib/export-html'
export async function GET() { export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url)
const format = searchParams.get('format')
if (format === 'backup') {
const backup = await createBackupSnapshot('manual')
return createSuccessResponse(backup)
}
const notes = await prisma.note.findMany({ const notes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } }, include: { tags: { include: { tag: true } } },
}) })
@@ -17,6 +28,38 @@ export async function GET() {
updatedAt: note.updatedAt.toISOString(), updatedAt: note.updatedAt.toISOString(),
})) }))
if (format === 'markdown') {
const notesForExport = notes.map(note => ({
...note,
tags: note.tags,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
}))
if (notes.length === 1) {
return createSuccessResponse({
filename: notesToMarkdownZip(notesForExport).files[0].name,
content: noteToMarkdown(notesForExport[0]),
})
}
return createSuccessResponse(notesToMarkdownZip(notesForExport))
}
if (format === 'html') {
const notesForExport = notes.map(note => ({
...note,
tags: note.tags,
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
}))
if (notes.length === 1) {
return createSuccessResponse({
filename: notesToHtmlZip(notesForExport).files[0].name,
content: noteToHtml(notesForExport[0]),
})
}
return createSuccessResponse(notesToHtmlZip(notesForExport))
}
return createSuccessResponse(exportData) return createSuccessResponse(exportData)
} catch (error) { } catch (error) {
return createErrorResponse(error) return createErrorResponse(error)
@@ -62,23 +105,25 @@ export async function POST(req: NextRequest) {
const createdAt = parseDate((item as { createdAt?: string }).createdAt) const createdAt = parseDate((item as { createdAt?: string }).createdAt)
const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt) const updatedAt = parseDate((item as { updatedAt?: string }).updatedAt)
const itemWithId = item as { id?: string }
if (item.id) { if (itemWithId.id) {
const existing = await tx.note.findUnique({ where: { id: item.id } }) const existing = await tx.note.findUnique({ where: { id: itemWithId.id } })
if (existing) { if (existing) {
await tx.note.update({ await tx.note.update({
where: { id: item.id }, where: { id: itemWithId.id },
data: { ...noteData, createdAt, updatedAt }, data: { ...noteData, createdAt, updatedAt },
}) })
await tx.noteTag.deleteMany({ where: { noteId: item.id } }) await tx.noteTag.deleteMany({ where: { noteId: itemWithId.id } })
processed++ processed++
} else { } else {
await tx.note.create({ await tx.note.create({
data: { data: {
...noteData, ...noteData,
id: item.id, id: itemWithId.id,
createdAt, createdAt,
updatedAt, updatedAt,
creationSource: 'import',
}, },
}) })
processed++ processed++
@@ -99,14 +144,15 @@ export async function POST(req: NextRequest) {
...noteData, ...noteData,
createdAt, createdAt,
updatedAt, updatedAt,
creationSource: 'import',
}, },
}) })
} }
processed++ processed++
} }
const noteId = item.id const noteId = itemWithId.id
? (await tx.note.findUnique({ where: { id: item.id } }))?.id ? (await tx.note.findUnique({ where: { id: itemWithId.id } }))?.id
: (await tx.note.findFirst({ where: { title: item.title } }))?.id : (await tx.note.findFirst({ where: { title: item.title } }))?.id
if (noteId && tags.length > 0) { if (noteId && tags.length > 0) {
+138
View File
@@ -0,0 +1,138 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
import { syncBacklinks } from '@/lib/backlinks'
import { parseMarkdownContent, convertWikiLinksToMarkdown, extractInlineTags } from '@/lib/import-markdown'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
if (!Array.isArray(body)) {
throw new ValidationError([{ path: 'body', message: 'Invalid format: expected array of markdown strings or objects' }])
}
const parseDate = (dateStr: string | undefined): Date => {
if (!dateStr) return new Date()
const parsed = new Date(dateStr)
return isNaN(parsed.getTime()) ? new Date() : parsed
}
let processed = 0
const errors: string[] = []
await prisma.$transaction(async (tx) => {
for (let i = 0; i < body.length; i++) {
const item = body[i]
// Handle both string markdown and object with markdown + filename
let markdown: string
let filename: string | undefined
if (typeof item === 'string') {
markdown = item
} else if (typeof item === 'object' && item !== null) {
markdown = item.markdown || item.content || item.body || ''
filename = item.filename
} else {
errors.push(`Item ${i}: Invalid format`)
continue
}
if (!markdown || typeof markdown !== 'string') {
errors.push(`Item ${i}: Empty or invalid markdown`)
continue
}
try {
const parsed = parseMarkdownContent(markdown, filename)
// Convert wiki links
let content = convertWikiLinksToMarkdown(parsed.content)
// Extract inline tags if none in frontmatter
const tags = parsed.frontmatter.tags || []
const inlineTags = extractInlineTags(content)
const allTags = [...new Set([...tags, ...inlineTags])]
const title = parsed.title || 'Untitled'
const type = parsed.frontmatter.type || 'note'
const createdAt = parseDate(parsed.frontmatter.createdAt)
const updatedAt = parseDate(parsed.frontmatter.updatedAt)
// Check for existing note by title
const existingByTitle = await tx.note.findFirst({
where: { title },
})
let noteId: string
if (existingByTitle) {
await tx.note.update({
where: { id: existingByTitle.id },
data: {
content,
type,
createdAt,
updatedAt,
isFavorite: parsed.frontmatter.favorite ?? existingByTitle.isFavorite,
isPinned: parsed.frontmatter.pinned ?? existingByTitle.isPinned,
},
})
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
noteId = existingByTitle.id
} else {
const note = await tx.note.create({
data: {
title,
content,
type,
createdAt,
updatedAt,
isFavorite: parsed.frontmatter.favorite ?? false,
isPinned: parsed.frontmatter.pinned ?? false,
creationSource: 'import',
},
})
noteId = note.id
}
// Add tags
for (const tagName of allTags) {
if (!tagName) continue
const tag = await tx.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
})
await tx.noteTag.create({
data: { noteId, tagId: tag.id },
})
}
// Sync backlinks
const note = await tx.note.findUnique({ where: { id: noteId } })
if (note) {
await syncBacklinks(note.id, note.content)
}
processed++
} catch (err) {
errors.push(`Item ${i}: ${err instanceof Error ? err.message : 'Parse error'}`)
}
}
})
if (errors.length > 0 && processed === 0) {
throw new ValidationError(errors)
}
return createSuccessResponse({
success: true,
count: processed,
warnings: errors.length > 0 ? errors : undefined,
}, 201)
} catch (error) {
return createErrorResponse(error)
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextRequest } from 'next/server'
import { getDashboardMetrics } from '@/lib/metrics'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const days = parseInt(searchParams.get('days') || '30', 10)
const metrics = await getDashboardMetrics(days)
return createSuccessResponse(metrics)
} catch (error) {
return createErrorResponse(error)
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { updateNoteSchema } from '@/lib/validators' import { updateNoteSchema } from '@/lib/validators'
import { syncBacklinks } from '@/lib/backlinks' import { syncBacklinks } from '@/lib/backlinks'
import { createVersion } from '@/lib/versions'
import { createErrorResponse, createSuccessResponse, NotFoundError, ValidationError } from '@/lib/errors' import { createErrorResponse, createSuccessResponse, NotFoundError, ValidationError } from '@/lib/errors'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -32,13 +33,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
throw new ValidationError(result.error.issues) throw new ValidationError(result.error.issues)
} }
const { tags, ...noteData } = result.data const { tags, id: _, ...noteData } = result.data
const existingNote = await prisma.note.findUnique({ where: { id } }) const existingNote = await prisma.note.findUnique({ where: { id } })
if (!existingNote) { if (!existingNote) {
throw new NotFoundError('Note') throw new NotFoundError('Note')
} }
await createVersion(id)
await prisma.noteTag.deleteMany({ where: { noteId: id } }) await prisma.noteTag.deleteMany({ where: { noteId: id } })
const note = await prisma.note.update({ const note = await prisma.note.update({
@@ -0,0 +1,23 @@
import { NextRequest } from 'next/server'
import { getVersion, restoreVersion } from '@/lib/versions'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) {
try {
const { versionId } = await params
const version = await getVersion(versionId)
return createSuccessResponse(version)
} catch (error) {
return createErrorResponse(error)
}
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) {
try {
const { id, versionId } = await params
const note = await restoreVersion(id, versionId)
return createSuccessResponse(note)
} catch (error) {
return createErrorResponse(error)
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest } from 'next/server'
import { createVersion, getVersions } from '@/lib/versions'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const versions = await getVersions(id)
return createSuccessResponse(versions)
} catch (error) {
return createErrorResponse(error)
}
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const version = await createVersion(id)
return createSuccessResponse(version, 201)
} catch (error) {
return createErrorResponse(error)
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const content = searchParams.get('content') || ''
const noteId = searchParams.get('noteId') || ''
if (!content.trim() || content.length < 10) {
return createSuccessResponse([])
}
// Get all notes except current one
const allNotes = await prisma.note.findMany({
where: noteId ? { id: { not: noteId } } : undefined,
select: { id: true, title: true },
})
if (allNotes.length === 0) {
return createSuccessResponse([])
}
// Find titles that appear in content
const suggestions: { term: string; noteId: string; noteTitle: string }[] = []
const contentLower = content.toLowerCase()
for (const note of allNotes) {
const titleLower = note.title.toLowerCase()
// Check if title appears as a whole word in content
const escaped = titleLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`\\b${escaped}\\b`, 'i')
if (regex.test(content)) {
suggestions.push({
term: note.title,
noteId: note.id,
noteTitle: note.title,
})
}
}
// Sort by title length (longer = more specific)
suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length)
return createSuccessResponse(suggestions.slice(0, 10))
} catch (error) {
return createErrorResponse(error)
}
}
+1
View File
@@ -31,6 +31,7 @@ export async function POST(req: NextRequest) {
title: title.trim(), title: title.trim(),
content: noteContent || title.trim(), content: noteContent || title.trim(),
type, type,
creationSource: 'quick',
tags: tags.length > 0 ? { tags: tags.length > 0 ? {
create: await Promise.all( create: await Promise.all(
tags.map(async (tagName) => { tags.map(async (tagName) => {
+4 -3
View File
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { noteSchema } from '@/lib/validators' import { noteSchema } from '@/lib/validators'
import { normalizeTag } from '@/lib/tags' import { normalizeTag } from '@/lib/tags'
import { noteQuery } from '@/lib/search' import { searchNotes } from '@/lib/search'
import { syncBacklinks } from '@/lib/backlinks' import { syncBacklinks } from '@/lib/backlinks'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors' import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
const tag = searchParams.get('tag') || undefined const tag = searchParams.get('tag') || undefined
if (q || type || tag) { if (q || type || tag) {
const notes = await noteQuery(q, { type, tag }) const notes = await searchNotes(q, { type, tag })
return createSuccessResponse(notes) return createSuccessResponse(notes)
} }
@@ -37,11 +37,12 @@ export async function POST(req: NextRequest) {
throw new ValidationError(result.error.issues) throw new ValidationError(result.error.issues)
} }
const { tags, ...noteData } = result.data const { tags, creationSource, ...noteData } = result.data
const note = await prisma.note.create({ const note = await prisma.note.create({
data: { data: {
...noteData, ...noteData,
creationSource: creationSource || 'form',
tags: tags && tags.length > 0 ? { tags: tags && tags.length > 0 ? {
create: await Promise.all( create: await Promise.all(
(tags as string[]).map(async (tagName) => { (tags as string[]).map(async (tagName) => {
+3 -3
View File
@@ -1,15 +1,15 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { searchNotes } from '@/lib/search' import { searchNotes } from '@/lib/search'
import { parseQuery } from '@/lib/query-parser'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors' import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const q = searchParams.get('q') || '' const q = searchParams.get('q') || ''
const type = searchParams.get('type') || undefined
const tag = searchParams.get('tag') || undefined
const notes = await searchNotes(q, { type, tag }) const queryAST = parseQuery(q)
const notes = await searchNotes(queryAST.text, queryAST.filters)
return createSuccessResponse(notes) return createSuccessResponse(notes)
} catch (error) { } catch (error) {
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest } from 'next/server'
import { trackCoUsage } from '@/lib/usage'
import { createErrorResponse } from '@/lib/errors'
export async function POST(req: NextRequest) {
try {
const { fromNoteId, toNoteId } = await req.json()
if (!fromNoteId || !toNoteId) {
return createErrorResponse(new Error('Missing note IDs'))
}
await trackCoUsage(fromNoteId, toNoteId)
return new Response(null, { status: 204 })
} catch (error) {
return createErrorResponse(error)
}
}
+195
View File
@@ -0,0 +1,195 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Card } from '@/components/ui/card'
import { Loader2, Bookmark } from 'lucide-react'
function CaptureForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [selection, setSelection] = useState('')
const [content, setContent] = useState('')
const [tags, setTags] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
const titleParam = searchParams.get('title') || ''
const urlParam = searchParams.get('url') || ''
const selectionParam = searchParams.get('selection') || ''
setTitle(titleParam)
setUrl(urlParam)
setSelection(selectionParam)
// Pre-fill content with captured web content
if (selectionParam) {
setContent(`## Web Selection\n\n${selectionParam}\n\n## Source\n\n${urlParam}`)
} else {
setContent(`## Source\n\n${urlParam}`)
}
}, [searchParams])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!content.trim() || isLoading) return
setIsLoading(true)
try {
// Build the full content with optional tags
const fullContent = tags.trim()
? `${content}\n\n## Tags\n\n${tags.trim().split(',').map(t => `#${t.trim()}`).join(' ')}`
: content
const response = await fetch('/api/notes/quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `web: ${title || url}\n\n${fullContent}`,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Error creating note')
}
toast.success('Nota creada desde web', {
description: title || url,
})
router.push('/notes')
router.refresh()
} catch (error) {
toast.error('Error', {
description: error instanceof Error ? error.message : 'No se pudo crear la nota',
})
} finally {
setIsLoading(false)
}
}
const bookmarkletCode = `javascript:var title = document.title; var url = location.href; var selection = window.getSelection().toString(); var params = new URLSearchParams({title, url, selection}); window.open('/capture?' + params.toString(), '_blank');`
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData('text/plain', bookmarkletCode)
e.dataTransfer.effectAllowed = 'copy'
setIsDragging(true)
}
const handleDragEnd = () => {
setIsDragging(false)
}
return (
<div className="container mx-auto py-8 px-4 max-w-2xl">
<div className="flex items-center gap-2 mb-6">
<Bookmark className="h-6 w-6" />
<h1 className="text-2xl font-bold">Capturar desde web</h1>
</div>
<Card className="p-4 mb-6 bg-muted/50">
<p className="text-sm text-muted-foreground mb-2">Arrastra este botón a tu barra de marcadores:</p>
<button
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
className={`px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium cursor-grab active:cursor-grabbing transition-all ${
isDragging ? 'opacity-50 scale-95' : ''
}`}
>
Capturar a Recall
</button>
</Card>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Título</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Título de la página"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">URL</label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
type="url"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Selección</label>
<Textarea
value={selection}
onChange={(e) => setSelection(e.target.value)}
placeholder="Texto seleccionado de la página..."
rows={4}
className="font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Contenido</label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Contenido adicional..."
rows={8}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tags (separados por coma)</label>
<Input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="web, referencia, artículo"
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={!content.trim() || isLoading}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Guardando...
</>
) : (
'Crear nota'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push('/notes')}
>
Cancelar
</Button>
</div>
</form>
</div>
)
}
export default function CapturePage() {
return (
<Suspense fallback={
<div className="container mx-auto py-8 px-4 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
}>
<CaptureForm />
</Suspense>
)
}
+4
View File
@@ -2,6 +2,8 @@ import type { Metadata } from 'next'
import './globals.css' import './globals.css'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { Header } from '@/components/header' import { Header } from '@/components/header'
import { CommandPalette } from '@/components/command-palette'
import { ShortcutsProvider } from '@/components/shortcuts-provider'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Recall - Gestor de Conocimiento Personal', title: 'Recall - Gestor de Conocimiento Personal',
@@ -19,6 +21,8 @@ export default function RootLayout({
<Header /> <Header />
{children} {children}
<Toaster /> <Toaster />
<CommandPalette />
<ShortcutsProvider />
</body> </body>
</html> </html>
) )
+17 -4
View File
@@ -1,10 +1,14 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { RelatedNotes } from '@/components/related-notes'
import { getRelatedNotes } from '@/lib/related' import { getRelatedNotes } from '@/lib/related'
import { getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks'
import { getCoUsedNotes } from '@/lib/usage'
import { NoteConnections } from '@/components/note-connections'
import { MarkdownContent } from '@/components/markdown-content' import { MarkdownContent } from '@/components/markdown-content'
import { DeleteNoteButton } from '@/components/delete-note-button' import { DeleteNoteButton } from '@/components/delete-note-button'
import { TrackNoteView } from '@/components/track-note-view' import { TrackNoteView } from '@/components/track-note-view'
import { TrackNavigationHistory } from '@/components/track-navigation-history'
import { VersionHistory } from '@/components/version-history'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -33,11 +37,15 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
} }
const related = await getRelatedNotes(id, 5) const related = await getRelatedNotes(id, 5)
const backlinks = await getBacklinksForNote(id)
const outgoingLinks = await getOutgoingLinksForNote(id)
const coUsedNotes = await getCoUsedNotes(id, 5)
const noteType = note.type as NoteType const noteType = note.type as NoteType
return ( return (
<> <>
<TrackNoteView noteId={note.id} /> <TrackNoteView noteId={note.id} />
<TrackNavigationHistory noteId={note.id} title={note.title} type={note.type} />
<main className="container mx-auto py-8 px-4 max-w-4xl"> <main className="container mx-auto py-8 px-4 max-w-4xl">
<div className="mb-6"> <div className="mb-6">
<Link href="/notes"> <Link href="/notes">
@@ -68,6 +76,7 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
<Edit className="h-4 w-4 mr-1" /> Editar <Edit className="h-4 w-4 mr-1" /> Editar
</Button> </Button>
</Link> </Link>
<VersionHistory noteId={note.id} />
<DeleteNoteButton noteId={note.id} noteTitle={note.title} /> <DeleteNoteButton noteId={note.id} noteTitle={note.title} />
</div> </div>
</div> </div>
@@ -92,9 +101,13 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
/> />
</div> </div>
{related.length > 0 && ( <NoteConnections
<RelatedNotes notes={related} /> noteId={note.id}
)} backlinks={backlinks}
outgoingLinks={outgoingLinks}
relatedNotes={related}
coUsedNotes={coUsedNotes}
/>
</main> </main>
</> </>
) )
+4 -2
View File
@@ -1,5 +1,6 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { NoteList } from '@/components/note-list' import { KeyboardNavigableNoteList } from '@/components/keyboard-navigable-note-list'
import { KeyboardHint } from '@/components/keyboard-hint'
import { SearchBar } from '@/components/search-bar' import { SearchBar } from '@/components/search-bar'
import { TagFilter } from '@/components/tag-filter' import { TagFilter } from '@/components/tag-filter'
import { NoteType } from '@/types/note' import { NoteType } from '@/types/note'
@@ -86,7 +87,8 @@ export default async function NotesPage({ searchParams }: { searchParams: Promis
</div> </div>
)} )}
<NoteList notes={notesWithTags} /> <KeyboardNavigableNoteList notes={notesWithTags} />
<KeyboardHint />
</main> </main>
) )
} }
+2
View File
@@ -1,6 +1,8 @@
import { Dashboard } from '@/components/dashboard' import { Dashboard } from '@/components/dashboard'
import { getDashboardData } from '@/lib/dashboard' import { getDashboardData } from '@/lib/dashboard'
export const dynamic = 'force-dynamic'
export default async function HomePage() { export default async function HomePage() {
const data = await getDashboardData(6) const data = await getDashboardData(6)
+109 -21
View File
@@ -1,10 +1,12 @@
'use client' 'use client'
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { Download, Upload } from 'lucide-react' import { Download, Upload, History, FileText, Code, FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BackupList } from '@/components/backup-list'
import { PreferencesPanel } from '@/components/preferences-panel'
function parseMarkdownToNote(content: string, filename: string) { function parseMarkdownToNote(content: string, filename: string) {
const lines = content.split('\n') const lines = content.split('\n')
@@ -27,31 +29,56 @@ function parseMarkdownToNote(content: string, filename: string) {
export default function SettingsPage() { export default function SettingsPage() {
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [exporting, setExporting] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const handleExport = async () => { const handleExport = async (format: 'json' | 'markdown' | 'html') => {
setExporting(format)
try { try {
const response = await fetch('/api/export-import') const response = await fetch(`/api/export-import?format=${format}`)
if (!response.ok) { if (!response.ok) {
throw new Error('Error al exportar') throw new Error('Error al exportar')
} }
const data = await response.json() const data = await response.json()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) let blob: Blob
const url = URL.createObjectURL(blob) let filename: string
const date = new Date().toISOString().split('T')[0] const date = new Date().toISOString().split('T')[0]
if (format === 'json') {
blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
filename = `recall-backup-${date}.json`
} else if (format === 'markdown') {
if (data.files) {
// Multiple files - in the future could be a zip
blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n---\n\n')], { type: 'text/markdown' })
} else {
blob = new Blob([data.content], { type: 'text/markdown' })
}
filename = `recall-export-${date}.md`
} else {
if (data.files) {
blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n')], { type: 'text/html' })
} else {
blob = new Blob([data.content], { type: 'text/html' })
}
filename = `recall-export-${date}.html`
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `recall-backup-${date}.json` a.download = filename
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
toast.success('Notas exportadas correctamente') toast.success(`Notas exportadas en formato ${format.toUpperCase()}`)
} catch { } catch {
toast.error('Error al exportar las notas') toast.error('Error al exportar las notas')
} finally {
setExporting(null)
} }
} }
@@ -68,15 +95,17 @@ export default function SettingsPage() {
const isMarkdown = file.name.endsWith('.md') const isMarkdown = file.name.endsWith('.md')
let payload: object[] let payload: object[]
let endpoint = '/api/export-import'
if (isMarkdown) { if (isMarkdown) {
const note = parseMarkdownToNote(text, file.name) const note = parseMarkdownToNote(text, file.name)
payload = [note] payload = [{ markdown: text, filename: file.name }]
endpoint = '/api/import-markdown'
} else { } else {
payload = JSON.parse(text) payload = JSON.parse(text)
} }
const response = await fetch('/api/export-import', { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -88,7 +117,11 @@ export default function SettingsPage() {
throw new Error(result.error || 'Error al importar') throw new Error(result.error || 'Error al importar')
} }
toast.success(`${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`) const msg = result.warnings
? `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente (con advertencias)`
: `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`
toast.success(msg)
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = '' fileInputRef.current.value = ''
} }
@@ -103,27 +136,82 @@ export default function SettingsPage() {
<main className="container mx-auto py-8 px-4"> <main className="container mx-auto py-8 px-4">
<h1 className="text-2xl font-bold mb-6">Configuración</h1> <h1 className="text-2xl font-bold mb-6">Configuración</h1>
<div className="grid gap-6 max-w-xl"> <div className="grid gap-6 max-w-2xl">
{/* Preferences Section */}
<PreferencesPanel />
{/* Backups Section */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Exportar notas</CardTitle> <CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Backups y Restauración
</CardTitle>
<CardDescription> <CardDescription>
Descarga todas tus notas en formato JSON. El archivo incluye títulos, contenido, tipos y tags. Los backups automáticos se guardan localmente. También puedes crear un backup manual antes de operaciones riesgosas.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button onClick={handleExport} className="gap-2"> <BackupList />
<Download className="h-4 w-4" />
Exportar
</Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Export Section */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Importar notas</CardTitle> <CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Exportar Notas
</CardTitle>
<CardDescription> <CardDescription>
Importa notas desde archivos JSON o MD. En archivos MD, el primer heading (#) se usa como título. Descarga tus notas en diferentes formatos. Elige el que mejor se adapte a tus necesidades.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => handleExport('json')}
disabled={exporting !== null}
variant="outline"
size="sm"
className="gap-2"
>
<FolderOpen className="h-4 w-4" />
{exporting === 'json' ? 'Exportando...' : 'JSON (Backup completo)'}
</Button>
<Button
onClick={() => handleExport('markdown')}
disabled={exporting !== null}
variant="outline"
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
{exporting === 'markdown' ? 'Exportando...' : 'Markdown'}
</Button>
<Button
onClick={() => handleExport('html')}
disabled={exporting !== null}
variant="outline"
size="sm"
className="gap-2"
>
<Code className="h-4 w-4" />
{exporting === 'html' ? 'Exportando...' : 'HTML'}
</Button>
</div>
</CardContent>
</Card>
{/* Import Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Importar Notas
</CardTitle>
<CardDescription>
Importa notas desde archivos JSON o Markdown. Soporta frontmatter, tags, y enlaces wiki.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
@@ -137,7 +225,7 @@ export default function SettingsPage() {
onClick={handleImport} onClick={handleImport}
disabled={importing} disabled={importing}
variant="outline" variant="outline"
className="gap-2" className="gap-2 self-start"
> >
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
{importing ? 'Importando...' : 'Importar'} {importing ? 'Importando...' : 'Importar'}
+136
View File
@@ -0,0 +1,136 @@
'use client'
import { useState, useEffect } from 'react'
import { getBackups, deleteBackup } from '@/lib/backup-storage'
import { RecallBackup } from '@/types/backup'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'
import { Trash2, RotateCcw, Calendar, FileText } from 'lucide-react'
import { BackupRestoreDialog } from './backup-restore-dialog'
export function BackupList() {
const [backups, setBackups] = useState<RecallBackup[]>([])
const [loading, setLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
useEffect(() => {
loadBackups()
}, [])
async function loadBackups() {
try {
const data = await getBackups()
setBackups(data)
} catch {
toast.error('Error al cargar los backups')
} finally {
setLoading(false)
}
}
async function handleDelete(id: string) {
setDeletingId(id)
try {
await deleteBackup(id)
setBackups((prev) => prev.filter((b) => b.id !== id))
toast.success('Backup eliminado')
} catch {
toast.error('Error al eliminar el backup')
} finally {
setDeletingId(null)
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('es-ES', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
function getSourceBadgeVariant(source: RecallBackup['source']) {
switch (source) {
case 'automatic':
return 'secondary'
case 'manual':
return 'default'
case 'pre-destructive':
return 'destructive'
default:
return 'secondary'
}
}
function getSourceLabel(source: RecallBackup['source']) {
switch (source) {
case 'automatic':
return 'Automático'
case 'manual':
return 'Manual'
case 'pre-destructive':
return 'Pre-destrucción'
default:
return source
}
}
if (loading) {
return <div className="text-sm text-muted-foreground">Cargando backups...</div>
}
if (backups.length === 0) {
return (
<div className="text-sm text-muted-foreground">
No hay backups disponibles. Los backups se crean automáticamente antes de operaciones
destructivas.
</div>
)
}
return (
<div className="space-y-3">
{backups.map((backup) => (
<div
key={backup.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{formatDate(backup.createdAt)}</span>
<Badge variant={getSourceBadgeVariant(backup.source)}>{getSourceLabel(backup.source)}</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{backup.metadata.noteCount} nota{backup.metadata.noteCount !== 1 ? 's' : ''}
</span>
<span>
{backup.metadata.tagCount} tag{backup.metadata.tagCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<BackupRestoreDialog
backup={backup}
trigger={
<Button variant="outline" size="sm" className="gap-1 cursor-pointer">
<RotateCcw className="h-3 w-3" />
Restaurar
</Button>
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(backup.id)}
disabled={deletingId === backup.id}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
))}
</div>
)
}
+179
View File
@@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { validateBackup } from '@/lib/backup-validator'
import { restoreBackup } from '@/lib/restore'
import { RecallBackup } from '@/types/backup'
import { toast } from 'sonner'
import { RotateCcw, FileText, Tag, Calendar, AlertTriangle } from 'lucide-react'
interface BackupRestoreDialogProps {
backup: RecallBackup
trigger?: React.ReactNode
}
export function BackupRestoreDialog({ backup, trigger }: BackupRestoreDialogProps) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<'merge' | 'replace'>('merge')
const [confirming, setConfirming] = useState(false)
const [loading, setLoading] = useState(false)
const validation = validateBackup(backup)
const backupInfo = validation.info
function handleModeChange(newMode: 'merge' | 'replace') {
setMode(newMode)
setConfirming(false)
}
async function handleRestore() {
setLoading(true)
try {
const result = await restoreBackup(backup, mode)
if (result.success) {
toast.success(`${result.restored} nota${result.restored !== 1 ? 's' : ''} restaurada${result.restored !== 1 ? 's' : ''} correctamente`)
setOpen(false)
setConfirming(false)
setMode('merge')
} else {
toast.error(`Error al restaurar: ${result.errors.join(', ')}`)
}
} catch {
toast.error('Error al restaurar el backup')
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger && <div onClick={() => setOpen(true)}>{trigger}</div>}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-5 w-5" />
Restaurar Backup
</DialogTitle>
<DialogDescription>
Recupera notas desde un backup anterior
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Backup Info */}
<div className="space-y-3 p-4 bg-muted rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.createdAt ? new Date(backupInfo.createdAt).toLocaleString('es-ES') : 'Fecha desconocida'}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.noteCount ?? 0} nota{(backupInfo?.noteCount ?? 0) !== 1 ? 's' : ''}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.tagCount ?? 0} tag{(backupInfo?.tagCount ?? 0) !== 1 ? 's' : ''}</span>
</div>
<div className="text-xs text-muted-foreground">
Fuente: {backupInfo?.source ?? 'desconocida'}
</div>
</div>
{/* Mode Selection */}
{!confirming && (
<div className="space-y-3">
<p className="text-sm font-medium">Modo de restauración</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleModeChange('merge')}
className={`p-3 border rounded-lg text-left transition-colors ${
mode === 'merge'
? 'border-primary bg-primary/5'
: 'hover:border-muted-foreground/50'
}`}
>
<div className="font-medium text-sm">Combinar</div>
<div className="text-xs text-muted-foreground">
Añade nuevas notas, actualiza existentes
</div>
</button>
<button
type="button"
onClick={() => handleModeChange('replace')}
className={`p-3 border rounded-lg text-left transition-colors ${
mode === 'replace'
? 'border-primary bg-primary/5'
: 'hover:border-muted-foreground/50'
}`}
>
<div className="font-medium text-sm">Reemplazar</div>
<div className="text-xs text-muted-foreground">
Sustituye todo el contenido actual
</div>
</button>
</div>
</div>
)}
{/* Confirmation */}
{confirming && (
<div className="space-y-4">
{mode === 'replace' && (
<div className="flex items-start gap-3 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-destructive">Operación destructiva</p>
<p className="text-muted-foreground">
Se eliminará el contenido actual antes de restaurar. Se creará un backup de seguridad automáticamente.
</p>
</div>
</div>
)}
<p className="text-sm">
¿Estás seguro de que quieres restaurar este backup? Esta acción{' '}
{mode === 'merge' ? 'no eliminará' : 'eliminará'} notas existentes.
</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
{!confirming ? (
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancelar
</Button>
<Button onClick={() => setConfirming(true)}>
Continuar
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => setConfirming(false)} disabled={loading}>
Volver
</Button>
<Button
variant={mode === 'replace' ? 'destructive' : 'default'}
onClick={handleRestore}
disabled={loading}
>
{loading ? 'Restaurando...' : 'Confirmar'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { generateBookmarklet } from '@/lib/external-capture'
import { Bookmark, Copy, Check, Info } from 'lucide-react'
export function BookmarkletInstructions() {
const [isOpen, setIsOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const bookmarkletCode = generateBookmarklet()
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(bookmarkletCode)
setCopied(true)
toast.success('Código copiado al portapapeles')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('Error al copiar el código')
}
}
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData('text/plain', bookmarkletCode)
e.dataTransfer.effectAllowed = 'copy'
setIsDragging(true)
}
const handleDragEnd = () => {
setIsDragging(false)
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{!isOpen && (
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => setIsOpen(true)}
>
<Bookmark className="h-4 w-4" />
Capturar web
</Button>
)}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bookmark className="h-5 w-5" />
Capturar desde web
</DialogTitle>
<DialogDescription>
Guarda contenido de cualquier página web directamente en tus notas.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium mb-2">Instrucciones:</p>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Arrastra el botón de abajo a tu barra de marcadores</li>
<li>Cuando quieras capturar algo, haz clic en el marcador</li>
<li>Se abrirá una página para confirmar y guardar</li>
</ol>
</div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Botón del marcador:</p>
</div>
<div className="flex items-center justify-center p-4 bg-muted/30 rounded-lg border-2 border-dashed border-muted">
<button
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={(e) => e.preventDefault()}
className={`px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium cursor-grab active:cursor-grabbing transition-all ${
isDragging ? 'opacity-50 scale-95' : ''
}`}
title="Arrastra esto a tu barra de marcadores"
>
Capturar a Recall
</button>
</div>
<p className="text-xs text-muted-foreground text-center">
No puedes arrastrar? Usa el botón copiar y crea un marcador manualmente.
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 gap-2"
onClick={handleCopy}
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copiado
</>
) : (
<>
<Copy className="h-4 w-4" />
Copiar código
</>
)}
</Button>
</div>
<div className="bg-muted/30 rounded-lg p-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<p className="text-xs text-muted-foreground">
El marcador capturará el título de la página, la URL y cualquier texto que hayas seleccionado antes de hacer clic.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { commands, CommandItem } from '@/lib/command-items'
import { Search, FileText, Settings, Home, Plus } from 'lucide-react'
export function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const router = useRouter()
// Listen for Ctrl+K / Cmd+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// Filter commands by query
const filtered = commands.filter(cmd =>
cmd.label.toLowerCase().includes(query.toLowerCase()) ||
cmd.keywords?.some(k => k.includes(query.toLowerCase()))
)
// Keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
if (e.key === 'ArrowDown') setSelectedIndex(i => Math.min(i + 1, filtered.length - 1))
if (e.key === 'ArrowUp') setSelectedIndex(i => Math.max(i - 1, 0))
if (e.key === 'Enter' && filtered[selectedIndex]) {
executeCommand(filtered[selectedIndex])
}
}
// Execute command
const executeCommand = (cmd: CommandItem) => {
router.push(cmd.id === 'action-new' ? '/new' : `/${cmd.id.split('-')[1]}`)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md p-0 gap-0">
<div className="flex items-center border-b px-3">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar comandos..."
className="border-0 focus-visible:ring-0"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className="max-h-80 overflow-y-auto">
{filtered.map((cmd, i) => (
<button
key={cmd.id}
onClick={() => executeCommand(cmd)}
className={`w-full px-3 py-2 text-left flex items-center gap-2 ${
i === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<span className="text-sm">{cmd.label}</span>
<span className="text-xs text-muted-foreground ml-auto">{cmd.group}</span>
</button>
))}
</div>
</DialogContent>
</Dialog>
)
}
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { Button } from '@/components/ui/button'
import { AlertCircle } from 'lucide-react'
interface DraftRecoveryBannerProps {
onRestore: () => void
onDiscard: () => void
}
export function DraftRecoveryBanner({ onRestore, onDiscard }: DraftRecoveryBannerProps) {
return (
<div className="bg-yellow-50 border-yellow-200 p-3 rounded-lg flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<p className="text-sm text-yellow-800 flex-1">Se encontró un borrador guardado</p>
<Button size="sm" variant="outline" onClick={onDiscard}>Descartar</Button>
<Button size="sm" onClick={onRestore}>Recuperar</Button>
</div>
)
}
+95 -10
View File
@@ -1,18 +1,36 @@
'use client' 'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Plus, FileText, Settings } from 'lucide-react' import { Plus, FileText, Settings, Menu, X } from 'lucide-react'
import { QuickAdd } from '@/components/quick-add' import { QuickAdd } from '@/components/quick-add'
import { WorkModeToggle } from '@/components/work-mode-toggle'
import { BookmarkletInstructions } from '@/components/bookmarklet-instructions'
import { isWorkModeEnabled } from '@/lib/preferences'
export function Header() { export function Header() {
const pathname = usePathname() const pathname = usePathname()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [workModeToggleVisible, setWorkModeToggleVisible] = useState(true)
useEffect(() => {
setWorkModeToggleVisible(isWorkModeEnabled())
const handlePreferencesChange = () => {
setWorkModeToggleVisible(isWorkModeEnabled())
}
window.addEventListener('preferences-updated', handlePreferencesChange)
return () => window.removeEventListener('preferences-updated', handlePreferencesChange)
}, [])
return ( return (
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 flex h-14 items-center justify-between"> <div className="container mx-auto px-2 sm:px-4">
<div className="flex items-center gap-4"> {/* Desktop: single row */}
<div className="hidden sm:flex h-14 items-center justify-between gap-2">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold">Recall</span> <span className="text-xl font-bold">Recall</span>
</Link> </Link>
@@ -38,16 +56,83 @@ export function Header() {
</Button> </Button>
</Link> </Link>
</nav> </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>
<div className="flex items-center gap-3">
<QuickAdd /> {/* Mobile: hamburger + logo */}
<Link href="/new"> <div className="flex sm:hidden h-14 items-center justify-between gap-2">
<Button size="sm" className="gap-1.5"> <Link href="/" className="flex items-center gap-2">
<Plus className="h-4 w-4" /> <span className="text-lg font-bold">Recall</span>
Nueva nota
</Button>
</Link> </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> </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> </div>
</header> </header>
) )
+9
View File
@@ -0,0 +1,9 @@
'use client'
export function KeyboardHint() {
return (
<div className="text-xs text-muted-foreground text-center py-2 border-t">
navegar · Enter abrir · E editar · F favoritar · P fijar
</div>
)
}
@@ -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>
)
}
+12 -2
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Note } from '@/types/note' import { Note } from '@/types/note'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -16,12 +17,21 @@ const typeColors: Record<string, string> = {
} }
export function NoteCard({ note }: { note: Note }) { export function NoteCard({ note }: { note: Note }) {
const router = useRouter()
const preview = note.content.slice(0, 100) + (note.content.length > 100 ? '...' : '') const preview = note.content.slice(0, 100) + (note.content.length > 100 ? '...' : '')
const typeColor = typeColors[note.type] || typeColors.note const typeColor = typeColors[note.type] || typeColors.note
const handleMouseEnter = () => {
// Prefetch on hover for faster navigation
router.prefetch(`/notes/${note.id}`)
}
return ( return (
<Link href={`/notes/${note.id}`}> <Link href={`/notes/${note.id}`} prefetch={true}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full"> <Card
className="hover:shadow-md transition-shadow cursor-pointer h-full"
onMouseEnter={handleMouseEnter}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between gap-2 mb-2"> <div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-lg line-clamp-1">{note.title}</h3> <h3 className="font-semibold text-lg line-clamp-1">{note.title}</h3>
+231
View File
@@ -0,0 +1,231 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History, Clock } from 'lucide-react'
import { getNavigationHistory, NavigationEntry } from '@/lib/navigation-history'
interface BacklinkInfo {
id: string
sourceNoteId: string
targetNoteId: string
sourceNote: {
id: string
title: string
type: string
}
}
interface RelatedNote {
id: string
title: string
type: string
tags: string[]
score: number
reason: string
}
interface NoteConnectionsProps {
noteId: string
backlinks: BacklinkInfo[]
outgoingLinks: BacklinkInfo[]
relatedNotes: RelatedNote[]
coUsedNotes: { noteId: string; title: string; type: string; weight: number }[]
}
function ConnectionGroup({
title,
icon: Icon,
notes,
emptyMessage,
isCollapsed,
onToggle,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
notes: { id: string; title: string; type: string }[]
emptyMessage: string
isCollapsed?: boolean
onToggle?: () => void
}) {
if (notes.length === 0 && isCollapsed) {
return null
}
if (notes.length === 0) {
return (
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<Icon className="h-4 w-4" />
{title}
</h4>
<p className="text-xs text-muted-foreground pl-6">{emptyMessage}</p>
</div>
)
}
return (
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<button
onClick={onToggle}
className="flex items-center gap-2 hover:text-primary transition-colors"
>
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<Icon className="h-4 w-4" />
{title}
</button>
<Badge variant="secondary" className="ml-auto text-xs">
{notes.length}
</Badge>
</h4>
{!isCollapsed && (
<div className="pl-6 space-y-1">
{notes.map((note) => (
<Link
key={note.id}
href={`/notes/${note.id}`}
className="block text-sm text-foreground hover:text-primary transition-colors"
>
{note.title}
</Link>
))}
</div>
)}
</div>
)
}
// Deduplicate notes by id, keeping first occurrence
function deduplicateById<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>()
return items.filter(item => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export function NoteConnections({
noteId,
backlinks,
outgoingLinks,
relatedNotes,
coUsedNotes,
}: NoteConnectionsProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
const [navigationHistory, setNavigationHistory] = useState<NavigationEntry[]>([])
useEffect(() => {
fetch(`/api/notes/${noteId}/versions`)
.then((r) => r.json())
.then((d) => setRecentVersions(d.data?.slice(0, 3) || []))
.catch(() => setRecentVersions([]))
}, [noteId])
useEffect(() => {
setNavigationHistory(getNavigationHistory())
}, [noteId])
const hasAnyConnections =
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
// Deduplicate all lists to prevent React key warnings
const uniqueBacklinks = deduplicateById(backlinks.map((bl) => ({ id: bl.sourceNote.id, title: bl.sourceNote.title, type: bl.sourceNote.type })))
const uniqueOutgoing = deduplicateById(outgoingLinks.map((ol) => ({ id: ol.sourceNote.id, title: ol.sourceNote.title, type: ol.sourceNote.type })))
const uniqueRelated = deduplicateById(relatedNotes.map((rn) => ({ id: rn.id, title: rn.title, type: rn.type })))
const uniqueCoUsed = deduplicateById(coUsedNotes.map((cu) => ({ id: cu.noteId, title: cu.title, type: cu.type })))
const uniqueHistory = deduplicateById(navigationHistory.slice(0, 5).map((entry) => ({ id: entry.noteId, title: entry.title, type: entry.type })))
const toggleCollapsed = (key: string) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}
if (!hasAnyConnections && recentVersions.length === 0) {
return null
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Link2 className="h-5 w-5" />
Conectado con
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Backlinks - notes that link TO this note */}
<ConnectionGroup
title="Enlaces entrantes"
icon={ExternalLink}
notes={uniqueBacklinks}
emptyMessage="Ningún otro documento enlaza a esta nota"
isCollapsed={collapsed['backlinks']}
onToggle={() => toggleCollapsed('backlinks')}
/>
{/* Outgoing links - notes this note links TO */}
<ConnectionGroup
title="Enlaces salientes"
icon={ArrowRight}
notes={uniqueOutgoing}
emptyMessage="Esta nota no enlaza a ningún otro documento"
isCollapsed={collapsed['outgoing']}
onToggle={() => toggleCollapsed('outgoing')}
/>
{/* Related notes - by content similarity and scoring */}
<ConnectionGroup
title="Relacionadas"
icon={RefreshCw}
notes={uniqueRelated}
emptyMessage="No hay notas relacionadas"
isCollapsed={collapsed['related']}
onToggle={() => toggleCollapsed('related')}
/>
{/* Co-used notes - often viewed together */}
<ConnectionGroup
title="Co-usadas"
icon={Users}
notes={uniqueCoUsed}
emptyMessage="No hay notas co-usadas"
isCollapsed={collapsed['coused']}
onToggle={() => toggleCollapsed('coused')}
/>
{/* Recent versions */}
{recentVersions.length > 0 && (
<div className="space-y-2 pt-2 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<History className="h-4 w-4" />
Versiones recientes
</h4>
<div className="pl-6 space-y-1">
{recentVersions.map((v) => (
<p key={v.id} className="text-xs text-muted-foreground">
v{v.version} - {new Date(v.createdAt).toLocaleDateString()}
</p>
))}
</div>
</div>
)}
{/* Navigation history */}
{uniqueHistory.length > 0 && (
<ConnectionGroup
title="Vista recientemente"
icon={Clock}
notes={uniqueHistory}
emptyMessage="No hay historial de navegación"
isCollapsed={collapsed['history']}
onToggle={() => toggleCollapsed('history')}
/>
)}
</CardContent>
</Card>
)
}
+269 -4
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useRef, useEffect, useMemo } from 'react' import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Note, NoteType, Tag } from '@/types/note' import { Note, NoteType, Tag } from '@/types/note'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -8,7 +8,11 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react' import { X, Sparkles } from 'lucide-react'
import { inferNoteType } from '@/lib/type-inference'
import { useUnsavedChanges } from '@/hooks/use-unsaved-changes'
import { saveDraft, loadDraft, deleteDraft } from '@/lib/drafts'
import { DraftRecoveryBanner } from '@/components/draft-recovery-banner'
// Command fields // Command fields
interface CommandFields { interface CommandFields {
@@ -614,22 +618,206 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
return defaultFields[type] return defaultFields[type]
}) })
const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || []) const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || [])
const [autoSuggestedTags, setAutoSuggestedTags] = useState<string[]>([])
const [autoSuggestedType, setAutoSuggestedType] = useState<NoteType | null>(null)
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false) const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false) const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [showDraftBanner, setShowDraftBanner] = useState(false)
const [draftLoaded, setDraftLoaded] = useState(false)
const draftLoadedRef = useRef(false)
// Store initial values for dirty tracking
const initialValuesRef = useRef({
title: initialData?.title || '',
type: initialData?.type || 'note',
fields: initialData?.content
? parseMarkdownToFields(initialData.type, initialData.content)
: defaultFields[initialData?.type || 'note'],
tags: initialData?.tags.map(t => t.tag.name) || [],
isFavorite: initialData?.isFavorite || false,
isPinned: initialData?.isPinned || false,
})
// Track dirty state
useEffect(() => {
const hasChanges =
title !== initialValuesRef.current.title ||
type !== initialValuesRef.current.type ||
JSON.stringify(fields) !== JSON.stringify(initialValuesRef.current.fields) ||
JSON.stringify(tags) !== JSON.stringify(initialValuesRef.current.tags) ||
isFavorite !== initialValuesRef.current.isFavorite ||
isPinned !== initialValuesRef.current.isPinned
setIsDirty(hasChanges)
}, [title, type, fields, tags, isFavorite, isPinned])
useUnsavedChanges(isDirty)
// Serialized content for drafts and auto-suggest
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
// Load draft on mount (only for new notes)
useEffect(() => {
if (isEdit || draftLoadedRef.current) return
const draftKey = 'new'
const draft = loadDraft(draftKey)
if (draft) {
draftLoadedRef.current = true
setDraftLoaded(true)
setShowDraftBanner(true)
}
}, [isEdit])
// Debounced save draft on changes (only for new notes, after initial load)
useEffect(() => {
if (isEdit || !draftLoaded || draftLoadedRef.current) return
const draftKey = 'new'
const timeoutId = setTimeout(() => {
saveDraft(draftKey, { title, content, type, tags, savedAt: Date.now() })
}, 1000)
return () => clearTimeout(timeoutId)
}, [title, content, type, tags, isEdit, draftLoaded])
const handleTypeChange = (newType: NoteType) => { const handleTypeChange = (newType: NoteType) => {
setType(newType) setType(newType)
setFields(defaultFields[newType]) setFields(defaultFields[newType])
} }
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields]) // Auto-suggest tags based on title and content
useEffect(() => {
const fetchSuggestions = async () => {
if (title.trim().length < 3 && content.trim().length < 10) {
setAutoSuggestedTags([])
return
}
try {
const res = await fetch(
`/api/tags/suggest?title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}`
)
if (res.ok) {
const data = await res.json()
const suggested: string[] = data.data || data || []
// Filter out tags already added
setAutoSuggestedTags(suggested.filter((t: string) => !tags.includes(t)))
}
} catch (error) {
console.error('Error fetching tag suggestions:', error)
}
}
const timeoutId = setTimeout(fetchSuggestions, 500)
return () => clearTimeout(timeoutId)
}, [title, content, tags])
// Auto-suggest type based on content (only for new notes, not edits)
useEffect(() => {
if (isEdit || !content.trim()) {
setAutoSuggestedType(null)
return
}
// Only suggest if content is reasonably filled and user hasn't changed type manually
const suggestion = inferNoteType(content)
if (suggestion && suggestion.confidence === 'high') {
setAutoSuggestedType(suggestion.type)
} else {
setAutoSuggestedType(null)
}
}, [content, isEdit])
const acceptSuggestedType = () => {
if (autoSuggestedType) {
setType(autoSuggestedType)
setFields(defaultFields[autoSuggestedType])
setAutoSuggestedType(null)
}
}
// Link suggestions state
const [linkSuggestions, setLinkSuggestions] = useState<{ term: string; noteId: string; noteTitle: string }[]>([])
// Fetch link suggestions based on content
useEffect(() => {
const fetchLinkSuggestions = async () => {
if (content.trim().length < 20) {
setLinkSuggestions([])
return
}
try {
const params = new URLSearchParams({ content })
if (initialData?.id) {
params.set('noteId', initialData.id)
}
const res = await fetch(`/api/notes/links?${params}`)
if (res.ok) {
const data = await res.json()
setLinkSuggestions(data.data || [])
}
} catch (error) {
console.error('Error fetching link suggestions:', error)
}
}
const timeoutId = setTimeout(fetchLinkSuggestions, 800)
return () => clearTimeout(timeoutId)
}, [content, initialData?.id])
const convertToWikiLink = (term: string) => {
// Replace the term with [[term]] in the content
// This requires modifying fields directly based on the type
const newContent = content.replace(new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi'), `[[${term}]]`)
// Update the appropriate field based on type
if (type === 'note') {
setFields({ content: newContent })
} else if (type === 'command') {
const f = fields as CommandFields
setFields({ ...f, example: newContent })
} else if (type === 'snippet') {
const f = fields as SnippetFields
setFields({ ...f, code: newContent })
} else {
setFields({ content: newContent } as TypeFields)
}
// Remove from suggestions
setLinkSuggestions(prev => prev.filter(s => s.term !== term))
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const handleRestoreDraft = useCallback(() => {
const draftKey = 'new'
const draft = loadDraft(draftKey)
if (draft) {
setTitle(draft.title)
setType(draft.type as NoteType)
setFields(parseMarkdownToFields(draft.type as NoteType, draft.content))
setTags(draft.tags)
setShowDraftBanner(false)
draftLoadedRef.current = true
setDraftLoaded(true)
}
}, [])
const handleDiscardDraft = useCallback(() => {
const draftKey = 'new'
deleteDraft(draftKey)
setShowDraftBanner(false)
draftLoadedRef.current = true
setDraftLoaded(true)
}, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setIsSubmitting(true) setIsSubmitting(true)
const noteData = { // Build payload, explicitly excluding id and any undefined values
const noteData: Record<string, unknown> = {
title, title,
content, content,
type, type,
@@ -638,6 +826,13 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
tags, tags,
} }
// Remove undefined values before sending
Object.keys(noteData).forEach(key => {
if (noteData[key] === undefined) {
delete noteData[key]
}
})
try { try {
const url = isEdit && initialData ? `/api/notes/${initialData.id}` : '/api/notes' const url = isEdit && initialData ? `/api/notes/${initialData.id}` : '/api/notes'
const method = isEdit ? 'PUT' : 'POST' const method = isEdit ? 'PUT' : 'POST'
@@ -649,6 +844,10 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
}) })
if (res.ok) { if (res.ok) {
setIsDirty(false)
if (!isEdit) {
deleteDraft('new')
}
router.push('/notes') router.push('/notes')
router.refresh() router.refresh()
} }
@@ -681,6 +880,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl"> <form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
{showDraftBanner && (
<DraftRecoveryBanner onRestore={handleRestoreDraft} onDiscard={handleDiscardDraft} />
)}
<div> <div>
<label className="block text-sm font-medium mb-1">Título</label> <label className="block text-sm font-medium mb-1">Título</label>
<Input <Input
@@ -705,6 +907,21 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{autoSuggestedType && autoSuggestedType !== type && (
<div className="mt-2 flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 text-primary" />
<span className="text-muted-foreground">
¿Es <span className="text-primary font-medium">{autoSuggestedType}</span>?
</span>
<button
type="button"
onClick={acceptSuggestedType}
className="text-primary hover:underline"
>
Usar tipo
</button>
</div>
)}
</div> </div>
<div> <div>
@@ -717,6 +934,54 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
<TagInput value={tags} onChange={setTags} /> <TagInput value={tags} onChange={setTags} />
</div> </div>
{autoSuggestedTags.length > 0 && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Sugerencias basadas en tu contenido:</p>
<div className="flex flex-wrap gap-2">
{autoSuggestedTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => {
if (!tags.includes(tag)) {
setTags([...tags, tag])
}
setAutoSuggestedTags(autoSuggestedTags.filter(t => t !== tag))
}}
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-background border rounded-full hover:bg-accent hover:text-accent-foreground transition-colors"
>
<span>{tag}</span>
<span className="text-xs opacity-60">+</span>
</button>
))}
</div>
</div>
)}
{/* Link suggestions */}
{linkSuggestions.length > 0 && (
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Sparkles className="h-3 w-3" />
Enlaces internos detectados:
</p>
<div className="flex flex-wrap gap-2">
{linkSuggestions.slice(0, 5).map((suggestion) => (
<button
key={suggestion.term}
type="button"
onClick={() => convertToWikiLink(suggestion.term)}
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-background border rounded-full hover:bg-accent hover:text-accent-foreground transition-colors"
title={`Convertir "${suggestion.term}" a [[${suggestion.term}]]`}
>
<span>{suggestion.term}</span>
<span className="text-xs opacity-60">[[]]</span>
</button>
))}
</div>
</div>
)}
<div className="flex gap-4"> <div className="flex gap-4">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<input <input
+114
View File
@@ -0,0 +1,114 @@
'use client'
import { useState, useEffect } from 'react'
import { FeatureFlags, getFeatureFlags, setFeatureFlags } from '@/lib/preferences'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { BookmarkletInstructions } from '@/components/bookmarklet-instructions'
export function PreferencesPanel() {
const [flags, setFlags] = useState<FeatureFlags>({
backupEnabled: true,
backupRetention: 30,
workModeEnabled: true,
})
const [retentionInput, setRetentionInput] = useState('30')
useEffect(() => {
setFlags(getFeatureFlags())
setRetentionInput(getFeatureFlags().backupRetention.toString())
}, [])
const handleBackupEnabled = (enabled: boolean) => {
setFeatureFlags({ backupEnabled: enabled })
setFlags(getFeatureFlags())
}
const handleWorkModeEnabled = (enabled: boolean) => {
setFeatureFlags({ workModeEnabled: enabled })
setFlags(getFeatureFlags())
// Dispatch custom event to notify other components (like Header)
window.dispatchEvent(new CustomEvent('preferences-updated'))
}
const handleRetentionChange = (value: string) => {
setRetentionInput(value)
const days = parseInt(value, 10)
if (!isNaN(days) && days > 0) {
setFeatureFlags({ backupRetention: days })
setFlags(getFeatureFlags())
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Preferencias
</CardTitle>
<CardDescription>
Configura el comportamiento de la aplicación. Los cambios se guardan automáticamente.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="text-sm font-medium">Backup automático</p>
<p className="text-xs text-muted-foreground">
Crear backups automáticamente al cerrar o cambiar de nota
</p>
</div>
<Button
variant={flags.backupEnabled ? 'default' : 'outline'}
size="sm"
onClick={() => handleBackupEnabled(!flags.backupEnabled)}
>
{flags.backupEnabled ? 'Activado' : 'Desactivado'}
</Button>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Retención de backups (días)</p>
<p className="text-xs text-muted-foreground">
Los backups automáticos se eliminarán después de este período
</p>
<input
type="number"
min="1"
max="365"
value={retentionInput}
onChange={(e) => handleRetentionChange(e.target.value)}
className="w-24 px-3 py-1 border rounded-md text-sm"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="text-sm font-medium">Modo trabajo</p>
<p className="text-xs text-muted-foreground">
Habilitar toggle de modo trabajo en el header
</p>
</div>
<Button
variant={flags.workModeEnabled ? 'default' : 'outline'}
size="sm"
onClick={() => handleWorkModeEnabled(!flags.workModeEnabled)}
>
{flags.workModeEnabled ? 'Activado' : 'Desactivado'}
</Button>
</div>
<div className="pt-4 border-t">
<p className="text-sm font-medium mb-3">Integración externa</p>
<BookmarkletInstructions />
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">Sprint MVP-5</Badge>
<Badge variant="outline">v0.1.0</Badge>
</div>
</CardContent>
</Card>
)
}
+225 -46
View File
@@ -1,19 +1,85 @@
'use client' 'use client'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Plus, Loader2 } from 'lucide-react' import { Plus, Loader2, Sparkles, X, Text, ChevronDown } from 'lucide-react'
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
import { NoteType } from '@/types/note'
interface TypeSuggestion {
type: NoteType
confidence: 'high' | 'medium' | 'low'
reason: string
formattedContent: string
}
const TYPE_LABELS: Record<NoteType, string> = {
command: 'Comando',
snippet: 'Snippet',
procedure: 'Procedimiento',
recipe: 'Receta',
decision: 'Decisión',
inventory: 'Inventario',
note: 'Nota',
}
export function QuickAdd() { export function QuickAdd() {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isExpanded, setIsExpanded] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [isMultiline, setIsMultiline] = useState(false)
const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null)
const [dismissedSuggestion, setDismissedSuggestion] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const popupRef = useRef<HTMLDivElement>(null)
const router = useRouter() const router = useRouter()
const detectContentType = useCallback((text: string) => {
if (!text.trim() || text.length < 10) {
setTypeSuggestion(null)
return
}
if (dismissedSuggestion) return
const suggestion = inferNoteType(text)
if (suggestion && suggestion.confidence === 'high') {
const formatted = formatContentForType(text, suggestion.type)
setTypeSuggestion({
...suggestion,
formattedContent: formatted,
})
} else {
setTypeSuggestion(null)
}
}, [dismissedSuggestion])
const handlePaste = (e: React.ClipboardEvent) => {
setDismissedSuggestion(false)
setTimeout(() => {
detectContentType(value)
}, 0)
}
const acceptSuggestion = () => {
if (typeSuggestion) {
setValue(typeSuggestion.formattedContent)
setTypeSuggestion(null)
setIsMultiline(true)
setIsOpen(true)
}
}
const dismissSuggestion = () => {
setTypeSuggestion(null)
setDismissedSuggestion(true)
}
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault() e?.preventDefault()
if (!value.trim() || isLoading) return if (!value.trim() || isLoading) return
@@ -36,7 +102,8 @@ export function QuickAdd() {
description: note.title, description: note.title,
}) })
setValue('') setValue('')
setIsExpanded(false) setIsOpen(false)
setIsMultiline(false)
router.refresh() router.refresh()
} catch (error) { } catch (error) {
toast.error('Error', { toast.error('Error', {
@@ -48,31 +115,55 @@ export function QuickAdd() {
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey && !isMultiline) {
e.preventDefault() e.preventDefault()
handleSubmit() handleSubmit()
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setValue('') setValue('')
setIsExpanded(false) setIsOpen(false)
setIsMultiline(false)
inputRef.current?.blur() inputRef.current?.blur()
textareaRef.current?.blur()
} }
} }
const toggleMultiline = () => {
setIsMultiline(!isMultiline)
if (!isMultiline) {
setTimeout(() => textareaRef.current?.focus(), 0)
}
}
const handleInputFocus = () => {
setIsOpen(true)
}
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
// Focus on keyboard shortcut // Focus on keyboard shortcut
useEffect(() => { useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => { const handleGlobalKeyDown = (e: KeyboardEvent) => {
// Ctrl+N or Cmd+N to focus quick add
if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) { if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
e.preventDefault() e.preventDefault()
inputRef.current?.focus() inputRef.current?.focus()
inputRef.current?.select() inputRef.current?.select()
setIsExpanded(true) setIsOpen(true)
} }
// Escape to blur
if (e.key === 'Escape' && document.activeElement === inputRef.current) { if (e.key === 'Escape' && document.activeElement === inputRef.current) {
inputRef.current?.blur() inputRef.current?.blur()
setIsExpanded(false) setIsOpen(false)
} }
} }
window.addEventListener('keydown', handleGlobalKeyDown) window.addEventListener('keydown', handleGlobalKeyDown)
@@ -80,42 +171,130 @@ export function QuickAdd() {
}, []) }, [])
return ( return (
<form onSubmit={handleSubmit} className="flex items-center gap-2"> <div className="relative" ref={popupRef}>
<div className="relative"> {/* Compact input row */}
<Input <form onSubmit={handleSubmit} className="flex items-center gap-1.5">
ref={inputRef} <div className="relative">
type="text" <Input
placeholder="cmd: título #tag..." ref={inputRef}
value={value} type="text"
onChange={(e) => setValue(e.target.value)} placeholder="cmd: título..."
onKeyDown={handleKeyDown} value={value}
onFocus={() => setIsExpanded(true)} onChange={(e) => {
className={cn( setValue(e.target.value)
'w-48 transition-all duration-200', detectContentType(e.target.value)
isExpanded && 'w-72' 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" />
)} )}
disabled={isLoading} {/* Action buttons inside input */}
/> <div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
{isLoading && ( <button
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" /> type="button"
)} onClick={toggleMultiline}
</div> className={cn(
<button 'p-1 rounded hover:bg-accent transition-colors',
type="submit" isMultiline && 'bg-accent text-accent-foreground'
disabled={!value.trim() || isLoading} )}
className={cn( title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
'inline-flex items-center justify-center rounded-lg border bg-background p-2', >
'hover:bg-accent hover:text-accent-foreground', {isMultiline ? (
'disabled:pointer-events-none disabled:opacity-50', <ChevronDown className="h-3.5 w-3.5" />
'transition-colors' ) : (
)} <Text className="h-3.5 w-3.5" />
> )}
{isLoading ? ( </button>
<Loader2 className="h-4 w-4 animate-spin" /> <button
) : ( type="submit"
<Plus className="h-4 w-4" /> disabled={!value.trim() || isLoading}
)} className={cn(
</button> 'p-1 rounded hover:bg-accent transition-colors',
</form> '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="Contenido multilínea..."
value={value}
onChange={(e) => {
setValue(e.target.value)
detectContentType(e.target.value)
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="min-h-[100px] max-h-[200px] resize-none w-full"
disabled={isLoading}
/>
)}
{/* Smart paste suggestion */}
{typeSuggestion && (
<div className="mt-2 p-2 bg-muted/50 rounded-lg border">
<div className="flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Detectado: <span className="text-primary">{TYPE_LABELS[typeSuggestion.type]}</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">{typeSuggestion.reason}</p>
</div>
<button
type="button"
onClick={dismissSuggestion}
className="p-1 hover:bg-accent rounded"
>
<X className="h-3 w-3" />
</button>
</div>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={acceptSuggestion}
className="text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90"
>
Usar plantilla
</button>
<button
type="button"
onClick={dismissSuggestion}
className="text-xs px-2 py-1 text-muted-foreground hover:bg-accent rounded"
>
Descartar
</button>
</div>
</div>
)}
{/* Help text */}
{!value && !typeSuggestion && (
<div className="text-xs text-muted-foreground">
Usa prefijos como <span className="font-mono bg-muted px-1 rounded">cmd:</span>, <span className="font-mono bg-muted px-1 rounded">snip:</span> para тип notes
</div>
)}
</div>
)}
</div>
) )
} }
+56
View File
@@ -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()
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function RelatedNotes({ notes }: { notes: RelatedNote[] }) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Notas relacionadas</CardTitle> <CardTitle className="text-lg">También podrías necesitar</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
+180 -15
View File
@@ -1,34 +1,199 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react' import { Search, Loader2 } from 'lucide-react'
import { ScoredNote } from '@/lib/search'
// Simple in-memory cache for search results
const searchCache = new Map<string, ScoredNote[]>()
const MAX_CACHE_SIZE = 50
export function SearchBar() { export function SearchBar() {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [results, setResults] = useState<ScoredNote[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const router = useRouter() const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
// Debounced search
useEffect(() => {
if (!query.trim()) {
setResults([])
setIsOpen(false)
return
}
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(async () => {
// Check cache first
if (searchCache.has(query)) {
setResults(searchCache.get(query)!)
setIsOpen(true)
setSelectedIndex(-1)
return
}
setIsLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
const json = await res.json()
if (json.success) {
const results = json.data.slice(0, 8)
setResults(results)
setIsOpen(true)
setSelectedIndex(-1)
// Cache the results
if (searchCache.size >= MAX_CACHE_SIZE) {
// Remove oldest entry (first in Map)
const firstKey = searchCache.keys().next().value
if (firstKey) searchCache.delete(firstKey)
}
searchCache.set(query, results)
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setIsLoading(false)
}
}, 300)
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
}
}, [query])
// Click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (query.trim()) { if (query.trim()) {
router.push(`/notes?q=${encodeURIComponent(query)}`) router.push(`/notes?q=${encodeURIComponent(query)}`)
setIsOpen(false)
} }
} }
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || results.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && results[selectedIndex]) {
router.push(`/notes/${results[selectedIndex].id}`)
setIsOpen(false)
setQuery('')
} else {
handleSearch(e)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
setSelectedIndex(-1)
break
}
}
const handleResultClick = (note: ScoredNote) => {
router.push(`/notes/${note.id}`)
setIsOpen(false)
setQuery('')
}
return ( return (
<form onSubmit={handleSearch} className="flex gap-2 w-full"> <div className="relative w-full">
<Input <form onSubmit={handleSearch} className="flex gap-2 w-full">
type="text" <Input
placeholder="Buscar notas..." ref={inputRef}
value={query} type="text"
onChange={(e) => setQuery(e.target.value)} placeholder="Buscar notas..."
className="flex-1 min-w-0" value={query}
/> onChange={(e) => setQuery(e.target.value)}
<Button type="submit" variant="secondary" size="icon"> onKeyDown={handleKeyDown}
<Search className="h-4 w-4" /> className="flex-1 min-w-0"
</Button> />
</form> <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>
) )
} }
+12
View File
@@ -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
}
+14 -1
View File
@@ -1,11 +1,24 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { addToRecentlyViewed } from '@/lib/usage' import { addToRecentlyViewed, getRecentlyViewedIds } from '@/lib/usage'
export function TrackNoteView({ noteId }: { noteId: string }) { export function TrackNoteView({ noteId }: { noteId: string }) {
useEffect(() => { useEffect(() => {
addToRecentlyViewed(noteId) addToRecentlyViewed(noteId)
// Track co-usage with previously viewed notes
const recentIds = getRecentlyViewedIds()
// Track co-usage with up to 3 most recent notes (excluding current)
const previousNotes = recentIds.filter(id => id !== noteId).slice(0, 3)
for (const prevNoteId of previousNotes) {
fetch('/api/usage/co-usage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fromNoteId: prevNoteId, toNoteId: noteId }),
}).catch(() => {}) // Silently fail
}
}, [noteId]) }, [noteId])
return null return null
+158
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+65
View File
@@ -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 }
}
+102
View File
@@ -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 }
}
+19
View File
@@ -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])
}
+56
View File
@@ -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
}
+78
View File
@@ -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()
})
}
+99
View File
@@ -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'
}
+62
View File
@@ -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
}
+89
View File
@@ -0,0 +1,89 @@
import { prisma } from '@/lib/prisma'
export interface CentralNote {
id: string
title: string
type: string
centralityScore: number
backlinks: number
outboundLinks: number
usageViews: number
coUsageWeight: number
}
/**
* Calculate centrality score for all notes.
* A note is "central" if it has many connections (backlinks/outbound) and high usage.
*/
export async function getCentralNotes(limit = 10): Promise<CentralNote[]> {
try {
// Get all notes with their counts
const notes = await prisma.note.findMany({
select: {
id: true,
title: true,
type: true,
backlinks: { select: { id: true } },
outbound: { select: { id: true } },
},
})
// Get usage stats for all notes
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const usageStats = await prisma.noteUsage.groupBy({
by: ['noteId'],
where: {
eventType: 'view',
createdAt: { gte: thirtyDaysAgo },
},
_count: { id: true },
})
const usageMap = new Map(usageStats.map((u) => [u.noteId, u._count.id]))
// Get co-usage stats
const coUsageStats = await prisma.noteCoUsage.groupBy({
by: ['fromNoteId', 'toNoteId'],
_sum: { weight: true },
})
const coUsageMap = new Map<string, number>()
for (const cu of coUsageStats) {
coUsageMap.set(cu.fromNoteId, (coUsageMap.get(cu.fromNoteId) || 0) + (cu._sum.weight || 0))
coUsageMap.set(cu.toNoteId, (coUsageMap.get(cu.toNoteId) || 0) + (cu._sum.weight || 0))
}
// Calculate centrality score for each note
const scoredNotes: CentralNote[] = notes.map((note) => {
const backlinks = note.backlinks.length
const outboundLinks = note.outbound.length
const usageViews = usageMap.get(note.id) || 0
const coUsageWeight = coUsageMap.get(note.id) || 0
// Centrality formula:
// - Each backlink = 3 points (incoming connections show importance)
// - Each outbound link = 1 point (shows knowledge breadth)
// - Each usage view = 0.5 points (shows relevance)
// - Each co-usage weight = 2 points (shows related usage patterns)
const centralityScore =
backlinks * 3 + outboundLinks * 1 + usageViews * 0.5 + coUsageWeight * 2
return {
id: note.id,
title: note.title,
type: note.type,
centralityScore,
backlinks,
outboundLinks,
usageViews,
coUsageWeight,
}
})
// Sort by centrality score descending
scoredNotes.sort((a, b) => b.centralityScore - a.centralityScore)
return scoredNotes.slice(0, limit)
} catch (error) {
console.error('Error calculating centrality:', error)
return []
}
}
+16
View File
@@ -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'] },
]
+36
View File
@@ -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)
}
+12
View File
@@ -56,6 +56,18 @@ export class ConflictError extends AppError {
} }
} }
export class PayloadTooLargeError extends AppError {
constructor(message: string = 'Payload too large') {
super('PAYLOAD_TOO_LARGE', message, 413)
}
}
export class RateLimitError extends AppError {
constructor(message: string = 'Too many requests') {
super('RATE_LIMITED', message, 429)
}
}
export function formatZodError(error: ZodError): ApiError { export function formatZodError(error: ZodError): ApiError {
return { return {
code: 'VALIDATION_ERROR', code: 'VALIDATION_ERROR',
+134
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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 }
}
+56
View File
@@ -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 }
}
+66
View File
@@ -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}`
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Feature flags for MVP-3 features.
* Can be toggled via environment variables or local config.
*/
export interface FeatureFlags {
centrality: boolean
passiveRecommendations: boolean
typeSuggestions: boolean
linkSuggestions: boolean
}
// Default values - all enabled unless explicitly disabled
const defaults: FeatureFlags = {
centrality: true,
passiveRecommendations: true,
typeSuggestions: true,
linkSuggestions: true,
}
// Environment variable parsing
function parseEnvBool(key: string, defaultValue: boolean): boolean {
const envValue = process.env[key]
if (envValue === undefined) return defaultValue
if (envValue === 'true' || envValue === '1') return true
if (envValue === 'false' || envValue === '0') return false
return defaultValue
}
/**
* Get current feature flags.
* Reads from environment variables with defaults.
*/
export function getFeatureFlags(): FeatureFlags {
return {
centrality: parseEnvBool('FLAG_CENTRALITY', defaults.centrality),
passiveRecommendations: parseEnvBool('FLAG_PASSIVE_RECOMMENDATIONS', defaults.passiveRecommendations),
typeSuggestions: parseEnvBool('FLAG_TYPE_SUGGESTIONS', defaults.typeSuggestions),
linkSuggestions: parseEnvBool('FLAG_LINK_SUGGESTIONS', defaults.linkSuggestions),
}
}
/**
* Check if a specific feature is enabled.
*/
export function isFeatureEnabled(feature: keyof FeatureFlags): boolean {
return getFeatureFlags()[feature]
}
/**
* Get flags for client-side use (only boolean values).
* This is safe to expose to the client.
*/
export function getClientFeatureFlags(): FeatureFlags {
return getFeatureFlags()
}
+132
View File
@@ -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
}
+72
View File
@@ -0,0 +1,72 @@
import { prisma } from '@/lib/prisma'
export interface LinkSuggestion {
term: string
noteId: string
noteTitle: string
}
/**
* Find potential wiki-link suggestions in content.
* Returns notes whose titles appear in the content and could be converted to [[links]].
*/
export async function findLinkSuggestions(
content: string,
currentNoteId?: string
): Promise<LinkSuggestion[]> {
if (!content.trim() || content.length < 10) {
return []
}
// Get all note titles except current note
const allNotes = await prisma.note.findMany({
where: currentNoteId ? { id: { not: currentNoteId } } : undefined,
select: { id: true, title: true },
})
if (allNotes.length === 0) {
return []
}
// Find titles that appear in content (case-insensitive)
const suggestions: LinkSuggestion[] = []
const contentLower = content.toLowerCase()
for (const note of allNotes) {
const titleLower = note.title.toLowerCase()
// Check if title appears as a whole word in content
const regex = new RegExp(`\\b${escapeRegex(titleLower)}\\b`, 'i')
if (regex.test(content)) {
suggestions.push({
term: note.title,
noteId: note.id,
noteTitle: note.title,
})
}
}
// Sort by title length (longer titles first - more specific matches)
return suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length)
}
/**
* Replace terms in content with wiki-links
*/
export function applyWikiLinks(
content: string,
replacements: { term: string; noteId: string }[]
): string {
let result = content
for (const { term, noteId } of replacements) {
// Replace all occurrences of the term (case-insensitive, whole word only)
const regex = new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi')
result = result.replace(regex, `[[${term}]]`)
}
return result
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
+110
View File
@@ -0,0 +1,110 @@
import { prisma } from '@/lib/prisma'
export interface DashboardMetrics {
totalNotes: number
totalTags: number
notesByType: Record<string, number>
topTags: { name: string; count: number }[]
topViewedNotes: { id: string; title: string; type: string; views: number }[]
creationSourceStats: { form: number; quick: number; import: number }
}
export async function getDashboardMetrics(days = 30): Promise<DashboardMetrics> {
try {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
// Get totals
const [totalNotes, totalTags, notesByTypeResult, topTagsResult] = await Promise.all([
prisma.note.count(),
prisma.tag.count(),
prisma.note.groupBy({
by: ['type'],
_count: { id: true },
}),
prisma.noteTag.groupBy({
by: ['tagId'],
_count: { noteId: true },
orderBy: { _count: { noteId: 'desc' } },
take: 10,
}),
])
// Get top viewed notes from usage
const topUsage = await prisma.noteUsage.groupBy({
by: ['noteId'],
where: {
eventType: 'view',
createdAt: { gte: since },
},
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10,
})
const topViewedNotes = await Promise.all(
topUsage.map(async (usage) => {
const note = await prisma.note.findUnique({
where: { id: usage.noteId },
select: { id: true, title: true, type: true },
})
return note
? {
id: note.id,
title: note.title,
type: note.type,
views: usage._count.id,
}
: null
})
)
// Get creation source stats
const creationSourceStats = await prisma.note.groupBy({
by: ['creationSource'],
_count: { id: true },
})
const sourceMap = { form: 0, quick: 0, import: 0 }
for (const stat of creationSourceStats) {
if (stat.creationSource in sourceMap) {
sourceMap[stat.creationSource as keyof typeof sourceMap] = stat._count.id
}
}
// Get tag names
const tagIds = topTagsResult.map((t) => t.tagId)
const tags = await prisma.tag.findMany({
where: { id: { in: tagIds } },
select: { id: true, name: true },
})
const tagMap = new Map(tags.map((t) => [t.id, t.name]))
const topTags = topTagsResult
.map((t) => ({
name: tagMap.get(t.tagId) || 'unknown',
count: t._count.noteId,
}))
.filter((t) => t.name !== 'unknown')
return {
totalNotes,
totalTags,
notesByType: Object.fromEntries(
notesByTypeResult.map((r) => [r.type, r._count.id])
),
topTags,
topViewedNotes: topViewedNotes.filter((n): n is NonNullable<typeof n> => n !== null),
creationSourceStats: sourceMap,
}
} catch (error) {
console.error('Error getting dashboard metrics:', error)
return {
totalNotes: 0,
totalTags: 0,
notesByType: {},
topTags: [],
topViewedNotes: [],
creationSourceStats: { form: 0, quick: 0, import: 0 },
}
}
}
+59
View File
@@ -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))
}
+57
View File
@@ -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 })
}
+61
View File
@@ -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 }
}
+1
View File
@@ -13,6 +13,7 @@ const TYPE_PREFIXES: Record<string, NoteType> = {
'rec:': 'recipe', 'rec:': 'recipe',
'proc:': 'procedure', 'proc:': 'procedure',
'inv:': 'inventory', 'inv:': 'inventory',
'web:': 'note',
} }
const TAG_REGEX = /#([a-z0-9]+)/g const TAG_REGEX = /#([a-z0-9]+)/g
+168
View File
@@ -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
View File
@@ -1,10 +1,13 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import stringSimilarity from 'string-similarity' import stringSimilarity from 'string-similarity'
import { getUsageStats } from '@/lib/usage' import { getUsageStats } from '@/lib/usage'
import { parseQuery, QueryAST } from '@/lib/query-parser'
export interface SearchFilters { export interface SearchFilters {
type?: string type?: string
tag?: string tag?: string
isFavorite?: boolean
isPinned?: boolean
} }
export interface ScoredNote { export interface ScoredNote {
@@ -133,10 +136,19 @@ async function scoreNote(
} }
export async function noteQuery( export async function noteQuery(
query: string, queryOrAST: string | QueryAST,
filters: SearchFilters = {} filters: SearchFilters = {}
): Promise<ScoredNote[]> { ): Promise<ScoredNote[]> {
const queryLower = query.toLowerCase().trim() // Support both old signature (query string + filters) and new (QueryAST)
let queryAST: QueryAST
if (typeof queryOrAST === 'string') {
queryAST = { text: queryOrAST, filters }
} else {
queryAST = queryOrAST
}
const { text: queryText, filters: appliedFilters } = queryAST
const queryLower = queryText.toLowerCase().trim()
const allNotes = await prisma.note.findMany({ const allNotes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } }, include: { tags: { include: { tag: true } } },
@@ -145,12 +157,14 @@ export async function noteQuery(
const scored: ScoredNote[] = [] const scored: ScoredNote[] = []
for (const note of allNotes) { for (const note of allNotes) {
if (filters.type && note.type !== filters.type) continue // Apply filters from AST BEFORE scoring
if (appliedFilters.type && note.type !== appliedFilters.type) continue
if (filters.tag) { if (appliedFilters.tag) {
const hasTag = note.tags.some(t => t.tag.name === filters.tag) const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag)
if (!hasTag) continue if (!hasTag) continue
} }
if (appliedFilters.isFavorite && note.isFavorite !== true) continue
if (appliedFilters.isPinned && note.isPinned !== true) continue
const titleLower = note.title.toLowerCase() const titleLower = note.title.toLowerCase()
const contentLower = note.content.toLowerCase() const contentLower = note.content.toLowerCase()
@@ -181,7 +195,7 @@ export async function noteQuery(
const highlight = highlightMatches( const highlight = highlightMatches(
exactTitleMatch ? note.title + ' ' + note.content : note.content, exactTitleMatch ? note.title + ' ' + note.content : note.content,
query queryText
) )
scored.push({ scored.push({
@@ -202,5 +216,11 @@ export async function searchNotes(
query: string, query: string,
filters: SearchFilters = {} filters: SearchFilters = {}
): Promise<ScoredNote[]> { ): Promise<ScoredNote[]> {
return noteQuery(query, filters) const queryAST: QueryAST = {
text: query,
filters: {
...filters,
},
}
return noteQuery(queryAST)
} }
+143
View File
@@ -0,0 +1,143 @@
import { NoteType } from '@/types/note'
interface TypeSuggestion {
type: NoteType
confidence: 'high' | 'medium' | 'low'
reason: string
}
// Patterns that indicate specific note types
const PATTERNS = {
command: [
/^\s*(git|docker|npm|yarn|node|python|curl|wget|ssh|scp|rsync|kubectl|helm|aws|gcloud|az)\s+/m,
/^\$\s*\w+/m,
/^>\s*\w+/m,
/`{3}(bash|sh|shell|zsh|fish)/m,
/#!/m, // shebang
],
snippet: [
/`{3}\w*/m, // code block with language
/^(function|const|let|var|class|import|export|def|async|await)\s+/m,
/\{\s*[\w\s]*:\s*[\w\s,}]+\}/m, // object literal
/=\s*>\s*{/m, // arrow function
/if\s*\(.+\)\s*{/m, // if statement
],
procedure: [
/^\d+[\.\)]\s+\w+/m, // numbered steps: 1. Do this
/^[-*]\s+\w+/m, // bullet steps: - Do this
/primer[oay]|segundo|tercero|cuarto|finalmente|después|antes|mientras|m paso/m,
/pasos?|steps?|instructions?|how to|tutorial/i,
],
recipe: [
/ingredientes?:?\s*$/im,
/^\s*-\s*\d+\s+\w+/m, // ingredient list: - 2 cups flour
/tiempo|horas?|minutos|preparación|cocción|servir/i,
/receta|recetas|cocina|cocinar|hornear|hervir/i,
],
decision: [
/decisión?:?\s*/im,
/alternativas?:?\s*/im,
/pros?\s*y\s*contras?:?\s*/im,
/consideramos?|optamos?|decidimos?|elegimos?/i,
/porque?|reason|context|vista|motivo/i,
],
inventory: [
/cantidad?:?\s*\d+/im,
/ubicación?:?\s*/im,
/stock|inventario|existencia|dispoble/i,
/^\s*\w+\s+\d+\s+\w+/m, // item quantity location pattern
],
}
export function inferNoteType(content: string): TypeSuggestion | null {
const scores: Record<NoteType, { score: number; matchedPatterns: string[] }> = {
command: { score: 0, matchedPatterns: [] },
snippet: { score: 0, matchedPatterns: [] },
procedure: { score: 0, matchedPatterns: [] },
recipe: { score: 0, matchedPatterns: [] },
decision: { score: 0, matchedPatterns: [] },
inventory: { score: 0, matchedPatterns: [] },
note: { score: 0, matchedPatterns: [] },
}
// Check each type's patterns
for (const [type, patterns] of Object.entries(PATTERNS)) {
for (const pattern of patterns) {
if (pattern.test(content)) {
scores[type as NoteType].score += 1
scores[type as NoteType].matchedPatterns.push(pattern.source)
}
}
}
// Find the type with highest score
let bestType: NoteType = 'note'
let bestScore = 0
for (const [type, { score }] of Object.entries(scores)) {
if (score > bestScore) {
bestScore = score
bestType = type as NoteType
}
}
// Determine confidence based on score
let confidence: 'high' | 'medium' | 'low' = 'low'
let reason = 'No clear pattern detected'
if (bestScore >= 3) {
confidence = 'high'
} else if (bestScore >= 2) {
confidence = 'medium'
} else if (bestScore >= 1) {
confidence = 'low'
}
if (bestScore > 0) {
switch (bestType) {
case 'command':
reason = 'Shell command patterns detected'
break
case 'snippet':
reason = 'Code block or programming syntax detected'
break
case 'procedure':
reason = 'Step-by-step instructions detected'
break
case 'recipe':
reason = 'Recipe or cooking instructions detected'
break
case 'decision':
reason = 'Decision-making context detected'
break
case 'inventory':
reason = 'Inventory or quantity patterns detected'
break
}
}
return {
type: bestType,
confidence,
reason,
}
}
export function formatContentForType(content: string, type: NoteType): string {
switch (type) {
case 'command':
return `## Comando\n\n${content.trim()}\n\n## Qué hace\n\n[Descripción]\n\n## Cuándo usarlo\n\n[Cuándo usar este comando]\n\n## Ejemplo\n\n\`\`\`bash\n[ejemplo]\n\`\`\``
case 'snippet':
return `## Snippet\n\n## Lenguaje\n\n[ Lenguaje ]\n\n## Código\n\n\`\`\`\n${content.trim()}\n\`\`\`\n\n## Qué resuelve\n\n[Descripción del problema que resuelve]\n\n## Notas\n\n[Notas adicionales]`
case 'procedure':
return `## Objetivo\n\n[Cuál es el objetivo]\n\n## Pasos\n\n${content.trim()}\n\n## Requisitos\n\n[Requisitos necesarios]\n\n## Problemas comunes\n\n[Problemas frecuentes y soluciones]`
case 'recipe':
return `## Ingredientes\n\n[Lista de ingredientes]\n\n## Pasos\n\n${content.trim()}\n\n## Tiempo\n\n[Tiempo de preparación]\n\n## Notas\n\n[Notas adicionales]`
case 'decision':
return `## Contexto\n\n[Cuál era la situación]\n\n## Decisión\n\n${content.trim()}\n\n## Alternativas consideradas\n\n[Otras opciones evaluadas]\n\n## Consecuencias\n\n[Impacto de esta decisión]`
case 'inventory':
return `## Item\n\n[Nombre del item]\n\n## Cantidad\n\n[Cantidad]\n\n## Ubicación\n\n[Ubicación]\n\n## Notas\n\n[Notas adicionales]`
default:
return `## Notas\n\n${content.trim()}`
}
}
+71
View File
@@ -136,3 +136,74 @@ export function clearRecentlyViewed(): void {
// Silently fail // Silently fail
} }
} }
// Co-usage tracking: record that two notes were viewed together
export async function trackCoUsage(fromNoteId: string, toNoteId: string): Promise<void> {
if (fromNoteId === toNoteId) return
try {
await prisma.noteCoUsage.upsert({
where: {
fromNoteId_toNoteId: { fromNoteId, toNoteId },
},
update: {
weight: { increment: 1 },
},
create: {
fromNoteId,
toNoteId,
weight: 1,
},
})
} catch {
// Silently fail
}
}
// Get notes that are often viewed together with a given note
export async function getCoUsedNotes(
noteId: string,
limit = 5,
days = 30
): Promise<{ noteId: string; title: string; type: string; weight: number }[]> {
try {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
const coUsages = await prisma.noteCoUsage.findMany({
where: {
OR: [
{ fromNoteId: noteId },
{ toNoteId: noteId },
],
updatedAt: { gte: since },
},
orderBy: { weight: 'desc' },
take: limit * 2, // Fetch more to account for duplicates we'll filter
})
// Deduplicate by relatedNoteId - only keep highest weight per note
const seenIds = new Set<string>()
const result: { noteId: string; title: string; type: string; weight: number }[] = []
for (const cu of coUsages) {
const relatedNoteId = cu.fromNoteId === noteId ? cu.toNoteId : cu.fromNoteId
if (seenIds.has(relatedNoteId)) continue
seenIds.add(relatedNoteId)
const note = await prisma.note.findUnique({
where: { id: relatedNoteId },
select: { id: true, title: true, type: true },
})
if (note) {
result.push({
noteId: note.id,
title: note.title,
type: note.type,
weight: cu.weight,
})
}
if (result.length >= limit) break
}
return result
} catch {
return []
}
}
+16 -4
View File
@@ -2,20 +2,32 @@ import { z } from 'zod'
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note']) export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
export const noteSchema = z.object({ export const CreationSourceEnum = z.enum(['form', 'quick', 'import'])
id: z.string().optional(),
// Base note schema without transform - for use with partial()
const baseNoteSchema = z.object({
id: z.string().optional().nullable(),
title: z.string().min(1, 'Title is required').max(200), title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(1, 'Content is required'), content: z.string().min(1, 'Content is required'),
type: NoteTypeEnum.default('note'), type: NoteTypeEnum.default('note'),
isFavorite: z.boolean().default(false), isFavorite: z.boolean().default(false),
isPinned: z.boolean().default(false), isPinned: z.boolean().default(false),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
creationSource: CreationSourceEnum.default('form'),
}) })
export const updateNoteSchema = noteSchema.partial().extend({ // Transform to remove id if null/undefined (for creation)
id: z.string(), export const noteSchema = baseNoteSchema.transform(data => {
if (data.id == null) {
const { id, ...rest } = data
return rest
}
return data
}) })
// For update, use partial of base schema with optional id (id comes from URL path, not body)
export const updateNoteSchema = baseNoteSchema.partial()
export const searchSchema = z.object({ export const searchSchema = z.object({
q: z.string().optional(), q: z.string().optional(),
type: NoteTypeEnum.optional(), type: NoteTypeEnum.optional(),
+96
View File
@@ -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
}
+11
View File
@@ -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))
}
+21
View File
@@ -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[]
}
}