feat: Organisateur IA — analyse carnet + tags + doublons + regroupements
- Service notebook-organizer.service.ts : analyse IA des notes - Endpoint /api/ai/organize-notebook - Dialog avec 4 sections : 1. Résumé de l'état du carnet 2. Tags suggérés (cliquable pour appliquer) 3. Regroupements logiques par catégorie 4. Détection de doublons avec explication - Bouton 'Organiser' (Wand2) dans la barre du carnet - i18n FR/EN complet - Complète les 3 scénarios : Prof (wizard+exercices), Étudiant (wizard+planning), Ingénieur (organisateur)
This commit is contained in:
57
memento-note/app/api/ai/organize-notebook/route.ts
Normal file
57
memento-note/app/api/ai/organize-notebook/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
|
||||||
|
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notebookId } = await request.json()
|
||||||
|
if (!notebookId) {
|
||||||
|
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QuotaExceededError) {
|
||||||
|
const isTierLocked = err.currentQuota === 0
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' },
|
||||||
|
{ status: 402 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: { notebookId, trashedAt: null, userId: session.user.id },
|
||||||
|
select: { id: true, title: true, content: true },
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (notes.length < 2) {
|
||||||
|
return NextResponse.json({ error: 'Need at least 2 notes to organize' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesForAnalysis = notes.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title || 'Sans titre',
|
||||||
|
contentPreview: n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await notebookOrganizerService.analyze(notesForAnalysis)
|
||||||
|
|
||||||
|
incrementUsageAsync(session.user.id, 'reformulate')
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Notebook Organizer] Error:', error)
|
||||||
|
return NextResponse.json({ error: error.message || 'Failed to organize notebook' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
|
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays } from 'lucide-react'
|
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2 } from 'lucide-react'
|
||||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||||
@@ -31,6 +31,7 @@ import { useEditorUI } from '@/context/editor-ui-context'
|
|||||||
import { NoteHistoryModal } from '@/components/note-history-modal'
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||||
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
|
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
|
||||||
import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
|
import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
|
||||||
|
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ export function HomeClient({
|
|||||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||||
|
const [showOrganizer, setShowOrganizer] = useState(false)
|
||||||
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
const notebookFilter = searchParams.get('notebook')
|
||||||
const schemaHook = useNotebookSchema(notebookFilter)
|
const schemaHook = useNotebookSchema(notebookFilter)
|
||||||
@@ -1051,6 +1053,19 @@ export function HomeClient({
|
|||||||
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
|
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{searchParams.get('notebook') && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOrganizer(true)}
|
||||||
|
disabled={!initialSettings.aiAssistantEnabled}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
|
||||||
|
initialSettings.aiAssistantEnabled ? "text-brand-accent hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wand2 size={16} />
|
||||||
|
<span>{t('wizard.organizer') || 'Organiser'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
|
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
|
||||||
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||||
@@ -1292,6 +1307,14 @@ export function HomeClient({
|
|||||||
onClose={() => setShowStudyPlanner(false)}
|
onClose={() => setShowStudyPlanner(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showOrganizer && currentNotebook && (
|
||||||
|
<NotebookOrganizerDialog
|
||||||
|
notebookId={currentNotebook.id}
|
||||||
|
notebookName={currentNotebook.name}
|
||||||
|
onClose={() => setShowOrganizer(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
196
memento-note/components/wizard/notebook-organizer-dialog.tsx
Normal file
196
memento-note/components/wizard/notebook-organizer-dialog.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Sparkles, Loader2, X, Tag, Copy, FolderTree, AlertTriangle, Check } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface SuggestedTag { name: string; noteIds: string[] }
|
||||||
|
interface Duplicate { note1Id: string; note1Title: string; note2Id: string; note2Title: string; reason: string }
|
||||||
|
interface Category { name: string; noteIds: string[]; noteTitles: string[] }
|
||||||
|
interface OrgResult {
|
||||||
|
suggestedTags: SuggestedTag[]
|
||||||
|
duplicates: Duplicate[]
|
||||||
|
categories: Category[]
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotebookOrganizerDialog({
|
||||||
|
notebookId,
|
||||||
|
notebookName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
notebookId: string
|
||||||
|
notebookName: string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [result, setResult] = useState<OrgResult | null>(null)
|
||||||
|
const [appliedTags, setAppliedTags] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/organize-notebook', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ notebookId }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||||
|
} else {
|
||||||
|
setResult(data)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Erreur')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTag = async (tagName: string, noteIds: string[]) => {
|
||||||
|
try {
|
||||||
|
for (const noteId of noteIds) {
|
||||||
|
await fetch(`/api/notes/${noteId}/properties`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tags: [tagName] }),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
setAppliedTags(prev => new Set(prev).add(tagName))
|
||||||
|
toast.success(t('wizard.tagApplied') || `Tag "${tagName}" appliqué à ${noteIds.length} notes`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Erreur')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" dir="auto" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-2xl max-h-[85vh] rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-brand-accent" />
|
||||||
|
<h2 className="text-base font-semibold">{t('wizard.organizer') || 'Organiser avec l\'IA'}</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto flex-1">
|
||||||
|
{!result && !loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('wizard.organizerDesc') || `L'IA va analyser toutes les notes du carnet "${notebookName}" et proposer : tags, regroupements, et détection de doublons.`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{t('wizard.analyze') || 'Analyser le carnet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-brand-accent" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('wizard.organizing') || 'Analyse des notes...'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="rounded-lg bg-muted/30 p-4">
|
||||||
|
<p className="text-sm leading-relaxed">{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{result.suggestedTags.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Tag size={12} /> {t('wizard.suggestedTags') || 'Tags suggérés'}
|
||||||
|
</h3>
|
||||||
|
{result.suggestedTags.map((tag, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 rounded-lg border border-border/50 hover:bg-muted/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent text-xs font-medium">
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{tag.noteIds.length} notes</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => applyTag(tag.name, tag.noteIds)}
|
||||||
|
disabled={appliedTags.has(tag.name)}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-[10px] rounded-md font-medium transition-colors',
|
||||||
|
appliedTags.has(tag.name)
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400'
|
||||||
|
: 'bg-foreground/5 hover:bg-foreground/10 text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> Appliqué</>) : (t('wizard.apply') || 'Appliquer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
{result.categories.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<FolderTree size={12} /> {t('wizard.categories') || 'Regroupements suggérés'}
|
||||||
|
</h3>
|
||||||
|
{result.categories.map((cat, i) => (
|
||||||
|
<div key={i} className="p-3 rounded-lg border border-border/50">
|
||||||
|
<div className="text-sm font-medium mb-1">{cat.name}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{cat.noteTitles.map((title, j) => (
|
||||||
|
<span key={j} className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||||
|
{title.length > 35 ? title.slice(0, 35) + '...' : title}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duplicates */}
|
||||||
|
{result.duplicates.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<AlertTriangle size={12} className="text-amber-500" /> {t('wizard.duplicates') || 'Doublons détectés'}
|
||||||
|
</h3>
|
||||||
|
{result.duplicates.map((dup, i) => (
|
||||||
|
<div key={i} className="p-3 rounded-lg border border-amber-200 dark:border-amber-900/50 bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Copy size={12} className="text-amber-500" />
|
||||||
|
<span className="text-xs font-medium">{dup.note1Title}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">≈</span>
|
||||||
|
<span className="text-xs font-medium">{dup.note2Title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground italic">{dup.reason}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.suggestedTags.length === 0 && result.duplicates.length === 0 && result.categories.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
{t('wizard.noSuggestions') || 'Aucune suggestion — le carnet semble bien organisé.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
memento-note/lib/ai/services/notebook-organizer.service.ts
Normal file
104
memento-note/lib/ai/services/notebook-organizer.service.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { getChatProvider } from '../factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
|
export interface OrganizationSuggestion {
|
||||||
|
suggestedTags: Array<{ name: string; noteIds: string[] }>
|
||||||
|
duplicates: Array<{ note1Id: string; note1Title: string; note2Id: string; note2Title: string; reason: string }>
|
||||||
|
categories: Array<{ name: string; noteIds: string[]; noteTitles: string[] }>
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotebookOrganizerService {
|
||||||
|
async analyze(
|
||||||
|
notes: Array<{ id: string; title: string; contentPreview: string }>,
|
||||||
|
language?: string
|
||||||
|
): Promise<OrganizationSuggestion> {
|
||||||
|
const lang = language || 'fr'
|
||||||
|
const langName = lang === 'fr' ? 'français' : lang === 'fa' ? 'فارسی' : 'English'
|
||||||
|
|
||||||
|
const notesList = notes.map(n =>
|
||||||
|
`ID: ${n.id}\nTitre: ${n.title}\nAperçu: ${n.contentPreview.slice(0, 200)}`
|
||||||
|
).join('\n---\n')
|
||||||
|
|
||||||
|
const prompt = `Tu es un expert en organisation de connaissances. Analyse ces notes et propose une organisation.
|
||||||
|
|
||||||
|
LANGUE : ${langName}
|
||||||
|
|
||||||
|
NOTES DANS LE CARNET :
|
||||||
|
${notesList}
|
||||||
|
|
||||||
|
Analyse les notes et retourne :
|
||||||
|
|
||||||
|
1. **suggestedTags** — Tags pertinents à appliquer (3-8 tags). Pour chaque tag, liste les IDs des notes qui correspondent.
|
||||||
|
2. **duplicates** — Notes qui se chevauchent ou répètent la même information. Pour chaque paire, explique pourquoi.
|
||||||
|
3. **categories** — Regroupements logiques (2-5 catégories). Pour chaque catégorie, liste les IDs et titres des notes.
|
||||||
|
4. **summary** — Un résumé court de l'état du carnet et des recommandations.
|
||||||
|
|
||||||
|
FORMAT JSON UNIQUEMENT :
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"suggestedTags": [
|
||||||
|
{ "name": "nom-du-tag", "noteIds": ["id1", "id2"] }
|
||||||
|
],
|
||||||
|
"duplicates": [
|
||||||
|
{ "note1Id": "id1", "note1Title": "...", "note2Id": "id2", "note2Title": "...", "reason": "..." }
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
{ "name": "Nom catégorie", "noteIds": ["id1"], "noteTitles": ["Titre"] }
|
||||||
|
],
|
||||||
|
"summary": "Résumé court..."
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Si aucune note en double n'est trouvée, retourne un array vide pour "duplicates".`
|
||||||
|
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
const raw = await provider.generateText(prompt)
|
||||||
|
|
||||||
|
return this.parseResponse(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseResponse(raw: string): OrganizationSuggestion {
|
||||||
|
const jsonMatch = raw.match(/```json\s*([\s\S]+?)\s*```/)
|
||||||
|
let jsonStr = jsonMatch ? jsonMatch[1] : raw
|
||||||
|
|
||||||
|
const start = jsonStr.indexOf('{')
|
||||||
|
const end = jsonStr.lastIndexOf('}')
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
jsonStr = jsonStr.slice(start, end + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr)
|
||||||
|
return {
|
||||||
|
suggestedTags: (parsed.suggestedTags || []).map((t: any) => ({
|
||||||
|
name: String(t.name || ''),
|
||||||
|
noteIds: Array.isArray(t.noteIds) ? t.noteIds.map(String) : [],
|
||||||
|
})),
|
||||||
|
duplicates: (parsed.duplicates || []).map((d: any) => ({
|
||||||
|
note1Id: String(d.note1Id || ''),
|
||||||
|
note1Title: String(d.note1Title || ''),
|
||||||
|
note2Id: String(d.note2Id || ''),
|
||||||
|
note2Title: String(d.note2Title || ''),
|
||||||
|
reason: String(d.reason || ''),
|
||||||
|
})),
|
||||||
|
categories: (parsed.categories || []).map((c: any) => ({
|
||||||
|
name: String(c.name || ''),
|
||||||
|
noteIds: Array.isArray(c.noteIds) ? c.noteIds.map(String) : [],
|
||||||
|
noteTitles: Array.isArray(c.noteTitles) ? c.noteTitles.map(String) : [],
|
||||||
|
})),
|
||||||
|
summary: String(parsed.summary || ''),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
suggestedTags: [],
|
||||||
|
duplicates: [],
|
||||||
|
categories: [],
|
||||||
|
summary: 'Analyse impossible — réessayer plus tard.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notebookOrganizerService = new NotebookOrganizerService()
|
||||||
@@ -2575,6 +2575,16 @@
|
|||||||
"wizardStudyPlanSuccess": "Plan created! Reminders have been added to your notes.",
|
"wizardStudyPlanSuccess": "Plan created! Reminders have been added to your notes.",
|
||||||
"wizardDaysPlanned": "days planned",
|
"wizardDaysPlanned": "days planned",
|
||||||
"wizardStudyPlanReminders": "Reminders have been automatically added to your notes.",
|
"wizardStudyPlanReminders": "Reminders have been automatically added to your notes.",
|
||||||
|
"wizardOrganizer": "Organize with AI",
|
||||||
|
"wizardOrganizerDesc": "AI analyzes your notes and suggests tags, groupings and duplicate detection.",
|
||||||
|
"wizardAnalyze": "Analyze notebook",
|
||||||
|
"wizardOrganizing": "Analyzing notes...",
|
||||||
|
"wizardSuggestedTags": "Suggested tags",
|
||||||
|
"wizardCategories": "Suggested groupings",
|
||||||
|
"wizardDuplicates": "Duplicates detected",
|
||||||
|
"wizardApply": "Apply",
|
||||||
|
"wizardTagApplied": "applied",
|
||||||
|
"wizardNoSuggestions": "No suggestions — notebook looks well organized.",
|
||||||
"importMarkdown": "Import Markdown",
|
"importMarkdown": "Import Markdown",
|
||||||
"markdownExportSuccess": "Note exported as Markdown",
|
"markdownExportSuccess": "Note exported as Markdown",
|
||||||
"markdownExportError": "Failed to export note",
|
"markdownExportError": "Failed to export note",
|
||||||
|
|||||||
@@ -2579,6 +2579,16 @@
|
|||||||
"wizardStudyPlanSuccess": "Planning créé ! Des rappels ont été ajoutés à vos notes.",
|
"wizardStudyPlanSuccess": "Planning créé ! Des rappels ont été ajoutés à vos notes.",
|
||||||
"wizardDaysPlanned": "jours planifiés",
|
"wizardDaysPlanned": "jours planifiés",
|
||||||
"wizardStudyPlanReminders": "Des rappels ont été ajoutés automatiquement à vos notes.",
|
"wizardStudyPlanReminders": "Des rappels ont été ajoutés automatiquement à vos notes.",
|
||||||
|
"wizardOrganizer": "Organiser avec l'IA",
|
||||||
|
"wizardOrganizerDesc": "L'IA analyse vos notes et propose tags, regroupements et détection de doublons.",
|
||||||
|
"wizardAnalyze": "Analyser le carnet",
|
||||||
|
"wizardOrganizing": "Analyse des notes en cours...",
|
||||||
|
"wizardSuggestedTags": "Tags suggérés",
|
||||||
|
"wizardCategories": "Regroupements suggérés",
|
||||||
|
"wizardDuplicates": "Doublons détectés",
|
||||||
|
"wizardApply": "Appliquer",
|
||||||
|
"wizardTagApplied": "appliqué",
|
||||||
|
"wizardNoSuggestions": "Aucune suggestion — le carnet semble bien organisé.",
|
||||||
"importMarkdown": "Importer un Markdown",
|
"importMarkdown": "Importer un Markdown",
|
||||||
"markdownExportSuccess": "Note exportée en Markdown",
|
"markdownExportSuccess": "Note exportée en Markdown",
|
||||||
"markdownExportError": "Échec de l'export de la note",
|
"markdownExportError": "Échec de l'export de la note",
|
||||||
|
|||||||
Reference in New Issue
Block a user