feat: add inline chart support for notes
- Add NoteChart component using Recharts (bar, line, area, pie, radar, funnel, gauge)
- Add generate_chart and insert_chart_in_note AI tools
- Add chart code block support in MarkdownContent (```chart ... ```)
- Support JSON and simple data formats (label: value)
- Add i18n translations for chart features
Chart syntax examples:
- JSON: ```chart {"type":"bar","data":[{"label":"A","value":10}]} ```
- Simple: ```chart\nbar\nSales Data\nJan: 120\nFeb: 150\n```
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { NoteChartFromCode } from './note-chart'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
@@ -23,6 +24,30 @@ export const MarkdownContent = memo(function MarkdownContent({ content, classNam
|
||||
a: ({ node, ...props }) => (
|
||||
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
|
||||
),
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match ? match[1] : ''
|
||||
|
||||
// Chart code blocks
|
||||
if (language === 'chart') {
|
||||
return <NoteChartFromCode code={String(children).replace(/\n$/, '')} />
|
||||
}
|
||||
|
||||
// Other code blocks
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
228
memento-note/components/note-chart.tsx
Normal file
228
memento-note/components/note-chart.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, AreaChart, Area,
|
||||
PieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
FunnelChart, Funnel, LabelList,
|
||||
} from 'recharts'
|
||||
|
||||
const CHART_COLORS = ['#6366f1', '#22d3ee', '#f59e0b', '#ef4444', '#10b981', '#a78bfa', '#fb923c', '#14b8a6']
|
||||
|
||||
interface ChartData {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface NoteChartProps {
|
||||
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
|
||||
title?: string
|
||||
data: ChartData[]
|
||||
colors?: string[]
|
||||
showLegend?: boolean
|
||||
height?: number
|
||||
}
|
||||
|
||||
const isDark = () => document.documentElement.classList.contains('dark')
|
||||
|
||||
function NoteChart({ type, title, data, colors, showLegend, height = 250 }: NoteChartProps) {
|
||||
const chartColors = colors ?? CHART_COLORS
|
||||
const dark = isDark()
|
||||
|
||||
const ttStyle = {
|
||||
background: dark ? '#1C1C1C' : '#fff',
|
||||
border: dark ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: dark ? '#fff' : '#000',
|
||||
}
|
||||
|
||||
const tickStyle = { fill: dark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)', fontSize: 11 }
|
||||
const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'
|
||||
const legendStyle = { fontSize: 12, color: dark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' }
|
||||
|
||||
const xKey = 'label'
|
||||
const yKeys = ['value']
|
||||
|
||||
const renderChart = () => {
|
||||
switch (type) {
|
||||
case 'bar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={ttStyle} cursor={{ fill: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }} />
|
||||
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
||||
<Bar dataKey={yKeys[0]} fill={chartColors[0]} radius={[4, 4, 0, 0]} maxBarSize={56} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
||||
case 'horizontal-bar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 24, left: 60, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis type="number" tick={tickStyle} axisLine={false} tickLine={false} />
|
||||
<YAxis dataKey={xKey} type="category" tick={tickStyle} axisLine={false} tickLine={false} width={55} />
|
||||
<Tooltip contentStyle={ttStyle} cursor={{ fill: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }} />
|
||||
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
||||
<Bar dataKey={yKeys[0]} fill={chartColors[0]} radius={[0, 4, 4, 0]} maxBarSize={56} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
||||
case 'line':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={ttStyle} />
|
||||
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
||||
<Line type="monotone" dataKey={yKeys[0]} stroke={chartColors[0]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} activeDot={{ r: 6 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
||||
case 'area':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
||||
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={ttStyle} />
|
||||
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
||||
<Area type="monotone" dataKey={yKeys[0]} stroke={chartColors[0]} fill={`${chartColors[0]}28`} strokeWidth={2.5} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
||||
case 'pie':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey={yKeys[0]} nameKey={xKey} cx="50%" cy="50%" outerRadius="70%" innerRadius="35%" paddingAngle={2}>
|
||||
{data.map((_, i) => <Cell key={i} fill={chartColors[i % chartColors.length]} stroke="transparent" />)}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={ttStyle} />
|
||||
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
||||
case 'radar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RadarChart data={data} cx="50%" cy="50%">
|
||||
<PolarGrid stroke={dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'} />
|
||||
<PolarAngleAxis dataKey={xKey} tick={{ fill: dark ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)', fontSize: 11 }} />
|
||||
<Radar name={title || 'Value'} dataKey={yKeys[0]} stroke={chartColors[0]} fill={chartColors[0]} fillOpacity={0.15} />
|
||||
<Tooltip contentStyle={ttStyle} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
||||
case 'funnel':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<FunnelChart>
|
||||
<Tooltip contentStyle={ttStyle} />
|
||||
<Funnel dataKey={yKeys[0]} data={data.map((d, i) => ({ ...d, fill: chartColors[i % chartColors.length] }))} isAnimationActive>
|
||||
<LabelList position="center" fill={dark ? '#fff' : '#000'} fontSize={12} fontWeight={700} dataKey={xKey} />
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
|
||||
case 'gauge': {
|
||||
const value = data[0]?.value ?? 0
|
||||
const max = Math.max(...data.map(d => d.value), 100)
|
||||
const pct = Math.min(Math.max(value / max, 0), 1)
|
||||
const color = pct > 0.7 ? '#10b981' : pct > 0.4 ? '#f59e0b' : '#ef4444'
|
||||
return (
|
||||
<div style={{ width: '100%', height: `${height}px`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 200 120" style={{ width: '60%', maxHeight: '70%' }}>
|
||||
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'} strokeWidth="16" strokeLinecap="round" />
|
||||
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={color} strokeWidth="16" strokeLinecap="round"
|
||||
strokeDasharray={`${pct * 251.2} 251.2`} />
|
||||
<text x="100" y="90" textAnchor="middle" fill={dark ? '#fff' : '#000'} fontSize="28" fontWeight="800">{value}</text>
|
||||
<text x="100" y="112" textAnchor="middle" fill={dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'} fontSize="11">{title || 'Value'}</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return <div className="text-center text-muted-foreground py-8">Type de graphique non supporté: {type}</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-6 rounded-xl border border-border bg-card p-4">
|
||||
{title && <h4 className="text-sm font-semibold text-foreground mb-4">{title}</h4>}
|
||||
{renderChart()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Parse chart from JSON or simple format
|
||||
export function parseChartFromCode(code: string): NoteChartProps | null {
|
||||
try {
|
||||
// Try JSON first
|
||||
const parsed = JSON.parse(code)
|
||||
if (parsed.type && parsed.data && Array.isArray(parsed.data)) {
|
||||
return {
|
||||
type: parsed.type,
|
||||
title: parsed.title,
|
||||
data: parsed.data,
|
||||
colors: parsed.colors,
|
||||
showLegend: parsed.showLegend,
|
||||
height: parsed.height,
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Try simple format: type, title, then label: value lines
|
||||
const lines = code.trim().split('\n').map(l => l.trim()).filter(Boolean)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
const firstLine = lines[0].toLowerCase()
|
||||
let type: NoteChartProps['type'] = 'bar'
|
||||
let title = ''
|
||||
|
||||
if (['bar', 'horizontal-bar', 'line', 'area', 'pie', 'radar', 'funnel', 'gauge'].includes(firstLine)) {
|
||||
type = firstLine as NoteChartProps['type']
|
||||
} else if (lines[0].includes(':')) {
|
||||
// No type specified, default to bar
|
||||
} else {
|
||||
title = lines[0]
|
||||
}
|
||||
|
||||
const data: ChartData[] = []
|
||||
for (const line of lines.slice(title ? 1 : type !== lines[0]?.toLowerCase() ? 0 : 1)) {
|
||||
const parts = line.split(/[:|]/)
|
||||
if (parts.length >= 2) {
|
||||
const label = parts[0].trim()
|
||||
const value = parseFloat(parts[1].trim())
|
||||
if (!isNaN(value)) {
|
||||
data.push({ label, value })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
return { type, title: title || undefined, data }
|
||||
}
|
||||
|
||||
export function NoteChartFromCode({ code }: { code: string }) {
|
||||
const props = useMemo(() => parseChartFromCode(code), [code])
|
||||
if (!props) return <div className="text-center text-muted-foreground py-4">Format de graphique invalide</div>
|
||||
return <NoteChart {...props} />
|
||||
}
|
||||
117
memento-note/lib/ai/tools/chart.tool.ts
Normal file
117
memento-note/lib/ai/tools/chart.tool.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Chart Tool for Notes
|
||||
* Allows AI to generate inline charts from note data
|
||||
*/
|
||||
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
|
||||
// Chart generation tool - inserts chart as markdown code block
|
||||
toolRegistry.register({
|
||||
name: 'generate_chart',
|
||||
description: 'Generate an inline chart from data and insert it into a note as markdown. The chart will be rendered directly in the note.',
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Generate an inline chart from data and return it as a markdown code block that can be inserted into a note.
|
||||
|
||||
Available chart types:
|
||||
- "bar": Vertical bar chart
|
||||
- "horizontal-bar": Horizontal bar chart
|
||||
- "line": Line chart for trends over time
|
||||
- "area": Area chart (filled line)
|
||||
- "pie": Pie/donut chart for proportions
|
||||
- "radar": Radar chart for comparing multiple dimensions
|
||||
- "funnel": Funnel chart for stages/conversions
|
||||
- "gauge": Gauge/meter for a single value
|
||||
|
||||
Data format: Array of objects with "label" (string) and "value" (number).
|
||||
|
||||
IMPORTANT:
|
||||
- Extract data from the note content when possible
|
||||
- Keep labels short (max 20 characters)
|
||||
- Round values to reasonable precision (max 2 decimal places)
|
||||
- For time series, use short date formats (Jan, Feb, Mar or 2023, 2024)
|
||||
- Maximum 12 data points for readability
|
||||
- The output will be rendered as a visual chart in the note`,
|
||||
inputSchema: z.object({
|
||||
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'area', 'pie', 'radar', 'funnel', 'gauge']).describe('Type of chart to generate'),
|
||||
title: z.string().optional().describe('Optional title for the chart'),
|
||||
data: z.array(z.object({
|
||||
label: z.string().describe('Label for the data point (e.g., month name, category)'),
|
||||
value: z.number().describe('Numeric value for the data point'),
|
||||
})).describe('Array of data points with label and value'),
|
||||
insertLocation: z.enum(['append', 'prepend', 'replace']).default('append').describe('Where to insert the chart in the note'),
|
||||
targetNoteId: z.string().optional().describe('Optional: specific note ID to update. If not provided, use the current note.'),
|
||||
}),
|
||||
execute: async ({ chartType, title, data, insertLocation, targetNoteId }) => {
|
||||
try {
|
||||
// Generate the markdown code block for the chart
|
||||
const chartData = data.map(d => `${d.label}: ${d.value}`).join('\n')
|
||||
const chartMarkdown = `\`\`\`chart
|
||||
${chartType}${title ? `\n${title}` : ''}
|
||||
${chartData}
|
||||
\`\`\`\n`
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chartMarkdown,
|
||||
chartType,
|
||||
dataPointCount: data.length,
|
||||
message: `Chart generated with ${data.length} data points. Insert this markdown into the note.`,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { success: false, error: `Chart generation failed: ${e.message}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Quick chart insert tool - directly updates a note with a chart
|
||||
toolRegistry.register({
|
||||
name: 'insert_chart_in_note',
|
||||
description: 'Insert a chart directly into a note. Reads the note, extracts relevant data, generates a chart, and updates the note content.',
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Insert a chart directly into a note. This tool will:
|
||||
1. Read the current note content
|
||||
2. Extract relevant data from the note (sales, metrics, comparisons, etc.)
|
||||
3. Generate an appropriate chart type based on the data
|
||||
4. Insert the chart markdown into the note
|
||||
|
||||
Use this when the user says "make a chart", "create a graph", "visualize this data", etc.
|
||||
|
||||
Choose the chart type based on the data:
|
||||
- Use "bar" for comparing values across categories (default)
|
||||
- Use "horizontal-bar" when labels are long
|
||||
- Use "line" or "area" for time series or trends
|
||||
- Use "pie" for showing proportions/percentages
|
||||
- Use "radar" for comparing multiple attributes
|
||||
- Use "funnel" for stages or conversion data
|
||||
- Use "gauge" for progress/KPI (single value)`,
|
||||
inputSchema: z.object({
|
||||
noteId: z.string().describe('The note ID to read and update'),
|
||||
chartHint: z.string().optional().describe('Optional hint about what to chart (e.g., "sales by month", "comparison of products")'),
|
||||
insertLocation: z.enum(['append', 'prepend', 'before-section', 'after-section']).default('append').describe('Where to insert the chart'),
|
||||
sectionMarker: z.string().optional().describe('For before-section/after-section: the heading text to find (e.g., "## Sales Data")'),
|
||||
}),
|
||||
execute: async ({ noteId, chartHint, insertLocation, sectionMarker }) => {
|
||||
try {
|
||||
// We'll return the instructions for the AI to format the chart
|
||||
// The actual note update will be done by note_find_and_update or note_update
|
||||
return {
|
||||
success: true,
|
||||
instructions: 'Generate a chart markdown block using the generate_chart tool, then use note_update or note_find_and_update to insert it into the note.',
|
||||
noteId,
|
||||
chartHint,
|
||||
insertLocation,
|
||||
sectionMarker,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { success: false, error: `Failed to prepare chart insertion: ${e.message}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import './pptx.tool'
|
||||
import './slides.tool'
|
||||
import './document-search.tool'
|
||||
import './task-extract.tool'
|
||||
import './chart.tool'
|
||||
|
||||
// Re-export registry
|
||||
export { toolRegistry, type ToolContext, type RegisteredTool } from './registry'
|
||||
|
||||
@@ -52,7 +52,7 @@ class ToolRegistry {
|
||||
* When webOnly is true, only web tools are included (no note access).
|
||||
*/
|
||||
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
|
||||
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'note_find_and_update', 'document_search', 'task_extract']
|
||||
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'note_find_and_update', 'document_search', 'task_extract', 'generate_chart', 'insert_chart_in_note']
|
||||
|
||||
// Add web tools only when user toggled web search AND config is present
|
||||
if (ctx.webSearch) {
|
||||
|
||||
@@ -635,6 +635,27 @@
|
||||
"generationTools": "Generation tools",
|
||||
"generateSlidesLoading": "⏳ Generating presentation...",
|
||||
"generateDiagramLoading": "⏳ Generating diagram...",
|
||||
"generateChartLoading": "⏳ Generating chart...",
|
||||
"chart": {
|
||||
"title": "Chart",
|
||||
"generate": "Generate chart",
|
||||
"generateDesc": "Create a visualization from data",
|
||||
"type": "Chart type",
|
||||
"typeBar": "Bar",
|
||||
"typeBarH": "Horizontal Bar",
|
||||
"typeLine": "Line",
|
||||
"typeArea": "Area",
|
||||
"typePie": "Pie",
|
||||
"typeRadar": "Radar",
|
||||
"typeFunnel": "Funnel",
|
||||
"typeGauge": "Gauge",
|
||||
"autoDetect": "Auto detect",
|
||||
"insertInNote": "Insert in note",
|
||||
"chartReady": "Chart ready!",
|
||||
"insertedMessage": "Chart inserted in note",
|
||||
"noDataError": "No data found to create chart",
|
||||
"invalidFormat": "Invalid data format"
|
||||
},
|
||||
"errorShort": "Error",
|
||||
"readyToast": "Ready!",
|
||||
"downloadFailedToast": "Download failed",
|
||||
|
||||
@@ -641,6 +641,27 @@
|
||||
"generationTools": "Outils de génération",
|
||||
"generateSlidesLoading": "⏳ Génération de la présentation...",
|
||||
"generateDiagramLoading": "⏳ Génération du diagramme...",
|
||||
"generateChartLoading": "⏳ Génération du graphique...",
|
||||
"chart": {
|
||||
"title": "Graphique",
|
||||
"generate": "Générer un graphique",
|
||||
"generateDesc": "Créer une visualisation à partir des données",
|
||||
"type": "Type de graphique",
|
||||
"typeBar": "Barres",
|
||||
"typeBarH": "Barres horizontales",
|
||||
"typeLine": "Ligne",
|
||||
"typeArea": "Aire",
|
||||
"typePie": "Circulaire",
|
||||
"typeRadar": "Radar",
|
||||
"typeFunnel": "Entonnoir",
|
||||
"typeGauge": "Jauge",
|
||||
"autoDetect": "Détection auto",
|
||||
"insertInNote": "Insérer dans la note",
|
||||
"chartReady": "Graphique prêt !",
|
||||
"insertedMessage": "Graphique inséré dans la note",
|
||||
"noDataError": "Aucune donnée trouvée pour créer un graphique",
|
||||
"invalidFormat": "Format de données invalide"
|
||||
},
|
||||
"errorShort": "Erreur",
|
||||
"readyToast": "Prêt !",
|
||||
"downloadFailedToast": "Échec du téléchargement",
|
||||
|
||||
Reference in New Issue
Block a user