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
259 lines
7.0 KiB
TypeScript
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 })
|
|
}
|