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
This commit is contained in:
2026-03-22 18:16:36 -03:00
parent 544decf4ac
commit 8c80a12b81
14 changed files with 1824 additions and 5 deletions

View File

@@ -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 } } },
})

View File

@@ -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>
)

View File

@@ -0,0 +1,136 @@
'use client'
import { useState, useEffect } from 'react'
import { getBackups, deleteBackup } from '@/lib/backup-storage'
import { RecallBackup } from '@/types/backup'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'
import { Trash2, RotateCcw, Calendar, FileText } from 'lucide-react'
import { BackupRestoreDialog } from './backup-restore-dialog'
export function BackupList() {
const [backups, setBackups] = useState<RecallBackup[]>([])
const [loading, setLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
useEffect(() => {
loadBackups()
}, [])
async function loadBackups() {
try {
const data = await getBackups()
setBackups(data)
} catch {
toast.error('Error al cargar los backups')
} finally {
setLoading(false)
}
}
async function handleDelete(id: string) {
setDeletingId(id)
try {
await deleteBackup(id)
setBackups((prev) => prev.filter((b) => b.id !== id))
toast.success('Backup eliminado')
} catch {
toast.error('Error al eliminar el backup')
} finally {
setDeletingId(null)
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('es-ES', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
function getSourceBadgeVariant(source: RecallBackup['source']) {
switch (source) {
case 'automatic':
return 'secondary'
case 'manual':
return 'default'
case 'pre-destructive':
return 'destructive'
default:
return 'secondary'
}
}
function getSourceLabel(source: RecallBackup['source']) {
switch (source) {
case 'automatic':
return 'Automático'
case 'manual':
return 'Manual'
case 'pre-destructive':
return 'Pre-destrucción'
default:
return source
}
}
if (loading) {
return <div className="text-sm text-muted-foreground">Cargando backups...</div>
}
if (backups.length === 0) {
return (
<div className="text-sm text-muted-foreground">
No hay backups disponibles. Los backups se crean automáticamente antes de operaciones
destructivas.
</div>
)
}
return (
<div className="space-y-3">
{backups.map((backup) => (
<div
key={backup.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{formatDate(backup.createdAt)}</span>
<Badge variant={getSourceBadgeVariant(backup.source)}>{getSourceLabel(backup.source)}</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{backup.metadata.noteCount} nota{backup.metadata.noteCount !== 1 ? 's' : ''}
</span>
<span>
{backup.metadata.tagCount} tag{backup.metadata.tagCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<BackupRestoreDialog
backup={backup}
trigger={
<Button variant="outline" size="sm" className="gap-1 cursor-pointer">
<RotateCcw className="h-3 w-3" />
Restaurar
</Button>
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(backup.id)}
disabled={deletingId === backup.id}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { validateBackup } from '@/lib/backup-validator'
import { restoreBackup } from '@/lib/restore'
import { RecallBackup } from '@/types/backup'
import { toast } from 'sonner'
import { RotateCcw, FileText, Tag, Calendar, AlertTriangle } from 'lucide-react'
interface BackupRestoreDialogProps {
backup: RecallBackup
trigger?: React.ReactNode
}
export function BackupRestoreDialog({ backup, trigger }: BackupRestoreDialogProps) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<'merge' | 'replace'>('merge')
const [confirming, setConfirming] = useState(false)
const [loading, setLoading] = useState(false)
const validation = validateBackup(backup)
const backupInfo = validation.info
function handleModeChange(newMode: 'merge' | 'replace') {
setMode(newMode)
setConfirming(false)
}
async function handleRestore() {
setLoading(true)
try {
const result = await restoreBackup(backup, mode)
if (result.success) {
toast.success(`${result.restored} nota${result.restored !== 1 ? 's' : ''} restaurada${result.restored !== 1 ? 's' : ''} correctamente`)
setOpen(false)
setConfirming(false)
setMode('merge')
} else {
toast.error(`Error al restaurar: ${result.errors.join(', ')}`)
}
} catch {
toast.error('Error al restaurar el backup')
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger && <div onClick={() => setOpen(true)}>{trigger}</div>}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-5 w-5" />
Restaurar Backup
</DialogTitle>
<DialogDescription>
Recupera notas desde un backup anterior
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Backup Info */}
<div className="space-y-3 p-4 bg-muted rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.createdAt ? new Date(backupInfo.createdAt).toLocaleString('es-ES') : 'Fecha desconocida'}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.noteCount ?? 0} nota{(backupInfo?.noteCount ?? 0) !== 1 ? 's' : ''}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.tagCount ?? 0} tag{(backupInfo?.tagCount ?? 0) !== 1 ? 's' : ''}</span>
</div>
<div className="text-xs text-muted-foreground">
Fuente: {backupInfo?.source ?? 'desconocida'}
</div>
</div>
{/* Mode Selection */}
{!confirming && (
<div className="space-y-3">
<p className="text-sm font-medium">Modo de restauración</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleModeChange('merge')}
className={`p-3 border rounded-lg text-left transition-colors ${
mode === 'merge'
? 'border-primary bg-primary/5'
: 'hover:border-muted-foreground/50'
}`}
>
<div className="font-medium text-sm">Combinar</div>
<div className="text-xs text-muted-foreground">
Añade nuevas notas, actualiza existentes
</div>
</button>
<button
type="button"
onClick={() => handleModeChange('replace')}
className={`p-3 border rounded-lg text-left transition-colors ${
mode === 'replace'
? 'border-primary bg-primary/5'
: 'hover:border-muted-foreground/50'
}`}
>
<div className="font-medium text-sm">Reemplazar</div>
<div className="text-xs text-muted-foreground">
Sustituye todo el contenido actual
</div>
</button>
</div>
</div>
)}
{/* Confirmation */}
{confirming && (
<div className="space-y-4">
{mode === 'replace' && (
<div className="flex items-start gap-3 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-destructive">Operación destructiva</p>
<p className="text-muted-foreground">
Se eliminará el contenido actual antes de restaurar. Se creará un backup de seguridad automáticamente.
</p>
</div>
</div>
)}
<p className="text-sm">
¿Estás seguro de que quieres restaurar este backup? Esta acción{' '}
{mode === 'merge' ? 'no eliminará' : 'eliminará'} notas existentes.
</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
{!confirming ? (
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancelar
</Button>
<Button onClick={() => setConfirming(true)}>
Continuar
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => setConfirming(false)} disabled={loading}>
Volver
</Button>
<Button
variant={mode === 'replace' ? 'destructive' : 'default'}
onClick={handleRestore}
disabled={loading}
>
{loading ? 'Restaurando...' : 'Confirmar'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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()
}

View File

@@ -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>

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react'
export function useUnsavedChanges(
isDirty: boolean,
message = '¿Salir sin guardar cambios?'
) {
useEffect(() => {
if (!isDirty) return
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault()
e.returnValue = message
return message
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty, message])
}

56
src/lib/backup-policy.ts Normal file
View File

@@ -0,0 +1,56 @@
import { getBackups } from '@/lib/backup-storage'
import { deleteBackup } from '@/lib/backup-storage'
import { BackupSource } from '@/types/backup'
const MAX_AUTOMATIC_BACKUPS = 10
const MAX_BACKUP_AGE_DAYS = 30
function daysAgo(date: Date): number {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
return diffMs / (1000 * 60 * 60 * 24)
}
export async function shouldCleanup(): Promise<boolean> {
const backups = await getBackups()
const automaticBackups = backups.filter((b) => b.source === 'automatic')
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
return true
}
const oldBackups = backups.filter(
(b) => daysAgo(new Date(b.createdAt)) > MAX_BACKUP_AGE_DAYS
)
if (oldBackups.length > 0) {
return true
}
return false
}
export async function cleanupOldBackups(): Promise<number> {
const backups = await getBackups()
let deletedCount = 0
// Remove automatic backups exceeding the limit
const automaticBackups = backups.filter((b) => b.source === 'automatic')
if (automaticBackups.length > MAX_AUTOMATIC_BACKUPS) {
const toRemove = automaticBackups.slice(MAX_AUTOMATIC_BACKUPS)
for (const backup of toRemove) {
await deleteBackup(backup.id)
deletedCount++
}
}
// Remove backups older than 30 days
const recentBackups = await getBackups()
for (const backup of recentBackups) {
if (daysAgo(new Date(backup.createdAt)) > MAX_BACKUP_AGE_DAYS) {
await deleteBackup(backup.id)
deletedCount++
}
}
return deletedCount
}

78
src/lib/backup-storage.ts Normal file
View File

@@ -0,0 +1,78 @@
import { RecallBackup } from '@/types/backup'
const DB_NAME = 'recall_backups'
const STORE_NAME = 'backups'
const DB_VERSION = 1
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
})
}
export async function saveBackup(backup: RecallBackup): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const request = store.put(backup)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
tx.oncomplete = () => db.close()
})
}
export async function getBackups(): Promise<RecallBackup[]> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.getAll()
request.onerror = () => reject(request.error)
request.onsuccess = () => {
const backups = (request.result as RecallBackup[]).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
resolve(backups)
}
tx.oncomplete = () => db.close()
})
}
export async function getBackup(id: string): Promise<RecallBackup | null> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get(id)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result || null)
tx.oncomplete = () => db.close()
})
}
export async function deleteBackup(id: string): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const request = store.delete(id)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
tx.oncomplete = () => db.close()
})
}

View 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
View File

@@ -0,0 +1,62 @@
import { prisma } from '@/lib/prisma'
import { BackupSource, RecallBackup } from '@/types/backup'
const SCHEMA_VERSION = '1.0'
export async function createBackupSnapshot(source: BackupSource): Promise<RecallBackup> {
const [notes, tags, backlinks, noteVersions] = await Promise.all([
prisma.note.findMany({
include: { tags: { include: { tag: true } } },
}),
prisma.tag.findMany(),
prisma.backlink.findMany(),
prisma.noteVersion.findMany(),
])
const exportNotes = notes.map((note) => ({
...note,
tags: note.tags.map((nt) => nt.tag.name),
createdAt: note.createdAt.toISOString(),
updatedAt: note.updatedAt.toISOString(),
}))
const exportTags = tags.map((tag) => ({
id: tag.id,
name: tag.name,
}))
const exportBacklinks = backlinks.map((bl) => ({
id: bl.id,
sourceNoteId: bl.sourceNoteId,
targetNoteId: bl.targetNoteId,
createdAt: bl.createdAt.toISOString(),
}))
const exportVersions = noteVersions.map((v) => ({
id: v.id,
noteId: v.noteId,
title: v.title,
content: v.content,
createdAt: v.createdAt.toISOString(),
}))
const backup: RecallBackup = {
id: crypto.randomUUID(),
schemaVersion: SCHEMA_VERSION,
createdAt: new Date().toISOString(),
source,
metadata: {
noteCount: notes.length,
tagCount: tags.length,
versionCount: noteVersions.length,
},
data: {
notes: exportNotes,
tags: exportTags,
backlinks: exportBacklinks,
noteVersions: exportVersions,
},
}
return backup
}

168
src/lib/restore.ts Normal file
View File

@@ -0,0 +1,168 @@
import { RecallBackup } from '@/types/backup'
import { prisma } from '@/lib/prisma'
import { createBackupSnapshot } from '@/lib/backup'
import { syncBacklinks } from '@/lib/backlinks'
interface RestoreResult {
success: boolean
restored: number
errors: string[]
}
export async function restoreBackup(
backup: RecallBackup,
mode: 'merge' | 'replace'
): Promise<RestoreResult> {
const errors: string[] = []
try {
if (mode === 'replace') {
await createBackupSnapshot('pre-destructive')
}
const notes = backup.data.notes as Array<{
id?: string
title: string
content: string
type: string
isFavorite?: boolean
isPinned?: boolean
tags?: string[]
createdAt?: string
updatedAt?: string
}>
let restored = 0
await prisma.$transaction(async (tx) => {
for (const note of notes) {
const parseDate = (dateStr: string | undefined): Date => {
if (!dateStr) return new Date()
const parsed = new Date(dateStr)
return isNaN(parsed.getTime()) ? new Date() : parsed
}
const createdAt = parseDate(note.createdAt)
const updatedAt = parseDate(note.updatedAt)
if (mode === 'replace') {
if (note.id) {
await tx.note.upsert({
where: { id: note.id },
create: {
id: note.id,
title: note.title,
content: note.content,
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
isFavorite: note.isFavorite ?? false,
isPinned: note.isPinned ?? false,
createdAt,
updatedAt,
creationSource: 'import',
},
update: {
title: note.title,
content: note.content,
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
isFavorite: note.isFavorite ?? false,
isPinned: note.isPinned ?? false,
updatedAt,
},
})
} else {
await tx.note.create({
data: {
title: note.title,
content: note.content,
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
isFavorite: note.isFavorite ?? false,
isPinned: note.isPinned ?? false,
createdAt,
updatedAt,
creationSource: 'import',
},
})
}
restored++
} else {
if (note.id) {
const existing = await tx.note.findUnique({ where: { id: note.id } })
if (existing) {
await tx.note.update({
where: { id: note.id },
data: { title: note.title, content: note.content, updatedAt },
})
await tx.noteTag.deleteMany({ where: { noteId: note.id } })
} else {
await tx.note.create({
data: {
id: note.id,
title: note.title,
content: note.content,
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
isFavorite: note.isFavorite ?? false,
isPinned: note.isPinned ?? false,
createdAt,
updatedAt,
creationSource: 'import',
},
})
}
} else {
const existingByTitle = await tx.note.findFirst({ where: { title: note.title } })
if (existingByTitle) {
await tx.note.update({
where: { id: existingByTitle.id },
data: { content: note.content, updatedAt },
})
await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
} else {
await tx.note.create({
data: {
title: note.title,
content: note.content,
type: note.type as 'command' | 'snippet' | 'decision' | 'recipe' | 'procedure' | 'inventory' | 'note',
isFavorite: note.isFavorite ?? false,
isPinned: note.isPinned ?? false,
createdAt,
updatedAt,
creationSource: 'import',
},
})
}
}
restored++
}
const noteId = note.id
? (await tx.note.findUnique({ where: { id: note.id } }))?.id
: (await tx.note.findFirst({ where: { title: note.title } }))?.id
if (noteId && note.tags && note.tags.length > 0) {
for (const tagName of note.tags) {
const tag = await tx.tag.upsert({
where: { name: tagName },
create: { name: tagName },
update: {},
})
await tx.noteTag.create({
data: { noteId, tagId: tag.id },
})
}
}
if (noteId) {
const noteRecord = await tx.note.findUnique({ where: { id: noteId } })
if (noteRecord) {
await syncBacklinks(noteId, noteRecord.content)
}
}
}
})
return { success: true, restored, errors: [] }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, restored: 0, errors: [message] }
}
}

21
src/types/backup.ts Normal file
View File

@@ -0,0 +1,21 @@
export type BackupSource = 'automatic' | 'manual' | 'pre-destructive'
export interface BackupMetadata {
noteCount: number
tagCount: number
versionCount?: number
}
export interface RecallBackup {
id: string
schemaVersion: string
createdAt: string
source: BackupSource
metadata: BackupMetadata
data: {
notes: unknown[]
tags: unknown[]
backlinks?: unknown[]
noteVersions?: unknown[]
}
}