Files
Momento/memento-note/app/api/integrations/readwise/route.ts
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
2026-06-28 07:32:57 +00:00

259 lines
7.0 KiB
TypeScript

/**
* POST /api/integrations/readwise/sync
* Syncs Readwise highlights into Memento 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 })
}