feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones

- Ticket 10: Navegación completa de listas por teclado (↑↓ Enter E F P)
- Ticket 13: Historial de navegación contextual con recent-context-list
- Ticket 17: Exportación mejorada a Markdown con frontmatter
- Ticket 18: Exportación HTML simple y legible
- Ticket 19: Importador Markdown mejorado con frontmatter, tags, wiki links
- Ticket 20: Importador Obsidian-compatible (wiki links, #tags inline)
- Ticket 21: Centro de respaldo y portabilidad en Settings
- Ticket 22: Configuración visible de feature flags
- Ticket 24: Tests de command palette y captura externa
- Ticket 25: Harden de validaciones y límites (50MB backup, 10K notas, etc)
This commit is contained in:
2026-03-22 19:39:55 -03:00
parent 8d56f34d68
commit e66a678160
24 changed files with 1286 additions and 42 deletions

View File

@@ -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<string, unknown>
if (!Array.isArray(data.notes)) {
errors.push('Missing or invalid data.notes (expected array)')
} else {
if (data.notes.length > MAX_NOTE_COUNT) {
errors.push(`Too many notes: ${data.notes.length} (max: ${MAX_NOTE_COUNT})`)
}
if (data.notes.length > 1000) {
warnings.push(`Large backup with ${data.notes.length} notes - restore may take time`)
}
}
}
@@ -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'
}

View File

@@ -56,6 +56,18 @@ export class ConflictError extends AppError {
}
}
export class PayloadTooLargeError extends AppError {
constructor(message: string = 'Payload too large') {
super('PAYLOAD_TOO_LARGE', message, 413)
}
}
export class RateLimitError extends AppError {
constructor(message: string = 'Too many requests') {
super('RATE_LIMITED', message, 429)
}
}
export function formatZodError(error: ZodError): ApiError {
return {
code: 'VALIDATION_ERROR',

134
src/lib/export-html.ts Normal file
View File

@@ -0,0 +1,134 @@
interface NoteWithTags {
id: string
title: string
content: string
type: string
isFavorite: boolean
isPinned: boolean
creationSource: string
tags: { tag: { id: string; name: string } }[]
createdAt: string
updatedAt: string
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function simpleMarkdownToHtml(content: string): string {
// Convert markdown to basic HTML
return content
// Headers
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
// Bold and italic
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Lists
.replace(/^\s*-\s+(.*$)/gm, '<li>$1</li>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
// Line breaks
.replace(/\n/g, '<br>')
}
const HTML_TEMPLATE = `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1 { border-bottom: 2px solid #333; padding-bottom: 10px; }
.meta { color: #666; font-size: 0.9em; margin-bottom: 20px; }
.tags { margin: 10px 0; }
.tag {
display: inline-block;
padding: 2px 8px;
background: #e0e0e0;
border-radius: 4px;
font-size: 0.8em;
margin-right: 5px;
}
pre {
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
code {
background: #f5f5f5;
padding: 2px 5px;
border-radius: 3px;
}
pre code { background: none; padding: 0; }
li { margin-left: 20px; }
a { color: #0066cc; }
</style>
</head>
<body>
<article>
<h1>{{TITLE}}</h1>
<div class="meta">
<span class="type">{{TYPE}}</span>
<span class="date"> · Creado: {{CREATED}} · Actualizado: {{UPDATED}}</span>
</div>
<div class="tags">{{TAGS}}</div>
<div class="content">{{CONTENT}}</div>
</article>
</body>
</html>`
export function noteToHtml(note: NoteWithTags): string {
const htmlContent = simpleMarkdownToHtml(escapeHtml(note.content))
const tagsHtml = note.tags
.map(({ tag }) => `<span class="tag">${escapeHtml(tag.name)}</span>`)
.join('')
return HTML_TEMPLATE
.replace('{{TITLE}}', escapeHtml(note.title))
.replace('{{TYPE}}', escapeHtml(note.type))
.replace('{{CREATED}}', new Date(note.createdAt).toLocaleDateString())
.replace('{{UPDATED}}', new Date(note.updatedAt).toLocaleDateString())
.replace('{{TAGS}}', tagsHtml)
.replace('{{CONTENT}}', htmlContent)
}
export function generateHtmlFilename(note: NoteWithTags): string {
const sanitized = note.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100)
return `${sanitized}-${note.id.slice(-8)}.html`
}
export function notesToHtmlZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
const files = notes.map((note) => ({
name: generateHtmlFilename(note),
content: noteToHtml(note),
}))
return { files }
}

View File

@@ -0,0 +1,56 @@
interface NoteWithTags {
id: string
title: string
content: string
type: string
isFavorite: boolean
isPinned: boolean
creationSource: string
tags: { tag: { id: string; name: string } }[]
createdAt: string
updatedAt: string
}
export function noteToMarkdown(note: NoteWithTags): string {
const frontmatter = [
'---',
`title: "${escapeYaml(note.title)}"`,
`type: ${note.type}`,
`createdAt: ${note.createdAt}`,
`updatedAt: ${note.updatedAt}`,
note.tags && note.tags.length > 0
? `tags:\n${note.tags.map(({ tag }) => ` - ${tag.name}`).join('\n')}`
: null,
note.isFavorite ? 'favorite: true' : null,
note.isPinned ? 'pinned: true' : null,
'---',
'',
]
.filter(Boolean)
.join('\n')
return `${frontmatter}\n# ${note.title}\n\n${note.content}`
}
export function generateFilename(note: NoteWithTags): string {
const sanitized = note.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100)
return `${sanitized}-${note.id.slice(-8)}.md`
}
export function escapeYaml(str: string): string {
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n')
}
export function notesToMarkdownZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
const files = notes.map((note) => ({
name: generateFilename(note),
content: noteToMarkdown(note),
}))
return { files }
}

View File

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

132
src/lib/import-markdown.ts Normal file
View File

@@ -0,0 +1,132 @@
interface ParsedFrontmatter {
title?: string
type?: string
tags?: string[]
favorite?: boolean
pinned?: boolean
createdAt?: string
updatedAt?: string
}
interface ParsedMarkdown {
frontmatter: ParsedFrontmatter
content: string
title: string
hasWikiLinks: boolean
}
export function parseMarkdownContent(
markdown: string,
filename?: string
): ParsedMarkdown {
const frontmatter: ParsedFrontmatter = {}
let content = markdown
let title = ''
// Check for frontmatter
const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
if (frontmatterMatch) {
const frontmatterStr = frontmatterMatch[1]
content = frontmatterMatch[2]
// Parse frontmatter fields
const lines = frontmatterStr.split('\n')
let currentKey = ''
let inList = false
for (const line of lines) {
if (inList && line.match(/^\s+-\s+/)) {
// Continuation of list
const value = line.replace(/^\s+-\s+/, '').trim()
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
frontmatter.tags.push(value)
}
continue
}
inList = false
// Key: value
const kvMatch = line.match(/^(\w+):\s*(.*)$/)
if (kvMatch) {
currentKey = kvMatch[1]
const value = kvMatch[2].trim()
if (currentKey === 'tags' && !value) {
frontmatter.tags = []
inList = true
} else if (currentKey === 'tags') {
frontmatter.tags = value.split(',').map((t) => t.trim())
} else if (currentKey === 'favorite' || currentKey === 'pinned') {
frontmatter[currentKey] = value === 'true'
} else {
;(frontmatter as Record<string, unknown>)[currentKey] = value
}
continue
}
// List item
const listMatch = line.match(/^\s+-\s+(.*)$/)
if (listMatch) {
if (!frontmatter.tags) frontmatter.tags = []
frontmatter.tags.push(listMatch[1].trim())
inList = true
}
}
}
// Extract title from content if not in frontmatter
if (frontmatter.title) {
title = frontmatter.title
} else {
// Try to find first # heading
const headingMatch = content.match(/^#\s+(.+)$/m)
if (headingMatch) {
title = headingMatch[1].trim()
} else if (filename) {
// Derive from filename
title = filename
.replace(/\.md$/i, '')
.replace(/[-_]/g, ' ')
.replace(/^\d+-\d+-\d+\s*/, '') // Remove date prefix if present
} else {
title = 'Untitled'
}
}
// Remove title heading from content if it exists
content = content.replace(/^#\s+.+\n+/, '')
// Check for wiki links
const hasWikiLinks = /\[\[([^\]]+)\]\]/.test(content)
return {
frontmatter,
content: content.trim(),
title,
hasWikiLinks,
}
}
export function convertWikiLinksToMarkdown(content: string): string {
// Convert [[link]] to [link](link)
return content.replace(/\[\[([^\]|]+)\]\]/g, '[$1]($1)')
}
export function extractInlineTags(content: string): string[] {
// Extract #tag patterns that aren't in code blocks
const tags: string[] = []
const codeBlockRegex = /```[\s\S]*?```|`[^`]+`/g
const contentWithoutCode = content.replace(codeBlockRegex, '')
const tagRegex = /#([a-zA-Z][a-zA-Z0-9_-]*)/g
let match
while ((match = tagRegex.exec(contentWithoutCode)) !== null) {
const tag = match[1].toLowerCase()
if (!tags.includes(tag)) {
tags.push(tag)
}
}
return tags
}

View File

@@ -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<NavigationEntry, 'visitedAt'>): void {
if (typeof window === 'undefined') return
const history = getNavigationHistory()
// Remove duplicate entries for the same note
const filtered = history.filter((e) => e.noteId !== entry.noteId)
// Add new entry at the beginning
const newEntry: NavigationEntry = {
...entry,
visitedAt: new Date().toISOString(),
}
const newHistory = [newEntry, ...filtered].slice(0, MAX_HISTORY_SIZE)
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(newHistory))
}
export function clearNavigationHistory(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(NAVIGATION_HISTORY_KEY)
}
export function removeFromNavigationHistory(noteId: string): void {
if (typeof window === 'undefined') return
const history = getNavigationHistory()
const filtered = history.filter((e) => e.noteId !== noteId)
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(filtered))
}

57
src/lib/preferences.ts Normal file
View File

@@ -0,0 +1,57 @@
const FEATURES_KEY = 'recall_features'
const BACKUP_ENABLED_KEY = 'recall_backup_enabled'
const BACKUP_RETENTION_KEY = 'recall_backup_retention'
export interface FeatureFlags {
backupEnabled: boolean
backupRetention: number // days
workModeEnabled: boolean
}
const defaultFlags: FeatureFlags = {
backupEnabled: true,
backupRetention: 30,
workModeEnabled: true,
}
export function getFeatureFlags(): FeatureFlags {
if (typeof window === 'undefined') return defaultFlags
try {
const stored = localStorage.getItem(FEATURES_KEY)
if (!stored) return defaultFlags
return { ...defaultFlags, ...JSON.parse(stored) }
} catch {
return defaultFlags
}
}
export function setFeatureFlags(flags: Partial<FeatureFlags>): void {
if (typeof window === 'undefined') return
const current = getFeatureFlags()
const updated = { ...current, ...flags }
localStorage.setItem(FEATURES_KEY, JSON.stringify(updated))
}
export function isBackupEnabled(): boolean {
return getFeatureFlags().backupEnabled
}
export function setBackupEnabled(enabled: boolean): void {
setFeatureFlags({ backupEnabled: enabled })
}
export function getBackupRetention(): number {
return getFeatureFlags().backupRetention
}
export function setBackupRetention(days: number): void {
setFeatureFlags({ backupRetention: days })
}
export function isWorkModeEnabled(): boolean {
return getFeatureFlags().workModeEnabled
}
export function setWorkModeEnabled(enabled: boolean): void {
setFeatureFlags({ workModeEnabled: enabled })
}