diff --git a/memento-note/.env.example b/memento-note/.env.example index 12f4a06..8c79d57 100644 --- a/memento-note/.env.example +++ b/memento-note/.env.example @@ -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-..." diff --git a/memento-note/app/(main)/settings/integrations/page.tsx b/memento-note/app/(main)/settings/integrations/page.tsx new file mode 100644 index 0000000..f574362 --- /dev/null +++ b/memento-note/app/(main)/settings/integrations/page.tsx @@ -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([]) + 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 ? ( + + Connecté + + ) : ( + + Non connecté + + ) + + return ( +
+
+

Intégrations

+

Connectez des services externes à Momento.

+
+ + {/* ── Google Calendar ────────────────────────────────────────────── */} +
+
+
+
+ +
+
+

Google Calendar

+

Accédez à vos événements et créez des notes de réunion

+
+
+ {calLoading ? : } +
+ + {!calConnected ? ( + + ) : ( +
+
+ + +
+ + {calEvents.length > 0 && ( +
+ {calEvents.map((ev) => ( +
+
+

{ev.summary}

+

+ {ev.start ? new Date(ev.start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''} +

+
+ +
+ ))} +
+ )} +
+ )} +
+ + {/* ── Readwise ──────────────────────────────────────────────────── */} +
+
+
+
+ +
+
+

Readwise

+

Importez vos surlignages de livres, articles et Kindle

+
+
+ {loading ? : } +
+ + {!rwConnected && ( +
+

+ Trouvez votre token sur{' '} + + readwise.io/access_token + +

+
+ 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()} + /> + +
+
+ )} + + {rwConnected && ( +
+ + +
+ )} + + {rwLastSync && ( +

+ Dernière sync : {rwLastSync.created} notes créées, {rwLastSync.updated} mises à jour +

+ )} +
+ + {/* Placeholder */} +
+

D'autres intégrations arrivent bientôt — Zapier, GitHub, Notion import…

+
+
+ ) +} diff --git a/memento-note/app/api/flashcards/generate/route.ts b/memento-note/app/api/flashcards/generate/route.ts index 646363c..f25a9b7 100644 --- a/memento-note/app/api/flashcards/generate/route.ts +++ b/memento-note/app/api/flashcards/generate/route.ts @@ -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) { diff --git a/memento-note/app/api/integrations/calendar/callback/route.ts b/memento-note/app/api/integrations/calendar/callback/route.ts new file mode 100644 index 0000000..040b5cf --- /dev/null +++ b/memento-note/app/api/integrations/calendar/callback/route.ts @@ -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 | 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`) +} diff --git a/memento-note/app/api/integrations/calendar/route.ts b/memento-note/app/api/integrations/calendar/route.ts new file mode 100644 index 0000000..a9330c0 --- /dev/null +++ b/memento-note/app/api/integrations/calendar/route.ts @@ -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 | 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 { + 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) ?? {}) + 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 }) +} diff --git a/memento-note/app/api/integrations/readwise/route.ts b/memento-note/app/api/integrations/readwise/route.ts new file mode 100644 index 0000000..a2517fd --- /dev/null +++ b/memento-note/app/api/integrations/readwise/route.ts @@ -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 { + count: number + next: string | null + results: T[] +} + +async function fetchAllPages(url: string, token: string): Promise { + 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 = await res.json() + results.push(...data.results) + nextUrl = data.next + } + + return results +} + +function buildNoteContent(book: ReadwiseBook, highlights: ReadwiseHighlight[]): string { + const sourceLink = book.source_url + ? `

${book.source_url}

` + : '' + const highlightLines = highlights + .sort((a, b) => a.location - b.location) + .map((h) => { + const note = h.note ? `
${h.note}
` : '' + return `
  • ${h.text}${note}
  • ` + }) + .join('\n') + + return `

    📚 ${book.title}

    +${book.author ? `

    par ${book.author}

    ` : ''} +${sourceLink} +

    ${highlights.length} surlignage${highlights.length > 1 ? 's' : ''}

    +
      +${highlightLines} +
    ` +} + +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 | 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(`${READWISE_API}/books/?page_size=100`, token), + fetchAllPages(`${READWISE_API}/highlights/?page_size=500`, token), + ]) + + // Group highlights by book + const byBook = new Map() + 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 | 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 | null) ?? {}) + delete meta.readwiseToken + await prisma.userAISettings.update({ + where: { userId }, + data: { integrationTokens: JSON.stringify(meta) }, + }) + } catch { /* no-op */ } + } + + return NextResponse.json({ success: true }) +} diff --git a/memento-note/app/api/notes/daily/route.ts b/memento-note/app/api/notes/daily/route.ts new file mode 100644 index 0000000..9cd3d4c --- /dev/null +++ b/memento-note/app/api/notes/daily/route.ts @@ -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 }) +} diff --git a/memento-note/components/agents/agent-templates.tsx b/memento-note/components/agents/agent-templates.tsx index df869b6..e0a2aae 100644 --- a/memento-note/components/agents/agent-templates.tsx +++ b/memento-note/components/agents/agent-templates.tsx @@ -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 = { scraper: Globe, researcher: Search, @@ -54,11 +79,25 @@ const typeIcons: Record = { '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> = { + dailyDigest: Newspaper, + weeklyRecap: FileText, + autoTagger: Tag, + knowledgeSynthesis: Brain, } export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) { const { t } = useLanguage() const [installingId, setInstallingId] = useState(null) + const [activeCategory, setActiveCategory] = useState('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 = { + 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 ( -
    - {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 ( -
    + {/* Category filter */} +
    + {CATEGORIES.map((cat) => ( + + ))} +
    + + {/* Template grid */} +
    + {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 ( +
    - {isInstalling ? ( - <> - - {t('agents.templates.installing')} - - ) : ( - <> - - {t('agents.templates.install')} - - )} - -
    - ) - })} +
    + +
    +
    +

    {t(nameKey)}

    + {tpl.frequency !== 'manual' && ( + + {tpl.frequency === 'daily' ? '📅 Quotidien' : '📆 Hebdo'} + + )} +
    +

    {t(descKey)}

    + +
    + ) + })} +
    ) } diff --git a/memento-note/components/note-editor/note-editor-toolbar.tsx b/memento-note/components/note-editor/note-editor-toolbar.tsx index 7224bd3..4f0518c 100644 --- a/memento-note/components/note-editor/note-editor-toolbar.tsx +++ b/memento-note/components/note-editor/note-editor-toolbar.tsx @@ -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 )} + {!readOnly && voiceSupported && ( + + )} + {!readOnly && onToggleAttachments && ( + +
    +'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('idle') + const recognitionRef = useRef(null) + const accumulatedRef = useRef('') + + 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 } +} diff --git a/memento-note/lib/entitlements.ts b/memento-note/lib/entitlements.ts index 352d80f..54cb4c3 100644 --- a/memento-note/lib/entitlements.ts +++ b/memento-note/lib/entitlements.ts @@ -75,12 +75,16 @@ const TIER_LIMITS: Record 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 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 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 suggest_charts: 'unlimited', slide_generate: 'unlimited', excalidraw_generate: 'unlimited', + ai_flashcard: 'unlimited', + voice_transcribe: 'unlimited', }, }; diff --git a/memento-note/lib/quota-utils.ts b/memento-note/lib/quota-utils.ts index cdc72ca..ecf30d5 100644 --- a/memento-note/lib/quota-utils.ts +++ b/memento-note/lib/quota-utils.ts @@ -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]; diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 58fc15c..f87ddae 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -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" } } \ No newline at end of file diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index c6ccc36..1453b20 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -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" } } \ No newline at end of file diff --git a/memento-note/package-lock.json b/memento-note/package-lock.json index b8f2fae..113ca29 100644 --- a/memento-note/package-lock.json +++ b/memento-note/package-lock.json @@ -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", diff --git a/memento-note/package.json b/memento-note/package.json index f222314..a7c9318 100644 --- a/memento-note/package.json +++ b/memento-note/package.json @@ -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", diff --git a/memento-note/prisma/migrations/20260529160000_add_integration_tokens/migration.sql b/memento-note/prisma/migrations/20260529160000_add_integration_tokens/migration.sql new file mode 100644 index 0000000..64cd93a --- /dev/null +++ b/memento-note/prisma/migrations/20260529160000_add_integration_tokens/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: add integrationTokens column to UserAISettings +ALTER TABLE "UserAISettings" ADD COLUMN IF NOT EXISTS "integrationTokens" JSONB; diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 70b7b4c..31366ff 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -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])