From d91072ed6b87e98fec3676e6ed670f85b827f94c Mon Sep 17 00:00:00 2001
From: sepehr
Date: Wed, 29 Apr 2026 22:34:13 +0200
Subject: [PATCH] feat: image AI titles (3 suggestions), describe-images
action, pin/list fixes, i18n
- Add image description service + API route for AI-powered image analysis
- Image title generation returns 3 selectable suggestions via TitleSuggestions component
- Add "Describe images" action in AI assistant (individual + collective)
- Fix pin refresh propagation in card and tabs view
- Fix note creation refresh in tabs mode, pass all notes to tabs view
- Add RTL support (dir="auto") on note content elements
- Pass UI language dynamically to AI endpoints instead of hardcoded 'fr'
- Add 18 missing i18n keys in both en.json and fr.json
- Sparkles button on images for AI title generation (bottom-right, pulse animation)
Co-Authored-By: Claude Opus 4.7
---
.../app/api/ai/describe-image/route.ts | 43 +++++
.../components/contextual-ai-chat.tsx | 71 +++++++-
memento-note/components/home-client.tsx | 15 +-
memento-note/components/note-card.tsx | 4 +-
memento-note/components/note-editor.tsx | 2 +
memento-note/components/note-input.tsx | 118 +++++++++++---
.../components/notes-main-section.tsx | 3 +
memento-note/components/notes-tabs-view.tsx | 33 ++--
.../ai/services/image-description.service.ts | 151 ++++++++++++++++++
memento-note/locales/en.json | 37 ++++-
memento-note/locales/fr.json | 35 +++-
11 files changed, 453 insertions(+), 59 deletions(-)
create mode 100644 memento-note/app/api/ai/describe-image/route.ts
create mode 100644 memento-note/lib/ai/services/image-description.service.ts
diff --git a/memento-note/app/api/ai/describe-image/route.ts b/memento-note/app/api/ai/describe-image/route.ts
new file mode 100644
index 0000000..3d42064
--- /dev/null
+++ b/memento-note/app/api/ai/describe-image/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { auth } from '@/auth'
+import { getAISettings } from '@/app/actions/ai-settings'
+import { describeImages } from '@/lib/ai/services/image-description.service'
+
+export async function POST(req: NextRequest) {
+ try {
+ const session = await auth()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const userSettings = await getAISettings(session.user.id)
+ if (userSettings.paragraphRefactor === false) {
+ return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })
+ }
+
+ const { imageUrls, mode, language } = await req.json()
+
+ if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
+ return NextResponse.json({ error: 'imageUrls must be a non-empty array' }, { status: 400 })
+ }
+
+ const result = await describeImages(
+ imageUrls,
+ mode === 'title' ? 'title' : 'description',
+ language || 'fr'
+ )
+
+ // For title mode, return suggestions in same format as /api/ai/title-suggestions
+ if (mode === 'title' && result.suggestions) {
+ return NextResponse.json({ suggestions: result.suggestions })
+ }
+
+ return NextResponse.json(result)
+ } catch (error: any) {
+ console.error('[describe-image] Error:', error)
+ return NextResponse.json(
+ { error: error.message || 'Failed to describe image' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/memento-note/components/contextual-ai-chat.tsx b/memento-note/components/contextual-ai-chat.tsx
index d4f90f4..5cf6303 100644
--- a/memento-note/components/contextual-ai-chat.tsx
+++ b/memento-note/components/contextual-ai-chat.tsx
@@ -11,7 +11,7 @@ import {
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
- Maximize2,
+ Maximize2, ImageIcon,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -52,9 +52,10 @@ interface ActionDef {
id: string
icon: any
apiPath: string
- body: (content: string) => object
+ body: (content: string, images?: string[], lang?: string) => object
resultKey: string
i18nKey: string
+ isImageAction?: boolean
}
const ACTION_IDS = [
@@ -62,6 +63,7 @@ const ACTION_IDS = [
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
+ { id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -155,6 +157,40 @@ export function ContextualAIChat({
// ── Action execution ────────────────────────────────────────────────────────
const handleAction = async (action: ActionDef) => {
+ // Image-specific action
+ if (action.isImageAction) {
+ if (!noteImages || noteImages.length === 0) {
+ toast.error(t('ai.noImagesError') || 'Aucune image dans cette note')
+ return
+ }
+ setActionLoading(action.id)
+ setActionPreview(null)
+ try {
+ const res = await fetch(action.apiPath, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(action.body('', noteImages, language)),
+ })
+ const data = await res.json()
+ if (!res.ok) throw new Error(data.error || t('ai.genericError'))
+ // Format image descriptions for preview
+ const descs = data.descriptions || []
+ let resultText = descs.map((d: any) =>
+ noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
+ ).join('\n\n')
+ if (data.combinedSummary) {
+ resultText += `\n\n---\n**${t('ai.overview') || 'Résumé'}:** ${data.combinedSummary}`
+ }
+ setActionPreview({ label: t(action.i18nKey), text: resultText })
+ } catch (e: any) {
+ toast.error(e.message || t('ai.actionError'))
+ } finally {
+ setActionLoading(null)
+ }
+ return
+ }
+
+ // Text-based actions
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
if (!noteContent || wc < 5) {
toast.error(t('ai.minWordsError'))
@@ -166,7 +202,7 @@ export function ContextualAIChat({
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(action.body(noteContent)),
+ body: JSON.stringify(action.body(noteContent, undefined, language)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
@@ -491,6 +527,33 @@ export function ContextualAIChat({
{t('ai.transformationsDesc')}
+ {/* Image actions — shown when note has images */}
+ {noteImages && noteImages.length > 0 && ACTION_IDS.filter(a => a.isImageAction).map(action => {
+ const Icon = action.icon
+ const loading = actionLoading === action.id
+ return (
+
+ )
+ })}
+
+ {/* Text actions — shown when note has sufficient text */}
{!noteContent || noteContent.trim().split(/\s+/).filter(Boolean).length < 5 ? (
@@ -499,7 +562,7 @@ export function ContextualAIChat({
) : (
- ACTION_IDS.map(action => {
+ ACTION_IDS.filter(a => !a.isImageAction).map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
return (
diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx
index edcb0db..0aead66 100644
--- a/memento-note/components/home-client.tsx
+++ b/memento-note/components/home-client.tsx
@@ -399,23 +399,26 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
{t('general.loading')}
) : (
<>
- setEditingNote({ note, readOnly })}
- onSizeChange={handleSizeChange}
- />
+ {!isTabs && (
+ setEditingNote({ note, readOnly })}
+ onSizeChange={handleSizeChange}
+ />
+ )}
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
!note.isPinned)}
+ notes={isTabs ? notes : notes.filter((note) => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
noteHistoryMode={noteHistoryMode}
onOpenHistory={handleOpenHistory}
onEnableHistory={handleEnableHistory}
+ onNoteCreated={handleNoteCreated}
/>
)}
diff --git a/memento-note/components/note-card.tsx b/memento-note/components/note-card.tsx
index 3fda3bf..e4987c8 100644
--- a/memento-note/components/note-card.tsx
+++ b/memento-note/components/note-card.tsx
@@ -299,6 +299,7 @@ export const NoteCard = memo(function NoteCard({
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
+ triggerRefresh()
if (!note.isPinned) {
toast.success(t('notes.pinned') || 'Note pinned')
@@ -312,6 +313,7 @@ export const NoteCard = memo(function NoteCard({
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
+ triggerRefresh()
})
}
@@ -516,7 +518,7 @@ export const NoteCard = memo(function NoteCard({
{/* Title */}
{optimisticNote.title && (
-
+
{optimisticNote.title}
)}
diff --git a/memento-note/components/note-editor.tsx b/memento-note/components/note-editor.tsx
index 1a5e2af..4447476 100644
--- a/memento-note/components/note-editor.tsx
+++ b/memento-note/components/note-editor.tsx
@@ -632,6 +632,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
{/* Title */}
@@ -723,6 +752,17 @@ export function NoteInput({
)}
+ {/* Image title suggestions */}
+ {!title && imageTitleSuggestions.length > 0 && (
+
+ { setTitle(s); setImageTitleSuggestions([]) }}
+ onDismiss={() => setImageTitleSuggestions([])}
+ />
+
+ )}
+
{/* Content area — scrolls internally when constrained by max-h */}
{type === 'text' ? (
@@ -746,6 +786,34 @@ export function NoteInput({
autoFocus
/>
)}
+ {/* Images — rendered between content and tag suggestions */}
+ {images.length > 0 && (
+
+ {images.map((img, idx) => (
+
+

+ {!title && !content.trim() && aiAssistantEnabled && (
+
+ )}
+
+
+ ))}
+
+ )}
) : (
+ {/* Images — rendered before checklist items */}
+ {images.length > 0 && (
+
+ {images.map((img, idx) => (
+
+

+ {!title && !content.trim() && aiAssistantEnabled && (
+
+ )}
+
+
+ ))}
+
+ )}
{checkItems.map((item) => (
@@ -788,22 +884,6 @@ export function NoteInput({
)}
- {/* Images */}
- {images.length > 0 && (
-
- {images.map((img, idx) => (
-
-

-
-
- ))}
-
- )}
-
{/* Link previews */}
{links.length > 0 && (
diff --git a/memento-note/components/notes-main-section.tsx b/memento-note/components/notes-main-section.tsx
index 7eb72b6..95ab56d 100644
--- a/memento-note/components/notes-main-section.tsx
+++ b/memento-note/components/notes-main-section.tsx
@@ -28,6 +28,7 @@ interface NotesMainSectionProps {
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise
+ onNoteCreated?: (note: Note) => void
}
export function NotesMainSection({
@@ -39,6 +40,7 @@ export function NotesMainSection({
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
+ onNoteCreated,
}: NotesMainSectionProps) {
if (viewMode === 'tabs') {
return (
@@ -50,6 +52,7 @@ export function NotesMainSection({
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
+ onNoteCreated={onNoteCreated}
/>
)
diff --git a/memento-note/components/notes-tabs-view.tsx b/memento-note/components/notes-tabs-view.tsx
index d4aad73..82c20a6 100644
--- a/memento-note/components/notes-tabs-view.tsx
+++ b/memento-note/components/notes-tabs-view.tsx
@@ -86,6 +86,7 @@ interface NotesTabsViewProps {
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise
+ onNoteCreated?: (note: Note) => void
}
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
@@ -256,6 +257,7 @@ function SortableNoteListItem({
{/* Row 2: title */}
+
{snippet}
)}
@@ -560,7 +562,6 @@ function NoteMetaSidebar({
icon={note.isPinned ? : }
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
onClick={() => onPinToggle(note)}
- disabled
/>
{/* Archive */}
@@ -593,10 +594,11 @@ export function NotesTabsView({
notes,
onEdit,
currentNotebookId,
-
+
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
+ onNoteCreated,
}: NotesTabsViewProps) {
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefreshOptional()
@@ -667,21 +669,18 @@ export function NotesTabsView({
return () => window.removeEventListener('label-deleted', handler)
}, [])
- // Sorted display items (does NOT affect persisted order)
+ // Sorted display items — pinned notes always float to the top
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 || '')
- }
+ const pinned = items.filter(n => n.isPinned)
+ const unpinned = items.filter(n => !n.isPinned)
+ const sortFn = (a: Note, b: Note) => {
+ if (sortOrder === 'date-desc') return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
+ 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
- })
+ }
+ return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)]
}, [items, sortOrder])
const sensors = useSensors(
@@ -727,6 +726,7 @@ export function NotesTabsView({
return [...pinned, newNote, ...unpinned]
})
setSelectedId(newNote.id)
+ onNoteCreated?.(newNote)
triggerRefresh()
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
@@ -739,6 +739,7 @@ export function NotesTabsView({
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
try {
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
+ triggerRefresh()
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))
diff --git a/memento-note/lib/ai/services/image-description.service.ts b/memento-note/lib/ai/services/image-description.service.ts
new file mode 100644
index 0000000..473b154
--- /dev/null
+++ b/memento-note/lib/ai/services/image-description.service.ts
@@ -0,0 +1,151 @@
+import { generateText } from 'ai'
+import { readFile } from 'fs/promises'
+import path from 'path'
+import { getChatProvider } from '../factory'
+import { getSystemConfig } from '@/lib/config'
+
+export interface ImageDescriptionResult {
+ descriptions: Array<{
+ index: number
+ description: string
+ }>
+ suggestions?: Array<{
+ title: string
+ confidence: number
+ reasoning?: string
+ }>
+ combinedSummary?: string
+}
+
+const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads')
+
+async function resolveImageAsBase64(imageUrl: string): Promise {
+ const localMatch = imageUrl.match(/\/uploads\/(.+)/)
+ if (localMatch) {
+ const filePath = path.join(UPLOAD_DIR, localMatch[1])
+ const buffer = await readFile(filePath)
+ const ext = path.extname(imageUrl).toLowerCase()
+ const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
+ return `data:${mime};base64,${buffer.toString('base64')}`
+ }
+
+ // Remote URL — fetch and convert
+ const res = await fetch(imageUrl)
+ if (!res.ok) throw new Error(`Failed to fetch image: ${imageUrl}`)
+ const contentType = res.headers.get('content-type') || 'image/jpeg'
+ const arrayBuffer = await res.arrayBuffer()
+ const base64 = Buffer.from(arrayBuffer).toString('base64')
+ return `data:${contentType};base64,${base64}`
+}
+
+export async function describeImages(
+ imageUrls: string[],
+ mode: 'description' | 'title',
+ language: string = 'fr'
+): Promise {
+ const config = await getSystemConfig()
+ const model = getChatProvider(config).getModel()
+
+ const isTitleMode = mode === 'title'
+ const langMap: Record = {
+ fr: 'French', en: 'English', fa: 'Persian', ar: 'Arabic',
+ es: 'Spanish', de: 'German', it: 'Italian', pt: 'Portuguese',
+ ru: 'Russian', zh: 'Chinese', ja: 'Japanese', ko: 'Korean',
+ hi: 'Hindi', nl: 'Dutch', pl: 'Polish',
+ }
+ const langName = langMap[language] || 'English'
+
+ // Resolve all images as base64 data URLs (same approach as the chat route)
+ const imageDataUrls = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url)))
+
+ if (isTitleMode) {
+ const prompt = imageUrls.length === 1
+ ? `Look carefully at this image and identify every concrete detail you can see: objects, people, animals, text, logos, colors, location/setting, actions, weather, time of day, style (photo/illustration/diagram), and any notable elements.
+
+Then generate 3 specific, descriptive titles (3-7 words each) in ${langName}. Each title must mention concrete elements actually visible in the image — do NOT use generic or abstract words like "beautiful scene", "interesting image", "visual content". Be precise and factual.
+
+Good example: "Red bicycle parked near a brick café wall"
+Bad example: "Beautiful urban scenery"
+
+Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
+ : `Look carefully at these images and identify every concrete detail visible: objects, people, animals, text, logos, colors, locations, actions, weather, styles, and any notable elements across all images.
+
+Then generate 3 specific, descriptive titles (3-7 words each) in ${langName} that capture what these images collectively show. Each title must mention concrete elements actually visible — do NOT use generic or abstract words like "beautiful scenes", "collection of images". Be precise and factual.
+
+Good example: "Red bicycle and brick café on a sunny street"
+Bad example: "Beautiful urban scenery collection"
+
+Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
+
+ const content: any[] = [{ type: 'text', text: prompt }]
+ for (const dataUrl of imageDataUrls) {
+ content.push({ type: 'image', image: dataUrl })
+ }
+
+ const { text } = await generateText({
+ model,
+ messages: [{ role: 'user', content }],
+ })
+
+ // Parse JSON response
+ const jsonMatch = text.match(/\[[\s\S]*\]/)
+ const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : []
+
+ const suggestions = parsed.map((t: any) => ({
+ title: t.title?.trim().replace(/^["']|["']$/g, '') || '',
+ confidence: Math.round((t.confidence || 0.5) * 100),
+ reasoning: undefined,
+ })).filter((s: any) => s.title)
+
+ return {
+ descriptions: [],
+ suggestions,
+ }
+ }
+
+ // Single image description
+ if (imageUrls.length === 1) {
+ const content: any[] = [
+ { type: 'text', text: `Describe this image in detail in ${langName}. Be specific about what you see: objects, people, colors, setting, mood, text visible. Keep it under 100 words.` },
+ { type: 'image', image: imageDataUrls[0] },
+ ]
+
+ const { text } = await generateText({
+ model,
+ messages: [{ role: 'user', content }],
+ })
+
+ return {
+ descriptions: [{ index: 0, description: text.trim() }],
+ }
+ }
+
+ // Multiple images: describe each individually
+ const descriptions: Array<{ index: number; description: string }> = []
+
+ for (let i = 0; i < imageDataUrls.length; i++) {
+ const content: any[] = [
+ { type: 'text', text: `Describe this image (image ${i + 1} of ${imageDataUrls.length}) in ${langName}. Be specific: objects, people, colors, setting, text visible. Under 80 words.` },
+ { type: 'image', image: imageDataUrls[i] },
+ ]
+
+ const { text } = await generateText({
+ model,
+ messages: [{ role: 'user', content }],
+ })
+
+ descriptions.push({ index: i, description: text.trim() })
+ }
+
+ // Combined summary
+ const allDescriptions = descriptions.map(d => d.description).join('\n')
+ const { text: summary } = await generateText({
+ model,
+ prompt: `Based on these individual image descriptions, write a brief (1-2 sentence) overall summary in ${langName} of what these images collectively show:\n\n${allDescriptions}`,
+ })
+
+ return {
+ descriptions,
+ combinedSummary: summary.trim(),
+ }
+}
diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json
index ac40c8d..fdddfca 100644
--- a/memento-note/locales/en.json
+++ b/memento-note/locales/en.json
@@ -39,7 +39,8 @@
"newNoteTabsHint": "Create note in this notebook",
"noLabelsInNotebook": "No labels in this notebook yet",
"archive": "Archive",
- "trash": "Trash"
+ "trash": "Trash",
+ "clearFilter": "Remove filter"
},
"notes": {
"title": "Notes",
@@ -186,7 +187,21 @@
"sortDateDesc": "Date (newest)",
"sortDateAsc": "Date (oldest)",
"sortTitleAsc": "Title A → Z",
- "sortTitleDesc": "Title Z → A"
+ "sortTitleDesc": "Title Z → A",
+ "suggestTitle": "AI title",
+ "generateTitleFromImage": "Generate title from image",
+ "titleGenerated": "Title generated",
+ "content": "Content",
+ "restore": "Restore",
+ "createFailed": "Failed to create note",
+ "updateFailed": "Failed to update note",
+ "archived": "Note archived",
+ "archiveFailed": "Failed to archive",
+ "sort": "Sort",
+ "confirmDeleteTitle": "Delete note",
+ "leftShare": "Share removed",
+ "dismissed": "Note dismissed from recent",
+ "generalNotes": "General Notes"
},
"pagination": {
"previous": "←",
@@ -390,11 +405,14 @@
"transformationsDesc": "Transformations — applied directly to the note",
"writeMinWordsAction": "Write at least 5 words to activate AI actions.",
"processingAction": "Processing...",
+ "noImagesError": "No images in this note",
+ "overview": "Overview",
"action": {
"clarify": "Clarify",
"shorten": "Shorten",
"improve": "Improve",
- "toMarkdown": "To Markdown"
+ "toMarkdown": "To Markdown",
+ "describeImages": "Describe images"
},
"openAssistant": "Open AI Assistant",
"poweredByMomento": "Powered by Momento AI",
@@ -409,7 +427,9 @@
"historyTab": "History",
"insightsTab": "Insights",
"aiCopilot": "AI Copilot",
- "suggestTitle": "AI title suggestion"
+ "suggestTitle": "AI title suggestion",
+ "generateTitleFromImage": "Generate title from image",
+ "titleGenerated": "Title generated from image"
},
"titleSuggestions": {
"available": "Title suggestions",
@@ -750,7 +770,8 @@
"pdfGeneratedOn": "Generated on:",
"confidence": "confidence",
"savingReminder": "Failed to save reminder",
- "removingReminder": "Failed to remove reminder"
+ "removingReminder": "Failed to remove reminder",
+ "generatingDescription": "Please wait..."
},
"notebookSuggestion": {
"title": "Move to {name}?",
@@ -1143,7 +1164,8 @@
"notesViewDescription": "Choose how notes are shown on home and in notebooks.",
"notesViewLabel": "Notes layout",
"notesViewTabs": "Tabs (OneNote-style)",
- "notesViewMasonry": "Cards (grid)"
+ "notesViewMasonry": "Cards (grid)",
+ "selectTheme": "Select theme"
},
"generalSettings": {
"title": "General Settings",
@@ -1567,7 +1589,8 @@
"createFailed": "Creation failed",
"deleteSpace": "Delete space",
"deleted": "Space deleted",
- "deleteError": "Error deleting"
+ "deleteError": "Error deleting",
+ "rename": "Rename"
},
"lab": {
"initializing": "Initializing workspace",
diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json
index 6941557..599a2f9 100644
--- a/memento-note/locales/fr.json
+++ b/memento-note/locales/fr.json
@@ -396,11 +396,14 @@
"transformationsDesc": "Transformations — appliquées directement à la note",
"writeMinWordsAction": "Écrivez au moins 5 mots pour activer les actions IA.",
"processingAction": "Traitement en cours...",
+ "noImagesError": "Aucune image dans cette note",
+ "overview": "Résumé",
"action": {
"clarify": "Clarifier",
"shorten": "Raccourcir",
"improve": "Améliorer",
- "toMarkdown": "Convertir en Markdown"
+ "toMarkdown": "Convertir en Markdown",
+ "describeImages": "Décrire les images"
},
"openAssistant": "Ouvrir l'Assistant IA",
"poweredByMomento": "Propulsé par Momento AI",
@@ -415,7 +418,9 @@
"historyTab": "Historique",
"insightsTab": "Insights",
"aiCopilot": "Copilote IA",
- "suggestTitle": "Suggestion de titre IA"
+ "suggestTitle": "Suggestion de titre IA",
+ "generateTitleFromImage": "Générer un titre à partir de l'image",
+ "titleGenerated": "Titre généré à partir de l'image"
},
"aiSettings": {
"description": "Configurez vos fonctionnalités IA et préférences",
@@ -445,6 +450,7 @@
"notesViewLabel": "Affichage des notes",
"notesViewTabs": "Onglets (type OneNote)",
"notesViewMasonry": "Cartes (grille)",
+ "selectTheme": "Sélectionner le thème",
"title": "Apparence"
},
"auth": {
@@ -852,7 +858,8 @@
"pdfGeneratedOn": "Généré le :",
"confidence": "confiance",
"savingReminder": "Erreur lors de la sauvegarde du rappel",
- "removingReminder": "Erreur lors de la suppression du rappel"
+ "removingReminder": "Erreur lors de la suppression du rappel",
+ "generatingDescription": "Veuillez patienter..."
},
"notebookSuggestion": {
"description": "Cette note semble appartenir à ce carnet",
@@ -1014,7 +1021,21 @@
"sortDateDesc": "Date (récent)",
"sortDateAsc": "Date (ancien)",
"sortTitleAsc": "Titre A → Z",
- "sortTitleDesc": "Titre Z → A"
+ "sortTitleDesc": "Titre Z → A",
+ "suggestTitle": "Titre IA",
+ "generateTitleFromImage": "Générer un titre à partir de l'image",
+ "titleGenerated": "Titre généré",
+ "content": "Contenu",
+ "restore": "Restaurer",
+ "createFailed": "Impossible de créer la note",
+ "updateFailed": "Mise à jour échouée",
+ "archived": "Note archivée",
+ "archiveFailed": "Échec de l'archivage",
+ "sort": "Trier",
+ "confirmDeleteTitle": "Supprimer la note",
+ "leftShare": "Partage retiré",
+ "dismissed": "Note retirée des récentes",
+ "generalNotes": "Notes générales"
},
"pagination": {
"next": "→",
@@ -1178,7 +1199,8 @@
"noLabelsInNotebook": "Aucune étiquette dans ce carnet",
"notes": "Notes",
"reminders": "Rappels",
- "trash": "Corbeille"
+ "trash": "Corbeille",
+ "clearFilter": "Retirer le filtre"
},
"support": {
"aiApiCosts": "Coûts API IA :",
@@ -1567,7 +1589,8 @@
"createFailed": "Échec de la création",
"deleteSpace": "Supprimer l'espace",
"deleted": "Espace supprimé",
- "deleteError": "Erreur lors de la suppression"
+ "deleteError": "Erreur lors de la suppression",
+ "rename": "Renommer"
},
"lab": {
"initializing": "Initialisation de l'espace de travail",