diff --git a/memento-note/README.md b/memento-note/README.md index 09e46f7..cacbba7 100644 --- a/memento-note/README.md +++ b/memento-note/README.md @@ -12,6 +12,10 @@ Keep Notes est une application avancée de prise de notes hybride, combinant la - **Auto-Tagging** : Création automatique d'étiquettes pertinentes. - **Organisation par lots** (Batch Organization) : Tri automatique des notes en vrac. - **Amélioration textuelle** : Reformulation, synthèse, ou traduction propulsés par l'IA. + - **Historique des notes** : Snapshots de versions avec deux modes : + - **Manuel** (par défaut) : Création de snapshots via un bouton "Commit" dans l'éditeur. + - **Automatique (intelligent)** : Snapshots automatiques avec détection de changements significatifs (diff 20+ chars) et cooldown de 5 min. Les changements structurels (couleur, épingle, labels) contournent le cooldown. + - **Assistant IA** : Chat contextuel avec support de recherche web, insights et historique des conversations. - **Haute Performance (RSC & Turbopack)** : Rendu Server Components natif pour une hydratation sans délai et développement accéléré via Turbopack. ## 📄 Licence et Droits d'Auteur @@ -41,10 +45,10 @@ Une version complète de **Keep Notes** destinée au grand public est prévue et ## 🛠️ Stack Technique -- **Framework** : Next.js 15 (App Router, Server Components) +- **Framework** : Next.js 16 (App Router, Server Components, Turbopack) - **Frontend** : React 19, Tailwind CSS, Radix UI primitives - **Drag & Drop** : `@dnd-kit/core` & `sortable` -- **Base de Données** : Prisma ORM, SQLite en env de développement (bientôt PostgreSQL) +- **Base de Données** : Prisma ORM, PostgreSQL (prod) / SQLite (dev) - **Outillage** : Turbopack, TypeScript ## 💻 Instructions de Développement @@ -62,6 +66,19 @@ npx prisma generate npx prisma db push ``` +### Migrations sûres (anti-perte de données) +```bash +npm run db:migrate +``` +- Cette commande passe par `scripts/safe-migrate.js`. +- Un backup est créé avant migration (`backups/migrations/`), puis `prisma migrate deploy` est exécuté. +- La migration est interrompue si le backup échoue. + +Commande dev avancée (à utiliser explicitement seulement) : +```bash +npm run db:migrate:dev +``` + ### Lancement du serveur (avec Turbopack) ```bash npm run dev diff --git a/memento-note/app/(main)/page.tsx b/memento-note/app/(main)/page.tsx index f48411e..f22ce53 100644 --- a/memento-note/app/(main)/page.tsx +++ b/memento-note/app/(main)/page.tsx @@ -21,6 +21,8 @@ export default async function HomePage() { initialSettings={{ showRecentNotes: settings?.showRecentNotes !== false, notesViewMode, + noteHistory: settings?.noteHistory === true, + noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto', }} /> ) diff --git a/memento-note/app/actions/ai-settings.ts b/memento-note/app/actions/ai-settings.ts index 444d2f6..d573986 100644 --- a/memento-note/app/actions/ai-settings.ts +++ b/memento-note/app/actions/ai-settings.ts @@ -21,6 +21,8 @@ export type UserAISettingsData = { fontSize?: 'small' | 'medium' | 'large' languageDetection?: boolean autoLabeling?: boolean + noteHistory?: boolean + noteHistoryMode?: 'manual' | 'auto' } /** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */ @@ -41,6 +43,8 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [ 'anonymousAnalytics', 'languageDetection', 'autoLabeling', + 'noteHistory', + 'noteHistoryMode', ] as const type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number] @@ -151,6 +155,8 @@ const getCachedAISettings = unstable_cache( fontSize: 'medium' as const, languageDetection: true, autoLabeling: true, + noteHistory: false, + noteHistoryMode: 'manual' as const, } } @@ -180,6 +186,8 @@ const getCachedAISettings = unstable_cache( fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large', languageDetection: settings.languageDetection ?? true, autoLabeling: settings.autoLabeling ?? true, + noteHistory: settings.noteHistory ?? false, + noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto', } } catch (error) { console.error('Error getting AI settings:', error) @@ -202,6 +210,8 @@ const getCachedAISettings = unstable_cache( fontSize: 'medium' as const, languageDetection: true, autoLabeling: true, + noteHistory: false, + noteHistoryMode: 'manual' as const, } } }, @@ -237,6 +247,8 @@ export async function getAISettings(userId?: string) { fontSize: 'medium' as const, languageDetection: true, autoLabeling: true, + noteHistory: false, + noteHistoryMode: 'manual' as const, } } @@ -266,6 +278,8 @@ export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Pro return settings.paragraphRefactor case 'memoryEcho': return settings.memoryEcho + case 'noteHistory': + return settings.noteHistory default: return true } diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 763f558..560897d 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -10,6 +10,14 @@ import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } f import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service' import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup' import { getAISettings } from '@/app/actions/ai-settings' +import { + createNoteHistorySnapshot, + getNoteHistoryMode, + isNoteHistoryEnabledForUser, + parseNoteHistoryEntry, + shouldCaptureHistorySnapshot, + shouldCreateAutoSnapshot, +} from '@/lib/note-history' /** @@ -57,6 +65,24 @@ function parseNote(dbNote: any): Note { return parseNoteUtil(dbNote) } +async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) { + const fallbackEmail = `user-${sessionUser.id}@local.momento` + const safeEmail = sessionUser.email || fallbackEmail + + await prisma.user.upsert({ + where: { id: sessionUser.id }, + update: { + ...(sessionUser.email ? { email: sessionUser.email } : {}), + ...(sessionUser.name !== undefined ? { name: sessionUser.name } : {}), + }, + create: { + id: sessionUser.id, + email: safeEmail, + name: sessionUser.name || null, + }, + }) +} + // Helper to get hash color for labels (copied from utils) function getHashColor(name: string): string { const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray'] @@ -316,6 +342,130 @@ export async function getArchivedNotes() { } } +export async function getNoteHistory(noteId: string, limit = 30) { + const session = await auth() + if (!session?.user?.id) return [] + + const enabled = await isNoteHistoryEnabledForUser(session.user.id) + if (!enabled) return [] + + const clampedLimit = Math.min(Math.max(limit, 1), 100) + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true }, + }) + if (!note) return [] + + const entries = await (prisma as any).noteHistory.findMany({ + where: { noteId: note.id, userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + take: clampedLimit, + }) + + return entries.map(parseNoteHistoryEntry) +} + +export async function restoreNoteVersion(noteId: string, historyEntryId: string) { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const enabled = await isNoteHistoryEnabledForUser(session.user.id) + if (!enabled) throw new Error('History is disabled') + + const [note, historyEntry] = await Promise.all([ + prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true, notebookId: true }, + }), + (prisma as any).noteHistory.findFirst({ + where: { + id: historyEntryId, + noteId, + userId: session.user.id, + }, + }), + ]) + + if (!note || !historyEntry) { + throw new Error('History entry not found') + } + + const restored = await prisma.note.update({ + where: { id: note.id, userId: session.user.id }, + data: { + title: historyEntry.title, + content: historyEntry.content, + color: historyEntry.color, + isPinned: historyEntry.isPinned, + isArchived: historyEntry.isArchived, + type: historyEntry.type, + checkItems: historyEntry.checkItems, + labels: historyEntry.labels, + images: historyEntry.images, + links: historyEntry.links, + isMarkdown: historyEntry.isMarkdown, + size: historyEntry.size, + notebookId: historyEntry.notebookId, + contentUpdatedAt: new Date(), + }, + }) + + try { + await createNoteHistorySnapshot({ + noteId: note.id, + userId: session.user.id, + reason: `restore:v${historyEntry.version}`, + }) + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot after restore:', snapshotError) + } + + revalidatePath('/') + revalidatePath(`/note/${note.id}`) + revalidatePath('/archive') + if (note.notebookId) revalidatePath(`/notebook/${note.notebookId}`) + if (historyEntry.notebookId && historyEntry.notebookId !== note.notebookId) { + revalidatePath(`/notebook/${historyEntry.notebookId}`) + } + + return parseNote(restored) +} + +export async function commitNoteHistory(noteId: string) { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const enabled = await isNoteHistoryEnabledForUser(session.user.id) + if (!enabled) throw new Error('History is disabled') + + const note = await prisma.note.findFirst({ + where: { id: noteId, userId: session.user.id }, + select: { id: true }, + }) + if (!note) throw new Error('Note not found') + + await createNoteHistorySnapshot({ + noteId: note.id, + userId: session.user.id, + reason: 'manual-commit', + }) +} + +export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: string) { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const entry = await (prisma as any).noteHistory.findFirst({ + where: { id: historyEntryId, noteId, userId: session.user.id }, + }) + if (!entry) throw new Error('History entry not found') + + await (prisma as any).noteHistory.delete({ + where: { id: historyEntryId }, + }) +} + // Search notes - DB-side filtering (fast) with optional semantic search // Supports contextual search within notebook (IA5) export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) { @@ -455,6 +605,14 @@ export async function createNote(data: { if (!session?.user?.id) throw new Error('Unauthorized'); try { + // Defensive guard: after DB reset/migration, auth session can exist while User row is missing. + // Recreate user row to avoid Note_userId_fkey failures. + await ensureSessionUserExists({ + id: session.user.id, + email: session.user.email, + name: session.user.name, + }) + // Save note to DB immediately (fast!) — AI operations run in background after const note = await prisma.note.create({ data: { @@ -482,6 +640,19 @@ export async function createNote(data: { await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id) } + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled) { + await createNoteHistorySnapshot({ + noteId: note.id, + userId: session.user.id, + reason: 'create', + }) + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create initial snapshot:', snapshotError) + } + if (!data.skipRevalidation) { // Revalidate main page (handles both inbox and notebook views via query params) revalidatePath('/') @@ -612,7 +783,7 @@ export async function updateNote(id: string, data: { try { const oldNote = await prisma.note.findUnique({ where: { id, userId: session.user.id }, - select: { labels: true, notebookId: true, reminder: true } + select: { labels: true, notebookId: true, reminder: true, content: true, title: true } }) const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : [] const oldNotebookId = oldNote?.notebookId @@ -682,6 +853,33 @@ export async function updateNote(id: string, data: { await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id) } + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled && shouldCaptureHistorySnapshot(data as Record)) { + const mode = await getNoteHistoryMode(session.user.id) + if (mode === 'manual') { + // No auto-snapshot in manual mode — user commits explicitly + } else { + const shouldAuto = await shouldCreateAutoSnapshot({ + noteId: id, + userId: session.user.id, + updateData: data as Record, + existingContent: oldNote?.content ?? '', + existingTitle: oldNote?.title ?? null, + }) + if (shouldAuto) { + await createNoteHistorySnapshot({ + noteId: id, + userId: session.user.id, + reason: 'update', + }) + } + } + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot after update:', snapshotError) + } + // Only revalidate for STRUCTURAL changes that affect the page layout/lists // Content edits (title, content, size, color) use optimistic UI — no refresh needed const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId'] @@ -690,6 +888,9 @@ export async function updateNote(id: string, data: { if (isStructuralChange && !options?.skipRevalidation) { revalidatePath('/') revalidatePath(`/note/${id}`) + if (data.isArchived !== undefined) { + revalidatePath('/archive') + } if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) { if (oldNotebookId) { diff --git a/memento-note/app/api/notes/[id]/history/route.ts b/memento-note/app/api/notes/[id]/history/route.ts new file mode 100644 index 0000000..84289c4 --- /dev/null +++ b/memento-note/app/api/notes/[id]/history/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { getNoteHistory, restoreNoteVersion } from '@/app/actions/notes' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { id } = await params + const limitRaw = request.nextUrl.searchParams.get('limit') + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 30 + const entries = await getNoteHistory(id, Number.isNaN(limit) ? 30 : limit) + return NextResponse.json({ success: true, data: entries }) + } catch (error) { + console.error('Error fetching note history:', error) + return NextResponse.json({ success: false, error: 'Failed to fetch history' }, { status: 500 }) + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { id } = await params + const body = await request.json() + const historyEntryId = body?.historyEntryId + + if (!historyEntryId || typeof historyEntryId !== 'string') { + return NextResponse.json({ success: false, error: 'historyEntryId is required' }, { status: 400 }) + } + + const restoredNote = await restoreNoteVersion(id, historyEntryId) + return NextResponse.json({ success: true, data: restoredNote }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to restore history version' + console.error('Error restoring note history:', error) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/memento-note/app/api/notes/[id]/route.ts b/memento-note/app/api/notes/[id]/route.ts index bef303e..4e43577 100644 --- a/memento-note/app/api/notes/[id]/route.ts +++ b/memento-note/app/api/notes/[id]/route.ts @@ -2,6 +2,13 @@ import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/prisma' import { auth } from '@/auth' import { parseNote } from '@/lib/utils' +import { + createNoteHistorySnapshot, + getNoteHistoryMode, + isNoteHistoryEnabledForUser, + shouldCaptureHistorySnapshot, + shouldCreateAutoSnapshot, +} from '@/lib/note-history' // GET /api/notes/[id] - Get a single note export async function GET( @@ -134,6 +141,32 @@ export async function PUT( data: updateData, }) + try { + const historyEnabled = await isNoteHistoryEnabledForUser(session.user.id) + if (historyEnabled && shouldCaptureHistorySnapshot(updateData)) { + const mode = await getNoteHistoryMode(session.user.id) + if (mode === 'auto') { + const shouldAuto = await shouldCreateAutoSnapshot({ + noteId: id, + userId: session.user.id, + updateData, + existingContent: existingNote.content ?? '', + existingTitle: existingNote.title ?? null, + }) + if (shouldAuto) { + await createNoteHistorySnapshot({ + noteId: id, + userId: session.user.id, + reason: 'api:update', + }) + } + } + // manual mode: no auto-snapshot + } + } catch (snapshotError) { + console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError) + } + return NextResponse.json({ success: true, data: parseNote(note) diff --git a/memento-note/components/agents/agent-run-log.tsx b/memento-note/components/agents/agent-run-log.tsx index 798d58d..3d6ed28 100644 --- a/memento-note/components/agents/agent-run-log.tsx +++ b/memento-note/components/agents/agent-run-log.tsx @@ -124,7 +124,7 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) { {t(statusKeys[action.status] || action.status)} - + {formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })} diff --git a/memento-note/components/ai/ai-settings-panel.tsx b/memento-note/components/ai/ai-settings-panel.tsx index 2d562e6..337cb29 100644 --- a/memento-note/components/ai/ai-settings-panel.tsx +++ b/memento-note/components/ai/ai-settings-panel.tsx @@ -24,6 +24,8 @@ interface AISettingsPanelProps { demoMode: boolean languageDetection: boolean autoLabeling: boolean + noteHistory: boolean + noteHistoryMode: 'manual' | 'auto' } } @@ -179,6 +181,53 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) { onChange={(checked) => handleToggle('autoLabeling', checked)} /> + handleToggle('noteHistory', checked)} + /> + + {settings.noteHistory && ( +
+

{t('notes.historyMode') || 'Mode d\'historique'}

+ { + const mode = value as 'manual' | 'auto' + setSettings((s) => ({ ...s, noteHistoryMode: mode })) + updateAISettings({ noteHistoryMode: mode }).then(() => { + toast.success(t('settings.settingsSaved') || 'Saved') + }) + }} + className="space-y-2" + > +
+ +
+ +

+ {t('notes.historyModeManualDesc') || 'Créer des snapshots avec le bouton commit'} +

+
+
+
+ +
+ +

+ {t('notes.historyModeAutoDesc') || 'Snapshots automatiques avec détection intelligente'} +

+
+
+
+
+ )} + {/* Demo Mode Toggle */} - + {formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })} diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index 47aadfe..982c494 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import dynamic from 'next/dynamic' import { Note } from '@/lib/types' -import { getAISettings } from '@/app/actions/ai-settings' +import { updateAISettings } from '@/app/actions/ai-settings' import { getAllNotes, searchNotes } from '@/app/actions/notes' import { NoteInput } from '@/components/note-input' import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section' @@ -24,6 +24,7 @@ import { cn } from '@/lib/utils' import { LabelFilter } from '@/components/label-filter' import { useLanguage } from '@/lib/i18n' import { useHomeView } from '@/context/home-view-context' +import { NoteHistoryModal } from '@/components/note-history-modal' // Lazy-load heavy dialogs — uniquement chargés à la demande const NoteEditor = dynamic( @@ -42,6 +43,8 @@ const AutoLabelSuggestionDialog = dynamic( type InitialSettings = { showRecentNotes: boolean notesViewMode: 'masonry' | 'tabs' + noteHistory: boolean + noteHistoryMode: 'manual' | 'auto' } interface HomeClientProps { @@ -59,10 +62,14 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { initialNotes.filter(n => n.isPinned) ) const [notesViewMode, setNotesViewMode] = useState(initialSettings.notesViewMode) + const [noteHistoryEnabled, setNoteHistoryEnabled] = useState(initialSettings.noteHistory) + const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode) const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null) const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null) const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) + const [historyNote, setHistoryNote] = useState(null) const { refreshKey, triggerRefresh } = useNoteRefresh() const { labels } = useLabels() const { setControls } = useHomeView() @@ -133,6 +140,22 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { if (note) setEditingNote({ note, readOnly: false }) } + const handleOpenHistory = useCallback((note: Note) => { + setHistoryNote(note) + setHistoryOpen(true) + }, []) + + const handleEnableHistory = useCallback(async () => { + await updateAISettings({ noteHistory: true }) + setNoteHistoryEnabled(true) + }, []) + + const handleHistoryRestored = useCallback((restored: Note) => { + setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) + setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n))) + setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev)) + }, []) + const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => { setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n)) setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n)) @@ -388,6 +411,10 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { onEdit={(note, readOnly) => setEditingNote({ note, readOnly })} onSizeChange={handleSizeChange} currentNotebookId={searchParams.get('notebook')} + noteHistoryEnabled={noteHistoryEnabled} + noteHistoryMode={noteHistoryMode} + onOpenHistory={handleOpenHistory} + onEnableHistory={handleEnableHistory} /> )} @@ -437,6 +464,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) { onClose={() => setEditingNote(null)} /> )} + + ) } diff --git a/memento-note/components/masonry-grid.tsx b/memento-note/components/masonry-grid.tsx index cc56c9e..9679c80 100644 --- a/memento-note/components/masonry-grid.tsx +++ b/memento-note/components/masonry-grid.tsx @@ -39,6 +39,9 @@ interface MasonryGridProps { onEdit?: (note: Note, readOnly?: boolean) => void; onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void; isTrashView?: boolean; + noteHistoryEnabled?: boolean; + noteHistoryMode?: 'manual' | 'auto'; + onOpenHistory?: (note: Note) => void; } // ───────────────────────────────────────────── @@ -53,6 +56,8 @@ interface SortableNoteProps { isDragging?: boolean; isOverlay?: boolean; isTrashView?: boolean; + noteHistoryEnabled?: boolean; + onOpenHistory?: (note: Note) => void; } const SortableNoteItem = memo(function SortableNoteItem({ @@ -64,6 +69,8 @@ const SortableNoteItem = memo(function SortableNoteItem({ isDragging, isOverlay, isTrashView, + noteHistoryEnabled, + onOpenHistory, }: SortableNoteProps) { const { attributes, @@ -98,6 +105,8 @@ const SortableNoteItem = memo(function SortableNoteItem({ isDragging={isDragging} isTrashView={isTrashView} onSizeChange={(newSize) => onSizeChange(note.id, newSize)} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={onOpenHistory} /> ); @@ -114,6 +123,8 @@ interface SortableGridSectionProps { onDragStartNote: (noteId: string) => void; onDragEndNote: () => void; isTrashView?: boolean; + noteHistoryEnabled?: boolean; + onOpenHistory?: (note: Note) => void; } const SortableGridSection = memo(function SortableGridSection({ @@ -124,6 +135,8 @@ const SortableGridSection = memo(function SortableGridSection({ onDragStartNote, onDragEndNote, isTrashView, + noteHistoryEnabled, + onOpenHistory, }: SortableGridSectionProps) { const ids = useMemo(() => notes.map(n => n.id), [notes]); @@ -140,6 +153,8 @@ const SortableGridSection = memo(function SortableGridSection({ onDragEndNote={onDragEndNote} isDragging={draggedNoteId === note.id} isTrashView={isTrashView} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={onOpenHistory} /> ))} @@ -150,7 +165,14 @@ const SortableGridSection = memo(function SortableGridSection({ // ───────────────────────────────────────────── // Main MasonryGrid component // ───────────────────────────────────────────── -export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: MasonryGridProps) { +export function MasonryGrid({ + notes, + onEdit, + onSizeChange, + isTrashView, + noteHistoryEnabled = false, + onOpenHistory, +}: MasonryGridProps) { const { t } = useLanguage(); const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null); const { startDrag, endDrag, draggedNoteId } = useNotebookDrag(); @@ -261,6 +283,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr onDragStartNote={startDrag} onDragEndNote={endDrag} isTrashView={isTrashView} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={onOpenHistory} /> )} @@ -280,6 +304,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr onDragStartNote={startDrag} onDragEndNote={endDrag} isTrashView={isTrashView} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={onOpenHistory} /> )} @@ -294,6 +320,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr onEdit={handleEdit} isDragging={true} onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)} + noteHistoryEnabled={noteHistoryEnabled} + onOpenHistory={onOpenHistory} /> ) : null} diff --git a/memento-note/components/note-actions.tsx b/memento-note/components/note-actions.tsx index 4d0dee6..c39f492 100644 --- a/memento-note/components/note-actions.tsx +++ b/memento-note/components/note-actions.tsx @@ -17,6 +17,7 @@ import { FileText, Trash2, RotateCcw, + History, } from "lucide-react" import { cn } from "@/lib/utils" import { NOTE_COLORS } from "@/lib/types" @@ -38,6 +39,8 @@ interface NoteActionsProps { isTrashView?: boolean onRestore?: () => void onPermanentDelete?: () => void + onOpenHistory?: () => void + historyEnabled?: boolean className?: string } @@ -57,6 +60,8 @@ export function NoteActions({ isTrashView, onRestore, onPermanentDelete, + onOpenHistory, + historyEnabled = false, className }: NoteActionsProps) { const { t } = useLanguage() @@ -176,6 +181,15 @@ export function NoteActions({ )} + {onOpenHistory && ( + + + {historyEnabled + ? (t('notes.history') || 'Historique') + : (t('notes.enableHistory') || "Activer l'historique")} + + )} + {/* Size Selector */} {onSizeChange && ( <> diff --git a/memento-note/components/note-card.tsx b/memento-note/components/note-card.tsx index 875a970..3fda3bf 100644 --- a/memento-note/components/note-card.tsx +++ b/memento-note/components/note-card.tsx @@ -115,6 +115,8 @@ interface NoteCardProps { onResize?: () => void onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void isTrashView?: boolean + noteHistoryEnabled?: boolean + onOpenHistory?: (note: Note) => void } // Helper function to get initials from name @@ -153,7 +155,9 @@ export const NoteCard = memo(function NoteCard({ isDragging, onResize, onSizeChange, - isTrashView + isTrashView, + noteHistoryEnabled = false, + onOpenHistory, }: NoteCardProps) { const router = useRouter() const searchParams = useSearchParams() @@ -610,7 +614,7 @@ export const NoteCard = memo(function NoteCard({ {/* Footer with Date only */}
{/* Creation Date */} -
+
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
@@ -645,6 +649,8 @@ export const NoteCard = memo(function NoteCard({ isTrashView={isTrashView} onRestore={handleRestore} onPermanentDelete={handlePermanentDelete} + onOpenHistory={() => onOpenHistory?.(note)} + historyEnabled={noteHistoryEnabled} className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity" /> )} diff --git a/memento-note/components/note-history-modal.tsx b/memento-note/components/note-history-modal.tsx new file mode 100644 index 0000000..dde8dcd --- /dev/null +++ b/memento-note/components/note-history-modal.tsx @@ -0,0 +1,249 @@ +'use client' + +import { useEffect, useMemo, useState, useTransition } from 'react' +import { formatDistanceToNow } from 'date-fns' +import { fr } from 'date-fns/locale/fr' +import { enUS } from 'date-fns/locale/en-US' +import { History, Loader2, RotateCcw, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry } from '@/app/actions/notes' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { useLanguage } from '@/lib/i18n' +import type { Note, NoteHistoryEntry } from '@/lib/types' +import { cn } from '@/lib/utils' + +interface NoteHistoryModalProps { + open: boolean + onOpenChange: (open: boolean) => void + note: Note | null + enabled: boolean + onEnableHistory: () => Promise + onRestored: (note: Note) => void +} + +function getDateLocale(language: string) { + if (language === 'fr') return fr + return enUS +} + +export function NoteHistoryModal({ + open, + onOpenChange, + note, + enabled, + onEnableHistory, + onRestored, +}: NoteHistoryModalProps) { + const { t, language } = useLanguage() + const [entries, setEntries] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isRestoring, startRestoring] = useTransition() + const [isEnabling, startEnabling] = useTransition() + + useEffect(() => { + if (!open || !note || !enabled) return + + let cancelled = false + setIsLoading(true) + getNoteHistory(note.id, 50) + .then((result) => { + if (cancelled) return + setEntries(result) + setSelectedId(result[0]?.id ?? null) + }) + .catch((error) => { + console.error('Failed to load note history:', error) + toast.error(t('general.error')) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, [open, note, enabled, t]) + + const selectedEntry = useMemo( + () => entries.find((entry) => entry.id === selectedId) ?? null, + [entries, selectedId] + ) + + const handleRestore = () => { + if (!note || !selectedEntry) return + startRestoring(async () => { + try { + const restored = await restoreNoteVersion(note.id, selectedEntry.id) + onRestored(restored) + toast.success(t('notes.historyRestored') || 'Version restaurée') + } catch (error) { + console.error('Failed to restore history entry:', error) + toast.error(t('general.error')) + } + }) + } + + const handleEnable = () => { + startEnabling(async () => { + try { + await onEnableHistory() + toast.success(t('notes.historyEnabled') || 'History activé') + } catch (error) { + console.error('Failed to enable history:', error) + toast.error(t('general.error')) + } + }) + } + + const handleDeleteEntry = (entryId: string) => { + if (!note) return + if (!confirm(t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?')) return + startRestoring(async () => { + try { + await deleteNoteHistoryEntry(note.id, entryId) + setEntries((prev) => prev.filter((e) => e.id !== entryId)) + if (selectedId === entryId) { + setSelectedId(null) + } + toast.success(t('notes.versionDeleted') || 'Version supprimée') + } catch (error) { + console.error('Failed to delete history entry:', error) + toast.error(t('general.error')) + } + }) + } + + return ( + + + + + + {t('notes.history') || 'Historique'} + + + {note?.title || t('notes.untitled') || 'Sans titre'} + + + + {!enabled ? ( +
+

+ {t('notes.historyDisabledDesc') || "L'historique est désactivé pour votre compte."} +

+ +
+ ) : ( +
+
+ {isLoading ? ( +
+ + {t('general.loading')} +
+ ) : entries.length === 0 ? ( +

+ {t('notes.historyEmpty') || 'Aucune version disponible'} +

+ ) : ( +
+ {entries.map((entry) => ( +
+ + +
+ ))} +
+ )} +
+ +
+ {selectedEntry ? ( +
+
+

+ {t('notes.title') || 'Titre'} +

+

+ {selectedEntry.title || t('notes.untitled') || 'Sans titre'} +

+
+ +
+

+ {t('notes.content') || 'Contenu'} +

+
+                      {selectedEntry.content || ''}
+                    
+
+
+ ) : ( +

+ {t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'} +

+ )} +
+
+ )} + + + {enabled && selectedEntry && ( + + )} + +
+
+ ) +} diff --git a/memento-note/components/note-inline-editor.tsx b/memento-note/components/note-inline-editor.tsx index 081267e..75de3f6 100644 --- a/memento-note/components/note-inline-editor.tsx +++ b/memento-note/components/note-inline-editor.tsx @@ -18,8 +18,10 @@ import { useLanguage } from '@/lib/i18n' import { cn } from '@/lib/utils' import { updateNote, + toggleArchive, deleteNote, createNote, + commitNoteHistory, } from '@/app/actions/notes' import { fetchLinkMetadata } from '@/app/actions/scrape' import { @@ -39,6 +41,8 @@ import { Loader2, Check, RotateCcw, + History, + GitCommitHorizontal, } from 'lucide-react' import { toast } from 'sonner' import { MarkdownContent } from '@/components/markdown-content' @@ -62,6 +66,9 @@ interface NoteInlineEditorProps { onDelete?: (noteId: string) => void onArchive?: (noteId: string) => void onChange?: (noteId: string, fields: Partial) => void + onOpenHistory?: (note: Note) => void + noteHistoryEnabled?: boolean + noteHistoryMode?: 'manual' | 'auto' colorKey: NoteColor /** If true and the note is a Markdown note, open directly in preview mode */ defaultPreviewMode?: boolean @@ -90,6 +97,9 @@ export function NoteInlineEditor({ onDelete, onArchive, onChange, + onOpenHistory, + noteHistoryEnabled = false, + noteHistoryMode = 'manual', colorKey, defaultPreviewMode = false, }: NoteInlineEditorProps) { @@ -313,7 +323,8 @@ export function NoteInlineEditor({ startTransition(async () => { onArchive?.(note.id) try { - await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true }) + await toggleArchive(note.id, !note.isArchived) + triggerRefresh() } catch { // Cannot easily revert since onArchive removes from list toast.error(t('general.error')) @@ -479,7 +490,13 @@ export function NoteInlineEditor({ @@ -515,6 +532,26 @@ export function NoteInlineEditor({ {/* Right group: meta actions + save indicator */}
+ {noteHistoryEnabled && noteHistoryMode === 'manual' && ( + + )} {isSaving ? ( <> {t('notes.saving')} @@ -557,6 +594,16 @@ export function NoteInlineEditor({ ? <>{t('notes.unarchive')} : <>{t('notes.archive')}} + {onOpenHistory && ( + onOpenHistory(note)} + > + + {noteHistoryEnabled + ? (t('notes.history') || 'Historique') + : (t('notes.enableHistory') || "Activer l'historique")} + + )} {t('notes.delete')} @@ -781,9 +828,9 @@ export function NoteInlineEditor({ {/* ── Footer ───────────────────────────────────────────────────────────── */}
- {t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })} + {t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })} · - {t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })} + {t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}
diff --git a/memento-note/components/notes-main-section.tsx b/memento-note/components/notes-main-section.tsx index 2efa102..1788b9b 100644 --- a/memento-note/components/notes-main-section.tsx +++ b/memento-note/components/notes-main-section.tsx @@ -25,20 +25,49 @@ interface NotesMainSectionProps { onEdit?: (note: Note, readOnly?: boolean) => void onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void currentNotebookId?: string | null + noteHistoryEnabled?: boolean + noteHistoryMode?: 'manual' | 'auto' + onOpenHistory?: (note: Note) => void + onEnableHistory?: () => Promise } -export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) { +export function NotesMainSection({ + notes, + viewMode, + onEdit, + onSizeChange, + currentNotebookId, + noteHistoryEnabled = false, + noteHistoryMode = 'manual', + onOpenHistory, + onEnableHistory, +}: NotesMainSectionProps) { if (viewMode === 'tabs') { return (
- +
) } return (
- +
) } diff --git a/memento-note/components/notes-tabs-view.tsx b/memento-note/components/notes-tabs-view.tsx index 4498511..d47f87b 100644 --- a/memento-note/components/notes-tabs-view.tsx +++ b/memento-note/components/notes-tabs-view.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useState, useTransition } from 'react' +import { useCallback, useEffect, useMemo, useState, useTransition } from 'react' import { useNoteRefreshOptional } from '@/context/NoteRefreshContext' import { DndContext, @@ -24,17 +24,32 @@ import { cn } from '@/lib/utils' import { NoteInlineEditor } from '@/components/note-inline-editor' import { useLanguage } from '@/lib/i18n' import { getNoteDisplayTitle } from '@/lib/note-preview' -import { updateFullOrderWithoutRevalidation, createNote, deleteNote } from '@/app/actions/notes' +import { + updateFullOrderWithoutRevalidation, + createNote, + deleteNote, + updateNote, + toggleArchive, +} from '@/app/actions/notes' +import { useNotebooks } from '@/context/notebooks-context' import { GripVertical, - Hash, ListChecks, Pin, + PinOff, FileText, - Clock, Plus, Loader2, Trash2, + ListFilter, + FolderInput, + Archive, + Share2, + Check, + Hash, + History, + PanelRightClose, + PanelRightOpen, } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -45,8 +60,22 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' import { toast } from 'sonner' -import { formatDistanceToNow } from 'date-fns' +import { format, type Locale } from 'date-fns' import { fr } from 'date-fns/locale/fr' import { enUS } from 'date-fns/locale/en-US' @@ -54,8 +83,14 @@ interface NotesTabsViewProps { notes: Note[] onEdit?: (note: Note, readOnly?: boolean) => void currentNotebookId?: string | null + noteHistoryEnabled?: boolean + noteHistoryMode?: 'manual' | 'auto' + onOpenHistory?: (note: Note) => void + onEnableHistory?: () => Promise } +type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc' + // Color accent strip for each note const COLOR_ACCENT: Record = { default: 'bg-primary', @@ -104,9 +139,19 @@ function getColorKey(note: Note): NoteColor { } function getDateLocale(language: string) { - if (language === 'fr') return fr; - if (language === 'fa') return require('date-fns/locale').faIR; - return enUS; + if (language === 'fr') return fr + if (language === 'fa') return require('date-fns/locale').faIR + return enUS +} + +function formatNoteDate(date: Date | string, locale: Locale): string { + const d = typeof date === 'string' ? new Date(date) : date + return format(d, 'd MMM yyyy', { locale }) +} + +function countWords(content: string | null | undefined): number { + if (!content) return 0 + return content.trim().split(/\s+/).filter(Boolean).length } // ─── Sortable List Item ─────────────────────────────────────────────────────── @@ -144,161 +189,445 @@ function SortableNoteListItem({ const title = getNoteDisplayTitle(note, untitledLabel) const snippet = note.type === 'checklist' - ? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150) - : (note.content || '').substring(0, 150) + ? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 200) + : (note.content || '').substring(0, 200) const dateLocale = getDateLocale(language) - const timeAgo = formatDistanceToNow(new Date(note.updatedAt), { - addSuffix: true, - locale: dateLocale, - }) + const dateStr = formatNoteDate(note.updatedAt, dateLocale) return (
- {/* Color accent bar */} + {/* Left accent bar — solid when selected, transparent otherwise */}
- {/* Drag handle */} - + {/* Main card content */} +
- {/* Note type icon */} -
- {note.type === 'checklist' ? ( - +
+ {note.type === 'checklist' ? ( + + ) : ( + )} - /> - ) : ( - )} - /> - )} -
- - {/* Text content */} -
-
-

+ - {title} -

- {note.isPinned && ( - - )} -
- {snippet && ( -

{snippet}

- )} -
- - - {timeAgo} + {dateStr} +
+ + {/* Row 2: title */} +

+ {title} +

+ + {/* Row 3: snippet */} + {snippet && ( +

+ {snippet} +

+ )} + + {/* Row 4: label chips */} + {Array.isArray(note.labels) && note.labels.length > 0 && ( +
+ {note.labels.slice(0, 3).map((label) => ( + + + {label} + + ))} + {note.labels.length > 3 && ( + + +{note.labels.length - 3} + + )} +
+ )} +
+ + {/* Actions column: drag + delete on hover */} +
+ + +
+
+ ) +} + +// ─── Note Meta Sidebar ──────────────────────────────────────────────────────── + +function SidebarSection({ title }: { title: string }) { + return ( +
+

+ {title} +

+
+
+ ) +} + +function SidebarActionBtn({ + icon, + label, + onClick, + disabled = false, +}: { + icon: React.ReactNode + label: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function NoteMetaSidebar({ + note, + onPinToggle, + onArchive, + noteHistoryEnabled = false, + onOpenHistory, + onEnableHistory, +}: { + note: Note + onPinToggle: (note: Note) => void + onArchive: (note: Note) => void + noteHistoryEnabled?: boolean + onOpenHistory?: (note: Note) => void + onEnableHistory?: () => Promise +}) { + const { t } = useLanguage() + const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks() + const [moveOpen, setMoveOpen] = useState(false) + const [isMoving, setIsMoving] = useState(false) + + // t() returns the key itself when not found — use this wrapper for safe fallbacks + const ts = (key: string, fallback: string) => { + const v = t(key as Parameters[0]) + return v === key ? fallback : v + } + + const wordCount = countWords(note.content) + + const noteTypeLabel = + note.type === 'checklist' + ? ts('notes.typeChecklist', 'Checklist') + : note.isMarkdown + ? ts('notes.typeMarkdown', 'Markdown') + : ts('notes.typeText', 'Text') + + const handleMoveToNotebook = async (notebookId: string | null) => { + setIsMoving(true) + try { + await moveNoteToNotebookOptimistic(note.id, notebookId) + setMoveOpen(false) + toast.success(ts('notebookSuggestion.movedToNotebook', 'Note déplacée')) + } catch { + toast.error(ts('notes.moveFailed', 'Déplacement échoué')) + } finally { + setIsMoving(false) + } + } + + const handleHistory = async () => { + if (!noteHistoryEnabled) { + if (!onEnableHistory) { + toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour votre compte.")) + return + } + try { + await onEnableHistory() + toast.success(ts('notes.historyEnabled', 'Historique activé')) + onOpenHistory?.(note) + } catch { + toast.error(ts('general.error', 'Erreur')) + } + return + } + + onOpenHistory?.(note) + } + + return ( + ) } // ─── Main Component ─────────────────────────────────────────────────────────── -export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) { +export function NotesTabsView({ + notes, + onEdit, + currentNotebookId, + noteHistoryEnabled = false, + noteHistoryMode = 'manual', + onOpenHistory, + onEnableHistory, +}: NotesTabsViewProps) { const { t, language } = useLanguage() const { triggerRefresh } = useNoteRefreshOptional() const [items, setItems] = useState(notes) const [selectedId, setSelectedId] = useState(null) const [isCreating, startCreating] = useTransition() const [noteToDelete, setNoteToDelete] = useState(null) + const [sortOrder, setSortOrder] = useState('date-desc') + const [sidebarOpen, setSidebarOpen] = useState(true) useEffect(() => { - // Only reset when notes are added or removed, NOT on content/field changes - // Field changes arrive through onChange -> setItems already setItems((prev) => { const prevIds = prev.map((n) => n.id).join(',') const incomingIds = notes.map((n) => n.id).join(',') if (prevIds === incomingIds) { - // Same set of notes: merge only structural fields (pin, color, archive) return prev.map((p) => { const fresh = notes.find((n) => n.id === p.id) if (!fresh) return p - // Use fresh labels from server if they've changed (e.g., global label deletion) const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort()) return { ...fresh, title: p.title, content: p.content, checkItems: p.checkItems, - isMarkdown: p.isMarkdown, - // Always use server labels if different (for global label changes) labels: labelsChanged ? fresh.labels : p.labels } }) } - // Different set (add/remove) or reordered from server: full sync - // CRITICAL: We MUST preserve local text edits so inline editor state isn't lost return notes.map((fresh) => { const local = prev.find((p) => p.id === fresh.id) if (!local) return fresh @@ -308,7 +637,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie title: local.title, content: local.content, checkItems: local.checkItems, - isMarkdown: local.isMarkdown, labels: labelsChanged ? fresh.labels : local.labels } }) @@ -325,7 +653,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie ) }, [items]) - // Listen for global label deletion and immediately update local state useEffect(() => { const handler = (e: Event) => { const { name } = (e as CustomEvent).detail @@ -343,7 +670,22 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie return () => window.removeEventListener('label-deleted', handler) }, []) - // Scroll to top of sidebar on note change handled by NoteInlineEditor internally + // Sorted display items (does NOT affect persisted order) + const sortedItems = useMemo(() => { + if (sortOrder === 'date-desc') return [...items] + return [...items].sort((a, b) => { + if (sortOrder === 'date-asc') { + return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + } + if (sortOrder === 'title-asc') { + return (a.title || '').localeCompare(b.title || '') + } + if (sortOrder === 'title-desc') { + return (b.title || '').localeCompare(a.title || '') + } + return 0 + }) + }, [items, sortOrder]) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), @@ -372,15 +714,14 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie const selected = items.find((n) => n.id === selectedId) ?? null const colorKey = selected ? getColorKey(selected) : 'default' - /** Create a new blank note, add it to the sidebar and select it immediately */ const handleCreateNote = () => { startCreating(async () => { try { - const newNote = await createNote({ - content: '', + const newNote = await createNote({ + content: '', title: undefined, notebookId: currentNotebookId || undefined, - skipRevalidation: true + skipRevalidation: true }) if (!newNote) return setItems((prev) => { @@ -396,52 +737,116 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie }) } + const handlePinToggle = async (note: Note) => { + const next = !note.isPinned + setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n)) + try { + await updateNote(note.id, { isPinned: next }, { skipRevalidation: true }) + toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée')) + } catch { + setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n)) + toast.error(t('notes.updateFailed') || 'Mise à jour échouée') + } + } + const handleArchive = async (note: Note) => { + try { + await toggleArchive(note.id, true) + setItems((prev) => prev.filter((n) => n.id !== note.id)) + setSelectedId((prev) => (prev === note.id ? null : prev)) + triggerRefresh() + toast.success(t('notes.archived') || 'Note archivée') + } catch { + toast.error(t('notes.archiveFailed') || 'Archivage échoué') + } + } return (
- {/* ── Left sidebar: note list ── */} -
- {/* Sidebar header with note count + new note button */} -
-
- + {/* ── Left panel: note list ── */} +
+ + {/* Header */} +
+
+ {t('notes.title')} - - {items.length} - + + {items.length} + +
+
+ {/* Sort / filter button */} + + + + + + + {t('notes.sortBy') || 'Trier par'} + + + setSortOrder(v as SortOrder)}> + + {t('notes.sortDateDesc') || 'Date (récent)'} + + + {t('notes.sortDateAsc') || 'Date (ancien)'} + + + {t('notes.sortTitleAsc') || 'Titre A → Z'} + + + {t('notes.sortTitleDesc') || 'Titre Z → A'} + + + + + + {/* New note button */}
{/* Scrollable note list */}
{items.length === 0 ? ( -
-
- +
+
+

{t('notes.emptyStateTabs') || 'Aucune note'}

+

{t('notes.createFirst') || 'Créez votre première note'}

) : ( n.id)} strategy={verticalListSortingStrategy} > -
- {items.map((note) => ( +
+ {sortedItems.map((note) => (
- {/* ── Right content panel — always in edit mode ── */} + {/* ── Right content panel ── */} {selected ? ( -
+ {/* Editor */} +
+ { + setItems((prev) => + prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n)) + ) + }} + onDelete={(noteId) => { + setItems((prev) => prev.filter((n) => n.id !== noteId)) + setSelectedId((prev) => (prev === noteId ? null : prev)) + }} + onArchive={(noteId) => { + setItems((prev) => prev.filter((n) => n.id !== noteId)) + setSelectedId((prev) => (prev === noteId ? null : prev)) + triggerRefresh() + }} + /> + {/* Toggle sidebar button — top-right of editor, always visible */} + +
+ + {/* Meta sidebar — collapsible */} + {sidebarOpen && ( + )} - > - { - setItems((prev) => - prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n)) - ) - }} - onDelete={(noteId) => { - setItems((prev) => prev.filter((n) => n.id !== noteId)) - setSelectedId((prev) => (prev === noteId ? null : prev)) - }} - onArchive={(noteId) => { - setItems((prev) => prev.filter((n) => n.id !== noteId)) - setSelectedId((prev) => (prev === noteId ? null : prev)) - }} - />
) : ( -
-
-
- +
+
+
+
-

{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}

-

+

+ {items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')} +

+

{items.length === 0 ? t('notes.emptyNotebookDesc') : t('notes.selectOrCreateNote')} @@ -528,7 +965,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie {t('notes.confirmDelete') || 'Are you sure you want to delete this note?'} {noteToDelete && ( - "{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}" + "{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}" )} @@ -561,4 +998,3 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie

) } - diff --git a/memento-note/components/sidebar.tsx b/memento-note/components/sidebar.tsx index 62cb6ef..63f07b1 100644 --- a/memento-note/components/sidebar.tsx +++ b/memento-note/components/sidebar.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { usePathname, useSearchParams, useRouter } from 'next/navigation' import { cn } from '@/lib/utils' import { - Lightbulb, + FileText, Bell, Archive, Trash2, @@ -81,7 +81,8 @@ export function Sidebar({ className, user }: { className?: string, user?: any }) return pathname === '/' && !searchParams.get('label') && !searchParams.get('archived') && - !searchParams.get('trashed') + !searchParams.get('trashed') && + !searchParams.get('notebook') } // For labels @@ -131,7 +132,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
diff --git a/memento-note/components/title-suggestions.tsx b/memento-note/components/title-suggestions.tsx index a76a0a5..af7899c 100644 --- a/memento-note/components/title-suggestions.tsx +++ b/memento-note/components/title-suggestions.tsx @@ -15,16 +15,16 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg if (suggestions.length === 0) return null return ( -
+
-
+
{t('titleSuggestions.title')}
@@ -38,19 +38,19 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg onClick={() => onSelect(suggestion.title)} className={cn( "w-full text-left px-3 py-2 rounded-md transition-all", - "hover:bg-amber-100 dark:hover:bg-amber-900", - "text-sm text-amber-900 dark:text-amber-100", - "border border-transparent hover:border-amber-300 dark:hover:border-amber-700" + "hover:bg-sky-100 dark:hover:bg-sky-900/50", + "text-sm text-sky-900 dark:text-sky-100", + "border border-transparent hover:border-sky-300 dark:hover:border-sky-700" )} >
{suggestion.title} - + {suggestion.confidence}%
{suggestion.reasoning && ( -

+

{suggestion.reasoning}

)} diff --git a/memento-note/lib/note-history.ts b/memento-note/lib/note-history.ts new file mode 100644 index 0000000..54fd4b5 --- /dev/null +++ b/memento-note/lib/note-history.ts @@ -0,0 +1,184 @@ +import prisma from '@/lib/prisma' +import { asArray } from '@/lib/utils' +import type { NoteHistoryEntry } from '@/lib/types' + +const COOLDOWN_MS = 5 * 60 * 1000 // 5 minutes +const CONTENT_DIFF_THRESHOLD = 20 // characters + +const HISTORY_TRACKED_FIELDS = [ + 'title', + 'content', + 'color', + 'isPinned', + 'isArchived', + 'type', + 'checkItems', + 'labels', + 'images', + 'links', + 'isMarkdown', + 'size', + 'notebookId', +] as const + +export function shouldCaptureHistorySnapshot(data: Record): boolean { + return HISTORY_TRACKED_FIELDS.some((field) => field in data) +} + +export async function isNoteHistoryEnabledForUser(userId: string): Promise { + const settings = await prisma.userAISettings.findUnique({ + where: { userId }, + select: { noteHistory: true }, + }) + + return settings?.noteHistory === true +} + +export async function createNoteHistorySnapshot({ + noteId, + userId, + reason, +}: { + noteId: string + userId: string + reason?: string +}): Promise { + const note = await prisma.note.findFirst({ + where: { id: noteId, userId }, + select: { + id: true, + userId: true, + title: true, + content: true, + color: true, + isPinned: true, + isArchived: true, + type: true, + checkItems: true, + labels: true, + images: true, + links: true, + isMarkdown: true, + size: true, + notebookId: true, + }, + }) + + if (!note || !note.userId) return + + await prisma.$transaction(async (tx) => { + const lastVersionEntry = await (tx as any).noteHistory.findFirst({ + where: { noteId }, + orderBy: { version: 'desc' }, + select: { version: true }, + }) + + const nextVersion = ((lastVersionEntry?.version as number | undefined) ?? 0) + 1 + + await (tx as any).noteHistory.create({ + data: { + noteId: note.id, + userId: note.userId, + version: nextVersion, + reason: reason ?? null, + title: note.title, + content: note.content, + color: note.color, + isPinned: note.isPinned, + isArchived: note.isArchived, + type: note.type, + checkItems: note.checkItems, + labels: note.labels, + images: note.images, + links: note.links, + isMarkdown: note.isMarkdown, + size: note.size, + notebookId: note.notebookId, + }, + }) + }) +} + +export function parseNoteHistoryEntry(entry: any): NoteHistoryEntry { + return { + id: entry.id, + noteId: entry.noteId, + userId: entry.userId, + version: entry.version, + reason: entry.reason ?? null, + title: entry.title ?? null, + content: entry.content, + color: entry.color, + isPinned: entry.isPinned, + isArchived: entry.isArchived, + type: entry.type, + checkItems: asArray(entry.checkItems, null as any) ?? null, + labels: asArray(entry.labels) || null, + images: asArray(entry.images) || null, + links: asArray(entry.links) || null, + isMarkdown: entry.isMarkdown, + size: entry.size, + notebookId: entry.notebookId ?? null, + createdAt: entry.createdAt, + } +} + +export async function getNoteHistoryMode(userId: string): Promise<'manual' | 'auto'> { + const settings = await prisma.userAISettings.findUnique({ + where: { userId }, + select: { noteHistoryMode: true }, + }) + const mode = settings?.noteHistoryMode + return mode === 'auto' ? 'auto' : 'manual' +} + +const STRUCTURAL_FIELDS = ['color', 'isPinned', 'isArchived', 'labels', 'notebookId'] as const + +export async function shouldCreateAutoSnapshot(params: { + noteId: string + userId: string + updateData: Record + existingContent: string + existingTitle: string | null +}): Promise { + const { noteId, userId, updateData, existingContent, existingTitle } = params + + // Structural changes (color, pin, archive, labels, notebook) always create a snapshot + const hasStructuralChange = STRUCTURAL_FIELDS.some((f) => f in updateData) + if (hasStructuralChange) return true + + // Content changes: check diff threshold + const newContent = typeof updateData.content === 'string' ? updateData.content : null + const newTitle = typeof updateData.title === 'string' ? updateData.title : null + + const contentChanged = newContent !== null + const titleChanged = newTitle !== null + + if (!contentChanged && !titleChanged) return false + + // Check cooldown: find the most recent snapshot for this note + const lastSnapshot = await (prisma as any).noteHistory.findFirst({ + where: { noteId }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }) + + if (lastSnapshot) { + const elapsed = Date.now() - new Date(lastSnapshot.createdAt).getTime() + if (elapsed < COOLDOWN_MS) { + // Within cooldown — only skip if the diff is trivial + const contentDiff = contentChanged + ? Math.abs((newContent as string).length - existingContent.length) + : 0 + const titleDiff = titleChanged + ? Math.abs((newTitle ?? '').length - (existingTitle ?? '').length) + : 0 + + if (contentDiff < CONTENT_DIFF_THRESHOLD && titleDiff === 0) { + return false + } + } + } + + return true +} diff --git a/memento-note/lib/types.ts b/memento-note/lib/types.ts index 0b15ea0..f2509a2 100644 --- a/memento-note/lib/types.ts +++ b/memento-note/lib/types.ts @@ -80,6 +80,28 @@ export interface Note { searchScore?: number | null; } +export interface NoteHistoryEntry { + id: string; + noteId: string; + userId: string; + version: number; + reason: string | null; + title: string | null; + content: string; + color: string; + isPinned: boolean; + isArchived: boolean; + type: 'text' | 'checklist'; + checkItems: CheckItem[] | null; + labels: string[] | null; + images: string[] | null; + links: LinkMetadata[] | null; + isMarkdown: boolean; + size: NoteSize; + notebookId: string | null; + createdAt: Date; +} + export type NoteSize = 'small' | 'medium' | 'large'; export interface LabelWithColor { diff --git a/memento-note/locales/ar.json b/memento-note/locales/ar.json index feba3cf..a001ff8 100644 --- a/memento-note/locales/ar.json +++ b/memento-note/locales/ar.json @@ -979,7 +979,29 @@ "notes.emptyNotebook": "دفتر فارغ", "notes.emptyNotebookDesc": "لا توجد ملاحظات. انقر على + لإنشاء واحدة.", "notes.noNoteSelected": "لم يتم تحديد ملاحظة", - "notes.selectOrCreateNote": "اختر ملاحظة من القائمة أو أنشئ واحدة جديدة." + "notes.selectOrCreateNote": "اختر ملاحظة من القائمة أو أنشئ واحدة جديدة.", + "commitVersion": "حفظ النسخة", + "versionSaved": "تم حفظ النسخة", + "deleteVersion": "حذف هذه النسخة", + "versionDeleted": "تم حذف النسخة", + "deleteVersionConfirm": "حذف هذه النسخة نهائياً؟", + "historyMode": "وضع السجل", + "historyModeManual": "يدوي (زر الالتزام)", + "historyModeAuto": "تلقائي (ذكي)", + "historyModeManualDesc": "إنشاء لقطات يدوياً بزر الالتزام", + "historyModeAutoDesc": "لقاطات تلقائية بالكشف الذكي", + "history": "السجل", + "historyRestored": "تم استعادة النسخة", + "historyEnabled": "تم تفعيل السجل", + "historyDisabledDesc": "السجل معطل لحسابك.", + "enableHistory": "تفعيل السجل", + "historyEmpty": "لا توجد نسخ متاحة", + "historySelectVersion": "اختر نسخة لمعاينة محتواها", + "sortBy": "ترتيب حسب", + "sortDateDesc": "التاريخ (الأحدث)", + "sortDateAsc": "التاريخ (الأقدم)", + "sortTitleAsc": "العنوان أ ← ي", + "sortTitleDesc": "العنوان ي ← أ" }, "pagination": { "next": "→", diff --git a/memento-note/locales/de.json b/memento-note/locales/de.json index 0dd4a47..a3227be 100644 --- a/memento-note/locales/de.json +++ b/memento-note/locales/de.json @@ -1002,7 +1002,29 @@ "notes.emptyNotebook": "Leeres Notizbuch", "notes.emptyNotebookDesc": "Keine Notizen vorhanden. Klicke auf + um eine zu erstellen.", "notes.noNoteSelected": "Keine Notiz ausgewählt", - "notes.selectOrCreateNote": "Wähle eine Notiz aus der Liste oder erstelle eine neue." + "notes.selectOrCreateNote": "Wähle eine Notiz aus der Liste oder erstelle eine neue.", + "commitVersion": "Version speichern", + "versionSaved": "Version gespeichert", + "deleteVersion": "Diese Version löschen", + "versionDeleted": "Version gelöscht", + "deleteVersionConfirm": "Diese Version endgültig löschen?", + "historyMode": "Verlaufsmodus", + "historyModeManual": "Manuell (Commit-Schaltfläche)", + "historyModeAuto": "Automatisch (intelligent)", + "historyModeManualDesc": "Snapshots manuell mit der Commit-Schaltfläche erstellen", + "historyModeAutoDesc": "Automatische Snapshots mit intelligenter Erkennung", + "history": "Verlauf", + "historyRestored": "Version wiederhergestellt", + "historyEnabled": "Verlauf aktiviert", + "historyDisabledDesc": "Der Verlauf ist für Ihr Konto deaktiviert.", + "enableHistory": "Verlauf aktivieren", + "historyEmpty": "Keine Versionen verfügbar", + "historySelectVersion": "Wählen Sie eine Version zur Vorschau aus", + "sortBy": "Sortieren nach", + "sortDateDesc": "Datum (neueste)", + "sortDateAsc": "Datum (älteste)", + "sortTitleAsc": "Titel A → Z", + "sortTitleDesc": "Titel Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 0fddee4..58fba7f 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -161,7 +161,29 @@ "notes.emptyNotebook": "Empty notebook", "notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.", "notes.noNoteSelected": "No note selected", - "notes.selectOrCreateNote": "Select a note from the list or create a new one." + "notes.selectOrCreateNote": "Select a note from the list or create a new one.", + "commitVersion": "Save version", + "versionSaved": "Version saved", + "deleteVersion": "Delete this version", + "versionDeleted": "Version deleted", + "deleteVersionConfirm": "Delete this version permanently?", + "historyMode": "History mode", + "historyModeManual": "Manual (commit button)", + "historyModeAuto": "Automatic (smart)", + "historyModeManualDesc": "Create snapshots manually with the commit button", + "historyModeAutoDesc": "Automatic snapshots with smart detection", + "history": "History", + "historyRestored": "Version restored", + "historyEnabled": "History enabled", + "historyDisabledDesc": "History is disabled for your account.", + "enableHistory": "Enable history", + "historyEmpty": "No versions available", + "historySelectVersion": "Select a version to preview its content", + "sortBy": "Sort by", + "sortDateDesc": "Date (newest)", + "sortDateAsc": "Date (oldest)", + "sortTitleAsc": "Title A → Z", + "sortTitleDesc": "Title Z → A" }, "pagination": { "previous": "←", diff --git a/memento-note/locales/es.json b/memento-note/locales/es.json index f2b1eaf..e5f07af 100644 --- a/memento-note/locales/es.json +++ b/memento-note/locales/es.json @@ -974,7 +974,29 @@ "notes.emptyNotebook": "Cuaderno vacío", "notes.emptyNotebookDesc": "Este cuaderno no tiene notas. Haz clic en + para crear una.", "notes.noNoteSelected": "Ninguna nota seleccionada", - "notes.selectOrCreateNote": "Selecciona una nota de la lista o crea una nueva." + "notes.selectOrCreateNote": "Selecciona una nota de la lista o crea una nueva.", + "commitVersion": "Guardar versión", + "versionSaved": "Versión guardada", + "deleteVersion": "Eliminar esta versión", + "versionDeleted": "Versión eliminada", + "deleteVersionConfirm": "¿Eliminar esta versión permanentemente?", + "historyMode": "Modo de historial", + "historyModeManual": "Manual (botón commit)", + "historyModeAuto": "Automático (inteligente)", + "historyModeManualDesc": "Crear snapshots manualmente con el botón commit", + "historyModeAutoDesc": "Snapshots automáticos con detección inteligente", + "history": "Historial", + "historyRestored": "Versión restaurada", + "historyEnabled": "Historial activado", + "historyDisabledDesc": "El historial está desactivado para tu cuenta.", + "enableHistory": "Activar historial", + "historyEmpty": "No hay versiones disponibles", + "historySelectVersion": "Selecciona una versión para previsualizar su contenido", + "sortBy": "Ordenar por", + "sortDateDesc": "Fecha (reciente)", + "sortDateAsc": "Fecha (antigua)", + "sortTitleAsc": "Título A → Z", + "sortTitleDesc": "Título Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/fa.json b/memento-note/locales/fa.json index ba2d83e..6382984 100644 --- a/memento-note/locales/fa.json +++ b/memento-note/locales/fa.json @@ -1032,7 +1032,29 @@ "notes.emptyNotebook": "دفترچه خالی", "notes.emptyNotebookDesc": "این دفترچه یادداشتی ندارد. روی + کلیک کنید تا یکی بسازید.", "notes.noNoteSelected": "یادداشتی انتخاب نشده", - "notes.selectOrCreateNote": "یک یادداشت از لیست انتخاب کنید یا یکی جدید بسازید." + "notes.selectOrCreateNote": "یک یادداشت از لیست انتخاب کنید یا یکی جدید بسازید.", + "commitVersion": "ذخیره نسخه", + "versionSaved": "نسخه ذخیره شد", + "deleteVersion": "حذف این نسخه", + "versionDeleted": "نسخه حذف شد", + "deleteVersionConfirm": "این نسخه برای همیشه حذف شود؟", + "historyMode": "حالت تاریخچه", + "historyModeManual": "دستی (دکمه ثبت)", + "historyModeAuto": "خودکار (هوشمند)", + "historyModeManualDesc": "ایجاد اسنپ‌شات دستی با دکمه ثبت", + "historyModeAutoDesc": "اسنپ‌شات خودکار با تشخیص هوشمند", + "history": "تاریخچه", + "historyRestored": "نسخه بازیابی شد", + "historyEnabled": "تاریخچه فعال شد", + "historyDisabledDesc": "تاریخچه برای حساب شما غیرفعال است.", + "enableHistory": "فعال‌سازی تاریخچه", + "historyEmpty": "نسخه‌ای موجود نیست", + "historySelectVersion": "نسخه‌ای را برای پیش‌نمایش انتخاب کنید", + "sortBy": "مرتب‌سازی بر اساس", + "sortDateDesc": "تاریخ (جدیدترین)", + "sortDateAsc": "تاریخ (قدیمی‌ترین)", + "sortTitleAsc": "عنوان الف ← ی", + "sortTitleDesc": "عنوان ی ← الف" }, "pagination": { "next": "→", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 0fd0548..3337bdf 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -362,56 +362,56 @@ "undo": "Annuler IA", "undoAI": "Annuler la transformation IA", "undoApplied": "Texte original restauré", - "minWordsError": "Note must contain at least 5 words to use AI actions.", - "genericError": "AI error", - "actionError": "Error during AI action", - "appliedToNote": "Applied to note", - "applyToNote": "Apply to note", - "undoLastAction": "Undo last AI action", - "selectContext": "Select context...", - "selectNotebook": "Select notebook", - "chatPlaceholder": "Ask AI to edit, summarize, or draft...", - "assistantTitle": "AI Assistant", - "currentNote": "Current note", - "shrinkPanel": "Shrink panel", - "expandPanel": "Expand panel", - "chatTab": "Chat", - "noteActions": "Note Actions", - "askToStart": "Ask the Assistant something to get started.", - "contextLabel": "Context", - "thisNote": "This note", - "allMyNotes": "All my notes", - "notebookGeneric": "Notebook", - "writingTone": "Writing Tone", - "askAboutThisNote": "Ask AI something about this note...", - "askAboutYourNotes": "Ask AI something about your notes...", - "webSearchLabel": "Web Search", - "newLineHint": "Shift+Enter = new line", - "resultLabel": "Result", - "discardAction": "Discard", - "transformationsDesc": "Transformations — applied directly to the note", - "writeMinWordsAction": "Write at least 5 words to activate AI actions.", - "processingAction": "Processing...", + "minWordsError": "La note doit contenir au moins 5 mots pour utiliser les actions IA.", + "genericError": "Erreur IA", + "actionError": "Erreur lors de l'action IA", + "appliedToNote": "Appliqué à la note", + "applyToNote": "Appliquer à la note", + "undoLastAction": "Annuler la dernière action IA", + "selectContext": "Sélectionner le contexte...", + "selectNotebook": "Sélectionner un carnet", + "chatPlaceholder": "Demandez à l'IA de modifier, résumer ou rédiger...", + "assistantTitle": "Assistant IA", + "currentNote": "Note actuelle", + "shrinkPanel": "Réduire le panneau", + "expandPanel": "Agrandir le panneau", + "chatTab": "Discussion", + "noteActions": "Actions sur la note", + "askToStart": "Posez une question à l'Assistant pour commencer.", + "contextLabel": "Contexte", + "thisNote": "Cette note", + "allMyNotes": "Toutes mes notes", + "notebookGeneric": "Carnet", + "writingTone": "Ton d'écriture", + "askAboutThisNote": "Posez une question sur cette note...", + "askAboutYourNotes": "Posez une question sur vos notes...", + "webSearchLabel": "Recherche web", + "newLineHint": "Maj+Entrée = nouvelle ligne", + "resultLabel": "Résultat", + "discardAction": "Ignorer", + "transformationsDesc": "Transformations — appliquées directement à la note", + "writeMinWordsAction": "Écrivez au moins 5 mots pour activer les actions IA.", + "processingAction": "Traitement en cours...", "action": { - "clarify": "Clarify", - "shorten": "Shorten", - "improve": "Improve", - "toMarkdown": "To Markdown" + "clarify": "Clarifier", + "shorten": "Raccourcir", + "improve": "Améliorer", + "toMarkdown": "Convertir en Markdown" }, - "openAssistant": "Open AI Assistant", - "poweredByMomento": "Powered by Momento AI", - "welcomeMsg": "Hello! I'm your AI assistant. How can I help you with your notes today? I can help refine tone, expand messaging, or summarize content.", - "summaryLast5": "Summary of your last 5 notes", - "analyzingProgress": "Analyzing...", - "generateInsightsBtn": "Generate Insights", - "newDiscussion": "New discussion", - "noRecentConversations": "No recent conversations.", - "discussionContextLabel": "Discussion Context", - "webSearchNotConfigured": "Web Search (Not configured)", - "historyTab": "History", + "openAssistant": "Ouvrir l'Assistant IA", + "poweredByMomento": "Propulsé par Momento AI", + "welcomeMsg": "Bonjour ! Je suis votre assistant IA. Comment puis-je vous aider avec vos notes ? Je peux affiner le ton, développer un message ou résumer le contenu.", + "summaryLast5": "Résumé de vos 5 dernières notes", + "analyzingProgress": "Analyse en cours...", + "generateInsightsBtn": "Générer des insights", + "newDiscussion": "Nouvelle discussion", + "noRecentConversations": "Aucune conversation récente.", + "discussionContextLabel": "Contexte de discussion", + "webSearchNotConfigured": "Recherche web (non configurée)", + "historyTab": "Historique", "insightsTab": "Insights", - "aiCopilot": "AI Copilot", - "suggestTitle": "AI title suggestion" + "aiCopilot": "Copilote IA", + "suggestTitle": "Suggestion de titre IA" }, "aiSettings": { "description": "Configurez vos fonctionnalités IA et préférences", @@ -985,7 +985,29 @@ "notes.emptyNotebook": "Empty notebook", "notes.emptyNotebookDesc": "This notebook has no notes. Click + to create one.", "notes.noNoteSelected": "No note selected", - "notes.selectOrCreateNote": "Select a note from the list or create a new one." + "notes.selectOrCreateNote": "Select a note from the list or create a new one.", + "commitVersion": "Enregistrer la version", + "versionSaved": "Version enregistrée", + "deleteVersion": "Supprimer cette version", + "versionDeleted": "Version supprimée", + "deleteVersionConfirm": "Supprimer cette version définitivement ?", + "historyMode": "Mode d'historique", + "historyModeManual": "Manuel (bouton commit)", + "historyModeAuto": "Automatique (intelligent)", + "historyModeManualDesc": "Créer des snapshots manuellement avec le bouton commit", + "historyModeAutoDesc": "Snapshots automatiques avec détection intelligente", + "history": "Historique", + "historyRestored": "Version restaurée", + "historyEnabled": "Historique activé", + "historyDisabledDesc": "L'historique est désactivé pour votre compte.", + "enableHistory": "Activer l'historique", + "historyEmpty": "Aucune version disponible", + "historySelectVersion": "Sélectionnez une version pour prévisualiser son contenu", + "sortBy": "Trier par", + "sortDateDesc": "Date (récent)", + "sortDateAsc": "Date (ancien)", + "sortTitleAsc": "Titre A → Z", + "sortTitleDesc": "Titre Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/hi.json b/memento-note/locales/hi.json index 65c2798..304c039 100644 --- a/memento-note/locales/hi.json +++ b/memento-note/locales/hi.json @@ -979,7 +979,29 @@ "notes.emptyNotebook": "खाली नोटबुक", "notes.emptyNotebookDesc": "इस नोटबुक में कोई नोट नहीं है। एक बनाने के लिए + पर क्लिक करें।", "notes.noNoteSelected": "कोई नोट चुना नहीं गया", - "notes.selectOrCreateNote": "सूची से एक नोट चुनें या एक नया बनाएं।" + "notes.selectOrCreateNote": "सूची से एक नोट चुनें या एक नया बनाएं।", + "commitVersion": "संस्करण सहेजें", + "versionSaved": "संस्करण सहेजा गया", + "deleteVersion": "इस संस्करण को हटाएं", + "versionDeleted": "संस्करण हटाया गया", + "deleteVersionConfirm": "क्या आप इस संस्करण को स्थायी रूप से हटाना चाहते हैं?", + "historyMode": "इतिहास मोड", + "historyModeManual": "मैनुअल (कमिट बटन)", + "historyModeAuto": "स्वचालित (स्मार्ट)", + "historyModeManualDesc": "कमिट बटन से मैन्युअल स्नैपशॉट बनाएं", + "historyModeAutoDesc": "स्मार्ट डिटेक्शन के साथ ऑटो स्नैपशॉट", + "history": "इतिहास", + "historyRestored": "संस्करण पुनर्स्थापित", + "historyEnabled": "इतिहास सक्षम किया गया", + "historyDisabledDesc": "आपके खाते के लिए इतिहास अक्षम है।", + "enableHistory": "इतिहास सक्षम करें", + "historyEmpty": "कोई संस्करण उपलब्ध नहीं", + "historySelectVersion": "पूर्वावलोकन के लिए एक संस्करण चुनें", + "sortBy": "इसके अनुसार क्रमबद्ध करें", + "sortDateDesc": "तिथि (नवीनतम)", + "sortDateAsc": "तिथि (पुराना)", + "sortTitleAsc": "शीर्षक A → Z", + "sortTitleDesc": "शीर्षक Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/it.json b/memento-note/locales/it.json index dee9a45..0eebac9 100644 --- a/memento-note/locales/it.json +++ b/memento-note/locales/it.json @@ -1024,7 +1024,29 @@ "notes.emptyNotebook": "Quaderno vuoto", "notes.emptyNotebookDesc": "Questo quaderno non ha note. Clicca + per crearne una.", "notes.noNoteSelected": "Nessuna nota selezionata", - "notes.selectOrCreateNote": "Seleziona una nota dalla lista o creane una nuova." + "notes.selectOrCreateNote": "Seleziona una nota dalla lista o creane una nuova.", + "commitVersion": "Salva versione", + "versionSaved": "Versione salvata", + "deleteVersion": "Elimina questa versione", + "versionDeleted": "Versione eliminata", + "deleteVersionConfirm": "Eliminare questa versione definitivamente?", + "historyMode": "Modalità cronologia", + "historyModeManual": "Manuale (pulsante commit)", + "historyModeAuto": "Automatico (intelligente)", + "historyModeManualDesc": "Crea snapshot manualmente con il pulsante commit", + "historyModeAutoDesc": "Snapshot automatici con rilevamento intelligente", + "history": "Cronologia", + "historyRestored": "Versione ripristinata", + "historyEnabled": "Cronologia attivata", + "historyDisabledDesc": "La cronologia è disattivata per il tuo account.", + "enableHistory": "Attiva cronologia", + "historyEmpty": "Nessuna versione disponibile", + "historySelectVersion": "Seleziona una versione per visualizzarne l'anteprima", + "sortBy": "Ordina per", + "sortDateDesc": "Data (recente)", + "sortDateAsc": "Data (meno recente)", + "sortTitleAsc": "Titolo A → Z", + "sortTitleDesc": "Titolo Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/ja.json b/memento-note/locales/ja.json index d79ef3d..1418362 100644 --- a/memento-note/locales/ja.json +++ b/memento-note/locales/ja.json @@ -1002,7 +1002,29 @@ "notes.emptyNotebook": "空のノートブック", "notes.emptyNotebookDesc": "このノートブックにはノートがありません。+ をクリックして作成。", "notes.noNoteSelected": "ノート未選択", - "notes.selectOrCreateNote": "リストからノートを選択または新規作成してください。" + "notes.selectOrCreateNote": "リストからノートを選択または新規作成してください。", + "commitVersion": "バージョンを保存", + "versionSaved": "バージョンを保存しました", + "deleteVersion": "このバージョンを削除", + "versionDeleted": "バージョンを削除しました", + "deleteVersionConfirm": "このバージョンを完全に削除しますか?", + "historyMode": "履歴モード", + "historyModeManual": "手動(コミットボタン)", + "historyModeAuto": "自動(スマート)", + "historyModeManualDesc": "コミットボタンで手動スナップショットを作成", + "historyModeAutoDesc": "スマート検出で自動スナップショットを作成", + "history": "履歴", + "historyRestored": "バージョンを復元しました", + "historyEnabled": "履歴を有効にしました", + "historyDisabledDesc": "履歴は無効になっています。", + "enableHistory": "履歴を有効にする", + "historyEmpty": "バージョンがありません", + "historySelectVersion": "プレビューするバージョンを選択してください", + "sortBy": "並び替え", + "sortDateDesc": "日付(新しい)", + "sortDateAsc": "日付(古い)", + "sortTitleAsc": "タイトル A → Z", + "sortTitleDesc": "タイトル Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/ko.json b/memento-note/locales/ko.json index 4fda800..13bb0e0 100644 --- a/memento-note/locales/ko.json +++ b/memento-note/locales/ko.json @@ -979,7 +979,29 @@ "notes.emptyNotebook": "빈 노트북", "notes.emptyNotebookDesc": "이 노트북에 노트가 없습니다. +를 클릭하여 만드세요.", "notes.noNoteSelected": "선택된 노트 없음", - "notes.selectOrCreateNote": "목록에서 노트를 선택하거나 새로 만드세요." + "notes.selectOrCreateNote": "목록에서 노트를 선택하거나 새로 만드세요.", + "commitVersion": "버전 저장", + "versionSaved": "버전이 저장되었습니다", + "deleteVersion": "이 버전 삭제", + "versionDeleted": "버전이 삭제되었습니다", + "deleteVersionConfirm": "이 버전을 영구적으로 삭제하시겠습니까?", + "historyMode": "기록 모드", + "historyModeManual": "수동 (커밋 버튼)", + "historyModeAuto": "자동 (스마트)", + "historyModeManualDesc": "커밋 버튼으로 수동 스냅샷 생성", + "historyModeAutoDesc": "스마트 감지로 자동 스냅샷 생성", + "history": "기록", + "historyRestored": "버전이 복원되었습니다", + "historyEnabled": "기록이 활성화되었습니다", + "historyDisabledDesc": "기록이 비활성화되어 있습니다.", + "enableHistory": "기록 활성화", + "historyEmpty": "사용 가능한 버전이 없습니다", + "historySelectVersion": "미리볼 버전을 선택하세요", + "sortBy": "정렬", + "sortDateDesc": "날짜 (최신)", + "sortDateAsc": "날짜 (오래된)", + "sortTitleAsc": "제목 A → Z", + "sortTitleDesc": "제목 Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/nl.json b/memento-note/locales/nl.json index eced583..8292052 100644 --- a/memento-note/locales/nl.json +++ b/memento-note/locales/nl.json @@ -1024,7 +1024,29 @@ "notes.emptyNotebook": "Leeg notitieboek", "notes.emptyNotebookDesc": "Dit notitieboek heeft geen notities. Klik op + om er een te maken.", "notes.noNoteSelected": "Geen notitie geselecteerd", - "notes.selectOrCreateNote": "Selecteer een notitie uit de lijst of maak een nieuwe." + "notes.selectOrCreateNote": "Selecteer een notitie uit de lijst of maak een nieuwe.", + "commitVersion": "Versie opslaan", + "versionSaved": "Versie opgeslagen", + "deleteVersion": "Deze versie verwijderen", + "versionDeleted": "Versie verwijderd", + "deleteVersionConfirm": "Deze versie definitief verwijderen?", + "historyMode": "Geschiedenismodus", + "historyModeManual": "Handmatig (commit-knop)", + "historyModeAuto": "Automatisch (slim)", + "historyModeManualDesc": "Handmatig snapshots maken met de commit-knop", + "historyModeAutoDesc": "Automatische snapshots met slimme detectie", + "history": "Geschiedenis", + "historyRestored": "Versie hersteld", + "historyEnabled": "Geschiedenis ingeschakeld", + "historyDisabledDesc": "Geschiedenis is uitgeschakeld voor uw account.", + "enableHistory": "Geschiedenis inschakelen", + "historyEmpty": "Geen versies beschikbaar", + "historySelectVersion": "Selecteer een versie om de inhoud te bekijken", + "sortBy": "Sorteren op", + "sortDateDesc": "Datum (nieuwste)", + "sortDateAsc": "Datum (oudste)", + "sortTitleAsc": "Titel A → Z", + "sortTitleDesc": "Titel Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/pl.json b/memento-note/locales/pl.json index 1d631f6..c721a37 100644 --- a/memento-note/locales/pl.json +++ b/memento-note/locales/pl.json @@ -1046,7 +1046,29 @@ "notes.emptyNotebook": "Pusty notatnik", "notes.emptyNotebookDesc": "Ten notatnik nie ma notatek. Kliknij + aby utworzyć.", "notes.noNoteSelected": "Nie wybrano notatki", - "notes.selectOrCreateNote": "Wybierz notatkę z listy lub utwórz nową." + "notes.selectOrCreateNote": "Wybierz notatkę z listy lub utwórz nową.", + "commitVersion": "Zapisz wersję", + "versionSaved": "Wersja zapisana", + "deleteVersion": "Usuń tę wersję", + "versionDeleted": "Wersja usunięta", + "deleteVersionConfirm": "Usunąć tę wersję trwale?", + "historyMode": "Tryb historii", + "historyModeManual": "Ręczny (przycisk commit)", + "historyModeAuto": "Automatyczny (inteligentny)", + "historyModeManualDesc": "Ręczne tworzenie snapshotów przyciskiem commit", + "historyModeAutoDesc": "Automatyczne snapshoty z inteligentnym wykrywaniem", + "history": "Historia", + "historyRestored": "Wersja przywrócona", + "historyEnabled": "Historia włączona", + "historyDisabledDesc": "Historia jest wyłączona dla Twojego konta.", + "enableHistory": "Włącz historię", + "historyEmpty": "Brak dostępnych wersji", + "historySelectVersion": "Wybierz wersję, aby zobaczyć podgląd", + "sortBy": "Sortuj według", + "sortDateDesc": "Data (najnowsze)", + "sortDateAsc": "Data (najstarsze)", + "sortTitleAsc": "Tytuł A → Z", + "sortTitleDesc": "Tytuł Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/pt.json b/memento-note/locales/pt.json index 8558a0f..9b52d9f 100644 --- a/memento-note/locales/pt.json +++ b/memento-note/locales/pt.json @@ -974,7 +974,29 @@ "notes.emptyNotebook": "Caderno vazio", "notes.emptyNotebookDesc": "Este caderno não tem notas. Clique em + para criar uma.", "notes.noNoteSelected": "Nenhuma nota selecionada", - "notes.selectOrCreateNote": "Selecione uma nota da lista ou crie uma nova." + "notes.selectOrCreateNote": "Selecione uma nota da lista ou crie uma nova.", + "commitVersion": "Salvar versão", + "versionSaved": "Versão salva", + "deleteVersion": "Excluir esta versão", + "versionDeleted": "Versão excluída", + "deleteVersionConfirm": "Excluir esta versão permanentemente?", + "historyMode": "Modo de histórico", + "historyModeManual": "Manual (botão commit)", + "historyModeAuto": "Automático (inteligente)", + "historyModeManualDesc": "Criar snapshots manualmente com o botão commit", + "historyModeAutoDesc": "Snapshots automáticos com detecção inteligente", + "history": "Histórico", + "historyRestored": "Versão restaurada", + "historyEnabled": "Histórico ativado", + "historyDisabledDesc": "O histórico está desativado para sua conta.", + "enableHistory": "Ativar histórico", + "historyEmpty": "Nenhuma versão disponível", + "historySelectVersion": "Selecione uma versão para visualizar seu conteúdo", + "sortBy": "Ordenar por", + "sortDateDesc": "Data (recente)", + "sortDateAsc": "Data (antiga)", + "sortTitleAsc": "Título A → Z", + "sortTitleDesc": "Título Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/locales/ru.json b/memento-note/locales/ru.json index ce996a3..0a0346f 100644 --- a/memento-note/locales/ru.json +++ b/memento-note/locales/ru.json @@ -974,7 +974,29 @@ "notes.emptyNotebook": "Пустой блокнот", "notes.emptyNotebookDesc": "В этом блокноте нет заметок. Нажмите +, чтобы создать.", "notes.noNoteSelected": "Заметка не выбрана", - "notes.selectOrCreateNote": "Выберите заметку из списка или создайте новую." + "notes.selectOrCreateNote": "Выберите заметку из списка или создайте новую.", + "commitVersion": "Сохранить версию", + "versionSaved": "Версия сохранена", + "deleteVersion": "Удалить эту версию", + "versionDeleted": "Версия удалена", + "deleteVersionConfirm": "Удалить эту версию навсегда?", + "historyMode": "Режим истории", + "historyModeManual": "Ручной (кнопка фиксации)", + "historyModeAuto": "Автоматический (умный)", + "historyModeManualDesc": "Создавать снимки вручную кнопкой фиксации", + "historyModeAutoDesc": "Автоматические снимки с умным обнаружением", + "history": "История", + "historyRestored": "Версия восстановлена", + "historyEnabled": "История включена", + "historyDisabledDesc": "История отключена для вашей учётной записи.", + "enableHistory": "Включить историю", + "historyEmpty": "Нет доступных версий", + "historySelectVersion": "Выберите версию для предпросмотра", + "sortBy": "Сортировать по", + "sortDateDesc": "Дата (новые)", + "sortDateAsc": "Дата (старые)", + "sortTitleAsc": "Заголовок А → Я", + "sortTitleDesc": "Заголовок Я → А" }, "pagination": { "next": "→", diff --git a/memento-note/locales/zh.json b/memento-note/locales/zh.json index 653c4ad..a5428d8 100644 --- a/memento-note/locales/zh.json +++ b/memento-note/locales/zh.json @@ -1002,7 +1002,29 @@ "notes.emptyNotebook": "空笔记本", "notes.emptyNotebookDesc": "此笔记本没有笔记。点击 + 创建一个。", "notes.noNoteSelected": "未选择笔记", - "notes.selectOrCreateNote": "从列表中选择笔记或创建新笔记。" + "notes.selectOrCreateNote": "从列表中选择笔记或创建新笔记。", + "commitVersion": "保存版本", + "versionSaved": "版本已保存", + "deleteVersion": "删除此版本", + "versionDeleted": "版本已删除", + "deleteVersionConfirm": "确定永久删除此版本?", + "historyMode": "历史模式", + "historyModeManual": "手动(提交按钮)", + "historyModeAuto": "自动(智能)", + "historyModeManualDesc": "使用提交按钮手动创建快照", + "historyModeAutoDesc": "智能检测自动创建快照", + "history": "历史", + "historyRestored": "版本已恢复", + "historyEnabled": "历史已启用", + "historyDisabledDesc": "您的账户已禁用历史记录。", + "enableHistory": "启用历史", + "historyEmpty": "暂无版本", + "historySelectVersion": "选择一个版本以预览其内容", + "sortBy": "排序方式", + "sortDateDesc": "日期(最新)", + "sortDateAsc": "日期(最早)", + "sortTitleAsc": "标题 A → Z", + "sortTitleDesc": "标题 Z → A" }, "pagination": { "next": "→", diff --git a/memento-note/package.json b/memento-note/package.json index b3ca30a..4297aaa 100644 --- a/memento-note/package.json +++ b/memento-note/package.json @@ -7,7 +7,8 @@ "build": "prisma generate && next build", "start": "next start", "db:generate": "prisma generate", - "db:migrate": "prisma migrate dev", + "db:migrate": "node scripts/safe-migrate.js", + "db:migrate:dev": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", "db:push": "prisma db push", "db:studio": "prisma studio", diff --git a/memento-note/prisma/migrations/20260429000000_add_note_history_mode/migration.sql b/memento-note/prisma/migrations/20260429000000_add_note_history_mode/migration.sql new file mode 100644 index 0000000..19221c0 --- /dev/null +++ b/memento-note/prisma/migrations/20260429000000_add_note_history_mode/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserAISettings" ADD COLUMN "noteHistoryMode" TEXT NOT NULL DEFAULT 'manual'; diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 037dc87..40dacd8 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -30,6 +30,7 @@ model User { labels Label[] memoryEchoInsights MemoryEchoInsight[] notes Note[] + noteHistories NoteHistory[] sentShares NoteShare[] @relation("SentShares") receivedShares NoteShare[] @relation("ReceivedShares") notebooks Notebook[] @@ -152,6 +153,7 @@ model Note { noteEmbedding NoteEmbedding? shares NoteShare[] labelRelations Label[] @relation("LabelToNote") + historyEntries NoteHistory[] @@index([isPinned]) @@index([isArchived]) @@ -162,6 +164,34 @@ model Note { @@index([userId, notebookId]) } +model NoteHistory { + id String @id @default(cuid()) + noteId String + userId String + version Int + reason String? + title String? + content String + color String + isPinned Boolean + isArchived Boolean + type String + checkItems String? + labels String? + images String? + links String? + isMarkdown Boolean + size String + notebookId String? + createdAt DateTime @default(now()) + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([noteId, version]) + @@index([noteId, createdAt(sort: Desc)]) + @@index([userId, noteId, createdAt(sort: Desc)]) +} + model NoteShare { id String @id @default(cuid()) noteId String @@ -244,6 +274,8 @@ model UserAISettings { desktopNotifications Boolean @default(false) anonymousAnalytics Boolean @default(false) autoLabeling Boolean @default(true) + noteHistory Boolean @default(false) + noteHistoryMode String @default("manual") languageDetection Boolean @default(true) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/memento-note/scripts/safe-migrate.js b/memento-note/scripts/safe-migrate.js new file mode 100644 index 0000000..9c8df7a --- /dev/null +++ b/memento-note/scripts/safe-migrate.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }) + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: 'inherit', + shell: process.platform === 'win32', + ...options, + }) + if (result.status !== 0) { + process.exit(result.status || 1) + } +} + +function nowStamp() { + const d = new Date() + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` +} + +const databaseUrl = process.env.DATABASE_URL +if (!databaseUrl) { + console.error('[safe-migrate] DATABASE_URL is missing in environment/.env') + process.exit(1) +} + +const backupsDir = path.join(__dirname, '..', 'backups', 'migrations') +fs.mkdirSync(backupsDir, { recursive: true }) + +const isPostgres = databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://') +const isSqlite = databaseUrl.startsWith('file:') + +console.log('[safe-migrate] Starting safe migration flow') + +if (isPostgres) { + const backupFile = path.join(backupsDir, `pre_migrate_${nowStamp()}.sql`) + console.log(`[safe-migrate] Creating PostgreSQL backup: ${backupFile}`) + let dump = spawnSync( + 'pg_dump', + ['--no-owner', '--no-privileges', '--format=plain', '--file', backupFile, databaseUrl], + { stdio: 'inherit', shell: process.platform === 'win32' } + ) + + if (dump.status !== 0) { + console.warn('[safe-migrate] Local pg_dump unavailable, trying Docker fallback...') + const pgUser = process.env.POSTGRES_USER || 'memento' + const pgDb = process.env.POSTGRES_DB || 'memento' + const dockerCmd = `docker exec memento-postgres pg_dump -U ${pgUser} -d ${pgDb} --no-owner --no-privileges --format=plain > "${backupFile}"` + dump = spawnSync(dockerCmd, { stdio: 'inherit', shell: true }) + } + + if (dump.status !== 0) { + console.error('[safe-migrate] Backup failed (local + docker). Migration aborted to protect data.') + process.exit(dump.status || 1) + } +} else if (isSqlite) { + const dbPath = databaseUrl.replace(/^file:/, '') + const absoluteDbPath = path.isAbsolute(dbPath) ? dbPath : path.join(__dirname, '..', dbPath) + if (fs.existsSync(absoluteDbPath)) { + const backupFile = path.join(backupsDir, `pre_migrate_${nowStamp()}.sqlite`) + console.log(`[safe-migrate] Creating SQLite backup: ${backupFile}`) + fs.copyFileSync(absoluteDbPath, backupFile) + } else { + console.warn(`[safe-migrate] SQLite file not found at ${absoluteDbPath}, skipping backup`) + } +} else { + console.warn('[safe-migrate] Unknown DATABASE_URL protocol, skipping backup step') +} + +console.log('[safe-migrate] Applying migrations with prisma migrate deploy') +run('npx', ['prisma', 'migrate', 'deploy']) +console.log('[safe-migrate] Migration completed successfully')