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:
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user