feat: Tier 1 & 2 — Daily Note, Voice, Flashcard quota, Readwise, Calendar, Agent Gallery
Tier 1: - BASIC tier: chat (10/mo) + reformulate (10/mo) désormais accessibles - Nouveaux quotas: ai_flashcard + voice_transcribe dans tous les tiers - /api/notes/daily : note du jour auto-créée (find or create) - Bouton Note du Jour dans la sidebar (CalendarDays) - Voice-to-Text dans l'éditeur (Web Speech API, bouton Mic toolbar) - Flashcard generation → quota ai_flashcard (au lieu de reformulate) Tier 2: - Intégration Readwise: GET/POST/DELETE /api/integrations/readwise - Intégration Google Calendar: OAuth flow + today's events + meeting notes - /api/integrations/calendar + /callback - Page /settings/integrations avec cards Calendar + Readwise - SettingsNav: onglet Intégrations - AgentTemplates: catégories + 4 nouveaux templates (Digest/Recap/AutoTagger/Synthesis) Schema: - UserAISettings.integrationTokens Json? (migration 20260529160000) - prisma generate + migrate deploy appliqués Fix: - SpeechRecognition types (triple-slash @types/dom-speech-recognition) - Notebook.create: suppression champ 'description' inexistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -107,3 +107,7 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# Feature flag — set to "true" to enable billing UI (default: false)
|
||||
# NEXT_PUBLIC_FEATURE_BILLING_ENABLED="false"
|
||||
|
||||
# Google Calendar Integration (optional — uses Google OAuth credentials above if not set separately)
|
||||
# GOOGLE_CALENDAR_CLIENT_ID="....apps.googleusercontent.com"
|
||||
# GOOGLE_CALENDAR_CLIENT_SECRET="GOCSPX-..."
|
||||
|
||||
289
memento-note/app/(main)/settings/integrations/page.tsx
Normal file
289
memento-note/app/(main)/settings/integrations/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
// ── Readwise ───────────────────────────────────────────────────────────
|
||||
const [rwToken, setRwToken] = useState('')
|
||||
const [rwConnected, setRwConnected] = useState(false)
|
||||
const [rwSyncing, setRwSyncing] = useState(false)
|
||||
const [rwConnecting, setRwConnecting] = useState(false)
|
||||
const [rwLastSync, setRwLastSync] = useState<{ created: number; updated: number } | null>(null)
|
||||
|
||||
// ── Google Calendar ────────────────────────────────────────────────────
|
||||
const [calConnected, setCalConnected] = useState(false)
|
||||
const [calLoading, setCalLoading] = useState(true)
|
||||
const [calEvents, setCalEvents] = useState<any[]>([])
|
||||
const [calFetching, setCalFetching] = useState(false)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/integrations/readwise').then((r) => r.json()),
|
||||
fetch('/api/integrations/calendar').then((r) => r.json()),
|
||||
]).then(([rw, cal]) => {
|
||||
setRwConnected(rw.connected)
|
||||
setCalConnected(cal.connected)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
setCalLoading(false)
|
||||
})
|
||||
|
||||
// Handle redirect params
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('connected') === 'calendar') {
|
||||
setCalConnected(true)
|
||||
toast.success('Google Calendar connecté !')
|
||||
window.history.replaceState({}, '', '/settings/integrations')
|
||||
}
|
||||
if (params.get('error')) {
|
||||
toast.error(`Erreur: ${params.get('error')}`)
|
||||
window.history.replaceState({}, '', '/settings/integrations')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Readwise handlers ──────────────────────────────────────────────────
|
||||
const handleRwConnect = async () => {
|
||||
if (!rwToken.trim()) return
|
||||
setRwConnecting(true)
|
||||
try {
|
||||
const res = await fetch('/api/integrations/readwise', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: rwToken.trim() }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return }
|
||||
setRwConnected(true)
|
||||
setRwToken('')
|
||||
setRwLastSync({ created: data.created, updated: data.updated })
|
||||
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
|
||||
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
|
||||
}
|
||||
|
||||
const handleRwSync = async () => {
|
||||
setRwSyncing(true)
|
||||
try {
|
||||
const res = await fetch('/api/integrations/readwise', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return }
|
||||
setRwLastSync({ created: data.created, updated: data.updated })
|
||||
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
|
||||
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
|
||||
}
|
||||
|
||||
const handleRwDisconnect = async () => {
|
||||
await fetch('/api/integrations/readwise', { method: 'DELETE' })
|
||||
setRwConnected(false)
|
||||
toast.success('Readwise déconnecté')
|
||||
}
|
||||
|
||||
// ── Calendar handlers ──────────────────────────────────────────────────
|
||||
const handleCalConnect = () => {
|
||||
window.location.href = '/api/integrations/calendar?connect=1'
|
||||
}
|
||||
|
||||
const handleCalFetchEvents = async () => {
|
||||
setCalFetching(true)
|
||||
try {
|
||||
const res = await fetch('/api/integrations/calendar?events=1')
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
|
||||
setCalEvents(data.events ?? [])
|
||||
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
|
||||
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
|
||||
}
|
||||
|
||||
const handleCreateMeetingNote = async (event: any) => {
|
||||
const res = await fetch('/api/integrations/calendar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ eventId: event.id, summary: event.summary, start: event.start }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
toast.success(`Note de réunion créée : ${event.summary}`, {
|
||||
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
|
||||
})
|
||||
} else {
|
||||
toast.error('Erreur lors de la création de la note')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCalDisconnect = async () => {
|
||||
await fetch('/api/integrations/calendar', { method: 'DELETE' })
|
||||
setCalConnected(false)
|
||||
setCalEvents([])
|
||||
toast.success('Google Calendar déconnecté')
|
||||
}
|
||||
|
||||
const StatusBadge = ({ connected }: { connected: boolean }) =>
|
||||
connected ? (
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800/40 rounded-full px-2.5 py-1">
|
||||
<Check size={11} /> Connecté
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
|
||||
<X size={11} /> Non connecté
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
|
||||
<p className="text-sm text-concrete mt-1">Connectez des services externes à Momento.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
||||
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<CalendarDays size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
|
||||
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p>
|
||||
</div>
|
||||
</div>
|
||||
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
|
||||
</div>
|
||||
|
||||
{!calConnected ? (
|
||||
<button
|
||||
onClick={handleCalConnect}
|
||||
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
Connecter Google Calendar
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleCalFetchEvents}
|
||||
disabled={calFetching}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
|
||||
>
|
||||
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
|
||||
Événements aujourd'hui
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCalDisconnect}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Déconnecter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{calEvents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{calEvents.map((ev) => (
|
||||
<div key={ev.id} className="flex items-center justify-between gap-3 px-4 py-3 bg-border/10 rounded-xl border border-border/20">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-ink truncate">{ev.summary}</p>
|
||||
<p className="text-[11px] text-concrete">
|
||||
{ev.start ? new Date(ev.start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCreateMeetingNote(ev)}
|
||||
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
|
||||
>
|
||||
+ Note
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Readwise ──────────────────────────────────────────────────── */}
|
||||
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<BookOpen size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
|
||||
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
|
||||
</div>
|
||||
|
||||
{!rwConnected && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px] text-concrete">
|
||||
Trouvez votre token sur{' '}
|
||||
<a href="https://readwise.io/access_token" target="_blank" rel="noopener noreferrer" className="text-brand-accent underline">
|
||||
readwise.io/access_token
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={rwToken}
|
||||
onChange={(e) => setRwToken(e.target.value)}
|
||||
placeholder="Token Readwise…"
|
||||
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRwConnect}
|
||||
disabled={!rwToken.trim() || rwConnecting}
|
||||
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
Connecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rwConnected && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleRwSync}
|
||||
disabled={rwSyncing}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
|
||||
>
|
||||
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
Synchroniser maintenant
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRwDisconnect}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Déconnecter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rwLastSync && (
|
||||
<p className="text-[11px] text-concrete">
|
||||
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placeholder */}
|
||||
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-concrete italic">D'autres intégrations arrivent bientôt — Zapier, GitHub, Notion import…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
await checkEntitlementOrThrow(session.user.id, 'reformulate')
|
||||
await checkEntitlementOrThrow(session.user.id, 'ai_flashcard')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
language: note.language || undefined,
|
||||
})
|
||||
|
||||
incrementUsageAsync(session.user.id, 'reformulate')
|
||||
incrementUsageAsync(session.user.id, 'ai_flashcard')
|
||||
|
||||
return NextResponse.json({ cards, noteId: note.id, style })
|
||||
} catch (error) {
|
||||
|
||||
69
memento-note/app/api/integrations/calendar/callback/route.ts
Normal file
69
memento-note/app/api/integrations/calendar/callback/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* GET /api/integrations/calendar/callback
|
||||
* Google OAuth callback — exchanges code for tokens and persists them.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const userId = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
if (error || !code || !userId) {
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/settings/integrations?error=calendar_auth_failed`,
|
||||
)
|
||||
}
|
||||
|
||||
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || ''
|
||||
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || ''
|
||||
const redirectUri = `${url.origin}/api/integrations/calendar/callback`
|
||||
|
||||
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
})
|
||||
|
||||
const tokenData = await tokenRes.json()
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/settings/integrations?error=calendar_token_failed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Persist tokens in UserAISettings.integrationTokens
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
const meta =
|
||||
typeof aiSettings?.integrationTokens === 'string'
|
||||
? JSON.parse(aiSettings.integrationTokens)
|
||||
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
|
||||
|
||||
meta.calendarAccessToken = tokenData.access_token
|
||||
if (tokenData.refresh_token) {
|
||||
meta.calendarRefreshToken = tokenData.refresh_token
|
||||
}
|
||||
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
update: { integrationTokens: JSON.stringify(meta) },
|
||||
create: { userId, integrationTokens: JSON.stringify(meta) },
|
||||
})
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=calendar`)
|
||||
}
|
||||
232
memento-note/app/api/integrations/calendar/route.ts
Normal file
232
memento-note/app/api/integrations/calendar/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Google Calendar Integration
|
||||
*
|
||||
* OAuth flow (simplified — uses the existing Google account from NextAuth):
|
||||
* 1. GET /api/integrations/calendar → redirect to Google OAuth consent (calendar scope)
|
||||
* 2. GET /api/integrations/calendar/callback → exchange code, store refresh token
|
||||
*
|
||||
* Usage after auth:
|
||||
* GET /api/integrations/calendar?events=1 → list today's events
|
||||
* POST /api/integrations/calendar → create a meeting note from an event id
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
const GOOGLE_CALENDAR_API = 'https://www.googleapis.com/calendar/v3'
|
||||
|
||||
function getClientId() {
|
||||
return process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || ''
|
||||
}
|
||||
function getClientSecret() {
|
||||
return process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || ''
|
||||
}
|
||||
function getRedirectUri(baseUrl: string) {
|
||||
return `${baseUrl}/api/integrations/calendar/callback`
|
||||
}
|
||||
|
||||
async function getStoredTokens(userId: string): Promise<{ accessToken: string; refreshToken: string } | null> {
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
try {
|
||||
const meta =
|
||||
typeof aiSettings?.integrationTokens === 'string'
|
||||
? JSON.parse(aiSettings.integrationTokens)
|
||||
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
|
||||
if (meta?.calendarAccessToken && meta?.calendarRefreshToken) {
|
||||
return {
|
||||
accessToken: meta.calendarAccessToken as string,
|
||||
refreshToken: meta.calendarRefreshToken as string,
|
||||
}
|
||||
}
|
||||
} catch { /* no-op */ }
|
||||
return null
|
||||
}
|
||||
|
||||
async function refreshAccessToken(userId: string, refreshToken: string): Promise<string | null> {
|
||||
const res = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: getClientId(),
|
||||
client_secret: getClientSecret(),
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.access_token) return null
|
||||
|
||||
// Persist refreshed access token
|
||||
const aiSettings = await prisma.userAISettings.findUnique({ where: { userId }, select: { integrationTokens: true } })
|
||||
const meta = typeof aiSettings?.integrationTokens === 'string' ? JSON.parse(aiSettings.integrationTokens) : {}
|
||||
meta.calendarAccessToken = data.access_token
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
update: { integrationTokens: JSON.stringify(meta) },
|
||||
create: { userId, integrationTokens: JSON.stringify(meta) },
|
||||
})
|
||||
|
||||
return data.access_token
|
||||
}
|
||||
|
||||
/** GET /api/integrations/calendar — status check or redirect to OAuth */
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(req.url)
|
||||
const connect = url.searchParams.get('connect')
|
||||
const events = url.searchParams.get('events')
|
||||
const userId = session.user.id
|
||||
|
||||
// Check connection status
|
||||
if (!connect && !events) {
|
||||
const tokens = await getStoredTokens(userId)
|
||||
return NextResponse.json({ connected: !!tokens })
|
||||
}
|
||||
|
||||
// Start OAuth flow
|
||||
if (connect === '1') {
|
||||
const clientId = getClientId()
|
||||
if (!clientId) {
|
||||
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
|
||||
}
|
||||
const baseUrl = url.origin
|
||||
const authUrl = new URL(GOOGLE_AUTH_URL)
|
||||
authUrl.searchParams.set('client_id', clientId)
|
||||
authUrl.searchParams.set('redirect_uri', getRedirectUri(baseUrl))
|
||||
authUrl.searchParams.set('response_type', 'code')
|
||||
authUrl.searchParams.set('scope', CALENDAR_SCOPE)
|
||||
authUrl.searchParams.set('access_type', 'offline')
|
||||
authUrl.searchParams.set('prompt', 'consent')
|
||||
authUrl.searchParams.set('state', userId)
|
||||
return NextResponse.redirect(authUrl.toString())
|
||||
}
|
||||
|
||||
// Fetch today's events
|
||||
if (events === '1') {
|
||||
let tokens = await getStoredTokens(userId)
|
||||
if (!tokens) {
|
||||
return NextResponse.json({ error: 'Calendar not connected' }, { status: 400 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
|
||||
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59).toISOString()
|
||||
|
||||
const fetchEvents = async (accessToken: string) =>
|
||||
fetch(
|
||||
`${GOOGLE_CALENDAR_API}/calendars/primary/events?` +
|
||||
new URLSearchParams({
|
||||
timeMin: startOfDay,
|
||||
timeMax: endOfDay,
|
||||
singleEvents: 'true',
|
||||
orderBy: 'startTime',
|
||||
}),
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
)
|
||||
|
||||
let res = await fetchEvents(tokens.accessToken)
|
||||
|
||||
// Token might be expired — refresh and retry
|
||||
if (res.status === 401 && tokens.refreshToken) {
|
||||
const newToken = await refreshAccessToken(userId, tokens.refreshToken)
|
||||
if (!newToken) {
|
||||
return NextResponse.json({ error: 'Token refresh failed — please reconnect Calendar' }, { status: 401 })
|
||||
}
|
||||
res = await fetchEvents(newToken)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const items = (data.items ?? []).map((e: any) => ({
|
||||
id: e.id,
|
||||
summary: e.summary || '(Sans titre)',
|
||||
start: e.start?.dateTime || e.start?.date,
|
||||
end: e.end?.dateTime || e.end?.date,
|
||||
location: e.location,
|
||||
description: e.description,
|
||||
htmlLink: e.htmlLink,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ events: items })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
|
||||
/** POST /api/integrations/calendar — create a meeting note from a calendar event */
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const eventId = body.eventId as string | undefined
|
||||
const summary = body.summary as string | undefined
|
||||
const start = body.start as string | undefined
|
||||
|
||||
if (!summary) {
|
||||
return NextResponse.json({ error: 'summary required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const dateStr = start ? new Date(start).toLocaleDateString('fr-FR') : new Date().toLocaleDateString('fr-FR')
|
||||
const timeStr = start ? new Date(start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
const content = JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: `🗓 ${summary}` }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: `📅 ${dateStr}${timeStr ? ` à ${timeStr}` : ''}` }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📋 Ordre du jour' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📝 Notes' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '✅ Actions à faire' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
|
||||
],
|
||||
})
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
title: `${summary} — ${dateStr}`,
|
||||
content,
|
||||
type: 'meeting',
|
||||
labels: JSON.stringify(['réunion', 'calendar']),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, note })
|
||||
}
|
||||
|
||||
/** DELETE /api/integrations/calendar — disconnect Calendar */
|
||||
export async function DELETE() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const aiSettings = await prisma.userAISettings.findUnique({ where: { userId }, select: { integrationTokens: true } })
|
||||
if (aiSettings) {
|
||||
try {
|
||||
const meta = ((aiSettings.integrationTokens as Record<string, unknown>) ?? {})
|
||||
delete meta.calendarAccessToken
|
||||
delete meta.calendarRefreshToken
|
||||
await prisma.userAISettings.update({ where: { userId }, data: { integrationTokens: JSON.stringify(meta) } })
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
258
memento-note/app/api/integrations/readwise/route.ts
Normal file
258
memento-note/app/api/integrations/readwise/route.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* POST /api/integrations/readwise/sync
|
||||
* Syncs Readwise highlights into Momento notes.
|
||||
* Each book/article becomes a note with all its highlights listed.
|
||||
*
|
||||
* Query params:
|
||||
* ?token=xxx (Readwise API token) — used for initial test; stored in UserAISettings.integrationTokens
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
const READWISE_API = 'https://readwise.io/api/v2'
|
||||
|
||||
interface ReadwiseHighlight {
|
||||
id: number
|
||||
text: string
|
||||
note: string
|
||||
location: number
|
||||
book_id: number
|
||||
highlighted_at: string | null
|
||||
url: string | null
|
||||
color: string
|
||||
}
|
||||
|
||||
interface ReadwiseBook {
|
||||
id: number
|
||||
title: string
|
||||
author: string | null
|
||||
category: string
|
||||
source: string
|
||||
num_highlights: number
|
||||
last_highlight_at: string | null
|
||||
cover_image_url: string | null
|
||||
source_url: string | null
|
||||
}
|
||||
|
||||
interface ReadwisePaginatedResponse<T> {
|
||||
count: number
|
||||
next: string | null
|
||||
results: T[]
|
||||
}
|
||||
|
||||
async function fetchAllPages<T>(url: string, token: string): Promise<T[]> {
|
||||
const results: T[] = []
|
||||
let nextUrl: string | null = url
|
||||
|
||||
while (nextUrl) {
|
||||
const res = await fetch(nextUrl, {
|
||||
headers: { Authorization: `Token ${token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Readwise API error: ${res.status}`)
|
||||
const data: ReadwisePaginatedResponse<T> = await res.json()
|
||||
results.push(...data.results)
|
||||
nextUrl = data.next
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function buildNoteContent(book: ReadwiseBook, highlights: ReadwiseHighlight[]): string {
|
||||
const sourceLink = book.source_url
|
||||
? `<p><a href="${book.source_url}">${book.source_url}</a></p>`
|
||||
: ''
|
||||
const highlightLines = highlights
|
||||
.sort((a, b) => a.location - b.location)
|
||||
.map((h) => {
|
||||
const note = h.note ? `<blockquote>${h.note}</blockquote>` : ''
|
||||
return `<li>${h.text}${note}</li>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<h1>📚 ${book.title}</h1>
|
||||
${book.author ? `<p><em>par ${book.author}</em></p>` : ''}
|
||||
${sourceLink}
|
||||
<p><strong>${highlights.length} surlignage${highlights.length > 1 ? 's' : ''}</strong></p>
|
||||
<ul>
|
||||
${highlightLines}
|
||||
</ul>`
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Retrieve stored Readwise token from UserAISettings metadata
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
|
||||
let token: string | undefined
|
||||
try {
|
||||
const meta = typeof aiSettings?.integrationTokens === 'string'
|
||||
? JSON.parse(aiSettings.integrationTokens)
|
||||
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
|
||||
token = meta?.readwiseToken as string | undefined
|
||||
} catch {
|
||||
token = undefined
|
||||
}
|
||||
|
||||
// Allow passing token directly (for test/setup)
|
||||
const body = await req.json().catch(() => ({}))
|
||||
if (body.token && typeof body.token === 'string') {
|
||||
token = body.token
|
||||
// Persist token
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
integrationTokens: JSON.stringify({
|
||||
...(typeof aiSettings?.integrationTokens === 'string' ? JSON.parse(aiSettings.integrationTokens) : {}),
|
||||
readwiseToken: body.token,
|
||||
}),
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
integrationTokens: { readwiseToken: body.token },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Readwise token not configured' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch books and highlights
|
||||
const [books, highlights] = await Promise.all([
|
||||
fetchAllPages<ReadwiseBook>(`${READWISE_API}/books/?page_size=100`, token),
|
||||
fetchAllPages<ReadwiseHighlight>(`${READWISE_API}/highlights/?page_size=500`, token),
|
||||
])
|
||||
|
||||
// Group highlights by book
|
||||
const byBook = new Map<number, ReadwiseHighlight[]>()
|
||||
for (const h of highlights) {
|
||||
const arr = byBook.get(h.book_id) ?? []
|
||||
arr.push(h)
|
||||
byBook.set(h.book_id, arr)
|
||||
}
|
||||
|
||||
// Find or create a "Readwise" notebook
|
||||
let notebook = await prisma.notebook.findFirst({
|
||||
where: { userId, name: 'Readwise' },
|
||||
})
|
||||
if (!notebook) {
|
||||
notebook = await prisma.notebook.create({
|
||||
data: { userId, name: 'Readwise', icon: '📚', order: 999 },
|
||||
})
|
||||
}
|
||||
|
||||
let created = 0
|
||||
let updated = 0
|
||||
|
||||
for (const book of books) {
|
||||
const bookHighlights = byBook.get(book.id) ?? []
|
||||
if (bookHighlights.length === 0) continue
|
||||
|
||||
const content = buildNoteContent(book, bookHighlights)
|
||||
const existing = await prisma.note.findFirst({
|
||||
where: { userId, notebookId: notebook.id, title: book.title, trashedAt: null },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await prisma.note.update({
|
||||
where: { id: existing.id },
|
||||
data: { content, updatedAt: new Date() },
|
||||
})
|
||||
updated++
|
||||
} else {
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
notebookId: notebook.id,
|
||||
title: book.title,
|
||||
content,
|
||||
labels: JSON.stringify(['readwise', book.category]),
|
||||
},
|
||||
})
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created,
|
||||
updated,
|
||||
notebookId: notebook.id,
|
||||
books: books.length,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('[readwise/sync]', message)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/integrations/readwise/sync
|
||||
* Returns Readwise connection status (token configured or not).
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
|
||||
let connected = false
|
||||
try {
|
||||
const meta = typeof aiSettings?.integrationTokens === 'string'
|
||||
? JSON.parse(aiSettings.integrationTokens)
|
||||
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
|
||||
connected = !!(meta?.readwiseToken)
|
||||
} catch {
|
||||
connected = false
|
||||
}
|
||||
|
||||
return NextResponse.json({ connected })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/integrations/readwise/sync
|
||||
* Removes the stored Readwise token.
|
||||
*/
|
||||
export async function DELETE() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
|
||||
if (aiSettings) {
|
||||
try {
|
||||
const meta = ((aiSettings.integrationTokens as Record<string, unknown> | null) ?? {})
|
||||
delete meta.readwiseToken
|
||||
await prisma.userAISettings.update({
|
||||
where: { userId },
|
||||
data: { integrationTokens: JSON.stringify(meta) },
|
||||
})
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
64
memento-note/app/api/notes/daily/route.ts
Normal file
64
memento-note/app/api/notes/daily/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
function getTodayTitle(): string {
|
||||
return new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
||||
}
|
||||
|
||||
function getTodayContent(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
const formatted = d.toLocaleDateString('fr-FR', options)
|
||||
return JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: `📅 ${formatted}` }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/notes/daily
|
||||
* Returns (or creates) today's daily note for the authenticated user.
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const today = getTodayTitle()
|
||||
const userId = session.user.id
|
||||
|
||||
// Try to find existing daily note for today
|
||||
let note = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: today,
|
||||
type: 'daily',
|
||||
trashedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
note = await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
title: today,
|
||||
content: getTodayContent(today),
|
||||
type: 'daily',
|
||||
color: '#FEF9C3', // yellow-100 — distinguishes daily notes visually
|
||||
labels: JSON.stringify(['daily']),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, note })
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
Presentation,
|
||||
Pencil,
|
||||
ListChecks,
|
||||
Newspaper,
|
||||
Youtube,
|
||||
BookMarked,
|
||||
FileText,
|
||||
Tag,
|
||||
Brain,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -21,7 +27,8 @@ interface AgentTemplatesProps {
|
||||
}
|
||||
|
||||
const templateConfig = [
|
||||
{ id: 'veilleAI', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
// ── Scrapers & Veille ──────────────────────────────────────────────────
|
||||
{ id: 'veilleAI', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', category: 'veille', urls: [
|
||||
'https://www.theverge.com/rss/ai-artificial-intelligence/index.xml',
|
||||
'https://techcrunch.com/category/artificial-intelligence/feed/',
|
||||
'https://feeds.arstechnica.com/arstechnica/technology-lab',
|
||||
@@ -29,23 +36,41 @@ const templateConfig = [
|
||||
'https://www.wired.com/feed/',
|
||||
'https://korben.info/feed',
|
||||
], frequency: 'weekly' },
|
||||
{ id: 'veilleTech', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
{ id: 'veilleTech', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', category: 'veille', urls: [
|
||||
'https://news.ycombinator.com/rss',
|
||||
'https://dev.to/feed',
|
||||
'https://www.producthunt.com/feed',
|
||||
], frequency: 'daily' },
|
||||
{ id: 'veilleDev', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
|
||||
{ id: 'veilleDev', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', category: 'veille', urls: [
|
||||
'https://dev.to/feed/tag/javascript',
|
||||
'https://dev.to/feed/tag/typescript',
|
||||
'https://dev.to/feed/tag/react',
|
||||
], frequency: 'weekly' },
|
||||
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], frequency: 'weekly' },
|
||||
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
|
||||
{ id: 'slideGenerator', type: 'slide-generator', roleKey: 'agents.defaultRoles.slideGenerator', urls: [], frequency: 'manual' },
|
||||
{ id: 'excalidrawGenerator', type: 'excalidraw-generator', roleKey: 'agents.defaultRoles.excalidrawGenerator', urls: [], frequency: 'manual' },
|
||||
{ id: 'taskExtractor', type: 'task-extractor', roleKey: 'agents.defaultRoles.taskExtractor', urls: [], frequency: 'manual' },
|
||||
// ── Digest & Résumés ──────────────────────────────────────────────────
|
||||
{ id: 'dailyDigest', type: 'digest', roleKey: 'agents.defaultRoles.researcher', category: 'digest', urls: [], frequency: 'daily' },
|
||||
{ id: 'weeklyRecap', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', category: 'digest', urls: [], frequency: 'weekly' },
|
||||
// ── Outils ────────────────────────────────────────────────────────────
|
||||
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', category: 'tools', urls: [], frequency: 'weekly' },
|
||||
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', category: 'tools', urls: [], frequency: 'manual' },
|
||||
{ id: 'autoTagger', type: 'auto-tagger', roleKey: 'agents.defaultRoles.researcher', category: 'tools', urls: [], frequency: 'weekly' },
|
||||
// ── Génération ────────────────────────────────────────────────────────
|
||||
{ id: 'slideGenerator', type: 'slide-generator', roleKey: 'agents.defaultRoles.slideGenerator', category: 'generate', urls: [], frequency: 'manual' },
|
||||
{ id: 'excalidrawGenerator', type: 'excalidraw-generator', roleKey: 'agents.defaultRoles.excalidrawGenerator', category: 'generate', urls: [], frequency: 'manual' },
|
||||
{ id: 'taskExtractor', type: 'task-extractor', roleKey: 'agents.defaultRoles.taskExtractor', category: 'generate', urls: [], frequency: 'manual' },
|
||||
{ id: 'knowledgeSynthesis', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', category: 'generate', urls: [], frequency: 'weekly' },
|
||||
] as const
|
||||
|
||||
type TemplateId = typeof templateConfig[number]['id']
|
||||
type CategoryId = 'all' | 'veille' | 'digest' | 'tools' | 'generate'
|
||||
|
||||
const CATEGORIES: { id: CategoryId; label: string }[] = [
|
||||
{ id: 'all', label: 'Tous' },
|
||||
{ id: 'veille', label: '📡 Veille' },
|
||||
{ id: 'digest', label: '📰 Digest' },
|
||||
{ id: 'tools', label: '🔧 Outils' },
|
||||
{ id: 'generate', label: '✨ Génération' },
|
||||
]
|
||||
|
||||
const typeIcons: Record<string, typeof Globe> = {
|
||||
scraper: Globe,
|
||||
researcher: Search,
|
||||
@@ -54,11 +79,25 @@ const typeIcons: Record<string, typeof Globe> = {
|
||||
'slide-generator': Presentation,
|
||||
'excalidraw-generator': Pencil,
|
||||
'task-extractor': ListChecks,
|
||||
digest: Newspaper,
|
||||
'youtube-transcript': Youtube,
|
||||
'readwise-sync': BookMarked,
|
||||
'auto-tagger': Tag,
|
||||
'knowledge-synthesis': Brain,
|
||||
}
|
||||
|
||||
// Extra icons for specific template IDs
|
||||
const templateIcons: Partial<Record<TemplateId, typeof Globe>> = {
|
||||
dailyDigest: Newspaper,
|
||||
weeklyRecap: FileText,
|
||||
autoTagger: Tag,
|
||||
knowledgeSynthesis: Brain,
|
||||
}
|
||||
|
||||
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
|
||||
const { t } = useLanguage()
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all')
|
||||
|
||||
const handleInstall = async (tpl: typeof templateConfig[number]) => {
|
||||
setInstallingId(tpl.id)
|
||||
@@ -73,6 +112,18 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
while (existingAgentNames.includes(`${baseName} ${n}`)) n++
|
||||
resolvedName = `${baseName} ${n}`
|
||||
}
|
||||
|
||||
const toolMap: Record<string, string[]> = {
|
||||
scraper: ['web_scrape', 'note_create'],
|
||||
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create'],
|
||||
monitor: ['note_search', 'note_read', 'note_create'],
|
||||
'slide-generator': ['note_search', 'note_read', 'generate_pptx'],
|
||||
'excalidraw-generator': ['note_search', 'note_read', 'generate_excalidraw'],
|
||||
'task-extractor': ['note_search', 'note_read', 'task_extract', 'note_create'],
|
||||
digest: ['note_search', 'note_read', 'note_create'],
|
||||
'auto-tagger': ['note_search', 'note_read', 'note_update'],
|
||||
}
|
||||
|
||||
await createAgent({
|
||||
name: resolvedName,
|
||||
description: t(descKey),
|
||||
@@ -80,19 +131,7 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
role: t(tpl.roleKey),
|
||||
sourceUrls: tpl.urls.length > 0 ? [...tpl.urls] : undefined,
|
||||
frequency: tpl.frequency,
|
||||
tools: tpl.type === 'scraper'
|
||||
? ['web_scrape', 'note_create']
|
||||
: tpl.type === 'researcher'
|
||||
? ['web_search', 'web_scrape', 'note_search', 'note_create']
|
||||
: tpl.type === 'monitor'
|
||||
? ['note_search', 'note_read', 'note_create']
|
||||
: tpl.type === 'slide-generator'
|
||||
? ['note_search', 'note_read', 'generate_pptx']
|
||||
: tpl.type === 'excalidraw-generator'
|
||||
? ['note_search', 'note_read', 'generate_excalidraw']
|
||||
: tpl.type === 'task-extractor'
|
||||
? ['note_search', 'note_read', 'task_extract', 'note_create']
|
||||
: [],
|
||||
tools: toolMap[tpl.type] ?? [],
|
||||
})
|
||||
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
|
||||
onInstalled()
|
||||
@@ -103,44 +142,75 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templateConfig.map(tpl => {
|
||||
const Icon = typeIcons[tpl.type] || Settings
|
||||
const isInstalling = installingId === tpl.id
|
||||
const nameKey = `agents.templates.${tpl.id}.name`
|
||||
const descKey = `agents.templates.${tpl.id}.description`
|
||||
const filtered = activeCategory === 'all'
|
||||
? templateConfig
|
||||
: templateConfig.filter((tpl) => tpl.category === activeCategory)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-card hover:border-foreground/20 transition-all"
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Category filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-all border ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-ink text-paper border-ink'
|
||||
: 'bg-paper text-muted-ink border-border/40 hover:border-ink/30'
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:bg-foreground group-hover:text-background mb-4 transition-all">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-foreground mb-2">{t(nameKey)}</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p>
|
||||
<button
|
||||
onClick={() => handleInstall(tpl)}
|
||||
disabled={isInstalling}
|
||||
className="text-[11px] font-bold uppercase tracking-widest text-foreground hover:opacity-60 transition-opacity flex items-center gap-2 disabled:opacity-50"
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filtered.map(tpl => {
|
||||
const Icon = templateIcons[tpl.id as TemplateId] ?? typeIcons[tpl.type] ?? Settings
|
||||
const isInstalling = installingId === tpl.id
|
||||
const nameKey = `agents.templates.${tpl.id}.name`
|
||||
const descKey = `agents.templates.${tpl.id}.description`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-card hover:border-foreground/20 transition-all"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t('agents.templates.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('agents.templates.install')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:bg-foreground group-hover:text-background mb-4 transition-all">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{t(nameKey)}</h4>
|
||||
{tpl.frequency !== 'manual' && (
|
||||
<span className="text-[10px] font-semibold text-concrete bg-border/20 rounded-full px-2 py-0.5 shrink-0">
|
||||
{tpl.frequency === 'daily' ? '📅 Quotidien' : '📆 Hebdo'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p>
|
||||
<button
|
||||
onClick={() => handleInstall(tpl)}
|
||||
disabled={isInstalling}
|
||||
className="text-[11px] font-bold uppercase tracking-widest text-foreground hover:opacity-60 transition-opacity flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t('agents.templates.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('agents.templates.install')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useNoteEditorContext } from './note-editor-context'
|
||||
import { LabelManager } from '@/components/label-manager'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
@@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
@@ -28,6 +28,7 @@ import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
||||
@@ -50,6 +51,18 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||
|
||||
// ── Voice transcription ──────────────────────────────────────────────────
|
||||
const handleTranscript = useCallback((text: string) => {
|
||||
const editor = richTextEditorRef?.current?.getEditor()
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(' ' + text).run()
|
||||
}
|
||||
}, [richTextEditorRef])
|
||||
|
||||
const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({
|
||||
onTranscript: handleTranscript,
|
||||
})
|
||||
|
||||
// ── Markdown export ───────────────────────────────────────────────────────
|
||||
const handleExportMarkdown = () => {
|
||||
try {
|
||||
@@ -246,6 +259,24 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && voiceSupported && (
|
||||
<button
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? 'Stop voice' : 'Start voice'}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
voiceState === 'listening'
|
||||
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && onToggleAttachments && (
|
||||
<button
|
||||
title={t('notes.attachments') || 'Attachments'}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key, CreditCard } from 'lucide-react'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key, CreditCard, Plug } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
@@ -21,6 +21,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
{ id: 'appearance', label: t('appearance.title'), icon: <Palette size={14} />, href: '/settings/appearance' },
|
||||
{ id: 'profile', label: t('profile.title'), icon: <User size={14} />, href: '/settings/profile' },
|
||||
{ id: 'data', label: t('dataManagement.title'), icon: <Database size={14} />, href: '/settings/data' },
|
||||
{ id: 'integrations', label: t('integrations.title') || 'Intégrations', icon: <Plug size={14} />, href: '/settings/integrations' },
|
||||
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key size={14} />, href: '/settings/mcp' },
|
||||
{ id: 'about', label: t('about.title'), icon: <Info size={14} />, href: '/settings/about' },
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BookMarked,
|
||||
Bot,
|
||||
Inbox,
|
||||
CalendarDays,
|
||||
FlaskConical,
|
||||
ArrowUpDown,
|
||||
Archive,
|
||||
@@ -764,6 +765,21 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
router.push('/home?forceList=1')
|
||||
}
|
||||
|
||||
const handleDailyNoteClick = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/notes/daily')
|
||||
const data = await res.json()
|
||||
if (data.success && data.note) {
|
||||
const params = new URLSearchParams()
|
||||
if (data.note.notebookId) params.set('notebook', data.note.notebookId)
|
||||
params.set('openNote', data.note.id)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('sidebar.dailyNoteError') || 'Impossible d\'ouvrir la note du jour')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteClick = (noteId: string, notebookId: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('notebook', notebookId)
|
||||
@@ -1379,6 +1395,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDailyNoteClick}
|
||||
className="sidebar-inbox-item"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0 bg-paper dark:bg-white/5 text-muted-ink border-border">
|
||||
<CalendarDays size={14} />
|
||||
</div>
|
||||
<span className="text-[13px] font-medium truncate text-muted-ink">
|
||||
{t('sidebar.dailyNote') || 'Note du jour'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-3 h-px bg-border/40" />
|
||||
|
||||
<div
|
||||
|
||||
87
memento-note/hooks/use-voice-transcription.ts
Normal file
87
memento-note/hooks/use-voice-transcription.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/// <reference types="@types/dom-speech-recognition" />
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type VoiceState = 'idle' | 'listening' | 'processing' | 'error'
|
||||
|
||||
interface UseVoiceTranscriptionOptions {
|
||||
onTranscript: (text: string) => void
|
||||
onError?: (message: string) => void
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export function useVoiceTranscription({ onTranscript, onError, lang = 'fr-FR' }: UseVoiceTranscriptionOptions) {
|
||||
const [state, setState] = useState<VoiceState>('idle')
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const accumulatedRef = useRef<string>('')
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' &&
|
||||
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
onError?.('La reconnaissance vocale n\'est pas disponible sur ce navigateur.')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
const rec: SpeechRecognition = new SR()
|
||||
rec.lang = lang
|
||||
rec.continuous = true
|
||||
rec.interimResults = false
|
||||
accumulatedRef.current = ''
|
||||
|
||||
rec.onstart = () => setState('listening')
|
||||
|
||||
rec.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let transcript = ''
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
if (event.results[i].isFinal) {
|
||||
transcript += event.results[i][0].transcript
|
||||
}
|
||||
}
|
||||
if (transcript) accumulatedRef.current += (accumulatedRef.current ? ' ' : '') + transcript
|
||||
}
|
||||
|
||||
rec.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
console.error('[voice] SpeechRecognition error:', event.error)
|
||||
onError?.(event.error === 'not-allowed' ? 'Microphone non autorisé.' : `Erreur: ${event.error}`)
|
||||
setState('error')
|
||||
}
|
||||
|
||||
rec.onend = () => {
|
||||
setState('idle')
|
||||
if (accumulatedRef.current.trim()) {
|
||||
onTranscript(accumulatedRef.current.trim())
|
||||
accumulatedRef.current = ''
|
||||
}
|
||||
}
|
||||
|
||||
recognitionRef.current = rec
|
||||
rec.start()
|
||||
}, [isSupported, lang, onTranscript, onError])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
recognitionRef.current?.stop()
|
||||
recognitionRef.current = null
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (state === 'listening') {
|
||||
stop()
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}, [state, start, stop])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recognitionRef.current?.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { state, toggle, start, stop, isSupported }
|
||||
}
|
||||
@@ -75,12 +75,16 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
|
||||
semantic_search: 30,
|
||||
auto_tag: 15,
|
||||
auto_title: 5,
|
||||
chat: 10,
|
||||
reformulate: 10,
|
||||
brainstorm_create: 1,
|
||||
brainstorm_expand: 10,
|
||||
brainstorm_enrich: 20,
|
||||
suggest_charts: 5,
|
||||
slide_generate: 3,
|
||||
excalidraw_generate: 3,
|
||||
ai_flashcard: 5,
|
||||
voice_transcribe: 20,
|
||||
},
|
||||
PRO: {
|
||||
semantic_search: 100,
|
||||
@@ -94,6 +98,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
|
||||
suggest_charts: 50,
|
||||
slide_generate: 20,
|
||||
excalidraw_generate: 20,
|
||||
ai_flashcard: 100,
|
||||
voice_transcribe: 500,
|
||||
},
|
||||
BUSINESS: {
|
||||
semantic_search: 1000,
|
||||
@@ -107,6 +113,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
|
||||
suggest_charts: 200,
|
||||
slide_generate: 100,
|
||||
excalidraw_generate: 100,
|
||||
ai_flashcard: 'unlimited',
|
||||
voice_transcribe: 'unlimited',
|
||||
},
|
||||
ENTERPRISE: {
|
||||
semantic_search: 'unlimited',
|
||||
@@ -120,6 +128,8 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
|
||||
suggest_charts: 'unlimited',
|
||||
slide_generate: 'unlimited',
|
||||
excalidraw_generate: 'unlimited',
|
||||
ai_flashcard: 'unlimited',
|
||||
voice_transcribe: 'unlimited',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export const VALID_FEATURES = [
|
||||
'suggest_charts',
|
||||
'slide_generate',
|
||||
'excalidraw_generate',
|
||||
'ai_flashcard',
|
||||
'voice_transcribe',
|
||||
] as const;
|
||||
|
||||
export type FeatureName = (typeof VALID_FEATURES)[number];
|
||||
|
||||
@@ -77,7 +77,9 @@
|
||||
"clearSearch": "Clear search",
|
||||
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
|
||||
"revisionPanelBody": "Review flashcards with the SM-2 algorithm. Decks are generated from your notes.",
|
||||
"backToNotebooks": "Back to notebooks"
|
||||
"backToNotebooks": "Back to notebooks",
|
||||
"dailyNote": "Daily Note",
|
||||
"dailyNoteError": "Could not open today's note"
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notes",
|
||||
@@ -2188,6 +2190,22 @@
|
||||
"excalidrawGenerator": {
|
||||
"name": "Diagram Generator",
|
||||
"description": "Reads a note and generates a visual diagram in the Excalidraw Lab."
|
||||
},
|
||||
"dailyDigest": {
|
||||
"name": "Daily Digest",
|
||||
"description": "Summarizes your notes from the day and creates a daily recap in your main notebook."
|
||||
},
|
||||
"weeklyRecap": {
|
||||
"name": "Weekly Recap",
|
||||
"description": "Analyzes your weekly notes and produces a summary of key themes, decisions and tasks."
|
||||
},
|
||||
"autoTagger": {
|
||||
"name": "Auto-Tagger",
|
||||
"description": "Scans your notes without labels and automatically suggests relevant tags based on content."
|
||||
},
|
||||
"knowledgeSynthesis": {
|
||||
"name": "Knowledge Synthesis",
|
||||
"description": "Groups related notes by theme and creates a synthesis note with identified connections."
|
||||
}
|
||||
},
|
||||
"runLog": {
|
||||
@@ -3566,5 +3584,12 @@
|
||||
"hint_insights_bridge_desc": "Bridge notes connect multiple clusters. They are highlighted because they hold your knowledge graph together.",
|
||||
"hint_insights_refresh_title": "Refresh clusters",
|
||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations"
|
||||
},
|
||||
"editor": {
|
||||
"voiceStart": "Dictate text (microphone)",
|
||||
"voiceStop": "Stop dictation"
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,9 @@
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
|
||||
"revisionPanelBody": "Révisez vos flashcards avec l'algorithme SM-2. Les decks sont générés depuis vos notes.",
|
||||
"backToNotebooks": "Retour aux carnets"
|
||||
"backToNotebooks": "Retour aux carnets",
|
||||
"dailyNote": "Note du jour",
|
||||
"dailyNoteError": "Impossible d'ouvrir la note du jour"
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notes",
|
||||
@@ -2192,6 +2194,22 @@
|
||||
"excalidrawGenerator": {
|
||||
"name": "Générateur de Diagrammes",
|
||||
"description": "Lit une note et génère un diagramme visuel dans le Lab Excalidraw."
|
||||
},
|
||||
"dailyDigest": {
|
||||
"name": "Digest Quotidien",
|
||||
"description": "Résume vos notes de la journée et crée un récapitulatif quotidien dans votre carnet principal."
|
||||
},
|
||||
"weeklyRecap": {
|
||||
"name": "Récap Hebdomadaire",
|
||||
"description": "Analyse vos notes de la semaine et produit un résumé des thèmes clés, décisions et tâches."
|
||||
},
|
||||
"autoTagger": {
|
||||
"name": "Auto-Tagueur",
|
||||
"description": "Parcourt vos notes sans labels et suggère automatiquement des tags pertinents basés sur le contenu."
|
||||
},
|
||||
"knowledgeSynthesis": {
|
||||
"name": "Synthèse de Connaissances",
|
||||
"description": "Regroupe les notes liées par thème et crée une note de synthèse avec les connexions identifiées."
|
||||
}
|
||||
},
|
||||
"runLog": {
|
||||
@@ -3570,5 +3588,12 @@
|
||||
"hint_insights_bridge_desc": "Les notes ponts relient plusieurs clusters. Elles sont mises en avant car elles constituent les connexions clés de votre graphe de connaissances.",
|
||||
"hint_insights_refresh_title": "Rafraîchir les clusters",
|
||||
"hint_insights_refresh_desc": "Si vous avez ajouté de nouvelles notes, cliquez sur le bouton de rafraîchissement pour recalculer les clusters avec le contenu le plus récent."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Intégrations"
|
||||
},
|
||||
"editor": {
|
||||
"voiceStart": "Dicter du texte (microphone)",
|
||||
"voiceStop": "Arrêter la dictée"
|
||||
}
|
||||
}
|
||||
8
memento-note/package-lock.json
generated
8
memento-note/package-lock.json
generated
@@ -135,6 +135,7 @@
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dom-speech-recognition": "^0.0.11",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
@@ -8591,6 +8592,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-speech-recognition": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.11.tgz",
|
||||
"integrity": "sha512-PyLFPLM9F5D+qEmkNLX/ZC3uiEV/2B/UhZA9uhWkFVOxUyDVj+UBKI2pF1dnhKhliOiIoR1d/QsOZQfOtQPE3A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dom-speech-recognition": "^0.0.11",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: add integrationTokens column to UserAISettings
|
||||
ALTER TABLE "UserAISettings" ADD COLUMN IF NOT EXISTS "integrationTokens" JSONB;
|
||||
@@ -342,6 +342,7 @@ model UserAISettings {
|
||||
noteHistoryMode String @default("manual")
|
||||
autoSave Boolean @default(true)
|
||||
aiProcessingConsent Boolean @default(false)
|
||||
integrationTokens Json? // Stores third-party integration tokens (Readwise, Calendar, etc.)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([memoryEcho])
|
||||
|
||||
Reference in New Issue
Block a user