From 8c80a12b81ce6b75afb8855efba15c04aa648b6d Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 22 Mar 2026 18:16:36 -0300 Subject: [PATCH] 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 --- backlog/recall-mvp5-tickets-detallado.md | 970 +++++++++++++++++++++++ src/app/api/export-import/route.ts | 11 +- src/app/settings/page.tsx | 18 +- src/components/backup-list.tsx | 136 ++++ src/components/backup-restore-dialog.tsx | 179 +++++ src/components/note-form.tsx | 30 + src/components/version-history.tsx | 6 +- src/hooks/use-unsaved-changes.ts | 19 + src/lib/backup-policy.ts | 56 ++ src/lib/backup-storage.ts | 78 ++ src/lib/backup-validator.ts | 75 ++ src/lib/backup.ts | 62 ++ src/lib/restore.ts | 168 ++++ src/types/backup.ts | 21 + 14 files changed, 1824 insertions(+), 5 deletions(-) create mode 100644 backlog/recall-mvp5-tickets-detallado.md create mode 100644 src/components/backup-list.tsx create mode 100644 src/components/backup-restore-dialog.tsx create mode 100644 src/hooks/use-unsaved-changes.ts create mode 100644 src/lib/backup-policy.ts create mode 100644 src/lib/backup-storage.ts create mode 100644 src/lib/backup-validator.ts create mode 100644 src/lib/backup.ts create mode 100644 src/lib/restore.ts create mode 100644 src/types/backup.ts diff --git a/backlog/recall-mvp5-tickets-detallado.md b/backlog/recall-mvp5-tickets-detallado.md new file mode 100644 index 0000000..b042c28 --- /dev/null +++ b/backlog/recall-mvp5-tickets-detallado.md @@ -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 diff --git a/src/app/api/export-import/route.ts b/src/app/api/export-import/route.ts index a8b723d..4bd7d68 100644 --- a/src/app/api/export-import/route.ts +++ b/src/app/api/export-import/route.ts @@ -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 } } }, }) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index ef24481..ffff494 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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() { + + + + + + Backups + + + Restaura notas desde backups guardados localmente en tu navegador. + + + + + + ) diff --git a/src/components/backup-list.tsx b/src/components/backup-list.tsx new file mode 100644 index 0000000..da31d8a --- /dev/null +++ b/src/components/backup-list.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [deletingId, setDeletingId] = useState(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
Cargando backups...
+ } + + if (backups.length === 0) { + return ( +
+ No hay backups disponibles. Los backups se crean automáticamente antes de operaciones + destructivas. +
+ ) + } + + return ( +
+ {backups.map((backup) => ( +
+
+
+ + {formatDate(backup.createdAt)} + {getSourceLabel(backup.source)} +
+
+ + + {backup.metadata.noteCount} nota{backup.metadata.noteCount !== 1 ? 's' : ''} + + + {backup.metadata.tagCount} tag{backup.metadata.tagCount !== 1 ? 's' : ''} + +
+
+
+ + + Restaurar + + } + /> + +
+
+ ))} +
+ ) +} diff --git a/src/components/backup-restore-dialog.tsx b/src/components/backup-restore-dialog.tsx new file mode 100644 index 0000000..9c200e9 --- /dev/null +++ b/src/components/backup-restore-dialog.tsx @@ -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 ( + + {trigger &&
setOpen(true)}>{trigger}
} + + + + + Restaurar Backup + + + Recupera notas desde un backup anterior + + + +
+ {/* Backup Info */} +
+
+ + {backupInfo?.createdAt ? new Date(backupInfo.createdAt).toLocaleString('es-ES') : 'Fecha desconocida'} +
+
+ + {backupInfo?.noteCount ?? 0} nota{(backupInfo?.noteCount ?? 0) !== 1 ? 's' : ''} +
+
+ + {backupInfo?.tagCount ?? 0} tag{(backupInfo?.tagCount ?? 0) !== 1 ? 's' : ''} +
+
+ Fuente: {backupInfo?.source ?? 'desconocida'} +
+
+ + {/* Mode Selection */} + {!confirming && ( +
+

Modo de restauración

+
+ + +
+
+ )} + + {/* Confirmation */} + {confirming && ( +
+ {mode === 'replace' && ( +
+ +
+

Operación destructiva

+

+ Se eliminará el contenido actual antes de restaurar. Se creará un backup de seguridad automáticamente. +

+
+
+ )} +

+ ¿Estás seguro de que quieres restaurar este backup? Esta acción{' '} + {mode === 'merge' ? 'no eliminará' : 'eliminará'} notas existentes. +

+
+ )} +
+ + + {!confirming ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/src/components/note-form.tsx b/src/components/note-form.tsx index ad721df..d45e324 100644 --- a/src/components/note-form.tsx +++ b/src/components/note-form.tsx @@ -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() } diff --git a/src/components/version-history.tsx b/src/components/version-history.tsx index 83962a8..697b62b 100644 --- a/src/components/version-history.tsx +++ b/src/components/version-history.tsx @@ -81,11 +81,11 @@ export function VersionHistory({ noteId }: VersionHistoryProps) { return ( - - - + Historial de versiones diff --git a/src/hooks/use-unsaved-changes.ts b/src/hooks/use-unsaved-changes.ts new file mode 100644 index 0000000..fa637dc --- /dev/null +++ b/src/hooks/use-unsaved-changes.ts @@ -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]) +} diff --git a/src/lib/backup-policy.ts b/src/lib/backup-policy.ts new file mode 100644 index 0000000..112e637 --- /dev/null +++ b/src/lib/backup-policy.ts @@ -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 { + 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 { + 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 +} diff --git a/src/lib/backup-storage.ts b/src/lib/backup-storage.ts new file mode 100644 index 0000000..d45d39b --- /dev/null +++ b/src/lib/backup-storage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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() + }) +} diff --git a/src/lib/backup-validator.ts b/src/lib/backup-validator.ts new file mode 100644 index 0000000..45be85a --- /dev/null +++ b/src/lib/backup-validator.ts @@ -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 + + 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 + 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 + 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' +} diff --git a/src/lib/backup.ts b/src/lib/backup.ts new file mode 100644 index 0000000..c21b645 --- /dev/null +++ b/src/lib/backup.ts @@ -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 { + 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 +} diff --git a/src/lib/restore.ts b/src/lib/restore.ts new file mode 100644 index 0000000..aefb76e --- /dev/null +++ b/src/lib/restore.ts @@ -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 { + 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] } + } +} diff --git a/src/types/backup.ts b/src/types/backup.ts new file mode 100644 index 0000000..f6742fa --- /dev/null +++ b/src/types/backup.ts @@ -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[] + } +}