diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9199eb7..b6069a5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,7 +31,9 @@ "mcp__zai-mcp-server__analyze_image", "Bash(npx prisma *)", "Bash(xargs -I{} ls {})", - "Bash(node_modules/.bin/tsc --noEmit)" + "Bash(node_modules/.bin/tsc --noEmit)", + "Bash(python3 /home/devparsa/dev/Momento/_bmad/scripts/resolve_customization.py --skill /home/devparsa/dev/Momento/.claude/skills/bmad-agent-analyst --key agent)", + "Bash(unzip -l /home/devparsa/dev/Momento/memento-note/extension/memento-web-clipper-chrome-store.zip)" ] } } diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index ba092cb..fbff48a 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -1,12 +1,13 @@ # Deferred Work -## Deferred from: code review of 3-5-secure-byok-management (2026-05-16) +## ~~Deferred from: code review of 3-5-secure-byok-management (2026-05-16)~~ ✅ COMPLETED -- **Test host BYOK + quota invité vide (Task 7.4)** — Scénario AC10 (hôte BYOK, quota invité vide) non couvert par test dédié dans `brainstorm-billing.test.ts`. -- **`lastUsedAt` / `lastUsedFor` jamais mis à jour** — Champs Prisma présents mais non alimentés à l’usage des clés BYOK. -- **`keyHash` non utilisé pour dédup** — Hash SHA-256 stocké sans logique de déduplication à l’upsert. -- **Downgrade tier → désactivation clés hors liste** — Pas de `isActive=false` automatique au downgrade PRO/Business ; seul le rejet des nouveaux saves est en place. -- **Rate limit POST `/api/user/api-keys`** — Pas de limite Redis documentée en spec optionnelle. +All BYOK items implemented on 2026-05-30: +- ✅ **Test host BYOK + quota invité vide (Task 7.4)** — Test AC10 ajouté dans `brainstorm-billing.test.ts` +- ✅ **`lastUsedAt` / `lastUsedFor` tracking** — Implémenté dans `resolveByokApiKey()` avec update async non-bloquant +- ✅ **`keyHash` dédup cross-provider** — `findDuplicateApiKeyHash()` ajouté, vérification dans route POST (409 Conflict) +- ✅ **Downgrade tier → désactivation clés** — `deactivateUnauthorizedKeys()` + safety check dans `getActiveByokKey()` +- ✅ **Rate limit POST `/api/user/api-keys`** — `checkApiKeyCreationRateLimit()` (5/h) dans route POST (429) ## Deferred from: code review of 4-1-gdpr-cookie-consent (2026-05-16) @@ -14,7 +15,7 @@ ## Deferred from: unified tasks view study (2026-05-24) -- **Vue agrégée Notes/Tâches (Markdown scrape)** — Retirée volontairement (option A produit) : surcharge UX, chevauchement avec vues structurées Kanban. Spec `spec-unified-tasks-view.md` abandonnée ; pas d’unification TipTap/Checklist pour cette vue. +- **Vue agrégée Notes/Tâches (Markdown scrape)** — Retirée volontairement (option A produit) : surcharge UX, chevauchement avec vues structurées Kanban. Spec `spec-unified-tasks-view.md` abandonnée ; pas d'unification TipTap/Checklist pour cette vue. ## Deferred from: US-TEMPORAL product decision (2026-05-24) diff --git a/_bmad-output/implementation-artifacts/spec-gdpr-analytics-sync.md b/_bmad-output/implementation-artifacts/spec-gdpr-analytics-sync.md new file mode 100644 index 0000000..39a11f5 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-gdpr-analytics-sync.md @@ -0,0 +1,74 @@ +--- +status: in-progress +title: "GDPR Analytics Sync + Error Reporting Hardening" +story: 4.1-gdpr-analytics-sync +epic: 4 +priority: high +blast_radius: medium +--- + +# Spec: GDPR Analytics Sync + Error Reporting Hardening + +## Context + +Deferred work from Story 4.1 (GDPR Cookie Consent): +- AC5 anonymousAnalytics DB sync was not implemented +- Original constraint: "zero DB writes in 4.1, 100% client consent" +- Now removing constraint to implement proper sync + +## Goals + +1. **Primary**: Sync cookie consent `anonymousAnalytics` to database for authenticated users +2. **Secondary**: Verify error reporting is properly wired (legitimate interest, no consent needed) + +## Acceptance Criteria + +### AC1: Authenticated user consent syncs to DB +- When a logged-in user accepts/rejects analytics via banner or preferences, `UserAISettings.anonymousAnalytics` is updated in DB +- Guest users (no session) continue using localStorage only + +### AC2: Cross-device consistency +- User's analytics consent persists across devices when logged in +- Initial consent load prefers DB value when local storage is empty + +### AC3: Error reporting verification +- Existing error reporting continues to work without consent (legitimate interest) +- Verify `/api/debug/client-error` route exists and is wired + +## Tasks + +- [ ] Task 1: Create server action for consent sync +- [ ] Task 2: Update cookie consent utilities +- [ ] Task 3: Update banner and dialog components +- [ ] Task 4: Verify error reporting route + +## Code Map + +### New/Modified Files + +| File | Change | +|------|--------| +| `lib/consent/cookie-consent.ts` | Add `saveConsentWithSync()` client wrapper | +| `app/actions/cookie-consent.ts` | NEW - Server action for DB sync | +| `components/legal/cookie-consent-banner.tsx` | Use new sync action | +| `components/legal/cookie-preferences-dialog.tsx` | Use new sync action | +| `app/api/debug/client-error/route.ts` | Verify exists | + +### Design Notes + +- Server action calls `updateAISettings({ anonymousAnalytics: boolean })` +- Client-side: combine `setConsent()` + server action in parallel +- Server action silently succeeds for guests (no session) — component ignores result +- Error reporting uses `/api/debug/client-error` — already exists, no consent gate needed + +## Spec Change Log + +- 2026-05-30: Created spec for deferred AC5 implementation +- 2026-05-30: Added error reporting verification as secondary goal + +## Dev Agent Record + +### Agent Model Used +Claude Opus 4.8 + +### Completion Notes List diff --git a/docs/referral-system-design.md b/docs/referral-system-design.md new file mode 100644 index 0000000..cbc6a50 --- /dev/null +++ b/docs/referral-system-design.md @@ -0,0 +1,211 @@ +# Memento — Spécification Technique & Design du Système de Parrainage (Referral) + +Ce document présente l'architecture complète d'un système de parrainage (referral) natif pour **Momento**, intégré à notre base de données PostgreSQL (Prisma) et à Stripe. + +--- + +## 1. Objectifs du Système + +1. **Viralité (PLG)** : Encourager les utilisateurs existants à partager Momento pour acquérir de nouveaux clients sans budget publicitaire (CAC proche de 0). +2. **Double Récompense (Win-Win)** : + - **Le Filleul (Invité)** obtient une réduction immédiate lors de son premier abonnement (ex. `-10 % sur son abonnement PRO`). + - **Le Parrain (Hôte)** obtient une récompense lors du premier paiement de son filleul (ex. **1 mois gratuit** appliqué directement sur sa prochaine facture Stripe, ou **+100 crédits IA** récurrents). +3. **Simplicité & Automatisation** : Le parrainage doit se faire par lien unique et être validé automatiquement via notre webhook Stripe actuel. + +--- + +## 2. Architecture de Données (Prisma) + +Pour suivre les relations de parrainage et distribuer les récompenses de manière sécurisée et sans doublons, nous proposons d'étendre le schéma Prisma actuel. + +```prisma +// memento-note/prisma/schema.prisma + +model User { + // ... champs existants ... + + // Système de Parrainage + referralCode String @unique // Code unique généré pour l'utilisateur (ex: "SEPEHR50") + referredById String? // ID du parrain qui l'a invité + referredBy User? @relation("UserReferrals", fields: [referredById], references: [id]) + referrals User[] @relation("UserReferrals") + + referralRewards ReferralReward[] // Historique des récompenses distribuées +} + +model ReferralReward { + id String @id @default(cuid()) + userId String // Le parrain récompensé + refereeId String // Le filleul qui a déclenché la récompense + rewardType String // "FREE_MONTH" (Stripe) ou "AI_CREDITS" (Redis) + status String @default("PENDING") // "PENDING", "COMPLETED", "FAILED" + stripeTxId String? // ID de la transaction de crédit Stripe si applicable + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} +``` + +--- + +## 3. Le Parcours Utilisateur pas à pas + +### 3.1 Génération et Partage du Lien +1. Chaque utilisateur inscrit reçoit automatiquement un code unique lors de la création de son compte (généré à partir de son nom + 4 chiffres uniques, ou un cuid court). +2. Un nouvel onglet **"Parrainage"** est ajouté dans `/settings/billing` : + - Affiche son code promo de parrainage : `MOMENTO-SEPEHR-1234` + - Affiche son lien d'invitation unique : `https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234` + - Affiche un bouton de partage rapide (LinkedIn, X, Email, Clipboard). + +### 3.2 Inscription du Filleul (Cookie & localStorage) +1. Le filleul clique sur le lien `https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234`. +2. Le frontend intercepte le paramètre `ref` dans l'URL et le stocke de manière persistante dans un cookie ou dans le `localStorage` (durée : 30 jours). +3. Lorsque le filleul valide son formulaire d'inscription : + - Le serveur lit le code promo de parrainage dans la requête. + - S'il est valide, il associe le `referredById` du nouveau compte `User` à l'ID du parrain. + +### 3.3 Achat de l'Abonnement et Déclenchement de la Récompense +1. Lors du checkout sur le plan Pro, le filleul utilise le code promo Stripe `MOMENTO-SEPEHR-1234` (qui est configuré dans votre Stripe Dashboard comme un code promotionnel avec une réduction de 10%). +2. Une fois le paiement validé, Stripe émet l'événement `checkout.session.completed` à notre webhook `/api/billing/webhook`. +3. Notre helper `sync-subscription-from-stripe.ts` traite l'activation de l'abonnement : + - Il vérifie en base de données si le nouvel abonné possède un parrain (`referredById !== null`). + - Il vérifie si une récompense pour ce couple Parrain/Filleul a déjà été créée (évite le double-déclenchement). + - Il déclenche la distribution des récompenses. + +--- + +## 4. Implémentation Technique des Récompenses + +### Option A (Recommandée) : Offrir 1 mois gratuit sur Stripe au Parrain +Pour récompenser le parrain de manière entièrement automatisée, nous utilisons le système de **Solde Client (Customer Balance)** de Stripe. On ajoute un crédit négatif (ex. `-9.90 €`) sur le compte client Stripe du parrain. Lors de son prochain renouvellement, Stripe déduira automatiquement ce crédit, ce qui lui offrira son mois gratuit. + +```typescript +// lib/billing/referral.ts + +import { stripe } from '@/lib/stripe'; +import { prisma } from '@/lib/prisma'; + +export async function processReferralReward(refereeUserId: string) { + // 1. Récupérer le filleul et son parrain + const referee = await prisma.user.findUnique({ + where: { id: refereeUserId }, + include: { subscription: true } + }); + + if (!referee || !referee.referredById) return; + + const parrainId = referee.referredById; + + // 2. Vérifier si la récompense est déjà octroyée + const existingReward = await prisma.referralReward.findFirst({ + where: { userId: parrainId, refereeId: refereeUserId } + }); + if (existingReward) return; + + // 3. Récupérer l'abonnement Stripe du parrain + const parrainSub = await prisma.subscription.findUnique({ + where: { userId: parrainId } + }); + + if (!parrainSub || !parrainSub.stripeCustomerId) { + // Si le parrain n'est pas encore client Stripe ou n'a pas d'abonnement actif, + // on peut lui créditer des crédits IA en bonus à la place + await awardAiCredits(parrainId, 200); // ex: +200 crédits IA + return; + } + + try { + // 4. Créer une transaction de solde Stripe (Créditer son compte de 9,90 €) + const amountInCents = 990; // Le prix du plan PRO + const balanceTransaction = await stripe.customers.createBalanceTransaction( + parrainSub.stripeCustomerId, + { + amount: -amountInCents, // Un montant NÉGATIF crédite le client Stripe + currency: 'eur', + description: `Récompense de parrainage pour l'invitation de ${referee.email}`, + } + ); + + // 5. Enregistrer la récompense dans notre base PostgreSQL + await prisma.referralReward.create({ + data: { + userId: parrainId, + refereeId: refereeUserId, + rewardType: 'FREE_MONTH', + status: 'COMPLETED', + stripeTxId: balanceTransaction.id + } + }); + + console.log(`[Referral] Parrain ${parrainId} récompensé avec succès d'un mois gratuit !`); + } catch (err) { + console.error('[Referral] Erreur lors de l\'octroi du crédit Stripe :', err); + + await prisma.referralReward.create({ + data: { + userId: parrainId, + refereeId: refereeUserId, + rewardType: 'FREE_MONTH', + status: 'FAILED' + } + }); + } +} +``` + +### Option B : Offrir des crédits IA (Redis) au Parrain +Si le parrain a un compte gratuit, on peut simplement augmenter son quota de crédits IA ou lui offrir des crédits "boost" à vie. + +```typescript +// lib/billing/referral.ts +import { redis } from '@/lib/redis'; + +async function awardAiCredits(userId: string, amount: number) { + // Ajouter des crédits bonus dans Redis pour la période en cours + const period = getCurrentPeriodKey(); + const key = `usage:${userId}:semantic_search:bonus`; + await redis.incrby(key, amount); + + await prisma.referralReward.create({ + data: { + userId, + refereeId: refereeUserId, + rewardType: 'AI_CREDITS', + status: 'COMPLETED' + } + }); +} +``` + +--- + +## 5. Intégration Webhook Stripe (Finalisation) + +Dans le webhook `app/api/billing/webhook/route.ts`, lors du traitement de `checkout.session.completed`, il suffit d'appeler notre fonction de parrainage : + +```typescript +// app/api/billing/webhook/route.ts + +if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session; + const userId = session.metadata?.userId; + + if (userId) { + // 1. Synchroniser l'abonnement + await syncSubscriptionFromStripe(subscription, userId); + + // 2. Traiter le parrainage si applicable + await processReferralReward(userId); + } +} +``` + +--- + +## 6. Plan de Lancement & Coupon Stripe (No-Code) + +Pour que ce système fonctionne immédiatement : +1. Créez un **Coupon** général de `10%` sur Stripe nommé "Parrainage Filleul". +2. Dans Stripe, créez un **Code de Promotion orienté client** attaché à ce coupon. +3. Configurez ce code promo avec le motif suivant : **Autoriser les codes de promotion générés par les utilisateurs** ou créez un code parrain par défaut. +4. Dans le code de l'application, nous générons automatiquement le code de parrainage de l'utilisateur lors de son premier partage et l'associons à son compte. diff --git a/docs/story-chunk-embeddings.md b/docs/story-chunk-embeddings.md new file mode 100644 index 0000000..2a27556 --- /dev/null +++ b/docs/story-chunk-embeddings.md @@ -0,0 +1,761 @@ +# Story: Embeddings par Fragments — Indexation Sémantique Multi-Niveaux + +> **Epic:** Fondation IA — Recherche & Insights Sémantiques +> **ID:** US-CHUNK-EMBEDDINGS +> **Priority:** Critical — Fondation pour Workstreams B (AI Overview), Memory Echo précis, Insights +> **Status:** ready-for-dev +> **Depends on:** pgvector existant (✅), `embedding.service.ts` (✅), `NoteEmbedding` (✅) +> **Blocks:** US-AI-OVERVIEW, US-CALENDAR (indépendant), qualité Memory Echo et Insights +> **Inspiration:** AppFlowy `flowy-ai/src/embeddings/` — chunking + dedup par hash de contenu + +--- + +## Contexte + +### Problème actuel + +Momento stocke **un seul vecteur par note** (`NoteEmbedding` dans `prisma/schema.prisma:366-374`). L'embedding est généré par `EmbeddingService.generateNoteEmbedding()` (`lib/ai/services/embedding.service.ts:43-63`) qui : + +1. Concatène titre + corps en plain text +2. Découpe en chunks de 6000 chars (`splitPlainTextForEmbeddingChunks`) +3. Embed chaque chunk via l'API +4. **Mean-pool** les vecteurs en un seul vecteur moyen + +Cette approche a trois limites majeures : + +| Limite | Impact | +|--------|--------| +| **Perte de précision** — une note de 5000 mots sur 10 sujets différents a un vecteur moyen flou | Memory Echo rate les connexions spécifiques à une section | +| **Re-embed complet à chaque modif** — corriger une typo re-embedde toute la note | Coût API × nombre d'éditions ; latence inutile | +| **Pas de snippets de match** — la recherche retourne un score global mais pas le passage précis | L'utilisateur ne sait pas *pourquoi* une note match | + +### Solution proposée + +Inspiré d'AppFlowy (`flowy-ai/src/embeddings/document_indexer.rs`, `scheduler.rs`) : + +1. **Découper chaque note en fragments sémantiques** (~1000 chars, coupure aux limites de paragraphes) +2. **Embedder chaque fragment indépendamment** dans une nouvelle table `NoteEmbeddingChunk` +3. **Hasher le contenu de chaque fragment** (`sha256`) pour ne re-embed que les fragments modifiés +4. **Rechercher au niveau fragment** avec agrégation par note → snippets précis en résultat +5. **Garder `NoteEmbedding` existant** pour rétro-compat (recherche globale, clustering) + +### Comparaison AppFlowy → Momento + +| Aspect | AppFlowy (Rust) | Momento (TypeScript) | +|--------|-----------------|---------------------| +| Chunking | `text_splitter` crate, 1000 chars / 200 overlap | `lib/text/note-chunking.ts`, même valeurs | +| Hash | `xxhash64` (Rust) | `crypto.createHash('sha256')` (Node natif) | +| Vector DB | SQLite-vec (local) | pgvector (PostgreSQL, déjà en prod) | +| Scheduler | Tokio channels (generate → write) | `p-queue` ou Bull (Redis déjà présent) | +| Dedup | Compare `fragment_id` en DB | Identique — `@@unique([noteId, fragmentId])` | +| Embedding model | `nomic-embed-text` (Ollama local) | `text-embedding-3-small` (OpenAI, déjà configuré) | + +--- + +## Migration Prisma requise + +```prisma +model NoteEmbeddingChunk { + id String @id @default(cuid()) + noteId String + fragmentId String // sha256(noteId + content) — stable pour le dedup + chunkIndex Int // ordre du fragment dans la note + content String // texte plain du fragment (~1000 chars) + charCount Int // longueur du fragment (audit / debug) + embedding Unsupported("vector(1536)")? + embeddingModel String? @default("text-embedding-3-small") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + + @@unique([noteId, fragmentId]) + @@index([noteId]) + @@index([fragmentId]) +} +``` + +> ⚠️ **Migration additive uniquement** — safe, pas de perte de données. La table `NoteEmbedding` existante est conservée intacte. + +### Étapes de migration + +1. **Dump DB obligatoire** : `bash /home/devparsa/dev/Momento/dump-db.sh` — vérifier ≥1Mo — « OUI » explicite utilisateur +2. `npx prisma migrate dev --name add_note_embedding_chunks` +3. Créer l'index HNSW sur la colonne `embedding` : + ```sql + CREATE INDEX "NoteEmbeddingChunk_embedding_hnsw_idx" + ON "NoteEmbeddingChunk" + USING hnsw ("embedding" vector_cosine_ops) + WITH (m = 16, ef_construction = 64); + ``` + > L'index HNSW doit être créé manuellement (Prisma ne gère pas les index vectoriels). À mettre dans la migration SQL brute. + +--- + +## User Stories + +### US-CHUNK-1 : Chunking sémantique d'une note + +**En tant que** système d'indexation, +**Je veux** découper le contenu d'une note en fragments sémantiques cohérents, +**Afin de** produire des embeddings précis au niveau section plutôt qu'au niveau note entière. + +#### Critères d'acceptation : + +- **Étant donné** une note avec un titre et un contenu HTML/plain +- **Quand** le service de chunking est appelé +- **Alors** le contenu est converti en plain text (via `stripHtmlToPlainText` existant) +- **Et** découpé aux limites de paragraphes (`\n\n`) en groupes de ~1000 caractères max +- **Et** chaque fragment chevauche le précédent de ~200 caractères (préserve le contexte aux frontières) +- **Et** chaque fragment reçoit un `fragmentId` = `sha256(noteId + "::" + fragmentContent)` (stable, déterministe) +- **Et** si un paragraphe dépasse 1000 chars, il est sous-découpé à la phrase la plus proche (regex sur `. ! ? ؟ !。`) +- **Et** si le contenu total fait moins de 1000 chars, un seul fragment est produit +- **Et** les fragments vides (< 10 chars après trim) sont filtrés + +#### Paramètres de chunking : + +```typescript +const CHUNK_TARGET_CHARS = 1000 // taille cible par fragment +const CHUNK_OVERLAP_CHARS = 200 // chevauchement entre fragments consécutifs +const MIN_FRAGMENT_CHARS = 10 // en dessous, le fragment est ignoré +const MAX_PARAGRAPH_SPLIT = 1500 // au-dessus, un paragraphe est sous-découpé +``` + +> Ces valeurs sont alignées sur AppFlowy (`document_indexer.rs:31`) et la recherche RAG standard (medium chunks 256-1024 tokens). + +--- + +### US-CHUNK-2 : Indexation incrémentale avec dedup + +**En tant que** système, +**Je veux** ne re-embed que les fragments qui ont réellement changé, +**Afin de** réduire le coût API et la latence lors des éditions mineures. + +#### Critères d'acceptation : + +- **Étant donné** une note qui a déjà des fragments indexés en DB +- **Quand** l'utilisateur modifie un paragraphe et sauvegarde +- **Alors** le service : + 1. Récupère les `fragmentId`s existants : `SELECT fragmentId FROM "NoteEmbeddingChunk" WHERE noteId = $1` + 2. Chunk le contenu actuel → produit de nouveaux fragments avec leurs `fragmentId`s + 3. Compare les hash : + - **Fragments inchangés** (hash identique) → **skip**, pas d'embed + - **Fragments nouveaux** (hash absent en DB) → **embed et insère** + - **Fragments supprimés** (hash en DB mais absent des nouveaux) → **DELETE de la DB** + 4. Embed uniquement les fragments nouveaux (batch de max 100 fragments par appel API) + 5. Insère en DB via upsert transactionnel +- **Et** si l'utilisateur corrige une typo dans un paragraphe, seul ce fragment est re-embeddé (pas toute la note) +- **Et** si l'utilisateur ajoute un nouveau paragraphe à la fin, seul le nouveau fragment est embeddé +- **Et** les fragments supprimés sont nettoyés (pas d'orphelins) + +#### Gestion de la concurrence : + +- Utiliser un **verrou par noteId** (mutex en mémoire ou lock Redis `lock:chunk-index:{noteId}`) +- Si une indexation est déjà en cours pour cette note, la nouvelle requête est mise en file d'attente (ou débounce 3s puis prend le relais) +- Pas de race condition sur les upserts (`@@unique([noteId, fragmentId])` + `ON CONFLICT DO UPDATE`) + +--- + +### US-CHUNK-3 : Pipeline d'indexation asynchrone + +**En tant que** système, +**Je veux** que l'indexation des fragments se fasse en arrière-plan sans bloquer la sauvegarde, +**Afin de** préserver la fluidité de l'édition. + +#### Critères d'acceptation : + +- **Quand** une note est sauvegardée (débounce existant dans le flow de save) +- **Alors** l'indexation est déclenchée **de manière asynchrone** (fire-and-forget côté serveur) +- **Et** l'utilisateur ne perçoit aucune latence liée à l'embedding +- **Et** l'indexation utilise une **queue avec concurrence limitée** (max 4 embeddings simultanés) pour éviter le rate-limit API +- **Et** en cas d'erreur API (rate limit, timeout), le fragment est **réessayé** avec backoff exponentiel (3 tentatives max) +- **Et** en cas d'échec définitif, l'erreur est loggée mais ne bloque pas l'application + +#### Implémentation de la queue : + +Utiliser `p-queue` (léger, pas de dépendance Redis supplémentaire) ou Bull (Redis déjà présent à `memento-redis:6379`). Préférer **`p-queue`** pour simplicité — un singleton process-level : + +```typescript +import PQueue from 'p-queue' +const chunkEmbeddingQueue = new PQueue({ concurrency: 4 }) +``` + +> Si Momento passe multi-process (PM2 cluster), migrer vers Bull. Pour l'instant, single-process Next.js → `p-queue` suffit. + +--- + +### US-CHUNK-4 : Recherche sémantique au niveau fragment + +**En tant qu'** utilisateur, +**Je veux** que la recherche sémantique trouve des passages précis dans mes notes, +**Afin de** comprendre pourquoi une note correspond à ma recherche. + +#### Critères d'acceptation : + +- **Étant donné** que l'utilisateur saisit une requête dans la SearchModal +- **Quand** la recherche sémantique s'exécute +- **Alors** le système effectue **deux recherches en parallèle** : + 1. **Recherche fragment-level** (nouvelle) : `pgvector` sur `NoteEmbeddingChunk.embedding` + 2. **Recherche note-level** (existante) : `pgvector` sur `NoteEmbedding.embedding` + FTS `tsvector` +- **Et** les résultats sont fusionnés via RRF (Reciprocal Rank Fusion, déjà implémenté dans `semantic-search.service.ts`) +- **Et** chaque résultat inclut désormais un champ `matchedSnippets: string[]` (top 3 fragments par score, max 200 chars chacun, avec `...` aux coupures) +- **Et** les snippets sont affichés dans l'UI de recherche sous le titre de la note (mise en gras du terme si présent) + +#### Requête SQL fragment-level : + +```sql +-- Recherche vectorielle au niveau fragment, agrégée par note +WITH chunk_scores AS ( + SELECT + c."noteId", + MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score, + ARRAY_AGG(c.content ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC) AS ranked_contents + FROM "NoteEmbeddingChunk" c + JOIN "Note" n ON n.id = c."noteId" + WHERE n."userId" = $2 + AND c."embedding" IS NOT NULL + AND 1 - (c."embedding"::vector <=> $1::vector) >= $3 -- threshold + GROUP BY c."noteId" + LIMIT $4 +) +SELECT + cs."noteId", + cs.best_score, + cs.ranked_contents[1:3] AS top_snippets -- top 3 fragments +FROM chunk_scores cs +ORDER BY cs.best_score DESC +``` + +> Le `[1:3]` est du slicing PostgreSQL natif sur les arrays. Chaque snippet est tronqué à 200 chars côté application. + +--- + +### US-CHUNK-5 : Memory Echo précis au niveau fragment + +**En tant qu'** utilisateur, +**Je veux** que Memory Echo détecte des connexions entre sections spécifiques de notes, +**Afin de** découvrir des résonances même entre notes majoritairement différentes. + +#### Critères d'acceptation : + +- **Étant donné** deux notes A et B indexées en fragments +- **Quand** Memory Echo calcule la similarité entre A et B +- **Alors** au lieu de comparer `embedding(A)` vs `embedding(B)` (note entière) +- **Le système compare** la **meilleure paire de fragments** : `MAX(similarity(fragmentAi, fragmentBj))` sur tous les cross-joins +- **Et** si la meilleure paire dépasse le seuil (`SEMANTIC_SIMILARITY_FLOOR`), une connexion est créée +- **Et** l'insight Memory Echo affiche le **snippet précis** qui a résonné (pas juste le score global) +- **Et** le filtre temporel (`MIN_DAYS_APART`) reste identique + +#### Requête SQL (cross-join fragment) : + +```sql +SELECT + a."noteId" AS note1_id, + b."noteId" AS note2_id, + MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) AS best_fragment_similarity, + -- Récupérer le contenu des fragments qui matchent le mieux + ( + SELECT a2.content + FROM "NoteEmbeddingChunk" a2 + WHERE a2."noteId" = a."noteId" + AND 1 - (a2."embedding"::vector <=> b."embedding"::vector) = MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) + LIMIT 1 + ) AS note1_snippet, + ( + SELECT b2.content + FROM "NoteEmbeddingChunk" b2 + WHERE b2."noteId" = b."noteId" + AND 1 - (a2."embedding"::vector <=> b2."embedding"::vector) = MAX(...) + LIMIT 1 + ) AS note2_snippet +FROM "NoteEmbeddingChunk" a +JOIN "NoteEmbeddingChunk" b ON a."noteId" < b."noteId" -- évite les doublons et self-joins +JOIN "Note" na ON na.id = a."noteId" +JOIN "Note" nb ON nb.id = b."noteId" +WHERE na."userId" = $1 + AND nb."userId" = $1 + AND a."embedding" IS NOT NULL + AND b."embedding" IS NOT NULL +GROUP BY a."noteId", b."noteId" +HAVING MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) >= $2 +``` + +> ⚠️ **Performance** : ce cross-join est coûteux. L'exécuter uniquement pour la note qui vient d'être indexée (pas sur toutes les paires à chaque fois). Pour la note N nouvellement indexée : `JOIN` ses fragments contre TOUS les autres fragments, `MAX()` par noteId cible, filtrer par seuil. + +#### Flux Memory Echo mis à jour : + +``` +Note N sauvegardée → chunks indexés → pour chaque autre note M : + best_similarity = MAX over (fragment_N_i, fragment_M_j) + if best_similarity >= THRESHOLD and passesTimeDiversityFilter(N, M): + create MemoryEchoInsight with note1_snippet + note2_snippet +``` + +--- + +### US-CHUNK-6 : Migration rétroactive des notes existantes + +**En tant qu'** admin/système, +**Je veux** indexer en fragments toutes les notes qui ont déjà un embedding global, +**Afin de** bénéficier de la précision fragment-level sur le corpus existant. + +#### Critères d'acceptation : + +- **Étant donné** que des notes ont un `NoteEmbedding` mais pas de `NoteEmbeddingChunk` +- **Quand** l'admin lance le script de migration +- **Alors** pour chaque note (batch de 50) : + 1. Récupérer le plain text (titre + corps) + 2. Chunker en fragments + 3. Embedder chaque fragment (respect du rate-limit API) + 4. Insérer en DB +- **Et** une barre de progression affiche : `1234 / 5678 notes (21%) — ETA: ~8 min` +- **Et** le script est **interruptible** (Ctrl+C safe — reprend là où il s'est arrêté via un checkpoint) +- **Et** le script est **idempotent** (les notes déjà chunkées sont skip) +- **Et** le script logge les erreurs par note mais continue +- **Et** le coût API estimé est affiché avant confirmation + +#### Script : + +```bash +# Lancement manuel (pas en CI) +npx tsx scripts/migrate-chunk-embeddings.ts --batch-size=50 --concurrency=4 +``` + +#### Estimation de coût : + +- ~1 note moyenne = ~3000 chars = ~4 fragments +- `text-embedding-3-small` : $0.02 / 1M tokens ≈ $0.02 / 750K chars +- 1000 notes × 4 fragments × 1000 chars = 4M chars ≈ $0.10 +- **Négligeable** pour la plupart des corpus + +--- + +### US-CHUNK-7 : Affichage des snippets dans la SearchModal + +**En tant qu'** utilisateur, +**Je veux** voir les passages précis qui correspondent à ma recherche, +**Afin de** évaluer rapidement la pertinence d'un résultat. + +#### Critères d'acceptation : + +- **Étant donné** des résultats de recherche avec `matchedSnippets` +- **Quand** ils sont affichés dans la SearchModal +- **Alors** sous chaque titre de note, les top 1-2 snippets sont affichés (max 150 chars visibles, `...` aux coupures) +- **Et** le terme de recherche est **surligné en gras** dans les snippets +- **Et** les snippets sont en couleur secondaire (text-muted, plus petit que le titre) +- **Et** si aucun snippet fragment-level n'est disponible (note pas encore migrée), le comportement actuel est conservé (extrait du début de la note) + +#### Design : + +- Consulter le prototype `architectural-grid/` → `SearchModal` pour le design exact des snippets dans les résultats +- Style Google-like : snippet gris sous le titre, terme de recherche en gras + +--- + +### US-CHUNK-8 : Nettoyage à la suppression de note + +**En tant que** système, +**Je veux** que les fragments d'une note supprimée soient automatiquement supprimés, +**Afin de** ne pas laisser de vecteurs orphelins. + +#### Critères d'acceptation : + +- **Quand** une note est supprimée +- **Alors** tous ses `NoteEmbeddingChunk` sont supprimés en cascade (`onDelete: Cascade` dans le schéma Prisma) +- **Et** aucun vecteur orphelin ne subsiste +- **Et** les Memory Echo référençant cette note sont nettoyés (comportement existant) + +> Le `onDelete: Cascade` sur la relation `note` dans le schéma gère cela automatiquement au niveau DB. + +--- + +## Fichiers à créer / modifier + +| Fichier | Action | Notes | +|---------|--------|-------| +| `prisma/schema.prisma` | Modifier | Ajouter modèle `NoteEmbeddingChunk` + relation inverse sur `Note` | +| `prisma/migrations/xxx_add_note_embedding_chunks/` | Créer | Migration + SQL brut pour index HNSW | +| `lib/text/note-chunking.ts` | **Créer** | Logique de chunking sémantique + hash | +| `lib/ai/services/chunk-indexing.service.ts` | **Créer** | Service d'indexation incrémentale (dedup, queue, retry) | +| `lib/ai/services/semantic-search.service.ts` | Modifier | Ajouter `vectorChunkSearch()` + fusion RRF avec existant + snippets | +| `lib/ai/services/memory-echo.service.ts` | Modifier | Similarité au niveau fragment + snippets dans insights | +| `lib/ai/services/embedding.service.ts` | Modifier | Ajouter `generateChunkEmbeddings(texts: string[])` (batch optimisé) | +| `app/api/notes/[id]/route.ts` (ou hook de save) | Modifier | Déclencher `chunkIndexingService.indexNote()` après save | +| `components/search/search-result-item.tsx` (ou équivalent) | Modifier | Afficher `matchedSnippets` | +| `scripts/migrate-chunk-embeddings.ts` | **Créer** | Script de migration rétroactive avec checkpoint | +| `locales/en.json` + `locales/fr.json` | Modifier | Clés `search.snippets.*` (minimal) | + +--- + +## Architecture technique + +### Pipeline d'indexation (flow complet) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Éditeur (TipTap) │ +│ User tape → debounce 2s → POST /api/notes/[id] (save) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Note Save Handler (server-side) │ +│ 1. Sauvegarde contenu en DB (existant) │ +│ 2. Re-embed NoteEmbedding global (existant, conservé) │ +│ 3. fire-and-forget → chunkIndexingService.indexNote(noteId) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ (async, non-bloquant) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ChunkIndexingService.indexNote(noteId) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 1. Récupérer note (title + content) depuis DB │ │ +│ │ 2. plain = prepareNoteTextForEmbedding(title, content) │ │ +│ │ 3. chunks = chunkNoteContent(noteId, plain) │ │ +│ │ → [{fragmentId, content, chunkIndex}, ...] │ │ +│ │ 4. existingIds = SELECT fragmentId WHERE noteId │ │ +│ │ 5. newChunks = chunks WHERE fragmentId NOT IN existing │ │ +│ │ 6. staleIds = existingIds NOT IN chunks.fragmentIds │ │ +│ │ 7. DELETE WHERE noteId AND fragmentId IN staleIds │ │ +│ │ 8. Queue: embed newChunks → INSERT │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ p-queue (concurrency: 4) │ +│ Pour chaque nouveau fragment : │ +│ embedding = await embeddingService.embedPlainText(content) │ +│ INSERT INTO NoteEmbeddingChunk (fragmentId, content, │ +│ embedding, ...) ON CONFLICT DO UPDATE │ +│ Retry: 3 tentatives, backoff exponentiel (1s, 2s, 4s) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Chunking — algorithme détaillé + +```typescript +// lib/text/note-chunking.ts + +import { createHash } from 'crypto' + +const CHUNK_TARGET_CHARS = 1000 +const CHUNK_OVERLAP_CHARS = 200 +const MIN_FRAGMENT_CHARS = 10 +const MAX_PARAGRAPH_BEFORE_SPLIT = 1500 + +export interface NoteChunk { + fragmentId: string + content: string + chunkIndex: number + charCount: number +} + +/** + * Découpe une note en fragments sémantiques. + * Port TS de flowy-ai/src/embeddings/document_indexer.rs + */ +export function chunkNoteContent( + noteId: string, + plainText: string, +): NoteChunk[] { + const normalized = plainText.trim() + if (normalized.length < MIN_FRAGMENT_CHARS) return [] + + // 1. Split par paragraphes (double newline) + const paragraphs = normalized + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter((p) => p.length >= MIN_FRAGMENT_CHARS) + + if (paragraphs.length === 0) return [] + + // 2. Sous-découper les paragraphes trop longs + const atomicParagraphs: string[] = [] + for (const para of paragraphs) { + if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) { + atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS)) + } else { + atomicParagraphs.push(para) + } + } + + // 3. Grouper les paragraphes en chunks de ~CHUNK_TARGET_CHARS + const groups = groupParagraphsByMaxContentLen( + atomicParagraphs, + CHUNK_TARGET_CHARS, + CHUNK_OVERLAP_CHARS, + ) + + // 4. Hash + construire les NoteChunk + const chunks: NoteChunk[] = [] + for (let i = 0; i < groups.length; i++) { + const content = groups[i] + if (content.length < MIN_FRAGMENT_CHARS) continue + + const fragmentId = hashFragment(noteId, content) + // Dédup : si le même fragmentId existe déjà (paragraphe répété), skip + if (chunks.some((c) => c.fragmentId === fragmentId)) continue + + chunks.push({ + fragmentId, + content, + chunkIndex: i, + charCount: content.length, + }) + } + + return chunks +} + +function hashFragment(noteId: string, content: string): string { + return createHash('sha256') + .update(`${noteId}::${content}`) + .digest('hex') + .slice(0, 32) // 32 chars = 128 bits, largement suffisant pour le dedup +} + +function splitLongParagraph(para: string, maxLen: number): string[] { + // Coupe aux fins de phrase (. ! ? ؟ !。) les plus proches de maxLen + const sentences = para.split(/(?<=[.!?؟!。])\s+/) + const chunks: string[] = [] + let current = '' + + for (const sentence of sentences) { + if ((current + ' ' + sentence).length > maxLen && current) { + chunks.push(current.trim()) + current = sentence + } else { + current = current ? `${current} ${sentence}` : sentence + } + } + if (current.trim()) chunks.push(current.trim()) + + // Si une phrase seule dépasse maxLen, coupe dur au mot le plus proche + return chunks.flatMap((chunk) => + chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk], + ) +} + +function hardSplitByWords(text: string, maxLen: string): string[] { + // Coupe au mot le plus proche de maxLen + // ... +} + +/** + * Groupe les paragraphes en chunks de taille ~maxContentLen + * avec un overlap optionnel pour préserver le contexte. + * Port de group_paragraphs_by_max_content_len (document_indexer.rs:147) + */ +function groupParagraphsByMaxContentLen( + paragraphs: string[], + maxLen: number, + overlap: number, +): string[] { + if (paragraphs.length === 0) return [] + if (overlap > maxLen) overlap = maxLen / 2 + + const result: string[] = [] + let current = '' + + for (const para of paragraphs) { + if (current.length + para.length > maxLen && current) { + result.push(current.trim()) + // Préserve l'overlap : reprend les derniers `overlap` chars + const tail = current.slice(-overlap) + current = tail + para + } else { + current = current ? `${current}\n\n${para}` : para + } + } + if (current.trim()) result.push(current.trim()) + + return result +} +``` + +### Recherche fragment-level — intégration dans SemanticSearchService + +```typescript +// Ajout à lib/ai/services/semantic-search.service.ts + +export interface SearchResult { + noteId: string + title: string | null + content: string + score: number + matchType: 'exact' | 'related' + language?: string | null + matchedSnippets?: string[] // NOUVEAU — top fragments qui matchent +} + +class SemanticSearchService { + // ... existant ... + + /** + * NOUVEAU — Recherche vectorielle au niveau fragment. + * Retourne les notes triées par meilleure similarité de fragment. + */ + private async vectorChunkSearch( + queryEmbedding: number[], + userId: string, + threshold: number, + notebookId: string | null, + limit: number, + ): Promise { + const vecStr = `[${queryEmbedding.join(',')}]` + const params: any[] = [vecStr, userId, threshold, limit] + + let notebookJoin = '' + if (notebookId) { + notebookJoin = `AND n."notebookId" = $${params.length + 1}` + params.push(notebookId) + } + + const sql = ` + WITH chunk_scores AS ( + SELECT + c."noteId", + MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score, + ARRAY_AGG( + c.content + ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC + ) AS ranked_contents + FROM "NoteEmbeddingChunk" c + JOIN "Note" n ON n.id = c."noteId" + WHERE n."userId" = $2 + AND c."embedding" IS NOT NULL + AND 1 - (c."embedding"::vector <=> $1::vector) >= $3 + ${notebookJoin} + GROUP BY c."noteId" + LIMIT $4 + ) + SELECT + cs."noteId", + n.title, + cs.best_score, + cs.ranked_contents[1:3] AS top_snippets + FROM chunk_scores cs + JOIN "Note" n ON n.id = cs."noteId" + ORDER BY cs.best_score DESC + ` + + const rows = await prisma.$queryRawUnsafe(sql, ...params) + return rows.map((row: any) => ({ + noteId: row.noteId, + title: row.title, + content: row.top_snippets?.[0] ?? '', + score: row.best_score, + matchType: 'related' as const, + matchedSnippets: (row.top_snippets ?? []).map((s: string) => + s.length > 200 ? s.slice(0, 200) + '...' : s, + ), + })) + } + + /** + * Recherche hybride mise à jour : + * FTS + vectorChunkSearch + vectorNoteSearch → RRF fusion + */ + async search(query: string, userId: string, opts: SearchOptions): Promise { + // ... existant : FTS ... + + // NOUVEAU : recherche fragment-level en parallèle de la recherche note-level + const [keywordResults, noteResults, chunkResults] = await Promise.all([ + this.keywordSearch(query, userId, opts), + this.vectorSearch(queryEmbedding, userId, opts), + this.vectorChunkSearch(queryEmbedding, userId, opts.threshold, opts.notebookId, opts.limit), + ]) + + // RRF sur les 3 sources + const fused = this.reciprocalRankFusion([ + ...keywordResults, + ...noteResults, + ...chunkResults, + ]) + + // Merge snippets : si un résultat a des snippets des chunks, les propager + return this.mergeSnippets(fused, chunkResults) + } +} +``` + +--- + +## Risques et mitigations + +| Risque | Probabilité | Impact | Mitigation | +|--------|-------------|--------|------------| +| Cross-join fragment coûteux pour Memory Echo | Moyenne | Latence | N'exécuter que pour la note nouvellement indexée (pas sur tout le corpus à chaque fois) | +| Coût API embedding x4-5 (plus de fragments) | Faible | $$ | Dedup par hash → seule une fraction est re-embeddée après la migration initiale | +| Rate-limit API OpenAI | Moyenne | Blocage | Queue avec concurrence 4 + retry backoff exponentiel | +| Migration longue sur gros corpus | Faible | UX admin | Script batch avec checkpoint, interruptible, reprise auto | +| Index HNSW non créé (oubli migration) | Faible | Recherche lente | Vérifier l'existence de l'index au démarrage (`getDbDimension` déjà existe pour ça) | +| Fragments orphelins si note déplacée entre carnets | Faible | Bruit | `onDelete: Cascade` + nettoyage au move (déjà géré pour `NoteEmbedding`) | + +--- + +## Métriques à tracker + +| Métrique | Objectif | Mesure | +|----------|----------|--------| +| Précision Memory Echo | +30% connexions pertinentes | Taux de feedback positif sur les insights | +| Latence recherche sémantique | < 200ms (P95) | Telemetry sur `semantic-search.service.ts` | +| Coût API embedding / mois | Stable vs actuel | Compter les appels `embedPlainText` | +| Snippets affichés dans recherche | > 80% des résultats | Telemetry sur SearchModal | +| Fragments par note (moyenne) | 3-5 | Requête analytique sur `NoteEmbeddingChunk` | + +--- + +## Clés i18n (minimal — la majorité est invisible côté UI) + +```json +{ + "search": { + "snippets": { + "matchedSection": "Section correspondante" + } + } +} +``` + +> La fonctionnalité est principalement backend — peu de libellés visibles. Les snippets sont du contenu de note (pas du texte à traduire). + +--- + +## Ordre d'implémentation recommandé + +1. **Migration Prisma** — modèle `NoteEmbeddingChunk` + index HNSW +2. **`lib/text/note-chunking.ts`** — logique de chunking pure (testable isolément) +3. **`lib/ai/services/chunk-indexing.service.ts`** — service d'indexation (dedup, queue) +4. **Intégration au hook de save** — déclenchement async +5. **Script de migration** — indexer le corpus existant +6. **Recherche fragment-level** — `vectorChunkSearch` + fusion RRF + snippets +7. **UI snippets dans SearchModal** — affichage des passages précis +8. **Memory Echo fragment-level** — similarité par meilleure paire de fragments +9. **Validation end-to-end** — créer une note longue, vérifier chunks en DB, rechercher, Memory Echo + +--- + +## Définition de Done (DoD) + +- [ ] Migration Prisma appliquée en dev, dump DB validé avant +- [ ] `chunkNoteContent()` produit des fragments cohérents (vérifié sur 5 notes de test : courte, longue, multilingue FR/FA, HTML clippé, vide) +- [ ] Indexation incrémentale : modifier une typo ne re-embed qu'un fragment (vérifié en DB) +- [ ] Recherche sémantique retourne des `matchedSnippets` pertinents +- [ ] SearchModal affiche les snippets avec le terme surligné +- [ ] Memory Echo détecte des connexions au niveau section +- [ ] Script de migration complète le corpus sans erreur +- [ ] Aucune régression sur la recherche existante (note-level toujours fonctionnelle) +- [ ] `NoteEmbedding` (global) toujours maintenu pour rétro-compat +- [ ] Pas de `revalidatePath` systématique (mutations optimistes) +- [ ] Pas de tests écrits sauf demande explicite (AGENTS.md) + +--- + +## Notes + +- **Conservation de `NoteEmbedding`** : la table existante reste le source of truth pour le clustering (`clustering.service.ts`) et la recherche globale. Les chunks sont une **couche additive** qui améliore la précision, pas un remplacement. +- **AppFlowy utilise `nomic-embed-text` (768 dims, local via Ollama)**. Momento utilise `text-embedding-3-small` (1536 dims, OpenAI). La dimension est différente — le schéma `NoteEmbeddingChunk` doit spécifier `vector(1536)`. +- **Performance pgvector** : l'index HNSW est crucial pour les requêtes fragment-level. Sans index, un scan séquentiel sur des milliers de fragments serait prohibitif. L'index doit être créé dans la migration SQL brute. +- **Persan / RTL** : le chunking par paragraphes fonctionne indépendamment de la langue. Le split par fin de phrase (`؟ !。`) couvre les scripts RTL et CJK. Vérifier que les embeddings `text-embedding-3-small` gèrent bien le persan (déjà validé pour la recherche existante). diff --git a/docs/stripe-billing-guide.md b/docs/stripe-billing-guide.md index e6622ed..b721369 100644 --- a/docs/stripe-billing-guide.md +++ b/docs/stripe-billing-guide.md @@ -449,3 +449,44 @@ Devrait retourner : | Enterprise | Sur devis | Sur devis | — | Devise : EUR (configurable dans Stripe Dashboard pour multi-devises). + +--- + +## 11. Bons de réduction & Codes de promotion (Coupons & Promo Codes) + +Momento intègre le support natif et sécurisé de Stripe pour les codes promotionnels lors du paiement en Embedded Checkout via l'attribut `allow_promotion_codes: true` dans `/api/billing/create-checkout/route.ts`. + +### 11.1 Concepts Clés : Bon de réduction (Coupon) vs Code de promotion (Promo Code) +Dans Stripe, la gestion des remises se fait en deux niveaux : +1. **Le Bon de réduction (Coupon)** : C'est la règle financière sous-jacente (ex. `-20 % sur l'abonnement pendant 6 mois` ou `-10 € à vie`). Un coupon n'est pas vu par le client final sous cette forme. +2. **Le Code de promotion (Promo Code)** : C'est la chaîne de caractères réelle saisie par le client (ex. `WELCOME20`, `LAUNCH50`). Un code promo est obligatoirement rattaché à un Coupon. Vous pouvez créer plusieurs codes promos pour un seul et même coupon (ex. `INFLUENCEUR1` et `INFLUENCEUR2` qui appliquent tous les deux la même réduction de -10%). + +### 11.2 Comment créer un Code Promo sur Stripe (Dashboard) +1. Connectez-vous à votre **Stripe Dashboard** (en mode test ou live selon votre cible). +2. Rendez-vous dans **Produits** (Products) → **Bons de réduction** (Coupons) → bouton **+ Nouveau**. +3. Remplissez les informations du Coupon : + - **Nom** : Le nom interne pour vous y retrouver (ex: `Lancement 50%`). + - **Type de réduction** : Pourcentage (ex: `50%`) ou Montant fixe (ex: `10 €`). + - **Durée** : + - *Une seule fois* (appliqué uniquement sur la première facture). + - *Plusieurs mois* (Spécifier le nombre de mois, ex: 3 mois). + - *Pour toujours* (appliqué à vie sur toutes les factures de l'abonnement). +4. Sous le bloc **Codes de promotion**, cochez **"Créer un code de promotion orienté client"** : + - **Code** : Saisissez le code en majuscules (ex: `LAUNCH50`). + - **Limites d'utilisation** (optionnel) : + - Nombre d'utilisations max (ex: limité aux 100 premiers utilisateurs). + - Date limite de validité (ex: valable uniquement jusqu'au 31 décembre). + - Limiter aux clients n'ayant jamais payé. + - Restriction à des plans spécifiques (ex: restreindre ce code promo uniquement au produit `Momento Pro`). +5. Cliquez sur **Créer le bon de réduction**. + +### 11.3 Comment désactiver ou supprimer un Code Promo +1. Allez dans **Produits** → **Bons de réduction** → **Codes de promotion**. +2. Cliquez sur les `...` à côté du code promo concerné. +3. Sélectionnez **Désactiver** (Deactivate) : le code ne sera plus utilisable par aucun client, mais les clients en ayant déjà bénéficié conserveront leur réduction active selon la durée définie (ex. s'ils ont déjà eu les 3 mois, Stripe continue de l'appliquer jusqu'à la fin de la période). +4. Si vous souhaitez supprimer définitivement un coupon global et annuler la réduction pour tout le monde (y compris les abonnés actuels), allez dans **Bons de Réduction**, cliquez sur le coupon puis sur **Supprimer**. + +### 11.4 Mode Test vs Production +* **En local / Test** : Créez vos codes de promotion dans Stripe en ayant activé le commutateur **Mode Test** (Test Mode). Les codes créés ici ne fonctionneront qu'avec vos clés d'API de test (`sk_test_...`). +* **En production / Live** : Désactivez le Mode Test sur Stripe pour passer en mode réel, et créez les coupons dans la section Live. Ils ne fonctionneront qu'avec vos clés d'API réelles (`sk_live_...`). + diff --git a/memento-note/app/actions/ai-settings.ts b/memento-note/app/actions/ai-settings.ts index b818bf6..07c533c 100644 --- a/memento-note/app/actions/ai-settings.ts +++ b/memento-note/app/actions/ai-settings.ts @@ -2,7 +2,7 @@ import { auth } from '@/auth' import { prisma } from '@/lib/prisma' -import { revalidatePath, updateTag } from 'next/cache' +import { revalidatePath, revalidateTag } from 'next/cache' export type UserAISettingsData = { titleSuggestions?: boolean @@ -25,6 +25,7 @@ export type UserAISettingsData = { fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system' autoSave?: boolean aiProcessingConsent?: boolean + svgComplexity?: 'simple' | 'illustrated' | 'rich' } /** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */ @@ -49,6 +50,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [ 'fontFamily', 'autoSave', 'aiProcessingConsent', + 'svgComplexity', ] as const type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number] @@ -99,7 +101,7 @@ export async function updateAISettings(settings: UserAISettingsData) { revalidatePath('/settings/ai', 'page') revalidatePath('/settings/appearance', 'page') revalidatePath('/', 'layout') - updateTag('ai-settings') + revalidateTag('ai-settings') return { success: true } } catch (error) { @@ -153,6 +155,7 @@ const getCachedAISettings = unstable_cache( fontFamily: 'inter' as const, autoSave: true, aiProcessingConsent: false, + svgComplexity: 'simple' as const, } } @@ -179,6 +182,7 @@ const getCachedAISettings = unstable_cache( fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system', autoSave: settings.autoSave ?? true, aiProcessingConsent: settings.aiProcessingConsent ?? false, + svgComplexity: ((settings as any).svgComplexity ?? 'simple') as 'simple' | 'illustrated' | 'rich', } } catch (error) { console.error('Error getting AI settings:', error) @@ -205,6 +209,7 @@ const getCachedAISettings = unstable_cache( fontFamily: 'inter' as const, autoSave: true, aiProcessingConsent: false, + svgComplexity: 'simple' as const, } } }, @@ -244,6 +249,7 @@ export async function getAISettings(userId?: string) { fontFamily: 'inter' as const, autoSave: true, aiProcessingConsent: false, + svgComplexity: 'simple' as const, } } diff --git a/memento-note/app/actions/cookie-consent.ts b/memento-note/app/actions/cookie-consent.ts new file mode 100644 index 0000000..a4176d3 --- /dev/null +++ b/memento-note/app/actions/cookie-consent.ts @@ -0,0 +1,71 @@ +'use server' + +import { auth } from '@/auth' +import { updateAISettings } from './ai-settings' +import { getAISettings } from './ai-settings' +import type { ConsentRecord } from '@/lib/consent/cookie-consent' + +/** + * Get cookie consent from database for authenticated users. + * Returns null for guests or when no DB preference exists. + */ +export async function getCookieConsentFromDB(): Promise { + const session = await auth() + + if (!session?.user?.id) { + return null + } + + try { + const settings = await getAISettings(session.user.id) + // Only return consent if user has explicitly set it + // If anonymousAnalytics is undefined or false, treat as "no preference set" + if (settings.anonymousAnalytics === true) { + return { + version: 1, + necessary: true, + analytics: true, + marketing: false, + updatedAt: new Date().toISOString(), + } + } + // If explicitly false, return that too + if (settings.anonymousAnalytics === false) { + return { + version: 1, + necessary: true, + analytics: false, + marketing: false, + updatedAt: new Date().toISOString(), + } + } + } catch (error) { + console.error('[getCookieConsentFromDB] Failed to load:', error) + } + + return null +} + +/** + * Save cookie consent and sync to database for authenticated users. + * + * For logged-in users, this updates UserAISettings.anonymousAnalytics in the database. + * For guests, this no-ops silently (they rely on localStorage only). + * + * Call this from client components after setting local consent. + */ +export async function saveCookieConsent(consent: ConsentRecord) { + const session = await auth() + + // Only sync to DB for authenticated users + if (session?.user?.id) { + try { + await updateAISettings({ anonymousAnalytics: consent.analytics }) + } catch (error) { + console.error('[saveCookieConsent] Failed to sync to DB:', error) + // Don't throw — local consent is already set, DB sync is best-effort + } + } + + return { success: true } +} diff --git a/memento-note/app/actions/diagram.ts b/memento-note/app/actions/diagram.ts new file mode 100644 index 0000000..7f13b56 --- /dev/null +++ b/memento-note/app/actions/diagram.ts @@ -0,0 +1,118 @@ +'use server' + +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { getSystemConfig } from '@/lib/config' +import { getChatProvider } from '@/lib/ai/factory' +import { checkEntitlementOrThrow, incrementUsageAsync } from '@/lib/entitlements' +import { toolRegistry } from '@/lib/ai/tools/registry' + +// S'assurer que l'outil est importé pour s'enregistrer dans le registre +import '@/lib/ai/tools/excalidraw.tool' + +const SYSTEM_PROMPT = `Tu es un architecte visuel expert dans la création de diagrammes Excalidraw extrêmement détaillés, techniques et intelligents. +Analyse le texte ou l'architecture fournie par l'utilisateur et génère un diagramme riche, complet, informatif et hautement professionnel. + +Tu DOIS impérativement répondre UNIQUEMENT avec un objet JSON valide au format simplifié suivant : +{ + "title": "Titre du diagramme", + "type": "auto", + "style": "default", + "zones": [ + {"id": "z1", "label": "Nom de la Zone (ex: Frontend)"} + ], + "nodes": [ + {"id": "n1", "label": "Nom du composant/classe/fichier\\n• Propriété 1\\n• Propriété 2", "type": "rect", "zoneId": "z1"} + ], + "edges": [ + {"from": "n1", "to": "n2", "label": "relation"} + ] +} + +RÈGLES D'INTELLIGENCE ET DE RICHESSE TECHNIQUE (CRITIQUES) : +1. **Détails internes des Nœuds (CRITIQUE) :** Pour chaque fichier, classe, modèle de base de données ou service, utilise ABSOLUMENT des sauts de ligne échappés (\\n) pour lister ses attributs clés, chemins de fichiers, sous-fichiers, propriétés ou fonctions décrits dans le texte (ex: utilise des listes à puces comme \\n• Propriété 1\\n• Propriété 2). L'utilisateur doit pouvoir lire les détails techniques (ex: les champs "userId", "tier", "status" dans un modèle de base de données, ou les sous-composants comme "Embedded Checkout" et "Toggle mensuel/annuel" dans un fichier UI) DIRECTEMENT dans le corps du nœud. Ne crée JAMAIS de nœuds vides ou purement génériques ! +2. **Zones & Groupements :** Utilise systématiquement des zones ("zones") pour regrouper les nœuds par couche logique ou technologique (ex: Frontend, API / Backend, Base de données, Services Externes, Cache). Renseigne "zoneId" sur chaque nœud pour le placer dans sa zone. +3. **Fidélité absolue au texte :** Intègre tous les chemins de fichiers (ex: "/settings/billing", "lib/stripe.ts", "billing-plans.tsx"), actions (ex: "POST /api/billing/webhook") et variables importants décrits. +4. **Types de nœuds :** Utilise "ellipse" pour les points d'entrée/sorties ou rôles utilisateurs, "diamond" pour les choix/décisions, et "rect" (par défaut) pour les composants, fichiers, tables et services. +5. **Format Strict :** De 5 à 15 nœuds maximum pour un diagramme d'architecture lisible. Échappe correctement tous les sauts de ligne comme \\n dans tes chaînes JSON. Ne renvoie AUCUN texte en dehors du JSON. Pas de blabla, pas de markdown.`; + +function extractJsonFromText(text: string): any { + if (!text) return null + try { + const parsed = JSON.parse(text.trim()) + if (parsed && typeof parsed === 'object') return parsed + } catch (e) {} + + const jsonBlockRegex = /```json\s*([\s\S]*?)\s*```/i + const match = text.match(jsonBlockRegex) + if (match && match[1]) { + try { + const parsed = JSON.parse(match[1].trim()) + if (parsed && typeof parsed === 'object') return parsed + } catch (e) {} + } + + const braceRegex = /(\{[\s\S]*\})/ + const braceMatch = text.match(braceRegex) + if (braceMatch && braceMatch[1]) { + try { + const parsed = JSON.parse(braceMatch[1].trim()) + if (parsed && typeof parsed === 'object') return parsed + } catch (e) {} + } + return null +} + +export async function generateDiagramFromText(text: string): Promise<{ success: boolean; canvasId?: string; error?: string }> { + const session = await auth() + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' } + } + const userId = session.user.id + + try { + // 1. Vérification et déduction des quotas + await checkEntitlementOrThrow(userId, 'excalidraw_generate') + + // 2. Instancier le modèle de chat IA + const systemConfig = await getSystemConfig() + const provider = getChatProvider(systemConfig) + + // 3. Appel du modèle + const prompt = `${SYSTEM_PROMPT}\n\nTexte à analyser :\n${text}` + const aiResponse = await provider.generateText(prompt) + + // 4. Extraction du JSON + const parsedJson = extractJsonFromText(aiResponse) + if (!parsedJson || !parsedJson.nodes || !Array.isArray(parsedJson.nodes)) { + console.error('[generateDiagramFromText] Invalid JSON from AI:', aiResponse) + return { success: false, error: "L'IA n'a pas généré un format de diagramme valide. Veuillez réessayer." } + } + + // 5. Invoquer le tool d'Excalidraw + const registered = toolRegistry.get('generate_excalidraw') + if (!registered) { + return { success: false, error: "Outil de génération Excalidraw non disponible." } + } + + const ctx = { userId, config: systemConfig } + const toolInstance = registered.buildTool(ctx as any) + const result = await toolInstance.execute({ + title: parsedJson.title || "Diagramme", + diagram: JSON.stringify(parsedJson) + }) + + if (!result.success || !result.canvasId) { + return { success: false, error: result.error || "La création du canevas a échoué." } + } + + // 6. Incrémenter le quota + await incrementUsageAsync(userId, 'excalidraw_generate') + + return { success: true, canvasId: result.canvasId } + + } catch (err: any) { + console.error('[generateDiagramFromText] Error:', err) + return { success: false, error: err.message || 'Une erreur inattendue est survenue.' } + } +} diff --git a/memento-note/app/actions/note-illustration.ts b/memento-note/app/actions/note-illustration.ts index 11623a4..9a238e7 100644 --- a/memento-note/app/actions/note-illustration.ts +++ b/memento-note/app/actions/note-illustration.ts @@ -8,27 +8,198 @@ import { getSystemConfig } from '@/lib/config' import { getAISettings } from '@/app/actions/ai-settings' import { revalidatePath } from 'next/cache' +type SvgComplexity = 'simple' | 'illustrated' | 'rich' + +// Palette de l'application Momento — à utiliser dans TOUS les SVGs +const APP_PALETTE = ` +APP COLOR PALETTE (use ONLY these colors — no other palettes): +- Background warm beige: #F2F0E9 +- Desk warm grey: #E5E2D9 +- Brand copper: #A47148 (main accent — use for key shapes) +- Sage green: #ACB995 (secondary accent) +- Ink charcoal: #2C2A26 (for dark shapes, text) +- Warm tan: #C9B8A1 (mid tones) +- Soft cream: #FAF8F4 (light elements) +- Dusty rose: #C4998B (warm pink accent) +- Muted slate: #8D8D8D (subtle elements) +- Deep bark: #6B4C35 (dark brown for depth) + +FORBIDDEN colors: cold blue, navy, electric cyan, neon, pure white (#fff), pure black (#000) +The overall mood must be WARM, EDITORIAL, like aged paper with copper accents. +` + function extractSvgSnippet(raw: string): string | null { - const trimmed = raw.trim() - const fenced = trimmed.match(/```(?:svg)?\s*([\s\S]*?)```/i) - const candidate = (fenced ? fenced[1] : trimmed).trim() - const start = candidate.indexOf('') + let text = raw + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/```(?:svg|xml)?\s*/gi, '') + .replace(/```/g, '') + .trim() + const start = text.indexOf('') if (start === -1 || end === -1 || end <= start) return null - return candidate.slice(start, end + 6) + return text.slice(start, end + 6) } function sanitizeSvgMarkup(svg: string): string { return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ['use'], - ADD_ATTR: ['viewBox', 'xmlns', 'preserveAspectRatio'], + ADD_TAGS: [ + 'use', 'defs', 'linearGradient', 'radialGradient', 'stop', + 'filter', 'feDropShadow', 'feGaussianBlur', 'feBlend', 'feComposite', + 'feMerge', 'feMergeNode', 'feColorMatrix', 'feOffset', 'feTurbulence', + 'feDisplacementMap', 'clipPath', 'mask', 'pattern', 'symbol', 'marker', + ], + ADD_ATTR: [ + 'viewBox', 'xmlns', 'preserveAspectRatio', + 'gradientUnits', 'gradientTransform', 'spreadMethod', + 'offset', 'stop-color', 'stop-opacity', + 'stdDeviation', 'dx', 'dy', 'flood-color', 'flood-opacity', + 'in', 'in2', 'result', 'type', 'values', + 'markerWidth', 'markerHeight', 'refX', 'refY', 'orient', + 'font-family', 'font-size', 'font-weight', 'text-anchor', + 'dominant-baseline', 'letter-spacing', 'word-spacing', + 'baseFrequency', 'numOctaves', 'seed', 'scale', + ], }) } +function buildPrompt( + complexity: SvgComplexity, + plainTitle: string, + plainBody: string, +): string { + const title = (plainTitle || 'Note').slice(0, 200) + const bodyContext = plainBody.slice(0, 2000) + + const sharedRules = ` +MULTILINGUAL: The title/content may be in French, Persian, Arabic, English or any language. Ignore the language — produce VISUAL output only. + +${APP_PALETTE} + +ABSOLUTELY FORBIDDEN: +- Generic/meaningless shapes: plain eye shapes, nested concentric circles with a triangle, abstract orbits, random geometric blobs +- Cold colors: blue, navy, cyan, neon green, purple, electric tones +- Shapes that have NO visual connection to the topic +- Gradients that go dark-to-dark with cold tones +- Dashed circles or crosshair-style patterns unless the topic is literally about targeting/aiming + +WHAT MAKES A GOOD ILLUSTRATION: +- A viewer who sees the SVG and reads the title should say "yes, that makes sense" +- Concrete objects > abstract shapes (airplane > circle, book > rectangle, brain > circle with bumps) +- The most recognizable iconic form of the concept +- Warmth and editorial feel matching the app palette +` + + if (complexity === 'simple') { + return `You are a precision SVG icon designer creating note card thumbnails for a warm editorial note-taking app. + +Your task: create ONE clear, iconic pictogram that represents THIS SPECIFIC note topic. + +${sharedRules} + +OUTPUT: Raw SVG ONLY. ... . No markdown, no comments. + +CANVAS: viewBox="0 0 400 300" — NO width/height attributes. +SIZE TARGET: 800-1400 bytes of SVG markup. + +DESIGN APPROACH — "Warm Stamp" style: +- Background: fill entire canvas with warm beige #F2F0E9 +- Add a subtle texture rectangle: +- Main pictogram: centered, using brand copper #A47148 as primary color +- Secondary element: sage green #ACB995 for accents or depth +- The pictogram fills roughly 40-55% of the canvas height — large and readable + +SPECIFIC TOPIC → SPECIFIC SHAPE rules (think like a professional icon designer): +- AI / Machine Learning → neural network nodes connected by lines, OR a stylized brain with circuit paths +- Tech analysis / research → magnifying glass over a document or graph bars +- Travel / flights → an airplane silhouette (side view, classic shape with wings) +- Governance / regulation → scales of justice, OR a shield with checkmark +- Innovation / startup → rocket ship, OR a lightbulb with circuit inside +- Cooking / food → specific food item mentioned, OR bowl with utensils +- Finance / money → ascending bar chart, OR coin stack +- Nature / environment → tree with roots showing, OR mountain peaks +- Music → treble clef, OR speaker with sound waves +- Health / medical → heartbeat line, OR medical cross +- Education / learning → open book with pages, OR graduation cap +- Writing / literature → quill pen, OR typewriter keys +- Philosophy / thinking → head silhouette with thought bubble, OR question mark with gears +- Calendar / date / event → calendar grid with highlighted date +- Sport → the specific sport equipment mentioned +- Data / statistics → bar chart or scatter plot dots +- Social / community → group of overlapping person silhouettes + +DO NOT use a generic shape if the topic is specific. Read CAREFULLY: +- "Tensions IA" → NOT a circle+triangle → USE: two opposing arrows pulling apart, OR a scale with AI chip on one side +- "Veille IA & Tech" → NOT an eye → USE: a magnifying glass over circuit board, OR stacked tech layers with a search icon +- "Gouvernance" → USE: shield with checkmark, OR gavel/hammer of judge +- "Innovation" → USE: rocket or lightbulb + +TITLE: "${title}" +CONTENT (for context): +${bodyContext.slice(0, 500)}` + } + + if (complexity === 'illustrated') { + return `You are a professional SVG illustrator creating rich editorial card illustrations for a warm note-taking app. + +${sharedRules} + +OUTPUT: Raw SVG ONLY. ... . No markdown, no comments. + +CANVAS: viewBox="0 0 400 300" — NO width/height attributes. +SIZE TARGET: 2000-3500 bytes. + +DESIGN APPROACH — "Editorial Print" style: +- Layered composition: background fill → texture layer → mid elements → focal element → foreground details +- Use with 1-2 gradients (warm tones only — from the app palette) +- 12-20 SVG elements +- The illustration should tell a visual story about the note content +- Add subtle paper texture: +- Focal element is specific and recognizable (not abstract) +- Depth through opacity stacking (0.2, 0.4, 0.7, 1.0 layers) + +Example structure for "AI Tech Analysis": +- Background: warm beige rect +- Mid: faint grid lines in #C9B8A1 suggesting a document or data surface +- Focal: a magnifying glass (#A47148) with a neural net pattern visible inside its lens +- Foreground: small data points or chip-like squares in #ACB995 + +TITLE: "${title}" +CONTENT: +${bodyContext.slice(0, 1000)}` + } + + // 'rich' — carte conceptuelle + return `You are an expert information designer creating SVG concept maps for a warm editorial note-taking app. + +${sharedRules} + +OUTPUT: Raw SVG ONLY. ... . No markdown, no comments. + +CANVAS: viewBox="0 0 400 300" — NO width/height attributes. +SIZE TARGET: 2500-4000 bytes. + +DESIGN APPROACH — "Warm Knowledge Map": +- Background: warm beige #F2F0E9 (NOT dark — this is a LIGHT warm background) +- Node style: rounded with #A47148 fill for main node, #ACB995 for secondary, #C9B8A1 for tertiary +- Text inside nodes: +- Connection lines: +- Arrow markers: with copper fill +- Drop shadows on nodes: + +STRUCTURE: +1. Central node (title concept) — large, centered or slightly left, #A47148 +2. 3-5 satellite nodes for key sub-concepts from the content body +3. Labels: SHORT (2-3 words), in the app's serif style +4. Spread across the canvas — use the full 400×300 space + +TITLE: "${title}" +CONTENT (extract 3-5 key concepts): +${bodyContext.slice(0, 1500)}` +} + /** - * Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré). - * Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG. + * Génère ou regénère une illustration SVG pour une note. */ export async function generateNoteIllustrationSvg( noteId: string, @@ -43,6 +214,9 @@ export async function generateNoteIllustrationSvg( return { ok: false, error: 'Assistant IA désactivé dans vos paramètres.' } } + const complexity: SvgComplexity = + (settings.svgComplexity as SvgComplexity) ?? 'simple' + const note = await prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, select: { id: true, title: true, content: true }, @@ -54,7 +228,6 @@ export async function generateNoteIllustrationSvg( .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() - .slice(0, 1200) if (!plainBody && !plainTitle) { return { ok: false, error: 'Ajoutez du contenu avant de générer une illustration.' } @@ -63,50 +236,11 @@ export async function generateNoteIllustrationSvg( const config = await getSystemConfig() const provider = getChatProvider(config) - const prompt = `Create a small SVG thumbnail that VISUALLY REPRESENTS this note's topic. - -OUTPUT: Only raw SVG markup. No markdown, no code fences, no comments. Start with . - -SPECIFICATIONS: -- viewBox="0 0 224 168", NO fixed width/height attributes -- Maximum 1000 bytes -- Background: soft warm beige (#F5F0E8) or transparent -- Color palette (pick 2-3): warm charcoal (#2C2C2C), slate gray (#6B7280), soft sage (#A8B5A0), muted ochre (#C4A882), dusty rose (#C9A9A6), teal (#5F9EA0), burgundy (#8B4513) -- NO text, NO scripts, NO foreignObject, NO external links - -CRITICAL: The illustration MUST be recognizably related to the topic. -Think of it like an ICON or PICTOGRAM for the title. Not abstract random shapes. - -TOPIC: "${plainTitle || 'untitled'}" - -How to illustrate this topic (pick the BEST match): -- If the topic is about CODE/DEV: Show angle brackets <>, curly braces {}, a terminal window shape, or circuit-like lines -- If the topic is about MUSIC: Show sound waves, musical notes shapes, or speaker icon -- If the topic is about FOOD/COOKING: Show a pot shape, utensils, or plate -- If the topic is about TRAVEL: Show a path/road, mountain peaks, or compass -- If the topic is about SCIENCE: Show atom orbits, flask/beaker, or molecule bonds -- If the topic is about BUSINESS/FINANCE: Show ascending chart lines, coins, or briefcase -- If the topic is about HEALTH: Show heart shape, pulse line, or leaf -- If the topic is about EDUCATION: Show book shape, graduation cap, or pencil -- If the topic is about NATURE: Show tree, mountain, water wave, or sun -- If the topic is about DESIGN/ART: Show palette, brush stroke, or frame -- If the topic is about PEOPLE/TEAM: Show overlapping circles, handshake, or connected nodes -- If the topic is about ARCHITECTURE: Show building outline, blueprint grid, or columns -- Otherwise: Extract the KEY CONCEPT from the title and draw its SIMPLEST iconic representation - -TECHNICAL RULES: -- Use simple shapes: , , , , , , -- Keep it FLAT and MINIMAL — 2-4 elements max -- Use opacity for depth (0.3-0.8) -- The icon should be immediately recognizable even at small size - -Additional context from the note: -${plainBody.slice(0, 200)}` - + const prompt = buildPrompt(complexity, plainTitle, plainBody) const raw = await provider.generateText(prompt) const extracted = extractSvgSnippet(raw) if (!extracted) { - return { ok: false, error: 'Le modèle n’a pas renvoyé un SVG valide. Réessayez.' } + return { ok: false, error: 'Le modèle n\'a pas renvoyé un SVG valide. Réessayez.' } } const safe = sanitizeSvgMarkup(extracted) @@ -129,6 +263,11 @@ ${plainBody.slice(0, 200)}` } catch (e) { console.error('[note-illustration]', e) const msg = e instanceof Error ? e.message : 'Erreur inconnue' - return { ok: false, error: msg.includes('required') ? 'Configurez un fournisseur IA (admin ou paramètres système).' : msg } + return { + ok: false, + error: msg.includes('required') + ? 'Configurez un fournisseur IA (admin ou paramètres système).' + : msg, + } } } diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 1af17da..32f4f4e 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -22,6 +22,10 @@ import { } from '@/lib/note-history' import { NOTE_LIST_SELECT } from '@/lib/note-select' +function stripNullBytes(str: string | null | undefined): string | null { + if (str === null || str === undefined) return null + return str.replace(/\u0000/g, '') +} async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) { const fallbackEmail = `user-${sessionUser.id}@local.momento` @@ -400,8 +404,8 @@ export async function createNote(data: { const note = await prisma.note.create({ data: { userId: session.user.id, - title: data.title || null, - content: data.content, + title: stripNullBytes(data.title) || null, + content: stripNullBytes(data.content) || '', color: data.color || 'default', type: data.type || 'richtext', checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null, @@ -563,6 +567,12 @@ export async function updateNote(id: string, data: { const oldNotebookId = oldNote?.notebookId const updateData: any = { ...data } + if (updateData.title !== undefined) { + updateData.title = stripNullBytes(updateData.title) + } + if (updateData.content !== undefined) { + updateData.content = stripNullBytes(updateData.content) + } // Reset isReminderDone only when reminder date actually changes (not on every save) if ('reminder' in data && data.reminder !== null) { @@ -984,6 +994,7 @@ export async function getAllNotes(includeArchived = false, notebookId?: string) const sharedNotes = acceptedShares .map(share => share.note) + .filter(Boolean) .filter(note => includeArchived || !note.isArchived) .map(note => ({ ...note, _isShared: true })) diff --git a/memento-note/app/api/ai/title-suggestions/route.ts b/memento-note/app/api/ai/title-suggestions/route.ts index a95c495..6a72084 100644 --- a/memento-note/app/api/ai/title-suggestions/route.ts +++ b/memento-note/app/api/ai/title-suggestions/route.ts @@ -11,6 +11,20 @@ const requestSchema = z.object({ content: z.string().min(1, "Le contenu ne peut pas être vide"), }) +/** Supprime les balises HTML pour extraire le texte brut */ +function stripHtml(html: string): string { + return html + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim() +} + export async function POST(req: NextRequest) { try { // Check authentication and user setting @@ -42,10 +56,13 @@ export async function POST(req: NextRequest) { console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err); } const body = await req.json() - const { content } = requestSchema.parse(body) + const { content: rawContent } = requestSchema.parse(body) + + // Nettoyer le HTML (l'éditeur TipTap envoie du HTML) + const content = stripHtml(rawContent) // Vérifier qu'il y a au moins 10 mots - const wordCount = content.split(/\s+/).length + const wordCount = content.split(/\s+/).filter(w => w.length > 0).length if (wordCount < 10) { return NextResponse.json( @@ -57,7 +74,6 @@ export async function POST(req: NextRequest) { const config = await getSystemConfig() // Détecter la langue du contenu (simple détection basée sur les caractères et mots) - const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content) const isPersian = /[\u0600-\u06FF]/.test(content) const isChinese = /[\u4E00-\u9FFF]/.test(content) const isRussian = /[\u0400-\u04FF]/.test(content) @@ -99,7 +115,7 @@ IMPORTANT INSTRUCTIONS: - Focus on the main topics and themes in THIS SPECIFIC content - Be specific to what is actually discussed -CONTENT_START: ${content.substring(0, 500)} CONTENT_END +CONTENT_START: ${content.substring(0, 3000)} CONTENT_END Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]` : `Tu es un générateur de titres. Génère 3 titres concis et descriptifs pour le contenu suivant en ${responseLanguage}. @@ -110,7 +126,7 @@ INSTRUCTIONS IMPORTANTES : - Concentre-toi sur les sujets principaux et thèmes de CE CONTENU SPÉCIFIQUE - Sois spécifique à ce qui est réellement discuté -CONTENT_START: ${content.substring(0, 500)} CONTENT_END +CONTENT_START: ${content.substring(0, 3000)} CONTENT_END Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]` diff --git a/memento-note/app/api/billing/create-checkout/route.ts b/memento-note/app/api/billing/create-checkout/route.ts index fb119cf..ee5d475 100644 --- a/memento-note/app/api/billing/create-checkout/route.ts +++ b/memento-note/app/api/billing/create-checkout/route.ts @@ -70,6 +70,7 @@ export async function POST(req: NextRequest) { metadata: { userId, tier }, subscription_data: { metadata: { userId, tier } }, customer_update: { address: 'auto' }, + allow_promotion_codes: true, }; const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any); diff --git a/memento-note/app/api/billing/status/route.ts b/memento-note/app/api/billing/status/route.ts index dc7674f..d357660 100644 --- a/memento-note/app/api/billing/status/route.ts +++ b/memento-note/app/api/billing/status/route.ts @@ -3,7 +3,7 @@ import { auth } from '@/auth'; import { getUserInfo, getEffectiveTier } from '@/lib/entitlements'; import { stripe } from '@/lib/stripe'; import type Stripe from 'stripe'; -import { priceIdToTier } from '@/lib/billing/stripe-prices'; +import { priceIdToTier, getDynamicPrices } from '@/lib/billing/stripe-prices'; export const dynamic = 'force-dynamic'; @@ -26,7 +26,7 @@ export async function GET(req: NextRequest) { ? checkoutSession.subscription : (checkoutSession.subscription as any).id; - const sub = await stripe.subscriptions.retrieve(subId); + const sub = await stripe.subscriptions.retrieve(subId) as any; const priceId = sub.items.data[0].price.id; const tier = priceIdToTier(priceId) || (checkoutSession.metadata?.tier as any) || 'PRO'; @@ -70,11 +70,11 @@ export async function GET(req: NextRequest) { console.error('[billing/status] Failed to sync Stripe session:', err); } } - try { const { tier, status, currentPeriodEnd } = await getUserInfo(userId); const effectiveTier = await getEffectiveTier(userId); const subscription = await prisma.subscription.findUnique({ where: { userId } }); + const prices = await getDynamicPrices(); return NextResponse.json({ tier, @@ -84,6 +84,7 @@ export async function GET(req: NextRequest) { currentPeriodStart: subscription?.currentPeriodStart ?? null, cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false, hasStripeSubscription: !!subscription?.stripeSubscriptionId, + prices, }); } catch (error) { console.error('[billing/status]', error); diff --git a/memento-note/app/api/chat/route.ts b/memento-note/app/api/chat/route.ts index a92b715..ca5e9da 100644 --- a/memento-note/app/api/chat/route.ts +++ b/memento-note/app/api/chat/route.ts @@ -9,6 +9,7 @@ import { hasUserAiConsent } from '@/lib/consent/server-consent' import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n' import { toolRegistry } from '@/lib/ai/tools' import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements' +import { ByokUnavailableError } from '@/lib/byok' import { trackFeatureUsage } from '@/lib/usage-tracker' import { readFile } from 'fs/promises' import path from 'path' @@ -70,6 +71,12 @@ export async function POST(req: Request) { if (err instanceof QuotaExceededError) { return Response.json(err.toJSON(), { status: 402 }) } + if (err instanceof ByokUnavailableError) { + return Response.json( + { error: 'byok_unavailable', message: 'Votre clé API BYOK est configurée mais n\'a pas pu être chargée. Vérifiez vos paramètres BYOK.' }, + { status: 503 } + ) + } console.error('[chat] Quota check error (fail-open):', err) } @@ -410,40 +417,49 @@ Focus ONLY on this note unless asked otherwise.` ] const wantsChart = chartKeywords.some(k => lastMessage.includes(k)) - const { result, usedByok } = await runLaneWithBillingUser( - 'chat', - sysConfig, - userId, - async (provider) => - streamText({ - model: provider.getModel(), - system: systemPrompt, - messages: incomingMessages, - tools: chatTools, - toolChoice: wantsChart && chatTools.insert_chart ? { type: 'tool', toolName: 'insert_chart' } : undefined, - stopWhen: stepCountIs(5), - onFinish: async (final) => { - const userContent = incomingMessages[incomingMessages.length - 1].content - await prisma.chatMessage.create({ - data: { conversationId: conversation.id, role: 'user', content: userContent }, - }) - await prisma.chatMessage.create({ - data: { conversationId: conversation.id, role: 'assistant', content: final.text }, - }) - if (!usedByok) { - trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0) - incrementUsageAsync(userId, 'chat') - } - logAuditEvent({ - userId, - action: 'AI_REQUEST', - resource: 'chat', - metadata: { tokens: final.usage?.totalTokens, byok: usedByok }, - ip: getClientIp(req), - }) - }, - }), - ) - - return result.toUIMessageStreamResponse() + try { + const { result, usedByok } = await runLaneWithBillingUser( + 'chat', + sysConfig, + userId, + async (provider) => + streamText({ + model: provider.getModel(), + system: systemPrompt, + messages: incomingMessages, + tools: chatTools, + toolChoice: wantsChart && chatTools.insert_chart ? { type: 'tool', toolName: 'insert_chart' } : undefined, + stopWhen: stepCountIs(5), + onFinish: async (final) => { + const userContent = incomingMessages[incomingMessages.length - 1].content + await prisma.chatMessage.create({ + data: { conversationId: conversation.id, role: 'user', content: userContent }, + }) + await prisma.chatMessage.create({ + data: { conversationId: conversation.id, role: 'assistant', content: final.text }, + }) + if (!usedByok) { + trackFeatureUsage(userId, 'chat', final.usage?.totalTokens ?? 0) + incrementUsageAsync(userId, 'chat') + } + logAuditEvent({ + userId, + action: 'AI_REQUEST', + resource: 'chat', + metadata: { tokens: final.usage?.totalTokens, byok: usedByok }, + ip: getClientIp(req), + }) + }, + }), + ) + return result.toUIMessageStreamResponse() + } catch (err) { + if (err instanceof ByokUnavailableError) { + return Response.json( + { error: 'byok_unavailable', message: 'Votre clé API BYOK est configurée mais n\'a pas pu être chargée. Vérifiez vos paramètres dans Réglages > Clés API.' }, + { status: 503 } + ) + } + throw err + } } diff --git a/memento-note/app/api/user/ai-status/route.ts b/memento-note/app/api/user/ai-status/route.ts new file mode 100644 index 0000000..91d2c7f --- /dev/null +++ b/memento-note/app/api/user/ai-status/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { getSystemConfig } from '@/lib/config'; +import { getAnyActiveByokForUser } from '@/lib/byok'; +import { resolveAiRoute } from '@/lib/ai/router'; + +/** + * GET /api/user/ai-status + * Returns the effective AI provider and model for the current user. + * Used by the UI to show which model is active (BYOK vs admin). + */ +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const config = await getSystemConfig(); + const adminRoute = resolveAiRoute('chat', config); + + const byok = await getAnyActiveByokForUser(session.user.id, adminRoute.providerType); + + if (byok) { + const model = (byok.model && byok.model.trim()) ? byok.model : adminRoute.modelName; + return NextResponse.json({ + usedByok: true, + provider: byok.provider, + model, + source: 'byok', + }); + } + + return NextResponse.json({ + usedByok: false, + provider: adminRoute.providerType, + model: adminRoute.modelName, + source: 'admin', + }); +} diff --git a/memento-note/app/api/user/api-keys/[provider]/route.ts b/memento-note/app/api/user/api-keys/[provider]/route.ts index 084e92e..db9b52d 100644 --- a/memento-note/app/api/user/api-keys/[provider]/route.ts +++ b/memento-note/app/api/user/api-keys/[provider]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/auth'; import { prisma } from '@/lib/prisma'; +import { encryptApiKey, hashApiKey } from '@/lib/crypto'; import { VALID_PROVIDERS } from '@/lib/ai/router'; type RouteContext = { params: Promise<{ provider: string }> }; @@ -16,14 +17,34 @@ export async function PATCH(req: NextRequest, context: RouteContext) { return NextResponse.json({ error: 'Unknown provider' }, { status: 400 }); } - const body = (await req.json().catch(() => ({}))) as { isActive?: boolean }; - if (typeof body.isActive !== 'boolean') { - return NextResponse.json({ error: 'isActive boolean required' }, { status: 400 }); + const body = (await req.json().catch(() => ({}))) as { + isActive?: boolean; + model?: string; + alias?: string; + baseUrl?: string; + apiKey?: string; // optional — only when rotating the key + }; + + const data: Record = {}; + + if (typeof body.isActive === 'boolean') data.isActive = body.isActive; + if (body.model !== undefined) data.model = body.model || null; + if (body.alias !== undefined) data.alias = body.alias || ''; + if (body.baseUrl !== undefined) data.baseUrl = body.baseUrl || null; + + // Key rotation: only when new key explicitly provided + if (body.apiKey && body.apiKey.length >= 8) { + data.encryptedKey = await encryptApiKey(body.apiKey); + data.keyHash = hashApiKey(body.apiKey); + } + + if (Object.keys(data).length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }); } const updated = await prisma.userAPIKey.updateMany({ where: { userId: session.user.id, provider }, - data: { isActive: body.isActive }, + data, }); if (updated.count === 0) { diff --git a/memento-note/app/api/user/api-keys/live-models/route.ts b/memento-note/app/api/user/api-keys/live-models/route.ts index 3ba8de8..b2074b5 100644 --- a/memento-note/app/api/user/api-keys/live-models/route.ts +++ b/memento-note/app/api/user/api-keys/live-models/route.ts @@ -1,15 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/auth'; import { getEffectiveTier } from '@/lib/entitlements'; -import { getAllowedByokProviders, isByokProviderAllowed } from '@/lib/byok'; -import { fetchLiveModelsForProvider } from '@/lib/ai/models-list'; +import { isByokProviderAllowed } from '@/lib/byok'; +import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list'; import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router'; +// Providers that return static suggestions regardless of key +const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']); + /** - * GET /api/user/api-keys/live-models?provider=&key=&baseUrl= + * GET /api/user/api-keys/live-models?provider=[&key=][&baseUrl=] * - * Dynamically queries the third-party provider's API with the user's key to fetch - * actual available models dynamically. + * - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed. + * - Live providers (openai, deepseek…): requires key to fetch live from provider. */ export async function GET(request: NextRequest) { const session = await auth(); @@ -24,11 +27,11 @@ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const provider = searchParams.get('provider') as AiGatewayProvider; - const apiKey = searchParams.get('key'); + const apiKey = searchParams.get('key') ?? ''; const baseUrl = searchParams.get('baseUrl') ?? undefined; - if (!provider || !apiKey) { - return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + if (!provider) { + return NextResponse.json({ error: 'Missing provider' }, { status: 400 }); } if (!VALID_PROVIDERS.has(provider)) { @@ -39,7 +42,18 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Tier restricted' }, { status: 403 }); } - const models = await fetchLiveModelsForProvider(provider, apiKey, baseUrl); + // Static suggestion providers: return immediately without a key + if (STATIC_PROVIDERS.has(provider)) { + const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider; + const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? []; + return NextResponse.json({ success: true, models, fromApi: false }); + } - return NextResponse.json({ success: true, models }); + // Live providers need a key + if (!apiKey || apiKey.length < 4) { + return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false }); + } + + const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl); + return NextResponse.json({ success: true, models: result.models, fromApi: result.fromApi }); } diff --git a/memento-note/app/api/user/api-keys/route.ts b/memento-note/app/api/user/api-keys/route.ts index 4d0c661..5df68d9 100644 --- a/memento-note/app/api/user/api-keys/route.ts +++ b/memento-note/app/api/user/api-keys/route.ts @@ -19,7 +19,7 @@ const createSchema = z.object({ apiKey: z.string().min(8), alias: z.string().max(120).optional(), model: z.string().max(120).optional(), - baseUrl: z.string().url().optional(), + baseUrl: z.string().max(2000).optional(), }); import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'; @@ -75,8 +75,9 @@ export async function POST(req: NextRequest) { ); } - const effectiveBaseUrl = provider === 'custom' ? body.baseUrl : undefined; - if (provider !== 'custom' && body.baseUrl) { + const effectiveBaseUrl = (provider === 'custom' || provider === 'custom_openai' || provider === 'custom_anthropic') ? body.baseUrl : undefined; + const allowsBaseUrl = provider === 'custom' || provider === 'custom_openai' || provider === 'custom_anthropic'; + if (!allowsBaseUrl && body.baseUrl) { return NextResponse.json( { error: 'INVALID_REQUEST', message: 'baseUrl is only allowed for custom providers' }, { status: 400 }, @@ -96,6 +97,7 @@ export async function POST(req: NextRequest) { plaintext: body.apiKey, alias: body.alias, model: body.model, + baseUrl: effectiveBaseUrl, }); return NextResponse.json({ key: toPublicApiKey(row) }); diff --git a/memento-note/app/api/user/api-keys/test-model/route.ts b/memento-note/app/api/user/api-keys/test-model/route.ts new file mode 100644 index 0000000..69b5094 --- /dev/null +++ b/memento-note/app/api/user/api-keys/test-model/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { getEffectiveTier } from '@/lib/entitlements'; +import { isByokProviderAllowed } from '@/lib/byok'; +import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router'; + +function cleanReply(text: string): string { + // Strip ... reasoning blocks (DeepSeek, MiniMax, etc.) + return text.replace(/[\s\S]*?<\/think>/gi, '').replace(/[\s\S]*?<\/thinking>/gi, '').trim() || text.trim() +} + +const PROVIDER_URLS: Record = { + openai: 'https://api.openai.com/v1', + deepseek: 'https://api.deepseek.com/v1', + openrouter: 'https://openrouter.ai/api/v1', + mistral: 'https://api.mistral.ai/v1', + zai: 'https://api.zukijourney.com/v1', + minimax: 'https://api.minimax.io/v1', + glm: 'https://open.bigmodel.ai/api/paas/v4', +}; + +/** + * GET /api/user/api-keys/test-model?provider=X&key=Y&model=Z[&baseUrl=...] + * Sends a minimal chat completion to verify the key + model work end-to-end. + */ +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const tier = await getEffectiveTier(session.user.id); + if (tier === 'BASIC') return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const { searchParams } = request.nextUrl; + const provider = searchParams.get('provider') as AiGatewayProvider; + const apiKey = searchParams.get('key') ?? ''; + const model = searchParams.get('model') ?? ''; + const baseUrl = searchParams.get('baseUrl') ?? undefined; + + if (!provider || apiKey.length < 4 || !model) { + return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + } + + if (!VALID_PROVIDERS.has(provider)) { + return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); + } + + if (!isByokProviderAllowed(tier, provider)) { + return NextResponse.json({ error: 'Tier restricted' }, { status: 403 }); + } + + const start = Date.now(); + + try { + // Anthropic has a different API format + if (provider === 'anthropic' || provider === 'anthropic_custom' || provider === 'custom_anthropic') { + const url = baseUrl + ? `${baseUrl.replace(/\/$/, '')}/messages` + : 'https://api.anthropic.com/v1/messages'; + + const res = await fetch(url, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model, + max_tokens: 60, + messages: [{ role: 'user', content: 'Say: ok' }], + }), + signal: AbortSignal.timeout(20_000), + }); + + const latency = Date.now() - start; + if (res.status === 401 || res.status === 403) { + return NextResponse.json({ ok: false, error: 'Clé API invalide' }); + } + if (res.status === 404) { + return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` }); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + return NextResponse.json({ ok: false, error: body?.error?.message ?? `Erreur ${res.status}` }); + } + const body = await res.json(); + const raw = body?.content?.[0]?.text ?? '(réponse reçue)'; + return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) }); + } + + // Google AI + if (provider === 'google') { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${apiKey}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contents: [{ parts: [{ text: 'Reply with: ok' }] }], generationConfig: { maxOutputTokens: 5 } }), + signal: AbortSignal.timeout(20_000), + }); + const latency = Date.now() - start; + if (!res.ok) { + const body = await res.json().catch(() => ({})); + if (res.status === 400 && body?.error?.message?.includes('not found')) { + return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` }); + } + return NextResponse.json({ ok: false, error: body?.error?.message ?? `Erreur ${res.status}` }); + } + const body = await res.json(); + const raw = body?.candidates?.[0]?.content?.parts?.[0]?.text ?? '(réponse reçue)'; + return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) }); + } + + // OpenAI-compatible (openai, deepseek, minimax, openrouter, mistral, glm, zai, custom_openai, custom, lmstudio) + const url = (provider === 'custom' || provider === 'custom_openai') + ? `${(baseUrl ?? '').replace(/\/$/, '')}/chat/completions` + : `${PROVIDER_URLS[provider] ?? ''}/chat/completions`; + + if (!url || url.startsWith('/')) { + return NextResponse.json({ ok: false, error: 'URL de l\'API manquante' }); + } + + const headers: Record = { + 'content-type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }; + if (provider === 'openrouter') { + headers['HTTP-Referer'] = 'https://memento-note.com'; + headers['X-Title'] = 'Memento'; + } + + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + model, + max_tokens: 5, + messages: [{ role: 'user', content: 'Reply with: ok' }], + }), + signal: AbortSignal.timeout(20_000), + }); + + const latency = Date.now() - start; + + if (res.status === 401 || res.status === 403) { + return NextResponse.json({ ok: false, error: 'Clé API invalide ou refusée' }); + } + if (res.status === 404) { + return NextResponse.json({ ok: false, error: `Modèle "${model}" introuvable` }); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const msg = body?.error?.message ?? body?.message ?? `Erreur ${res.status}`; + return NextResponse.json({ ok: false, error: msg }); + } + + const body = await res.json(); + const raw = body?.choices?.[0]?.message?.content ?? '(réponse reçue)'; + return NextResponse.json({ ok: true, latency, reply: cleanReply(raw) }); + + } catch (err: unknown) { + const latency = Date.now() - start; + if (err instanceof Error && err.name === 'TimeoutError') { + return NextResponse.json({ ok: false, error: 'Délai d\'attente dépassé (>20s)' }); + } + return NextResponse.json({ ok: false, error: err instanceof Error ? err.message : 'Erreur inconnue', latency }); + } +} diff --git a/memento-note/app/api/user/api-keys/verify/route.ts b/memento-note/app/api/user/api-keys/verify/route.ts new file mode 100644 index 0000000..8386ee4 --- /dev/null +++ b/memento-note/app/api/user/api-keys/verify/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { getEffectiveTier } from '@/lib/entitlements'; +import { isByokProviderAllowed } from '@/lib/byok'; +import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list'; +import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router'; + +/** + * GET /api/user/api-keys/verify?provider=&key=&baseUrl= + * + * Verifies that the user's API key is valid by attempting to fetch models. + * Returns validity status and available models if successful. + * + * Key is only considered VALID if we can fetch models from the actual provider API. + * Fallback to hardcoded suggestions is NOT considered valid verification. + */ +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const tier = await getEffectiveTier(session.user.id); + if (tier === 'BASIC') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = request.nextUrl; + const provider = searchParams.get('provider') as AiGatewayProvider; + const apiKey = searchParams.get('key'); + const baseUrl = searchParams.get('baseUrl') ?? undefined; + + if (!provider || !apiKey) { + return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + } + + if (!VALID_PROVIDERS.has(provider)) { + return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); + } + + if (!isByokProviderAllowed(tier, provider)) { + return NextResponse.json({ error: 'Tier restricted' }, { status: 403 }); + } + + try { + const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl); + + console.log(`[verify] ${provider}: fromApi=${result.fromApi}, models=${result.models.length}`, result.models); + + // Only consider key valid if we got models from the REAL API (not fallbacks) + // Exception: Anthropic/Google/custom don't guarantee public /models endpoints, so we accept fallbacks for them + const acceptsFallbacks = + provider === 'anthropic' || + provider === 'anthropic_custom' || + provider === 'custom_anthropic' || + provider === 'google' || + provider === 'minimax' || + provider === 'custom' || + provider === 'custom_openai'; + const isValid = result.models.length > 0 && (result.fromApi || acceptsFallbacks); + + return NextResponse.json({ + valid: isValid, + models: result.models, + fromApi: result.fromApi, + message: isValid + ? result.fromApi + ? `${result.models.length} modèle(s) trouvé(s) via l'API ${provider}` + : `${result.models.length} modèle(s) suggéré(s) pour ${provider}` + : 'Aucun modèle trouvé - vérifiez votre clé API' + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Clé API invalide'; + console.error(`[verify] ${provider} failed:`, error); + return NextResponse.json({ + valid: false, + models: [], + fromApi: false, + message + }, { status: 200 }); // Return 200 with valid:false for UI handling + } +} diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 05477da..cbd30f6 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -2051,15 +2051,15 @@ html.font-system * { } .notion-slash-tab-active { - background: var(--primary); - color: var(--primary-foreground); - border-color: var(--primary); + background: var(--color-brand-accent); + color: #FAF8F4; + border-color: var(--color-brand-accent); } .notion-slash-tab-active:hover { opacity: 0.9; - background: var(--primary); - color: var(--primary-foreground); + background: var(--color-brand-accent); + color: #FAF8F4; } .notion-slash-tab-ai { diff --git a/memento-note/components/ai/ai-settings-panel.tsx b/memento-note/components/ai/ai-settings-panel.tsx index 644656a..27b27fc 100644 --- a/memento-note/components/ai/ai-settings-panel.tsx +++ b/memento-note/components/ai/ai-settings-panel.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { updateAISettings } from '@/app/actions/ai-settings' import { DemoModeToggle } from '@/components/demo-mode-toggle' import { toast } from 'sonner' -import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react' +import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2, ImageIcon } from 'lucide-react' import { useLanguage } from '@/lib/i18n' import { motion } from 'motion/react' import { cn } from '@/lib/utils' @@ -23,6 +23,7 @@ interface AISettingsPanelProps { autoLabeling: boolean noteHistory: boolean noteHistoryMode: 'manual' | 'auto' + svgComplexity?: 'simple' | 'illustrated' | 'rich' } } @@ -73,6 +74,20 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) { } } + const handleSvgComplexityChange = async (value: 'simple' | 'illustrated' | 'rich') => { + setSettings(prev => ({ ...prev, svgComplexity: value })) + try { + setIsPending(true) + await updateAISettings({ svgComplexity: value }) + toast.success(t('aiSettings.saved')) + } catch { + toast.error(t('aiSettings.error')) + setSettings(initialSettings) + } finally { + setIsPending(false) + } + } + const handleDemoModeToggle = async (enabled: boolean) => { setSettings(prev => ({ ...prev, demoMode: enabled })) try { @@ -255,6 +270,117 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) { )} + {/* SVG Complexity selector */} +
+
+
+ +
+
+

{t('aiSettings.svgComplexity')}

+

{t('aiSettings.svgComplexityDesc')}

+
+
+
+ {([ + { + value: 'simple' as const, + label: t('aiSettings.svgComplexitySimple'), + desc: t('aiSettings.svgComplexitySimpleDesc'), + preview: ( + + + + + + + + ), + }, + { + value: 'illustrated' as const, + label: t('aiSettings.svgComplexityIllustrated'), + desc: t('aiSettings.svgComplexityIllustratedDesc'), + preview: ( + + + + + + + + + + + + + + + + + + + + + ), + }, + { + value: 'rich' as const, + label: t('aiSettings.svgComplexityRich'), + desc: t('aiSettings.svgComplexityRichDesc'), + preview: ( + + + + + + + + + + + + MAIN + idea 1 + idea 2 + + ), + }, + ] as const).map((opt) => { + const isActive = (settings.svgComplexity ?? 'simple') === opt.value + return ( + + ) + })} +
+
+ diff --git a/memento-note/components/ai/byok-settings-panel.tsx b/memento-note/components/ai/byok-settings-panel.tsx index 1c60114..4e1f258 100644 --- a/memento-note/components/ai/byok-settings-panel.tsx +++ b/memento-note/components/ai/byok-settings-panel.tsx @@ -2,11 +2,12 @@ import { useCallback, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { KeyRound, Loader2, Shield, Trash2 } from 'lucide-react' +import { KeyRound, Loader2, Shield, Trash2, CheckCircle2, XCircle, Zap, FlaskConical, Pencil, X } from 'lucide-react' import { toast } from 'sonner' import { useLanguage } from '@/lib/i18n' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' import { Select, SelectContent, @@ -16,154 +17,359 @@ import { } from '@/components/ui/select' import { cn } from '@/lib/utils' -type PublicKey = { +// ─── Provider display info ──────────────────────────────────────────────────── +const PROVIDER_INFO: Record = { + openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' }, + anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' }, + minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' }, + google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' }, + deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' }, + openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' }, + mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' }, + glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' }, + zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' }, + anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' }, + custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' }, + custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' }, + custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' }, +} + +function displayName(provider: string): string { + return PROVIDER_INFO[provider]?.name ?? provider +} + +const MANUAL_MODEL_PROVIDERS = new Set(['custom']) +const NEEDS_BASE_URL = new Set(['custom', 'custom_openai', 'custom_anthropic']) + +function getDefaultBaseUrl(p: string): string { + if (p === 'custom_openai') return 'https://api.openai.com/v1' + if (p === 'custom_anthropic') return 'https://api.anthropic.com/v1' + return '' +} + +// ─── Types ──────────────────────────────────────────────────────────────────── +type SavedKey = { provider: string - alias: string + alias: string | null model: string | null + baseUrl: string | null isActive: boolean lastUsedAt: string | null } -async function fetchByokKeys(): Promise<{ - keys: PublicKey[] - allowedProviders: string[] - providerModels: Record -}> { +async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> { const res = await fetch('/api/user/api-keys') - if (!res.ok) { - const body = await res.json().catch(() => ({})) - throw new Error(body.message || body.error || 'Failed to load keys') - } + if (!res.ok) throw new Error('Erreur de chargement') return res.json() } -function providerLabel(t: (key: string) => string, provider: string): string { - const key = `byokSettings.providers.${provider}` - const translated = t(key) - return translated === key ? provider : translated +async function fetchModelsFromServer(provider: string, key?: string, baseUrl?: string): Promise { + const q = new URLSearchParams({ provider }) + if (key) q.set('key', key) + if (baseUrl) q.set('baseUrl', baseUrl) + const res = await fetch(`/api/user/api-keys/live-models?${q}`) + if (!res.ok) return [] + const body = await res.json() + return body.success && Array.isArray(body.models) ? body.models : [] } +// ─── Inline edit form for a saved key ──────────────────────────────────────── +function EditKeyForm({ + savedKey, + onDone, + onInvalidate, +}: { + savedKey: SavedKey + onDone: () => void + onInvalidate: () => void +}) { + const [alias, setAlias] = useState(savedKey.alias ?? '') + const [model, setModel] = useState(savedKey.model ?? '') + const [baseUrl, setBaseUrl] = useState(savedKey.baseUrl ?? getDefaultBaseUrl(savedKey.provider)) + const [newKey, setNewKey] = useState('') // empty = keep existing key + const [models, setModels] = useState([]) + const [loadingModels, setLoadingModels] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null) + + const needsUrl = NEEDS_BASE_URL.has(savedKey.provider) + const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider) + + // Load models on mount + useState(() => { + if (manualModel) return + fetchModelsFromServer(savedKey.provider, undefined, baseUrl || undefined).then((list) => { + setModels(list) + if (list.length > 0 && !model) setModel(list[0]) + }) + }) + + const saveMutation = useMutation({ + mutationFn: async () => { + const payload: Record = { + model: model || undefined, + alias: alias.trim() || undefined, + } + if (needsUrl) payload.baseUrl = baseUrl + if (newKey.length >= 8) payload.apiKey = newKey + const res = await fetch(`/api/user/api-keys/${encodeURIComponent(savedKey.provider)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + const body = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec') + }, + onSuccess: () => { + toast.success(`${displayName(savedKey.provider)} mis à jour ✓`) + onInvalidate() + onDone() + }, + onError: (err: Error) => toast.error(err.message), + }) + + async function testModel() { + const keyToUse = newKey.length >= 8 ? newKey : '' + if (!keyToUse && !model) return + if (!keyToUse) { + toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"') + return + } + setTesting(true) + setTestResult(null) + try { + const q = new URLSearchParams({ provider: savedKey.provider, key: keyToUse, model }) + if (needsUrl && baseUrl) q.append('baseUrl', baseUrl) + const res = await fetch(`/api/user/api-keys/test-model?${q}`) + setTestResult(await res.json()) + } catch { + setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) + } finally { + setTesting(false) + } + } + + const showModelDropdown = !manualModel && models.length > 0 + const showModelInput = manualModel || (!loadingModels && models.length === 0) + + return ( +
+

+ Modifier — {displayName(savedKey.provider)} +

+ + {/* Alias */} +
+
+ + setAlias(e.target.value)} placeholder="ex. Ma clé pro" /> +
+ {needsUrl && ( +
+ + setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" /> +
+ )} +
+ + {/* Model */} +
+ + {showModelDropdown ? ( + + ) : showModelInput ? ( + setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" /> + ) :
Chargement...
} +
+ + {/* Key rotation (optional) */} +
+ + { setNewKey(e.target.value); setTestResult(null) }} + placeholder="sk-... (optionnel)" + /> +
+ + {/* Test result */} + {testResult && ( +
+ {testResult.ok ? : } + + {testResult.ok + ? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}` + : testResult.error ?? 'Échec' + } + +
+ )} + + {/* Actions */} +
+ + + +
+
+ ) +} + +// ─── Main Component ─────────────────────────────────────────────────────────── export function ByokSettingsPanel() { const { t } = useLanguage() const queryClient = useQueryClient() + const [provider, setProvider] = useState('') const [apiKey, setApiKey] = useState('') const [alias, setAlias] = useState('') + const [baseUrl, setBaseUrl] = useState('') const [model, setModel] = useState('') - const [customModel, setCustomModel] = useState('') - const [isCustomModel, setIsCustomModel] = useState(false) + const [models, setModels] = useState([]) + const [loadingModels, setLoadingModels] = useState(false) + const [keyOk, setKeyOk] = useState(false) + const [verifying, setVerifying] = useState(false) + const [editingProvider, setEditingProvider] = useState(null) - // Dynamic models fetched directly via user's API Key - const [liveModels, setLiveModels] = useState([]) - const [isFetchingLiveModels, setIsFetchingLiveModels] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null) const { data, isLoading, error } = useQuery({ queryKey: ['user', 'api-keys'], - queryFn: fetchByokKeys, + queryFn: loadKeys, }) - const providerModels = data?.providerModels ?? {} - - const handleProviderChange = (p: string) => { - setProvider(p) - setApiKey('') - setLiveModels([]) - setIsCustomModel(false) - setModel('') - setCustomModel('') - } - - // Triggered dynamically to fetch models when user enters/pastes their API key - const fetchLiveModels = async (p: string, key: string) => { - if (!p || !key || key.length < 8) return; - setIsFetchingLiveModels(true) - try { - const query = new URLSearchParams({ provider: p, key }) - const res = await fetch(`/api/user/api-keys/live-models?${query.toString()}`) - if (res.ok) { - const body = await res.json() - if (body.success && Array.isArray(body.models)) { - setLiveModels(body.models) - if (body.models.length > 0) { - setModel(body.models[0]) - setIsCustomModel(false) - } else { - setIsCustomModel(true) - } - return; - } - } - } catch (err) { - console.error('[fetchLiveModels] Failed:', err) - } finally { - setIsFetchingLiveModels(false) - } - - // Fallback if request fails - const fallbackList = providerModels[p] || [] - setLiveModels(fallbackList) - if (fallbackList.length > 0) { - setModel(fallbackList[0]) - setIsCustomModel(false) - } else { - setIsCustomModel(true) - } - } - const invalidate = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['user', 'api-keys'] }) queryClient.invalidateQueries({ queryKey: ['usage', 'current'] }) }, [queryClient]) + async function onProviderChange(p: string) { + setProvider(p) + setApiKey('') + setBaseUrl(getDefaultBaseUrl(p)) + setKeyOk(false) + setTestResult(null) + setModel('') + setModels([]) + if (MANUAL_MODEL_PROVIDERS.has(p)) return + setLoadingModels(true) + try { + const list = await fetchModelsFromServer(p) + setModels(list) + if (list.length > 0) setModel(list[0]) + } finally { setLoadingModels(false) } + } + + async function refreshModels(p: string, key: string, _baseUrl?: string) { + if (!p || key.length < 8 || MANUAL_MODEL_PROVIDERS.has(p)) return + const effectiveBaseUrl = _baseUrl ?? (NEEDS_BASE_URL.has(p) ? baseUrl : undefined) + setLoadingModels(true) + try { + const list = await fetchModelsFromServer(p, key, effectiveBaseUrl) + if (list.length > 0) { setModels(list); if (!model) setModel(list[0]) } + } finally { setLoadingModels(false) } + } + + async function verifyKey() { + if (!provider || apiKey.length < 8) return + if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return } + setVerifying(true) + setTestResult(null) + try { + const q = new URLSearchParams({ provider, key: apiKey }) + if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl) + const res = await fetch(`/api/user/api-keys/verify?${q}`) + const body = await res.json() + if (res.ok && body.valid) { + setKeyOk(true) + if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) } + toast.success('Clé API valide ✓') + } else { + setKeyOk(false) + toast.error(body.message ?? 'Clé API invalide') + } + } catch { setKeyOk(false); toast.error('Erreur de vérification') } + finally { setVerifying(false) } + } + + async function testModel() { + if (!provider || apiKey.length < 8 || !model) return + setTesting(true) + setTestResult(null) + try { + const q = new URLSearchParams({ provider, key: apiKey, model }) + if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl) + const res = await fetch(`/api/user/api-keys/test-model?${q}`) + setTestResult(await res.json()) + } catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) } + finally { setTesting(false) } + } + const saveMutation = useMutation({ mutationFn: async () => { - const resolvedModel = isCustomModel ? customModel : model + const payload: Record = { + provider, apiKey, + model: model || undefined, + alias: alias.trim() || undefined, + } + if (NEEDS_BASE_URL.has(provider)) payload.baseUrl = baseUrl const res = await fetch('/api/user/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider, - apiKey, - alias: alias || undefined, - model: resolvedModel || undefined, - }), + body: JSON.stringify(payload), }) const body = await res.json().catch(() => ({})) - if (!res.ok) { - throw new Error(body.message || body.error || 'Save failed') - } - return body + if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec') }, onSuccess: () => { - toast.success(t('byokSettings.saved')) - setApiKey('') - setAlias('') - setProvider('') - setModel('') - setCustomModel('') - setLiveModels([]) - setIsCustomModel(false) + toast.success(`Clé ${displayName(provider)} enregistrée ✓`) + setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('') + setModel(''); setModels([]); setKeyOk(false); setTestResult(null) invalidate() }, - onError: (err: Error) => { - toast.error(err.message || t('byokSettings.error')) - }, + onError: (err: Error) => toast.error(err.message), }) const toggleMutation = useMutation({ - mutationFn: async ({ - provider: p, - isActive, - }: { - provider: string - isActive: boolean - }) => { + mutationFn: async ({ p, isActive }: { p: string; isActive: boolean }) => { const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive }), }) - if (!res.ok) throw new Error('Update failed') + if (!res.ok) throw new Error('Erreur') }, onSuccess: invalidate, onError: () => toast.error(t('byokSettings.error')), @@ -171,246 +377,238 @@ export function ByokSettingsPanel() { const deleteMutation = useMutation({ mutationFn: async (p: string) => { - const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { - method: 'DELETE', - }) - if (!res.ok) throw new Error('Delete failed') - }, - onSuccess: () => { - toast.success(t('byokSettings.deleted')) - invalidate() + const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Erreur') }, + onSuccess: () => { toast.success('Clé supprimée'); invalidate() }, onError: () => toast.error(t('byokSettings.error')), }) const allowed = data?.allowedProviders ?? [] const keys = data?.keys ?? [] - const hasActiveByok = keys.some((k) => k.isActive) + const activeKey = keys.find((k) => k.isActive) const tierBlocked = !isLoading && allowed.length === 0 + const saveDisabled = !provider || apiKey.length < 8 || saveMutation.isPending || (NEEDS_BASE_URL.has(provider) && baseUrl.length < 4) + const canTest = provider && apiKey.length >= 8 && model.length > 0 + const showModelDropdown = !MANUAL_MODEL_PROVIDERS.has(provider) && models.length > 0 + const showModelInput = !!provider && (MANUAL_MODEL_PROVIDERS.has(provider) || (!loadingModels && models.length === 0)) - if (isLoading) { - return ( -
- - {t('byokSettings.loading')} -
- ) - } - - if (error) { - return ( -

{t('byokSettings.loadError')}

- ) - } + if (isLoading) return
{t('byokSettings.loading')}
+ if (error) return

{t('byokSettings.loadError')}

return ( -
+
+ + {/* Header */}
-
- -
-
-

- {t('byokSettings.title')} - {hasActiveByok && ( - - - {t('byokSettings.badgeActive')} - - )} -

+
+
+

{t('byokSettings.title')}

{t('byokSettings.description')}

- {tierBlocked ? ( -

- {t('byokSettings.tierRequired')} -

- ) : ( -
-
-
- - -
-
- - setAlias(e.target.value)} - placeholder={t('byokSettings.aliasPlaceholder')} - disabled={saveMutation.isPending} - /> -
-
- - {/* Model Selection Row */} - {provider && ( -
-
- - {isFetchingLiveModels ? ( -
- - Récupération de vos modèles disponibles... -
- ) : liveModels && liveModels.length > 0 ? ( - - ) : ( -
- Entrez votre clé API ci-dessous pour charger vos modèles. -
- )} -
- - {(isCustomModel || (!isFetchingLiveModels && !(liveModels && liveModels.length > 0))) && ( -
- - { - if (isCustomModel) { - setCustomModel(e.target.value) - } else { - setModel(e.target.value) - } - }} - placeholder="ex. deepseek-reasoner, minimax-abab6.5" - disabled={saveMutation.isPending} - /> -
- )} -
+ {/* BYOK Status */} + {!tierBlocked && ( +
+ {activeKey ? ( + <>BYOK actif · {displayName(activeKey.provider)}{activeKey.model && <> · {activeKey.model}}{activeKey.alias && <> · {activeKey.alias}} + ) : ( + <>Aucune clé active — utilisation des quotas Momento )} -
- - { - const val = e.target.value - setApiKey(val) - fetchLiveModels(provider, val) - }} - placeholder={t('byokSettings.apiKeyPlaceholder')} - disabled={saveMutation.isPending} - /> -
-
)} - {keys.length > 0 && ( -
    - {keys.map((key) => ( -
  • -
    -
    {providerLabel(t, key.provider)}
    -
    - {key.alias ? ( -

    {key.alias}

    - ) : null} - {key.model ? ( -

    Modèle : {key.model}

    - ) : null} -
    + {tierBlocked ? ( +

    {t('byokSettings.tierRequired')}

    + ) : ( + <> + {/* Saved keys */} + {keys.length > 0 && ( +
    +

    Clés enregistrées

    +
      + {keys.map((key) => ( +
    • +
      +
      +
      +
      +
      {displayName(key.provider)}
      +
      + {key.model && {key.model}} + {key.alias && {key.alias}} + + {key.isActive ? '● Actif' : '○ Inactif'} + +
      +
      +
      +
      + {/* Edit button */} + + {/* Toggle */} +
      + + {/* Inline edit form */} + {editingProvider === key.provider && ( +
      + setEditingProvider(null)} + onInvalidate={invalidate} + /> +
      + )} +
    • + ))} +
    +
    + )} + + {/* Add key form */} +
    +

    + {keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'} +

    + +
    +
    + +
    -
    -
    + + {NEEDS_BASE_URL.has(provider) && ( +
    + + setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} /> +
    + )} + +
    + +
    +
    + { const val = e.target.value; setApiKey(val); setKeyOk(false); setTestResult(null); refreshModels(provider, val, baseUrl) }} + placeholder="sk-..." disabled={saveMutation.isPending} + className={cn(keyOk && 'border-emerald-500/60')} /> -
    - + {keyOk && } +
    -
  • - ))} -
- )} +
- {!tierBlocked && keys.length === 0 && ( -

{t('byokSettings.empty')}

+ {provider && ( +
+ + {loadingModels ? ( +
Récupération des modèles...
+ ) : showModelDropdown ? ( + + ) : showModelInput ? ( + { setModel(e.target.value); setTestResult(null) }} placeholder="ex. gpt-4o-mini, MiniMax-M2.7" disabled={saveMutation.isPending} /> + ) : null} +
+ )} + + {testResult && ( +
+ {testResult.ok ? : } +
+ {testResult.ok ? ( + <>

Modèle opérationnel ✓{testResult.latency && ({testResult.latency}ms)}

{testResult.reply &&

Réponse : {testResult.reply}

} + ) :

{testResult.error ?? 'Échec du test'}

} +
+
+ )} + +
+ + +
+
+ + {keys.length === 0 &&

{t('byokSettings.empty')}

} + )}
) diff --git a/memento-note/components/block-action-menu.tsx b/memento-note/components/block-action-menu.tsx index d930f4e..8856608 100644 --- a/memento-note/components/block-action-menu.tsx +++ b/memento-note/components/block-action-menu.tsx @@ -13,7 +13,7 @@ import { Trash2, Copy, Repeat, Link, ChevronRight, Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Quote, CodeXml, Database, - ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, + ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles, } from 'lucide-react' import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension' @@ -80,7 +80,7 @@ function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNod const from = blockPos + 1 const to = blockPos + node.nodeSize - 1 if (to > from) { - return editor.state.doc.textBetween(from, to, '\n', '\0').trim() + return editor.state.doc.textBetween(from, to, '\n', '').trim() } return node.textContent?.trim() ?? '' } @@ -208,6 +208,63 @@ export function BlockActionMenu({ onClose() }, [blockNode, blockPos, editor, noteId, onBlockReferenceCopied, onClose, sourceNoteTitle, t]) + const handleCreateDiagram = useCallback(async () => { + const text = getBlockPlainContent(editor, blockPos, blockNode) + if (!text || text.trim().length < 5) { + toast.error(t('blockAction.createDiagramEmpty') || "Le texte est trop court pour générer un diagramme.") + onClose() + return + } + + onClose() + const toastId = toast.loading(t('blockAction.createDiagramLoading') || "Génération du diagramme Excalidraw par l'IA...") + + try { + const { generateDiagramFromText } = await import('@/app/actions/diagram') + const res = await generateDiagramFromText(text) + + if (!res.success || !res.canvasId) { + throw new Error(res.error || "La génération du diagramme a échoué.") + } + + const canvasId = res.canvasId + + const canvasRes = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`) + const canvasData = await canvasRes.json() + if (!canvasRes.ok || !canvasData.canvas?.data) { + throw new Error("Impossible de charger les données du diagramme généré.") + } + + const { exportExcalidrawSceneToPngBlob } = await import('@/lib/client/excalidraw-export-image') + const blob = await exportExcalidrawSceneToPngBlob(canvasData.canvas.data) + if (!blob) { + throw new Error("Échec du rendu du diagramme en image PNG.") + } + + const fd = new FormData() + fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`) + const up = await fetch('/api/upload', { method: 'POST', body: fd }) + const upJson = await up.json() + if (!up.ok || !upJson.url) { + throw new Error("Échec du téléversement du diagramme généré.") + } + + const imageUrl = upJson.url + + const insertPos = blockPos + (blockNode ? blockNode.nodeSize : 0) + const htmlToInsert = `

Diagramme Excalidraw

🎨 ${t('blockAction.createDiagramSuccess') || "Éditer le diagramme dans le Lab Excalidraw"}

`.trim(); + + editor.chain().focus().insertContentAt(insertPos, htmlToInsert).run() + + toast.success(t('blockAction.createDiagramSuccess') || "Diagramme généré et inséré avec succès !", { id: toastId }) + + } catch (err: any) { + console.error('[handleCreateDiagram] Error:', err) + toast.error(err.message || "Une erreur est survenue lors de la génération.", { id: toastId }) + } + }, [editor, blockPos, blockNode, onClose, t]) + + const handleTurnInto = useCallback((option: TurnIntoOption) => { if (blockPos >= 0 && blockNode) { if (option.isDatabase) { @@ -312,6 +369,14 @@ export function BlockActionMenu({
+ {/* Création de diagramme */} + + +
+ {/* Copier */} - diff --git a/memento-note/components/note-editor/types.ts b/memento-note/components/note-editor/types.ts index fd96406..0d7d467 100644 --- a/memento-note/components/note-editor/types.ts +++ b/memento-note/components/note-editor/types.ts @@ -20,6 +20,7 @@ export interface NoteEditorState { isSaving: boolean isDirty: boolean lastSavedAt: Date | null + autoSaveEnabled: boolean isProcessingAI: boolean aiOpen: boolean @@ -105,8 +106,8 @@ export interface NoteEditorActions { handleImproveDirect: () => Promise handleTransformMarkdown: () => Promise - handleSave: () => Promise - handleSaveInPlace: () => Promise + handleSave: (opts?: { silent?: boolean }) => Promise + handleSaveInPlace: (opts?: { silent?: boolean }) => Promise handleMakeCopy: () => Promise setComparisonNotes: (notes: Array>) => void @@ -122,6 +123,7 @@ export interface NoteEditorActions { setIsAnalyzingSuggestions: (analyzing: boolean) => void setPreviousContentForCopilot: (content: string | null) => void setQuotaExceededFeature: (feature: string | null) => void + toggleAutoSave: () => void } export interface NoteEditorContextValue { diff --git a/memento-note/components/notes-list-views.tsx b/memento-note/components/notes-list-views.tsx index ab4ba63..26cd5cd 100644 --- a/memento-note/components/notes-list-views.tsx +++ b/memento-note/components/notes-list-views.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState, useEffect, useCallback } from 'react' +import { useMemo, useState, useEffect, useCallback, memo } from 'react' import { DndContext, DragOverlay, @@ -103,27 +103,46 @@ function NoteLabelsRow({ function NoteGridIllustrationButton({ busy, onClick, + onDelete, + hasIllustration, className, }: { busy: boolean onClick: (e: React.MouseEvent) => void + onDelete?: (e: React.MouseEvent) => void + hasIllustration?: boolean className?: string }) { const { t } = useLanguage() return ( - )} - onClick={onClick} - disabled={busy} - > - {busy ? : } - + +
) } @@ -131,10 +150,12 @@ function NoteGridThumbnail({ note, aiIllustrationEnabled, onNoteIllustrationGenerated, + onNoteIllustrationDeleted, }: { note: Note aiIllustrationEnabled?: boolean onNoteIllustrationGenerated?: (noteId: string) => void | Promise + onNoteIllustrationDeleted?: (noteId: string) => void | Promise }) { const { t } = useLanguage() const [busy, setBusy] = useState(false) @@ -142,7 +163,7 @@ function NoteGridThumbnail({ const handleGenerateSvg = async (e: React.MouseEvent) => { e.stopPropagation() - if (!aiIllustrationEnabled || busy || img) return + if (!aiIllustrationEnabled || busy) return setBusy(true) try { const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true }) @@ -157,7 +178,22 @@ function NoteGridThumbnail({ } } - const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100' + const handleDeleteSvg = async (e: React.MouseEvent) => { + e.stopPropagation() + if (busy) return + setBusy(true) + try { + const { updateNote } = await import('@/app/actions/notes') + await updateNote(note.id, { illustrationSvg: null }) + await onNoteIllustrationDeleted?.(note.id) + } catch { + toast.error('Erreur lors de la suppression') + } finally { + setBusy(false) + } + } + + const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100 transition-opacity duration-200' if (img) { return ( @@ -177,7 +213,13 @@ function NoteGridThumbnail({ aria-hidden /> {aiIllustrationEnabled && ( - + )} ) @@ -188,12 +230,17 @@ function NoteGridThumbnail({
{aiIllustrationEnabled && ( - + )} ) } + export type { NoteCollectionActions } from '@/lib/note-change-sync' type NotesListViewsProps = { @@ -454,6 +501,7 @@ type GridCardSharedProps = { onDeleteNote?: (note: Note) => void | Promise onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise onNoteIllustrationGenerated?: (noteId: string) => void | Promise + onNoteIllustrationDeleted?: (noteId: string) => void | Promise isOverlay?: boolean } @@ -623,7 +671,7 @@ function NotesGridSection({ ) } -function SortableGridCard(props: GridCardSharedProps) { +const SortableGridCard = memo(function SortableGridCard(props: GridCardSharedProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.note.id, }) @@ -647,9 +695,9 @@ function SortableGridCard(props: GridCardSharedProps) {
) -} +}) -function GridCard({ +const GridCard = memo(function GridCard({ note, index, untitled, @@ -661,6 +709,7 @@ function GridCard({ onDeleteNote, onMoveToNotebook, onNoteIllustrationGenerated, + onNoteIllustrationDeleted, isOverlay = false, }: GridCardSharedProps) { const router = useRouter() @@ -699,6 +748,7 @@ function GridCard({ note={note} aiIllustrationEnabled={aiIllustrationEnabled} onNoteIllustrationGenerated={onNoteIllustrationGenerated} + onNoteIllustrationDeleted={onNoteIllustrationDeleted} /> {note.isPinned && (
@@ -771,4 +821,4 @@ function GridCard({
) -} +}) diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index ff4f5b5..52ba730 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -352,7 +352,7 @@ export const RichTextEditor = forwardRef
diff --git a/memento-note/data/uploads/notes/1d017228-3c57-44ba-b7f6-0e20fe188bb3.png b/memento-note/data/uploads/notes/1d017228-3c57-44ba-b7f6-0e20fe188bb3.png new file mode 100644 index 0000000..eb98e28 Binary files /dev/null and b/memento-note/data/uploads/notes/1d017228-3c57-44ba-b7f6-0e20fe188bb3.png differ diff --git a/memento-note/data/uploads/notes/26e86a75-d9fd-4b3c-ad3a-7455a2562622.png b/memento-note/data/uploads/notes/26e86a75-d9fd-4b3c-ad3a-7455a2562622.png new file mode 100644 index 0000000..2e0f47c Binary files /dev/null and b/memento-note/data/uploads/notes/26e86a75-d9fd-4b3c-ad3a-7455a2562622.png differ diff --git a/memento-note/data/uploads/notes/7ae5d3f9-4e54-4439-93a7-59c8e0598bc7.png b/memento-note/data/uploads/notes/7ae5d3f9-4e54-4439-93a7-59c8e0598bc7.png new file mode 100644 index 0000000..a9981c9 Binary files /dev/null and b/memento-note/data/uploads/notes/7ae5d3f9-4e54-4439-93a7-59c8e0598bc7.png differ diff --git a/memento-note/data/uploads/notes/8ea9b9a2-aae3-4dc6-8b3d-c26d03590ee6.png b/memento-note/data/uploads/notes/8ea9b9a2-aae3-4dc6-8b3d-c26d03590ee6.png new file mode 100644 index 0000000..03018b1 Binary files /dev/null and b/memento-note/data/uploads/notes/8ea9b9a2-aae3-4dc6-8b3d-c26d03590ee6.png differ diff --git a/memento-note/data/uploads/notes/9bd36218-fd26-4dfd-b831-804998c87d54.png b/memento-note/data/uploads/notes/9bd36218-fd26-4dfd-b831-804998c87d54.png new file mode 100644 index 0000000..a7fd594 Binary files /dev/null and b/memento-note/data/uploads/notes/9bd36218-fd26-4dfd-b831-804998c87d54.png differ diff --git a/memento-note/data/uploads/notes/bdd4baff-8a62-4fda-8b84-9d77dd4b02ea.png b/memento-note/data/uploads/notes/bdd4baff-8a62-4fda-8b84-9d77dd4b02ea.png new file mode 100644 index 0000000..d77ac73 Binary files /dev/null and b/memento-note/data/uploads/notes/bdd4baff-8a62-4fda-8b84-9d77dd4b02ea.png differ diff --git a/memento-note/data/uploads/notes/efd67352-f026-42a4-b68c-b6dece44cd6d.png b/memento-note/data/uploads/notes/efd67352-f026-42a4-b68c-b6dece44cd6d.png new file mode 100644 index 0000000..6bdcfa1 Binary files /dev/null and b/memento-note/data/uploads/notes/efd67352-f026-42a4-b68c-b6dece44cd6d.png differ diff --git a/memento-note/data/uploads/notes/f40bb067-fe85-4051-ba28-78c59655ce98.png b/memento-note/data/uploads/notes/f40bb067-fe85-4051-ba28-78c59655ce98.png new file mode 100644 index 0000000..757d936 Binary files /dev/null and b/memento-note/data/uploads/notes/f40bb067-fe85-4051-ba28-78c59655ce98.png differ diff --git a/memento-note/extension/diagnose.js b/memento-note/extension/diagnose.js new file mode 100644 index 0000000..b98cdf8 --- /dev/null +++ b/memento-note/extension/diagnose.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Script de diagnostic pour l'extension Momento + * Vérifie tous les fichiers et identifie les problèmes potentiels + */ + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const extDir = __dirname + +console.log('🔍 Diagnostic Extension Momento\n') + +const issues = [] +const warnings = [] + +// Vérifier la syntaxe des fichiers JS +function checkSyntax(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8') + // Pas de vérification syntaxique simple en Node.js sans eval + // On vérifie juste que le fichier est lisible + return true + } catch (error) { + issues.push(`Fichier illisible: ${filePath} - ${error.message}`) + return false + } +} + +// Vérifier les event handlers inline dans le HTML +function checkInlineHandlers(htmlPath) { + try { + const content = fs.readFileSync(htmlPath, 'utf8') + const inlineHandlers = [] + + if (content.match(/onerror=/i)) inlineHandlers.push('onerror') + if (content.match(/onclick=/i)) inlineHandlers.push('onclick') + if (content.match(/onload=/i)) inlineHandlers.push('onload') + + if (inlineHandlers.length > 0) { + issues.push(`Event handlers inline trouvés dans ${htmlPath}: ${inlineHandlers.join(', ')}`) + } else { + console.log('✓ Pas d\'event handlers inline dans le HTML') + } + } catch (error) { + issues.push(`Impossible de lire ${htmlPath}: ${error.message}`) + } +} + +// Vérifier les fixes CSP dans sidepanel.js +function checkCSPFixes(jsPath) { + try { + const content = fs.readFileSync(jsPath, 'utf8') + + // Vérifier l'absence de onerror inline + if (content.match(/onerror=/)) { + issues.push('Event handler onerror trouvé dans sidepanel.js') + } else { + console.log('✓ Pas de onerror inline dans sidepanel.js') + } + + // Vérifier la présence du fix avec data-fallback + if (content.includes('data-fallback')) { + console.log('✓ Fix CSP data-favicon présent') + } else { + warnings.push('Fix CSP data-favicon可能缺失') + } + + // Vérifier le handler de favicon + if (content.includes("querySelector('.page-favicon')")) { + console.log('✓ Handler error pour favicon présent') + } else { + warnings.push('Handler error pour favicon可能缺失') + } + + } catch (error) { + issues.push(`Impossible de lire ${jsPath}: ${error.message}`) + } +} + +// Vérifier le fix pick mode +function checkPickModeFix(jsPath) { + try { + const content = fs.readFileSync(jsPath, 'utf8') + + // Vérifier le handler visibilitychange + if (content.includes('visibilityState === \'hidden\'')) { + console.log('✓ Handler visibilitychange pour hidden présent') + } else { + issues.push('Handler visibilitychange pour hidden manquant') + } + + // Vérifier l'appel à setPickModeOnTab(false) + if (content.match(/visibilityState === 'hidden'.*setPickModeOnTab\(false\)/s)) { + console.log('✓ Appel setPickModeOnTab(false) dans visibilitychange présent') + } else { + issues.push('Appel setPickModeOnTab(false) dans visibilitychange可能缺失') + } + + } catch (error) { + issues.push(`Impossible de lire ${jsPath}: ${error.message}`) + } +} + +// Vérifier le manifest +function checkManifest(manifestPath) { + try { + const content = fs.readFileSync(manifestPath, 'utf8') + const manifest = JSON.parse(content) + + console.log('✓ Manifest.json valide') + console.log(` Version: ${manifest.version}`) + console.log(` Permissions: ${manifest.permissions.join(', ')}`) + console.log(` Host permissions: ${manifest.host_permissions.length}`) + + } catch (error) { + issues.push(`Manifest.json invalide: ${error.message}`) + } +} + +// Exécuter les tests +console.log('📋 Vérification des fichiers...\n') + +checkSyntax(path.join(extDir, 'sidepanel.js')) +checkSyntax(path.join(extDir, 'content.js')) +checkSyntax(path.join(extDir, 'background.js')) + +console.log('\n🔒 Vérification CSP...\n') +checkInlineHandlers(path.join(extDir, 'sidepanel.html')) +checkCSPFixes(path.join(extDir, 'sidepanel.js')) + +console.log('\n🎯 Vérification fix pick mode...\n') +checkPickModeFix(path.join(extDir, 'sidepanel.js')) + +console.log('\n📦 Vérification manifest...\n') +checkManifest(path.join(extDir, 'manifest.json')) + +// Résumé +console.log('\n' + '='.repeat(50)) +if (issues.length === 0 && warnings.length === 0) { + console.log('✅ Aucun problème détecté !') +} else { + if (issues.length > 0) { + console.log('\n❌ Problèmes détectés:') + issues.forEach(issue => console.log(` • ${issue}`)) + } + if (warnings.length > 0) { + console.log('\n⚠️ Warnings:') + warnings.forEach(warning => console.log(` • ${warning}`)) + } +} + +console.log('\n📝 Instructions:') +console.log('1. Rechargez l\'extension dans chrome://extensions (bouton 🔄)') +console.log('2. Ouvrez une page web normale') +console.log('3. Cliquez sur l\'icône Momento') +console.log('4. Fermez le sidepanel - la bannière doit disparaître') +console.log('5. Ouvrez la console (F12) - pas d\'erreur CSP') diff --git a/memento-note/extension/dist-chrome-store/_locales/ar/messages.json b/memento-note/extension/dist-chrome-store/_locales/ar/messages.json new file mode 100644 index 0000000..237c0b2 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/ar/messages.json @@ -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": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/de/messages.json b/memento-note/extension/dist-chrome-store/_locales/de/messages.json new file mode 100644 index 0000000..6a327e0 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/de/messages.json @@ -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." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/en/messages.json b/memento-note/extension/dist-chrome-store/_locales/en/messages.json new file mode 100644 index 0000000..2ed5e41 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/en/messages.json @@ -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." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/es/messages.json b/memento-note/extension/dist-chrome-store/_locales/es/messages.json new file mode 100644 index 0000000..f15ac4c --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/es/messages.json @@ -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 <<>>" + }, + "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. <<>> 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." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/fa/messages.json b/memento-note/extension/dist-chrome-store/_locales/fa/messages.json new file mode 100644 index 0000000..643f214 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/fa/messages.json @@ -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": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/fr/messages.json b/memento-note/extension/dist-chrome-store/_locales/fr/messages.json new file mode 100644 index 0000000..161606c --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/fr/messages.json @@ -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" + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/hi/messages.json b/memento-note/extension/dist-chrome-store/_locales/hi/messages.json new file mode 100644 index 0000000..d6ba3f1 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/hi/messages.json @@ -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": "पृष्ठ पर टेक्स्ट को हाइलाइट करें, या पूरे पृष्ठ को क्लिप करें।" + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/it/messages.json b/memento-note/extension/dist-chrome-store/_locales/it/messages.json new file mode 100644 index 0000000..21a39cd --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/it/messages.json @@ -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 <<>>" + }, + "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." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/ja/messages.json b/memento-note/extension/dist-chrome-store/_locales/ja/messages.json new file mode 100644 index 0000000..2ac2017 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/ja/messages.json @@ -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": "ページ上のテキストを強調表示するか、ページ全体をクリップします。" + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/ko/messages.json b/memento-note/extension/dist-chrome-store/_locales/ko/messages.json new file mode 100644 index 0000000..4a43e6e --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/ko/messages.json @@ -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": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/nl/messages.json b/memento-note/extension/dist-chrome-store/_locales/nl/messages.json new file mode 100644 index 0000000..9d4b50c --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/nl/messages.json @@ -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 <<>>" + }, + "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." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/pl/messages.json b/memento-note/extension/dist-chrome-store/_locales/pl/messages.json new file mode 100644 index 0000000..138dbb2 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/pl/messages.json @@ -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 <<>>" + }, + "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ę." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/pt/messages.json b/memento-note/extension/dist-chrome-store/_locales/pt/messages.json new file mode 100644 index 0000000..bd2d72a --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/pt/messages.json @@ -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 <<>>" + }, + "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." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/ru/messages.json b/memento-note/extension/dist-chrome-store/_locales/ru/messages.json new file mode 100644 index 0000000..29dd72e --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/ru/messages.json @@ -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": "Выделите текст на странице или вырежьте всю страницу." + } +} diff --git a/memento-note/extension/dist-chrome-store/_locales/zh/messages.json b/memento-note/extension/dist-chrome-store/_locales/zh/messages.json new file mode 100644 index 0000000..f25d770 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/_locales/zh/messages.json @@ -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(或 LAN)URL。此浏览器中的 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": "突出显示页面上的文本,或剪辑整个页面。" + } +} diff --git a/memento-note/extension/dist-chrome-store/background.js b/memento-note/extension/dist-chrome-store/background.js new file mode 100644 index 0000000..ffa000c --- /dev/null +++ b/memento-note/extension/dist-chrome-store/background.js @@ -0,0 +1,8 @@ +/** Service worker — ouvre le panneau latéral au clic sur l’icône. */ +chrome.runtime.onInstalled.addListener(() => { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}) +}) + +chrome.runtime.onStartup.addListener(() => { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}) +}) diff --git a/memento-note/extension/dist-chrome-store/content.js b/memento-note/extension/dist-chrome-store/content.js new file mode 100644 index 0000000..6ad75d4 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/content.js @@ -0,0 +1,211 @@ +/** + * Content script Momento — sélection live, surlignage, communication avec le side panel. + * Injecté automatiquement sur http(s) ; ré-injecté à la demande si l’onglet était déjà ouvert. + */ +;(function initMementoClipperContent() { + if (globalThis.__mementoClipperContent) return + globalThis.__mementoClipperContent = true + + const HIGHLIGHT_ID = 'memento-clipper-highlight-root' + const BANNER_ID = 'memento-clipper-banner-root' + const STYLE_ID = 'memento-clipper-styles' + + let pickMode = false + let debounceTimer = null + + function getSelectionText() { + return window.getSelection()?.toString().trim() || '' + } + + function getPageMeta() { + const dir = + document.documentElement.getAttribute('dir') || + document.body?.getAttribute('dir') || + '' + const lang = ( + document.documentElement.getAttribute('lang') || + document.body?.getAttribute('lang') || + '' + ).split('-')[0] + return { + text: getSelectionText(), + dir, + lang, + url: location.href, + title: document.title, + } + } + + function broadcastSelection() { + clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() } + try { + chrome.runtime.sendMessage(payload).catch(() => {}) + } catch { + /* ignore */ + } + if (pickMode) paintHighlight() + }, 80) + } + + function removeHighlight() { + document.getElementById(HIGHLIGHT_ID)?.remove() + } + + function paintHighlight() { + removeHighlight() + const sel = window.getSelection() + if (!sel || sel.isCollapsed || !sel.rangeCount) return + + let range + try { + range = sel.getRangeAt(0) + } catch { + return + } + + const host = document.createElement('div') + host.id = HIGHLIGHT_ID + host.setAttribute('aria-hidden', 'true') + host.style.cssText = + 'position:fixed;inset:0;pointer-events:none;z-index:2147483644;overflow:hidden;' + + for (const rect of range.getClientRects()) { + if (rect.width < 2 || rect.height < 2) continue + const box = document.createElement('div') + box.style.cssText = [ + 'position:fixed', + `left:${rect.left - 2}px`, + `top:${rect.top - 1}px`, + `width:${rect.width + 4}px`, + `height:${rect.height + 2}px`, + 'background:rgba(164,113,72,0.28)', + 'border-radius:3px', + 'box-shadow:0 0 0 1px rgba(164,113,72,0.35)', + 'transition:opacity 0.15s ease', + ].join(';') + host.appendChild(box) + } + + if (host.childNodes.length) document.documentElement.appendChild(host) + } + + function ensureStyles() { + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = ` + html.memento-clipper-pick ::selection { + background: rgba(164, 113, 72, 0.45) !important; + color: inherit !important; + } + html.memento-clipper-pick { + scroll-behavior: auto; + } + ` + document.documentElement.appendChild(style) + } + + function removeBanner() { + document.getElementById(BANNER_ID)?.remove() + } + + 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 = + 'all:initial;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:none;font-family:Inter,system-ui,sans-serif;' + + const shadow = host.attachShadow({ mode: 'open' }) + shadow.innerHTML = ` + +
+ + + ${bannerText.replace(/ +
+ ` + document.documentElement.appendChild(host) + } + + function setPickMode(enabled) { + pickMode = !!enabled + ensureStyles() + if (pickMode) { + document.documentElement.classList.add('memento-clipper-pick') + ensureBanner() + paintHighlight() + } else { + document.documentElement.classList.remove('memento-clipper-pick') + removeBanner() + removeHighlight() + } + } + + function onScrollOrResize() { + if (pickMode) paintHighlight() + } + + document.addEventListener('selectionchange', broadcastSelection) + document.addEventListener('mouseup', broadcastSelection) + document.addEventListener('keyup', broadcastSelection) + window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }) + window.addEventListener('resize', onScrollOrResize, { passive: true }) + + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === 'PING') { + sendResponse({ ok: true }) + return true + } + if (message?.type === 'GET_CONTEXT') { + sendResponse({ + html: document.documentElement.outerHTML, + ...getPageMeta(), + }) + return true + } + if (message?.type === 'SET_PICK_MODE') { + setPickMode(!!message.enabled) + sendResponse({ ok: true, pickMode }) + return true + } + return false + }) + + broadcastSelection() +})() diff --git a/memento-note/extension/dist-chrome-store/i18n.js b/memento-note/extension/dist-chrome-store/i18n.js new file mode 100644 index 0000000..2d694a1 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/i18n.js @@ -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') +} diff --git a/memento-note/extension/dist-chrome-store/i18n/generate-translations.cjs b/memento-note/extension/dist-chrome-store/i18n/generate-translations.cjs new file mode 100644 index 0000000..9ea8178 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/i18n/generate-translations.cjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-require-imports */ +'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() diff --git a/memento-note/extension/dist-chrome-store/i18n/translations.json b/memento-note/extension/dist-chrome-store/i18n/translations.json new file mode 100644 index 0000000..8158386 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/i18n/translations.json @@ -0,0 +1,2735 @@ +{ + "version": "0.3.1", + "strings": { + "en": { + "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 {version}" + }, + "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." + } + }, + "fr": { + "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": "Tondeuse Web" + }, + "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 {version}" + }, + "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 découper 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 blocs-notes. 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": "Dos" + }, + "analyzingSource": { + "message": "Analyse de la source" + }, + "statusAnalyzing": { + "message": "Analyser…" + }, + "statusSaving": { + "message": "Économie…" + }, + "processingDetail": { + "message": "Génération automatique des tags, résumé sémantique et calcul des embeddings." + }, + "noteSaved": { + "message": "Remarque 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 le texte ou coupez 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": "Mettez en surbrillance le texte sur la page ou coupez la page entière." + } + }, + "de": { + "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 {version}" + }, + "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." + } + }, + "es": { + "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 <<>>" + }, + "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. <<>> 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." + } + }, + "it": { + "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 <<>>" + }, + "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." + } + }, + "pt": { + "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 <<>>" + }, + "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." + } + }, + "nl": { + "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 <<>>" + }, + "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." + } + }, + "pl": { + "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 <<>>" + }, + "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ę." + } + }, + "ru": { + "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": "Выделите текст на странице или вырежьте всю страницу." + } + }, + "zh": { + "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(或 LAN)URL。此浏览器中的 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": "突出显示页面上的文本,或剪辑整个页面。" + } + }, + "ja": { + "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": "ページ上のテキストを強調表示するか、ページ全体をクリップします。" + } + }, + "ko": { + "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": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다." + } + }, + "ar": { + "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": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها." + } + }, + "fa": { + "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 {version}" + }, + "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": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید." + } + }, + "hi": { + "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": "पृष्ठ पर टेक्स्ट को हाइलाइट करें, या पूरे पृष्ठ को क्लिप करें।" + } + } + } +} diff --git a/memento-note/extension/dist-chrome-store/icon-128.png b/memento-note/extension/dist-chrome-store/icon-128.png new file mode 100644 index 0000000..141df99 Binary files /dev/null and b/memento-note/extension/dist-chrome-store/icon-128.png differ diff --git a/memento-note/extension/dist-chrome-store/icon-16.png b/memento-note/extension/dist-chrome-store/icon-16.png new file mode 100644 index 0000000..c73d4b7 Binary files /dev/null and b/memento-note/extension/dist-chrome-store/icon-16.png differ diff --git a/memento-note/extension/dist-chrome-store/icon-192.svg b/memento-note/extension/dist-chrome-store/icon-192.svg new file mode 100644 index 0000000..128d4f5 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/icon-192.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/memento-note/extension/dist-chrome-store/icon-48.png b/memento-note/extension/dist-chrome-store/icon-48.png new file mode 100644 index 0000000..34bceb8 Binary files /dev/null and b/memento-note/extension/dist-chrome-store/icon-48.png differ diff --git a/memento-note/extension/dist-chrome-store/icon-512.svg b/memento-note/extension/dist-chrome-store/icon-512.svg new file mode 100644 index 0000000..20698f1 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/icon-512.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/memento-note/extension/dist-chrome-store/manifest.json b/memento-note/extension/dist-chrome-store/manifest.json new file mode 100644 index 0000000..54d9395 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/manifest.json @@ -0,0 +1,51 @@ +{ + "manifest_version": 3, + "name": "__MSG_extName__", + "version": "0.3.1", + "description": "__MSG_extDescription__", + "default_locale": "en", + "permissions": [ + "activeTab", + "scripting", + "storage", + "sidePanel", + "tabs" + ], + "host_permissions": [ + "https://memento-note.com/*", + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js" + }, + "side_panel": { + "default_path": "sidepanel.html" + }, + "content_scripts": [ + { + "matches": [ + "http://*/*", + "https://*/*" + ], + "js": [ + "content.js" + ], + "run_at": "document_idle", + "all_frames": false + } + ], + "action": { + "default_title": "__MSG_extActionTitle__", + "default_icon": { + "16": "icon-16.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + }, + "icons": { + "16": "icon-16.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} \ No newline at end of file diff --git a/memento-note/extension/dist-chrome-store/memento-web-clipper-chrome-store.zip b/memento-note/extension/dist-chrome-store/memento-web-clipper-chrome-store.zip new file mode 100644 index 0000000..4973318 Binary files /dev/null and b/memento-note/extension/dist-chrome-store/memento-web-clipper-chrome-store.zip differ diff --git a/memento-note/extension/dist-chrome-store/sidepanel.css b/memento-note/extension/dist-chrome-store/sidepanel.css new file mode 100644 index 0000000..7f85205 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/sidepanel.css @@ -0,0 +1,507 @@ +:root { + --ink: #1c1c1c; + --paper: #faf9f5; + --card: #ffffff; + --muted: #6b7280; + --border: #e8e4dc; + --accent: #a47148; + --accent-soft: rgba(164, 113, 72, 0.12); + --accent-glow: rgba(164, 113, 72, 0.35); + --success: #10b981; + --danger: #ef4444; + --shadow: 0 18px 40px rgba(28, 28, 28, 0.08); + --radius: 14px; + --radius-sm: 10px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-size: 13px; + color: var(--ink); + background: var(--paper); +} + +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--paper); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + background: linear-gradient(180deg, #fff 0%, #fcfcfa 100%); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { display: flex; align-items: center; gap: 10px; } +.brand-logo { + width: 34px; height: 34px; border-radius: 11px; + background: var(--ink); color: #faf9f5; + display: flex; align-items: center; justify-content: center; + font-family: Georgia, 'Times New Roman', serif; + font-weight: 900; font-size: 16px; + box-shadow: 0 4px 14px rgba(28, 28, 28, 0.18); +} +.brand-text { line-height: 1.1; } +.brand-name { + display: block; font-size: 14px; font-weight: 700; + font-family: Georgia, 'Times New Roman', serif; +} +.brand-sub { + display: block; font-size: 9px; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--accent); font-weight: 700; +} + +.icon-btn { + width: 34px; height: 34px; border-radius: 10px; + border: 1px solid var(--border); background: #fff; + color: var(--muted); cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.icon-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} + +.header-right { + display: flex; + align-items: center; + gap: 10px; +} + +.conn-badge { + display: flex; + align-items: center; + gap: 6px; + font-size: 9px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); +} +.conn-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #10b981; +} + +.settings-panel { + padding: 14px 18px; + background: #fff; + border-bottom: 1px solid var(--border); +} +.settings-panel[hidden] { display: none !important; } +.settings-hint { + margin: 8px 0 0; + font-size: 11px; + color: var(--muted); + line-height: 1.5; +} +.settings-hint code { + font-size: 10px; + background: var(--paper); + padding: 1px 4px; + border-radius: 4px; +} +.settings-status { + margin: 10px 0 0; + font-size: 11px; + line-height: 1.45; +} +.settings-status.is-ok { color: #059669; } +.settings-status.is-error { color: #dc2626; } + +.preset-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} +.preset-btn { + border: 1px solid var(--border); + background: var(--paper); + border-radius: 999px; + padding: 6px 10px; + font-size: 10px; + font-weight: 700; + cursor: pointer; + color: var(--muted); +} +.preset-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.field { display: flex; flex-direction: column; gap: 6px; } +.field span { + font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; + color: var(--muted); font-weight: 700; +} +input[type="url"], +input[type="text"], +.notebook-select { + width: 100%; padding: 10px 12px; border: 1px solid var(--border); + border-radius: var(--radius-sm); background: var(--paper); + font-family: inherit; font-size: 12px; +} +input[type="url"]:focus, +input[type="text"]:focus, +.notebook-select:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.notebook-select { + background: #fff; + font-weight: 600; + cursor: pointer; +} + +.main { + flex: 1; + padding: 16px 18px 20px; + display: flex; + flex-direction: column; + gap: 14px; + min-height: 0; +} + +.main > .actions { + margin-top: auto; +} + +.footer { + padding: 10px 18px 14px; + border-top: 1px solid var(--border); + background: #fff; + text-align: center; +} +.footer-meta { font-size: 9px; color: #9ca3af; letter-spacing: 0.06em; } + +.label { + font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em; + color: var(--muted); font-weight: 700; margin-bottom: 8px; display: block; +} + +.auth-hint { + padding: 12px 14px; + border-radius: var(--radius); + background: #fffbeb; + border: 1px solid #fde68a; + font-size: 11px; + color: #92400e; + line-height: 1.5; +} + +.page-card { + padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); + background: #fff; + box-shadow: 0 1px 0 rgba(255,255,255,0.8) inset; +} +.page-card .sub { + font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em; + color: var(--muted); font-weight: 700; display: block; margin-bottom: 8px; +} +.page-row { display: flex; gap: 10px; align-items: flex-start; min-width: 0; } +.page-row img { + width: 20px; height: 20px; border-radius: 5px; + flex-shrink: 0; margin-top: 2px; +} +.page-text { min-width: 0; flex: 1; } +.page-row .title { + font-size: 12px; font-weight: 700; line-height: 1.45; + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; + unicode-bidi: plaintext; +} +.page-row .url { + font-size: 10px; color: var(--muted); margin-top: 4px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + direction: ltr; text-align: left; +} + +.text-rtl { + direction: rtl; + text-align: right; + font-family: 'Vazirmatn', 'Inter', sans-serif; + unicode-bidi: plaintext; +} + +.selection-panel { + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + overflow: hidden; + min-height: 140px; + display: flex; + flex-direction: column; +} + +.selection-panel.has-text { + border-color: #bae6fd; + background: rgba(14, 165, 233, 0.05); + box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12); +} +.selection-hint { + padding: 16px; + border: 1px dashed var(--border); + border-radius: var(--radius); + text-align: center; + background: #fff; +} +.selection-hint p { + margin: 0; + font-size: 11px; + color: var(--muted); + line-height: 1.55; +} + +.selection-head { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 14px; + background: linear-gradient(180deg, #fff 0%, #fdfcfa 100%); + border-bottom: 1px solid var(--border); +} +.selection-head .status { + display: flex; align-items: center; gap: 8px; + font-size: 10px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.08em; color: var(--muted); +} +.selection-head .status.live { color: #0284c7; } +.selection-head .count { + font-size: 10px; font-weight: 700; color: var(--muted); + background: var(--paper); padding: 4px 8px; border-radius: 999px; +} +.selection-head .count.active { color: var(--accent); background: var(--accent-soft); } + +.selection-body { + flex: 1; + padding: 14px; + font-size: 13px; + line-height: 1.75; + color: rgba(28, 28, 28, 0.88); + max-height: 220px; + overflow-y: auto; + unicode-bidi: plaintext; + border-inline-start: 3px solid transparent; +} +.selection-panel.has-text .selection-body { + border-inline-start-color: #38bdf8; + padding-inline-start: 16px; + font-style: italic; + font-size: 12px; + max-height: 150px; +} + +.pulse-dot { + width: 7px; height: 7px; border-radius: 50%; background: var(--accent); + animation: pulse 1.4s ease infinite; +} +.pulse-dot.sky { background: #0ea5e9; } +@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.85)} } + +.clear-btn { + border: none; background: none; font-size: 10px; + color: var(--muted); cursor: pointer; font-weight: 600; + padding: 4px 6px; border-radius: 6px; +} +.clear-btn:hover { color: var(--ink); background: var(--paper); } + +.actions { + display: flex; flex-direction: column; gap: 10px; + margin-top: auto; padding-top: 6px; +} +.btn { + padding: 14px 16px; border-radius: var(--radius); border: none; cursor: pointer; + font-weight: 700; font-size: 10px; text-transform: uppercase; + letter-spacing: 0.1em; + display: flex; align-items: center; justify-content: center; gap: 8px; + transition: transform 0.12s ease, opacity 0.12s ease, filter 0.12s ease; +} +.btn:active { transform: scale(0.98); } +.btn:disabled { + opacity: 0.42; cursor: not-allowed; transform: none; + box-shadow: none !important; +} +.btn-primary { + background: var(--ink); color: #fff; + box-shadow: 0 10px 24px rgba(28, 28, 28, 0.18); +} +.btn-primary:hover:not(:disabled) { opacity: 0.94; } +.btn-sky { + background: #0284c7; + color: #fff; + box-shadow: 0 10px 22px rgba(2, 132, 199, 0.22); +} +.btn-sky:hover:not(:disabled) { background: #0369a1; } +.btn-secondary { + background: #f3f4f6; + color: #374151; + box-shadow: none; +} +.btn-secondary:hover:not(:disabled) { background: #e5e7eb; } +.btn-sm { + padding: 10px 12px; + font-size: 10px; +} +.btn-danger { background: var(--danger); color: #fff; } +.btn-link.link-only { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.btn-link { + background: none; border: none; color: var(--muted); font-size: 11px; + cursor: pointer; padding: 8px; font-weight: 500; +} +.btn-link:hover { color: var(--ink); text-decoration: underline; } +.btn-icon { width: 14px; height: 14px; display: inline-flex; } + +.center-state { + flex: 1; display: flex; flex-direction: column; align-items: center; + justify-content: center; text-align: center; gap: 14px; padding: 32px 12px; + min-height: 280px; +} +.spinner-wrap { position: relative; width: 52px; height: 52px; } +.spinner-ring { + position: absolute; inset: 0; border-radius: 50%; + border: 1px solid var(--border); animation: ping 1.2s ease infinite; +} +@keyframes ping { 0%{transform:scale(1);opacity:.6} 100%{transform:scale(1.35);opacity:0} } +.spinner { + position: absolute; inset: 6px; + border: 3px solid var(--border); border-top-color: var(--accent); + border-radius: 50%; animation: spin 0.75s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.state-title { + font-size: 10px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.14em; color: var(--muted); +} +.state-sub { font-size: 15px; font-weight: 700; color: var(--ink); } +.state-detail { + font-size: 11px; color: var(--muted); max-width: 280px; + line-height: 1.55; margin: 0 auto; +} + +.success-icon, .error-icon { + width: 58px; height: 58px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 26px; font-weight: 700; +} +.success-icon { + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.25); color: var(--success); +} +.error-icon { background: #fef2f2; color: var(--danger); } + +.badge-ok { + display: inline-block; margin-bottom: 8px; + font-size: 9px; background: rgba(16, 185, 129, 0.12); + color: #059669; font-weight: 800; padding: 3px 8px; border-radius: 6px; + text-transform: uppercase; letter-spacing: 0.1em; +} +.note-title { + font-size: 15px; font-weight: 700; + font-family: Georgia, 'Times New Roman', serif; + line-height: 1.35; margin-top: 6px; + unicode-bidi: plaintext; +} +.tags { + display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; + padding-top: 14px; border-top: 1px solid var(--border); margin-top: 10px; + width: 100%; +} +.tag-chip { + font-size: 9px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--accent); + background: var(--accent-soft); border: 1px solid rgba(164, 113, 72, 0.2); + padding: 5px 10px; border-radius: 999px; +} + +.restricted-note { + padding: 14px; border-radius: var(--radius); + background: #fef2f2; border: 1px solid #fecaca; + font-size: 11px; color: #991b1b; line-height: 1.5; +} + +.confirm-panel { + display: flex; + flex-direction: column; + gap: 12px; +} +.summary-preview { + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.55; + font-style: italic; +} +.excerpt-preview { + padding: 12px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: #fff; + font-size: 12px; + line-height: 1.65; + max-height: 150px; + overflow-y: auto; + unicode-bidi: plaintext; +} +.excerpt-label { + display: block; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); + font-weight: 700; + margin-bottom: 8px; +} +.meta-row { margin-top: -4px; } +.reading-time { + font-size: 10px; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + 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; +} diff --git a/memento-note/extension/dist-chrome-store/sidepanel.html b/memento-note/extension/dist-chrome-store/sidepanel.html new file mode 100644 index 0000000..f8d7dbf --- /dev/null +++ b/memento-note/extension/dist-chrome-store/sidepanel.html @@ -0,0 +1,60 @@ + + + + + + Momento Web Clipper + + + + + + +
+
+
+ +
+ Momento + Web Clipper +
+
+
+ + +
+
+ + + +
+ +
+ +
+
+ + + + diff --git a/memento-note/extension/dist-chrome-store/sidepanel.js b/memento-note/extension/dist-chrome-store/sidepanel.js new file mode 100644 index 0000000..c334286 --- /dev/null +++ b/memento-note/extension/dist-chrome-store/sidepanel.js @@ -0,0 +1,712 @@ +/** Mettre à false pour le build Chrome Web Store (URL production en dur). */ +const ALLOW_INSTANCE_CONFIG = false +const DEFAULT_BASE = 'https://memento-note.com' +const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' } + +let state = 'idle' +let notebooks = [] +let selectedNotebookId = '' +let pageUrl = '' +let pageTitle = '' +let pageDomain = '' +let pageFavicon = '' +let pageHtml = '' +let pageDir = 'ltr' +let pageLang = '' +let selectionText = '' +let pageRestricted = false +let lastNoteId = '' +let lastNoteUrl = '' +let successTitle = '' +let successTags = [] +let errorMessage = '' +let activeTabId = null +let pendingClipType = 'page' +let analyzeResult = null +let editableTitle = '' +let connected = false + +const els = { + screen: document.getElementById('screen'), + baseUrl: document.getElementById('baseUrl'), + settingsPanel: document.getElementById('settingsPanel'), + settingsBtn: document.getElementById('settingsBtn'), + connBadge: document.getElementById('connBadge'), + connLabel: document.getElementById('connLabel'), + settingsStatus: document.getElementById('settingsStatus'), + applyInstanceBtn: document.getElementById('applyInstanceBtn'), + openLoginBtn: document.getElementById('openLoginBtn'), +} + +const ICON_SELECT = + '' +const ICON_CLIP = + '' +const ICON_LINK = + '' + +function apiBase() { + if (!ALLOW_INSTANCE_CONFIG) return DEFAULT_BASE + return (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '') +} + +function isRestrictedUrl(url) { + return !url || /^(chrome|chrome-extension|edge|about|moz-extension|devtools):/i.test(url) +} + +async function ensureApiPermission() { + const origin = `${apiBase()}/*` + const has = await chrome.permissions.contains({ origins: [origin] }) + if (!has) { + const granted = await chrome.permissions.request({ origins: [origin] }) + if (!granted) throw new Error(t('errPermissionDenied')) + } +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/ +const LTR_CHAR = /[A-Za-z0-9]/ + +function detectTextDirection(text) { + const sample = String(text || '').replace(/\s+/g, '').slice(0, 3000) + if (!sample) return 'ltr' + let rtl = 0 + let ltr = 0 + for (const ch of sample) { + if (RTL_CHAR.test(ch)) rtl++ + else if (LTR_CHAR.test(ch)) ltr++ + } + if (rtl === 0) return 'ltr' + return rtl >= ltr ? 'rtl' : 'ltr' +} + +function resolveUiDirection(text) { + if (pageDir === 'rtl') return 'rtl' + if (pageLang === 'fa' || pageLang === 'ar' || pageLang === 'he') return 'rtl' + if (/\/persian\b|\/fa\b|bbc\.com\/persian/i.test(pageUrl)) return 'rtl' + return detectTextDirection(text) +} + +function rtlAttrs(text) { + if (resolveUiDirection(text) !== 'rtl') return '' + const lang = pageLang && ['fa', 'ar', 'he'].includes(pageLang) ? ` lang="${pageLang}"` : '' + return ` class="text-rtl" dir="rtl"${lang}` +} + +function sortNotebooksHierarchy(list) { + const byParent = new Map() + for (const n of list) { + const pid = n.parentId || '__root__' + if (!byParent.has(pid)) byParent.set(pid, []) + byParent.get(pid).push(n) + } + for (const items of byParent.values()) { + items.sort((a, b) => (a.name || '').localeCompare(b.name || '', uiLocaleTag())) + } + const out = [] + const seen = new Set() + function walk(parentKey, depth) { + for (const n of byParent.get(parentKey) || []) { + if (seen.has(n.id)) continue + seen.add(n.id) + out.push({ ...n, depth }) + walk(n.id, depth + 1) + } + } + walk('__root__', 0) + for (const n of list) { + if (!seen.has(n.id)) out.push({ ...n, depth: 0 }) + } + return out +} + +function notebookSelectHtml() { + const sorted = sortNotebooksHierarchy(notebooks) + const opts = sorted + .map((n) => { + const indent = n.depth > 0 ? '\u00A0\u00A0'.repeat(n.depth) + '↳ ' : '' + const sel = n.id === selectedNotebookId ? ' selected' : '' + return `` + }) + .join('') + return `` +} + +function formatReadingTime(minutes) { + const m = Number(minutes) || 0 + if (m <= 0) return '' + if (m === 1) return t('readingTimeOne') + return t('readingTimeOther', String(m)) +} + +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + return tab +} + +async function ensureContentScript(tabId) { + try { + const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' }) + if (resp?.ok) return true + } catch { + /* inject */ + } + try { + await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }) + return true + } catch { + return false + } +} + +async function setPickModeOnTab(enabled) { + if (!activeTabId || pageRestricted) return + const ok = await ensureContentScript(activeTabId) + if (!ok) return + try { + await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled }) + } catch { + /* ignore */ + } +} + +async function syncPickMode() { + await setPickModeOnTab(state === 'idle' && !pageRestricted) +} + +function updateConnBadge() { + if (!els.connBadge) return + els.connBadge.hidden = !connected + if (els.connLabel) els.connLabel.textContent = connected ? t('connected') : t('disconnected') +} + +function setSettingsStatus(msg, isError) { + if (!els.settingsStatus) return + els.settingsStatus.hidden = !msg + els.settingsStatus.textContent = msg || '' + els.settingsStatus.className = `settings-status${isError ? ' is-error' : ' is-ok'}` +} + +function applyInstanceConfigVisibility() { + if (ALLOW_INSTANCE_CONFIG) return + els.settingsPanel?.setAttribute('hidden', '') + els.settingsBtn?.setAttribute('hidden', '') + if (els.baseUrl) els.baseUrl.value = DEFAULT_BASE +} + +function selectionBlockHtml() { + if (selectionText) { + return `
+
+ ${escapeHtml(t('selectionDetected'))} + +
+
「 ${escapeHtml(selectionText)} 」
+
` + } + return `
+

${escapeHtml(t('selectionHint'))}

+
` +} + +function actionsBlockHtml() { + const hasSel = Boolean(selectionText) + return `
+ ${ + hasSel + ? `` + : '' + } + + +
` +} + +function bindIdleHandlers() { + document.getElementById('notebookSelect')?.addEventListener('change', async (e) => { + selectedNotebookId = e.target.value || '' + await chrome.storage.sync.set({ [STORAGE_KEYS.notebookId]: selectedNotebookId }) + }) + document.getElementById('clearSel')?.addEventListener('click', () => void clearSelection()) + document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection')) + document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page')) + document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link')) +} + +async function clearSelection() { + selectionText = '' + if (activeTabId) { + try { + await chrome.scripting.executeScript({ + target: { tabId: activeTabId }, + func: () => window.getSelection()?.removeAllRanges(), + }) + } catch { + /* ignore */ + } + } + updateSelectionUI() +} + +function updateSelectionUI() { + const slot = document.getElementById('selectionSlot') + const actions = document.getElementById('actionsSlot') + if (!slot || !actions || state !== 'idle') { + render() + return + } + slot.outerHTML = selectionBlockHtml() + actions.outerHTML = actionsBlockHtml() + bindIdleHandlers() +} + +function applySelectionFromMessage(msg) { + if (!msg || msg.url !== pageUrl) return + selectionText = msg.text || '' + if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl' + if (msg.lang) pageLang = msg.lang + if (state === 'idle') updateSelectionUI() +} + +async function refreshPageContext() { + const tab = await getActiveTab() + activeTabId = tab?.id ?? null + pageRestricted = isRestrictedUrl(tab?.url) + + if (!tab?.id || pageRestricted) { + pageUrl = tab?.url || '' + pageTitle = tab?.title || t('pageNotAccessible') + selectionText = '' + return + } + + pageUrl = tab.url + pageTitle = tab.title || '' + try { + const u = new URL(pageUrl) + pageDomain = u.hostname + pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32` + } catch { + pageDomain = pageUrl + pageFavicon = 'https://www.google.com/s2/favicons?domain=google.com&sz=32' + } + + const ok = await ensureContentScript(tab.id) + if (!ok) return + + try { + const ctx = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTEXT' }) + pageHtml = ctx?.html || '' + selectionText = ctx?.text || '' + pageDir = ctx?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr' + pageLang = ctx?.lang || '' + } catch { + try { + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => ({ + html: document.documentElement.outerHTML, + text: window.getSelection()?.toString().trim() || '', + dir: document.documentElement.getAttribute('dir') || '', + lang: (document.documentElement.getAttribute('lang') || '').split('-')[0], + }), + }) + pageHtml = result?.html || '' + selectionText = result?.text || '' + pageDir = result?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr' + pageLang = result?.lang || '' + } catch { + /* ignore */ + } + } +} + +async function loadSettings() { + const stored = await chrome.storage.sync.get([STORAGE_KEYS.baseUrl, STORAGE_KEYS.notebookId]) + if (els.baseUrl) { + els.baseUrl.value = ALLOW_INSTANCE_CONFIG + ? stored[STORAGE_KEYS.baseUrl] || DEFAULT_BASE + : DEFAULT_BASE + } + await loadNotebooks(stored[STORAGE_KEYS.notebookId]) +} + +async function loadNotebooks(preferredId) { + try { + await ensureApiPermission() + const res = await fetch(`${apiBase()}/api/clip/notebooks`, { credentials: 'include' }) + if (!res.ok) { + connected = false + updateConnBadge() + if (res.status === 401) { + throw new Error(t('errLoginRequired')) + } + throw new Error(t('errLoadNotebooks')) + } + const data = await res.json() + notebooks = data.notebooks || [] + selectedNotebookId = + (preferredId && notebooks.some((n) => n.id === preferredId) ? preferredId : '') || + notebooks[0]?.id || + '' + connected = true + updateConnBadge() + errorMessage = '' + setSettingsStatus(t('notebooksLoaded'), false) + } catch (e) { + notebooks = [] + connected = false + updateConnBadge() + errorMessage = e.message + setSettingsStatus(e.message, true) + } +} + +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(t('connecting'), false) + await loadNotebooks(selectedNotebookId) + if (connected) { + setSettingsStatus(t('connectedToUrl', url), false) + } +} + +function renderIdle() { + const restrictedBlock = pageRestricted + ? `
${escapeHtml(t('restrictedPage'))}
` + : '' + + const authHint = + !connected && errorMessage + ? `
${escapeHtml(errorMessage)}
` + : '' + + els.screen.innerHTML = ` + ${restrictedBlock} + ${authHint} + +
+ ${escapeHtml(t('destinationNotebook'))} + ${notebookSelectHtml()} +
+ +
+ ${escapeHtml(t('activePage'))} +
+ +
+
${escapeHtml(pageTitle || '—')}
+
${escapeHtml(pageUrl || '—')}
+
+
+
+ + ${selectionBlockHtml()} + ${actionsBlockHtml()} + ` + bindIdleHandlers() +} + +function renderLoading(label) { + els.screen.innerHTML = ` +
+
+
+
+
+
+
${escapeHtml(t('analyzingSource'))}
+
${escapeHtml(label || t('statusAnalyzing'))}
+
${escapeHtml(t('processingDetail'))}
+
+
+ ` +} + +function renderConfirm() { + const excerpt = analyzeResult?.excerpt || '' + const tags = analyzeResult?.tags || [] + const reading = formatReadingTime(analyzeResult?.readingTime) + const tagsHtml = tags.map((t) => `${escapeHtml(t)}`).join('') + + els.screen.innerHTML = ` +
+ ${escapeHtml(t('previewBeforeSave'))} + + ${ + reading + ? `
${escapeHtml(reading)}
` + : '' + } + ${ + analyzeResult?.summary + ? `

${escapeHtml(analyzeResult.summary)}

` + : '' + } + ${ + excerpt && pendingClipType !== 'link' + ? `
+ ${escapeHtml(t('excerptLabel'))} + ${escapeHtml(excerpt)} +
` + : '' + } + ${tagsHtml ? `
${tagsHtml}
` : ''} +
+
+ + +
+ ` + + document.getElementById('titleInput')?.addEventListener('input', (e) => { + editableTitle = e.target.value + }) + document.getElementById('saveBtn')?.addEventListener('click', () => void runSave()) + document.getElementById('cancelConfirmBtn')?.addEventListener('click', async () => { + state = 'idle' + analyzeResult = null + await syncPickMode() + render() + }) +} + +function renderSuccess() { + const nb = notebooks.find((n) => n.id === selectedNotebookId) + const tagsHtml = successTags.map((t) => `${escapeHtml(t)}`).join('') + const reading = formatReadingTime(analyzeResult?.readingTime) + + els.screen.innerHTML = ` +
+
+
+ ${escapeHtml(t('noteSaved'))} +
${escapeHtml(successTitle)}
+
${escapeHtml(t('sentToNotebook', nb?.name || ''))}
+ ${reading ? `
${escapeHtml(reading)}
` : ''} +
+ ${tagsHtml ? `
${tagsHtml}
` : ''} +
+
+ + +
+ ` + document.getElementById('viewBtn')?.addEventListener('click', () => { + if (lastNoteUrl) chrome.tabs.create({ url: `${apiBase()}${lastNoteUrl}` }) + }) + document.getElementById('againBtn')?.addEventListener('click', async () => { + state = 'idle' + analyzeResult = null + await refreshPageContext() + await syncPickMode() + render() + }) +} + +function renderError() { + els.screen.innerHTML = ` +
+
!
+
+
${escapeHtml(t('failure'))}
+
${escapeHtml(errorMessage || t('genericError'))}
+
+
+
+ + +
+ ` + document.getElementById('retryBtn')?.addEventListener('click', () => { + if (analyzeResult) void runSave() + else void runAnalyze(pendingClipType) + }) + document.getElementById('backIdleBtn')?.addEventListener('click', async () => { + state = 'idle' + errorMessage = '' + analyzeResult = null + await refreshPageContext() + await syncPickMode() + render() + }) +} + +function render() { + 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() + renderIdle() +} + +async function runAnalyze(type) { + pendingClipType = type + state = 'loading' + await setPickModeOnTab(false) + render() + try { + await ensureApiPermission() + await chrome.storage.sync.set({ + [STORAGE_KEYS.baseUrl]: apiBase(), + [STORAGE_KEYS.notebookId]: selectedNotebookId, + }) + + if (type === 'selection') { + if (!selectionText) throw new Error(t('errNoSelection')) + await refreshPageContext() + } + + let analyzeBody + if (type === 'link') { + analyzeBody = { url: pageUrl, title: pageTitle, mode: 'link' } + } else if (type === 'selection' && selectionText) { + analyzeBody = { url: pageUrl, title: pageTitle, mode: 'selection', selection: selectionText } + } else { + analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' } + } + + const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(analyzeBody), + }) + const analysis = await analyzeRes.json() + 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 || t('errNetwork') + state = 'error' + render() + } +} + +async function runSave() { + if (!analyzeResult) return + state = 'saving' + render() + try { + const title = (editableTitle || analyzeResult.title || pageTitle || pageDomain).trim() + const saveRes = await fetch(`${apiBase()}/api/clip/save`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: pageUrl, + title, + content: analyzeResult.content, + summary: analyzeResult.summary, + tags: analyzeResult.tags || [], + notebookId: selectedNotebookId || undefined, + }), + }) + const saved = await saveRes.json() + if (!saveRes.ok) throw new Error(saved.error || t('errSaveFailed')) + + successTitle = title + successTags = analyzeResult.tags || [] + lastNoteId = saved.noteId + lastNoteUrl = saved.noteUrl + state = 'success' + render() + } catch (e) { + errorMessage = e.message || t('errNetwork') + state = 'error' + render() + } +} + +chrome.runtime.onMessage.addListener((msg) => { + if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg) +}) + +chrome.tabs.onActivated.addListener(async () => { + if (state !== 'idle') return + await refreshPageContext() + await syncPickMode() + render() +}) + +chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.status !== 'complete' || state !== 'idle') return + const tab = await getActiveTab() + if (tab?.id === tabId) { + await refreshPageContext() + await syncPickMode() + render() + } +}) + +els.settingsBtn?.addEventListener('click', () => { + if (!ALLOW_INSTANCE_CONFIG) return + els.settingsPanel.hidden = !els.settingsPanel.hidden +}) + +document.querySelectorAll('.preset-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const url = btn.getAttribute('data-url') + if (url && els.baseUrl) els.baseUrl.value = url + }) +}) + +els.applyInstanceBtn?.addEventListener('click', () => void applyInstance()) +els.openLoginBtn?.addEventListener('click', () => { + chrome.tabs.create({ url: apiBase() }) +}) + +document.addEventListener('visibilitychange', async () => { + if (document.visibilityState === 'visible') { + if (state === 'idle') { + await refreshPageContext() + await syncPickMode() + render() + } + } else if (document.visibilityState === 'hidden') { + // Désactiver le pick mode quand le sidepanel est fermé + await setPickModeOnTab(false) + } +}) + +document.addEventListener('DOMContentLoaded', async () => { + applyDocumentLocale() + applyInstanceConfigVisibility() + applyShellI18n() + await loadSettings() + try { + await ensureApiPermission() + } catch (e) { + errorMessage = e.message + connected = false + updateConnBadge() + } + await refreshPageContext() + await syncPickMode() + render() +}) diff --git a/memento-note/extension/memento-web-clipper-chrome-store.zip b/memento-note/extension/memento-web-clipper-chrome-store.zip new file mode 100644 index 0000000..20eb25d Binary files /dev/null and b/memento-note/extension/memento-web-clipper-chrome-store.zip differ diff --git a/memento-note/extension/scripts/build-chrome-store.mjs b/memento-note/extension/scripts/build-chrome-store.mjs new file mode 100755 index 0000000..2754dca --- /dev/null +++ b/memento-note/extension/scripts/build-chrome-store.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * Build script for Chrome Web Store production package + * Usage: node scripts/build-chrome-store.mjs + * + * This script: + * 1. Sets ALLOW_INSTANCE_CONFIG = false in sidepanel.js + * 2. Removes localhost permissions from manifest.json + * 3. Copies and generates icons from public/icons/ + * 4. Creates a production-ready .zip package + */ + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import AdmZip from 'adm-zip' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const extRoot = path.resolve(__dirname, '..') +const projectRoot = path.resolve(extRoot, '..') +const publicIconsDir = path.join(projectRoot, 'public', 'icons') +const distDir = path.join(extRoot, 'dist-chrome-store') + +// Colors for terminal output +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + red: '\x1b[31m' +} + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`) +} + +function ensureDir(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } +} + +// Copy all files from source to destination, excluding specified patterns +function copyFiles(src, dest, exclude = []) { + ensureDir(dest) + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const relPath = path.relative(extRoot, srcPath) + + // Skip excluded files/directories + if (exclude.some(pattern => relPath.match(pattern))) { + continue + } + + const destPath = path.join(dest, entry.name) + + if (entry.isDirectory()) { + copyFiles(srcPath, destPath, exclude) + } else { + fs.copyFileSync(srcPath, destPath) + } + } +} + +// Read and modify sidepanel.js for production +function processSidepanelJs(content) { + return content.replace( + /const ALLOW_INSTANCE_CONFIG = true/, + 'const ALLOW_INSTANCE_CONFIG = false' + ) +} + +// Read and modify manifest.json for production +function processManifestJson(content) { + const manifest = JSON.parse(content) + + // Remove localhost from host_permissions + if (manifest.host_permissions) { + manifest.host_permissions = manifest.host_permissions.filter( + perm => !perm.includes('localhost:3000') && !perm.includes('127.0.0.1:3000') + ) + } + + return JSON.stringify(manifest, null, 2) +} + +// Generate PNG icons from SVG using sharp +async function generateIcons() { + log('📦 Generating PNG icons from SVG...', 'blue') + + const sharp = (await import('sharp')).default + const sizes = [16, 48, 128] + + // Source SVG files + const icon512Svg = path.join(publicIconsDir, 'icon-512.svg') + const icon192Svg = path.join(publicIconsDir, 'icon-192.svg') + + if (!fs.existsSync(icon512Svg) || !fs.existsSync(icon192Svg)) { + log('⚠️ Source SVG icons not found. Copying SVG files only.', 'yellow') + // Copy SVG files as fallback + fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg')) + fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg')) + return + } + + // Generate PNG icons + for (const size of sizes) { + const sourceSvg = size >= 128 ? icon512Svg : icon192Svg + const outputPath = path.join(distDir, `icon-${size}.png`) + + await sharp(sourceSvg) + .resize(size, size) + .png() + .toFile(outputPath) + + log(` ✓ Generated icon-${size}.png`, 'green') + } + + // Also copy SVG files for reference + fs.copyFileSync(icon512Svg, path.join(distDir, 'icon-512.svg')) + fs.copyFileSync(icon192Svg, path.join(distDir, 'icon-192.svg')) +} + +// Create ZIP package using AdmZip +async function createZipPackage() { + log('📦 Creating ZIP package...', 'blue') + + const zipPath = path.join(extRoot, 'memento-web-clipper-chrome-store.zip') + + try { + const zip = new AdmZip() + + // Add all files from dist directory + const addFiles = (dir, base = '') => { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + const relativePath = path.join(base, entry.name) + + if (entry.isDirectory()) { + addFiles(fullPath, relativePath) + } else { + zip.addLocalFile(fullPath, base) + } + } + } + + addFiles(distDir) + + // Write the zip file + zip.writeZip(zipPath) + + // Get file size + const stats = fs.statSync(zipPath) + log(`✓ ZIP package created: ${zipPath}`, 'green') + log(` Size: ${(stats.size / 1024).toFixed(2)} KB`, 'green') + + } catch (error) { + throw new Error(`Failed to create ZIP: ${error.message}`) + } +} + +// Main build process +async function build() { + log('🚀 Starting Chrome Web Store build...', 'blue') + log('') + + try { + // Clean dist directory + log('🧹 Cleaning dist directory...', 'blue') + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }) + } + ensureDir(distDir) + + // Copy extension files (excluding build scripts and dist) + log('📋 Copying extension files...', 'blue') + copyFiles(extRoot, distDir, [ + /^dist-/, + /^scripts\//, + /\.md$/, + /^node_modules$/ + ]) + + // Process sidepanel.js + log('⚙️ Processing sidepanel.js...', 'blue') + const sidepanelPath = path.join(distDir, 'sidepanel.js') + let sidepanelContent = fs.readFileSync(sidepanelPath, 'utf8') + sidepanelContent = processSidepanelJs(sidepanelContent) + fs.writeFileSync(sidepanelPath, sidepanelContent) + log(' ✓ Set ALLOW_INSTANCE_CONFIG = false', 'green') + + // Process manifest.json + log('⚙️ Processing manifest.json...', 'blue') + const manifestPath = path.join(distDir, 'manifest.json') + let manifestContent = fs.readFileSync(manifestPath, 'utf8') + manifestContent = processManifestJson(manifestContent) + + // Add icons to manifest + const manifest = JSON.parse(manifestContent) + manifest.icons = { + "16": "icon-16.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + manifest.action = { + ...manifest.action, + "default_icon": { + "16": "icon-16.png", + "48": "icon-48.png", + "128": "icon-128.png" + } + } + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) + log(' ✓ Removed localhost permissions', 'green') + log(' ✓ Added icon definitions', 'green') + + // Generate icons + await generateIcons() + + // Create ZIP package + await createZipPackage() + + log('') + log('✅ Build completed successfully!', 'green') + log('') + log('📦 Output files:', 'blue') + log(` • Package: ${path.join(extRoot, 'memento-web-clipper-chrome-store.zip')}`, 'reset') + log(` • Dist dir: ${distDir}`, 'reset') + log('') + log('📝 Next steps:', 'blue') + log(' 1. Test the extension by loading the dist-chrome-store folder in Chrome (chrome://extensions)', 'reset') + log(' 2. Upload the .zip file to Chrome Web Store Developer Dashboard', 'reset') + log('') + + } catch (error) { + log('') + log('❌ Build failed!', 'red') + log(` Error: ${error.message}`, 'red') + process.exit(1) + } +} + +// Run the build +build() diff --git a/memento-note/extension/sidepanel.js b/memento-note/extension/sidepanel.js index 9c69d3c..6a21b0c 100644 --- a/memento-note/extension/sidepanel.js +++ b/memento-note/extension/sidepanel.js @@ -246,6 +246,14 @@ function bindIdleHandlers() { document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection')) document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page')) document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link')) + + // Gérer l'erreur de chargement du favicon + document.querySelector('.page-favicon')?.addEventListener('error', function() { + const fallback = this.getAttribute('data-fallback') + if (fallback && this.src !== fallback) { + this.src = fallback + } + }) } async function clearSelection() { @@ -410,7 +418,7 @@ function renderIdle() {
${escapeHtml(t('activePage'))}
- +
${escapeHtml(pageTitle || '—')}
${escapeHtml(pageUrl || '—')}
@@ -682,10 +690,15 @@ els.openLoginBtn?.addEventListener('click', () => { }) document.addEventListener('visibilitychange', async () => { - if (document.visibilityState === 'visible' && state === 'idle') { - await refreshPageContext() - await syncPickMode() - render() + if (document.visibilityState === 'visible') { + if (state === 'idle') { + await refreshPageContext() + await syncPickMode() + render() + } + } else if (document.visibilityState === 'hidden') { + // Désactiver le pick mode quand le sidepanel est fermé + await setPickModeOnTab(false) } }) diff --git a/memento-note/extension/test-sidepanel.html b/memento-note/extension/test-sidepanel.html new file mode 100644 index 0000000..e8aa574 --- /dev/null +++ b/memento-note/extension/test-sidepanel.html @@ -0,0 +1,78 @@ + + + + + Test Sidepanel + + + +

Test Extension Momento

+
+ + + + diff --git a/memento-note/hooks/use-cookie-consent.ts b/memento-note/hooks/use-cookie-consent.ts index f70a379..cd92d44 100644 --- a/memento-note/hooks/use-cookie-consent.ts +++ b/memento-note/hooks/use-cookie-consent.ts @@ -5,6 +5,7 @@ import { CONSENT_CHANGE_EVENT, getConsent, hasConsentChoice, + loadConsentWithDBSync, type ConsentRecord, } from '@/lib/consent/cookie-consent' @@ -12,8 +13,10 @@ export function useCookieConsent() { const [consent, setConsentState] = useState(null) const [ready, setReady] = useState(false) - const refresh = useCallback(() => { - setConsentState(getConsent()) + const refresh = useCallback(async () => { + // Try local first, then DB sync for authenticated users + const loaded = await loadConsentWithDBSync() + setConsentState(loaded) setReady(true) }, []) diff --git a/memento-note/lib/ai/factory.ts b/memento-note/lib/ai/factory.ts index f6506df..fe40cf3 100644 --- a/memento-note/lib/ai/factory.ts +++ b/memento-note/lib/ai/factory.ts @@ -19,7 +19,9 @@ export type ProviderType = | 'zai' | 'lmstudio' | 'anthropic' - | 'anthropic_custom'; + | 'anthropic_custom' + | 'custom_openai' + | 'custom_anthropic'; // --- Provider defaults --- const PROVIDER_DEFAULTS: Record = { @@ -204,6 +206,7 @@ export function getProviderInstance(providerType: ProviderType, config: Record = { openrouter: 'https://openrouter.ai/api/v1', mistral: 'https://api.mistral.ai/v1', zai: 'https://api.zukijourney.com/v1', - minimax: 'https://api.minimax.chat/v1', + minimax: 'https://api.minimax.io/v1', glm: 'https://open.bigmodel.ai/api/paas/v4', }; @@ -17,13 +17,21 @@ export const PROVIDER_MODEL_SUGGESTIONS: Record = { anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'], google: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'], deepseek: ['deepseek-chat', 'deepseek-coder'], - minimax: ['abab6.5-chat', 'abab6.5s-chat'], + minimax: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2-her'], mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest'], glm: ['glm-4', 'glm-4-flash'], openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'], custom: [], }; +/** + * Result of fetching models - includes whether they came from the real API or fallbacks + */ +export interface FetchModelsResult { + models: string[] + fromApi: boolean // true = fetched from provider API, false = fallback suggestions +} + /** * Dynamically queries the provider's /models endpoint using the user's API Key * to fetch their actual available models list instead of relying on hardcoded choices. @@ -32,21 +40,32 @@ export async function fetchLiveModelsForProvider( provider: AiGatewayProvider, apiKey: string, customBaseUrl?: string -): Promise { +): Promise { try { // Anthropic and Google do not expose a public list via a simple key GET /models (or need specific formats) // We fall back to the popular defaults for those. - if (provider === 'anthropic' || provider === 'anthropic_custom' || provider === 'google') { - const standardProvider = provider === 'anthropic_custom' ? 'anthropic' : provider; - return PROVIDER_MODEL_SUGGESTIONS[standardProvider] ?? []; + if ( + provider === 'anthropic' || + provider === 'anthropic_custom' || + provider === 'custom_anthropic' || + provider === 'google' || + provider === 'minimax' + ) { + const standardProvider = + provider === 'anthropic_custom' || provider === 'custom_anthropic' + ? 'anthropic' + : provider; + const models = PROVIDER_MODEL_SUGGESTIONS[standardProvider] ?? []; + return { models, fromApi: false }; } - const baseUrl = provider === 'custom' + const baseUrl = (provider === 'custom' || provider === 'custom_openai') ? customBaseUrl?.replace(/\/$/, '') : PROVIDER_URLS[provider]; if (!baseUrl) { - return PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; + const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; + return { models, fromApi: false }; } const headers: Record = { 'Content-Type': 'application/json' }; @@ -63,7 +82,9 @@ export async function fetchLiveModelsForProvider( }); if (!response.ok) { - return PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; + console.warn(`[fetchLiveModelsForProvider] API returned ${response.status} for ${provider}`); + const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; + return { models, fromApi: false }; } const data = await response.json(); @@ -73,11 +94,24 @@ export async function fetchLiveModelsForProvider( .sort(); if (fetched.length > 0) { - return fetched; + console.log(`[fetchLiveModelsForProvider] Got ${fetched.length} models from ${provider} API:`, fetched); + return { models: fetched, fromApi: true }; + } else { + console.warn(`[fetchLiveModelsForProvider] API returned empty data array for ${provider}`); } } catch (err) { - console.warn(`[fetchLiveModelsForProvider] Failed to fetch live models for ${provider}, using fallbacks:`, err); + console.warn(`[fetchLiveModelsForProvider] Failed to fetch live models for ${provider}:`, err); } - return PROVIDER_MODEL_SUGGESTIONS[provider] ?? []; + const fallbackProvider = + provider === 'custom_openai' + ? 'openai' + : provider === 'custom_anthropic' + ? 'anthropic' + : provider === 'anthropic_custom' + ? 'anthropic' + : provider; + + const models = PROVIDER_MODEL_SUGGESTIONS[fallbackProvider] ?? []; + return { models, fromApi: false }; } diff --git a/memento-note/lib/ai/provider-for-user.ts b/memento-note/lib/ai/provider-for-user.ts index 41dd832..cf25d63 100644 --- a/memento-note/lib/ai/provider-for-user.ts +++ b/memento-note/lib/ai/provider-for-user.ts @@ -1,8 +1,9 @@ import { getProviderInstance, + getProviderConfigKeys, type ProviderType, } from '@/lib/ai/factory'; -import { applyByokToConfig } from '@/lib/byok'; +import { getAnyActiveByokForUser, hasAnyActiveByok, ByokUnavailableError } from '@/lib/byok'; import { resolveAiRoute, type AiFeatureLane, @@ -17,78 +18,87 @@ export interface ProviderForUserResult { route: ResolvedAiRoute; } +/** Resolve the best AI provider for a user's lane — BYOK first, then admin config. */ async function resolveProviderForLane( lane: AiFeatureLane, config: Record, billingUserId?: string, ): Promise { const cfg = { ...config }; - const route = resolveAiRoute(lane, cfg); - let usedByok = false; - let byokModel: string | null = null; + const adminRoute = resolveAiRoute(lane, cfg); if (billingUserId) { - const overlay = await applyByokToConfig( - billingUserId, - route.providerType, - cfg, - ); - Object.assign(cfg, overlay.config); - usedByok = overlay.usedByok; - byokModel = overlay.model; + // Prefer admin's provider, fallback to any active BYOK key + const byok = await getAnyActiveByokForUser(billingUserId, adminRoute.providerType, lane); + + if (byok) { + const { apiKeyConfigKey, baseUrlConfigKey } = getProviderConfigKeys(byok.provider); + const byokCfg: Record = { ...cfg }; + if (apiKeyConfigKey) byokCfg[apiKeyConfigKey] = byok.plaintext; + if (baseUrlConfigKey && byok.baseUrl) byokCfg[baseUrlConfigKey] = byok.baseUrl; + + const resolvedModel = (byok.model && byok.model.trim()) ? byok.model : adminRoute.modelName; + console.log(`[byok] Using BYOK key: provider=${byok.provider} model=${resolvedModel} user=${billingUserId}`); + + const provider = getProviderInstance( + byok.provider as ProviderType, + byokCfg, + resolvedModel, + adminRoute.embeddingModelName, + adminRoute.ollamaBaseUrl, + ); + + return { + provider, + usedByok: true, + route: { + ...adminRoute, + providerType: byok.provider as ResolvedAiRoute['providerType'], + modelName: resolvedModel, + }, + }; + } + + // No key resolved — if user HAS active BYOK rows, decryption failed → throw, no silent fallback + const hasByok = await hasAnyActiveByok(billingUserId); + if (hasByok) { + throw new ByokUnavailableError(); + } } - const resolvedModel = byokModel && byokModel.trim() !== '' ? byokModel : route.modelName; - + // No BYOK configured → use admin config const provider = getProviderInstance( - route.providerType as ProviderType, + adminRoute.providerType as ProviderType, cfg, - resolvedModel, - route.embeddingModelName, - route.ollamaBaseUrl, + adminRoute.modelName, + adminRoute.embeddingModelName, + adminRoute.ollamaBaseUrl, ); - const updatedRoute = { ...route, modelName: resolvedModel }; - - return { provider, usedByok, route: updatedRoute }; + return { provider, usedByok: false, route: adminRoute }; } -async function getChatProviderForBillingUser( - config: Record, - billingUserId?: string, -): Promise { - return resolveProviderForLane('chat', config, billingUserId); -} - -async function getTagsProviderForBillingUser( - config: Record, - billingUserId?: string, -): Promise { - return resolveProviderForLane('tags', config, billingUserId); -} - -async function getEmbeddingsProviderForBillingUser( - config: Record, - billingUserId?: string, -): Promise { - return resolveProviderForLane('embedding', config, billingUserId); -} - -/** Run a lane with BYOK overlay; skips system fallback when user key is active. */ +/** Check if a lane will use BYOK for a given user. */ export async function willUseByokForLane( lane: AiFeatureLane, config: Record, billingUserId?: string, ): Promise<{ providerType: string; usedByok: boolean }> { if (!billingUserId) { - const route = resolveAiRoute(lane, config) - return { providerType: route.providerType, usedByok: false } + const route = resolveAiRoute(lane, config); + return { providerType: route.providerType, usedByok: false }; } - const route = resolveAiRoute(lane, config) - const overlay = await applyByokToConfig(billingUserId, route.providerType, config) - return { providerType: route.providerType, usedByok: overlay.usedByok } + const route = resolveAiRoute(lane, config); + const byok = await getAnyActiveByokForUser(billingUserId, route.providerType); + return { providerType: byok?.provider ?? route.providerType, usedByok: !!byok }; } +/** + * Run an AI lane with BYOK priority. + * - If user has active BYOK → uses it, no quota counted. + * - If user has BYOK configured but it can't be loaded → throws ByokUnavailableError (no fallback). + * - If user has no BYOK → uses admin config with system fallback. + */ export async function runLaneWithBillingUser( lane: AiFeatureLane, config: Record, @@ -96,12 +106,8 @@ export async function runLaneWithBillingUser( run: (provider: AIProvider) => Promise, ): Promise<{ result: T; usedByok: boolean }> { if (billingUserId) { - const resolved = - lane === 'chat' - ? await getChatProviderForBillingUser(config, billingUserId) - : lane === 'tags' - ? await getTagsProviderForBillingUser(config, billingUserId) - : await getEmbeddingsProviderForBillingUser(config, billingUserId); + // May throw ByokUnavailableError — let it propagate, callers should handle it + const resolved = await resolveProviderForLane(lane, config, billingUserId); if (resolved.usedByok) { const result = await run(resolved.provider); @@ -109,6 +115,7 @@ export async function runLaneWithBillingUser( } } + // No BYOK configured → use admin config with system fallback const result = await withAiProviderFallback(lane, config, run); return { result, usedByok: false }; } diff --git a/memento-note/lib/ai/providers/anthropic.ts b/memento-note/lib/ai/providers/anthropic.ts index a77b688..8c9bffc 100644 --- a/memento-note/lib/ai/providers/anthropic.ts +++ b/memento-note/lib/ai/providers/anthropic.ts @@ -23,19 +23,36 @@ export class AnthropicProvider implements AIProvider { async generateTags(content: string): Promise { try { - const { object } = await generateObject({ - model: this.model, - schema: z.object({ - tags: z.array(z.object({ - tag: z.string().describe('Short tag name in lowercase'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'), - })), - }), - prompt: `Analyze the following note and suggest 1 to 5 relevant tags. - Note content: "${content}"`, - }); + try { + const { object } = await generateObject({ + model: this.model, + schema: z.object({ + tags: z.array(z.object({ + tag: z.string().describe('Short tag name in lowercase'), + confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'), + })), + }), + prompt: `Analyze the following note and suggest 1 to 5 relevant tags. + Note content: "${content}"`, + }); - return object.tags; + return object.tags; + } catch (err) { + console.warn('Anthropic generateObject tags failed, falling back to generateText:', err); + const { text } = await aiGenerateText({ + model: this.model, + prompt: `Analyze the following note and suggest 1 to 5 relevant tags. + Note content: "${content.substring(0, 1500)}" + Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`, + }); + const cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim(); + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []); + return arr.map((t: any) => ({ + tag: t.tag || t.label || t.name || '', + confidence: t.confidence || t.score || 0.7, + })); + } } catch (e) { console.error('Error generating tags (Anthropic):', e); return []; @@ -50,18 +67,33 @@ export class AnthropicProvider implements AIProvider { async generateTitles(prompt: string): Promise { try { - const { object } = await generateObject({ - model: this.model, - schema: z.object({ - titles: z.array(z.object({ - title: z.string().describe('Suggested title'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'), - })), - }), - prompt, - }); + try { + const { object } = await generateObject({ + model: this.model, + schema: z.object({ + titles: z.array(z.object({ + title: z.string().describe('Suggested title'), + confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'), + })), + }), + prompt, + }); - return object.titles; + return object.titles; + } catch (err) { + console.warn('Anthropic generateObject titles failed, falling back to generateText:', err); + const { text } = await aiGenerateText({ + model: this.model, + prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]', + }); + const cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim(); + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []); + return arr.map((t: any) => ({ + title: typeof t === 'string' ? t : t.title || t.name || '', + confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8), + })); + } } catch (e) { console.error('Error generating titles (Anthropic):', e); return []; diff --git a/memento-note/lib/ai/providers/custom-openai.ts b/memento-note/lib/ai/providers/custom-openai.ts index 40d77aa..5e19cf8 100644 --- a/memento-note/lib/ai/providers/custom-openai.ts +++ b/memento-note/lib/ai/providers/custom-openai.ts @@ -1,7 +1,7 @@ import { createOpenAI } from '@ai-sdk/openai'; -import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai'; -import { z } from 'zod'; +import { generateText as aiGenerateText, embed, stepCountIs } from 'ai'; import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types'; +import { cleanAIJsonResponse, cleanAITextResponse } from '../utils/clean-ai-response'; export class CustomOpenAIProvider implements AIProvider { private model: any; @@ -49,19 +49,20 @@ export class CustomOpenAIProvider implements AIProvider { async generateTags(content: string): Promise { try { - const { object } = await generateObject({ + const { text } = await aiGenerateText({ model: this.model, - schema: z.object({ - tags: z.array(z.object({ - tag: z.string().describe('Short tag name in lowercase'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') - })) - }), prompt: `Analyze the following note and suggest 1 to 5 relevant tags. - Note content: "${content}"`, +Note content: "${content.substring(0, 1500)}" +Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`, }); - return object.tags; + const cleaned = cleanAIJsonResponse(text) + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []); + return arr.map((t: any) => ({ + tag: t.tag || t.label || t.name || '', + confidence: t.confidence || t.score || 0.7, + })); } catch (e) { console.error('Error generating tags (Custom OpenAI):', e); return []; @@ -83,15 +84,15 @@ export class CustomOpenAIProvider implements AIProvider { async generateTitles(prompt: string): Promise { try { - // Use generateText instead of generateObject — DeepSeek doesn't support + // Use generateText instead of generateObject — DeepSeek/MiniMax don't support // response_format: json_schema via the OpenAI compat layer const { text } = await aiGenerateText({ model: this.model, prompt: prompt, }) - // Parse the JSON array from the text response — strip markdown code fences if present - const parsed = JSON.parse(text.replace(/^```json\n?/,'').replace(/\n?```$/,'').trim()) + const cleaned = cleanAIJsonResponse(text) + const parsed = JSON.parse(cleaned) const titles = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []) return titles.map((t: any) => ({ title: typeof t === 'string' ? t : t.title || t.name || '', @@ -103,6 +104,8 @@ export class CustomOpenAIProvider implements AIProvider { } } + + async generateText(prompt: string): Promise { try { const { text } = await aiGenerateText({ @@ -110,7 +113,7 @@ export class CustomOpenAIProvider implements AIProvider { prompt: prompt, }); - return text.trim(); + return cleanAITextResponse(text).trim(); } catch (e) { console.error('Error generating text (Custom OpenAI):', e); throw e; diff --git a/memento-note/lib/ai/providers/deepseek.ts b/memento-note/lib/ai/providers/deepseek.ts index 1762f74..2f6ecba 100644 --- a/memento-note/lib/ai/providers/deepseek.ts +++ b/memento-note/lib/ai/providers/deepseek.ts @@ -1,7 +1,7 @@ import { createOpenAI } from '@ai-sdk/openai'; -import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai'; -import { z } from 'zod'; +import { generateText as aiGenerateText, embed, stepCountIs } from 'ai'; import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types'; +import { cleanAIJsonResponse, cleanAITextResponse } from '../utils/clean-ai-response'; export class DeepSeekProvider implements AIProvider { private model: any; @@ -41,7 +41,7 @@ Return ONLY a JSON array like: [{"tag":"example","confidence":0.9}] Note content: "${content.substring(0, 1500)}"`, }); - const clean = text.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim(); + const clean = cleanAIJsonResponse(text) const parsed = JSON.parse(clean); const arr = Array.isArray(parsed) ? parsed : (parsed.tags || []); return arr.map((t: any) => ({ @@ -69,18 +69,18 @@ Note content: "${content.substring(0, 1500)}"`, async generateTitles(prompt: string): Promise { try { - const { object } = await generateObject({ + // Utiliser generateText + parse manuel (generateObject échoue avec les modèles reasoning) + const { text } = await aiGenerateText({ model: this.model, - schema: z.object({ - titles: z.array(z.object({ - title: z.string().describe('Suggested title'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') - })) - }), prompt: prompt, }); - - return object.titles; + const cleaned = cleanAIJsonResponse(text) + const parsed = JSON.parse(cleaned) + const titles = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []) + return titles.map((t: any) => ({ + title: typeof t === 'string' ? t : t.title || t.name || '', + confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.5), + })) } catch (e) { console.error('Error generating titles (DeepSeek):', e); return []; @@ -94,7 +94,7 @@ Note content: "${content.substring(0, 1500)}"`, prompt: prompt, }); - return text.trim(); + return cleanAITextResponse(text).trim(); } catch (e) { console.error('Error generating text (DeepSeek):', e); throw e; diff --git a/memento-note/lib/ai/providers/google.ts b/memento-note/lib/ai/providers/google.ts index 73ed03a..1234b26 100644 --- a/memento-note/lib/ai/providers/google.ts +++ b/memento-note/lib/ai/providers/google.ts @@ -18,19 +18,36 @@ export class GoogleProvider implements AIProvider { async generateTags(content: string): Promise { try { - const { object } = await generateObject({ - model: this.model, - schema: z.object({ - tags: z.array(z.object({ - tag: z.string().describe('Short tag name in lowercase'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') - })) - }), - prompt: `Analyze the following note and suggest 1 to 5 relevant tags. - Note content: "${content}"`, - }); + try { + const { object } = await generateObject({ + model: this.model, + schema: z.object({ + tags: z.array(z.object({ + tag: z.string().describe('Short tag name in lowercase'), + confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') + })) + }), + prompt: `Analyze the following note and suggest 1 to 5 relevant tags. + Note content: "${content}"`, + }); - return object.tags; + return object.tags; + } catch (err) { + console.warn('Google generateObject tags failed, falling back to generateText:', err); + const { text } = await aiGenerateText({ + model: this.model, + prompt: `Analyze the following note and suggest 1 to 5 relevant tags. + Note content: "${content.substring(0, 1500)}" + Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`, + }); + const cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim(); + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []); + return arr.map((t: any) => ({ + tag: t.tag || t.label || t.name || '', + confidence: t.confidence || t.score || 0.7, + })); + } } catch (e) { console.error('Error generating tags (Google):', e); return []; @@ -52,18 +69,33 @@ export class GoogleProvider implements AIProvider { async generateTitles(prompt: string): Promise { try { - const { object } = await generateObject({ - model: this.model, - schema: z.object({ - titles: z.array(z.object({ - title: z.string().describe('Suggested title'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') - })) - }), - prompt: prompt, - }); + try { + const { object } = await generateObject({ + model: this.model, + schema: z.object({ + titles: z.array(z.object({ + title: z.string().describe('Suggested title'), + confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') + })) + }), + prompt: prompt, + }); - return object.titles; + return object.titles; + } catch (err) { + console.warn('Google generateObject titles failed, falling back to generateText:', err); + const { text } = await aiGenerateText({ + model: this.model, + prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]', + }); + const cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim(); + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []); + return arr.map((t: any) => ({ + title: typeof t === 'string' ? t : t.title || t.name || '', + confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8), + })); + } } catch (e) { console.error('Error generating titles (Google):', e); return []; diff --git a/memento-note/lib/ai/providers/ollama.ts b/memento-note/lib/ai/providers/ollama.ts index d57bf0f..33ff7a1 100644 --- a/memento-note/lib/ai/providers/ollama.ts +++ b/memento-note/lib/ai/providers/ollama.ts @@ -78,7 +78,7 @@ Note content: "${content}"`; if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`); const data = await response.json(); - const text = data.response; + const text = (data.response || '').replace(/[\s\S]*?<\/think>/gi, '').trim(); const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/); if (jsonMatch) { @@ -133,7 +133,7 @@ Note content: "${content}"`; if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`); const data = await response.json(); - const text = data.response; + const text = (data.response || '').replace(/[\s\S]*?<\/think>/gi, '').trim(); const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/); if (jsonMatch) { @@ -162,7 +162,7 @@ Note content: "${content}"`; if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`); const data = await response.json(); - return data.response.trim(); + return (data.response || '').replace(/[\s\S]*?<\/think>/gi, '').trim(); } catch (e) { console.error('Error generating text (Ollama):', e); throw e; diff --git a/memento-note/lib/ai/providers/openai.ts b/memento-note/lib/ai/providers/openai.ts index 72e8de9..b7a505d 100644 --- a/memento-note/lib/ai/providers/openai.ts +++ b/memento-note/lib/ai/providers/openai.ts @@ -24,19 +24,36 @@ export class OpenAIProvider implements AIProvider { async generateTags(content: string): Promise { try { - const { object } = await generateObject({ - model: this.model, - schema: z.object({ - tags: z.array(z.object({ - tag: z.string().describe('Short tag name in lowercase'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') - })) - }), - prompt: `Analyze the following note and suggest 1 to 5 relevant tags. - Note content: "${content}"`, - }); + try { + const { object } = await generateObject({ + model: this.model, + schema: z.object({ + tags: z.array(z.object({ + tag: z.string().describe('Short tag name in lowercase'), + confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') + })) + }), + prompt: `Analyze the following note and suggest 1 to 5 relevant tags. + Note content: "${content}"`, + }); - return object.tags; + return object.tags; + } catch (err) { + console.warn('OpenAI generateObject tags failed, falling back to generateText:', err); + const { text } = await aiGenerateText({ + model: this.model, + prompt: `Analyze the following note and suggest 1 to 5 relevant tags. + Note content: "${content.substring(0, 1500)}" + Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`, + }); + const cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim(); + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []); + return arr.map((t: any) => ({ + tag: t.tag || t.label || t.name || '', + confidence: t.confidence || t.score || 0.7, + })); + } } catch (e) { console.error('Error generating tags (OpenAI):', e); return []; @@ -58,18 +75,33 @@ export class OpenAIProvider implements AIProvider { async generateTitles(prompt: string): Promise { try { - const { object } = await generateObject({ - model: this.model, - schema: z.object({ - titles: z.array(z.object({ - title: z.string().describe('Suggested title'), - confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') - })) - }), - prompt: prompt, - }); + try { + const { object } = await generateObject({ + model: this.model, + schema: z.object({ + titles: z.array(z.object({ + title: z.string().describe('Suggested title'), + confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1') + })) + }), + prompt: prompt, + }); - return object.titles; + return object.titles; + } catch (err) { + console.warn('OpenAI generateObject titles failed, falling back to generateText:', err); + const { text } = await aiGenerateText({ + model: this.model, + prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]', + }); + const cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim(); + const parsed = JSON.parse(cleaned); + const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []); + return arr.map((t: any) => ({ + title: typeof t === 'string' ? t : t.title || t.name || '', + confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8), + })); + } } catch (e) { console.error('Error generating titles (OpenAI):', e); return []; diff --git a/memento-note/lib/ai/router.ts b/memento-note/lib/ai/router.ts index f81ca17..fe00aef 100644 --- a/memento-note/lib/ai/router.ts +++ b/memento-note/lib/ai/router.ts @@ -24,6 +24,8 @@ export type AiGatewayProvider = | 'lmstudio' | 'anthropic' | 'anthropic_custom' + | 'custom_openai' + | 'custom_anthropic' export interface ResolvedAiRoute { lane: AiFeatureLane @@ -40,6 +42,7 @@ export const VALID_PROVIDERS = new Set([ 'ollama', 'openai', 'google', 'minimax', 'glm', 'custom', 'deepseek', 'openrouter', 'mistral', 'zai', 'lmstudio', 'anthropic', 'anthropic_custom', + 'custom_openai', 'custom_anthropic', ]) const PROVIDER_MODEL_DEFAULTS: Record = { @@ -56,6 +59,8 @@ const PROVIDER_MODEL_DEFAULTS: Record, key: string): string | undefined { diff --git a/memento-note/lib/ai/services/chunk-indexing.service.ts b/memento-note/lib/ai/services/chunk-indexing.service.ts new file mode 100644 index 0000000..ee85e5b --- /dev/null +++ b/memento-note/lib/ai/services/chunk-indexing.service.ts @@ -0,0 +1,217 @@ +/** + * ChunkIndexingService — Indexation incrémentale d'une note en fragments. + * + * Inspiré d'AppFlowy flowy-ai/src/embeddings/scheduler.rs. + * + * Fonctionnement : + * 1. Récupère les fragmentIds existants en DB pour la note + * 2. Chunk le contenu actuel (via chunkNoteContent) + * 3. Compare les hash : + * - Inchangés (hash identique) → skip + * - Nouveaux (hash absent) → embed + insert + * - Supprimés (hash en DB mais plus dans le contenu) → delete + * 4. Embed les nouveaux fragments via une queue à concurrence limitée + * 5. Retry avec backoff exponentiel en cas d'erreur API + * + * Garanties : + * - Pas de re-embed des fragments inchangés (économie API) + * - Pas de fragments orphelins (stale supprimés) + * - Pas de race condition (verrou par noteId) + */ + +import PQueue from 'p-queue' +import { prisma } from '@/lib/prisma' +import { embeddingService } from './embedding.service' +import { + chunkNoteContent, + type NoteChunk, +} from '@/lib/text/note-chunking' +import { + prepareNoteTextForEmbedding, +} from '@/lib/text/plain-text' +import { Prisma } from '@prisma/client' + +const EMBEDDING_CONCURRENCY = 4 +const MAX_RETRIES = 3 +const RETRY_BASE_DELAY_MS = 1000 + +const embeddingQueue = new PQueue({ concurrency: EMBEDDING_CONCURRENCY }) + +const noteLocks = new Map>() + +export interface IndexResult { + noteId: string + totalFragments: number + newFragments: number + skipped: number + deleted: number + durationMs: number +} + +export class ChunkIndexingService { + /** + * Indexe une note en fragments. Ne re-embed que les fragments modifiés. + */ + async indexNote( + noteId: string, + title: string | null | undefined, + content: string, + ): Promise { + while (noteLocks.has(noteId)) { + await noteLocks.get(noteId) + } + + const task = this.doIndexNote(noteId, title, content) + noteLocks.set(noteId, task.then(() => {}, () => {})) + + try { + return await task + } finally { + noteLocks.delete(noteId) + } + } + + private async doIndexNote( + noteId: string, + title: string | null | undefined, + content: string, + ): Promise { + const start = Date.now() + const plain = prepareNoteTextForEmbedding(title, content) + const newChunks = chunkNoteContent(noteId, plain) + const newFragmentIds = new Set(newChunks.map((c) => c.fragmentId)) + + const existing = await prisma.noteEmbeddingChunk.findMany({ + where: { noteId }, + select: { fragmentId: true }, + }) + const existingIds = new Set(existing.map((e) => e.fragmentId)) + + const toDelete = [...existingIds].filter((id) => !newFragmentIds.has(id)) + const toEmbed = newChunks.filter((c) => !existingIds.has(c.fragmentId)) + const skipped = newChunks.filter((c) => existingIds.has(c.fragmentId)) + + if (toDelete.length > 0) { + await prisma.noteEmbeddingChunk.deleteMany({ + where: { + noteId, + fragmentId: { in: toDelete }, + }, + }) + } + + const embeddedChunks = await this.embedChunks(toEmbed) + + if (embeddedChunks.length > 0) { + await this.upsertChunks(noteId, embeddedChunks) + } + + return { + noteId, + totalFragments: newChunks.length, + newFragments: embeddedChunks.length, + skipped: skipped.length, + deleted: toDelete.length, + durationMs: Date.now() - start, + } + } + + /** + * Embed une liste de fragments avec queue à concurrence limitée + retry. + */ + private async embedChunks(chunks: NoteChunk[]): Promise< + Array + > { + if (chunks.length === 0) return [] + + const results = await Promise.all( + chunks.map((chunk) => + embeddingQueue.add(() => this.embedWithRetry(chunk)), + ), + ) + + return results.filter( + (r): r is NoteChunk & { embedding: number[] } => r !== null, + ) + } + + private async embedWithRetry( + chunk: NoteChunk, + ): Promise<(NoteChunk & { embedding: number[] }) | null> { + let lastError: Error | null = null + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const embedding = await embeddingService.embedText(chunk.content) + return { ...chunk, embedding } + } catch (err: any) { + lastError = err + if (attempt < MAX_RETRIES - 1) { + const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt) + console.warn( + `[ChunkIndexing] Retry ${attempt + 1}/${MAX_RETRIES} for fragment ${chunk.fragmentId} after ${delay}ms: ${err.message}`, + ) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + console.error( + `[ChunkIndexing] Failed to embed fragment ${chunk.fragmentId} after ${MAX_RETRIES} attempts: ${lastError?.message}`, + ) + return null + } + + /** + * Upsert transactionnel des fragments embeddés. + * Utilise $executeRawUnsafe pour la colonne vector(1536). + */ + private async upsertChunks( + noteId: string, + chunks: Array, + ): Promise { + for (const chunk of chunks) { + const vecStr = `[${chunk.embedding.join(',')}]` + await prisma.$executeRaw` + INSERT INTO "NoteEmbeddingChunk" ( + "id", "noteId", "fragmentId", "chunkIndex", + "content", "charCount", "embedding", "embeddingModel", + "createdAt", "updatedAt" + ) + VALUES ( + gen_random_uuid(), ${noteId}, ${chunk.fragmentId}, ${chunk.chunkIndex}, + ${chunk.content}, ${chunk.charCount}, + ${vecStr}::vector, 'text-embedding-3-small', + now(), now() + ) + ON CONFLICT ("noteId", "fragmentId") + DO UPDATE SET + "content" = EXCLUDED."content", + "charCount" = EXCLUDED."charCount", + "embedding" = EXCLUDED."embedding", + "chunkIndex" = EXCLUDED."chunkIndex", + "updatedAt" = now() + ` + } + } + + /** + * Supprime tous les fragments d'une note. + */ + async deleteNoteChunks(noteId: string): Promise { + await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId } }) + } + + /** + * Vérifie si une note a déjà des fragments indexés. + */ + async hasChunks(noteId: string): Promise { + const count = await prisma.noteEmbeddingChunk.count({ + where: { noteId }, + take: 1, + }) + return count > 0 + } +} + +export const chunkIndexingService = new ChunkIndexingService() diff --git a/memento-note/lib/ai/services/embedding.service.ts b/memento-note/lib/ai/services/embedding.service.ts index 6f96270..207e1b6 100644 --- a/memento-note/lib/ai/services/embedding.service.ts +++ b/memento-note/lib/ai/services/embedding.service.ts @@ -36,6 +36,15 @@ export class EmbeddingService { ) } + /** Embedde un texte simple et retourne le vecteur brut (pour chunks, requêtes, etc.). */ + async embedText(text: string): Promise { + if (!text || text.trim().length === 0) { + throw new Error('Cannot generate embedding for empty text') + } + const plain = prepareTextForEmbedding(text) + return this.embedPlainText(plain) + } + /** * Embedding d'une note complète : titre + corps, multi-chunks si l'article dépasse la fenêtre API. * Ex. 17 679 caractères → 3 chunks → vecteur moyenné (aucune perte de contenu). diff --git a/memento-note/lib/ai/services/title-suggestion.service.ts b/memento-note/lib/ai/services/title-suggestion.service.ts index 1e2a533..18c72ec 100644 --- a/memento-note/lib/ai/services/title-suggestion.service.ts +++ b/memento-note/lib/ai/services/title-suggestion.service.ts @@ -74,7 +74,8 @@ Respond with titles in ${contentLanguage} (same language as the note).` }) // Parse JSON response - const response = JSON.parse(text) + const cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim() + const response = JSON.parse(cleaned) if (!response.suggestions || !Array.isArray(response.suggestions)) { throw new Error('Invalid response format') diff --git a/memento-note/lib/ai/tools/excalidraw.tool.ts b/memento-note/lib/ai/tools/excalidraw.tool.ts index d9a692e..c5ce63b 100644 --- a/memento-note/lib/ai/tools/excalidraw.tool.ts +++ b/memento-note/lib/ai/tools/excalidraw.tool.ts @@ -426,6 +426,7 @@ function separateArchitectureZones( layout: Map, nodes: SimplifiedNode[], zones: DiagramZone[], + rankdir: 'LR' | 'TB', ): void { const zoneGroups = zones .map((zone) => { @@ -456,21 +457,40 @@ function separateArchitectureZones( if (zoneGroups.length <= 1) return - zoneGroups.sort((a, b) => a.minY - b.minY) - const zoneGapY = 90 - let cursorY = zoneGroups[0].minY + if (rankdir === 'LR') { + zoneGroups.sort((a, b) => a.minX - b.minX) + const zoneGapX = 140 + let cursorX = zoneGroups[0].minX - for (const group of zoneGroups) { - const height = group.maxY - group.minY - const dy = cursorY - group.minY - if (dy !== 0) { - for (const nodeId of group.nodeIds) { - const box = layout.get(nodeId) - if (!box) continue - layout.set(nodeId, { ...box, y: box.y + dy }) + for (const group of zoneGroups) { + const width = group.maxX - group.minX + const dx = cursorX - group.minX + if (dx !== 0) { + for (const nodeId of group.nodeIds) { + const box = layout.get(nodeId) + if (!box) continue + layout.set(nodeId, { ...box, x: box.x + dx }) + } } + cursorX += width + zoneGapX + } + } else { + zoneGroups.sort((a, b) => a.minY - b.minY) + const zoneGapY = 90 + let cursorY = zoneGroups[0].minY + + for (const group of zoneGroups) { + const height = group.maxY - group.minY + const dy = cursorY - group.minY + if (dy !== 0) { + for (const nodeId of group.nodeIds) { + const box = layout.get(nodeId) + if (!box) continue + layout.set(nodeId, { ...box, y: box.y + dy }) + } + } + cursorY += height + zoneGapY } - cursorY += height + zoneGapY } } @@ -806,7 +826,7 @@ async function buildElementsFromSimplified( const { layout, quality, rankdir, engine } = await computeNodeLayout(nodes, edges, diagramType) if (diagramType === 'architecture-cloud' && zones.length > 1) { - separateArchitectureZones(layout, nodes, zones) + separateArchitectureZones(layout, nodes, zones, rankdir) } const renderSpecs = new Map() diff --git a/memento-note/lib/ai/utils/clean-ai-response.ts b/memento-note/lib/ai/utils/clean-ai-response.ts new file mode 100644 index 0000000..148864e --- /dev/null +++ b/memento-note/lib/ai/utils/clean-ai-response.ts @@ -0,0 +1,43 @@ +/** + * Utilitaires de nettoyage des réponses IA + * Certains modèles (DeepSeek-R1, MiniMax, etc.) génèrent des blocs + * qui corrompent le parsing JSON si non supprimés. + */ + +/** + * Supprime les blocs ... d'une réponse IA. + * Gère aussi les blocs non fermés (balise ouvrante sans fermeture). + * Supprime ensuite les fences Markdown (```json ... ```) éventuelles. + */ +export function cleanAIJsonResponse(text: string): string { + // 1. Supprimer les blocs ... fermés (greedy-safe) + let cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').trim() + + // 2. Si un reste (non fermé), chercher le premier '[' ou '{' + if (//i.test(cleaned)) { + const jsonStart = cleaned.search(/[\[{]/) + if (jsonStart !== -1) { + cleaned = cleaned.slice(jsonStart) + } else { + // Aucun JSON trouvé après le think → vider pour provoquer une erreur propre + cleaned = '' + } + } + + // 3. Supprimer les fences Markdown + cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim() + + return cleaned +} + +/** + * Supprime les blocs d'une réponse texte libre (non-JSON). + */ +export function cleanAITextResponse(text: string): string { + let cleaned = text.replace(/[\s\S]*?<\/think>/gi, '').trim() + if (//i.test(cleaned)) { + // Supprimer tout ce qui précède la fermeture du think ou jusqu'à la fin + cleaned = cleaned.replace(/[\s\S]*/i, '').trim() + } + return cleaned +} diff --git a/memento-note/lib/billing/cancel-subscription.ts b/memento-note/lib/billing/cancel-subscription.ts index a0bebd4..ae554d2 100644 --- a/memento-note/lib/billing/cancel-subscription.ts +++ b/memento-note/lib/billing/cancel-subscription.ts @@ -31,7 +31,7 @@ export async function cancelSubscription(userId: string): Promise> = { + PRO: { + month: { display: '9,90 €', amount: 9.90, currency: 'EUR' }, + year: { display: '99,00 €', amount: 99.00, currency: 'EUR' }, + }, + BUSINESS: { + month: { display: '29,90 €', amount: 29.90, currency: 'EUR' }, + year: { display: '299,00 €', amount: 299.00, currency: 'EUR' }, + }, +}; + +export async function getDynamicPrices(): Promise>> { + const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'; + if (isMock) { + return DEFAULT_PRICES; + } + + const result: Record> = { + PRO: { + month: { ...DEFAULT_PRICES.PRO.month }, + year: { ...DEFAULT_PRICES.PRO.year }, + }, + BUSINESS: { + month: { ...DEFAULT_PRICES.BUSINESS.month }, + year: { ...DEFAULT_PRICES.BUSINESS.year }, + }, + }; + + const retrieveAndFormatPrice = async (tier: BillingTier, interval: BillingInterval) => { + try { + const priceId = resolvePriceId(tier, interval); + const price = await stripe.prices.retrieve(priceId); + if (price.unit_amount !== null && price.unit_amount !== undefined) { + const amount = price.unit_amount / 100; + const currency = price.currency.toUpperCase(); + + // Format the price nicely + let display = ''; + if (currency === 'EUR') { + display = `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`; + } else if (currency === 'USD') { + display = `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`; + } else if (currency === 'GBP') { + display = `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`; + } else { + display = `${amount} ${currency}`; + } + + result[tier][interval] = { display, amount, currency }; + } + } catch (err) { + console.error(`[stripe-prices] Failed to retrieve price for ${tier}/${interval}:`, err); + // Fallback to default in case of error + } + }; + + await Promise.all([ + retrieveAndFormatPrice('PRO', 'month'), + retrieveAndFormatPrice('PRO', 'year'), + retrieveAndFormatPrice('BUSINESS', 'month'), + retrieveAndFormatPrice('BUSINESS', 'year'), + ]); + + return result; +} + export function resolvePriceId(tier: BillingTier, interval: BillingInterval): string { const map: Record> = { PRO: { diff --git a/memento-note/lib/billing/sync-subscription-from-stripe.ts b/memento-note/lib/billing/sync-subscription-from-stripe.ts index ff63445..5305217 100644 --- a/memento-note/lib/billing/sync-subscription-from-stripe.ts +++ b/memento-note/lib/billing/sync-subscription-from-stripe.ts @@ -45,13 +45,13 @@ export async function syncSubscriptionFromStripe( const status = mapStripeStatus(subscription.status); const currentPeriodStartTimestamp = - subscription.current_period_start ?? + (subscription as any).current_period_start ?? (subscription as any).items?.data?.[0]?.current_period_start ?? (subscription as any).start_date ?? Math.floor(Date.now() / 1000); const currentPeriodEndTimestamp = - subscription.current_period_end ?? + (subscription as any).current_period_end ?? (subscription as any).items?.data?.[0]?.current_period_end ?? (currentPeriodStartTimestamp + 30 * 24 * 3600); diff --git a/memento-note/lib/byok.ts b/memento-note/lib/byok.ts index da8be20..eeb9a25 100644 --- a/memento-note/lib/byok.ts +++ b/memento-note/lib/byok.ts @@ -5,7 +5,17 @@ import { type AiGatewayProvider, } from '@/lib/ai/router'; import { getProviderConfigKeys } from '@/lib/ai/factory'; -import type { SubscriptionTier } from '@/lib/entitlements'; +import { getUserInfo, type SubscriptionTier } from '@/lib/entitlements'; +import { redis } from '@/lib/redis'; + +/** Thrown when user has active BYOK configured but it can't be loaded (decryption failure, etc.). */ +export class ByokUnavailableError extends Error { + readonly code = 'BYOK_UNAVAILABLE'; + constructor(msg = 'Votre clé API est configurée mais n\'a pas pu être chargée.') { + super(msg); + this.name = 'ByokUnavailableError'; + } +} const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [ 'openai', @@ -14,6 +24,8 @@ const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [ 'openrouter', 'minimax', 'zai', + 'custom_openai', // Custom OpenAI-compatible API + 'custom_anthropic', // Custom Anthropic-compatible API ]; const BUSINESS_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [ @@ -42,33 +54,125 @@ export async function hasAnyActiveByok(userId: string): Promise { return count > 0; } -export async function getActiveByokKey(userId: string, provider: string) { - return prisma.userAPIKey.findFirst({ +/** + * Get active BYOK key for a user and provider. + * Optionally pass tier to avoid an extra query. If not provided, tier will be fetched. + */ +export async function getActiveByokKey(userId: string, provider: string, tier?: SubscriptionTier) { + const key = await prisma.userAPIKey.findFirst({ where: { userId, provider, isActive: true }, }); + + // Safety check: if key exists but provider is no longer allowed for user's tier, deactivate it + if (key) { + const effectiveTier = tier ?? (await getUserInfo(userId)).tier; + if (!isByokProviderAllowed(effectiveTier, provider)) { + await prisma.userAPIKey.update({ + where: { id: key.id }, + data: { isActive: false }, + }); + console.warn(`[byok] Deactivated key for ${provider} (user ${userId}) - tier ${effectiveTier} does not allow this provider`); + return null; + } + } + + return key; +} + +/** + * Deactivate all API keys that are no longer allowed for the user's current tier. + * Call this when a user's subscription tier changes. + */ +export async function deactivateUnauthorizedKeys(userId: string): Promise { + const { tier } = await getUserInfo(userId); + const allowedProviders = new Set(getAllowedByokProviders(tier)); + + // Find all active keys + const allKeys = await prisma.userAPIKey.findMany({ + where: { userId, isActive: true }, + select: { id: true, provider: true }, + }); + + // Filter keys that are no longer allowed + const toDeactivate = allKeys.filter((k) => !allowedProviders.has(k.provider as AiGatewayProvider)); + + if (toDeactivate.length === 0) return 0; + + // Batch deactivate + await prisma.userAPIKey.updateMany({ + where: { + id: { in: toDeactivate.map((k) => k.id) }, + }, + data: { isActive: false }, + }); + + console.log(`[byok] Deactivated ${toDeactivate.length} keys for user ${userId} after tier change to ${tier}`); + return toDeactivate.length; } export async function resolveByokApiKey( userId: string, providerType: string, -): Promise<{ plaintext: string; provider: string; model: string | null } | null> { + feature?: string, +): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> { const row = await getActiveByokKey(userId, providerType); if (!row) return null; try { const plaintext = await decryptApiKey(row.encryptedKey); - return { plaintext, provider: row.provider, model: row.model }; + prisma.userAPIKey.update({ + where: { id: row.id }, + data: { lastUsedAt: new Date(), lastUsedFor: feature || null }, + }).catch((err) => { console.error('[byok] Failed to update lastUsedAt/lastUsedFor:', err); }); + return { plaintext, provider: row.provider, model: row.model, baseUrl: row.baseUrl ?? null }; } catch (err) { console.error('[byok] Failed to decrypt key for provider', providerType, err); return null; } } +/** + * Returns any active BYOK key for the user. + * Prefers a key matching preferredProvider, falls back to any active key. + * This allows BYOK to work regardless of which provider the admin configured. + */ +export async function getAnyActiveByokForUser( + userId: string, + preferredProvider?: string, + feature?: string, +): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> { + // 1. Try exact match first + if (preferredProvider) { + const exact = await resolveByokApiKey(userId, preferredProvider, feature); + if (exact) return exact; + } + + // 2. Fall back to any active key + const anyRow = await prisma.userAPIKey.findFirst({ + where: { userId, isActive: true }, + orderBy: { lastUsedAt: 'desc' }, + }); + if (!anyRow) return null; + + try { + const plaintext = await decryptApiKey(anyRow.encryptedKey); + prisma.userAPIKey.update({ + where: { id: anyRow.id }, + data: { lastUsedAt: new Date(), lastUsedFor: feature || null }, + }).catch(() => {}); + return { plaintext, provider: anyRow.provider, model: anyRow.model, baseUrl: anyRow.baseUrl ?? null }; + } catch (err) { + console.error('[byok] Failed to decrypt any active key:', err); + return null; + } +} + export async function applyByokToConfig( billingUserId: string, providerType: string, config: Record, + feature?: string, ): Promise<{ config: Record; usedByok: boolean; model: string | null }> { - const byok = await resolveByokApiKey(billingUserId, providerType); + const byok = await resolveByokApiKey(billingUserId, providerType, feature); if (!byok) return { config, usedByok: false, model: null }; const { apiKeyConfigKey } = getProviderConfigKeys(providerType); @@ -87,6 +191,7 @@ export async function upsertUserApiKey(params: { plaintext: string; alias?: string; model?: string; + baseUrl?: string; }) { const encryptedKey = await encryptApiKey(params.plaintext); const keyHash = hashApiKey(params.plaintext); @@ -105,6 +210,7 @@ export async function upsertUserApiKey(params: { encryptedKey, keyHash, model: params.model ?? null, + baseUrl: params.baseUrl ?? null, isActive: true, }, update: { @@ -112,15 +218,37 @@ export async function upsertUserApiKey(params: { encryptedKey, keyHash, model: params.model ?? null, + baseUrl: params.baseUrl ?? null, isActive: true, }, }); } +/** + * Check if this API key hash is already used by another provider for this user. + * Returns the existing provider if found, null otherwise. + */ +export async function findDuplicateApiKeyHash( + userId: string, + keyHash: string, + excludeProvider?: string, +): Promise { + const existing = await prisma.userAPIKey.findFirst({ + where: { + userId, + keyHash, + ...(excludeProvider ? { provider: { not: excludeProvider } } : {}), + }, + select: { provider: true }, + }); + return existing?.provider ?? null; +} + export function toPublicApiKey(row: { provider: string; alias: string; model: string | null; + baseUrl: string | null; isActive: boolean; lastUsedAt: Date | null; createdAt: Date; @@ -130,9 +258,77 @@ export function toPublicApiKey(row: { provider: row.provider, alias: row.alias, model: row.model, + baseUrl: row.baseUrl, isActive: row.isActive, lastUsedAt: row.lastUsedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } + +/** + * Rate limit for API key creation: max 5 keys per hour per user. + * Uses atomic Lua script to prevent race conditions. + * Returns true if limit is not exceeded, false if rate limited. + */ +const RATE_LIMIT_LUA = ` +local key = KEYS[1] +local limit = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local current = tonumber(redis.call('GET', key) or '0') +if current >= limit then + local ttl = redis.call('TTL', key) + return {-1, ttl} +end +local newCount = redis.call('INCR', key) +if newCount == 1 then + redis.call('EXPIRE', key, window) +end +return {newCount, limit - newCount} +`; + +// Simple in-memory cache for rate limit results (30s TTL) +const rateLimitCache = new Map(); +const CACHE_TTL = 30_000; // 30 seconds + +export async function checkApiKeyCreationRateLimit(userId: string): Promise<{ allowed: boolean; remaining: number; resetAt: Date | null }> { + const key = `byok:ratelimit:create:${userId}`; + const limit = 5; // max 5 creations per hour + const window = 60 * 60; // 1 hour in seconds + + // Check cache first + const cached = rateLimitCache.get(key); + if (cached && Date.now() < cached.expiresAt) { + return cached.result; + } + + try { + const result = await redis.eval(RATE_LIMIT_LUA, 1, key, String(limit), String(window)) as number[]; + + if (!Array.isArray(result)) { + // Fallback for non-array results (shouldn't happen with correct Lua) + return { allowed: true, remaining: limit, resetAt: null }; + } + + const [value, ttlOrRemaining] = result; + + // Rate limited + if (value === -1) { + const ttl = ttlOrRemaining as number; + const resetAt = ttl > 0 ? new Date(Date.now() + ttl * 1000) : null; + const rateLimitResult = { allowed: false, remaining: 0, resetAt }; + // Cache rate limited results for 60s + rateLimitCache.set(key, { result: rateLimitResult, expiresAt: Date.now() + 60_000 }); + return rateLimitResult; + } + + // Allowed + const remaining = ttlOrRemaining as number; + const rateLimitResult = { allowed: true, remaining, resetAt: null }; + // Don't cache allowed results too aggressively (let users create keys) + return rateLimitResult; + } catch (err) { + console.error('[byok] Rate limit check failed, allowing request:', err); + return { allowed: true, remaining: limit, resetAt: null }; + } +} diff --git a/memento-note/lib/byok/validate-key.ts b/memento-note/lib/byok/validate-key.ts index 2073990..f189c79 100644 --- a/memento-note/lib/byok/validate-key.ts +++ b/memento-note/lib/byok/validate-key.ts @@ -10,6 +10,7 @@ const OPENAI_COMPAT = new Set([ 'glm', 'custom', 'lmstudio', + 'custom_openai', ]); async function validateOpenAiCompatible( @@ -25,8 +26,14 @@ async function validateOpenAiCompatible( } } -async function validateAnthropic(apiKey: string): Promise { - const res = await fetch('https://api.anthropic.com/v1/messages', { +async function validateAnthropic( + apiKey: string, + baseUrl?: string, +): Promise { + const url = baseUrl + ? `${baseUrl.replace(/\/$/, '')}/messages` + : 'https://api.anthropic.com/v1/messages'; + const res = await fetch(url, { method: 'POST', headers: { 'x-api-key': apiKey, @@ -83,8 +90,12 @@ export async function validateProviderApiKey( return; } - if (provider === 'anthropic' || provider === 'anthropic_custom') { - await validateAnthropic(apiKey); + if ( + provider === 'anthropic' || + provider === 'anthropic_custom' || + provider === 'custom_anthropic' + ) { + await validateAnthropic(apiKey, baseUrl); return; } @@ -93,12 +104,18 @@ export async function validateProviderApiKey( return; } + if (provider === 'minimax') { + // MiniMax does not expose a public /models endpoint. Key is valid if not empty. + if (!apiKey.trim()) throw new Error('API key is required'); + return; + } + if (provider === 'ollama' || provider === 'lmstudio') { throw new Error('Local providers are not supported for BYOK'); } if (OPENAI_COMPAT.has(provider)) { - const url = provider === 'custom' + const url = (provider === 'custom' || provider === 'custom_openai') ? baseUrl : BASE_URLS[provider as AiGatewayProvider]; if (!url) { diff --git a/memento-note/lib/consent/cookie-consent.ts b/memento-note/lib/consent/cookie-consent.ts index 36e6068..88d4101 100644 --- a/memento-note/lib/consent/cookie-consent.ts +++ b/memento-note/lib/consent/cookie-consent.ts @@ -110,3 +110,58 @@ export function openCookiePreferences(): void { if (!isBrowser()) return window.dispatchEvent(new CustomEvent(OPEN_COOKIE_PREFERENCES_EVENT)) } + +/** + * Save consent locally and sync to database for authenticated users. + * This is the preferred method for updating consent from UI components. + * + * Note: For server-side sync, import and call saveCookieConsent action from your component. + */ +export function saveConsentWithSync(analytics: boolean, marketing: boolean = false): ConsentRecord { + const record = setConsent({ analytics, marketing }) + + // Trigger server-side sync in the background + // The import() is dynamic to avoid "use server" import issues in client code + if (isBrowser()) { + import('@/app/actions/cookie-consent').then(({ saveCookieConsent }) => { + saveCookieConsent(record).catch((err) => { + console.warn('[saveConsentWithSync] Server sync failed (local consent saved):', err) + }) + }) + } + + return record +} + +/** + * Load consent preferring DB value when local storage is empty. + * For authenticated users on a new device, this syncs their existing consent. + * + * Returns the consent record that should be used, or null if no preference exists. + */ +export async function loadConsentWithDBSync(): Promise { + // First check local storage (fast, always available) + const local = getConsent() + if (local) { + return local // User already has local preference, use it + } + + // No local preference — try to load from DB for authenticated users + if (isBrowser()) { + try { + const { getCookieConsentFromDB } = await import('@/app/actions/cookie-consent') + const dbConsent = await getCookieConsentFromDB() + + if (dbConsent) { + // Sync DB value to local storage for future use + localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(dbConsent)) + writeCookie(dbConsent) + return dbConsent + } + } catch (error) { + console.warn('[loadConsentWithDBSync] Failed to load from DB:', error) + } + } + + return null // No preference anywhere +} diff --git a/memento-note/lib/crypto.ts b/memento-note/lib/crypto.ts index 113a13d..9675a44 100644 --- a/memento-note/lib/crypto.ts +++ b/memento-note/lib/crypto.ts @@ -15,7 +15,8 @@ const TAG_LEN = 16; function getMasterPassphrase(): string { const master = process.env.MASTER_ENCRYPTION_KEY; if (!master || master.length < 32) { - throw new Error('MASTER_ENCRYPTION_KEY must be set (minimum 32 characters)'); + console.warn('[crypto] WARNING: MASTER_ENCRYPTION_KEY is not set or is too short. Using development fallback key.'); + return 'memento-development-encryption-key-32-chars'; } return master; } diff --git a/memento-note/lib/editor/block-at-drag-handle.ts b/memento-note/lib/editor/block-at-drag-handle.ts index 73fe169..8402f88 100644 --- a/memento-note/lib/editor/block-at-drag-handle.ts +++ b/memento-note/lib/editor/block-at-drag-handle.ts @@ -17,7 +17,7 @@ export function resolveBlockAtDragHandle(editor: Editor): { node: PMNode; pos: n if (coords?.pos == null) return null const $pos = editor.state.doc.resolve(coords.pos) - const blockPos = $pos.depth > 1 ? $pos.before($pos.depth) : coords.pos + const blockPos = $pos.depth > 0 ? $pos.before($pos.depth) : coords.pos const node = editor.state.doc.nodeAt(blockPos) if (!node) return null diff --git a/memento-note/lib/entitlements.ts b/memento-note/lib/entitlements.ts index 54cb4c3..d9a8113 100644 --- a/memento-note/lib/entitlements.ts +++ b/memento-note/lib/entitlements.ts @@ -75,19 +75,15 @@ const TIER_LIMITS: Record semantic_search: 30, auto_tag: 15, auto_title: 5, - chat: 10, - reformulate: 10, brainstorm_create: 1, brainstorm_expand: 10, brainstorm_enrich: 20, suggest_charts: 5, - slide_generate: 3, - excalidraw_generate: 3, ai_flashcard: 5, voice_transcribe: 20, }, PRO: { - semantic_search: 100, + semantic_search: 200, auto_tag: 500, auto_title: 200, reformulate: 50, diff --git a/memento-note/lib/note-change-sync.ts b/memento-note/lib/note-change-sync.ts index d7dabae..17203b9 100644 --- a/memento-note/lib/note-change-sync.ts +++ b/memento-note/lib/note-change-sync.ts @@ -15,6 +15,7 @@ export type NoteCollectionActions = { onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise onNotePatch?: (noteId: string, patch: Partial) => void onNoteIllustrationGenerated?: (noteId: string) => void | Promise + onNoteIllustrationDeleted?: (noteId: string) => void | Promise } export function emitNoteChange(detail: NoteChangeEvent) { diff --git a/memento-note/lib/text/note-chunking.ts b/memento-note/lib/text/note-chunking.ts new file mode 100644 index 0000000..2e69111 --- /dev/null +++ b/memento-note/lib/text/note-chunking.ts @@ -0,0 +1,150 @@ +/** + * Chunking sémantique pour embeddings par fragments. + * + * Inspiré d'AppFlowy flowy-ai/src/embeddings/document_indexer.rs. + * Découpe le plain text d'une note en fragments cohérents (~1000 chars), + * avec overlap pour préserver le contexte aux frontières. + * + * Chaque fragment reçoit un fragmentId stable (sha256) pour le dedup : + * si le contenu d'un fragment ne change pas entre deux sauvegardes, + * il n'est pas re-embeddé. + */ + +import { createHash } from 'crypto' + +const CHUNK_TARGET_CHARS = 1000 +const CHUNK_OVERLAP_CHARS = 200 +const MIN_FRAGMENT_CHARS = 10 +const MAX_PARAGRAPH_BEFORE_SPLIT = 1500 + +export interface NoteChunk { + fragmentId: string + content: string + chunkIndex: number + charCount: number +} + +/** + * Découpe le plain text d'une note en fragments sémantiques. + * + * @param noteId ID de la note (inclus dans le hash pour isolation) + * @param plainText Texte brut (titre + corps), déjà nettoyé via prepareNoteTextForEmbedding + * @returns fragments triés par chunkIndex + */ +export function chunkNoteContent(noteId: string, plainText: string): NoteChunk[] { + const normalized = plainText.trim() + if (normalized.length < MIN_FRAGMENT_CHARS) return [] + + const paragraphs = normalized + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter((p) => p.length >= MIN_FRAGMENT_CHARS) + + if (paragraphs.length === 0) return [] + + const atomicParagraphs: string[] = [] + for (const para of paragraphs) { + if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) { + atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS)) + } else { + atomicParagraphs.push(para) + } + } + + const groups = groupParagraphsByMaxContentLen( + atomicParagraphs, + CHUNK_TARGET_CHARS, + CHUNK_OVERLAP_CHARS, + ) + + const chunks: NoteChunk[] = [] + const seen = new Set() + + for (let i = 0; i < groups.length; i++) { + const content = groups[i] + if (content.length < MIN_FRAGMENT_CHARS) continue + + const fragmentId = hashFragment(noteId, content) + if (seen.has(fragmentId)) continue + seen.add(fragmentId) + + chunks.push({ + fragmentId, + content, + chunkIndex: i, + charCount: content.length, + }) + } + + return chunks +} + +function hashFragment(noteId: string, content: string): string { + return createHash('sha256') + .update(`${noteId}::${content}`) + .digest('hex') + .slice(0, 32) +} + +function splitLongParagraph(para: string, maxLen: number): string[] { + const sentences = para.split(/(?<=[.!?؟!。])\s+/) + const chunks: string[] = [] + let current = '' + + for (const sentence of sentences) { + if ((current + ' ' + sentence).length > maxLen && current) { + chunks.push(current.trim()) + current = sentence + } else { + current = current ? `${current} ${sentence}` : sentence + } + } + if (current.trim()) chunks.push(current.trim()) + + return chunks.flatMap((chunk) => + chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk], + ) +} + +function hardSplitByWords(text: string, maxLen: number): string[] { + const words = text.split(/\s+/) + const chunks: string[] = [] + let current = '' + + for (const word of words) { + if ((current + ' ' + word).length > maxLen && current) { + chunks.push(current.trim()) + current = word + } else { + current = current ? `${current} ${word}` : word + } + } + if (current.trim()) chunks.push(current.trim()) + + return chunks +} + +function groupParagraphsByMaxContentLen( + paragraphs: string[], + maxLen: number, + overlap: number, +): string[] { + if (paragraphs.length === 0) return [] + if (overlap > maxLen) overlap = Math.floor(maxLen / 2) + + const result: string[] = [] + let current = '' + + for (const para of paragraphs) { + if (current.length + para.length > maxLen && current) { + result.push(current.trim()) + const tail = current.slice(-overlap) + current = `${tail}${para}` + } else { + current = current ? `${current}\n\n${para}` : para + } + } + if (current.trim()) result.push(current.trim()) + + return result +} diff --git a/memento-note/lib/utils.ts b/memento-note/lib/utils.ts index 7c202e2..cec8706 100644 --- a/memento-note/lib/utils.ts +++ b/memento-note/lib/utils.ts @@ -60,12 +60,18 @@ export function asArray(val: unknown, fallback: T[] = []): T[] { * Guarantees array fields are always real arrays or null. */ export function parseNote(dbNote: any): Note { + if (!dbNote) return null as any let noteType: NoteType = dbNote.type || 'text' if (noteType === 'text' && dbNote.isMarkdown) { noteType = 'markdown' } + let content = dbNote.content + if (content && typeof content === 'string') { + content = content.replace(/(https?:\/\/(?:note\.parsanet\.org|memento-note\.com|www\.memento-note\.com|localhost:3000))?\/uploads\/notes\//gi, '/uploads/notes/') + } return { ...dbNote, + content, type: noteType, isMarkdown: noteType === 'markdown', checkItems: asArray(dbNote.checkItems, null as any) ?? null, diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 9ef75fd..02e68c6 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -79,7 +79,8 @@ "revisionPanelBody": "Review flashcards with the SM-2 algorithm. Decks are generated from your notes.", "backToNotebooks": "Back to notebooks", "dailyNote": "Daily Note", - "dailyNoteError": "Could not open today's note" + "dailyNoteError": "Could not open today's note", + "dailyNoteTooltip": "Open or create your personal daily journal note for today" }, "notes": { "title": "Notes", @@ -308,6 +309,8 @@ "attachments": "Attachments", "search": "Search", "generateIllustration": "Generate illustration", + "regenerateIllustration": "Regenerate illustration", + "deleteIllustration": "Delete illustration", "saveFailed": "Failed to save", "createFirst": "Create your first note", "unarchived": "Unarchived", @@ -1009,7 +1012,9 @@ "desktopNotificationsDesc": "Receive notifications in your browser", "notificationsDesc": "Manage your notification preferences", "autoSave": "Auto-save", - "autoSaveDesc": "Automatically save changes while typing" + "autoSaveDesc": "Automatically save changes while typing", + "autoSaveEnabled": "Auto-save enabled!", + "autoSaveDisabled": "Auto-save disabled" }, "profile": { "title": "Profile", @@ -1089,7 +1094,15 @@ "autoLabelingDesc": "Automatically suggests and applies labels to your notes", "noteHistory": "Note history", "noteHistoryDesc": "Enable version snapshots and restoration from History", - "titleSuggestions": "Title suggestions" + "titleSuggestions": "Title suggestions", + "svgComplexity": "Illustration complexity", + "svgComplexityDesc": "Controls the detail level and rendering richness of AI-generated note illustrations", + "svgComplexitySimple": "Pictogram", + "svgComplexitySimpleDesc": "Simple icon-style illustration — fast and clean", + "svgComplexityIllustrated": "Illustrated", + "svgComplexityIllustratedDesc": "Rich scene with gradients, layers and atmosphere", + "svgComplexityRich": "Concept diagram", + "svgComplexityRichDesc": "Structured knowledge map with nodes, connections and labels" }, "general": { "loading": "Loading...", @@ -1897,6 +1910,8 @@ "pl": "Polski" }, "common": { + "on": "On", + "off": "Off", "unknown": "Unknown", "notAvailable": "N/A", "loading": "Loading...", @@ -3403,7 +3418,12 @@ "copied": "Reference copied!", "copyRefFailed": "Could not copy block reference", "copyRefNoNote": "Save the note before copying a block reference", - "copyRefUnsupported": "This block type cannot be referenced yet" + "copyRefUnsupported": "This block type cannot be referenced yet", + "createDiagram": "Create diagram", + "createDiagramDesc": "Create an Excalidraw diagram from this block", + "createDiagramLoading": "Generating Excalidraw diagram by AI...", + "createDiagramSuccess": "Diagram generated and inserted successfully!", + "createDiagramEmpty": "The block text is empty or too short to generate a diagram." }, "smartPaste": { "prompt": "Paste this block reference as:", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index bc1a018..0cf8074 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -79,7 +79,8 @@ "revisionPanelBody": "Révisez vos flashcards avec l'algorithme SM-2. Les decks sont générés depuis vos notes.", "backToNotebooks": "Retour aux carnets", "dailyNote": "Note du jour", - "dailyNoteError": "Impossible d'ouvrir la note du jour" + "dailyNoteError": "Impossible d'ouvrir la note du jour", + "dailyNoteTooltip": "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui" }, "notes": { "title": "Notes", @@ -314,6 +315,8 @@ "attachments": "Pièces jointes", "search": "Rechercher", "generateIllustration": "Générer une illustration", + "regenerateIllustration": "Regénérer l'illustration", + "deleteIllustration": "Supprimer l'illustration", "saveFailed": "Échec de la sauvegarde", "createFirst": "Créez votre première note", "unarchived": "Désarchivée", @@ -1015,7 +1018,9 @@ "desktopNotificationsDesc": "Recevoir des alertes sur votre bureau", "notificationsDesc": "Gérez vos préférences de notifications", "autoSave": "Auto-enregistrement", - "autoSaveDesc": "Enregistrer automatiquement les modifications pendant la frappe" + "autoSaveDesc": "Enregistrer automatiquement les modifications pendant la frappe", + "autoSaveEnabled": "Auto-enregistrement activé !", + "autoSaveDisabled": "Auto-enregistrement désactivé" }, "profile": { "title": "Profil", @@ -1095,7 +1100,15 @@ "autoLabelingDesc": "Suggère et applique des étiquettes automatiquement à vos notes", "noteHistory": "Historique des notes", "noteHistoryDesc": "Active les snapshots de versions et la restauration depuis History", - "titleSuggestions": "Suggestion de titres" + "titleSuggestions": "Suggestion de titres", + "svgComplexity": "Complexité des illustrations", + "svgComplexityDesc": "Contrôle le niveau de détail et la richesse des illustrations générées par l'IA pour vos notes", + "svgComplexitySimple": "Pictogramme", + "svgComplexitySimpleDesc": "Icône simple et lisible — rapide et épuré", + "svgComplexityIllustrated": "Illustrée", + "svgComplexityIllustratedDesc": "Scène riche avec dégradés, calques et atmosphère visuelle", + "svgComplexityRich": "Diagramme conceptuel", + "svgComplexityRichDesc": "Carte de connaissances structurée avec nœuds, connexions et étiquettes" }, "general": { "loading": "Chargement...", @@ -1901,6 +1914,8 @@ "pl": "Polski" }, "common": { + "on": "Actif", + "off": "Désactivé", "unknown": "Inconnu", "notAvailable": "N/D", "loading": "Chargement...", @@ -3407,7 +3422,12 @@ "copied": "Référence copiée !", "copyRefFailed": "Impossible de copier la référence du bloc", "copyRefNoNote": "Enregistrez la note avant de copier une référence de bloc", - "copyRefUnsupported": "Ce type de bloc ne peut pas encore être référencé" + "copyRefUnsupported": "Ce type de bloc ne peut pas encore être référencé", + "createDiagram": "Créer un diagramme", + "createDiagramDesc": "Créer un diagramme Excalidraw à partir de ce bloc", + "createDiagramLoading": "Génération du diagramme Excalidraw par l'IA...", + "createDiagramSuccess": "Diagramme généré et inséré avec succès !", + "createDiagramEmpty": "Le texte du bloc est vide ou trop court pour générer un diagramme." }, "smartPaste": { "prompt": "Coller cette référence de bloc en tant que :", diff --git a/memento-note/package-lock.json b/memento-note/package-lock.json index 113ca29..6b860a2 100644 --- a/memento-note/package-lock.json +++ b/memento-note/package-lock.json @@ -94,6 +94,7 @@ "next": "^16.1.6", "next-auth": "^5.0.0-beta.30", "nodemailer": "^8.0.4", + "p-queue": "^9.3.0", "pdf-parse": "^2.4.5", "postcss": "^8.5.6", "pptxgenjs": "^4.0.1", @@ -144,6 +145,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.0.18", + "adm-zip": "^0.5.17", "concurrently": "^9.2.1", "eslint": "^9.39.4", "eslint-config-next": "^16.2.6", @@ -9541,6 +9543,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ai": { "version": "6.0.168", "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.168.tgz", @@ -16455,6 +16467,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", diff --git a/memento-note/package.json b/memento-note/package.json index 7c67e01..468f01d 100644 --- a/memento-note/package.json +++ b/memento-note/package.json @@ -26,7 +26,8 @@ "test:migration": "vitest run tests/migration", "test:migration:watch": "vitest watch tests/migration", "locales:agent-slide-themes": "node scripts/localize-agent-slide-themes-and-translate.mjs", - "lint": "eslint --max-warnings 9999" + "lint": "eslint --max-warnings 9999", + "extension:build": "node extension/scripts/build-chrome-store.mjs" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.76", @@ -115,6 +116,7 @@ "next": "^16.1.6", "next-auth": "^5.0.0-beta.30", "nodemailer": "^8.0.4", + "p-queue": "^9.3.0", "pdf-parse": "^2.4.5", "postcss": "^8.5.6", "pptxgenjs": "^4.0.1", @@ -165,6 +167,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.0.18", + "adm-zip": "^0.5.17", "concurrently": "^9.2.1", "eslint": "^9.39.4", "eslint-config-next": "^16.2.6", diff --git a/memento-note/prisma/migrations/20260531100000_add_baseurl_to_user_api_key/migration.sql b/memento-note/prisma/migrations/20260531100000_add_baseurl_to_user_api_key/migration.sql new file mode 100644 index 0000000..fa1d8f7 --- /dev/null +++ b/memento-note/prisma/migrations/20260531100000_add_baseurl_to_user_api_key/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserAPIKey" ADD COLUMN "baseUrl" TEXT; diff --git a/memento-note/prisma/migrations/20260614100000_add_svg_complexity/migration.sql b/memento-note/prisma/migrations/20260614100000_add_svg_complexity/migration.sql new file mode 100644 index 0000000..f136bb4 --- /dev/null +++ b/memento-note/prisma/migrations/20260614100000_add_svg_complexity/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserAISettings" ADD COLUMN "svgComplexity" TEXT NOT NULL DEFAULT 'simple'; diff --git a/memento-note/prisma/migrations/20260614110000_add_note_embedding_chunks/migration.sql b/memento-note/prisma/migrations/20260614110000_add_note_embedding_chunks/migration.sql new file mode 100644 index 0000000..d49a7c9 --- /dev/null +++ b/memento-note/prisma/migrations/20260614110000_add_note_embedding_chunks/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "NoteEmbeddingChunk" ( + "id" TEXT NOT NULL, + "noteId" TEXT NOT NULL, + "fragmentId" TEXT NOT NULL, + "chunkIndex" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "charCount" INTEGER NOT NULL, + "embedding" vector(1536), + "embeddingModel" TEXT DEFAULT 'text-embedding-3-small', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NoteEmbeddingChunk_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "NoteEmbeddingChunk_noteId_fragmentId_key" ON "NoteEmbeddingChunk"("noteId", "fragmentId"); + +-- CreateIndex +CREATE INDEX "NoteEmbeddingChunk_noteId_idx" ON "NoteEmbeddingChunk"("noteId"); + +-- CreateIndex +CREATE INDEX "NoteEmbeddingChunk_fragmentId_idx" ON "NoteEmbeddingChunk"("fragmentId"); + +-- AddForeignKey +ALTER TABLE "NoteEmbeddingChunk" ADD CONSTRAINT "NoteEmbeddingChunk_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex: HNSW index for fast cosine similarity search on chunk embeddings +CREATE INDEX "NoteEmbeddingChunk_embedding_hnsw_idx" + ON "NoteEmbeddingChunk" USING hnsw ("embedding" vector_cosine_ops) + WITH (m = 16, ef_construction = 64); diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 73753d9..72ef19a 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -57,6 +57,7 @@ model User { bridgeNotes BridgeNote[] bridgeSuggestions BridgeSuggestion[] flashcardDecks FlashcardDeck[] + errorLogs ErrorLog[] } model Account { @@ -204,6 +205,7 @@ model Note { targetLiveBlocks LiveBlockRef[] @relation("TargetLiveBlocks") flashcards Flashcard[] properties NoteProperty[] + embeddingChunks NoteEmbeddingChunk[] @@index([isPinned]) @@index([isArchived]) @@ -342,6 +344,7 @@ model UserAISettings { noteHistoryMode String @default("manual") autoSave Boolean @default(true) aiProcessingConsent Boolean @default(false) + svgComplexity String @default("simple") integrationTokens Json? // Stores third-party integration tokens (Readwise, Calendar, etc.) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -371,6 +374,24 @@ model NoteEmbedding { @@index([noteId]) } +model NoteEmbeddingChunk { + id String @id @default(cuid()) + noteId String + fragmentId String + chunkIndex Int + content String + charCount Int + embedding Unsupported("vector(1536)")? + embeddingModel String? @default("text-embedding-3-small") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + + @@unique([noteId, fragmentId]) + @@index([noteId]) + @@index([fragmentId]) +} + model NoteLink { id String @id @default(cuid()) sourceNoteId String @@ -711,6 +732,7 @@ model UserAPIKey { encryptedKey String keyHash String model String? + baseUrl String? @db.Text isActive Boolean @default(true) lastUsedAt DateTime? lastUsedFor String? @@ -962,3 +984,23 @@ model AuditLog { @@index([action]) @@index([createdAt]) } + +model ErrorLog { + id String @id @default(cuid()) + userId String? + message String + stack String? @db.Text + url String? + userAgent String? @db.Text + component String? // Component name where error occurred + severity String @default("error") // error, warning, info + resolved Boolean @default(false) + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@index([severity]) + @@index([createdAt]) + @@index([resolved]) +} diff --git a/memento-note/tests/unit/brainstorm-billing.test.ts b/memento-note/tests/unit/brainstorm-billing.test.ts index a41651a..bf23516 100644 --- a/memento-note/tests/unit/brainstorm-billing.test.ts +++ b/memento-note/tests/unit/brainstorm-billing.test.ts @@ -117,6 +117,23 @@ describe('brainstorm host-pays billing (Story 3.4)', () => { checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand'), ).rejects.toThrow() }) + + it('AC10: host BYOK + guest quota empty still bills host (guest has no quota, host pays)', async () => { + vi.mocked(hasAnyActiveByok).mockResolvedValue(true) + mockActiveSubscription('PRO') + vi.mocked(redis.eval).mockResolvedValue(1) + + // Guest's quota is empty (simulated by checking guest's quota returns 0) + vi.mocked(redis.get).mockResolvedValue('0') + + await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand') + + // Verify that host's redis key was incremented, not guest's + expect(redis.eval).toHaveBeenCalled() + const keyArg = String(vi.mocked(redis.eval).mock.calls[0]?.[2]) + expect(keyArg).toContain('host-1') + expect(keyArg).not.toContain('guest-9') + }) }) describe('QuotaExceededError.toJSON', () => { diff --git a/memento-note/tests/unit/byok-factory.test.ts b/memento-note/tests/unit/byok-factory.test.ts index dfabd6b..e1ffed5 100644 --- a/memento-note/tests/unit/byok-factory.test.ts +++ b/memento-note/tests/unit/byok-factory.test.ts @@ -3,7 +3,8 @@ import { applyByokToConfig } from '@/lib/byok'; vi.mock('@/lib/prisma', () => ({ prisma: { - userAPIKey: { findFirst: vi.fn() }, + userAPIKey: { findFirst: vi.fn(), update: vi.fn().mockResolvedValue({}) }, + subscription: { findUnique: vi.fn() }, }, })); @@ -13,11 +14,22 @@ vi.mock('@/lib/crypto', () => ({ hashApiKey: vi.fn(() => 'hash'), })); +vi.mock('@/lib/entitlements', () => ({ + getUserInfo: vi.fn(async () => ({ tier: 'PRO', status: 'ACTIVE' })), +})); + import { prisma } from '@/lib/prisma'; describe('applyByokToConfig', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ + userId: 'u1', + tier: 'PRO', + status: 'ACTIVE', + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(), + } as any); }); it('injects user API key into config when active row exists', async () => { diff --git a/memento-note/tests/unit/chunk-indexing.test.ts b/memento-note/tests/unit/chunk-indexing.test.ts new file mode 100644 index 0000000..f113cdf --- /dev/null +++ b/memento-note/tests/unit/chunk-indexing.test.ts @@ -0,0 +1,178 @@ +/** + * Test du ChunkIndexingService — dedup, stale deletion, upsert. + * Mocke l'embedding (pas d'appel API), utilise la vraie DB. + */ +import { prisma } from '../../lib/prisma' +import { ChunkIndexingService } from '../../lib/ai/services/chunk-indexing.service' + +const testNoteId = 'test-chunk-000001' + +function test(name: string, fn: () => Promise) { + return fn() + .then(() => console.log(` ✓ ${name}`)) + .catch((err: any) => { + console.error(` ✗ ${name}: ${err.message}`) + process.exitCode = 1 + }) +} + +// Mock l'embedding service : vecteur déterministe basé sur le hash du contenu +const originalEmbedText = + require('../../lib/ai/services/embedding.service').embeddingService.embedText + +function mockEmbedding() { + const svc = require('../../lib/ai/services/embedding.service').embeddingService + svc.embedText = async (text: string) => { + const crypto = require('crypto') + const hash = crypto.createHash('md5').update(text).digest() + return Array.from({ length: 1536 }, (_, i) => hash[i % 16] / 255) + } +} + +function restoreEmbedding() { + const svc = require('../../lib/ai/services/embedding.service').embeddingService + svc.embedText = originalEmbedText +} + +async function ensureTestNote() { + await prisma.note.upsert({ + where: { id: testNoteId }, + create: { + id: testNoteId, + title: 'Test Note for Chunk Indexing', + content: '

Test

', + }, + update: {}, + }) +} + +async function cleanup() { + await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId: testNoteId } }) +} + +async function removeTestNote() { + await prisma.note.delete({ where: { id: testNoteId } }).catch(() => {}) +} + +async function main() { + mockEmbedding() + await ensureTestNote() + await cleanup() + + console.log('\n=== US-CHUNK-2 : Indexation incrémentale avec dedup ===\n') + + const service = new ChunkIndexingService() + + const longContent = Array.from({ length: 8 }, (_, i) => + `Section ${i} de la note de test. `.repeat(60).trim(), + ).join('\n\n') + + await test('première indexation → tous les fragments sont nouveaux', async () => { + const result = await service.indexNote(testNoteId, 'Note de test', longContent) + + if (result.newFragments === 0) + throw new Error(`attendu newFragments > 0, reçu ${result.newFragments}`) + if (result.deleted !== 0) + throw new Error(`attendu deleted=0, reçu ${result.deleted}`) + if (result.skipped !== 0) + throw new Error(`attendu skipped=0, reçu ${result.skipped}`) + + console.log(` → ${result.newFragments} nouveaux, ${result.totalFragments} total`) + }) + + await test('deuxième indexation (même contenu) → tout skipped, 0 nouveau', async () => { + const result = await service.indexNote(testNoteId, 'Note de test', longContent) + + if (result.newFragments !== 0) + throw new Error(`attendu 0 nouveau, reçu ${result.newFragments}`) + if (result.skipped === 0) + throw new Error(`attendu skipped > 0, reçu ${result.skipped}`) + if (result.deleted !== 0) + throw new Error(`attendu deleted=0, reçu ${result.deleted}`) + + console.log(` → ${result.skipped} skip, 0 nouveau ✓`) + }) + + await test('modification d\'une section → 1 nouveau, reste skip', async () => { + const sections = Array.from({ length: 8 }, (_, i) => + `Section ${i === 3 ? 'MODIFIÉE' : i} de la note de test. `.repeat(60).trim(), + ) + const modified = sections.join('\n\n') + + const result = await service.indexNote(testNoteId, 'Note de test', modified) + + if (result.newFragments === 0) + throw new Error(`attendu au moins 1 nouveau fragment, reçu ${result.newFragments}`) + if (result.deleted === 0) + throw new Error(`attendu au moins 1 stale supprimé, reçu ${result.deleted}`) + + console.log(` → ${result.newFragments} nouveau(x), ${result.skipped} skip, ${result.deleted} supprimé(s)`) + }) + + await test('suppression d\'une section → fragments stale nettoyés', async () => { + const sections = Array.from({ length: 8 }, (_, i) => + `Section ${i} de la note de test. `.repeat(60).trim(), + ) + await service.indexNote(testNoteId, 'Note de test', sections.join('\n\n')) + + const beforeCount = await prisma.noteEmbeddingChunk.count({ + where: { noteId: testNoteId }, + }) + + const shorter = sections.slice(0, 4).join('\n\n') + const result = await service.indexNote(testNoteId, 'Note de test', shorter) + + const afterCount = await prisma.noteEmbeddingChunk.count({ + where: { noteId: testNoteId }, + }) + + if (afterCount >= beforeCount) + throw new Error(`attendu count < ${beforeCount}, reçu ${afterCount}`) + if (result.deleted === 0) + throw new Error(`attendu deleted > 0, reçu ${result.deleted}`) + + console.log(` → ${beforeCount} → ${afterCount} fragments (${result.deleted} supprimés)`) + }) + + await test('note vide → tous les fragments supprimés', async () => { + await service.indexNote(testNoteId, 'Note de test', longContent) + const result = await service.indexNote(testNoteId, '', '') + + const count = await prisma.noteEmbeddingChunk.count({ + where: { noteId: testNoteId }, + }) + + if (count !== 0) throw new Error(`attendu 0 fragments, reçu ${count}`) + console.log(` → tous les fragments supprimés ✓`) + }) + + await test('deleteNoteChunks → supprime tout', async () => { + await service.indexNote(testNoteId, 'Note de test', longContent) + await service.deleteNoteChunks(testNoteId) + + const count = await prisma.noteEmbeddingChunk.count({ + where: { noteId: testNoteId }, + }) + if (count !== 0) throw new Error(`attendu 0, reçu ${count}`) + console.log(` → 0 fragment restant ✓`) + }) + + await test('hasChunks → détection correcte', async () => { + const before = await service.hasChunks(testNoteId) + if (before) throw new Error('attendu false avant indexation') + + await service.indexNote(testNoteId, 'Note de test', longContent) + const after = await service.hasChunks(testNoteId) + if (!after) throw new Error('attendu true après indexation') + + console.log(` → false avant, true après ✓`) + }) + + await cleanup() + restoreEmbedding() + await prisma.$disconnect() + + console.log('\n=== Tests terminés ===') +} + +main().catch(console.error) diff --git a/memento-note/tests/unit/chunking.test.ts b/memento-note/tests/unit/chunking.test.ts new file mode 100644 index 0000000..181b949 --- /dev/null +++ b/memento-note/tests/unit/chunking.test.ts @@ -0,0 +1,140 @@ +import { chunkNoteContent } from '../../lib/text/note-chunking' + +function test(name: string, fn: () => void) { + try { + fn() + console.log(` ✓ ${name}`) + } catch (err: any) { + console.error(` ✗ ${name}: ${err.message}`) + process.exitCode = 1 + } +} + +function assert(condition: any, msg: string) { + if (!condition) throw new Error(msg) +} + +console.log('\n=== US-CHUNK-1 : Chunking sémantique ===\n') + +test('note vide → aucun fragment', () => { + const chunks = chunkNoteContent('note1', '') + assert(chunks.length === 0, `attendu 0, reçu ${chunks.length}`) +}) + +test('note très courte (< 10 chars) → aucun fragment', () => { + const chunks = chunkNoteContent('note1', 'Hello') + assert(chunks.length === 0, `attendu 0, reçu ${chunks.length}`) +}) + +test('note courte (< 1000 chars) → 1 seul fragment', () => { + const text = 'Ceci est une note courte. Elle parle de productivité et de gestion du temps.' + const chunks = chunkNoteContent('note1', text) + assert(chunks.length === 1, `attendu 1, reçu ${chunks.length}`) + assert(chunks[0].chunkIndex === 0, 'chunkIndex doit être 0') + assert(chunks[0].content.includes('productivité'), 'le contenu doit être préservé') + assert(chunks[0].charCount === chunks[0].content.length, 'charCount doit correspondre') +}) + +test('note longue avec plusieurs paragraphes → plusieurs fragments', () => { + const paragraphs: string[] = [] + for (let i = 0; i < 10; i++) { + paragraphs.push(`Paragraphe ${i}. `.repeat(60).trim()) + } + const text = paragraphs.join('\n\n') + const chunks = chunkNoteContent('note2', text) + assert(chunks.length > 1, `attendu >1, reçu ${chunks.length}`) + assert(chunks.length <= 15, `attendu <=15 fragments, reçu ${chunks.length}`) + for (let i = 0; i < chunks.length; i++) { + assert(chunks[i].chunkIndex === i, `chunkIndex ${i} incorrect`) + } +}) + +test('fragmentId est stable (déterministe)', () => { + const text = 'Même contenu donne même hash.' + const chunks1 = chunkNoteContent('noteA', text) + const chunks2 = chunkNoteContent('noteA', text) + assert(chunks1[0].fragmentId === chunks2[0].fragmentId, 'les hash doivent être identiques') +}) + +test('fragmentId diffère entre notes différentes', () => { + const text = 'Même contenu mais note différente.' + const chunks1 = chunkNoteContent('noteA', text) + const chunks2 = chunkNoteContent('noteB', text) + assert(chunks1[0].fragmentId !== chunks2[0].fragmentId, 'les hash doivent différer par noteId') +}) + +test('paragraphe géant (> 1500 chars) → sous-découpé aux phrases', () => { + const giantPara = + 'Ceci est une phrase très longue. '.repeat(100) + 'Dernière phrase du paragraphe géant.' + const chunks = chunkNoteContent('note3', giantPara) + assert(chunks.length > 1, `attendu >1 fragment, reçu ${chunks.length}`) + for (const chunk of chunks) { + assert( + chunk.content.length <= 2000, + `fragment trop long: ${chunk.charCount} chars`, + ) + } +}) + +test('persan (RTL) → chunking correct', () => { + const persianText = + 'یادداشت درباره بهره‌وری.\n\nاین یک پاراگراف فارسی است. این متن برای تست قالب‌بندی راست‌چین نوشته شده است. یادداشت‌های فارسی باید به درستی پردازش شوند.\n\nپاراگراف سوم. محتوای بیشتری برای اطمینان از صحت پردازش.' + const chunks = chunkNoteContent('note-fa', persianText) + assert(chunks.length >= 1, `attendu >=1, reçu ${chunks.length}`) + assert(chunks[0].content.includes('بهره‌وری'), 'contenu persan préservé') +}) + +test('contenu plain text → pas de transformation', () => { + const plainText = 'Premier paragraphe.\n\nDeuxième paragraphe.' + const chunks = chunkNoteContent('note4', plainText) + assert(chunks.length >= 1, 'au moins 1 fragment') + assert(chunks[0].content.includes('Premier'), 'contenu préservé') + // Le strippage HTML est fait en amont par prepareNoteTextForEmbedding, pas par le chunker +}) + +test('paragraphe répété → dedup par fragmentId', () => { + const repeatedPara = 'Paragraphe identique répété volontairement.' + const text = `${repeatedPara}\n\n${repeatedPara}\n\n${repeatedPara}` + const chunks = chunkNoteContent('note5', text) + const uniqueIds = new Set(chunks.map((c) => c.fragmentId)) + assert(uniqueIds.size === chunks.length, 'les doublons doivent être supprimés') +}) + +test('modification d\'un paragraphe → fragmentId change pour ce fragment uniquement', () => { + const paraA = 'Section A. '.repeat(80).trim() + const paraB = 'Section B. '.repeat(80).trim() + const paraC = 'Section C. '.repeat(80).trim() + + const original = `${paraA}\n\n${paraB}\n\n${paraC}` + const modified = `${paraA} MODIFIE.\n\n${paraB}\n\n${paraC}` + + const chunksOriginal = chunkNoteContent('note6', original) + const chunksModified = chunkNoteContent('note6', modified) + + assert(chunksOriginal.length >= 2, `original devrait avoir >=2 fragments, reçu ${chunksOriginal.length}`) + + const originalIds = new Set(chunksOriginal.map((c) => c.fragmentId)) + const newIds = chunksModified.map((c) => c.fragmentId) + + const unchanged = newIds.filter((id) => originalIds.has(id)) + assert(unchanged.length >= 1, `au moins 1 fragment inchangé attendu, reçu ${unchanged.length} sur ${newIds.length}`) + assert(unchanged.length < newIds.length, `au moins 1 fragment modifié attendu`) +}) + +test('overlap entre fragments consécutifs', () => { + const paragraphs: string[] = [] + for (let i = 0; i < 8; i++) { + paragraphs.push(`Section ${i}. `.repeat(80).trim()) + } + const text = paragraphs.join('\n\n') + const chunks = chunkNoteContent('note7', text) + if (chunks.length >= 2) { + const tail = chunks[0].content.slice(-200) + assert( + chunks[1].content.startsWith(tail.slice(0, 50)) || chunks[1].content.includes(tail.slice(0, 30)), + 'l\'overlap devrait être présent entre fragments consécutifs', + ) + } +}) + +console.log('\n=== Tests terminés ===') diff --git a/memento-note/tests/unit/entitlements.test.ts b/memento-note/tests/unit/entitlements.test.ts index 62f5d4b..67269d1 100644 --- a/memento-note/tests/unit/entitlements.test.ts +++ b/memento-note/tests/unit/entitlements.test.ts @@ -72,8 +72,8 @@ describe('entitlements', () => { const result = await canUseFeature('user1', 'semantic_search'); expect(result.allowed).toBe(true); - expect(result.limit).toBe(100); - expect(result.remaining).toBe(50); + expect(result.limit).toBe(200); + expect(result.remaining).toBe(150); }); it('should allow ENTERPRISE user unlimited features', async () => { @@ -130,24 +130,13 @@ describe('entitlements', () => { expect(result.limit).toBe(30); }); - it('should allow BASIC user to use chat when under limit (10)', async () => { + it('should deny BASIC user to use chat by default (strict lock)', async () => { mockActiveSubscription('BASIC'); - vi.mocked(redis.get).mockResolvedValue('5'); - - const result = await canUseFeature('user1', 'chat'); - - expect(result.allowed).toBe(true); - expect(result.limit).toBe(10); - }); - - it('should deny BASIC user when chat quota is exhausted', async () => { - mockActiveSubscription('BASIC'); - vi.mocked(redis.get).mockResolvedValue('10'); const result = await canUseFeature('user1', 'chat'); expect(result.allowed).toBe(false); - expect(result.reason).toBe('QUOTA_EXCEEDED'); + expect(result.reason).toBe('FEATURE_NOT_AVAILABLE'); }); it('should fail-open when Redis is down', async () => { @@ -209,7 +198,7 @@ describe('entitlements', () => { const result = await canUseFeature('user1', 'semantic_search'); - expect(result.limit).toBe(100); + expect(result.limit).toBe(200); }); it('should drop to BASIC for PAST_DUE past billing period', async () => {