feat(flashcards): révision SM-2, génération IA et page /revision
Some checks failed
CI / Lint, Test & Build (push) Failing after 32s
CI / Deploy production (on server) (push) Has been skipped

Livre US-FLASHCARDS avec decks, session de révision, stats et migration Prisma. Finalise le Web Clipper (i18n 15 langues) et corrige les erreurs ESLint bloquant la CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-24 19:22:20 +00:00
parent 8697ae244f
commit 36336e6b0d
50 changed files with 7687 additions and 98 deletions

View File

@@ -14,11 +14,11 @@
| **US-LIVING-BLOCKS** | Blocs Vivants (Transclusion Bidirectionnelle) | ✅ **LIVRÉ** | `tiptap-unique-id-extension.ts`, `tiptap-live-block-extension.tsx`, `block-picker.tsx`, `app/api/blocks/*`, migration `LiveBlockRef` |
| **US-MEMORY-ECHO** | Résonance Sémantique + Embed depuis Echo | ✅ **LIVRÉ** | `memory-echo-section.tsx`, `/api/notes/[id]/live-block-refs`, `/api/blocks/resolve` |
| **US-INFO-RÉSEAU** | Panneau Info + Réseau Local | ✅ **LIVRÉ** | `note-network-tab.tsx`, `sync-note-links.ts`, migration `NoteLink`, picker `[[` |
| **US-CLIPPER** | Web Clipper | 🚧 **En cours** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info |
| **US-CLIPPER** | Web Clipper | **LIVRÉ** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info |
| **US-GRAPH** | Graphe de Connaissance Global enrichi | ✅ **LIVRÉ** | `note-graph-view.tsx` — filtres liens, seuil sémantique, focus voisinage, couleurs carnets, double-clic ouverture |
| **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | 🚧 **EN COURS** | clusters en base mais page masquait les résultats périmés — correction affichage |
| **US-TEMPORAL** | Prédictions d'accès temporelles | ⏳ À faire | — |
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ⏳ À faire | — |
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | **LIVRÉ** | `/revision`, `/api/flashcards/*`, SM-2, génération IA depuis l'éditeur |
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ⏳ À faire | — |
---

View File

@@ -0,0 +1,16 @@
'use client'
import dynamic from 'next/dynamic'
const RevisionView = dynamic(
() => import('@/components/flashcards/revision-view').then((m) => m.RevisionView),
{ ssr: false },
)
export default function RevisionPage() {
return (
<div className="h-full min-h-0 flex flex-col">
<RevisionView />
</div>
)
}

View File

@@ -157,7 +157,7 @@ Response format (COPY this structure):
let parsed: SuggestChartsResponse
try {
// Clean the response - remove markdown code blocks
let cleanText = text
const cleanText = text
.replace(/```json\n?/gi, '')
.replace(/```\n?/gi, '')
.trim()

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { computeSm2Update } from '@/lib/flashcards/sm2'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: cardId } = await params
const body = await request.json()
const grade = typeof body.grade === 'number' ? body.grade : Number(body.grade)
if (!Number.isFinite(grade) || grade < 1 || grade > 4) {
return NextResponse.json({ error: 'Grade must be 14' }, { status: 400 })
}
const card = await prisma.flashcard.findFirst({
where: {
id: cardId,
deck: { userId: session.user.id },
},
})
if (!card) {
return NextResponse.json({ error: 'Card not found' }, { status: 404 })
}
const updated = computeSm2Update(grade, {
easinessFactor: card.easinessFactor,
interval: card.interval,
})
const [savedCard] = await prisma.$transaction([
prisma.flashcard.update({
where: { id: cardId },
data: {
easinessFactor: updated.easinessFactor,
interval: updated.interval,
nextReviewAt: updated.nextReviewAt,
},
}),
prisma.flashcardReview.create({
data: { cardId, grade: Math.round(grade) },
}),
])
return NextResponse.json({
card: {
id: savedCard.id,
interval: savedCard.interval,
easinessFactor: savedCard.easinessFactor,
nextReviewAt: savedCard.nextReviewAt.toISOString(),
},
})
} catch (error) {
console.error('[flashcards/[id]/review]', error)
return NextResponse.json({ error: 'Review failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getDeckDetail } from '@/lib/flashcards/deck-queries'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const deck = await getDeckDetail(session.user.id, id)
if (!deck) {
return NextResponse.json({ error: 'Deck not found' }, { status: 404 })
}
const now = new Date()
const dueCount = deck.cards.filter((c) => c.due).length
const masteredCount = deck.cards.filter((c) => c.mastered).length
return NextResponse.json({
deck,
stats: {
total: deck.cards.length,
due: dueCount,
mastered: masteredCount,
},
})
} catch (error) {
console.error('[flashcards/decks/[id] GET]', error)
return NextResponse.json({ error: 'Failed to load deck' }, { status: 500 })
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { listDeckSummaries } from '@/lib/flashcards/deck-queries'
import { getOrCreateDeckForNotebook } from '@/lib/flashcards/deck-utils'
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const decks = await listDeckSummaries(session.user.id)
return NextResponse.json({ decks })
} catch (error) {
console.error('[flashcards/decks GET]', error)
return NextResponse.json({ error: 'Failed to load decks' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (!name) {
return NextResponse.json({ error: 'Name required' }, { status: 400 })
}
const deck = await getOrCreateDeckForNotebook({
userId: session.user.id,
notebookId: null,
manualName: name,
})
return NextResponse.json({
deck: {
id: deck.id,
name: deck.name,
notebookId: deck.notebookId,
},
})
} catch (error) {
console.error('[flashcards/decks POST]', error)
return NextResponse.json({ error: 'Failed to create deck' }, { status: 500 })
}
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getAISettings } from '@/app/actions/ai-settings'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const userSettings = await getAISettings(session.user.id)
if (userSettings.paragraphRefactor === false) {
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })
}
const body = await request.json()
const noteId = typeof body.noteId === 'string' ? body.noteId : ''
const count = typeof body.count === 'number' ? body.count : 10
const styleRaw = typeof body.style === 'string' ? body.style : 'qa'
const style: FlashcardStyle = ['qa', 'cloze', 'concept'].includes(styleRaw)
? (styleRaw as FlashcardStyle)
: 'qa'
if (!noteId) {
return NextResponse.json({ error: 'noteId required' }, { status: 400 })
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id, trashedAt: null },
select: { id: true, title: true, content: true, language: true },
})
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
}
const textContent = stripHtmlToText(note.content)
if (textContent.length < 80) {
return NextResponse.json({ error: 'Not enough content to generate flashcards' }, { status: 400 })
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json({
error: isTierLocked ? 'feature_locked' : 'quota_exceeded',
errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded',
quotaExceeded: true,
}, { status: 402 })
}
throw err
}
const cards = await generateFlashcardsFromNote({
title: note.title || 'Untitled',
textContent,
count,
style,
language: note.language || undefined,
})
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({ cards, noteId: note.id, style })
} catch (error) {
console.error('[flashcards/generate]', error)
const message = error instanceof Error ? error.message : 'Generation failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getOrCreateDeckForNotebook } from '@/lib/flashcards/deck-utils'
import type { FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
interface CardInput {
front: string
back: string
type?: string
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const noteId = typeof body.noteId === 'string' ? body.noteId : null
const deckIdInput = typeof body.deckId === 'string' ? body.deckId : null
const cards = Array.isArray(body.cards) ? body.cards as CardInput[] : []
if (cards.length === 0) {
return NextResponse.json({ error: 'No cards to save' }, { status: 400 })
}
let notebookId: string | null = null
let fallbackDeckName: string | undefined
if (noteId) {
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { notebookId: true, title: true },
})
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
}
notebookId = note.notebookId
if (!notebookId) {
fallbackDeckName = note.title?.trim() || 'General'
}
}
let deckId = deckIdInput
if (!deckId) {
if (noteId) {
const existingFromNote = await prisma.flashcard.findFirst({
where: { noteId, deck: { userId: session.user.id } },
select: { deckId: true },
})
if (existingFromNote) {
deckId = existingFromNote.deckId
}
}
if (!deckId) {
const deck = await getOrCreateDeckForNotebook({
userId: session.user.id,
notebookId,
manualName: fallbackDeckName,
})
deckId = deck.id
}
} else {
const deck = await prisma.flashcardDeck.findFirst({
where: { id: deckId, userId: session.user.id },
})
if (!deck) {
return NextResponse.json({ error: 'Deck not found' }, { status: 404 })
}
}
const sanitized = cards
.map((c) => ({
front: typeof c.front === 'string' ? c.front.trim().slice(0, 500) : '',
back: typeof c.back === 'string' ? c.back.trim().slice(0, 800) : '',
type: (['qa', 'cloze', 'concept'].includes(c.type || '') ? c.type : 'qa') as FlashcardStyle,
}))
.filter((c) => c.front && c.back)
if (sanitized.length === 0) {
return NextResponse.json({ error: 'No valid cards' }, { status: 400 })
}
await prisma.flashcard.createMany({
data: sanitized.map((c) => ({
deckId: deckId!,
noteId,
front: c.front,
back: c.back,
type: c.type,
})),
})
await prisma.flashcardDeck.update({
where: { id: deckId! },
data: { updatedAt: new Date() },
})
return NextResponse.json({
deckId,
savedCount: sanitized.length,
})
} catch (error) {
console.error('[flashcards/save]', error)
return NextResponse.json({ error: 'Failed to save flashcards' }, { status: 500 })
}
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { isCardMastered } from '@/lib/flashcards/sm2'
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const now = new Date()
const [reviews, allCards] = await Promise.all([
prisma.flashcardReview.findMany({
where: { card: { deck: { userId } } },
select: { reviewedAt: true, grade: true },
orderBy: { reviewedAt: 'desc' },
take: 500,
}),
prisma.flashcard.findMany({
where: { deck: { userId } },
select: { id: true, interval: true, easinessFactor: true, front: true, deck: { select: { name: true } } },
}),
])
const heatmapMap = new Map<string, number>()
for (const r of reviews) {
const day = r.reviewedAt.toISOString().slice(0, 10)
heatmapMap.set(day, (heatmapMap.get(day) || 0) + 1)
}
const heatmap = Array.from(heatmapMap.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-90)
const totalCards = allCards.length
const masteredCount = allCards.filter((c) => isCardMastered(c.interval)).length
const retentionRate = totalCards > 0 ? Math.round((masteredCount / totalCards) * 100) : 0
const weekMs = 7 * 24 * 60 * 60 * 1000
const retentionByWeek: { week: string; rate: number }[] = []
for (let i = 7; i >= 0; i--) {
const weekEnd = new Date(now.getTime() - i * weekMs)
const weekStart = new Date(weekEnd.getTime() - weekMs)
const cardsAtWeek = allCards.filter((c) => c.interval >= 7 * (8 - i))
const masteredAtWeek = cardsAtWeek.filter((c) => isCardMastered(c.interval)).length
const rate = cardsAtWeek.length > 0
? Math.round((masteredAtWeek / cardsAtWeek.length) * 100)
: retentionRate
retentionByWeek.push({
week: weekStart.toISOString().slice(0, 10),
rate,
})
}
const difficultCards = [...allCards]
.sort((a, b) => a.easinessFactor - b.easinessFactor)
.slice(0, 5)
.map((c) => ({
id: c.id,
front: c.front.slice(0, 120),
easinessFactor: c.easinessFactor,
deckName: c.deck.name,
}))
return NextResponse.json({
heatmap,
retentionRate,
retentionByWeek,
difficultCards,
totalReviews: reviews.length,
})
} catch (error) {
console.error('[flashcards/stats]', error)
return NextResponse.json({ error: 'Failed to load stats' }, { status: 500 })
}
}

View File

@@ -51,7 +51,7 @@ export async function POST() {
if (wikilinks.length === 0) continue
for (const { title, snippet } of wikilinks) {
let targetNote = await prisma.note.findFirst({
const targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },

View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useCallback } from 'react'
import { GraduationCap, Loader2, Sparkles, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export type FlashcardStyle = 'qa' | 'cloze' | 'concept'
export interface PreviewCard {
front: string
back: string
type: FlashcardStyle
}
interface FlashcardGenerateDialogProps {
open: boolean
onClose: () => void
noteId: string
noteTitle: string
onSaved?: (deckId: string) => void
}
export function FlashcardGenerateDialog({
open,
onClose,
noteId,
noteTitle,
onSaved,
}: FlashcardGenerateDialogProps) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [count, setCount] = useState(10)
const [style, setStyle] = useState<FlashcardStyle>('qa')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [cards, setCards] = useState<PreviewCard[] | null>(null)
const handleGenerate = useCallback(async () => {
if (!(await requestAiConsent())) return
setLoading(true)
try {
const res = await fetch('/api/flashcards/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId, count, style }),
})
const data = await res.json()
if (!res.ok) {
if (data.errorKey) {
toast.error(t(data.errorKey) || data.error)
} else {
toast.error(data.error || t('flashcards.generateFailed'))
}
return
}
setCards(data.cards || [])
} catch {
toast.error(t('flashcards.generateFailed'))
} finally {
setLoading(false)
}
}, [count, noteId, requestAiConsent, style, t])
const handleSave = useCallback(async () => {
if (!cards?.length) return
setSaving(true)
try {
const res = await fetch('/api/flashcards/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId, cards }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.saveFailed'))
return
}
toast.success(t('flashcards.savedCount', { count: data.savedCount }))
onSaved?.(data.deckId)
onClose()
setCards(null)
} catch {
toast.error(t('flashcards.saveFailed'))
} finally {
setSaving(false)
}
}, [cards, noteId, onClose, onSaved, t])
const updateCard = (index: number, field: 'front' | 'back', value: string) => {
setCards((prev) => {
if (!prev) return prev
const next = [...prev]
next[index] = { ...next[index], [field]: value }
return next
})
}
if (!open) return null
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<div
className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
<div className="flex items-center gap-2">
<GraduationCap size={18} className="text-brand-accent" />
<div>
<h2 className="text-sm font-semibold">{t('flashcards.generateTitle')}</h2>
<p className="text-[11px] text-muted-foreground truncate max-w-[280px]">{noteTitle}</p>
</div>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-black/5">
<X size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{!cards ? (
<>
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-concrete block mb-2">
{t('flashcards.cardCount')} ({count})
</label>
<input
type="range"
min={5}
max={20}
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="w-full accent-brand-accent"
/>
</div>
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-concrete block mb-2">
{t('flashcards.styleLabel')}
</label>
<div className="flex flex-wrap gap-2">
{(['qa', 'cloze', 'concept'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setStyle(s)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
style === s
? 'bg-brand-accent/10 border-brand-accent/40 text-brand-accent'
: 'border-border text-muted-foreground hover:border-brand-accent/30',
)}
>
{t(`flashcards.style.${s}`)}
</button>
))}
</div>
</div>
</>
) : (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t('flashcards.previewHint')}</p>
{cards.map((card, i) => (
<div key={i} className="p-3 rounded-xl border border-border/60 space-y-2 bg-paper/30">
<input
value={card.front}
onChange={(e) => updateCard(i, 'front', e.target.value)}
className="w-full text-sm font-medium bg-transparent border-b border-border/40 pb-1 outline-none"
placeholder={t('flashcards.frontPlaceholder')}
/>
<textarea
value={card.back}
onChange={(e) => updateCard(i, 'back', e.target.value)}
rows={2}
className="w-full text-xs text-muted-foreground bg-transparent outline-none resize-none"
placeholder={t('flashcards.backPlaceholder')}
/>
</div>
))}
</div>
)}
</div>
<div className="px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs font-medium rounded-lg border border-border hover:bg-black/[0.03]"
>
{t('general.cancel')}
</button>
{!cards ? (
<button
type="button"
onClick={handleGenerate}
disabled={loading}
className="px-4 py-2 text-xs font-bold rounded-lg bg-brand-accent text-white flex items-center gap-1.5 disabled:opacity-60"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
{t('flashcards.generateAction')}
</button>
) : (
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-xs font-bold rounded-lg bg-brand-accent text-white flex items-center gap-1.5 disabled:opacity-60"
>
{saving && <Loader2 size={14} className="animate-spin" />}
{t('flashcards.confirmSave')}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface HeatmapDay {
date: string
count: number
}
interface RevisionHeatmapProps {
data: HeatmapDay[]
className?: string
}
function intensityClass(count: number, max: number): string {
if (count <= 0) return 'bg-black/[0.04] dark:bg-white/[0.06]'
const ratio = count / Math.max(max, 1)
if (ratio >= 0.75) return 'bg-brand-accent'
if (ratio >= 0.5) return 'bg-brand-accent/70'
if (ratio >= 0.25) return 'bg-brand-accent/40'
return 'bg-brand-accent/20'
}
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
const { t } = useLanguage()
const { cells, maxCount } = useMemo(() => {
const map = new Map(data.map((d) => [d.date, d.count]))
const today = new Date()
const cells: { date: string; count: number; label: string }[] = []
for (let i = 89; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
cells.push({
date: key,
count: map.get(key) || 0,
label: d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }),
})
}
const maxCount = Math.max(1, ...cells.map((c) => c.count))
return { cells, maxCount }
}, [data])
return (
<div className={cn('space-y-3', className)}>
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{t('flashcards.heatmapTitle')}
</p>
<span className="text-[10px] text-concrete/60">{t('flashcards.heatmapLast90')}</span>
</div>
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => (
<div
key={cell.date}
title={`${cell.label}: ${cell.count}`}
className={cn(
'aspect-square rounded-[3px] transition-colors',
intensityClass(cell.count, maxCount),
)}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,575 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import {
GraduationCap,
Layers,
ArrowLeft,
ChevronLeft,
ChevronRight,
Calendar,
Plus,
BarChart3,
Loader2,
BookOpen,
} from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { NoteChart } from '@/components/note-chart'
import { RevisionHeatmap } from './revision-heatmap'
import { toast } from 'sonner'
interface DeckSummary {
id: string
name: string
notebookId: string | null
totalCards: number
dueCount: number
masteredCount: number
lastReviewedAt: string | null
}
interface FlashcardItem {
id: string
front: string
back: string
type: string
due: boolean
mastered: boolean
}
interface StatsResponse {
heatmap: { date: string; count: number }[]
retentionRate: number
retentionByWeek: { week: string; rate: number }[]
difficultCards: { id: string; front: string; easinessFactor: number; deckName: string }[]
}
type PageTab = 'decks' | 'progress'
type SessionGrade = 1 | 2 | 3 | 4
export function RevisionView() {
const { t } = useLanguage()
const searchParams = useSearchParams()
const initialDeckId = searchParams.get('deckId')
const [tab, setTab] = useState<PageTab>('decks')
const [decks, setDecks] = useState<DeckSummary[]>([])
const [loadingDecks, setLoadingDecks] = useState(true)
const [stats, setStats] = useState<StatsResponse | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [activeDeckId, setActiveDeckId] = useState<string | null>(null)
const [deckCards, setDeckCards] = useState<FlashcardItem[]>([])
const [deckStats, setDeckStats] = useState({ total: 0, due: 0, mastered: 0 })
const [loadingDeck, setLoadingDeck] = useState(false)
const [isSessionActive, setIsSessionActive] = useState(false)
const [isSessionFinished, setIsSessionFinished] = useState(false)
const [sessionCards, setSessionCards] = useState<FlashcardItem[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [isFlipped, setIsFlipped] = useState(false)
const [sessionGrades, setSessionGrades] = useState<Record<string, SessionGrade>>({})
const [reviewing, setReviewing] = useState(false)
const [newDeckName, setNewDeckName] = useState('')
const [creatingDeck, setCreatingDeck] = useState(false)
const loadDecks = useCallback(async () => {
setLoadingDecks(true)
try {
const res = await fetch('/api/flashcards/decks')
const data = await res.json()
if (res.ok) setDecks(data.decks || [])
} finally {
setLoadingDecks(false)
}
}, [])
const loadStats = useCallback(async () => {
setLoadingStats(true)
try {
const res = await fetch('/api/flashcards/stats')
const data = await res.json()
if (res.ok) setStats(data)
} finally {
setLoadingStats(false)
}
}, [])
const loadDeck = useCallback(async (deckId: string) => {
setLoadingDeck(true)
try {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
return
}
setActiveDeckId(deckId)
setDeckCards(data.deck.cards || [])
setDeckStats(data.stats)
} finally {
setLoadingDeck(false)
}
}, [t])
useEffect(() => {
loadDecks()
}, [loadDecks])
useEffect(() => {
if (tab === 'progress') loadStats()
}, [tab, loadStats])
useEffect(() => {
if (initialDeckId && !activeDeckId) {
loadDeck(initialDeckId)
}
}, [initialDeckId, activeDeckId, loadDeck])
const activeDeck = useMemo(
() => decks.find((d) => d.id === activeDeckId),
[decks, activeDeckId],
)
const sessionChartData = useMemo(() => {
const counts = { 1: 0, 2: 0, 3: 0, 4: 0 }
Object.values(sessionGrades).forEach((g) => { counts[g] += 1 })
return [
{ label: t('flashcards.grade.hard'), value: counts[1] },
{ label: t('flashcards.grade.difficult'), value: counts[2] },
{ label: t('flashcards.grade.good'), value: counts[3] },
{ label: t('flashcards.grade.easy'), value: counts[4] },
]
}, [sessionGrades, t])
const startSession = useCallback((deckId: string, cardsInput?: FlashcardItem[], dueOnly = true) => {
const cards = cardsInput ?? (activeDeckId === deckId ? deckCards : [])
if (cards.length === 0) {
void loadDeck(deckId)
return
}
let toReview = dueOnly ? cards.filter((c) => c.due) : cards
if (toReview.length === 0) toReview = cards
const shuffled = [...toReview].sort(() => Math.random() - 0.5)
setSessionCards(shuffled)
setCurrentIndex(0)
setIsFlipped(false)
setSessionGrades({})
setIsSessionActive(true)
setIsSessionFinished(false)
setActiveDeckId(deckId)
}, [activeDeckId, deckCards, loadDeck])
useEffect(() => {
if (!initialDeckId) return
let cancelled = false
;(async () => {
const res = await fetch(`/api/flashcards/decks/${initialDeckId}`)
const data = await res.json()
if (cancelled || !res.ok) return
const cards: FlashcardItem[] = data.deck?.cards || []
setActiveDeckId(initialDeckId)
setDeckCards(cards)
setDeckStats(data.stats || { total: 0, due: 0, mastered: 0 })
startSession(initialDeckId, cards)
})()
return () => { cancelled = true }
}, [initialDeckId, startSession])
const handleEvaluate = async (grade: SessionGrade) => {
const card = sessionCards[currentIndex]
if (!card || reviewing) return
setReviewing(true)
setSessionGrades((prev) => ({ ...prev, [card.id]: grade }))
try {
await fetch(`/api/flashcards/${card.id}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade }),
})
} catch {
toast.error(t('flashcards.reviewFailed'))
} finally {
setReviewing(false)
}
if (currentIndex < sessionCards.length - 1) {
setTimeout(() => {
setCurrentIndex((i) => i + 1)
setIsFlipped(false)
}, 250)
} else {
setTimeout(() => setIsSessionFinished(true), 250)
}
}
useEffect(() => {
if (!isSessionActive || isSessionFinished) return
const onKey = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault()
setIsFlipped((f) => !f)
} else if (isFlipped) {
if (e.key === '1') handleEvaluate(1)
if (e.key === '2') handleEvaluate(2)
if (e.key === '3') handleEvaluate(3)
if (e.key === '4') handleEvaluate(4)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isSessionActive, isSessionFinished, isFlipped, currentIndex, reviewing])
const exitSession = () => {
setIsSessionActive(false)
setIsSessionFinished(false)
if (activeDeckId) {
loadDeck(activeDeckId)
loadDecks()
}
}
const createDeck = async () => {
const name = newDeckName.trim()
if (!name) return
setCreatingDeck(true)
try {
const res = await fetch('/api/flashcards/decks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = await res.json()
if (res.ok) {
setNewDeckName('')
await loadDecks()
toast.success(t('flashcards.deckCreated'))
if (data.deck?.id) setActiveDeckId(data.deck.id)
}
} finally {
setCreatingDeck(false)
}
}
const formatDue = (dueCount: number) => {
if (dueCount > 0) return t('flashcards.dueCount', { count: dueCount })
return t('flashcards.upToDate')
}
return (
<div className="h-full flex flex-col bg-memento-paper dark:bg-background overflow-y-auto">
<div className="px-6 sm:px-10 py-5 flex items-center justify-between sticky top-0 bg-memento-paper/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border/40">
<div className="flex items-center gap-3">
{isSessionActive ? (
<button
type="button"
onClick={exitSession}
className="flex items-center gap-2 text-concrete hover:text-foreground transition-colors"
>
<ArrowLeft size={16} />
<span className="text-xs font-bold uppercase tracking-widest">{t('flashcards.exitSession')}</span>
</button>
) : (
<div className="flex items-center gap-2">
<GraduationCap className="text-brand-accent" size={20} />
<h1 className="text-sm font-black tracking-widest uppercase">{t('nav.revision')}</h1>
</div>
)}
</div>
{!isSessionActive && (
<div className="flex gap-1 p-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.04]">
{(['decks', 'progress'] as const).map((id) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn(
'px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors',
tab === id ? 'bg-white dark:bg-card shadow-sm text-brand-accent' : 'text-concrete',
)}
>
{id === 'decks' ? t('flashcards.tabDecks') : t('flashcards.tabProgress')}
</button>
))}
</div>
)}
</div>
<div className="flex-1 max-w-5xl mx-auto w-full p-6 sm:p-10">
<AnimatePresence mode="wait">
{isSessionActive && !isSessionFinished && sessionCards[currentIndex] && (
<motion.div
key="session"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="max-w-2xl mx-auto flex flex-col items-center gap-8"
>
<div className="w-full flex items-center justify-between text-xs text-concrete">
<button
type="button"
disabled={currentIndex === 0}
onClick={() => { setCurrentIndex((i) => i - 1); setIsFlipped(false) }}
className="flex items-center gap-1 disabled:opacity-30"
>
<ChevronLeft size={16} /> {t('flashcards.previous')}
</button>
<span className="font-mono font-bold px-3 py-1 rounded-full bg-black/[0.04] dark:bg-white/[0.06]">
{currentIndex + 1} / {sessionCards.length}
</span>
<button
type="button"
disabled={currentIndex >= sessionCards.length - 1}
onClick={() => { setCurrentIndex((i) => i + 1); setIsFlipped(false) }}
className="flex items-center gap-1 disabled:opacity-30"
>
{t('flashcards.next')} <ChevronRight size={16} />
</button>
</div>
<button
type="button"
onClick={() => setIsFlipped((f) => !f)}
className="w-full max-w-md min-h-[240px] [perspective:1000px] cursor-pointer select-none"
>
<div
className={cn(
'relative w-full min-h-[240px] transition-transform duration-500 [transform-style:preserve-3d]',
isFlipped && '[transform:rotateY(180deg)]',
)}
>
<div className="absolute inset-0 [backface-visibility:hidden] rounded-2xl border border-border bg-card p-8 flex flex-col shadow-sm">
<span className="text-[10px] uppercase tracking-widest text-concrete mb-4">{t('flashcards.front')}</span>
<p className="flex-1 flex items-center justify-center text-center text-lg font-serif font-semibold">
{sessionCards[currentIndex].front}
</p>
<span className="text-[10px] text-concrete/60 text-center">{t('flashcards.tapToFlip')}</span>
</div>
<div className="absolute inset-0 [backface-visibility:hidden] [transform:rotateY(180deg)] rounded-2xl border border-brand-accent/30 bg-brand-accent/5 p-8 flex flex-col shadow-md">
<span className="text-[10px] uppercase tracking-widest text-brand-accent mb-4">{t('flashcards.back')}</span>
<p className="flex-1 flex items-center justify-center text-center text-sm leading-relaxed overflow-y-auto">
{sessionCards[currentIndex].back}
</p>
</div>
</div>
</button>
{isFlipped && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 w-full max-w-md">
{([
[1, t('flashcards.grade.hard'), 'bg-red-500/10 text-red-600 border-red-500/20'],
[2, t('flashcards.grade.difficult'), 'bg-amber-500/10 text-amber-700 border-amber-500/20'],
[3, t('flashcards.grade.good'), 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20'],
[4, t('flashcards.grade.easy'), 'bg-brand-accent/10 text-brand-accent border-brand-accent/30'],
] as const).map(([grade, label, cls]) => (
<button
key={grade}
type="button"
disabled={reviewing}
onClick={() => handleEvaluate(grade as SessionGrade)}
className={cn('py-2.5 px-2 rounded-xl border text-[10px] font-bold uppercase tracking-wide', cls)}
>
{label}
</button>
))}
</div>
)}
</motion.div>
)}
{isSessionFinished && (
<motion.div
key="finished"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-lg mx-auto text-center space-y-6"
>
<GraduationCap size={40} className="mx-auto text-brand-accent" />
<h2 className="text-2xl font-serif font-bold">{t('flashcards.sessionComplete')}</h2>
<div className="h-48">
<NoteChart type="bar" data={sessionChartData} height={180} />
</div>
<button
type="button"
onClick={exitSession}
className="px-6 py-2.5 rounded-xl bg-brand-accent text-white text-xs font-bold uppercase tracking-wider"
>
{t('flashcards.backToDecks')}
</button>
</motion.div>
)}
{!isSessionActive && tab === 'decks' && (
<motion.div key="decks" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
{activeDeckId && deckCards.length > 0 && (
<div className="p-5 rounded-2xl border border-brand-accent/25 bg-brand-accent/5 space-y-3">
<h3 className="font-serif font-semibold text-lg">{activeDeck?.name || t('flashcards.activeDeck')}</h3>
<div className="flex flex-wrap gap-3 text-xs text-concrete">
<span>{t('flashcards.statTotal', { count: deckStats.total })}</span>
<span>{t('flashcards.statDue', { count: deckStats.due })}</span>
<span>{t('flashcards.statMastered', { count: deckStats.mastered })}</span>
</div>
<button
type="button"
onClick={() => startSession(activeDeckId)}
disabled={loadingDeck}
className="px-4 py-2 rounded-lg bg-brand-accent text-white text-xs font-bold uppercase tracking-wider flex items-center gap-2"
>
{loadingDeck ? <Loader2 size={14} className="animate-spin" /> : <GraduationCap size={14} />}
{t('flashcards.startReview')}
</button>
</div>
)}
<div className="flex flex-wrap gap-2 items-center">
<input
value={newDeckName}
onChange={(e) => setNewDeckName(e.target.value)}
placeholder={t('flashcards.newDeckPlaceholder')}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg border border-border text-sm bg-transparent"
/>
<button
type="button"
onClick={createDeck}
disabled={creatingDeck || !newDeckName.trim()}
className="px-3 py-2 rounded-lg border border-border text-xs font-bold flex items-center gap-1 disabled:opacity-50"
>
{creatingDeck ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
{t('flashcards.createDeck')}
</button>
</div>
{loadingDecks ? (
<div className="flex justify-center py-16"><Loader2 className="animate-spin text-concrete" /></div>
) : decks.length === 0 ? (
<div className="text-center py-16 border border-dashed border-border rounded-2xl space-y-3">
<GraduationCap size={32} className="mx-auto text-concrete/40" />
<p className="text-sm text-concrete">{t('flashcards.emptyDecks')}</p>
<p className="text-xs text-concrete/60 max-w-md mx-auto">{t('flashcards.emptyDecksHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{decks.map((deck) => (
<div
key={deck.id}
className="p-5 rounded-2xl border border-border/60 bg-card/50 hover:border-brand-accent/30 transition-colors flex flex-col gap-4"
>
<div>
<h3 className="font-serif font-semibold truncate">{deck.name}</h3>
<p className="text-xs text-concrete flex items-center gap-1 mt-1">
<Layers size={12} />
{t('flashcards.cardCountLabel', { count: deck.totalCards })}
</p>
</div>
<div className="flex flex-wrap gap-2 text-[10px]">
{deck.dueCount > 0 ? (
<span className="px-2 py-1 rounded-full bg-red-500/10 text-red-600 font-bold border border-red-500/15">
{formatDue(deck.dueCount)}
</span>
) : (
<span className="px-2 py-1 rounded-full bg-emerald-500/10 text-emerald-700 font-bold border border-emerald-500/15">
{t('flashcards.upToDate')}
</span>
)}
<span className="px-2 py-1 rounded-full border border-border flex items-center gap-1 font-mono">
<Calendar size={10} />
{deck.masteredCount}/{deck.totalCards} {t('flashcards.masteredShort')}
</span>
</div>
<div className="flex gap-2 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => loadDeck(deck.id)}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-border rounded-lg hover:bg-black/[0.03]"
>
{t('flashcards.viewDeck')}
</button>
<button
type="button"
onClick={async () => {
setLoadingDeck(true)
try {
const res = await fetch(`/api/flashcards/decks/${deck.id}`)
const data = await res.json()
if (res.ok) {
setActiveDeckId(deck.id)
setDeckCards(data.deck.cards || [])
setDeckStats(data.stats)
startSession(deck.id, data.deck.cards || [])
}
} finally {
setLoadingDeck(false)
}
}}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider bg-brand-accent text-white rounded-lg flex items-center justify-center gap-1"
>
<GraduationCap size={12} />
{t('flashcards.review')}
</button>
</div>
</div>
))}
</div>
)}
</motion.div>
)}
{!isSessionActive && tab === 'progress' && (
<motion.div key="progress" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
{loadingStats ? (
<div className="flex justify-center py-16"><Loader2 className="animate-spin text-concrete" /></div>
) : stats ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl border border-border bg-card/50">
<p className="text-[10px] uppercase tracking-widest text-concrete">{t('flashcards.retentionRate')}</p>
<p className="text-3xl font-serif font-bold mt-1">{stats.retentionRate}%</p>
</div>
<div className="p-4 rounded-xl border border-border bg-card/50 sm:col-span-2">
<p className="text-[10px] uppercase tracking-widest text-concrete mb-2 flex items-center gap-1">
<BarChart3 size={12} /> {t('flashcards.retentionCurve')}
</p>
<NoteChart
type="line"
data={stats.retentionByWeek.map((w) => ({
label: w.week.slice(5),
value: w.rate,
}))}
height={120}
/>
</div>
</div>
<RevisionHeatmap data={stats.heatmap} />
{stats.difficultCards.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-bold uppercase tracking-widest text-concrete flex items-center gap-2">
<BookOpen size={14} /> {t('flashcards.difficultCards')}
</h3>
<ul className="space-y-2">
{stats.difficultCards.map((c) => (
<li key={c.id} className="p-3 rounded-xl border border-border/60 text-sm flex justify-between gap-4">
<span className="truncate">{c.front}</span>
<span className="text-[10px] font-mono text-concrete shrink-0">EF {c.easinessFactor.toFixed(2)}</span>
</li>
))}
</ul>
</div>
)}
</>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -313,9 +313,19 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}, [content, isMarkdown])
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
const extracted = !isMarkdown ? extractImagesFromHTML(contentToSave) : []
return Array.from(new Set([...images, ...extracted]))
}, [images, isMarkdown])
if (!contentToSave) return []
if (!isMarkdown) {
return extractImagesFromHTML(contentToSave)
} else {
const urls = new Set<string>()
const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g)
for (const match of matches) {
const src = match[1]?.trim()
if (src) urls.add(src)
}
return Array.from(urls)
}
}, [isMarkdown])
const handleGenerateTitles = async () => {
const fullContentForAI = [
@@ -613,8 +623,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (contentToSave !== content) setContent(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
const deletedImages = Array.from(new Set([
...removedImageUrls,
...images.filter(url => !imagesToSave.includes(url))
]))
if (deletedImages.length > 0) {
cleanupOrphanedImages(deletedImages, note.id).catch(() => {})
setRemovedImageUrls([])
}
await refreshLabels()
onNoteSaved?.(result)
@@ -732,8 +747,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (contentToSave !== content) setContent(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
const deletedImages = Array.from(new Set([
...removedImageUrls,
...images.filter(url => !imagesToSave.includes(url))
]))
if (deletedImages.length > 0) {
cleanupOrphanedImages(deletedImages, note.id).catch(() => {})
setRemovedImageUrls([])
}
await refreshLabels()
onNoteSaved?.(result)

View File

@@ -18,8 +18,9 @@ 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
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
@@ -41,6 +42,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const { t } = useLanguage()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
@@ -169,6 +171,17 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<Wind size={16} />
</button>
{!readOnly && (
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setFlashcardsOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
)}
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
@@ -247,6 +260,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
/>
)}
<FlashcardGenerateDialog
open={flashcardsOpen}
onClose={() => setFlashcardsOpen(false)}
noteId={note.id}
noteTitle={state.title || note.title || 'Untitled'}
onSaved={(deckId) => {
window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self')
}}
/>
<button
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}

View File

@@ -508,7 +508,6 @@ export function NoteNetworkTab({ noteId, noteTitle }: NoteNetworkTabProps) {
})
return nodes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedSemantic, outbound, backlinks, embedHosts, notebooks, t])
const orbitNodes = graphNodes.slice(0, MAX_GRAPH_NODES)

View File

@@ -959,7 +959,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
@@ -1258,27 +1258,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</motion.div>
)}
{/* ── Vue Révisions (placeholder en attendant US-FLASHCARDS) ── */}
{activeView === 'revision' && (
<motion.div
key="revision"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<div className="flex items-center gap-1.5 mb-4">
<GraduationCap size={13} className="text-brand-accent" />
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">Révisions</p>
</div>
<div className="flex flex-col items-center justify-center text-center p-6 border border-dashed border-border/50 rounded-2xl bg-paper/20 space-y-3">
<GraduationCap size={24} className="text-concrete/40" />
<p className="text-[11px] font-medium text-concrete/70">Flashcards bientôt disponibles</p>
<p className="text-[10px] text-concrete/50">Les decks de révision IA (SM-2) arrivent dans la prochaine itération.</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>

View File

@@ -44,7 +44,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
}
})
.catch(() => setIsOffline(true))
}, [sourceNoteId, blockId]) // eslint-disable-line react-hooks/exhaustive-deps
}, [sourceNoteId, blockId])
// Listen for real-time block update events
useEffect(() => {

View File

@@ -2,6 +2,12 @@
Clipper web avec **panneau latéral** : le panneau reste ouvert pendant que vous surlignez du texte sur la page.
## Langues
Lextension suit la **langue de linterface Chrome** (`chrome.i18n.getUILanguage`) — 15 locales comme lapp Momento : `de`, `en`, `es`, `fr`, `it`, `pt`, `nl`, `pl`, `ru`, `zh`, `ja`, `ko`, `ar`, `fa`, `hi`.
Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node extension/i18n/generate-translations.cjs` puis `node extension/scripts/build-extension-locales.mjs`.
## Installation (dev)
1. Chrome → `chrome://extensions`

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "مومنتو ويب كليبر"
},
"extDescription": {
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Momento الخاصة بك - ويتصل بخادم Momento الخاص بك."
},
"extActionTitle": {
"message": "مقطع إلى مومنتو"
},
"webClipper": {
"message": "مقص الويب"
},
"connected": {
"message": "متصل"
},
"disconnected": {
"message": "غير متصل"
},
"instanceSettings": {
"message": "عنوان URL لمومنتو"
},
"instanceUrlLabel": {
"message": "عنوان URL لمثيل Momento الخاص بك"
},
"presetProduction": {
"message": "إعداد مسبق للإنتاج · memento-note.com"
},
"applyReconnect": {
"message": "تطبيق وإعادة الاتصال"
},
"openMomento": {
"message": "افتح مومنتو"
},
"settingsHint": {
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Momento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
},
"footerVersion": {
"message": "Momento Web Clipper <<<الإصدار>>>"
},
"errPermissionDenied": {
"message": "لا يستطيع Momento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
},
"notebookUnnamed": {
"message": "دفتر بلا عنوان"
},
"noNotebooks": {
"message": "لا توجد دفاتر ملاحظات حتى الآن"
},
"readingTimeOne": {
"message": "~1 دقيقة قراءة"
},
"readingTimeOther": {
"message": "تقريبا. $COUNT$ دقيقة قراءة",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "تم الكشف عن التحديد"
},
"ignore": {
"message": "يتجاهل"
},
"selectionHint": {
"message": "نصيحة: قم بتمييز النص الموجود على الصفحة لقص التحديد الدقيق كملاحظة."
},
"clipSelection": {
"message": "اختيار المقطع"
},
"clipPage": {
"message": "قص هذه الصفحة"
},
"saveLinkOnly": {
"message": "حفظ الرابط فقط"
},
"pageNotAccessible": {
"message": "لا يمكن القص هنا — هذه الصفحة تحظر الوصول إلى الإضافات."
},
"errLoginRequired": {
"message": "يرجى تسجيل الدخول إلى Momento في هذا المتصفح أولاً."
},
"errLoadNotebooks": {
"message": "تعذر تحميل دفاتر الملاحظات. حاول إعادة الاتصال."
},
"notebooksLoaded": {
"message": "تم تحميل دفاتر الملاحظات"
},
"connecting": {
"message": "جارٍ الاتصال…"
},
"connectedToUrl": {
"message": "متصل بـ $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Momento أو اللوحة الجانبية."
},
"destinationNotebook": {
"message": "دفتر الوجهة"
},
"activePage": {
"message": "صفحة نشطة"
},
"previewBeforeSave": {
"message": "المراجعة قبل الحفظ"
},
"noteTitleLabel": {
"message": "عنوان"
},
"excerptLabel": {
"message": "مقتطفات"
},
"saveToMomento": {
"message": "حفظ إلى مومنتو"
},
"back": {
"message": "خلف"
},
"analyzingSource": {
"message": "تحليل المصدر"
},
"statusAnalyzing": {
"message": "جارٍ التحليل…"
},
"statusSaving": {
"message": "توفير…"
},
"processingDetail": {
"message": "إنشاء العلامات والملخص الدلالي والتضمينات."
},
"noteSaved": {
"message": "تم حفظ الملاحظة"
},
"sentToNotebook": {
"message": "تم الحفظ في $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "عرض في مومنتو"
},
"clipAnother": {
"message": "قص صفحة أخرى"
},
"failure": {
"message": "لا يمكن إكماله"
},
"genericError": {
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Momento."
},
"retry": {
"message": "أعد المحاولة"
},
"errNoSelection": {
"message": "حدد النص أولاً، أو قم بقص الصفحة بأكملها."
},
"errAnalyzeFailed": {
"message": "لا يمكن تحليل هذه الصفحة."
},
"errSaveFailed": {
"message": "لا يمكن حفظ ملاحظتك."
},
"errNetwork": {
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Momento."
},
"bannerPickText": {
"message": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Momento-Notizbüchern stellt eine Verbindung zu Ihrem eigenen Momento-Server her."
},
"extActionTitle": {
"message": "Clip auf Momento"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "Verbunden"
},
"disconnected": {
"message": "Nicht verbunden"
},
"instanceSettings": {
"message": "Momento-URL"
},
"instanceUrlLabel": {
"message": "Ihre Momento-Instanz-URL"
},
"presetProduction": {
"message": "Produktionsvoreinstellung · memento-note.com"
},
"applyReconnect": {
"message": "Anwenden und erneut verbinden"
},
"openMomento": {
"message": "Öffnen Sie Momento"
},
"settingsHint": {
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Momento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen oder öffnen Sie den Seitenbereich."
},
"notebookUnnamed": {
"message": "Notizbuch ohne Titel"
},
"noNotebooks": {
"message": "Noch keine Notizbücher"
},
"readingTimeOne": {
"message": "~1 Minute gelesen"
},
"readingTimeOther": {
"message": "Ca. $COUNT$ min. gelesen",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Auswahl erkannt"
},
"ignore": {
"message": "ignorieren"
},
"selectionHint": {
"message": "Tipp: Markieren Sie Text auf der Seite, um eine präzise Auswahl als Notiz auszuschneiden."
},
"clipSelection": {
"message": "Clip-Auswahl"
},
"clipPage": {
"message": "Clip diese Seite aus"
},
"saveLinkOnly": {
"message": "Nur Link speichern"
},
"pageNotAccessible": {
"message": "Hier kann kein Clip erstellt werden diese Seite blockiert den Zugriff auf die Erweiterung."
},
"errLoginRequired": {
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Momento an."
},
"errLoadNotebooks": {
"message": "Notebooks konnten nicht geladen werden. Versuchen Sie, die Verbindung wiederherzustellen."
},
"notebooksLoaded": {
"message": "Notizbücher geladen"
},
"connecting": {
"message": "Verbinden…"
},
"connectedToUrl": {
"message": "Verbunden mit $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Eingeschränkte Seite Ausschneiden über die Momento-Symbolleiste oder den Seitenbereich."
},
"destinationNotebook": {
"message": "Zielnotizbuch"
},
"activePage": {
"message": "Aktive Seite"
},
"previewBeforeSave": {
"message": "Vor dem Speichern überprüfen"
},
"noteTitleLabel": {
"message": "Titel"
},
"excerptLabel": {
"message": "Auszug"
},
"saveToMomento": {
"message": "In Momento speichern"
},
"back": {
"message": "Zurück"
},
"analyzingSource": {
"message": "Quelle analysieren"
},
"statusAnalyzing": {
"message": "Analysieren…"
},
"statusSaving": {
"message": "Sparen…"
},
"processingDetail": {
"message": "Generieren von Tags, einer semantischen Zusammenfassung und Einbettungen."
},
"noteSaved": {
"message": "Notiz gespeichert"
},
"sentToNotebook": {
"message": "Gespeichert unter $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "In Momento ansehen"
},
"clipAnother": {
"message": "Schneiden Sie eine weitere Seite aus"
},
"failure": {
"message": "Konnte nicht abgeschlossen werden"
},
"genericError": {
"message": "Beim Erreichen Ihrer Momento-Instanz ist ein Fehler aufgetreten."
},
"retry": {
"message": "Wiederholen"
},
"errNoSelection": {
"message": "Wählen Sie zuerst den Text aus oder schneiden Sie die gesamte Seite aus."
},
"errAnalyzeFailed": {
"message": "Diese Seite konnte nicht analysiert werden."
},
"errSaveFailed": {
"message": "Ihre Notiz konnte nicht gespeichert werden."
},
"errNetwork": {
"message": "Netzwerkproblem überprüfen Sie Ihre Verbindung und Momento-URL."
},
"bannerPickText": {
"message": "Markieren Sie Text auf der Seite oder schneiden Sie die gesamte Seite aus."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Capture web pages and highlighted text into your Momento notebooks — connects to your own Momento server."
},
"extActionTitle": {
"message": "Clip to Momento"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "Connected"
},
"disconnected": {
"message": "Not connected"
},
"instanceSettings": {
"message": "Momento URL"
},
"instanceUrlLabel": {
"message": "Your Momento instance URL"
},
"presetProduction": {
"message": "Production preset · memento-note.com"
},
"applyReconnect": {
"message": "Apply and reconnect"
},
"openMomento": {
"message": "Open Momento"
},
"settingsHint": {
"message": "Paste the HTTPS (or LAN) URL of your Momento server. Cookies in this browser handle sign-in."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
},
"notebookUnnamed": {
"message": "Untitled notebook"
},
"noNotebooks": {
"message": "No notebooks yet"
},
"readingTimeOne": {
"message": "~1 minute read"
},
"readingTimeOther": {
"message": "Approx. $COUNT$ min read",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selection detected"
},
"ignore": {
"message": "ignore"
},
"selectionHint": {
"message": "Tip: highlight text on the page to clip a precise selection as a note."
},
"clipSelection": {
"message": "Clip selection"
},
"clipPage": {
"message": "Clip this page"
},
"saveLinkOnly": {
"message": "Save link only"
},
"pageNotAccessible": {
"message": "Can't clip here — this page blocks extension access."
},
"errLoginRequired": {
"message": "Please sign in to Momento in this browser first."
},
"errLoadNotebooks": {
"message": "Could not load notebooks. Try reconnecting."
},
"notebooksLoaded": {
"message": "Notebooks loaded"
},
"connecting": {
"message": "Connecting…"
},
"connectedToUrl": {
"message": "Connected to $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Restricted page — clip via the Momento toolbar or Side Panel."
},
"destinationNotebook": {
"message": "Destination notebook"
},
"activePage": {
"message": "Active page"
},
"previewBeforeSave": {
"message": "Review before saving"
},
"noteTitleLabel": {
"message": "Title"
},
"excerptLabel": {
"message": "Excerpt"
},
"saveToMomento": {
"message": "Save to Momento"
},
"back": {
"message": "Back"
},
"analyzingSource": {
"message": "Analyzing source"
},
"statusAnalyzing": {
"message": "Analyzing…"
},
"statusSaving": {
"message": "Saving…"
},
"processingDetail": {
"message": "Generating tags, a semantic summary, and embeddings."
},
"noteSaved": {
"message": "Note saved"
},
"sentToNotebook": {
"message": "Saved to $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "View in Momento"
},
"clipAnother": {
"message": "Clip another page"
},
"failure": {
"message": "Could not complete"
},
"genericError": {
"message": "Something went wrong reaching your Momento instance."
},
"retry": {
"message": "Retry"
},
"errNoSelection": {
"message": "Select text first, or clip the full page."
},
"errAnalyzeFailed": {
"message": "Could not analyze this page."
},
"errSaveFailed": {
"message": "Could not save your note."
},
"errNetwork": {
"message": "Network issue — check your connection and Momento URL."
},
"bannerPickText": {
"message": "Highlight text on the page, or clip the whole page."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Cortadora web Momento"
},
"extDescription": {
"message": "Capture páginas web y texto resaltado en sus cuadernos Momento: se conecta a su propio servidor Momento."
},
"extActionTitle": {
"message": "Clip al momento"
},
"webClipper": {
"message": "Cortadora web"
},
"connected": {
"message": "Conectado"
},
"disconnected": {
"message": "No conectado"
},
"instanceSettings": {
"message": "URL del momento"
},
"instanceUrlLabel": {
"message": "La URL de tu instancia de Momento"
},
"presetProduction": {
"message": "Preajuste de producción · memento-note.com"
},
"applyReconnect": {
"message": "Aplicar y reconectar"
},
"openMomento": {
"message": "Momento abierto"
},
"settingsHint": {
"message": "Pegue la URL HTTPS (o LAN) de su servidor Momento. Las cookies en este navegador controlan el inicio de sesión."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSIÓN>>>"
},
"errPermissionDenied": {
"message": "Momento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
},
"notebookUnnamed": {
"message": "Cuaderno sin título"
},
"noNotebooks": {
"message": "Aún no hay cuadernos"
},
"readingTimeOne": {
"message": "~1 minuto de lectura"
},
"readingTimeOther": {
"message": "Aprox. <<<CONTAR>>> lectura mínima (Approx. $COUNT$ min read)",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selección detectada"
},
"ignore": {
"message": "ignorar"
},
"selectionHint": {
"message": "Consejo: resalte el texto en la página para recortar una selección precisa como nota."
},
"clipSelection": {
"message": "Selección de clips"
},
"clipPage": {
"message": "Recortar esta página"
},
"saveLinkOnly": {
"message": "Guardar enlace solamente"
},
"pageNotAccessible": {
"message": "No se puede recortar aquí: esta página bloquea el acceso a la extensión."
},
"errLoginRequired": {
"message": "Primero inicie sesión en Momento en este navegador."
},
"errLoadNotebooks": {
"message": "No se pudieron cargar los cuadernos. Intente volver a conectarse."
},
"notebooksLoaded": {
"message": "Cuadernos cargados"
},
"connecting": {
"message": "Conectando…"
},
"connectedToUrl": {
"message": "Conectado a $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Página restringida: recorte mediante la barra de herramientas de Momento o el panel lateral."
},
"destinationNotebook": {
"message": "Cuaderno de destino"
},
"activePage": {
"message": "Página activa"
},
"previewBeforeSave": {
"message": "Revisar antes de guardar"
},
"noteTitleLabel": {
"message": "Título"
},
"excerptLabel": {
"message": "Extracto"
},
"saveToMomento": {
"message": "Guardar en momento"
},
"back": {
"message": "Atrás"
},
"analyzingSource": {
"message": "Analizando fuente"
},
"statusAnalyzing": {
"message": "Analizando…"
},
"statusSaving": {
"message": "Ahorro…"
},
"processingDetail": {
"message": "Generación de etiquetas, resumen semántico e incrustaciones."
},
"noteSaved": {
"message": "Nota guardada"
},
"sentToNotebook": {
"message": "Guardado en $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Ver en momento"
},
"clipAnother": {
"message": "Recortar otra página"
},
"failure": {
"message": "No se pudo completar"
},
"genericError": {
"message": "Algo salió mal al llegar a tu instancia de Momento."
},
"retry": {
"message": "Rever"
},
"errNoSelection": {
"message": "Seleccione el texto primero o recorte la página completa."
},
"errAnalyzeFailed": {
"message": "No se pudo analizar esta página."
},
"errSaveFailed": {
"message": "No se pudo guardar tu nota."
},
"errNetwork": {
"message": "Problema de red: verifique su conexión y la URL de Momento."
},
"bannerPickText": {
"message": "Resalte el texto de la página o recorte toda la página."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Momento خود ضبط کنید — به سرور Momento خودتان متصل می شود."
},
"extActionTitle": {
"message": "کلیپ به لحظه"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "متصل شد"
},
"disconnected": {
"message": "متصل نیست"
},
"instanceSettings": {
"message": "آدرس لحظه ای"
},
"instanceUrlLabel": {
"message": "URL نمونه Momento شما"
},
"presetProduction": {
"message": "پیش تنظیم تولید · memento-note.com"
},
"applyReconnect": {
"message": "درخواست کنید و دوباره وصل شوید"
},
"openMomento": {
"message": "Momento را باز کنید"
},
"settingsHint": {
"message": "URL HTTPS (یا LAN) سرور Momento خود را جایگذاری کنید. کوکی‌های این مرورگر ورود به سیستم را کنترل می‌کنند."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
},
"notebookUnnamed": {
"message": "دفترچه بدون عنوان"
},
"noNotebooks": {
"message": "هنوز نوت بوک نیست"
},
"readingTimeOne": {
"message": "~ 1 دقیقه مطالعه کنید"
},
"readingTimeOther": {
"message": "تقریبا $COUNT$ دقیقه خواندن",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "انتخاب شناسایی شد"
},
"ignore": {
"message": "نادیده گرفتن"
},
"selectionHint": {
"message": "نکته: متن را در صفحه برجسته کنید تا یک انتخاب دقیق به عنوان یادداشت بریده شود."
},
"clipSelection": {
"message": "انتخاب کلیپ"
},
"clipPage": {
"message": "این صفحه را کلیپ کنید"
},
"saveLinkOnly": {
"message": "فقط لینک را ذخیره کنید"
},
"pageNotAccessible": {
"message": "در اینجا نمی توان کلیپ کرد - این صفحه دسترسی برنامه های افزودنی را مسدود می کند."
},
"errLoginRequired": {
"message": "لطفاً ابتدا با این مرورگر وارد Momento شوید."
},
"errLoadNotebooks": {
"message": "نوت‌بوک‌ها بارگیری نشد. سعی کنید دوباره وصل شوید."
},
"notebooksLoaded": {
"message": "نوت بوک ها بارگیری شدند"
},
"connecting": {
"message": "در حال اتصال…"
},
"connectedToUrl": {
"message": "به $URL$ متصل شد",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "صفحه محدود - از طریق نوار ابزار Momento یا پانل جانبی کلیپ کنید."
},
"destinationNotebook": {
"message": "دفترچه یادداشت مقصد"
},
"activePage": {
"message": "صفحه فعال"
},
"previewBeforeSave": {
"message": "قبل از ذخیره بررسی کنید"
},
"noteTitleLabel": {
"message": "عنوان"
},
"excerptLabel": {
"message": "گزیده"
},
"saveToMomento": {
"message": "ذخیره در Momento"
},
"back": {
"message": "برگشت"
},
"analyzingSource": {
"message": "تجزیه و تحلیل منبع"
},
"statusAnalyzing": {
"message": "در حال تجزیه و تحلیل…"
},
"statusSaving": {
"message": "در حال ذخیره…"
},
"processingDetail": {
"message": "تولید برچسب ها، خلاصه معنایی، و جاسازی ها."
},
"noteSaved": {
"message": "یادداشت ذخیره شد"
},
"sentToNotebook": {
"message": "در $NOTEBOOK$ ذخیره شد",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "مشاهده در Momento"
},
"clipAnother": {
"message": "یک صفحه دیگر را کلیپ کنید"
},
"failure": {
"message": "تکمیل نشد"
},
"genericError": {
"message": "هنگام رسیدن به نمونه Momento شما مشکلی پیش آمد."
},
"retry": {
"message": "دوباره امتحان کنید"
},
"errNoSelection": {
"message": "ابتدا متن را انتخاب کنید، یا صفحه کامل را کلیپ کنید."
},
"errAnalyzeFailed": {
"message": "نمی توان این صفحه را تجزیه و تحلیل کرد."
},
"errSaveFailed": {
"message": "یادداشت شما ذخیره نشد."
},
"errNetwork": {
"message": "مشکل شبکه - اتصال و URL Momento خود را بررسی کنید."
},
"bannerPickText": {
"message": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento · Web Clipper"
},
"extDescription": {
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Momento — connecté à votre propre serveur Momento."
},
"extActionTitle": {
"message": "Clipper vers Momento"
},
"webClipper": {
"message": "Web Clipper"
},
"connected": {
"message": "Connecté"
},
"disconnected": {
"message": "Non connecté"
},
"instanceSettings": {
"message": "Adresse Momento"
},
"instanceUrlLabel": {
"message": "URL de votre instance Momento"
},
"presetProduction": {
"message": "Préréglage production · memento-note.com"
},
"applyReconnect": {
"message": "Appliquer et reconnecter"
},
"openMomento": {
"message": "Ouvrir Momento"
},
"settingsHint": {
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Momento. Les cookies de ce navigateur gèrent la connexion."
},
"footerVersion": {
"message": "Momento Web Clipper 0.3.1"
},
"errPermissionDenied": {
"message": "Momento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site ou ouvrez le panneau latéral."
},
"notebookUnnamed": {
"message": "Carnet sans titre"
},
"noNotebooks": {
"message": "Pas encore de carnets"
},
"readingTimeOne": {
"message": "≈ 1 minute de lecture"
},
"readingTimeOther": {
"message": "≈ $COUNT$ minutes de lecture",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Sélection détectée"
},
"ignore": {
"message": "ignorer"
},
"selectionHint": {
"message": "Astuce : surlignez du texte à lécran pour clipper une sélection précise de la page en tant que note."
},
"clipSelection": {
"message": "Clipper la sélection"
},
"clipPage": {
"message": "Clipper cette page"
},
"saveLinkOnly": {
"message": "Enregistrer le lien uniquement"
},
"pageNotAccessible": {
"message": "Impossible de clipper ici — cette page bloque l'accès aux extensions."
},
"errLoginRequired": {
"message": "Veuillez d'abord vous connecter à Momento dans ce navigateur."
},
"errLoadNotebooks": {
"message": "Impossible de charger les carnets. Essayez de vous reconnecter."
},
"notebooksLoaded": {
"message": "Carnets chargés"
},
"connecting": {
"message": "Connexion…"
},
"connectedToUrl": {
"message": "Connecté à $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Page restreinte : clip via la barre d'outils Momento ou le panneau latéral."
},
"destinationNotebook": {
"message": "Carnet de destination"
},
"activePage": {
"message": "Page active"
},
"previewBeforeSave": {
"message": "Vérifier avant d'enregistrer"
},
"noteTitleLabel": {
"message": "Titre"
},
"excerptLabel": {
"message": "Extrait"
},
"saveToMomento": {
"message": "Enregistrer dans Momento"
},
"back": {
"message": "Retour"
},
"analyzingSource": {
"message": "Analyse de la source"
},
"statusAnalyzing": {
"message": "Analyse…"
},
"statusSaving": {
"message": "Enregistrement…"
},
"processingDetail": {
"message": "Génération automatique des tags, résumé sémantique et calcul des embeddings."
},
"noteSaved": {
"message": "Note enregistrée"
},
"sentToNotebook": {
"message": "Enregistré dans $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Voir dans Momento"
},
"clipAnother": {
"message": "Clipper une autre page"
},
"failure": {
"message": "Impossible de terminer"
},
"genericError": {
"message": "Une erreur est survenue lors de la communication avec votre instance Momento."
},
"retry": {
"message": "Réessayer"
},
"errNoSelection": {
"message": "Sélectionnez d'abord du texte, ou clippez la page entière."
},
"errAnalyzeFailed": {
"message": "Impossible d'analyser cette page."
},
"errSaveFailed": {
"message": "Impossible d'enregistrer votre note."
},
"errNetwork": {
"message": "Problème de réseau : vérifiez votre connexion et l'URL Momento."
},
"bannerPickText": {
"message": "Surlignez le texte à clipper"
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "मोमेंटो वेब क्लिपर"
},
"extDescription": {
"message": "अपने मोमेंटो नोटबुक में वेब पेज और हाइलाइट किए गए टेक्स्ट को कैप्चर करें - यह आपके अपने मोमेंटो सर्वर से जुड़ता है।"
},
"extActionTitle": {
"message": "मोमेंटो पर क्लिप करें"
},
"webClipper": {
"message": "वेब क्लिपर"
},
"connected": {
"message": "जुड़े हुए"
},
"disconnected": {
"message": "जुड़े नहीं हैं"
},
"instanceSettings": {
"message": "मोमेंटो यूआरएल"
},
"instanceUrlLabel": {
"message": "आपका मोमेंटो इंस्टेंस यूआरएल"
},
"presetProduction": {
"message": "प्रोडक्शन प्रीसेट · memento-note.com"
},
"applyReconnect": {
"message": "आवेदन करें और पुनः कनेक्ट करें"
},
"openMomento": {
"message": "मोमेंटो खोलें"
},
"settingsHint": {
"message": "अपने मोमेंटो सर्वर का HTTPS (या LAN) URL चिपकाएँ। इस ब्राउज़र में कुकीज़ साइन-इन को संभालती हैं।"
},
"footerVersion": {
"message": "मोमेंटो वेब क्लिपर <<<संस्करण>>>"
},
"errPermissionDenied": {
"message": "मोमेंटो इस टैब तक नहीं पहुंच सकता. कीबोर्ड/साइट एक्सटेंशन अनुमतियां जांचें - या साइड पैनल खोलें।"
},
"notebookUnnamed": {
"message": "शीर्षक रहित नोटबुक"
},
"noNotebooks": {
"message": "अभी तक कोई नोटबुक नहीं"
},
"readingTimeOne": {
"message": "~1 मिनट पढ़ें"
},
"readingTimeOther": {
"message": "लगभग। $COUNT$ मिनट पढ़ा",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "चयन का पता चला"
},
"ignore": {
"message": "अनदेखा करना"
},
"selectionHint": {
"message": "युक्ति: किसी सटीक चयन को नोट के रूप में क्लिप करने के लिए पृष्ठ पर टेक्स्ट को हाइलाइट करें।"
},
"clipSelection": {
"message": "क्लिप चयन"
},
"clipPage": {
"message": "इस पृष्ठ को क्लिप करें"
},
"saveLinkOnly": {
"message": "केवल लिंक सहेजें"
},
"pageNotAccessible": {
"message": "यहां क्लिप नहीं किया जा सकता - यह पेज एक्सटेंशन एक्सेस को ब्लॉक करता है।"
},
"errLoginRequired": {
"message": "कृपया पहले इस ब्राउज़र में मोमेंटो में साइन इन करें।"
},
"errLoadNotebooks": {
"message": "नोटबुक लोड नहीं हो सकीं. पुनः कनेक्ट करने का प्रयास करें."
},
"notebooksLoaded": {
"message": "नोटबुक लोड किए गए"
},
"connecting": {
"message": "कनेक्ट हो रहा है..."
},
"connectedToUrl": {
"message": "$URL$ से कनेक्ट किया गया",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "प्रतिबंधित पृष्ठ - मोमेंटो टूलबार या साइड पैनल के माध्यम से क्लिप करें।"
},
"destinationNotebook": {
"message": "गंतव्य नोटबुक"
},
"activePage": {
"message": "सक्रिय पृष्ठ"
},
"previewBeforeSave": {
"message": "सहेजने से पहले समीक्षा करें"
},
"noteTitleLabel": {
"message": "शीर्षक"
},
"excerptLabel": {
"message": "अंश"
},
"saveToMomento": {
"message": "मोमेंटो में सहेजें"
},
"back": {
"message": "पीछे"
},
"analyzingSource": {
"message": "स्रोत का विश्लेषण"
},
"statusAnalyzing": {
"message": "विश्लेषण कर रहा हूँ..."
},
"statusSaving": {
"message": "सहेजा जा रहा है..."
},
"processingDetail": {
"message": "टैग, सिमेंटिक सारांश और एम्बेडिंग तैयार करना।"
},
"noteSaved": {
"message": "नोट सहेजा गया"
},
"sentToNotebook": {
"message": "$NOTEBOOK$ में सहेजा गया",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "मोमेंटो में देखें"
},
"clipAnother": {
"message": "दूसरे पेज को क्लिप करें"
},
"failure": {
"message": "पूरा नहीं हो सका"
},
"genericError": {
"message": "आपके मोमेंटो इंस्टेंस तक पहुँचने में कुछ गड़बड़ी हुई।"
},
"retry": {
"message": "पुन: प्रयास करें"
},
"errNoSelection": {
"message": "पहले टेक्स्ट चुनें, या पूरा पेज क्लिप करें।"
},
"errAnalyzeFailed": {
"message": "इस पृष्ठ का विश्लेषण नहीं किया जा सका."
},
"errSaveFailed": {
"message": "आपका नोट सहेजा नहीं जा सका."
},
"errNetwork": {
"message": "नेटवर्क समस्या - अपना कनेक्शन और मोमेंटो यूआरएल जांचें।"
},
"bannerPickText": {
"message": "पृष्ठ पर टेक्स्ट को हाइलाइट करें, या पूरे पृष्ठ को क्लिप करें।"
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Momento: si connette al tuo server Momento."
},
"extActionTitle": {
"message": "Clip su Momento"
},
"webClipper": {
"message": "Tagliatore di fotoricettore"
},
"connected": {
"message": "Collegato"
},
"disconnected": {
"message": "Non connesso"
},
"instanceSettings": {
"message": "URL del momento"
},
"instanceUrlLabel": {
"message": "L'URL dell'istanza di Momento"
},
"presetProduction": {
"message": "Preimpostazione di produzione · memento-note.com"
},
"applyReconnect": {
"message": "Applicare e riconnettersi"
},
"openMomento": {
"message": "Momento aperto"
},
"settingsHint": {
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Momento. I cookie in questo browser gestiscono l'accesso."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSIONE>>>"
},
"errPermissionDenied": {
"message": "Momento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
},
"notebookUnnamed": {
"message": "Taccuino senza titolo"
},
"noNotebooks": {
"message": "Nessun taccuino ancora"
},
"readingTimeOne": {
"message": "~1 minuto di lettura"
},
"readingTimeOther": {
"message": "ca. $COUNT$ min letto",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selezione rilevata"
},
"ignore": {
"message": "ignorare"
},
"selectionHint": {
"message": "Suggerimento: evidenzia il testo sulla pagina per ritagliare una selezione precisa come nota."
},
"clipSelection": {
"message": "Selezione clip"
},
"clipPage": {
"message": "Ritaglia questa pagina"
},
"saveLinkOnly": {
"message": "Salva solo il collegamento"
},
"pageNotAccessible": {
"message": "Impossibile ritagliare qui: questa pagina blocca l'accesso all'estensione."
},
"errLoginRequired": {
"message": "Accedi prima a Momento in questo browser."
},
"errLoadNotebooks": {
"message": "Impossibile caricare i taccuini. Prova a riconnetterti."
},
"notebooksLoaded": {
"message": "Taccuini caricati"
},
"connecting": {
"message": "Connessione…"
},
"connectedToUrl": {
"message": "Connesso a $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Momento o il pannello laterale."
},
"destinationNotebook": {
"message": "Taccuino di destinazione"
},
"activePage": {
"message": "Pagina attiva"
},
"previewBeforeSave": {
"message": "Rivedi prima di salvare"
},
"noteTitleLabel": {
"message": "Titolo"
},
"excerptLabel": {
"message": "Estratto"
},
"saveToMomento": {
"message": "Salva su Momento"
},
"back": {
"message": "Indietro"
},
"analyzingSource": {
"message": "Analisi della fonte"
},
"statusAnalyzing": {
"message": "Analizzando..."
},
"statusSaving": {
"message": "Risparmio…"
},
"processingDetail": {
"message": "Generazione di tag, riepilogo semantico e incorporamenti."
},
"noteSaved": {
"message": "Nota salvata"
},
"sentToNotebook": {
"message": "Salvato in $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Visualizza in Momento"
},
"clipAnother": {
"message": "Ritaglia un'altra pagina"
},
"failure": {
"message": "Impossibile completare"
},
"genericError": {
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Momento."
},
"retry": {
"message": "Riprova"
},
"errNoSelection": {
"message": "Seleziona prima il testo o ritaglia l'intera pagina."
},
"errAnalyzeFailed": {
"message": "Impossibile analizzare questa pagina."
},
"errSaveFailed": {
"message": "Impossibile salvare la nota."
},
"errNetwork": {
"message": "Problema di rete: controlla la connessione e l'URL Momento."
},
"bannerPickText": {
"message": "Evidenzia il testo sulla pagina o ritaglia l'intera pagina."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "モーメントウェブクリッパー"
},
"extDescription": {
"message": "Web ページとハイライトされたテキストを Momento ノートブックにキャプチャします。独自の Momento サーバーに接続します。"
},
"extActionTitle": {
"message": "モーメントにクリップ"
},
"webClipper": {
"message": "ウェブクリッパー"
},
"connected": {
"message": "接続済み"
},
"disconnected": {
"message": "接続されていません"
},
"instanceSettings": {
"message": "モーメントのURL"
},
"instanceUrlLabel": {
"message": "Momento インスタンスの URL"
},
"presetProduction": {
"message": "プロダクションプリセット・memento-note.com"
},
"applyReconnect": {
"message": "適用して再接続する"
},
"openMomento": {
"message": "モーメントを開く"
},
"settingsHint": {
"message": "Momento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
},
"footerVersion": {
"message": "Momento Web クリッパー <<<バージョン>>>"
},
"errPermissionDenied": {
"message": "Momento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
},
"notebookUnnamed": {
"message": "無題のノート"
},
"noNotebooks": {
"message": "まだノートはありません"
},
"readingTimeOne": {
"message": "約 1 分で読めます"
},
"readingTimeOther": {
"message": "約$COUNT$ 分読み取り",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "選択が検出されました"
},
"ignore": {
"message": "無視する"
},
"selectionHint": {
"message": "ヒント: ページ上のテキストをハイライト表示して、正確な選択範囲をメモとしてクリップします。"
},
"clipSelection": {
"message": "クリップの選択"
},
"clipPage": {
"message": "このページをクリップします"
},
"saveLinkOnly": {
"message": "リンクのみを保存"
},
"pageNotAccessible": {
"message": "ここではクリップできません — このページは拡張機能へのアクセスをブロックしています。"
},
"errLoginRequired": {
"message": "まずこのブラウザで Momento にサインインしてください。"
},
"errLoadNotebooks": {
"message": "ノートブックをロードできませんでした。再接続してみてください。"
},
"notebooksLoaded": {
"message": "ノートブックがロードされました"
},
"connecting": {
"message": "接続中…"
},
"connectedToUrl": {
"message": "$URL$ に接続しました",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "制限されたページ — Momento ツールバーまたはサイド パネルを介してクリップします。"
},
"destinationNotebook": {
"message": "宛先ノートブック"
},
"activePage": {
"message": "アクティブなページ"
},
"previewBeforeSave": {
"message": "保存する前に確認してください"
},
"noteTitleLabel": {
"message": "タイトル"
},
"excerptLabel": {
"message": "抜粋"
},
"saveToMomento": {
"message": "モーメントに保存"
},
"back": {
"message": "戻る"
},
"analyzingSource": {
"message": "ソースを分析中"
},
"statusAnalyzing": {
"message": "分析中…"
},
"statusSaving": {
"message": "保存中…"
},
"processingDetail": {
"message": "タグ、意味の概要、および埋め込みを生成します。"
},
"noteSaved": {
"message": "メモが保存されました"
},
"sentToNotebook": {
"message": "$NOTEBOOK$ に保存されました",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "モメントで見る"
},
"clipAnother": {
"message": "別のページをクリップする"
},
"failure": {
"message": "完了できませんでした"
},
"genericError": {
"message": "Momento インスタンスに到達する際に問題が発生しました。"
},
"retry": {
"message": "リトライ"
},
"errNoSelection": {
"message": "最初にテキストを選択するか、ページ全体をクリップします。"
},
"errAnalyzeFailed": {
"message": "このページを分析できませんでした。"
},
"errSaveFailed": {
"message": "メモを保存できませんでした。"
},
"errNetwork": {
"message": "ネットワークの問題 — 接続と Momento URL を確認してください。"
},
"bannerPickText": {
"message": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "모멘토 웹 클리퍼"
},
"extDescription": {
"message": "웹 페이지와 강조 표시된 텍스트를 Momento 노트북에 캡처하여 자체 Momento 서버에 연결합니다."
},
"extActionTitle": {
"message": "순간에 클립"
},
"webClipper": {
"message": "웹 클리퍼"
},
"connected": {
"message": "연결됨"
},
"disconnected": {
"message": "연결되지 않음"
},
"instanceSettings": {
"message": "모멘토 URL"
},
"instanceUrlLabel": {
"message": "귀하의 Momento 인스턴스 URL"
},
"presetProduction": {
"message": "프로덕션 프리셋 · memento-note.com"
},
"applyReconnect": {
"message": "적용하고 다시 연결하세요"
},
"openMomento": {
"message": "모멘토 열기"
},
"settingsHint": {
"message": "Momento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
},
"footerVersion": {
"message": "Momento Web Clipper <<<버전>>>"
},
"errPermissionDenied": {
"message": "Momento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
},
"notebookUnnamed": {
"message": "제목 없는 노트"
},
"noNotebooks": {
"message": "아직 노트가 없습니다."
},
"readingTimeOne": {
"message": "~1분 읽기"
},
"readingTimeOther": {
"message": "대략. $COUNT$분 읽음",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "선택 항목이 감지되었습니다."
},
"ignore": {
"message": "무시하다"
},
"selectionHint": {
"message": "팁: 페이지의 텍스트를 강조 표시하여 정확한 선택 항목을 메모로 자릅니다."
},
"clipSelection": {
"message": "클립 선택"
},
"clipPage": {
"message": "이 페이지 클립"
},
"saveLinkOnly": {
"message": "링크만 저장"
},
"pageNotAccessible": {
"message": "여기서 클립할 수 없습니다. 이 페이지는 확장 프로그램 액세스를 차단합니다."
},
"errLoginRequired": {
"message": "먼저 이 브라우저에서 Momento에 로그인하세요."
},
"errLoadNotebooks": {
"message": "노트북을 로드할 수 없습니다. 다시 연결해 보세요."
},
"notebooksLoaded": {
"message": "노트북이 로드되었습니다."
},
"connecting": {
"message": "연결 중…"
},
"connectedToUrl": {
"message": "$URL$에 연결됨",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "제한된 페이지 — Momento 도구 모음 또는 측면 패널을 통해 클립합니다."
},
"destinationNotebook": {
"message": "대상 노트북"
},
"activePage": {
"message": "활성 페이지"
},
"previewBeforeSave": {
"message": "저장하기 전에 검토하세요"
},
"noteTitleLabel": {
"message": "제목"
},
"excerptLabel": {
"message": "발췌"
},
"saveToMomento": {
"message": "모멘토에 저장"
},
"back": {
"message": "뒤쪽에"
},
"analyzingSource": {
"message": "소스 분석 중"
},
"statusAnalyzing": {
"message": "분석 중…"
},
"statusSaving": {
"message": "절약…"
},
"processingDetail": {
"message": "태그, 의미 요약 및 임베딩을 생성합니다."
},
"noteSaved": {
"message": "메모가 저장되었습니다."
},
"sentToNotebook": {
"message": "$NOTEBOOK$에 저장됨",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Momento에서 보기"
},
"clipAnother": {
"message": "다른 페이지 자르기"
},
"failure": {
"message": "완료할 수 없습니다."
},
"genericError": {
"message": "Momento 인스턴스에 연결하는 데 문제가 발생했습니다."
},
"retry": {
"message": "다시 해 보다"
},
"errNoSelection": {
"message": "먼저 텍스트를 선택하거나 전체 페이지를 자릅니다."
},
"errAnalyzeFailed": {
"message": "이 페이지를 분석할 수 없습니다."
},
"errSaveFailed": {
"message": "메모를 저장할 수 없습니다."
},
"errNetwork": {
"message": "네트워크 문제 - 연결 및 Momento URL을 확인하세요."
},
"bannerPickText": {
"message": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Webclipper"
},
"extDescription": {
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Momento-notebooks - maakt verbinding met uw eigen Momento-server."
},
"extActionTitle": {
"message": "Clip naar Momento"
},
"webClipper": {
"message": "Webclipper"
},
"connected": {
"message": "Aangesloten"
},
"disconnected": {
"message": "Niet verbonden"
},
"instanceSettings": {
"message": "Momento-URL"
},
"instanceUrlLabel": {
"message": "Uw Momento-instantie-URL"
},
"presetProduction": {
"message": "Productievoorinstelling · memento-note.com"
},
"applyReconnect": {
"message": "Toepassen en opnieuw verbinden"
},
"openMomento": {
"message": "Momento openen"
},
"settingsHint": {
"message": "Plak de HTTPS (of LAN) URL van uw Momento-server. Cookies in deze browser zorgen voor het inloggen."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSIE>>>"
},
"errPermissionDenied": {
"message": "Momento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
},
"notebookUnnamed": {
"message": "Naamloos notitieboekje"
},
"noNotebooks": {
"message": "Nog geen notitieboekjes"
},
"readingTimeOne": {
"message": "~1 minuut lezen"
},
"readingTimeOther": {
"message": "Ongeveer. $COUNT$ min gelezen",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Selectie gedetecteerd"
},
"ignore": {
"message": "negeren"
},
"selectionHint": {
"message": "Tip: markeer tekst op de pagina om een precieze selectie als notitie te knippen."
},
"clipSelection": {
"message": "Clipselectie"
},
"clipPage": {
"message": "Knip deze pagina uit"
},
"saveLinkOnly": {
"message": "Alleen link opslaan"
},
"pageNotAccessible": {
"message": "Kan hier niet knippen: deze pagina blokkeert de toegang tot extensies."
},
"errLoginRequired": {
"message": "Meld u eerst aan bij Momento in deze browser."
},
"errLoadNotebooks": {
"message": "Kan notitieboekjes niet laden. Probeer opnieuw verbinding te maken."
},
"notebooksLoaded": {
"message": "Notitieboekjes geladen"
},
"connecting": {
"message": "Verbinden…"
},
"connectedToUrl": {
"message": "Verbonden met $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Beperkte pagina — clip via de Momento-werkbalk of het zijpaneel."
},
"destinationNotebook": {
"message": "Bestemmingsnotitieboekje"
},
"activePage": {
"message": "Actieve pagina"
},
"previewBeforeSave": {
"message": "Controleer voordat u opslaat"
},
"noteTitleLabel": {
"message": "Titel"
},
"excerptLabel": {
"message": "Uittreksel"
},
"saveToMomento": {
"message": "Opslaan in Momento"
},
"back": {
"message": "Rug"
},
"analyzingSource": {
"message": "Bron analyseren"
},
"statusAnalyzing": {
"message": "Analyseren…"
},
"statusSaving": {
"message": "Besparing…"
},
"processingDetail": {
"message": "Tags, een semantische samenvatting en insluitingen genereren."
},
"noteSaved": {
"message": "Notitie opgeslagen"
},
"sentToNotebook": {
"message": "Opgeslagen in $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Bekijk in Momento"
},
"clipAnother": {
"message": "Knip nog een pagina uit"
},
"failure": {
"message": "Kon niet voltooien"
},
"genericError": {
"message": "Er is iets misgegaan bij het bereiken van uw Momento-instantie."
},
"retry": {
"message": "Opnieuw proberen"
},
"errNoSelection": {
"message": "Selecteer eerst tekst of knip de volledige pagina uit."
},
"errAnalyzeFailed": {
"message": "Kan deze pagina niet analyseren."
},
"errSaveFailed": {
"message": "Kan uw notitie niet opslaan."
},
"errNetwork": {
"message": "Netwerkprobleem: controleer uw verbinding en Momento-URL."
},
"bannerPickText": {
"message": "Markeer tekst op de pagina of knip de hele pagina uit."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Narzędzie do strzyżenia sieci Momento"
},
"extDescription": {
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Momento — łączy się z Twoim własnym serwerem Momento."
},
"extActionTitle": {
"message": "Klip do Momento"
},
"webClipper": {
"message": "Obcinacz sieci"
},
"connected": {
"message": "Połączony"
},
"disconnected": {
"message": "Nie podłączony"
},
"instanceSettings": {
"message": "Adres URL chwili"
},
"instanceUrlLabel": {
"message": "Adres URL Twojej instancji Momento"
},
"presetProduction": {
"message": "Wstępne ustawienia produkcyjne · memento-note.com"
},
"applyReconnect": {
"message": "Zastosuj i połącz ponownie"
},
"openMomento": {
"message": "Otwórz Momento"
},
"settingsHint": {
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Momento. Pliki cookie w tej przeglądarce obsługują logowanie."
},
"footerVersion": {
"message": "Momento Web Clipper <<<WERSJA>>>"
},
"errPermissionDenied": {
"message": "Momento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
},
"notebookUnnamed": {
"message": "Notatnik bez tytułu"
},
"noNotebooks": {
"message": "Nie ma jeszcze żadnych notatników"
},
"readingTimeOne": {
"message": "~1 minuta czytania"
},
"readingTimeOther": {
"message": "Około. $COUNT$ min odczytu",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Wykryto wybór"
},
"ignore": {
"message": "ignorować"
},
"selectionHint": {
"message": "Wskazówka: zaznacz tekst na stronie, aby wyciąć dokładne zaznaczenie jako notatkę."
},
"clipSelection": {
"message": "Wybór klipu"
},
"clipPage": {
"message": "Przytnij tę stronę"
},
"saveLinkOnly": {
"message": "Zapisz tylko link"
},
"pageNotAccessible": {
"message": "Nie można tutaj przyciąć — ta strona blokuje dostęp do rozszerzenia."
},
"errLoginRequired": {
"message": "Najpierw zaloguj się do Momento w tej przeglądarce."
},
"errLoadNotebooks": {
"message": "Nie udało się załadować notatników. Spróbuj połączyć się ponownie."
},
"notebooksLoaded": {
"message": "Notatniki załadowane"
},
"connecting": {
"message": "Złączony…"
},
"connectedToUrl": {
"message": "Połączono z $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Strona z ograniczeniami — klip za pomocą paska narzędzi Momento lub panelu bocznego."
},
"destinationNotebook": {
"message": "Notatnik docelowy"
},
"activePage": {
"message": "Aktywna strona"
},
"previewBeforeSave": {
"message": "Przejrzyj przed zapisaniem"
},
"noteTitleLabel": {
"message": "Tytuł"
},
"excerptLabel": {
"message": "Fragment"
},
"saveToMomento": {
"message": "Zapisz w Momento"
},
"back": {
"message": "Z powrotem"
},
"analyzingSource": {
"message": "Analizowanie źródła"
},
"statusAnalyzing": {
"message": "Analizuję…"
},
"statusSaving": {
"message": "Oszczędność…"
},
"processingDetail": {
"message": "Generowanie tagów, podsumowań semantycznych i osadzania."
},
"noteSaved": {
"message": "Uwaga zapisana"
},
"sentToNotebook": {
"message": "Zapisano w $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Zobacz w Momento"
},
"clipAnother": {
"message": "Wytnij kolejną stronę"
},
"failure": {
"message": "Nie udało się ukończyć"
},
"genericError": {
"message": "Coś poszło nie tak podczas docierania do Twojej instancji Momento."
},
"retry": {
"message": "Spróbować ponownie"
},
"errNoSelection": {
"message": "Najpierw zaznacz tekst lub wytnij całą stronę."
},
"errAnalyzeFailed": {
"message": "Nie można przeanalizować tej strony."
},
"errSaveFailed": {
"message": "Nie można zapisać notatki."
},
"errNetwork": {
"message": "Problem z siecią — sprawdź swoje połączenie i adres URL Momento."
},
"bannerPickText": {
"message": "Zaznacz tekst na stronie lub przytnij całą stronę."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento Web Clipper"
},
"extDescription": {
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Momento conecte-se ao seu próprio servidor Momento."
},
"extActionTitle": {
"message": "Clipe para Momento"
},
"webClipper": {
"message": "Clipper da Web"
},
"connected": {
"message": "Conectado"
},
"disconnected": {
"message": "Não conectado"
},
"instanceSettings": {
"message": "URL do momento"
},
"instanceUrlLabel": {
"message": "URL da sua instância do Momento"
},
"presetProduction": {
"message": "Predefinição de produção · memento-note.com"
},
"applyReconnect": {
"message": "Aplicar e reconectar"
},
"openMomento": {
"message": "Momento aberto"
},
"settingsHint": {
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Momento. Os cookies neste navegador controlam o login."
},
"footerVersion": {
"message": "Momento Web Clipper <<<VERSÃO>>>"
},
"errPermissionDenied": {
"message": "Momento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site ou abra o painel lateral."
},
"notebookUnnamed": {
"message": "Caderno sem título"
},
"noNotebooks": {
"message": "Ainda não há cadernos"
},
"readingTimeOne": {
"message": "~1 minuto de leitura"
},
"readingTimeOther": {
"message": "Aprox. $COUNT$ minutos de leitura",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Seleção detectada"
},
"ignore": {
"message": "ignorar"
},
"selectionHint": {
"message": "Dica: destaque o texto na página para recortar uma seleção precisa como uma nota."
},
"clipSelection": {
"message": "Seleção de clipe"
},
"clipPage": {
"message": "Recorte esta página"
},
"saveLinkOnly": {
"message": "Salvar apenas link"
},
"pageNotAccessible": {
"message": "Não é possível recortar aqui esta página bloqueia o acesso à extensão."
},
"errLoginRequired": {
"message": "Faça login no Momento neste navegador primeiro."
},
"errLoadNotebooks": {
"message": "Não foi possível carregar os notebooks. Tente reconectar."
},
"notebooksLoaded": {
"message": "Cadernos carregados"
},
"connecting": {
"message": "Conectando…"
},
"connectedToUrl": {
"message": "Conectado a $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Página restrita recorte por meio da barra de ferramentas do Momento ou do painel lateral."
},
"destinationNotebook": {
"message": "Caderno de destino"
},
"activePage": {
"message": "Página ativa"
},
"previewBeforeSave": {
"message": "Revise antes de salvar"
},
"noteTitleLabel": {
"message": "Título"
},
"excerptLabel": {
"message": "Trecho"
},
"saveToMomento": {
"message": "Salvar no Momento"
},
"back": {
"message": "Voltar"
},
"analyzingSource": {
"message": "Analisando fonte"
},
"statusAnalyzing": {
"message": "Analisando…"
},
"statusSaving": {
"message": "Salvando…"
},
"processingDetail": {
"message": "Geração de tags, resumo semântico e incorporações."
},
"noteSaved": {
"message": "Nota salva"
},
"sentToNotebook": {
"message": "Salvo em $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Ver em Momento"
},
"clipAnother": {
"message": "Recortar outra página"
},
"failure": {
"message": "Não foi possível concluir"
},
"genericError": {
"message": "Algo deu errado ao chegar à sua instância do Momento."
},
"retry": {
"message": "Tentar novamente"
},
"errNoSelection": {
"message": "Selecione o texto primeiro ou recorte a página inteira."
},
"errAnalyzeFailed": {
"message": "Não foi possível analisar esta página."
},
"errSaveFailed": {
"message": "Não foi possível salvar sua nota."
},
"errNetwork": {
"message": "Problema de rede verifique sua conexão e URL do Momento."
},
"bannerPickText": {
"message": "Destaque o texto na página ou recorte a página inteira."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Веб-клипер Momento"
},
"extDescription": {
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Momento — подключайтесь к вашему собственному серверу Momento."
},
"extActionTitle": {
"message": "Клип на Momento"
},
"webClipper": {
"message": "Веб-клипер"
},
"connected": {
"message": "Подключено"
},
"disconnected": {
"message": "Не подключено"
},
"instanceSettings": {
"message": "URL-адрес момента"
},
"instanceUrlLabel": {
"message": "URL-адрес вашего экземпляра Momento"
},
"presetProduction": {
"message": "Настройки производства · memento-note.com"
},
"applyReconnect": {
"message": "Подать заявку и повторно подключиться"
},
"openMomento": {
"message": "Открыть Моменто"
},
"settingsHint": {
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Momento. Файлы cookie в этом браузере обрабатывают вход в систему."
},
"footerVersion": {
"message": "Momento Web Clipper <<<ВЕРСИЯ>>>"
},
"errPermissionDenied": {
"message": "Momento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
},
"notebookUnnamed": {
"message": "Блокнот без названия"
},
"noNotebooks": {
"message": "Блокнотов пока нет"
},
"readingTimeOne": {
"message": "~1 минута чтения"
},
"readingTimeOther": {
"message": "Прибл. $COUNT$ мин чтения",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "Выбор обнаружен"
},
"ignore": {
"message": "игнорировать"
},
"selectionHint": {
"message": "Совет: выделите текст на странице, чтобы выделить его в виде заметки."
},
"clipSelection": {
"message": "Выбор клипа"
},
"clipPage": {
"message": "Вырезать эту страницу"
},
"saveLinkOnly": {
"message": "Сохранить только ссылку"
},
"pageNotAccessible": {
"message": "Невозможно обрезать здесь — эта страница блокирует доступ к расширению."
},
"errLoginRequired": {
"message": "Сначала войдите в Momento в этом браузере."
},
"errLoadNotebooks": {
"message": "Не удалось загрузить блокноты. Попробуйте переподключиться."
},
"notebooksLoaded": {
"message": "Ноутбуки загружены"
},
"connecting": {
"message": "Подключение…"
},
"connectedToUrl": {
"message": "Подключено к $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "Страница с ограниченным доступом — вырезайте с помощью панели инструментов Momento или боковой панели."
},
"destinationNotebook": {
"message": "Блокнот назначения"
},
"activePage": {
"message": "Активная страница"
},
"previewBeforeSave": {
"message": "Проверьте перед сохранением"
},
"noteTitleLabel": {
"message": "Заголовок"
},
"excerptLabel": {
"message": "Отрывок"
},
"saveToMomento": {
"message": "Сохранить в Моменто"
},
"back": {
"message": "Назад"
},
"analyzingSource": {
"message": "Анализ источника"
},
"statusAnalyzing": {
"message": "Анализ…"
},
"statusSaving": {
"message": "Сохранение…"
},
"processingDetail": {
"message": "Генерация тегов, семантического резюме и вложений."
},
"noteSaved": {
"message": "Заметка сохранена."
},
"sentToNotebook": {
"message": "Сохранено в $NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "Посмотреть в Моменто"
},
"clipAnother": {
"message": "Вырезать другую страницу"
},
"failure": {
"message": "Не удалось завершить"
},
"genericError": {
"message": "Что-то пошло не так при получении вашего экземпляра Momento."
},
"retry": {
"message": "Повторить попытку"
},
"errNoSelection": {
"message": "Сначала выделите текст или вырежьте всю страницу."
},
"errAnalyzeFailed": {
"message": "Не удалось проанализировать эту страницу."
},
"errSaveFailed": {
"message": "Не удалось сохранить заметку."
},
"errNetwork": {
"message": "Проблема с сетью. Проверьте подключение и URL-адрес Momento."
},
"bannerPickText": {
"message": "Выделите текст на странице или вырежьте всю страницу."
}
}

View File

@@ -0,0 +1,182 @@
{
"extName": {
"message": "Momento 网页剪辑器"
},
"extDescription": {
"message": "将网页和突出显示的文本捕获到您的 Momento 笔记本中 — 连接到您自己的 Momento 服务器。"
},
"extActionTitle": {
"message": "剪辑到时刻"
},
"webClipper": {
"message": "网页剪辑器"
},
"connected": {
"message": "已连接"
},
"disconnected": {
"message": "未连接"
},
"instanceSettings": {
"message": "时刻网址"
},
"instanceUrlLabel": {
"message": "您的 Momento 实例 URL"
},
"presetProduction": {
"message": "制作预设 · memento-note.com"
},
"applyReconnect": {
"message": "应用并重新连接"
},
"openMomento": {
"message": "打开时刻"
},
"settingsHint": {
"message": "粘贴 Momento 服务器的 HTTPS或 LANURL。此浏览器中的 Cookie 处理登录。"
},
"footerVersion": {
"message": "Momento Web Clipper <<<版本>>>"
},
"errPermissionDenied": {
"message": "Momento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
},
"notebookUnnamed": {
"message": "无标题笔记本"
},
"noNotebooks": {
"message": "还没有笔记本"
},
"readingTimeOne": {
"message": "阅读约 1 分钟"
},
"readingTimeOther": {
"message": "大约。 $COUNT$ 最少阅读次数",
"placeholders": {
"COUNT": {
"content": "$1",
"example": "5"
}
}
},
"selectionDetected": {
"message": "检测到选择"
},
"ignore": {
"message": "忽略"
},
"selectionHint": {
"message": "提示:突出显示页面上的文本以将精确的选择剪辑为注释。"
},
"clipSelection": {
"message": "剪辑选择"
},
"clipPage": {
"message": "剪辑此页"
},
"saveLinkOnly": {
"message": "仅保存链接"
},
"pageNotAccessible": {
"message": "此处无法剪辑 — 此页面阻止扩展程序访问。"
},
"errLoginRequired": {
"message": "请先在此浏览器中登录 Momento。"
},
"errLoadNotebooks": {
"message": "无法加载笔记本。尝试重新连接。"
},
"notebooksLoaded": {
"message": "笔记本已加载"
},
"connecting": {
"message": "正在连接…"
},
"connectedToUrl": {
"message": "连接到 $URL$",
"placeholders": {
"URL": {
"content": "$1",
"example": "https://memento-note.com"
}
}
},
"restrictedPage": {
"message": "受限页面 — 通过 Momento 工具栏或侧面板进行剪辑。"
},
"destinationNotebook": {
"message": "目的地笔记本"
},
"activePage": {
"message": "活动页面"
},
"previewBeforeSave": {
"message": "保存前查看"
},
"noteTitleLabel": {
"message": "标题"
},
"excerptLabel": {
"message": "摘抄"
},
"saveToMomento": {
"message": "保存到时刻"
},
"back": {
"message": "后退"
},
"analyzingSource": {
"message": "分析来源"
},
"statusAnalyzing": {
"message": "正在分析……"
},
"statusSaving": {
"message": "保存…"
},
"processingDetail": {
"message": "生成标签、语义摘要和嵌入。"
},
"noteSaved": {
"message": "注释已保存"
},
"sentToNotebook": {
"message": "保存至$NOTEBOOK$",
"placeholders": {
"NOTEBOOK": {
"content": "$1",
"example": "Read later"
}
}
},
"viewInMomento": {
"message": "在 Momento 中查看"
},
"clipAnother": {
"message": "剪辑另一页"
},
"failure": {
"message": "无法完成"
},
"genericError": {
"message": "您的 Momento 实例出现问题。"
},
"retry": {
"message": "重试"
},
"errNoSelection": {
"message": "首先选择文本,或剪辑整个页面。"
},
"errAnalyzeFailed": {
"message": "无法分析此页面。"
},
"errSaveFailed": {
"message": "无法保存您的笔记。"
},
"errNetwork": {
"message": "网络问题 — 检查您的连接和 Momento URL。"
},
"bannerPickText": {
"message": "突出显示页面上的文本,或剪辑整个页面。"
}
}

View File

@@ -114,6 +114,10 @@
function ensureBanner() {
if (document.getElementById(BANNER_ID)) return
const bannerText =
(typeof chrome !== 'undefined' && chrome.i18n?.getMessage?.('bannerPickText')) ||
'Highlight the text to clip'
const host = document.createElement('div')
host.id = BANNER_ID
host.style.cssText =
@@ -153,7 +157,7 @@
<div class="pill">
<span class="logo">M</span>
<span class="dot"></span>
<span>Surlignez le texte à clipper</span>
<span>${bannerText.replace(/</g, '&lt;')}</span>
</div>
`
document.documentElement.appendChild(host)

View File

@@ -0,0 +1,47 @@
/** Helpers i18n — langue UI Chrome (chrome.i18n.getUILanguage). */
function t(key, ...subs) {
const msg = chrome.i18n.getMessage(key, subs)
return msg || key
}
function uiLocaleTag() {
return (chrome.i18n.getUILanguage() || 'en').replace('_', '-').split('-')[0]
}
function isUiRtl() {
return ['ar', 'fa', 'he', 'ur'].includes(uiLocaleTag())
}
function applyDocumentLocale() {
const lang = uiLocaleTag()
document.documentElement.lang = lang
document.documentElement.dir = isUiRtl() ? 'rtl' : 'ltr'
document.body?.classList.toggle('ui-rtl', isUiRtl())
}
function applyShellI18n() {
document.title = t('extName')
const brandSub = document.querySelector('.brand-sub')
if (brandSub) brandSub.textContent = t('webClipper')
if (typeof els !== 'undefined' && els.connLabel) {
els.connLabel.textContent = t('connected')
}
if (typeof els !== 'undefined' && els.settingsBtn) {
els.settingsBtn.title = t('instanceSettings')
els.settingsBtn.setAttribute('aria-label', t('instanceSettings'))
}
const urlLabel = document.getElementById('instanceUrlLabel') || document.querySelector('#settingsPanel .field > span')
if (urlLabel) urlLabel.textContent = t('instanceUrlLabel')
const presetProd = document.querySelector('[data-url="https://memento-note.com"]')
if (presetProd) presetProd.textContent = t('presetProduction')
if (typeof els !== 'undefined' && els.applyInstanceBtn) {
els.applyInstanceBtn.textContent = t('applyReconnect')
}
if (typeof els !== 'undefined' && els.openLoginBtn) {
els.openLoginBtn.textContent = `${t('openMomento')}`
}
const hint = document.querySelector('.settings-hint')
if (hint) hint.textContent = t('settingsHint')
const footer = document.querySelector('.footer-meta')
if (footer) footer.textContent = t('footerVersion')
}

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
'use strict'
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const VERSION = "0.3.1"
// Embedded Momento clipper/extension string table (gzip+base64).
// Source English + contextual French anchors; other locales are MT batch
// post-edited with placeholder hardening.
const PACK_B64 = `
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
wOW7jPkAAA==
`
function loadStrings () {
const buf = Buffer.from(PACK_B64.replace(/\s+/g, ''), 'base64')
return JSON.parse(zlib.gunzipSync(buf).toString('utf8'))
}
function main () {
const STRINGS = loadStrings()
const payload = {
version: VERSION,
strings: STRINGS,
}
fs.writeFileSync(
path.join(__dirname, 'translations.json'),
JSON.stringify(payload, null, 2) + "\n",
'utf8'
)
console.log('Wrote', path.join(__dirname, 'translations.json'))
}
main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"manifest_version": 3,
"name": "Memento Web Clipper",
"version": "0.3.0",
"description": "Enregistrez des pages et des sélections dans Momento avec résumé IA.",
"name": "__MSG_extName__",
"version": "0.3.1",
"description": "__MSG_extDescription__",
"default_locale": "en",
"permissions": ["activeTab", "scripting", "storage", "sidePanel", "tabs"],
"host_permissions": [
"http://localhost:3000/*",
@@ -26,6 +27,6 @@
}
],
"action": {
"default_title": "Momento Web Clipper"
"default_title": "__MSG_extActionTitle__"
}
}

View File

@@ -488,3 +488,20 @@ input[type="text"]:focus,
letter-spacing: 0.08em;
}
.preview-tags { justify-content: flex-start; border-top: none; margin-top: 0; padding-top: 0; }
html[dir="rtl"] .header,
html[dir="rtl"] .header-right,
html[dir="rtl"] .selection-head,
html[dir="rtl"] .page-row {
flex-direction: row-reverse;
}
html[dir="rtl"] .page-row .url {
direction: ltr;
text-align: left;
}
html[dir="rtl"] .notebook-select,
html[dir="rtl"] .dropdown-item,
html[dir="rtl"] .label,
html[dir="rtl"] .sub {
text-align: right;
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -22,9 +22,9 @@
<div class="header-right">
<div id="connBadge" class="conn-badge" hidden>
<span class="conn-dot"></span>
<span id="connLabel">Connecté</span>
<span id="connLabel"></span>
</div>
<button type="button" id="settingsBtn" class="icon-btn" title="Instance Momento" aria-label="Instance Momento">
<button type="button" id="settingsBtn" class="icon-btn" title="" aria-label="">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
</div>
@@ -32,31 +32,29 @@
<div id="settingsPanel" class="settings-panel" hidden>
<label class="field">
<span>URL de votre instance Momento</span>
<span id="instanceUrlLabel"></span>
<input id="baseUrl" type="text" spellcheck="false" placeholder="http://localhost:3000" />
</label>
<div class="preset-row">
<button type="button" class="preset-btn" data-url="https://memento-note.com">Production</button>
<button type="button" class="preset-btn" data-url="https://memento-note.com"></button>
<button type="button" class="preset-btn" data-url="http://localhost:3000">localhost:3000</button>
<button type="button" class="preset-btn" data-url="http://127.0.0.1:3000">127.0.0.1:3000</button>
</div>
<div class="settings-actions">
<button type="button" id="applyInstanceBtn" class="btn btn-primary btn-sm">Appliquer &amp; reconnecter</button>
<button type="button" id="openLoginBtn" class="btn btn-secondary btn-sm">Ouvrir Momento ↗</button>
<button type="button" id="applyInstanceBtn" class="btn btn-primary btn-sm"></button>
<button type="button" id="openLoginBtn" class="btn btn-secondary btn-sm"></button>
</div>
<p class="settings-hint">
Connectez-vous sur <strong>la même URL</strong> dans Chrome (Google OAuth). En dev, utilisez exactement
<code>http://localhost:3000</code> ou <code>http://127.0.0.1:3000</code> — pas un mélange des deux.
</p>
<p class="settings-hint"></p>
<p id="settingsStatus" class="settings-status" hidden></p>
</div>
<main id="screen" class="main"></main>
<footer class="footer">
<span class="footer-meta">Momento Web Clipper v0.3.0</span>
<span class="footer-meta"></span>
</footer>
</div>
<script src="i18n.js"></script>
<script src="sidepanel.js"></script>
</body>
</html>

View File

@@ -59,7 +59,7 @@ async function ensureApiPermission() {
const has = await chrome.permissions.contains({ origins: [origin] })
if (!has) {
const granted = await chrome.permissions.request({ origins: [origin] })
if (!granted) throw new Error('Autorisez laccès à votre instance Momento dans Chrome.')
if (!granted) throw new Error(t('errPermissionDenied'))
}
}
@@ -108,7 +108,7 @@ function sortNotebooksHierarchy(list) {
byParent.get(pid).push(n)
}
for (const items of byParent.values()) {
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'fr'))
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', uiLocaleTag()))
}
const out = []
const seen = new Set()
@@ -133,19 +133,19 @@ function notebookSelectHtml() {
.map((n) => {
const indent = n.depth > 0 ? '\u00A0\u00A0'.repeat(n.depth) + '↳ ' : ''
const sel = n.id === selectedNotebookId ? ' selected' : ''
return `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || 'Sans nom'))}</option>`
return `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || t('notebookUnnamed')))}</option>`
})
.join('')
return `<select id="notebookSelect" class="notebook-select" aria-label="Carnet de destination">
${notebooks.length ? opts : '<option value="">Aucun carnet</option>'}
return `<select id="notebookSelect" class="notebook-select" aria-label="${escapeHtml(t('destinationNotebook'))}">
${notebooks.length ? opts : `<option value="">${escapeHtml(t('noNotebooks'))}</option>`}
</select>`
}
function formatReadingTime(minutes) {
const m = Number(minutes) || 0
if (m <= 0) return ''
if (m === 1) return '1 min de lecture'
return `${m} min de lecture`
if (m === 1) return t('readingTimeOne')
return t('readingTimeOther', String(m))
}
async function getActiveTab() {
@@ -186,7 +186,7 @@ async function syncPickMode() {
function updateConnBadge() {
if (!els.connBadge) return
els.connBadge.hidden = !connected
if (els.connLabel) els.connLabel.textContent = connected ? 'Connecté' : 'Déconnecté'
if (els.connLabel) els.connLabel.textContent = connected ? t('connected') : t('disconnected')
}
function setSettingsStatus(msg, isError) {
@@ -207,14 +207,14 @@ function selectionBlockHtml() {
if (selectionText) {
return `<div class="selection-panel has-text" id="selectionSlot">
<div class="selection-head">
<span class="status live"><span class="pulse-dot sky"></span> lectiontectée</span>
<button type="button" class="clear-btn" id="clearSel">Ignorer</button>
<span class="status live"><span class="pulse-dot sky"></span> ${escapeHtml(t('selectionDetected'))}</span>
<button type="button" class="clear-btn" id="clearSel">${escapeHtml(t('ignore'))}</button>
</div>
<div class="selection-body"${rtlAttrs(selectionText)}>「 ${escapeHtml(selectionText)} 」</div>
</div>`
}
return `<div class="selection-hint" id="selectionSlot">
<p>Astuce : surlignez du texte sur la page pour clipper une sélection précise. Le panneau reste ouvert pendant la sélection.</p>
<p>${escapeHtml(t('selectionHint'))}</p>
</div>`
}
@@ -224,15 +224,15 @@ function actionsBlockHtml() {
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
${ICON_SELECT} Clipper la sélection
${ICON_SELECT} ${escapeHtml(t('clipSelection'))}
</button>`
: ''
}
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_CLIP} Clipper cette page
${ICON_CLIP} ${escapeHtml(t('clipPage'))}
</button>
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_LINK} Enregistrer le lien seul
${ICON_LINK} ${escapeHtml(t('saveLinkOnly'))}
</button>
</div>`
}
@@ -290,7 +290,7 @@ async function refreshPageContext() {
if (!tab?.id || pageRestricted) {
pageUrl = tab?.url || ''
pageTitle = tab?.title || 'Page non accessible'
pageTitle = tab?.title || t('pageNotAccessible')
selectionText = ''
return
}
@@ -354,9 +354,9 @@ async function loadNotebooks(preferredId) {
connected = false
updateConnBadge()
if (res.status === 401) {
throw new Error('Connectez-vous à Momento sur la même URL (bouton « Ouvrir Momento »).')
throw new Error(t('errLoginRequired'))
}
throw new Error('Impossible de charger les carnets.')
throw new Error(t('errLoadNotebooks'))
}
const data = await res.json()
notebooks = data.notebooks || []
@@ -367,7 +367,7 @@ async function loadNotebooks(preferredId) {
connected = true
updateConnBadge()
errorMessage = ''
setSettingsStatus('Carnets chargés.', false)
setSettingsStatus(t('notebooksLoaded'), false)
} catch (e) {
notebooks = []
connected = false
@@ -381,16 +381,16 @@ async function applyInstance() {
const url = (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
if (els.baseUrl) els.baseUrl.value = url
await chrome.storage.sync.set({ [STORAGE_KEYS.baseUrl]: url })
setSettingsStatus('Connexion en cours…', false)
setSettingsStatus(t('connecting'), false)
await loadNotebooks(selectedNotebookId)
if (connected) {
setSettingsStatus(`Connecté à ${url}`, false)
setSettingsStatus(t('connectedToUrl', url), false)
}
}
function renderIdle() {
const restrictedBlock = pageRestricted
? `<div class="restricted-note">Cette page ne peut pas être clippée (page système Chrome). Ouvrez un site web normal.</div>`
? `<div class="restricted-note">${escapeHtml(t('restrictedPage'))}</div>`
: ''
const authHint =
@@ -403,12 +403,12 @@ function renderIdle() {
${authHint}
<div>
<span class="label">Carnet de destination</span>
<span class="label">${escapeHtml(t('destinationNotebook'))}</span>
${notebookSelectHtml()}
</div>
<div class="page-card">
<span class="sub">Page active</span>
<span class="sub">${escapeHtml(t('activePage'))}</span>
<div class="page-row">
<img src="${escapeHtml(pageFavicon)}" alt="" onerror="this.src='https://www.google.com/s2/favicons?domain=google.com&sz=32'" />
<div class="page-text">
@@ -432,9 +432,9 @@ function renderLoading(label) {
<div class="spinner"></div>
</div>
<div>
<div class="state-title">Analyse de la source</div>
<div class="state-sub">${escapeHtml(label || 'Traitement en cours…')}</div>
<div class="state-detail">Résumé, tags et préparation de la note Momento.</div>
<div class="state-title">${escapeHtml(t('analyzingSource'))}</div>
<div class="state-sub">${escapeHtml(label || t('statusAnalyzing'))}</div>
<div class="state-detail">${escapeHtml(t('processingDetail'))}</div>
</div>
</div>
`
@@ -448,9 +448,9 @@ function renderConfirm() {
els.screen.innerHTML = `
<div class="confirm-panel">
<span class="label">Aperçu avant enregistrement</span>
<span class="label">${escapeHtml(t('previewBeforeSave'))}</span>
<label class="field">
<span>Titre de la note</span>
<span>${escapeHtml(t('noteTitleLabel'))}</span>
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
</label>
${
@@ -466,7 +466,7 @@ function renderConfirm() {
${
excerpt && pendingClipType !== 'link'
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
<span class="excerpt-label">Extrait</span>
<span class="excerpt-label">${escapeHtml(t('excerptLabel'))}</span>
${escapeHtml(excerpt)}
</div>`
: ''
@@ -474,8 +474,8 @@ function renderConfirm() {
${tagsHtml ? `<div class="tags preview-tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="saveBtn">Enregistrer dans Momento</button>
<button type="button" class="btn-link" id="cancelConfirmBtn">Retour</button>
<button type="button" class="btn btn-primary" id="saveBtn">${escapeHtml(t('saveToMomento'))}</button>
<button type="button" class="btn-link" id="cancelConfirmBtn">${escapeHtml(t('back'))}</button>
</div>
`
@@ -500,16 +500,16 @@ function renderSuccess() {
<div class="center-state" style="justify-content:flex-start;padding-top:12px">
<div class="success-icon">✓</div>
<div>
<span class="badge-ok">Note enregistrée</span>
<span class="badge-ok">${escapeHtml(t('noteSaved'))}</span>
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
<div class="state-detail">Carnet « ${escapeHtml(nb?.name || '')} »</div>
<div class="state-detail">${escapeHtml(t('sentToNotebook', nb?.name || ''))}</div>
${reading ? `<div class="state-detail">${escapeHtml(reading)}</div>` : ''}
</div>
${tagsHtml ? `<div class="tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="viewBtn">Voir dans Momento ↗</button>
<button type="button" class="btn-link" id="againBtn">Clipper autre chose</button>
<button type="button" class="btn btn-primary" id="viewBtn">${escapeHtml(t('viewInMomento'))} ↗</button>
<button type="button" class="btn-link" id="againBtn">${escapeHtml(t('clipAnother'))}</button>
</div>
`
document.getElementById('viewBtn')?.addEventListener('click', () => {
@@ -529,13 +529,13 @@ function renderError() {
<div class="center-state">
<div class="error-icon">!</div>
<div>
<div class="state-title" style="color:#ef4444">Échec</div>
<div class="state-detail">${escapeHtml(errorMessage || 'Une erreur s\'est produite.')}</div>
<div class="state-title" style="color:#ef4444">${escapeHtml(t('failure'))}</div>
<div class="state-detail">${escapeHtml(errorMessage || t('genericError'))}</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-danger" id="retryBtn">Réessayer</button>
<button type="button" class="btn-link" id="backIdleBtn">Retour</button>
<button type="button" class="btn btn-danger" id="retryBtn">${escapeHtml(t('retry'))}</button>
<button type="button" class="btn-link" id="backIdleBtn">${escapeHtml(t('back'))}</button>
</div>
`
document.getElementById('retryBtn')?.addEventListener('click', () => {
@@ -553,7 +553,9 @@ function renderError() {
}
function render() {
if (state === 'loading' || state === 'saving') return renderLoading(state === 'saving' ? 'Enregistrement…' : 'Analyse…')
if (state === 'loading' || state === 'saving') {
return renderLoading(state === 'saving' ? t('statusSaving') : t('statusAnalyzing'))
}
if (state === 'confirm') return renderConfirm()
if (state === 'success') return renderSuccess()
if (state === 'error') return renderError()
@@ -573,7 +575,7 @@ async function runAnalyze(type) {
})
if (type === 'selection') {
if (!selectionText) throw new Error('Aucune sélection active.')
if (!selectionText) throw new Error(t('errNoSelection'))
await refreshPageContext()
}
@@ -593,14 +595,14 @@ async function runAnalyze(type) {
body: JSON.stringify(analyzeBody),
})
const analysis = await analyzeRes.json()
if (!analyzeRes.ok) throw new Error(analysis.error || 'Analyse impossible')
if (!analyzeRes.ok) throw new Error(analysis.error || t('errAnalyzeFailed'))
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
render()
} catch (e) {
errorMessage = e.message || 'Erreur réseau'
errorMessage = e.message || t('errNetwork')
state = 'error'
render()
}
@@ -626,7 +628,7 @@ async function runSave() {
}),
})
const saved = await saveRes.json()
if (!saveRes.ok) throw new Error(saved.error || 'Enregistrement impossible')
if (!saveRes.ok) throw new Error(saved.error || t('errSaveFailed'))
successTitle = title
successTags = analyzeResult.tags || []
@@ -635,7 +637,7 @@ async function runSave() {
state = 'success'
render()
} catch (e) {
errorMessage = e.message || 'Erreur réseau'
errorMessage = e.message || t('errNetwork')
state = 'error'
render()
}
@@ -688,7 +690,9 @@ document.addEventListener('visibilitychange', async () => {
})
document.addEventListener('DOMContentLoaded', async () => {
applyDocumentLocale()
applyInstanceConfigVisibility()
applyShellI18n()
await loadSettings()
try {
await ensureApiPermission()

View File

@@ -0,0 +1,90 @@
import prisma from '@/lib/prisma'
import { isCardMastered } from '@/lib/flashcards/sm2'
export interface DeckSummary {
id: string
name: string
notebookId: string | null
totalCards: number
dueCount: number
masteredCount: number
lastReviewedAt: string | null
createdAt: string
}
export async function listDeckSummaries(userId: string): Promise<DeckSummary[]> {
const now = new Date()
const decks = await prisma.flashcardDeck.findMany({
where: { userId },
include: {
flashcards: {
select: {
id: true,
interval: true,
nextReviewAt: true,
reviews: {
orderBy: { reviewedAt: 'desc' },
take: 1,
select: { reviewedAt: true },
},
},
},
},
orderBy: { updatedAt: 'desc' },
})
return decks.map((deck) => {
const totalCards = deck.flashcards.length
const dueCount = deck.flashcards.filter((c) => c.nextReviewAt <= now).length
const masteredCount = deck.flashcards.filter((c) => isCardMastered(c.interval)).length
const lastReview = deck.flashcards
.flatMap((c) => c.reviews.map((r) => r.reviewedAt))
.sort((a, b) => b.getTime() - a.getTime())[0]
return {
id: deck.id,
name: deck.name,
notebookId: deck.notebookId,
totalCards,
dueCount,
masteredCount,
lastReviewedAt: lastReview ? lastReview.toISOString() : null,
createdAt: deck.createdAt.toISOString(),
}
})
}
export async function getDeckDetail(userId: string, deckId: string) {
const deck = await prisma.flashcardDeck.findFirst({
where: { id: deckId, userId },
include: {
flashcards: {
orderBy: { nextReviewAt: 'asc' },
include: {
note: { select: { id: true, title: true } },
},
},
},
})
if (!deck) return null
const now = new Date()
return {
id: deck.id,
name: deck.name,
notebookId: deck.notebookId,
cards: deck.flashcards.map((c) => ({
id: c.id,
front: c.front,
back: c.back,
type: c.type,
interval: c.interval,
easinessFactor: c.easinessFactor,
nextReviewAt: c.nextReviewAt.toISOString(),
noteId: c.noteId,
noteTitle: c.note?.title ?? null,
due: c.nextReviewAt <= now,
mastered: isCardMastered(c.interval),
})),
}
}

View File

@@ -0,0 +1,55 @@
import prisma from '@/lib/prisma'
export async function getOrCreateDeckForNotebook(params: {
userId: string
notebookId: string | null
notebookName?: string | null
manualName?: string
}) {
const { userId, notebookId, notebookName, manualName } = params
if (notebookId) {
const existing = await prisma.flashcardDeck.findFirst({
where: { userId, notebookId },
})
if (existing) return existing
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId },
select: { name: true },
})
if (!notebook) {
throw new Error('Notebook not found')
}
return prisma.flashcardDeck.create({
data: {
userId,
notebookId,
name: notebook.name,
},
})
}
const name = (manualName || notebookName || 'Deck').trim().slice(0, 120)
if (!name) {
throw new Error('Deck name required')
}
return prisma.flashcardDeck.create({
data: {
userId,
name,
},
})
}
export function stripHtmlToText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}

View File

@@ -0,0 +1,86 @@
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
export type FlashcardStyle = 'qa' | 'cloze' | 'concept'
export interface GeneratedFlashcard {
front: string
back: string
type: FlashcardStyle
}
const STYLE_HINTS: Record<FlashcardStyle, string> = {
qa: 'question/answer pairs — front is a clear question, back is a concise answer',
cloze: 'fill-in-the-blank — front uses ___ for the missing word(s), back is the complete sentence',
concept: 'term/definition — front is a term or concept name, back is its definition',
}
function parseFlashcardsJson(raw: string, style: FlashcardStyle): GeneratedFlashcard[] {
const trimmed = raw.trim()
const arrayMatch = trimmed.match(/\[[\s\S]*\]/)
if (!arrayMatch) return []
try {
const parsed = JSON.parse(arrayMatch[0]) as unknown
if (!Array.isArray(parsed)) return []
return parsed
.map((item) => {
if (!item || typeof item !== 'object') return null
const obj = item as Record<string, unknown>
const front = typeof obj.front === 'string' ? obj.front.trim() : ''
const back = typeof obj.back === 'string' ? obj.back.trim() : ''
const type = (typeof obj.type === 'string' ? obj.type : style) as FlashcardStyle
if (!front || !back) return null
return {
front: front.slice(0, 500),
back: back.slice(0, 800),
type: ['qa', 'cloze', 'concept'].includes(type) ? type : style,
}
})
.filter((c): c is GeneratedFlashcard => c !== null)
} catch {
return []
}
}
export async function generateFlashcardsFromNote(params: {
title: string
textContent: string
count: number
style: FlashcardStyle
language?: string
}): Promise<GeneratedFlashcard[]> {
const count = Math.min(20, Math.max(5, params.count))
const excerpt = params.textContent.slice(0, 8000)
const lang = params.language && params.language !== 'auto' ? params.language : 'same as source'
const config = await getSystemConfig()
const provider = getChatProvider(config)
const prompt = `You create study flashcards from personal notes for spaced repetition.
Note title: ${params.title || 'Untitled'}
Language: ${lang}
Style: ${params.style}${STYLE_HINTS[params.style]}
Source content:
${excerpt}
Generate exactly ${count} flashcards. Use the same language as the source.
Respond with ONLY a JSON array (no markdown):
[
{ "front": "...", "back": "...", "type": "${params.style}" }
]
Rules:
- Each card tests one distinct fact from the note
- Front and back must be self-contained
- No duplicate cards
- Cloze cards must include ___ on the front`
const raw = await provider.generateText(prompt)
const cards = parseFlashcardsJson(raw, params.style)
if (cards.length === 0) {
throw new Error('Could not parse flashcards from AI response')
}
return cards.slice(0, count)
}

View File

@@ -0,0 +1,39 @@
export type Sm2Grade = 1 | 2 | 3 | 4
export interface Sm2State {
easinessFactor: number
interval: number
}
export interface Sm2UpdateResult extends Sm2State {
nextReviewAt: Date
}
/** SM-2 update per US-FLASHCARDS spec (grades 14). */
export function computeSm2Update(
grade: number,
previous: Sm2State,
): Sm2UpdateResult {
const g = Math.min(4, Math.max(1, Math.round(grade))) as Sm2Grade
const ef = previous.easinessFactor
const newEF = Math.max(
1.3,
ef + 0.1 - (5 - g) * (0.08 + (5 - g) * 0.02),
)
const nextInterval =
g <= 2 ? 1 : Math.max(1, Math.round(previous.interval * newEF))
const nextReviewAt = new Date()
nextReviewAt.setDate(nextReviewAt.getDate() + nextInterval)
return {
easinessFactor: newEF,
interval: nextInterval,
nextReviewAt,
}
}
export function isCardMastered(interval: number): boolean {
return interval >= 21
}

View File

@@ -2404,6 +2404,64 @@
"Japonais": "Japanese"
}
},
"flashcards": {
"generateTitle": "Generate flashcards",
"generateAction": "Generate with AI",
"confirmSave": "Save to deck",
"generateFailed": "Could not generate flashcards",
"saveFailed": "Could not save flashcards",
"savedCount": "{count} flashcards saved",
"cardCount": "Number of cards",
"styleLabel": "Card style",
"style": {
"qa": "Q&A",
"cloze": "Cloze",
"concept": "Concept"
},
"previewHint": "Edit cards before saving",
"frontPlaceholder": "Question / front",
"backPlaceholder": "Answer / back",
"toolbarGenerate": "Generate flashcards",
"tabDecks": "Decks",
"tabProgress": "Progress",
"emptyDecks": "No flashcard decks yet",
"emptyDecksHint": "Open a note and use the graduation cap in the toolbar to generate cards with AI.",
"createDeck": "Create deck",
"newDeckPlaceholder": "Thematic deck name…",
"deckCreated": "Deck created",
"dueCount": "{count} due today",
"upToDate": "Up to date",
"cardCountLabel": "{count} cards",
"masteredShort": "mastered",
"viewDeck": "Details",
"review": "Review",
"startReview": "Start review",
"activeDeck": "Active deck",
"statTotal": "Total: {count}",
"statDue": "Due: {count}",
"statMastered": "Mastered: {count}",
"exitSession": "Exit session",
"previous": "Previous",
"next": "Next",
"front": "Front",
"back": "Back",
"tapToFlip": "Space or tap to flip",
"grade": {
"hard": "Hard (1)",
"difficult": "Difficult (2)",
"good": "Good (3)",
"easy": "Easy (4)"
},
"sessionComplete": "Session complete",
"backToDecks": "Back to decks",
"loadDeckFailed": "Could not load deck",
"reviewFailed": "Could not save review",
"heatmapTitle": "Review activity",
"heatmapLast90": "Last 90 days",
"retentionRate": "Retention rate",
"retentionCurve": "Weekly retention",
"difficultCards": "Hardest cards"
},
"brainstorm": {
"title": "Waves of Thought",
"subtitle": "Unfold dimensions of potentiality",

View File

@@ -2408,6 +2408,64 @@
"Japonais": "Japonais"
}
},
"flashcards": {
"generateTitle": "Générer des flashcards",
"generateAction": "Générer avec l'IA",
"confirmSave": "Enregistrer dans le deck",
"generateFailed": "Impossible de générer les flashcards",
"saveFailed": "Impossible d'enregistrer les flashcards",
"savedCount": "{count} flashcards enregistrées",
"cardCount": "Nombre de cartes",
"styleLabel": "Style de cartes",
"style": {
"qa": "Question / réponse",
"cloze": "Texte à trous",
"concept": "Terme / définition"
},
"previewHint": "Modifiez les cartes avant l'enregistrement",
"frontPlaceholder": "Question / recto",
"backPlaceholder": "Réponse / verso",
"toolbarGenerate": "Générer des flashcards",
"tabDecks": "Decks",
"tabProgress": "Progression",
"emptyDecks": "Aucun deck de flashcards",
"emptyDecksHint": "Ouvrez une note et utilisez l'icône casquette dans la barre d'outils pour générer des cartes avec l'IA.",
"createDeck": "Créer un deck",
"newDeckPlaceholder": "Nom du deck thématique…",
"deckCreated": "Deck créé",
"dueCount": "{count} à réviser",
"upToDate": "À jour",
"cardCountLabel": "{count} cartes",
"masteredShort": "maîtrisées",
"viewDeck": "Détails",
"review": "Réviser",
"startReview": "Lancer la révision",
"activeDeck": "Deck actif",
"statTotal": "Total : {count}",
"statDue": "À réviser : {count}",
"statMastered": "Maîtrisées : {count}",
"exitSession": "Quitter la session",
"previous": "Précédent",
"next": "Suivant",
"front": "Recto",
"back": "Verso",
"tapToFlip": "Espace ou clic pour retourner",
"grade": {
"hard": "Difficile (1)",
"difficult": "Dur (2)",
"good": "Bien (3)",
"easy": "Facile (4)"
},
"sessionComplete": "Session terminée",
"backToDecks": "Retour aux decks",
"loadDeckFailed": "Impossible de charger le deck",
"reviewFailed": "Impossible d'enregistrer la révision",
"heatmapTitle": "Activité de révision",
"heatmapLast90": "90 derniers jours",
"retentionRate": "Taux de rétention",
"retentionCurve": "Rétention hebdomadaire",
"difficultCards": "Cartes difficiles"
},
"brainstorm": {
"title": "Vagues de pensée",
"subtitle": "Déployer les dimensions du potentiel",

View File

@@ -0,0 +1,74 @@
-- CreateTable
CREATE TABLE "FlashcardDeck" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"notebookId" TEXT,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FlashcardDeck_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Flashcard" (
"id" TEXT NOT NULL,
"deckId" TEXT NOT NULL,
"noteId" TEXT,
"front" TEXT NOT NULL,
"back" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'qa',
"interval" INTEGER NOT NULL DEFAULT 1,
"easinessFactor" DOUBLE PRECISION NOT NULL DEFAULT 2.5,
"nextReviewAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Flashcard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FlashcardReview" (
"id" TEXT NOT NULL,
"cardId" TEXT NOT NULL,
"grade" INTEGER NOT NULL,
"reviewedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FlashcardReview_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FlashcardDeck_notebookId_key" ON "FlashcardDeck"("notebookId");
-- CreateIndex
CREATE INDEX "FlashcardDeck_userId_idx" ON "FlashcardDeck"("userId");
-- CreateIndex
CREATE INDEX "Flashcard_deckId_idx" ON "Flashcard"("deckId");
-- CreateIndex
CREATE INDEX "Flashcard_noteId_idx" ON "Flashcard"("noteId");
-- CreateIndex
CREATE INDEX "Flashcard_nextReviewAt_idx" ON "Flashcard"("nextReviewAt");
-- CreateIndex
CREATE INDEX "FlashcardReview_cardId_idx" ON "FlashcardReview"("cardId");
-- CreateIndex
CREATE INDEX "FlashcardReview_reviewedAt_idx" ON "FlashcardReview"("reviewedAt");
-- AddForeignKey
ALTER TABLE "FlashcardDeck" ADD CONSTRAINT "FlashcardDeck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FlashcardDeck" ADD CONSTRAINT "FlashcardDeck_notebookId_fkey" FOREIGN KEY ("notebookId") REFERENCES "Notebook"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Flashcard" ADD CONSTRAINT "Flashcard_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "FlashcardDeck"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Flashcard" ADD CONSTRAINT "Flashcard_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FlashcardReview" ADD CONSTRAINT "FlashcardReview_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "Flashcard"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -54,6 +54,7 @@ model User {
noteClusters NoteCluster[]
bridgeNotes BridgeNote[]
bridgeSuggestions BridgeSuggestion[]
flashcardDecks FlashcardDeck[]
}
model Account {
@@ -111,6 +112,7 @@ model Notebook {
children Notebook[] @relation("NotebookTree")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workflows Workflow[]
flashcardDeck FlashcardDeck?
@@index([userId, order])
@@index([userId])
@@ -195,6 +197,7 @@ model Note {
bridgeNote BridgeNote?
sourceLiveBlocks LiveBlockRef[] @relation("SourceLiveBlocks")
targetLiveBlocks LiveBlockRef[] @relation("TargetLiveBlocks")
flashcards Flashcard[]
@@index([isPinned])
@@index([isArchived])
@@ -855,3 +858,49 @@ model BridgeSuggestion {
@@index([userId, isDismissed])
@@index([clusterAId, clusterBId])
}
model FlashcardDeck {
id String @id @default(cuid())
userId String
notebookId String? @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
flashcards Flashcard[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
@@index([userId])
}
model Flashcard {
id String @id @default(cuid())
deckId String
noteId String?
front String
back String
type String @default("qa")
interval Int @default(1)
easinessFactor Float @default(2.5)
nextReviewAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deck FlashcardDeck @relation(fields: [deckId], references: [id], onDelete: Cascade)
note Note? @relation(fields: [noteId], references: [id], onDelete: SetNull)
reviews FlashcardReview[]
@@index([deckId])
@@index([noteId])
@@index([nextReviewAt])
}
model FlashcardReview {
id String @id @default(cuid())
cardId String
grade Int
reviewedAt DateTime @default(now())
card Flashcard @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@index([cardId])
@@index([reviewedAt])
}