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 { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||||
import { syncBacklinks } from '@/lib/backlinks'
|
import { syncBacklinks } from '@/lib/backlinks'
|
||||||
import { createBackupSnapshot } from '@/lib/backup'
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -26,6 +28,38 @@ export async function GET(req: NextRequest) {
|
|||||||
updatedAt: note.updatedAt.toISOString(),
|
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)
|
return createSuccessResponse(exportData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return createErrorResponse(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 { MarkdownContent } from '@/components/markdown-content'
|
||||||
import { DeleteNoteButton } from '@/components/delete-note-button'
|
import { DeleteNoteButton } from '@/components/delete-note-button'
|
||||||
import { TrackNoteView } from '@/components/track-note-view'
|
import { TrackNoteView } from '@/components/track-note-view'
|
||||||
|
import { TrackNavigationHistory } from '@/components/track-navigation-history'
|
||||||
import { VersionHistory } from '@/components/version-history'
|
import { VersionHistory } from '@/components/version-history'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -44,6 +45,7 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TrackNoteView noteId={note.id} />
|
<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">
|
<main className="container mx-auto py-8 px-4 max-w-4xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href="/notes">
|
<Link href="/notes">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
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 { SearchBar } from '@/components/search-bar'
|
||||||
import { TagFilter } from '@/components/tag-filter'
|
import { TagFilter } from '@/components/tag-filter'
|
||||||
import { NoteType } from '@/types/note'
|
import { NoteType } from '@/types/note'
|
||||||
@@ -86,7 +87,8 @@ export default async function NotesPage({ searchParams }: { searchParams: Promis
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<NoteList notes={notesWithTags} />
|
<KeyboardNavigableNoteList notes={notesWithTags} />
|
||||||
|
<KeyboardHint />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { BackupList } from '@/components/backup-list'
|
import { BackupList } from '@/components/backup-list'
|
||||||
|
import { PreferencesPanel } from '@/components/preferences-panel'
|
||||||
|
|
||||||
function parseMarkdownToNote(content: string, filename: string) {
|
function parseMarkdownToNote(content: string, filename: string) {
|
||||||
const lines = content.split('\n')
|
const lines = content.split('\n')
|
||||||
@@ -28,31 +29,56 @@ function parseMarkdownToNote(content: string, filename: string) {
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [exporting, setExporting] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async (format: 'json' | 'markdown' | 'html') => {
|
||||||
|
setExporting(format)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/export-import')
|
const response = await fetch(`/api/export-import?format=${format}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error al exportar')
|
throw new Error('Error al exportar')
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
let blob: Blob
|
||||||
const url = URL.createObjectURL(blob)
|
let filename: string
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0]
|
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')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `recall-backup-${date}.json`
|
a.download = filename
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
toast.success('Notas exportadas correctamente')
|
toast.success(`Notas exportadas en formato ${format.toUpperCase()}`)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Error al exportar las notas')
|
toast.error('Error al exportar las notas')
|
||||||
|
} finally {
|
||||||
|
setExporting(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,15 +95,17 @@ export default function SettingsPage() {
|
|||||||
const isMarkdown = file.name.endsWith('.md')
|
const isMarkdown = file.name.endsWith('.md')
|
||||||
|
|
||||||
let payload: object[]
|
let payload: object[]
|
||||||
|
let endpoint = '/api/export-import'
|
||||||
|
|
||||||
if (isMarkdown) {
|
if (isMarkdown) {
|
||||||
const note = parseMarkdownToNote(text, file.name)
|
const note = parseMarkdownToNote(text, file.name)
|
||||||
payload = [note]
|
payload = [{ markdown: text, filename: file.name }]
|
||||||
|
endpoint = '/api/import-markdown'
|
||||||
} else {
|
} else {
|
||||||
payload = JSON.parse(text)
|
payload = JSON.parse(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/export-import', {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -89,7 +117,11 @@ export default function SettingsPage() {
|
|||||||
throw new Error(result.error || 'Error al importar')
|
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) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ''
|
fileInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
@@ -104,27 +136,82 @@ export default function SettingsPage() {
|
|||||||
<main className="container mx-auto py-8 px-4">
|
<main className="container mx-auto py-8 px-4">
|
||||||
<h1 className="text-2xl font-bold mb-6">Configuración</h1>
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Exportar notas</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
Backups y Restauración
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button onClick={handleExport} className="gap-2">
|
<BackupList />
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Export Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Importar notas</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
Exportar Notas
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
@@ -138,28 +225,13 @@ export default function SettingsPage() {
|
|||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2"
|
className="gap-2 self-start"
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
{importing ? 'Importando...' : 'Importar'}
|
{importing ? 'Importando...' : 'Importar'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</main>
|
</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 { useState, useEffect } from 'react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 {
|
interface BacklinkInfo {
|
||||||
id: string
|
id: string
|
||||||
@@ -106,6 +107,7 @@ export function NoteConnections({
|
|||||||
}: NoteConnectionsProps) {
|
}: NoteConnectionsProps) {
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||||
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
|
const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
|
||||||
|
const [navigationHistory, setNavigationHistory] = useState<NavigationEntry[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/notes/${noteId}/versions`)
|
fetch(`/api/notes/${noteId}/versions`)
|
||||||
@@ -114,6 +116,10 @@ export function NoteConnections({
|
|||||||
.catch(() => setRecentVersions([]))
|
.catch(() => setRecentVersions([]))
|
||||||
}, [noteId])
|
}, [noteId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavigationHistory(getNavigationHistory())
|
||||||
|
}, [noteId])
|
||||||
|
|
||||||
const hasAnyConnections =
|
const hasAnyConnections =
|
||||||
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
|
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
|
||||||
|
|
||||||
@@ -206,6 +212,22 @@ export function NoteConnections({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</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'
|
import { RecallBackup } from '@/types/backup'
|
||||||
|
|
||||||
|
// Limits
|
||||||
|
const MAX_BACKUP_SIZE_BYTES = 50 * 1024 * 1024 // 50MB
|
||||||
|
const MAX_NOTE_COUNT = 10000
|
||||||
|
|
||||||
interface ValidationResult {
|
interface ValidationResult {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
errors: string[]
|
errors: string[]
|
||||||
|
warnings?: string[]
|
||||||
info?: {
|
info?: {
|
||||||
noteCount: number
|
noteCount: number
|
||||||
tagCount: number
|
tagCount: number
|
||||||
@@ -13,6 +18,7 @@ interface ValidationResult {
|
|||||||
|
|
||||||
export function validateBackup(data: unknown): ValidationResult {
|
export function validateBackup(data: unknown): ValidationResult {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
if (typeof data !== 'object' || data === null) {
|
if (typeof data !== 'object' || data === null) {
|
||||||
return { valid: false, errors: ['Backup must be an object'] }
|
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>
|
const data = backup.data as Record<string, unknown>
|
||||||
if (!Array.isArray(data.notes)) {
|
if (!Array.isArray(data.notes)) {
|
||||||
errors.push('Missing or invalid data.notes (expected array)')
|
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 {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
errors: [],
|
errors: [],
|
||||||
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
info: {
|
info: {
|
||||||
noteCount: metadata.noteCount,
|
noteCount: metadata.noteCount,
|
||||||
tagCount: metadata.tagCount,
|
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 {
|
export function validateSchemaVersion(backup: RecallBackup): boolean {
|
||||||
return backup.schemaVersion === '1.0'
|
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 {
|
export function formatZodError(error: ZodError): ApiError {
|
||||||
return {
|
return {
|
||||||
code: 'VALIDATION_ERROR',
|
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
|
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 {
|
export function encodeCapturePayload(payload: CapturePayload): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
title: payload.title,
|
title: payload.title.slice(0, MAX_TITLE_LENGTH),
|
||||||
url: payload.url,
|
url: payload.url.slice(0, MAX_URL_LENGTH),
|
||||||
selection: payload.selection,
|
selection: payload.selection.slice(0, MAX_SELECTION_LENGTH),
|
||||||
})
|
})
|
||||||
return params.toString()
|
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