develop #1
970
backlog/recall-mvp5-tickets-detallado.md
Normal file
970
backlog/recall-mvp5-tickets-detallado.md
Normal file
@@ -0,0 +1,970 @@
|
||||
# Recall — Tickets técnicos MVP-5 (Confianza, flujo diario y expansión)
|
||||
|
||||
## Objetivo general
|
||||
Consolidar Recall como sistema principal de pensamiento y memoria externa, enfocado en:
|
||||
- confianza total en los datos
|
||||
- reducción extrema de fricción
|
||||
- captura desde fuera de la app
|
||||
- recuperación y operación desde teclado
|
||||
- portabilidad real del conocimiento
|
||||
|
||||
## Principios de producto
|
||||
1. **No perder nada**: backup y restore confiables.
|
||||
2. **Todo a mano**: acciones principales accesibles por teclado.
|
||||
3. **Captura ubicua**: guardar conocimiento desde cualquier contexto.
|
||||
4. **Salida garantizada**: exportaciones útiles y reversibles.
|
||||
5. **Experiencia continua**: la app acompaña el flujo de trabajo, no lo interrumpe.
|
||||
|
||||
---
|
||||
|
||||
# EPIC 1 — Confianza total y resiliencia de datos
|
||||
|
||||
## [P1] Ticket 01 — Diseñar estrategia de backup automático local
|
||||
|
||||
**Objetivo**
|
||||
Definir e implementar una estrategia segura de backup automático para evitar pérdida de datos y aumentar la confianza en Recall.
|
||||
|
||||
**Contexto**
|
||||
Recall ya cuenta con export/import manual e historial de versiones por nota. El siguiente salto es garantizar respaldo periódico y silencioso del estado global del conocimiento.
|
||||
|
||||
**Problema que resuelve**
|
||||
- Riesgo de pérdida por errores del usuario, corrupción local o cambios no deseados.
|
||||
- Dependencia de exportaciones manuales.
|
||||
- Falta de sensación de “sistema confiable”.
|
||||
|
||||
**Alcance**
|
||||
- Diseñar estrategia de backup automático basada en eventos y/o tiempo:
|
||||
- al detectar cambios significativos
|
||||
- cada cierto intervalo configurable
|
||||
- al cerrar sesión o abandonar pestaña cuando aplique
|
||||
- Definir formato del backup:
|
||||
- JSON estructurado compatible con importación
|
||||
- metadatos de versión, fecha, origen, conteos
|
||||
- Definir almacenamiento inicial:
|
||||
- IndexedDB recomendado para snapshots locales
|
||||
- alternativa: local filesystem vía descarga manual asistida
|
||||
- Crear servicio de generación de backup
|
||||
- Crear política de retención:
|
||||
- conservar últimos N backups
|
||||
- limpiar backups viejos automáticamente
|
||||
|
||||
**No incluye**
|
||||
- sincronización cloud
|
||||
- backup remoto
|
||||
- cifrado extremo a extremo
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La app genera backups automáticamente sin intervención manual
|
||||
- Los backups se almacenan con timestamp y metadatos
|
||||
- Existe retención automática configurable o fija
|
||||
- El proceso no bloquea la UI
|
||||
- El formato es compatible con restore/import
|
||||
- Hay tests para serialización y política de retención
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup.ts`
|
||||
- `src/lib/backup-storage.ts`
|
||||
- `src/lib/backup-policy.ts`
|
||||
- `src/types/backup.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
**Notas técnicas**
|
||||
- Separar claramente:
|
||||
- generación del snapshot
|
||||
- persistencia local
|
||||
- política de retención
|
||||
- Preferir un esquema de versión explícito del backup (`schemaVersion`)
|
||||
- Incluir checksum o hash simple opcional para detectar corrupción
|
||||
- Mantener compatibilidad hacia atrás cuando cambie el formato
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 02 — Implementar motor de snapshot global exportable
|
||||
|
||||
**Objetivo**
|
||||
Crear una utilidad robusta que genere snapshots completos y consistentes del estado de Recall.
|
||||
|
||||
**Alcance**
|
||||
- Incluir en el snapshot:
|
||||
- notas
|
||||
- tags
|
||||
- backlinks/enlaces si corresponden
|
||||
- métricas relevantes necesarias para restore
|
||||
- versiones de notas, si se decide incluirlas
|
||||
- metadatos de creación
|
||||
- Crear función `createBackupSnapshot()`
|
||||
- Reutilizar la lógica existente de exportación para evitar duplicación
|
||||
- Estandarizar el shape del payload exportable
|
||||
|
||||
**No incluye**
|
||||
- compresión
|
||||
- cifrado
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Un snapshot puede reconstruir el estado esperado del sistema
|
||||
- El export manual y el backup automático comparten formato base o traductor explícito
|
||||
- Tests verifican consistencia del snapshot
|
||||
- El snapshot incluye versión de esquema y fecha de creación
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup.ts`
|
||||
- `src/lib/export.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
- `__tests__/backup.test.ts`
|
||||
|
||||
**Notas técnicas**
|
||||
- Evitar incluir datos derivados si pueden regenerarse fácilmente
|
||||
- Documentar claramente qué campos se consideran fuente de verdad
|
||||
- Si `NoteUsage` no debe restaurarse, dejarlo explícito en especificación
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 03 — Restore desde backup con preview y validación
|
||||
|
||||
**Objetivo**
|
||||
Permitir restaurar un backup de forma segura, transparente y reversible.
|
||||
|
||||
**Alcance**
|
||||
- Crear flujo de restore desde Settings
|
||||
- Validar el archivo antes de aplicar:
|
||||
- schemaVersion
|
||||
- integridad mínima
|
||||
- estructura esperada
|
||||
- Mostrar preview:
|
||||
- cantidad de notas
|
||||
- tags
|
||||
- versiones
|
||||
- fecha del backup
|
||||
- Permitir dos modos:
|
||||
- merge
|
||||
- replace completo
|
||||
- Confirmación explícita antes de aplicar
|
||||
|
||||
**No incluye**
|
||||
- merge inteligente avanzado por conflicto
|
||||
- restore parcial por selección de entidades
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede seleccionar un backup y previsualizarlo
|
||||
- El sistema informa claramente qué se va a restaurar
|
||||
- Hay confirmación antes del replace
|
||||
- El restore fallido no deja la base en estado inconsistente
|
||||
- Existe feedback claro de éxito/error
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/backup-restore-dialog.tsx`
|
||||
- `src/lib/restore.ts`
|
||||
- `src/lib/backup-validator.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
**Notas técnicas**
|
||||
- En `replace`, considerar transacción única
|
||||
- En `merge`, definir reglas claras por ID/título
|
||||
- Crear un backup previo automático antes de aplicar restore
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 04 — Backup previo automático antes de operaciones destructivas
|
||||
|
||||
**Objetivo**
|
||||
Reducir al mínimo el riesgo antes de operaciones peligrosas.
|
||||
|
||||
**Alcance**
|
||||
- Generar backup automático antes de:
|
||||
- restore replace
|
||||
- import replace
|
||||
- borrados masivos futuros
|
||||
- Etiquetar ese backup como `pre-destructive`
|
||||
- Permitir revertir rápidamente
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Antes de una operación destructiva se crea un backup
|
||||
- El backup queda identificado y visible en UI
|
||||
- Si la operación falla, el backup sigue disponible
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup.ts`
|
||||
- `src/lib/restore.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 05 — Guard de cambios no guardados
|
||||
|
||||
**Objetivo**
|
||||
Evitar pérdida accidental de trabajo durante edición.
|
||||
|
||||
**Alcance**
|
||||
- Detectar cambios sucios en `note-form`
|
||||
- Advertir al:
|
||||
- navegar fuera de la página
|
||||
- cerrar pestaña
|
||||
- refrescar
|
||||
- Permitir omitir warning cuando no hay cambios
|
||||
|
||||
**No incluye**
|
||||
- autosave completo
|
||||
- borradores persistentes
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Si hay cambios sin guardar, aparece advertencia al salir
|
||||
- Si no hay cambios, no aparece advertencia
|
||||
- Funciona en crear y editar
|
||||
- No rompe submit exitoso
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/hooks/use-unsaved-changes.ts`
|
||||
- `src/components/note-form.tsx`
|
||||
- `src/app/edit/[id]/page.tsx`
|
||||
- `src/app/new/page.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Diferenciar estado inicial vs actual
|
||||
- Manejar `beforeunload` con cuidado por compatibilidad del navegador
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 06 — Autosave opcional de borrador local
|
||||
|
||||
**Objetivo**
|
||||
Agregar una capa extra de protección sin imponer complejidad excesiva.
|
||||
|
||||
**Alcance**
|
||||
- Guardar borrador local temporal de la nota en edición
|
||||
- Recuperarlo al reabrir la pantalla
|
||||
- Permitir descartarlo manualmente
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Si se cierra accidentalmente, el borrador puede recuperarse
|
||||
- El borrador se limpia al guardar correctamente
|
||||
- El usuario puede descartar borrador recuperado
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/drafts.ts`
|
||||
- `src/components/note-form.tsx`
|
||||
- `src/components/draft-recovery-banner.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 2 — Operación diaria desde teclado
|
||||
|
||||
## [P1] Ticket 07 — Command Palette global (`Ctrl+K` / `Cmd+K`)
|
||||
|
||||
**Objetivo**
|
||||
Centralizar búsqueda, navegación y acciones en una interfaz rápida tipo command palette.
|
||||
|
||||
**Contexto**
|
||||
Recall ya tiene búsqueda potente y navegación por teclado en el search bar. El siguiente paso es ofrecer una capa global de comandos que reduzca aún más la fricción.
|
||||
|
||||
**Alcance**
|
||||
- Atajo global:
|
||||
- `Ctrl+K` en Windows/Linux
|
||||
- `Cmd+K` en macOS
|
||||
- Modal o palette flotante global
|
||||
- Soportar acciones iniciales:
|
||||
- buscar notas
|
||||
- abrir nota
|
||||
- crear nueva nota
|
||||
- quick add
|
||||
- ir a dashboard
|
||||
- ir a settings
|
||||
- ir a notas favoritas
|
||||
- ir a notas recientes
|
||||
- Secciones:
|
||||
- acciones
|
||||
- resultados de búsqueda
|
||||
- navegación
|
||||
- Navegación total por teclado
|
||||
|
||||
**No incluye**
|
||||
- edición avanzada dentro de la palette
|
||||
- plugins de comandos externos
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La palette abre/cierra con shortcut global
|
||||
- Se puede usar sin mouse
|
||||
- Enter ejecuta acción seleccionada
|
||||
- ESC cierra
|
||||
- Resultados y acciones están claramente separadas
|
||||
- Funciona desde cualquier pantalla
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/command-palette.tsx`
|
||||
- `src/hooks/use-command-palette.ts`
|
||||
- `src/lib/command-palette.ts`
|
||||
- `src/app/layout.tsx`
|
||||
- `src/components/header.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Reutilizar `search.ts` y la API existente cuando sea posible
|
||||
- Mantener selección activa y scroll automático
|
||||
- Considerar accesibilidad: focus trap, ARIA roles
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 08 — Modelo de acciones y proveedores para Command Palette
|
||||
|
||||
**Objetivo**
|
||||
Desacoplar la palette de las acciones concretas para facilitar expansión futura.
|
||||
|
||||
**Alcance**
|
||||
- Crear modelo uniforme de comando:
|
||||
- id
|
||||
- label
|
||||
- description
|
||||
- group
|
||||
- keywords
|
||||
- action handler
|
||||
- icon opcional
|
||||
- Crear proveedores:
|
||||
- acciones estáticas
|
||||
- notas recientes
|
||||
- resultados de búsqueda
|
||||
- Sistema de ranking simple para comandos
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Se pueden agregar nuevas acciones sin tocar el core visual
|
||||
- La palette consume una lista homogénea de items
|
||||
- El sistema soporta agrupación y orden
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/command-items.ts`
|
||||
- `src/lib/command-groups.ts`
|
||||
- `src/lib/command-ranking.ts`
|
||||
- `src/components/command-palette.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 09 — Acciones rápidas por teclado fuera de la palette
|
||||
|
||||
**Objetivo**
|
||||
Expandir la operación de Recall sin depender de clicks.
|
||||
|
||||
**Alcance**
|
||||
- Definir shortcuts globales seguros:
|
||||
- `g h` → dashboard
|
||||
- `g n` → notas
|
||||
- `n` → nueva nota
|
||||
- `/` → enfocar búsqueda
|
||||
- `?` → ayuda de shortcuts
|
||||
- Mostrar ayuda contextual de shortcuts
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Los shortcuts no interfieren con inputs activos
|
||||
- Se pueden desactivar en campos de texto
|
||||
- Existe una vista/modal de ayuda
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/hooks/use-global-shortcuts.ts`
|
||||
- `src/components/keyboard-shortcuts-dialog.tsx`
|
||||
- `src/app/layout.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Ignorar shortcuts cuando hay foco en input, textarea o contenteditable
|
||||
- Centralizar mapa de shortcuts en un único archivo
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 10 — Navegación completa de listas por teclado
|
||||
|
||||
**Objetivo**
|
||||
Permitir abrir y operar notas desde listados sin usar mouse.
|
||||
|
||||
**Alcance**
|
||||
- Flechas para moverse entre resultados/listas
|
||||
- Enter para abrir
|
||||
- Atajos para:
|
||||
- editar
|
||||
- favorite
|
||||
- pin
|
||||
- Soporte en:
|
||||
- `/notes`
|
||||
- dashboard
|
||||
- dropdown de búsqueda
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Las listas principales se pueden recorrer por teclado
|
||||
- El elemento seleccionado tiene estado visual claro
|
||||
- Las acciones rápidas no rompen accesibilidad
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/note-list.tsx`
|
||||
- `src/components/dashboard.tsx`
|
||||
- `src/components/search-bar.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 3 — Contexto activo y workspace continuo
|
||||
|
||||
## [P1] Ticket 11 — Sidebar contextual persistente mejorada
|
||||
|
||||
**Objetivo**
|
||||
Convertir la sidebar contextual en un asistente permanente del flujo de trabajo.
|
||||
|
||||
**Alcance**
|
||||
- Mantener sidebar visible en detalle de nota y opcionalmente en edición
|
||||
- Secciones posibles:
|
||||
- relacionadas
|
||||
- backlinks
|
||||
- co-usadas
|
||||
- recientes
|
||||
- versiones recientes
|
||||
- sugerencias contextuales
|
||||
- Mejorar densidad y jerarquía visual
|
||||
- Permitir colapsar/expandir secciones
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La sidebar muestra contenido útil sin saturar
|
||||
- Las secciones pueden plegarse
|
||||
- En pantallas pequeñas se adapta sin romper el layout
|
||||
- Se distinguen claramente los tipos de relación
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/note-context-sidebar.tsx`
|
||||
- `src/components/note-connections.tsx`
|
||||
- `src/app/notes/[id]/page.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 12 — Modo trabajo enfocado
|
||||
|
||||
**Objetivo**
|
||||
Ofrecer una experiencia de lectura/consulta prolongada con menos distracciones y más contexto útil.
|
||||
|
||||
**Alcance**
|
||||
- Crear un “modo trabajo” activable por toggle
|
||||
- Cambios de UI:
|
||||
- ancho de lectura optimizado
|
||||
- sidebar contextual persistente
|
||||
- header reducido
|
||||
- acciones rápidas siempre visibles
|
||||
- Persistir preferencia local
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede activar/desactivar el modo trabajo
|
||||
- La preferencia se mantiene entre sesiones
|
||||
- Mejora la experiencia en detalle de nota sin romper navegación general
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/components/work-mode-toggle.tsx`
|
||||
- `src/lib/work-mode.ts`
|
||||
- `src/app/notes/[id]/page.tsx`
|
||||
- `src/app/globals.css`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 13 — Historial de navegación contextual
|
||||
|
||||
**Objetivo**
|
||||
Facilitar volver sobre el camino mental reciente.
|
||||
|
||||
**Alcance**
|
||||
- Registrar secuencia reciente de notas abiertas
|
||||
- Mostrar “visto recientemente en este contexto”
|
||||
- Posibilidad de volver rápido a 5–10 notas recientes
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario ve un historial local reciente
|
||||
- Puede reabrir notas anteriores con un click o atajo
|
||||
- El historial no duplica entradas consecutivas idénticas
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/navigation-history.ts`
|
||||
- `src/components/recent-context-list.tsx`
|
||||
- `src/components/command-palette.tsx`
|
||||
- `src/components/note-context-sidebar.tsx`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 4 — Captura ubicua fuera de Recall
|
||||
|
||||
## [P1] Ticket 14 — Bookmarklet para guardar página actual
|
||||
|
||||
**Objetivo**
|
||||
Permitir capturar contenido desde cualquier web hacia Recall con fricción mínima.
|
||||
|
||||
**Contexto**
|
||||
Recall ya resuelve bien captura interna. El siguiente paso de uso diario es capturar desde el navegador sin tener que abrir manualmente la app y crear una nota.
|
||||
|
||||
**Alcance**
|
||||
- Diseñar bookmarklet inicial que:
|
||||
- tome `document.title`
|
||||
- tome `location.href`
|
||||
- opcionalmente tome selección de texto
|
||||
- abra una URL de Recall con payload prellenado
|
||||
- Crear pantalla o endpoint receptor para captura externa
|
||||
- Mapear captura a tipo de nota por defecto (`note` o `snippet` según caso)
|
||||
|
||||
**No incluye**
|
||||
- extensión completa de navegador
|
||||
- scraping profundo del DOM
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El bookmarklet funciona en páginas comunes
|
||||
- Si hay texto seleccionado, se incluye en la captura
|
||||
- Si no hay selección, se guarda al menos título + URL
|
||||
- Recall recibe y prellena una nota lista para confirmar o guardar
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/capture/page.tsx`
|
||||
- `src/lib/external-capture.ts`
|
||||
- `src/components/bookmarklet-instructions.tsx`
|
||||
- `src/app/settings/page.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Codificar payload en query string de forma segura
|
||||
- Considerar límites de longitud: si es largo, usar mecanismo de POST o fallback
|
||||
- Sanitizar el contenido recibido
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 15 — Flujo de confirmación para captura externa
|
||||
|
||||
**Objetivo**
|
||||
Evitar guardar basura y dar control antes de persistir.
|
||||
|
||||
**Alcance**
|
||||
- Pantalla de revisión para captura externa:
|
||||
- título
|
||||
- url
|
||||
- contenido/selección
|
||||
- tags sugeridos
|
||||
- tipo sugerido
|
||||
- Botones:
|
||||
- guardar
|
||||
- editar
|
||||
- cancelar
|
||||
- Posibilidad de convertir la URL en markdown limpio
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La captura externa llega prellenada
|
||||
- El usuario puede corregir antes de guardar
|
||||
- El flujo es rápido y no requiere pasos innecesarios
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/capture/page.tsx`
|
||||
- `src/components/external-capture-form.tsx`
|
||||
- `src/lib/type-inference.ts`
|
||||
- `src/lib/tags.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 16 — Endpoint seguro para captura externa por POST
|
||||
|
||||
**Objetivo**
|
||||
Preparar Recall para integraciones futuras más robustas que el bookmarklet simple.
|
||||
|
||||
**Alcance**
|
||||
- Crear endpoint dedicado para captura externa
|
||||
- Aceptar payload estructurado:
|
||||
- title
|
||||
- url
|
||||
- selection
|
||||
- source
|
||||
- inferredType
|
||||
- Validar con Zod
|
||||
- Responder con payload listo para preview o guardado
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El endpoint valida correctamente el payload
|
||||
- No guarda automáticamente sin intención explícita
|
||||
- Puede ser reutilizado por extensión futura o integraciones
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/api/capture/route.ts`
|
||||
- `src/lib/external-capture.ts`
|
||||
- `src/lib/validators.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 5 — Importación y exportación de nivel producto
|
||||
|
||||
## [P1] Ticket 17 — Exportación mejorada a Markdown
|
||||
|
||||
**Objetivo**
|
||||
Asegurar portabilidad real del conocimiento en un formato simple y durable.
|
||||
|
||||
**Alcance**
|
||||
- Exportar todas las notas a estructura Markdown
|
||||
- Incluir:
|
||||
- frontmatter opcional
|
||||
- título
|
||||
- contenido
|
||||
- tags
|
||||
- tipo
|
||||
- fechas
|
||||
- Generar nombres de archivo estables y seguros
|
||||
- Opción de exportar zip de múltiples `.md`
|
||||
|
||||
**No incluye**
|
||||
- sync con repos remotos
|
||||
- assets binarios complejos
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede exportar todas las notas a `.md`
|
||||
- Cada nota queda representada de forma legible
|
||||
- Los archivos son reimportables con reglas definidas
|
||||
- Tags y tipo no se pierden
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/export-markdown.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
- `src/app/settings/page.tsx`
|
||||
|
||||
**Notas técnicas**
|
||||
- Resolver colisiones de nombres
|
||||
- Normalizar saltos de línea
|
||||
- Documentar formato de frontmatter si se usa
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 18 — Exportación HTML simple y legible
|
||||
|
||||
**Objetivo**
|
||||
Facilitar compartir o archivar notas en un formato visualmente cómodo.
|
||||
|
||||
**Alcance**
|
||||
- Crear export HTML por nota o lote
|
||||
- Incluir render de markdown
|
||||
- Estilo básico embebido o plantilla simple
|
||||
|
||||
**Criterios de aceptación**
|
||||
- La exportación HTML es legible offline
|
||||
- Respeta headings, listas, código y enlaces
|
||||
- Puede abrirse directamente en navegador
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/export-html.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 19 — Importador de Markdown mejorado
|
||||
|
||||
**Objetivo**
|
||||
Hacer Recall más interoperable con flujos existentes.
|
||||
|
||||
**Alcance**
|
||||
- Mejorar importador `.md` actual para soportar:
|
||||
- frontmatter
|
||||
- tags
|
||||
- tipo
|
||||
- títulos ausentes o derivados
|
||||
- sintaxis `[[wiki]]`
|
||||
- Permitir importar múltiples archivos si la UX lo permite
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Markdown con frontmatter se importa correctamente
|
||||
- Se preservan tags y tipo cuando existen
|
||||
- El contenido sigue siendo fiel al original
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/import-markdown.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
- `src/components/import-dialog.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 20 — Importador base de Obsidian-compatible Markdown
|
||||
|
||||
**Objetivo**
|
||||
Reducir fricción de entrada para usuarios con conocimiento ya almacenado fuera de Recall.
|
||||
|
||||
**Alcance**
|
||||
- Aceptar archivos/estructura compatibles con vault simple:
|
||||
- markdown
|
||||
- `[[wiki links]]`
|
||||
- tags inline `#tag`
|
||||
- Resolver títulos desde filename cuando haga falta
|
||||
- Crear estrategia básica de deduplicación
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Un conjunto simple de notas estilo Obsidian se importa sin perder estructura esencial
|
||||
- Los wiki links se preservan o transforman correctamente
|
||||
- La deduplicación evita duplicados obvios
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/import-obsidian.ts`
|
||||
- `src/app/api/export-import/route.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 6 — Operación y configuración visible
|
||||
|
||||
## [P2] Ticket 21 — Centro de respaldo y portabilidad en Settings
|
||||
|
||||
**Objetivo**
|
||||
Reunir en una sola UI todas las capacidades de backup, restore, import y export.
|
||||
|
||||
**Alcance**
|
||||
- Crear sección clara en Settings:
|
||||
- backups automáticos
|
||||
- backups disponibles
|
||||
- restore
|
||||
- export JSON
|
||||
- export Markdown
|
||||
- export HTML
|
||||
- import Markdown/JSON
|
||||
- Mostrar último backup realizado
|
||||
- Mostrar tamaño aproximado y fecha
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Settings concentra todas las acciones de seguridad y portabilidad
|
||||
- El usuario entiende claramente qué hace cada opción
|
||||
- El flujo no requiere conocer detalles técnicos internos
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/components/backup-center.tsx`
|
||||
- `src/components/export-options.tsx`
|
||||
- `src/components/import-options.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 22 — Configuración visible de feature flags y preferencias clave
|
||||
|
||||
**Objetivo**
|
||||
Dar control operativo sobre comportamientos avanzados ya implementados.
|
||||
|
||||
**Alcance**
|
||||
- Exponer desde Settings:
|
||||
- feature flags activas
|
||||
- modo trabajo
|
||||
- backup automático on/off
|
||||
- retención de backups
|
||||
- shortcuts visibles
|
||||
- Persistencia local o en configuración simple
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El usuario puede ver y cambiar flags/preferencias principales
|
||||
- Los cambios se reflejan sin romper la app
|
||||
- Existe estado inicial razonable por defecto
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/app/settings/page.tsx`
|
||||
- `src/lib/features.ts`
|
||||
- `src/lib/preferences.ts`
|
||||
|
||||
---
|
||||
|
||||
# EPIC 7 — Calidad, seguridad operativa y pruebas
|
||||
|
||||
## [P1] Ticket 23 — Tests unitarios para backup/restore
|
||||
|
||||
**Objetivo**
|
||||
Proteger la capa de confianza antes de expandir más el producto.
|
||||
|
||||
**Alcance**
|
||||
- Tests para:
|
||||
- snapshot generation
|
||||
- validación de backup
|
||||
- retención
|
||||
- restore merge
|
||||
- restore replace
|
||||
- backup pre-destructive
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Casos felices y bordes cubiertos
|
||||
- Fixtures de backup versionados
|
||||
- Restore inválido falla de forma segura
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `__tests__/backup.test.ts`
|
||||
- `__tests__/restore.test.ts`
|
||||
- `__tests__/backup-validator.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## [P1] Ticket 24 — Tests de integración para command palette y captura externa
|
||||
|
||||
**Objetivo**
|
||||
Validar los nuevos flujos de uso diario y expansión.
|
||||
|
||||
**Alcance**
|
||||
- Probar:
|
||||
- apertura/cierre de palette
|
||||
- navegación por teclado
|
||||
- ejecución de comandos
|
||||
- recepción de captura externa
|
||||
- flujo de confirmación de captura
|
||||
|
||||
**Criterios de aceptación**
|
||||
- Los flujos críticos están cubiertos
|
||||
- Los shortcuts no interfieren con formularios
|
||||
- La captura externa llega correctamente prellenada
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `__tests__/command-palette.test.tsx`
|
||||
- `__tests__/capture-flow.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
## [P2] Ticket 25 — Harden de validaciones y límites operativos
|
||||
|
||||
**Objetivo**
|
||||
Aumentar robustez de las nuevas entradas/salidas del sistema.
|
||||
|
||||
**Alcance**
|
||||
- Definir límites razonables para:
|
||||
- tamaño de backup
|
||||
- tamaño de payload de captura externa
|
||||
- cantidad de backups retenidos
|
||||
- Validación estricta de formatos
|
||||
- Mensajes de error claros y recuperables
|
||||
|
||||
**Criterios de aceptación**
|
||||
- El sistema rechaza entradas excesivas o inválidas de forma clara
|
||||
- No se degrada la app por payloads grandes o malformados
|
||||
- Los errores se muestran de forma consistente
|
||||
|
||||
**Archivos sugeridos**
|
||||
- `src/lib/backup-validator.ts`
|
||||
- `src/lib/external-capture.ts`
|
||||
- `src/lib/errors.ts`
|
||||
- `src/lib/validators.ts`
|
||||
|
||||
---
|
||||
|
||||
# Orden recomendado de implementación
|
||||
|
||||
## Sprint 1 — Confianza primero
|
||||
- Ticket 01 — Estrategia de backup automático local
|
||||
- Ticket 02 — Motor de snapshot global exportable
|
||||
- Ticket 03 — Restore con preview y validación
|
||||
- Ticket 04 — Backup previo automático
|
||||
- Ticket 23 — Tests unitarios backup/restore
|
||||
|
||||
## Sprint 2 — Flujo diario brutal
|
||||
- Ticket 07 — Command Palette global
|
||||
- Ticket 08 — Modelo de acciones para palette
|
||||
- Ticket 09 — Shortcuts globales
|
||||
- Ticket 10 — Navegación de listas por teclado
|
||||
- Ticket 24 — Tests integración palette
|
||||
|
||||
## Sprint 3 — Contexto y continuidad
|
||||
- Ticket 11 — Sidebar contextual persistente mejorada
|
||||
- Ticket 12 — Modo trabajo enfocado
|
||||
- Ticket 13 — Historial de navegación contextual
|
||||
- Ticket 05 — Guard de cambios no guardados
|
||||
- Ticket 06 — Autosave opcional de borrador local
|
||||
|
||||
## Sprint 4 — Captura externa
|
||||
- Ticket 14 — Bookmarklet para guardar página actual
|
||||
- Ticket 15 — Flujo de confirmación para captura externa
|
||||
- Ticket 16 — Endpoint seguro para captura externa
|
||||
|
||||
## Sprint 5 — Portabilidad real
|
||||
- Ticket 17 — Exportación mejorada a Markdown
|
||||
- Ticket 18 — Exportación HTML
|
||||
- Ticket 19 — Importador Markdown mejorado
|
||||
- Ticket 20 — Importador base Obsidian-compatible
|
||||
- Ticket 21 — Centro de respaldo y portabilidad
|
||||
- Ticket 22 — Configuración visible de flags/preferencias
|
||||
- Ticket 25 — Harden de validaciones y límites
|
||||
|
||||
---
|
||||
|
||||
# Dependencias y decisiones de arquitectura recomendadas
|
||||
|
||||
## Decisión 1 — Backup format
|
||||
Definir explícitamente un formato versionado:
|
||||
|
||||
```ts
|
||||
type RecallBackup = {
|
||||
schemaVersion: "1.0";
|
||||
createdAt: string;
|
||||
source: "automatic" | "manual" | "pre-destructive";
|
||||
appVersion?: string;
|
||||
metadata: {
|
||||
noteCount: number;
|
||||
tagCount: number;
|
||||
versionCount?: number;
|
||||
};
|
||||
data: {
|
||||
notes: unknown[];
|
||||
tags: unknown[];
|
||||
noteVersions?: unknown[];
|
||||
backlinks?: unknown[];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Decisión 2 — Restore modes
|
||||
Mantener solo dos modos al inicio:
|
||||
- `merge`: agrega/actualiza sin borrar todo
|
||||
- `replace`: reemplaza completamente el dataset
|
||||
|
||||
No agregar modos intermedios hasta tener uso real.
|
||||
|
||||
## Decisión 3 — Command palette scope inicial
|
||||
La primera versión debe centrarse en:
|
||||
- navegación
|
||||
- búsqueda
|
||||
- creación
|
||||
- acceso a pantallas
|
||||
No convertirla aún en un motor de automatizaciones complejas.
|
||||
|
||||
## Decisión 4 — Bookmarklet MVP
|
||||
El bookmarklet debe ser lo más simple posible:
|
||||
- capturar `title`
|
||||
- capturar `url`
|
||||
- capturar selección si existe
|
||||
- abrir Recall con preview prellenada
|
||||
|
||||
No hacer scraping complejo en esta fase.
|
||||
|
||||
## Decisión 5 — Export portability
|
||||
Markdown debe convertirse en el formato de salida principal legible por humanos.
|
||||
JSON debe seguir siendo el formato fiel para restore exacto.
|
||||
|
||||
---
|
||||
|
||||
# Plantilla sugerida para Claude Code
|
||||
|
||||
## Título
|
||||
`[P1] Implementar restore desde backup con preview y validación`
|
||||
|
||||
## Contexto
|
||||
Recall ya ofrece export/import manual e historial de versiones. Para convertirlo en una herramienta confiable de uso diario, se necesita restore seguro desde backups automáticos y manuales.
|
||||
|
||||
## Objetivo
|
||||
Permitir restaurar backups con validación previa, preview del contenido y confirmación explícita, soportando modos `merge` y `replace`.
|
||||
|
||||
## Alcance
|
||||
- validador de backup
|
||||
- preview de metadatos
|
||||
- flujo de confirmación
|
||||
- ejecución segura del restore
|
||||
- integración con settings
|
||||
|
||||
## No incluye
|
||||
- resolución avanzada de conflictos
|
||||
- restore parcial por tipo de entidad
|
||||
- sync remoto
|
||||
|
||||
## Criterios de aceptación
|
||||
- ...
|
||||
- ...
|
||||
- ...
|
||||
|
||||
## Archivos a tocar
|
||||
- ...
|
||||
- ...
|
||||
|
||||
## Notas técnicas
|
||||
- usar transacciones en replace
|
||||
- crear backup previo automático
|
||||
- mostrar errores consistentes
|
||||
|
||||
---
|
||||
|
||||
# Definition of Done
|
||||
|
||||
- Funcionalidad implementada y usable
|
||||
- Tests unitarios e integración relevantes pasando
|
||||
- Sin regresiones en CRUD, búsqueda, versiones y captura
|
||||
- Estados vacíos, borde y error cubiertos
|
||||
- UI clara para acciones sensibles
|
||||
- Portabilidad comprobable con export/import real
|
||||
@@ -3,9 +3,18 @@ import { prisma } from '@/lib/prisma'
|
||||
import { noteSchema, NoteInput } from '@/lib/validators'
|
||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
import { createBackupSnapshot } from '@/lib/backup'
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const format = searchParams.get('format')
|
||||
|
||||
if (format === 'backup') {
|
||||
const backup = await createBackupSnapshot('manual')
|
||||
return createSuccessResponse(backup)
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Download, Upload } from 'lucide-react'
|
||||
import { Download, Upload, History } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toast } from 'sonner'
|
||||
import { BackupList } from '@/components/backup-list'
|
||||
|
||||
function parseMarkdownToNote(content: string, filename: string) {
|
||||
const lines = content.split('\n')
|
||||
@@ -144,6 +145,21 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Restaura notas desde backups guardados localmente en tu navegador.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BackupList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
136
src/components/backup-list.tsx
Normal file
136
src/components/backup-list.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getBackups, deleteBackup } from '@/lib/backup-storage'
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2, RotateCcw, Calendar, FileText } from 'lucide-react'
|
||||
import { BackupRestoreDialog } from './backup-restore-dialog'
|
||||
|
||||
export function BackupList() {
|
||||
const [backups, setBackups] = useState<RecallBackup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBackups()
|
||||
}, [])
|
||||
|
||||
async function loadBackups() {
|
||||
try {
|
||||
const data = await getBackups()
|
||||
setBackups(data)
|
||||
} catch {
|
||||
toast.error('Error al cargar los backups')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id)
|
||||
try {
|
||||
await deleteBackup(id)
|
||||
setBackups((prev) => prev.filter((b) => b.id !== id))
|
||||
toast.success('Backup eliminado')
|
||||
} catch {
|
||||
toast.error('Error al eliminar el backup')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('es-ES', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function getSourceBadgeVariant(source: RecallBackup['source']) {
|
||||
switch (source) {
|
||||
case 'automatic':
|
||||
return 'secondary'
|
||||
case 'manual':
|
||||
return 'default'
|
||||
case 'pre-destructive':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceLabel(source: RecallBackup['source']) {
|
||||
switch (source) {
|
||||
case 'automatic':
|
||||
return 'Automático'
|
||||
case 'manual':
|
||||
return 'Manual'
|
||||
case 'pre-destructive':
|
||||
return 'Pre-destrucción'
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-sm text-muted-foreground">Cargando backups...</div>
|
||||
}
|
||||
|
||||
if (backups.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No hay backups disponibles. Los backups se crean automáticamente antes de operaciones
|
||||
destructivas.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{formatDate(backup.createdAt)}</span>
|
||||
<Badge variant={getSourceBadgeVariant(backup.source)}>{getSourceLabel(backup.source)}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
{backup.metadata.noteCount} nota{backup.metadata.noteCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>
|
||||
{backup.metadata.tagCount} tag{backup.metadata.tagCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<BackupRestoreDialog
|
||||
backup={backup}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" className="gap-1 cursor-pointer">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restaurar
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(backup.id)}
|
||||
disabled={deletingId === backup.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
src/components/backup-restore-dialog.tsx
Normal file
179
src/components/backup-restore-dialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { validateBackup } from '@/lib/backup-validator'
|
||||
import { restoreBackup } from '@/lib/restore'
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
import { toast } from 'sonner'
|
||||
import { RotateCcw, FileText, Tag, Calendar, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface BackupRestoreDialogProps {
|
||||
backup: RecallBackup
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function BackupRestoreDialog({ backup, trigger }: BackupRestoreDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [mode, setMode] = useState<'merge' | 'replace'>('merge')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const validation = validateBackup(backup)
|
||||
const backupInfo = validation.info
|
||||
|
||||
function handleModeChange(newMode: 'merge' | 'replace') {
|
||||
setMode(newMode)
|
||||
setConfirming(false)
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await restoreBackup(backup, mode)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${result.restored} nota${result.restored !== 1 ? 's' : ''} restaurada${result.restored !== 1 ? 's' : ''} correctamente`)
|
||||
setOpen(false)
|
||||
setConfirming(false)
|
||||
setMode('merge')
|
||||
} else {
|
||||
toast.error(`Error al restaurar: ${result.errors.join(', ')}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al restaurar el backup')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{trigger && <div onClick={() => setOpen(true)}>{trigger}</div>}
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
Restaurar Backup
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Recupera notas desde un backup anterior
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Backup Info */}
|
||||
<div className="space-y-3 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{backupInfo?.createdAt ? new Date(backupInfo.createdAt).toLocaleString('es-ES') : 'Fecha desconocida'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{backupInfo?.noteCount ?? 0} nota{(backupInfo?.noteCount ?? 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{backupInfo?.tagCount ?? 0} tag{(backupInfo?.tagCount ?? 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Fuente: {backupInfo?.source ?? 'desconocida'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
{!confirming && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Modo de restauración</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('merge')}
|
||||
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||
mode === 'merge'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">Combinar</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Añade nuevas notas, actualiza existentes
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('replace')}
|
||||
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||
mode === 'replace'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">Reemplazar</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Sustituye todo el contenido actual
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation */}
|
||||
{confirming && (
|
||||
<div className="space-y-4">
|
||||
{mode === 'replace' && (
|
||||
<div className="flex items-start gap-3 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-destructive">Operación destructiva</p>
|
||||
<p className="text-muted-foreground">
|
||||
Se eliminará el contenido actual antes de restaurar. Se creará un backup de seguridad automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
¿Estás seguro de que quieres restaurar este backup? Esta acción{' '}
|
||||
{mode === 'merge' ? 'no eliminará' : 'eliminará'} notas existentes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{!confirming ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => setConfirming(true)}>
|
||||
Continuar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setConfirming(false)} disabled={loading}>
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'replace' ? 'destructive' : 'default'}
|
||||
onClick={handleRestore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Restaurando...' : 'Confirmar'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
import { inferNoteType } from '@/lib/type-inference'
|
||||
import { useUnsavedChanges } from '@/hooks/use-unsaved-changes'
|
||||
|
||||
// Command fields
|
||||
interface CommandFields {
|
||||
@@ -620,6 +621,34 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
// 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)
|
||||
|
||||
const handleTypeChange = (newType: NoteType) => {
|
||||
setType(newType)
|
||||
@@ -757,6 +786,7 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setIsDirty(false)
|
||||
router.push('/notes')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ export function VersionHistory({ noteId }: VersionHistoryProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<div onClick={() => handleOpenChange(true)}>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||
<History className="h-4 w-4 mr-1" /> Historial
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Historial de versiones</DialogTitle>
|
||||
|
||||
19
src/hooks/use-unsaved-changes.ts
Normal file
19
src/hooks/use-unsaved-changes.ts
Normal 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
src/lib/backup-policy.ts
Normal file
56
src/lib/backup-policy.ts
Normal 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
src/lib/backup-storage.ts
Normal file
78
src/lib/backup-storage.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
75
src/lib/backup-validator.ts
Normal file
75
src/lib/backup-validator.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
info?: {
|
||||
noteCount: number
|
||||
tagCount: number
|
||||
createdAt: string
|
||||
source: string
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBackup(data: unknown): ValidationResult {
|
||||
const errors: 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)')
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
const metadata = backup.metadata as { noteCount: number; tagCount: number }
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
info: {
|
||||
noteCount: metadata.noteCount,
|
||||
tagCount: metadata.tagCount,
|
||||
createdAt: backup.createdAt as string,
|
||||
source: backup.source as string,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSchemaVersion(backup: RecallBackup): boolean {
|
||||
return backup.schemaVersion === '1.0'
|
||||
}
|
||||
62
src/lib/backup.ts
Normal file
62
src/lib/backup.ts
Normal 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
|
||||
}
|
||||
168
src/lib/restore.ts
Normal file
168
src/lib/restore.ts
Normal 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] }
|
||||
}
|
||||
}
|
||||
21
src/types/backup.ts
Normal file
21
src/types/backup.ts
Normal 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[]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user