develop #1
56
__tests__/command-items.test.ts
Normal file
56
__tests__/command-items.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
36
__tests__/external-capture.test.ts
Normal file
36
__tests__/external-capture.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
14
__tests__/navigation-history.test.ts
Normal file
14
__tests__/navigation-history.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@@ -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)
|
||||
|
||||
138
src/app/api/import-markdown/route.ts
Normal file
138
src/app/api/import-markdown/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<TrackNoteView noteId={note.id} />
|
||||
<TrackNavigationHistory noteId={note.id} title={note.title} type={note.type} />
|
||||
<main className="container mx-auto py-8 px-4 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<Link href="/notes">
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NoteList notes={notesWithTags} />
|
||||
<KeyboardNavigableNoteList notes={notesWithTags} />
|
||||
<KeyboardHint />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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() {
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Configuración</h1>
|
||||
|
||||
<div className="grid gap-6 max-w-xl">
|
||||
<div className="grid gap-6 max-w-2xl">
|
||||
{/* Preferences Section */}
|
||||
<PreferencesPanel />
|
||||
|
||||
{/* Backups Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exportar notas</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Backups y Restauración
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={handleExport} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Exportar
|
||||
</Button>
|
||||
<BackupList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Export Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Importar notas</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Exportar Notas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => handleExport('json')}
|
||||
disabled={exporting !== null}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{exporting === 'json' ? 'Exportando...' : 'JSON (Backup completo)'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleExport('markdown')}
|
||||
disabled={exporting !== null}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{exporting === 'markdown' ? 'Exportando...' : 'Markdown'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleExport('html')}
|
||||
disabled={exporting !== null}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
{exporting === 'html' ? 'Exportando...' : 'HTML'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Import Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Importar Notas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Importa notas desde archivos JSON o Markdown. Soporta frontmatter, tags, y enlaces wiki.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
@@ -138,28 +225,13 @@ export default function SettingsPage() {
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
className="gap-2 self-start"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{importing ? 'Importando...' : 'Importar'}
|
||||
</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>
|
||||
)
|
||||
|
||||
9
src/components/keyboard-hint.tsx
Normal file
9
src/components/keyboard-hint.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
export function KeyboardHint() {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground text-center py-2 border-t">
|
||||
↑↓ navegar · Enter abrir · E editar · F favoritar · P fijar
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
src/components/keyboard-navigable-note-list.tsx
Normal file
86
src/components/keyboard-navigable-note-list.tsx
Normal file
@@ -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 (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-lg">No hay notas todavía</p>
|
||||
<p className="text-sm">Crea tu primera nota para comenzar</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{notes.map((note, index) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`relative ${index === selectedIndex ? 'ring-2 ring-primary ring-offset-2 rounded-lg' : ''}`}
|
||||
data-selected={index === selectedIndex}
|
||||
>
|
||||
<NoteCard note={note} />
|
||||
{index === selectedIndex && (
|
||||
<div className="absolute bottom-2 right-2 flex gap-1">
|
||||
<span className="px-1.5 py-0.5 bg-muted text-xs rounded text-muted-foreground">
|
||||
Enter: abrir | E: editar | F: favoritar | P: fijar
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, boolean>>({})
|
||||
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
|
||||
const [navigationHistory, setNavigationHistory] = useState<NavigationEntry[]>([])
|
||||
|
||||
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({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation history */}
|
||||
{navigationHistory.length > 0 && (
|
||||
<ConnectionGroup
|
||||
title="Vista recientemente"
|
||||
icon={Clock}
|
||||
notes={navigationHistory.slice(0, 5).map((entry) => ({
|
||||
id: entry.noteId,
|
||||
title: entry.title,
|
||||
type: entry.type,
|
||||
}))}
|
||||
emptyMessage="No hay historial de navegación"
|
||||
isCollapsed={collapsed['history']}
|
||||
onToggle={() => toggleCollapsed('history')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
108
src/components/preferences-panel.tsx
Normal file
108
src/components/preferences-panel.tsx
Normal file
@@ -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<FeatureFlags>({
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Preferencias
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura el comportamiento de la aplicación. Los cambios se guardan automáticamente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Backup automático</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Crear backups automáticamente al cerrar o cambiar de nota
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={flags.backupEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleBackupEnabled(!flags.backupEnabled)}
|
||||
>
|
||||
{flags.backupEnabled ? 'Activado' : 'Desactivado'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Retención de backups (días)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Los backups automáticos se eliminarán después de este período
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={retentionInput}
|
||||
onChange={(e) => handleRetentionChange(e.target.value)}
|
||||
className="w-24 px-3 py-1 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Modo trabajo</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Habilitar toggle de modo trabajo en el header
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={flags.workModeEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleWorkModeEnabled(!flags.workModeEnabled)}
|
||||
>
|
||||
{flags.workModeEnabled ? 'Activado' : 'Desactivado'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Sprint MVP-5</Badge>
|
||||
<Badge variant="outline">v0.1.0</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
56
src/components/recent-context-list.tsx
Normal file
56
src/components/recent-context-list.tsx
Normal file
@@ -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<NavigationEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(getNavigationHistory())
|
||||
}, [])
|
||||
|
||||
if (history.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Vista recientemente</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{history.slice(0, 5).map((entry) => (
|
||||
<Link
|
||||
key={entry.noteId}
|
||||
href={`/notes/${entry.noteId}`}
|
||||
className="block px-2 py-1.5 text-sm rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="truncate">{entry.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.type} · {formatRelativeTime(entry.visitedAt)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
18
src/components/track-navigation-history.tsx
Normal file
18
src/components/track-navigation-history.tsx
Normal file
@@ -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
|
||||
}
|
||||
87
src/hooks/use-note-list-keyboard.ts
Normal file
87
src/hooks/use-note-list-keyboard.ts
Normal file
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
134
src/lib/export-html.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
56
src/lib/export-markdown.ts
Normal file
56
src/lib/export-markdown.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
132
src/lib/import-markdown.ts
Normal 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
|
||||
}
|
||||
52
src/lib/navigation-history.ts
Normal file
52
src/lib/navigation-history.ts
Normal 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
57
src/lib/preferences.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user