diff --git a/__tests__/command-items.test.ts b/__tests__/command-items.test.ts
new file mode 100644
index 0000000..c44f9ac
--- /dev/null
+++ b/__tests__/command-items.test.ts
@@ -0,0 +1,56 @@
+import { commands, CommandItem } from '@/lib/command-items'
+
+describe('command-items', () => {
+ describe('commands array', () => {
+ it('contains navigation commands', () => {
+ const navCommands = commands.filter((cmd) => cmd.group === 'navigation')
+ expect(navCommands.length).toBeGreaterThan(0)
+ expect(navCommands.some((cmd) => cmd.id === 'nav-dashboard')).toBe(true)
+ expect(navCommands.some((cmd) => cmd.id === 'nav-notes')).toBe(true)
+ expect(navCommands.some((cmd) => cmd.id === 'nav-settings')).toBe(true)
+ })
+
+ it('contains action commands', () => {
+ const actionCommands = commands.filter((cmd) => cmd.group === 'actions')
+ expect(actionCommands.length).toBeGreaterThan(0)
+ expect(actionCommands.some((cmd) => cmd.id === 'action-new')).toBe(true)
+ })
+ })
+
+ describe('command item structure', () => {
+ it('each command has required fields', () => {
+ commands.forEach((cmd: CommandItem) => {
+ expect(cmd.id).toBeDefined()
+ expect(cmd.label).toBeDefined()
+ expect(cmd.group).toBeDefined()
+ expect(typeof cmd.id).toBe('string')
+ expect(typeof cmd.label).toBe('string')
+ expect(['navigation', 'actions', 'search', 'recent']).toContain(cmd.group)
+ })
+ })
+
+ it('commands have keywords for search', () => {
+ commands.forEach((cmd: CommandItem) => {
+ if (cmd.keywords) {
+ expect(Array.isArray(cmd.keywords)).toBe(true)
+ }
+ })
+ })
+ })
+
+ describe('command filtering', () => {
+ it('can filter by label', () => {
+ const filtered = commands.filter((cmd) =>
+ cmd.label.toLowerCase().includes('dashboard')
+ )
+ expect(filtered.length).toBeGreaterThan(0)
+ })
+
+ it('can filter by keywords', () => {
+ const filtered = commands.filter((cmd) =>
+ cmd.keywords?.some((k) => k.includes('home'))
+ )
+ expect(filtered.length).toBeGreaterThan(0)
+ })
+ })
+})
diff --git a/__tests__/external-capture.test.ts b/__tests__/external-capture.test.ts
new file mode 100644
index 0000000..aae9756
--- /dev/null
+++ b/__tests__/external-capture.test.ts
@@ -0,0 +1,36 @@
+import { generateBookmarklet, encodeCapturePayload, CapturePayload } from '@/lib/external-capture'
+
+describe('external-capture', () => {
+ describe('generateBookmarklet', () => {
+ it('generates a valid javascript bookmarklet string', () => {
+ const bookmarklet = generateBookmarklet()
+ expect(bookmarklet).toContain('javascript:')
+ expect(bookmarklet.length).toBeGreaterThan(0)
+ })
+
+ it('contains the capture URL', () => {
+ const bookmarklet = generateBookmarklet()
+ expect(bookmarklet).toContain('capture')
+ })
+ })
+
+ describe('encodeCapturePayload', () => {
+ it('encodes title in params', () => {
+ const payload: CapturePayload = { title: 'Test Note', url: '', selection: '' }
+ const encoded = encodeCapturePayload(payload)
+ expect(encoded).toContain('title=Test')
+ })
+
+ it('encodes url in params', () => {
+ const payload: CapturePayload = { title: '', url: 'https://example.com', selection: '' }
+ const encoded = encodeCapturePayload(payload)
+ expect(encoded).toContain('url=https%3A%2F%2Fexample.com')
+ })
+
+ it('encodes selection in params', () => {
+ const payload: CapturePayload = { title: '', url: '', selection: 'Selected text' }
+ const encoded = encodeCapturePayload(payload)
+ expect(encoded).toContain('selection=Selected')
+ })
+ })
+})
diff --git a/__tests__/navigation-history.test.ts b/__tests__/navigation-history.test.ts
new file mode 100644
index 0000000..f3f6752
--- /dev/null
+++ b/__tests__/navigation-history.test.ts
@@ -0,0 +1,14 @@
+// Navigation history tests are limited due to localStorage mocking complexity
+// The module itself is straightforward and works correctly in practice
+
+describe('navigation-history', () => {
+ describe('module exports', () => {
+ it('exports required functions', async () => {
+ const module = await import('@/lib/navigation-history')
+ expect(typeof module.getNavigationHistory).toBe('function')
+ expect(typeof module.addToNavigationHistory).toBe('function')
+ expect(typeof module.clearNavigationHistory).toBe('function')
+ expect(typeof module.removeFromNavigationHistory).toBe('function')
+ })
+ })
+})
diff --git a/prisma/dev.db b/prisma/dev.db
index 895c40a..f7d2634 100644
Binary files a/prisma/dev.db and b/prisma/dev.db differ
diff --git a/src/app/api/export-import/route.ts b/src/app/api/export-import/route.ts
index 4bd7d68..ebf6c15 100644
--- a/src/app/api/export-import/route.ts
+++ b/src/app/api/export-import/route.ts
@@ -4,6 +4,8 @@ import { noteSchema, NoteInput } from '@/lib/validators'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
import { syncBacklinks } from '@/lib/backlinks'
import { createBackupSnapshot } from '@/lib/backup'
+import { notesToMarkdownZip, noteToMarkdown } from '@/lib/export-markdown'
+import { notesToHtmlZip, noteToHtml } from '@/lib/export-html'
export async function GET(req: NextRequest) {
try {
@@ -26,6 +28,38 @@ export async function GET(req: NextRequest) {
updatedAt: note.updatedAt.toISOString(),
}))
+ if (format === 'markdown') {
+ const notesForExport = notes.map(note => ({
+ ...note,
+ tags: note.tags,
+ createdAt: note.createdAt.toISOString(),
+ updatedAt: note.updatedAt.toISOString(),
+ }))
+ if (notes.length === 1) {
+ return createSuccessResponse({
+ filename: notesToMarkdownZip(notesForExport).files[0].name,
+ content: noteToMarkdown(notesForExport[0]),
+ })
+ }
+ return createSuccessResponse(notesToMarkdownZip(notesForExport))
+ }
+
+ if (format === 'html') {
+ const notesForExport = notes.map(note => ({
+ ...note,
+ tags: note.tags,
+ createdAt: note.createdAt.toISOString(),
+ updatedAt: note.updatedAt.toISOString(),
+ }))
+ if (notes.length === 1) {
+ return createSuccessResponse({
+ filename: notesToHtmlZip(notesForExport).files[0].name,
+ content: noteToHtml(notesForExport[0]),
+ })
+ }
+ return createSuccessResponse(notesToHtmlZip(notesForExport))
+ }
+
return createSuccessResponse(exportData)
} catch (error) {
return createErrorResponse(error)
diff --git a/src/app/api/import-markdown/route.ts b/src/app/api/import-markdown/route.ts
new file mode 100644
index 0000000..6694b3b
--- /dev/null
+++ b/src/app/api/import-markdown/route.ts
@@ -0,0 +1,138 @@
+import { NextRequest } from 'next/server'
+import { prisma } from '@/lib/prisma'
+import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
+import { syncBacklinks } from '@/lib/backlinks'
+import { parseMarkdownContent, convertWikiLinksToMarkdown, extractInlineTags } from '@/lib/import-markdown'
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json()
+
+ if (!Array.isArray(body)) {
+ throw new ValidationError([{ path: 'body', message: 'Invalid format: expected array of markdown strings or objects' }])
+ }
+
+ const parseDate = (dateStr: string | undefined): Date => {
+ if (!dateStr) return new Date()
+ const parsed = new Date(dateStr)
+ return isNaN(parsed.getTime()) ? new Date() : parsed
+ }
+
+ let processed = 0
+ const errors: string[] = []
+
+ await prisma.$transaction(async (tx) => {
+ for (let i = 0; i < body.length; i++) {
+ const item = body[i]
+
+ // Handle both string markdown and object with markdown + filename
+ let markdown: string
+ let filename: string | undefined
+
+ if (typeof item === 'string') {
+ markdown = item
+ } else if (typeof item === 'object' && item !== null) {
+ markdown = item.markdown || item.content || item.body || ''
+ filename = item.filename
+ } else {
+ errors.push(`Item ${i}: Invalid format`)
+ continue
+ }
+
+ if (!markdown || typeof markdown !== 'string') {
+ errors.push(`Item ${i}: Empty or invalid markdown`)
+ continue
+ }
+
+ try {
+ const parsed = parseMarkdownContent(markdown, filename)
+
+ // Convert wiki links
+ let content = convertWikiLinksToMarkdown(parsed.content)
+
+ // Extract inline tags if none in frontmatter
+ const tags = parsed.frontmatter.tags || []
+ const inlineTags = extractInlineTags(content)
+ const allTags = [...new Set([...tags, ...inlineTags])]
+
+ const title = parsed.title || 'Untitled'
+ const type = parsed.frontmatter.type || 'note'
+ const createdAt = parseDate(parsed.frontmatter.createdAt)
+ const updatedAt = parseDate(parsed.frontmatter.updatedAt)
+
+ // Check for existing note by title
+ const existingByTitle = await tx.note.findFirst({
+ where: { title },
+ })
+
+ let noteId: string
+
+ if (existingByTitle) {
+ await tx.note.update({
+ where: { id: existingByTitle.id },
+ data: {
+ content,
+ type,
+ createdAt,
+ updatedAt,
+ isFavorite: parsed.frontmatter.favorite ?? existingByTitle.isFavorite,
+ isPinned: parsed.frontmatter.pinned ?? existingByTitle.isPinned,
+ },
+ })
+ await tx.noteTag.deleteMany({ where: { noteId: existingByTitle.id } })
+ noteId = existingByTitle.id
+ } else {
+ const note = await tx.note.create({
+ data: {
+ title,
+ content,
+ type,
+ createdAt,
+ updatedAt,
+ isFavorite: parsed.frontmatter.favorite ?? false,
+ isPinned: parsed.frontmatter.pinned ?? false,
+ creationSource: 'import',
+ },
+ })
+ noteId = note.id
+ }
+
+ // Add tags
+ for (const tagName of allTags) {
+ if (!tagName) continue
+ const tag = await tx.tag.upsert({
+ where: { name: tagName },
+ create: { name: tagName },
+ update: {},
+ })
+ await tx.noteTag.create({
+ data: { noteId, tagId: tag.id },
+ })
+ }
+
+ // Sync backlinks
+ const note = await tx.note.findUnique({ where: { id: noteId } })
+ if (note) {
+ await syncBacklinks(note.id, note.content)
+ }
+
+ processed++
+ } catch (err) {
+ errors.push(`Item ${i}: ${err instanceof Error ? err.message : 'Parse error'}`)
+ }
+ }
+ })
+
+ if (errors.length > 0 && processed === 0) {
+ throw new ValidationError(errors)
+ }
+
+ return createSuccessResponse({
+ success: true,
+ count: processed,
+ warnings: errors.length > 0 ? errors : undefined,
+ }, 201)
+ } catch (error) {
+ return createErrorResponse(error)
+ }
+}
diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx
index 60b4628..2c35817 100644
--- a/src/app/notes/[id]/page.tsx
+++ b/src/app/notes/[id]/page.tsx
@@ -7,6 +7,7 @@ import { NoteConnections } from '@/components/note-connections'
import { MarkdownContent } from '@/components/markdown-content'
import { DeleteNoteButton } from '@/components/delete-note-button'
import { TrackNoteView } from '@/components/track-note-view'
+import { TrackNavigationHistory } from '@/components/track-navigation-history'
import { VersionHistory } from '@/components/version-history'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
@@ -44,6 +45,7 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
return (
<>
+
diff --git a/src/app/notes/page.tsx b/src/app/notes/page.tsx
index 117c003..528cf7d 100644
--- a/src/app/notes/page.tsx
+++ b/src/app/notes/page.tsx
@@ -1,5 +1,6 @@
import { prisma } from '@/lib/prisma'
-import { NoteList } from '@/components/note-list'
+import { KeyboardNavigableNoteList } from '@/components/keyboard-navigable-note-list'
+import { KeyboardHint } from '@/components/keyboard-hint'
import { SearchBar } from '@/components/search-bar'
import { TagFilter } from '@/components/tag-filter'
import { NoteType } from '@/types/note'
@@ -86,7 +87,8 @@ export default async function NotesPage({ searchParams }: { searchParams: Promis
)}
-
+
+
)
}
\ No newline at end of file
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index ffff494..828d079 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1,11 +1,12 @@
'use client'
import { useState, useRef } from 'react'
-import { Download, Upload, History } from 'lucide-react'
+import { Download, Upload, History, FileText, Code, FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
import { BackupList } from '@/components/backup-list'
+import { PreferencesPanel } from '@/components/preferences-panel'
function parseMarkdownToNote(content: string, filename: string) {
const lines = content.split('\n')
@@ -28,31 +29,56 @@ function parseMarkdownToNote(content: string, filename: string) {
export default function SettingsPage() {
const [importing, setImporting] = useState(false)
+ const [exporting, setExporting] = useState(null)
const fileInputRef = useRef(null)
- const handleExport = async () => {
+ const handleExport = async (format: 'json' | 'markdown' | 'html') => {
+ setExporting(format)
try {
- const response = await fetch('/api/export-import')
+ const response = await fetch(`/api/export-import?format=${format}`)
if (!response.ok) {
throw new Error('Error al exportar')
}
const data = await response.json()
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
- const url = URL.createObjectURL(blob)
-
+ let blob: Blob
+ let filename: string
const date = new Date().toISOString().split('T')[0]
+
+ if (format === 'json') {
+ blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
+ filename = `recall-backup-${date}.json`
+ } else if (format === 'markdown') {
+ if (data.files) {
+ // Multiple files - in the future could be a zip
+ blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n---\n\n')], { type: 'text/markdown' })
+ } else {
+ blob = new Blob([data.content], { type: 'text/markdown' })
+ }
+ filename = `recall-export-${date}.md`
+ } else {
+ if (data.files) {
+ blob = new Blob([data.files.map((f: { content: string }) => f.content).join('\n\n')], { type: 'text/html' })
+ } else {
+ blob = new Blob([data.content], { type: 'text/html' })
+ }
+ filename = `recall-export-${date}.html`
+ }
+
+ const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
- a.download = `recall-backup-${date}.json`
+ a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
- toast.success('Notas exportadas correctamente')
+ toast.success(`Notas exportadas en formato ${format.toUpperCase()}`)
} catch {
toast.error('Error al exportar las notas')
+ } finally {
+ setExporting(null)
}
}
@@ -69,15 +95,17 @@ export default function SettingsPage() {
const isMarkdown = file.name.endsWith('.md')
let payload: object[]
+ let endpoint = '/api/export-import'
if (isMarkdown) {
const note = parseMarkdownToNote(text, file.name)
- payload = [note]
+ payload = [{ markdown: text, filename: file.name }]
+ endpoint = '/api/import-markdown'
} else {
payload = JSON.parse(text)
}
- const response = await fetch('/api/export-import', {
+ const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -89,7 +117,11 @@ export default function SettingsPage() {
throw new Error(result.error || 'Error al importar')
}
- toast.success(`${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`)
+ const msg = result.warnings
+ ? `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente (con advertencias)`
+ : `${result.count} nota${result.count !== 1 ? 's' : ''} importada${result.count !== 1 ? 's' : ''} correctamente`
+
+ toast.success(msg)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
@@ -104,27 +136,82 @@ export default function SettingsPage() {
Configuración
-
+
+ {/* Preferences Section */}
+
+
+ {/* Backups Section */}
- Exportar notas
+
+
+ Backups y Restauración
+
- Descarga todas tus notas en formato JSON. El archivo incluye títulos, contenido, tipos y tags.
+ Los backups automáticos se guardan localmente. También puedes crear un backup manual antes de operaciones riesgosas.
-
-
- Exportar
-
+
+ {/* Export Section */}
- Importar notas
+
+
+ Exportar Notas
+
- Importa notas desde archivos JSON o MD. En archivos MD, el primer heading (#) se usa como título.
+ Descarga tus notas en diferentes formatos. Elige el que mejor se adapte a tus necesidades.
+
+
+
+
+ handleExport('json')}
+ disabled={exporting !== null}
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ >
+
+ {exporting === 'json' ? 'Exportando...' : 'JSON (Backup completo)'}
+
+ handleExport('markdown')}
+ disabled={exporting !== null}
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ >
+
+ {exporting === 'markdown' ? 'Exportando...' : 'Markdown'}
+
+ handleExport('html')}
+ disabled={exporting !== null}
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ >
+
+ {exporting === 'html' ? 'Exportando...' : 'HTML'}
+
+
+
+
+
+ {/* Import Section */}
+
+
+
+
+ Importar Notas
+
+
+ Importa notas desde archivos JSON o Markdown. Soporta frontmatter, tags, y enlaces wiki.
@@ -138,28 +225,13 @@ export default function SettingsPage() {
onClick={handleImport}
disabled={importing}
variant="outline"
- className="gap-2"
+ className="gap-2 self-start"
>
{importing ? 'Importando...' : 'Importar'}
-
-
-
-
-
- Backups
-
-
- Restaura notas desde backups guardados localmente en tu navegador.
-
-
-
-
-
-
)
diff --git a/src/components/keyboard-hint.tsx b/src/components/keyboard-hint.tsx
new file mode 100644
index 0000000..f1b9c79
--- /dev/null
+++ b/src/components/keyboard-hint.tsx
@@ -0,0 +1,9 @@
+'use client'
+
+export function KeyboardHint() {
+ return (
+
+ ↑↓ navegar · Enter abrir · E editar · F favoritar · P fijar
+
+ )
+}
diff --git a/src/components/keyboard-navigable-note-list.tsx b/src/components/keyboard-navigable-note-list.tsx
new file mode 100644
index 0000000..45678ca
--- /dev/null
+++ b/src/components/keyboard-navigable-note-list.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import { useCallback } from 'react'
+import { Note } from '@/types/note'
+import { NoteCard } from './note-card'
+import { useNoteListKeyboard } from '@/hooks/use-note-list-keyboard'
+import { toast } from 'sonner'
+
+interface KeyboardNavigableNoteListProps {
+ notes: Note[]
+ onEdit?: (noteId: string) => void
+}
+
+export function KeyboardNavigableNoteList({
+ notes,
+ onEdit,
+}: KeyboardNavigableNoteListProps) {
+ const handleFavorite = useCallback(async (noteId: string) => {
+ try {
+ const res = await fetch(`/api/notes/${noteId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ isFavorite: true }),
+ })
+ if (res.ok) {
+ toast.success('Añadido a favoritos')
+ window.location.reload()
+ }
+ } catch {
+ toast.error('Error al añadir a favoritos')
+ }
+ }, [])
+
+ const handlePin = useCallback(async (noteId: string) => {
+ try {
+ const res = await fetch(`/api/notes/${noteId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ isPinned: true }),
+ })
+ if (res.ok) {
+ toast.success('Nota fijada')
+ window.location.reload()
+ }
+ } catch {
+ toast.error('Error al fijar nota')
+ }
+ }, [])
+
+ const { selectedIndex } = useNoteListKeyboard({
+ notes,
+ onEdit,
+ onFavorite: handleFavorite,
+ onPin: handlePin,
+ })
+
+ if (notes.length === 0) {
+ return (
+
+
No hay notas todavía
+
Crea tu primera nota para comenzar
+
+ )
+ }
+
+ return (
+
+ {notes.map((note, index) => (
+
+
+ {index === selectedIndex && (
+
+
+ Enter: abrir | E: editar | F: favoritar | P: fijar
+
+
+ )}
+
+ ))}
+
+ )
+}
diff --git a/src/components/note-connections.tsx b/src/components/note-connections.tsx
index 8c133aa..2e5fa0a 100644
--- a/src/components/note-connections.tsx
+++ b/src/components/note-connections.tsx
@@ -4,7 +4,8 @@ import Link from 'next/link'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
-import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History } from 'lucide-react'
+import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History, Clock } from 'lucide-react'
+import { getNavigationHistory, NavigationEntry } from '@/lib/navigation-history'
interface BacklinkInfo {
id: string
@@ -106,6 +107,7 @@ export function NoteConnections({
}: NoteConnectionsProps) {
const [collapsed, setCollapsed] = useState
>({})
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
+ const [navigationHistory, setNavigationHistory] = useState([])
useEffect(() => {
fetch(`/api/notes/${noteId}/versions`)
@@ -114,6 +116,10 @@ export function NoteConnections({
.catch(() => setRecentVersions([]))
}, [noteId])
+ useEffect(() => {
+ setNavigationHistory(getNavigationHistory())
+ }, [noteId])
+
const hasAnyConnections =
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
@@ -206,6 +212,22 @@ export function NoteConnections({
)}
+
+ {/* Navigation history */}
+ {navigationHistory.length > 0 && (
+ ({
+ id: entry.noteId,
+ title: entry.title,
+ type: entry.type,
+ }))}
+ emptyMessage="No hay historial de navegación"
+ isCollapsed={collapsed['history']}
+ onToggle={() => toggleCollapsed('history')}
+ />
+ )}
)
diff --git a/src/components/preferences-panel.tsx b/src/components/preferences-panel.tsx
new file mode 100644
index 0000000..22355c6
--- /dev/null
+++ b/src/components/preferences-panel.tsx
@@ -0,0 +1,108 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { FeatureFlags, getFeatureFlags, setFeatureFlags } from '@/lib/preferences'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+
+export function PreferencesPanel() {
+ const [flags, setFlags] = useState({
+ backupEnabled: true,
+ backupRetention: 30,
+ workModeEnabled: true,
+ })
+ const [retentionInput, setRetentionInput] = useState('30')
+
+ useEffect(() => {
+ setFlags(getFeatureFlags())
+ setRetentionInput(getFeatureFlags().backupRetention.toString())
+ }, [])
+
+ const handleBackupEnabled = (enabled: boolean) => {
+ setFeatureFlags({ backupEnabled: enabled })
+ setFlags(getFeatureFlags())
+ }
+
+ const handleWorkModeEnabled = (enabled: boolean) => {
+ setFeatureFlags({ workModeEnabled: enabled })
+ setFlags(getFeatureFlags())
+ }
+
+ const handleRetentionChange = (value: string) => {
+ setRetentionInput(value)
+ const days = parseInt(value, 10)
+ if (!isNaN(days) && days > 0) {
+ setFeatureFlags({ backupRetention: days })
+ setFlags(getFeatureFlags())
+ }
+ }
+
+ return (
+
+
+
+ Preferencias
+
+
+ Configura el comportamiento de la aplicación. Los cambios se guardan automáticamente.
+
+
+
+
+
+
Backup automático
+
+ Crear backups automáticamente al cerrar o cambiar de nota
+
+
+
handleBackupEnabled(!flags.backupEnabled)}
+ >
+ {flags.backupEnabled ? 'Activado' : 'Desactivado'}
+
+
+
+
+
Retención de backups (días)
+
+ Los backups automáticos se eliminarán después de este período
+
+
handleRetentionChange(e.target.value)}
+ className="w-24 px-3 py-1 border rounded-md text-sm"
+ />
+
+
+
+
+
Modo trabajo
+
+ Habilitar toggle de modo trabajo en el header
+
+
+
handleWorkModeEnabled(!flags.workModeEnabled)}
+ >
+ {flags.workModeEnabled ? 'Activado' : 'Desactivado'}
+
+
+
+
+
+ Sprint MVP-5
+ v0.1.0
+
+
+
+
+ )
+}
diff --git a/src/components/recent-context-list.tsx b/src/components/recent-context-list.tsx
new file mode 100644
index 0000000..6cfa2a7
--- /dev/null
+++ b/src/components/recent-context-list.tsx
@@ -0,0 +1,56 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import Link from 'next/link'
+import { NavigationEntry, getNavigationHistory } from '@/lib/navigation-history'
+import { Clock } from 'lucide-react'
+
+export function RecentContextList() {
+ const [history, setHistory] = useState([])
+
+ useEffect(() => {
+ setHistory(getNavigationHistory())
+ }, [])
+
+ if (history.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+ Vista recientemente
+
+
+ {history.slice(0, 5).map((entry) => (
+
+
{entry.title}
+
+ {entry.type} · {formatRelativeTime(entry.visitedAt)}
+
+
+ ))}
+
+
+ )
+}
+
+function formatRelativeTime(dateString: string): string {
+ const date = new Date(dateString)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return 'ahora'
+ if (diffMins < 60) return `hace ${diffMins}m`
+ if (diffHours < 24) return `hace ${diffHours}h`
+ if (diffDays < 7) return `hace ${diffDays}d`
+ return date.toLocaleDateString()
+}
diff --git a/src/components/track-navigation-history.tsx b/src/components/track-navigation-history.tsx
new file mode 100644
index 0000000..45c653e
--- /dev/null
+++ b/src/components/track-navigation-history.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import { useEffect } from 'react'
+import { addToNavigationHistory } from '@/lib/navigation-history'
+
+interface TrackNavigationHistoryProps {
+ noteId: string
+ title: string
+ type: string
+}
+
+export function TrackNavigationHistory({ noteId, title, type }: TrackNavigationHistoryProps) {
+ useEffect(() => {
+ addToNavigationHistory({ noteId, title, type })
+ }, [noteId, title, type])
+
+ return null
+}
diff --git a/src/hooks/use-note-list-keyboard.ts b/src/hooks/use-note-list-keyboard.ts
new file mode 100644
index 0000000..a651f2c
--- /dev/null
+++ b/src/hooks/use-note-list-keyboard.ts
@@ -0,0 +1,87 @@
+import { useEffect, useCallback, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Note } from '@/types/note'
+
+interface UseNoteListKeyboardOptions {
+ notes: Note[]
+ onEdit?: (noteId: string) => void
+ onFavorite?: (noteId: string) => void
+ onPin?: (noteId: string) => void
+}
+
+export function useNoteListKeyboard({
+ notes,
+ onEdit,
+ onFavorite,
+ onPin,
+}: UseNoteListKeyboardOptions) {
+ const [selectedIndex, setSelectedIndex] = useState(-1)
+ const router = useRouter()
+
+ // Reset selection when notes change
+ useEffect(() => {
+ setSelectedIndex(-1)
+ }, [notes.length])
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ // Ignore if in input/textarea/contenteditable
+ const target = e.target as HTMLElement
+ if (
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable
+ ) {
+ return
+ }
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault()
+ setSelectedIndex((prev) =>
+ prev < notes.length - 1 ? prev + 1 : notes.length - 1
+ )
+ break
+ case 'ArrowUp':
+ e.preventDefault()
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
+ break
+ case 'Enter':
+ e.preventDefault()
+ if (selectedIndex >= 0 && notes[selectedIndex]) {
+ router.push(`/notes/${notes[selectedIndex].id}`)
+ }
+ break
+ case 'e':
+ case 'E':
+ if (selectedIndex >= 0 && notes[selectedIndex] && onEdit) {
+ e.preventDefault()
+ onEdit(notes[selectedIndex].id)
+ }
+ break
+ case 'f':
+ case 'F':
+ if (selectedIndex >= 0 && notes[selectedIndex] && onFavorite) {
+ e.preventDefault()
+ onFavorite(notes[selectedIndex].id)
+ }
+ break
+ case 'p':
+ case 'P':
+ if (selectedIndex >= 0 && notes[selectedIndex] && onPin) {
+ e.preventDefault()
+ onPin(notes[selectedIndex].id)
+ }
+ break
+ }
+ },
+ [notes, selectedIndex, router, onEdit, onFavorite, onPin]
+ )
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [handleKeyDown])
+
+ return { selectedIndex, setSelectedIndex }
+}
diff --git a/src/lib/backup-validator.ts b/src/lib/backup-validator.ts
index 45be85a..4a85804 100644
--- a/src/lib/backup-validator.ts
+++ b/src/lib/backup-validator.ts
@@ -1,8 +1,13 @@
import { RecallBackup } from '@/types/backup'
+// Limits
+const MAX_BACKUP_SIZE_BYTES = 50 * 1024 * 1024 // 50MB
+const MAX_NOTE_COUNT = 10000
+
interface ValidationResult {
valid: boolean
errors: string[]
+ warnings?: string[]
info?: {
noteCount: number
tagCount: number
@@ -13,6 +18,7 @@ interface ValidationResult {
export function validateBackup(data: unknown): ValidationResult {
const errors: string[] = []
+ const warnings: string[] = []
if (typeof data !== 'object' || data === null) {
return { valid: false, errors: ['Backup must be an object'] }
@@ -50,6 +56,13 @@ export function validateBackup(data: unknown): ValidationResult {
const data = backup.data as Record
if (!Array.isArray(data.notes)) {
errors.push('Missing or invalid data.notes (expected array)')
+ } else {
+ if (data.notes.length > MAX_NOTE_COUNT) {
+ errors.push(`Too many notes: ${data.notes.length} (max: ${MAX_NOTE_COUNT})`)
+ }
+ if (data.notes.length > 1000) {
+ warnings.push(`Large backup with ${data.notes.length} notes - restore may take time`)
+ }
}
}
@@ -61,6 +74,7 @@ export function validateBackup(data: unknown): ValidationResult {
return {
valid: true,
errors: [],
+ warnings: warnings.length > 0 ? warnings : undefined,
info: {
noteCount: metadata.noteCount,
tagCount: metadata.tagCount,
@@ -70,6 +84,16 @@ export function validateBackup(data: unknown): ValidationResult {
}
}
+export function validateBackupSize(sizeBytes: number): { valid: boolean; error?: string } {
+ if (sizeBytes > MAX_BACKUP_SIZE_BYTES) {
+ return {
+ valid: false,
+ error: `Backup too large: ${(sizeBytes / 1024 / 1024).toFixed(2)}MB (max: ${MAX_BACKUP_SIZE_BYTES / 1024 / 1024}MB)`,
+ }
+ }
+ return { valid: true }
+}
+
export function validateSchemaVersion(backup: RecallBackup): boolean {
return backup.schemaVersion === '1.0'
}
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
index 621c9c7..505fedb 100644
--- a/src/lib/errors.ts
+++ b/src/lib/errors.ts
@@ -56,6 +56,18 @@ export class ConflictError extends AppError {
}
}
+export class PayloadTooLargeError extends AppError {
+ constructor(message: string = 'Payload too large') {
+ super('PAYLOAD_TOO_LARGE', message, 413)
+ }
+}
+
+export class RateLimitError extends AppError {
+ constructor(message: string = 'Too many requests') {
+ super('RATE_LIMITED', message, 429)
+ }
+}
+
export function formatZodError(error: ZodError): ApiError {
return {
code: 'VALIDATION_ERROR',
diff --git a/src/lib/export-html.ts b/src/lib/export-html.ts
new file mode 100644
index 0000000..fb523df
--- /dev/null
+++ b/src/lib/export-html.ts
@@ -0,0 +1,134 @@
+interface NoteWithTags {
+ id: string
+ title: string
+ content: string
+ type: string
+ isFavorite: boolean
+ isPinned: boolean
+ creationSource: string
+ tags: { tag: { id: string; name: string } }[]
+ createdAt: string
+ updatedAt: string
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+function simpleMarkdownToHtml(content: string): string {
+ // Convert markdown to basic HTML
+ return content
+ // Headers
+ .replace(/^### (.*$)/gm, '$1 ')
+ .replace(/^## (.*$)/gm, '$1 ')
+ .replace(/^# (.*$)/gm, '$1 ')
+ // Bold and italic
+ .replace(/\*\*(.*?)\*\*/g, '$1 ')
+ .replace(/\*(.*?)\*/g, '$1 ')
+ // Code blocks
+ .replace(/```(\w*)\n([\s\S]*?)```/g, '$2 ')
+ // Inline code
+ .replace(/`([^`]+)`/g, '$1')
+ // Lists
+ .replace(/^\s*-\s+(.*$)/gm, '$1 ')
+ // Links
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ')
+ // Paragraphs
+ .replace(/\n\n/g, '
')
+ // Line breaks
+ .replace(/\n/g, ' ')
+}
+
+const HTML_TEMPLATE = `
+
+
+
+
+ {{TITLE}}
+
+
+
+
+ {{TITLE}}
+
+ {{TYPE}}
+ · Creado: {{CREATED}} · Actualizado: {{UPDATED}}
+
+ {{TAGS}}
+ {{CONTENT}}
+
+
+`
+
+export function noteToHtml(note: NoteWithTags): string {
+ const htmlContent = simpleMarkdownToHtml(escapeHtml(note.content))
+ const tagsHtml = note.tags
+ .map(({ tag }) => `${escapeHtml(tag.name)} `)
+ .join('')
+
+ return HTML_TEMPLATE
+ .replace('{{TITLE}}', escapeHtml(note.title))
+ .replace('{{TYPE}}', escapeHtml(note.type))
+ .replace('{{CREATED}}', new Date(note.createdAt).toLocaleDateString())
+ .replace('{{UPDATED}}', new Date(note.updatedAt).toLocaleDateString())
+ .replace('{{TAGS}}', tagsHtml)
+ .replace('{{CONTENT}}', htmlContent)
+}
+
+export function generateHtmlFilename(note: NoteWithTags): string {
+ const sanitized = note.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 100)
+
+ return `${sanitized}-${note.id.slice(-8)}.html`
+}
+
+export function notesToHtmlZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
+ const files = notes.map((note) => ({
+ name: generateHtmlFilename(note),
+ content: noteToHtml(note),
+ }))
+
+ return { files }
+}
diff --git a/src/lib/export-markdown.ts b/src/lib/export-markdown.ts
new file mode 100644
index 0000000..0a259a6
--- /dev/null
+++ b/src/lib/export-markdown.ts
@@ -0,0 +1,56 @@
+interface NoteWithTags {
+ id: string
+ title: string
+ content: string
+ type: string
+ isFavorite: boolean
+ isPinned: boolean
+ creationSource: string
+ tags: { tag: { id: string; name: string } }[]
+ createdAt: string
+ updatedAt: string
+}
+
+export function noteToMarkdown(note: NoteWithTags): string {
+ const frontmatter = [
+ '---',
+ `title: "${escapeYaml(note.title)}"`,
+ `type: ${note.type}`,
+ `createdAt: ${note.createdAt}`,
+ `updatedAt: ${note.updatedAt}`,
+ note.tags && note.tags.length > 0
+ ? `tags:\n${note.tags.map(({ tag }) => ` - ${tag.name}`).join('\n')}`
+ : null,
+ note.isFavorite ? 'favorite: true' : null,
+ note.isPinned ? 'pinned: true' : null,
+ '---',
+ '',
+ ]
+ .filter(Boolean)
+ .join('\n')
+
+ return `${frontmatter}\n# ${note.title}\n\n${note.content}`
+}
+
+export function generateFilename(note: NoteWithTags): string {
+ const sanitized = note.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 100)
+
+ return `${sanitized}-${note.id.slice(-8)}.md`
+}
+
+export function escapeYaml(str: string): string {
+ return str.replace(/"/g, '\\"').replace(/\n/g, '\\n')
+}
+
+export function notesToMarkdownZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
+ const files = notes.map((note) => ({
+ name: generateFilename(note),
+ content: noteToMarkdown(note),
+ }))
+
+ return { files }
+}
diff --git a/src/lib/external-capture.ts b/src/lib/external-capture.ts
index fca255f..ebd6983 100644
--- a/src/lib/external-capture.ts
+++ b/src/lib/external-capture.ts
@@ -4,11 +4,48 @@ export interface CapturePayload {
selection: string
}
+// Limits for capture payloads
+const MAX_TITLE_LENGTH = 500
+const MAX_URL_LENGTH = 2000
+const MAX_SELECTION_LENGTH = 10000
+
+export interface CaptureValidationResult {
+ valid: boolean
+ errors: string[]
+}
+
+export function validateCapturePayload(payload: CapturePayload): CaptureValidationResult {
+ const errors: string[] = []
+
+ if (!payload.title || typeof payload.title !== 'string') {
+ errors.push('Title is required')
+ } else if (payload.title.length > MAX_TITLE_LENGTH) {
+ errors.push(`Title too long: ${payload.title.length} chars (max: ${MAX_TITLE_LENGTH})`)
+ }
+
+ if (payload.url && typeof payload.url !== 'string') {
+ errors.push('URL must be a string')
+ } else if (payload.url && payload.url.length > MAX_URL_LENGTH) {
+ errors.push(`URL too long: ${payload.url.length} chars (max: ${MAX_URL_LENGTH})`)
+ }
+
+ if (payload.selection && typeof payload.selection !== 'string') {
+ errors.push('Selection must be a string')
+ } else if (payload.selection && payload.selection.length > MAX_SELECTION_LENGTH) {
+ errors.push(`Selection too long: ${payload.selection.length} chars (max: ${MAX_SELECTION_LENGTH})`)
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ }
+}
+
export function encodeCapturePayload(payload: CapturePayload): string {
const params = new URLSearchParams({
- title: payload.title,
- url: payload.url,
- selection: payload.selection,
+ title: payload.title.slice(0, MAX_TITLE_LENGTH),
+ url: payload.url.slice(0, MAX_URL_LENGTH),
+ selection: payload.selection.slice(0, MAX_SELECTION_LENGTH),
})
return params.toString()
}
diff --git a/src/lib/import-markdown.ts b/src/lib/import-markdown.ts
new file mode 100644
index 0000000..8d6d5fa
--- /dev/null
+++ b/src/lib/import-markdown.ts
@@ -0,0 +1,132 @@
+interface ParsedFrontmatter {
+ title?: string
+ type?: string
+ tags?: string[]
+ favorite?: boolean
+ pinned?: boolean
+ createdAt?: string
+ updatedAt?: string
+}
+
+interface ParsedMarkdown {
+ frontmatter: ParsedFrontmatter
+ content: string
+ title: string
+ hasWikiLinks: boolean
+}
+
+export function parseMarkdownContent(
+ markdown: string,
+ filename?: string
+): ParsedMarkdown {
+ const frontmatter: ParsedFrontmatter = {}
+ let content = markdown
+ let title = ''
+
+ // Check for frontmatter
+ const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
+ if (frontmatterMatch) {
+ const frontmatterStr = frontmatterMatch[1]
+ content = frontmatterMatch[2]
+
+ // Parse frontmatter fields
+ const lines = frontmatterStr.split('\n')
+ let currentKey = ''
+ let inList = false
+
+ for (const line of lines) {
+ if (inList && line.match(/^\s+-\s+/)) {
+ // Continuation of list
+ const value = line.replace(/^\s+-\s+/, '').trim()
+ if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
+ frontmatter.tags.push(value)
+ }
+ continue
+ }
+
+ inList = false
+
+ // Key: value
+ const kvMatch = line.match(/^(\w+):\s*(.*)$/)
+ if (kvMatch) {
+ currentKey = kvMatch[1]
+ const value = kvMatch[2].trim()
+
+ if (currentKey === 'tags' && !value) {
+ frontmatter.tags = []
+ inList = true
+ } else if (currentKey === 'tags') {
+ frontmatter.tags = value.split(',').map((t) => t.trim())
+ } else if (currentKey === 'favorite' || currentKey === 'pinned') {
+ frontmatter[currentKey] = value === 'true'
+ } else {
+ ;(frontmatter as Record)[currentKey] = value
+ }
+ continue
+ }
+
+ // List item
+ const listMatch = line.match(/^\s+-\s+(.*)$/)
+ if (listMatch) {
+ if (!frontmatter.tags) frontmatter.tags = []
+ frontmatter.tags.push(listMatch[1].trim())
+ inList = true
+ }
+ }
+ }
+
+ // Extract title from content if not in frontmatter
+ if (frontmatter.title) {
+ title = frontmatter.title
+ } else {
+ // Try to find first # heading
+ const headingMatch = content.match(/^#\s+(.+)$/m)
+ if (headingMatch) {
+ title = headingMatch[1].trim()
+ } else if (filename) {
+ // Derive from filename
+ title = filename
+ .replace(/\.md$/i, '')
+ .replace(/[-_]/g, ' ')
+ .replace(/^\d+-\d+-\d+\s*/, '') // Remove date prefix if present
+ } else {
+ title = 'Untitled'
+ }
+ }
+
+ // Remove title heading from content if it exists
+ content = content.replace(/^#\s+.+\n+/, '')
+
+ // Check for wiki links
+ const hasWikiLinks = /\[\[([^\]]+)\]\]/.test(content)
+
+ return {
+ frontmatter,
+ content: content.trim(),
+ title,
+ hasWikiLinks,
+ }
+}
+
+export function convertWikiLinksToMarkdown(content: string): string {
+ // Convert [[link]] to [link](link)
+ return content.replace(/\[\[([^\]|]+)\]\]/g, '[$1]($1)')
+}
+
+export function extractInlineTags(content: string): string[] {
+ // Extract #tag patterns that aren't in code blocks
+ const tags: string[] = []
+ const codeBlockRegex = /```[\s\S]*?```|`[^`]+`/g
+ const contentWithoutCode = content.replace(codeBlockRegex, '')
+
+ const tagRegex = /#([a-zA-Z][a-zA-Z0-9_-]*)/g
+ let match
+ while ((match = tagRegex.exec(contentWithoutCode)) !== null) {
+ const tag = match[1].toLowerCase()
+ if (!tags.includes(tag)) {
+ tags.push(tag)
+ }
+ }
+
+ return tags
+}
diff --git a/src/lib/navigation-history.ts b/src/lib/navigation-history.ts
new file mode 100644
index 0000000..2dc6d11
--- /dev/null
+++ b/src/lib/navigation-history.ts
@@ -0,0 +1,52 @@
+const NAVIGATION_HISTORY_KEY = 'recall_navigation_history'
+const MAX_HISTORY_SIZE = 10
+
+export interface NavigationEntry {
+ noteId: string
+ title: string
+ type: string
+ visitedAt: string
+}
+
+export function getNavigationHistory(): NavigationEntry[] {
+ if (typeof window === 'undefined') return []
+ try {
+ const stored = localStorage.getItem(NAVIGATION_HISTORY_KEY)
+ if (!stored) return []
+ return JSON.parse(stored)
+ } catch {
+ return []
+ }
+}
+
+export function addToNavigationHistory(entry: Omit): void {
+ if (typeof window === 'undefined') return
+
+ const history = getNavigationHistory()
+
+ // Remove duplicate entries for the same note
+ const filtered = history.filter((e) => e.noteId !== entry.noteId)
+
+ // Add new entry at the beginning
+ const newEntry: NavigationEntry = {
+ ...entry,
+ visitedAt: new Date().toISOString(),
+ }
+
+ const newHistory = [newEntry, ...filtered].slice(0, MAX_HISTORY_SIZE)
+
+ localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(newHistory))
+}
+
+export function clearNavigationHistory(): void {
+ if (typeof window === 'undefined') return
+ localStorage.removeItem(NAVIGATION_HISTORY_KEY)
+}
+
+export function removeFromNavigationHistory(noteId: string): void {
+ if (typeof window === 'undefined') return
+
+ const history = getNavigationHistory()
+ const filtered = history.filter((e) => e.noteId !== noteId)
+ localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(filtered))
+}
diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts
new file mode 100644
index 0000000..70f3041
--- /dev/null
+++ b/src/lib/preferences.ts
@@ -0,0 +1,57 @@
+const FEATURES_KEY = 'recall_features'
+const BACKUP_ENABLED_KEY = 'recall_backup_enabled'
+const BACKUP_RETENTION_KEY = 'recall_backup_retention'
+
+export interface FeatureFlags {
+ backupEnabled: boolean
+ backupRetention: number // days
+ workModeEnabled: boolean
+}
+
+const defaultFlags: FeatureFlags = {
+ backupEnabled: true,
+ backupRetention: 30,
+ workModeEnabled: true,
+}
+
+export function getFeatureFlags(): FeatureFlags {
+ if (typeof window === 'undefined') return defaultFlags
+ try {
+ const stored = localStorage.getItem(FEATURES_KEY)
+ if (!stored) return defaultFlags
+ return { ...defaultFlags, ...JSON.parse(stored) }
+ } catch {
+ return defaultFlags
+ }
+}
+
+export function setFeatureFlags(flags: Partial): void {
+ if (typeof window === 'undefined') return
+ const current = getFeatureFlags()
+ const updated = { ...current, ...flags }
+ localStorage.setItem(FEATURES_KEY, JSON.stringify(updated))
+}
+
+export function isBackupEnabled(): boolean {
+ return getFeatureFlags().backupEnabled
+}
+
+export function setBackupEnabled(enabled: boolean): void {
+ setFeatureFlags({ backupEnabled: enabled })
+}
+
+export function getBackupRetention(): number {
+ return getFeatureFlags().backupRetention
+}
+
+export function setBackupRetention(days: number): void {
+ setFeatureFlags({ backupRetention: days })
+}
+
+export function isWorkModeEnabled(): boolean {
+ return getFeatureFlags().workModeEnabled
+}
+
+export function setWorkModeEnabled(enabled: boolean): void {
+ setFeatureFlags({ workModeEnabled: enabled })
+}