16 Commits

Author SHA1 Message Date
Antigravity
f385d43d5d feat: redirect to AI consent settings when consent is missing
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m27s
CI / Deploy production (on server) (push) Successful in 23s
- requestAiConsent now redirects to /settings/general?highlight=aiConsent
  instead of opening the modal when called outside the settings page
- Settings page highlights the consent section and scrolls to it
- Adds consent.ai.settingsBanner i18n key (fr/en)
- Keeps inline modal on /settings/general so the grant button still works
2026-07-18 17:52:40 +00:00
Antigravity
324bf40658 fix: replace raw ai_consent_required code with translatable message and consent prompt
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m20s
CI / Deploy production (on server) (push) Successful in 24s
- Centralize AI consent 403 responses in server-consent helpers
- Return human-readable message + code + i18n errorKey from all AI APIs
- Update dashboard and AI clients to detect code/errorKey and translate
- Add consent.ai.requiredToast i18n keys (fr/en)
2026-07-18 17:32:00 +00:00
Antigravity
86505659cf fix(extension): masquer le diagnostic en builds Store v0.4.6
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m47s
CI / Deploy production (on server) (push) Successful in 23s
- Ajout du flag ALLOW_DIAGNOSTICS dans sidepanel.js.
- En prod (builds Chrome Store / Firefox) ALLOW_DIAGNOSTICS = false :
  le bouton ? et le panneau diagnostic sont masqués, logDiag reste silencieux.
- Mise à jour des scripts de build pour appliquer ALLOW_DIAGNOSTICS = false.
- Bump version 0.4.6.
2026-07-18 17:21:40 +00:00
Antigravity
4946c0d44a fix(extension): permissions universelles Store + nettoyage article v0.4.5
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m44s
CI / Deploy production (on server) (push) Successful in 24s
- build-chrome-store.mjs / build-firefox.mjs : host_permissions et content_scripts matches
  passent à http://*/* + https://*/* au lieu de memento-note.com uniquement.
  Un Web Clipper a besoin d'accéder à tous les sites ; le manifest Store
  bloquait l'injection du content script sur n'importe quel article.
- extract-article.ts : refonte de cleanClipText en filtrage par ligne avec
  patterns élargis (mis à jour, temps de lecture, aujourd'hui à HH:MM,
  source - date, publié le, etc.).
- Bump version 0.4.5 dans manifest, locales, README et builds.
2026-07-18 17:04:25 +00:00
Antigravity
0ee2afbd62 fix(extension): sync robuste content/sidepanel, diagnostic panel, preview propre v0.4.4
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
- content.js : écrit la sélection dans chrome.storage.session/local en plus du message runtime
- sidepanel.js : lit le storage en fallback, loggue chaque étape, affiche un diagnostic panel
- sidepanel.js : avertissement explicite si le content script n'est pas détecté
- sidepanel.js : sépare le rendu Markdown du résumé et l'affichage texte brut de l'extrait
- sidepanel.css : agrandit la zone de prévisualisation
- i18n : nouvelles clés contentScriptMissing, diagnosticsTitle, copyDiagnostics
- locales : correction FR (Retour, Enregistrement, Note, etc.) + version 0.4.4 partout
- extract-article.ts : nettoie textContent des métadonnées (dates, temps de lecture, sources)
- analyze/route.ts : excerpt propre via makeExcerpt
- builds Chrome Store et Firefox regénérés en 0.4.4
2026-07-18 16:56:51 +00:00
Antigravity
bff060ad20 feat(extension): rendu Markdown dans la prévisualisation + zone agrandie v0.4.3
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m24s
CI / Deploy production (on server) (push) Successful in 23s
Avant : renderConfirm() échappait le résumé et l'extrait avec escapeHtml(),
donc les **gras**, *italique*, [liens](), listes, titres du Markdown
produit par l'IA s'affichaient en texte brut (littéral « **gras** »).

Maintenant : renderMarkdownSafe() parse le Markdown en HTML limité
et sûr (escapeHtml d'abord, puis regex sur le texte échappé — pas de
HTML brut autorisé).

+ Refonte du panneau de prévisualisation :
  - .preview-block avec header sticky (label Résumé / Extrait)
  - .excerpt-block : min-height 180px, max-height 360px, scroll si long
  - .summary-block : fond bleuté léger, bordure gauche sky
  - markdown-h3/h4/h5 stylés (serif pour titres), strong/em/code/ul/a
  - Polices 14px, line-height 1.7, word-break pour texte long

+ Nouvelle clé i18n summaryLabel dans les 15 locales.
+ Version 0.4.2 → 0.4.3.
2026-07-18 10:35:00 +00:00
Antigravity
1987b084c3 fix(extension): applySelectionFromMessage tolérant + amorçage pageUrl v0.4.2
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
VRAI bug (que tu as raison de souligner, j'aurais pas dû revert) :
la fonction applySelectionFromMessage rejetait TOUT message si pageUrl
n'était pas encore settée, ou si les URLs différaient sur un trailing
slash / fragment / query. Et pageUrl se populate de façon async via
chrome.tabs.query dans refreshPageContext — donc le premier SELECTION_CHANGED
arrivait avant pageUrl, était rejeté, et la sélection n'était jamais
appliquée.

Fixes dans sidepanel.js :
- applySelectionFromMessage : si pageUrl vide, on amorce depuis le
  message (pageUrl, pageTitle, pageDomain, pageFavicon). Sans ce
  fallback, le bouton « Clipper la sélection » n'apparaît jamais.
- Comparaison d'URL tolérante (origin + pathname, sans trailing slash,
  hash, query).
- refreshPageContext : ne pas marquer tab?.url undefined comme restricted.

Version bumpée 0.4.1 → 0.4.2 (à chaque livraison comme demandé).
2026-07-18 10:30:45 +00:00
Antigravity
07ac42cbee fix(extension): rollback content/background/sidepanel à v0.3.1, version 0.4.1
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Le rollback complet de content.js, background.js et sidepanel.js vers
la v0.3.1 (que tu confirmes fonctionner) règle la régression. Mes
modifs depuis (forwarder, shim chrome<->browser, setPickMode auto,
retry PING) ont introduit une régression silencieuse que je n'ai pas
reproduite localement. Je remets le code qui marchait.

Manifest = v0.3.1 + ajout sidebar_action + browser_specific_settings.gecko
pour le build Firefox. Version bumpée 0.3.1 → 0.4.1.
2026-07-18 10:24:47 +00:00
Antigravity
1e9b1b30c9 fix(extension): bannière visible dès l'injection + exclusion store-assets
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
Bug réel : la bannière 'Surlignez le texte à clipper' n'apparaissait
jamais en pratique parce que le side panel envoyait SET_PICK_MODE
avant que le content script ne soit prêt (race condition MV3 entre
chrome.scripting.executeScript et la mise en place du listener
chrome.runtime.onMessage). Le retry PING dans ensureContentScript
ne suffisait pas : le listener sidepanel peut aussi être en retard.

Fix radical : content.js affiche la bannière IMMÉDIATEMENT à
l'initialisation, sans attendre SET_PICK_MODE. L'utilisateur voit
immédiatement que l'extension est active. Un bouton ✕ permet de
la fermer (state local au tab). Pas de dépendance sur le side panel
pour le feedback visuel.

+ manifest : retrait de optional_host_permissions (redondant avec
  host_permissions http://*/* et https://*/*, peut perturber Chrome
  en dev).
+ bannière rediseignée : max-width 640px responsive, animation
  slideIn, bouton ✕ accessible.
+ nouvelles clés i18n bannerDismiss dans les 15 locales.
+ build : exclusion de store-assets/ du zip et du xpi (sinon le
  package Store faisait 327 Ko au lieu de 47 Ko).
2026-07-18 10:20:31 +00:00
Antigravity
d5c44aafb6 fix(extension): timing bannière pick mode + responsive side panel
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m46s
CI / Deploy production (on server) (push) Successful in 23s
Bannière pick mode :
- ensureContentScript() : retry le PING 8x (avec backoff 80→290ms) après
  chrome.scripting.executeScript, parce que l'injection est async côté page
  et le listener n'est pas enregistré immédiatement.
- Avant : SET_PICK_MODE partait dans le vide, la bannière « surlignez le
  texte à clipper » n'apparaissait jamais.

Responsive :
- Media queries : <380px (Chrome Side Panel narrow), 380-420px (default),
  >420px (Firefox sidebar), <320px (very small).
- Padding/margin/font-size adaptatifs selon la largeur du panneau.
- .selection-body : min-height 80px, plus de max-height fixe, scroll si
  trop long. overflow-wrap: anywhere pour le texte long.
- .btn : padding 16px / min-height 48px pour le tactile.
- .page-card : word-break + overflow-wrap pour les URLs et titres longs.
- Firefox : media query dédié >480px pour aérer la sidebar Firefox
  (plus large que Chrome Side Panel).

Rebuilt :
- memento-web-clipper-chrome-store.zip
- memento-web-clipper-firefox.xpi
2026-07-18 09:48:45 +00:00
Antigravity
9d10815415 fix(extension): forwarder les messages content→sidepanel via le background
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m44s
CI / Deploy production (on server) (push) Successful in 24s
Bug réel : chrome.runtime.sendMessage depuis un content script ne va
QU'au background service worker en MV3. Le side panel qui écoute via
chrome.runtime.onMessage ne reçoit jamais rien directement. Résultat :
la sélection, le titre de la page et le contexte n'atteignaient jamais
le panneau.

Fix :
- background.js : ajouter un onMessage qui forward SELECTION_CHANGED et
  PAGE_CONTEXT_CHANGED vers toutes les surfaces (side panel).
- sidepanel.js applySelectionFromMessage : normaliser les URLs
  (ignorer query/fragment/trailing slash), amorcer pageUrl depuis le
  premier message si tabs.query a échoué, accepter une sélection qui
  arrive avant refreshPageContext.

La v0.3.1 souffrait du même bug — la sélection fonctionnait parfois par
chance quand Chrome routait le message avant que le side panel ne soit
complètement initialisé. Maintenant c'est garanti.
2026-07-18 09:41:27 +00:00
Antigravity
cf3b5483d2 fix(extension): corriger les traductions 'clip' et améliorer le diagnostic 401
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m24s
CI / Deploy production (on server) (push) Successful in 23s
Traductions:
- 'Tondeuse Web' (FR), 'Cortadora web' (ES), 'Schneiden Sie aus' (DE),
  'ritagliare' (IT) etc. — le terme 'clip' était traduit littéralement
  (tondeuse = mower, ausschneiden = cut out, ritagliare = trim).
- Garder 'clip' comme terme technique dans les 15 locales, sauf les
  formulations verbe qui prennent une conjugaison locale correcte.
- 87 chaînes corrigées dans le bundle de traductions embarqué.

Diagnostic 401:
- Le message 'Veuillez vous connecter à Memento' s'affichait même
  quand l'utilisateur était connecté, simplement sur une URL différente
  (localhost vs 127.0.0.1 vs LAN IP — les cookies sont scopés par host).
- Nouveau message multi-ligne incluant l'URL configurée + bouton
  'Ouvrir Memento' qui ouvre l'onglet directement à cette URL.
- Nouvelle clé i18n errLoginHintUrl (placeholder $URL$) traduite
  dans les 15 locales.

Rebuilt :
- memento-web-clipper-chrome-store.zip (44 Ko)
- memento-web-clipper-firefox.xpi (44 Ko)
2026-07-18 09:28:08 +00:00
Antigravity
6eb46fd1b7 feat(extension): générer les assets visuels Chrome Web Store (icon, tiles, screenshots, marquee)
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m51s
CI / Deploy production (on server) (push) Successful in 23s
- extension/scripts/generate-store-assets.py : générateur Python/PIL avec
  la palette x.ai premium (BG #0B0A09, TEXT #F4F1EA, OCHRE #D4A373).
- extension/store-assets/ :
  * icon-128.png (128×128)
  * promo-tile-small.png (440×280)
  * promo-tile-large.png (920×680)
  * marquee.png (1400×560)
  * screenshot-1.png (1280×800) — sidebar idle avec sélecteur de carnet
  * screenshot-2.png (1280×800) — confirmation IA avec résumé + tags
  * screenshot-3.png (1280×800) — vue RTL/persan (font DejaVu pour glyphes)

Tous les assets respectent les dimensions Chrome Web Store et sont prêts
pour la soumission.
2026-07-18 09:16:46 +00:00
Antigravity
a7c15e9fc3 feat(privacy+extension): publier la page /privacy et préparer les builds Chrome/Firefox Store
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m49s
CI / Deploy production (on server) (push) Successful in 24s
- Nouvelle page /privacy bilingue (FR/EN) avec sélection par Accept-Language
  ou ?lang=, sections complètes (données collectées, IA/BYOK, RGPD, sécurité,
  contact). Style sombre aligné sur la landing (x.ai premium).
- Liens footer landing.footer.legal.* mis à jour vers /privacy dans les 15 locales.
- Extension Web Clipper rendue cross-browser :
  * background.js détecte chrome.sidePanel vs browser.sidebarAction
  * sidepanel.js ajoute un shim chrome<->browser pour Firefox
  * manifest.json inclut sidebar_action + browser_specific_settings.gecko
    + optional_host_permissions (Chrome les ignore)
- Script scripts/build-firefox.mjs pour produire un .xpi signé pour AMO,
  symétrique de build-chrome-store.mjs. Exclusion mutuelle des artefacts
  Chrome/Firefox pour éviter la cross-contamination.
- Branding corrigé : 176 occurrences de 'Momento' -> 'Memento' dans le
  bundle de traductions embarqué.
- Version bumpée 0.3.1 -> 0.4.0.
2026-07-18 09:00:44 +00:00
Antigravity
88a7d2ad0a fix(audit): nettoyage accès, garde-fous build, unification quotas
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m59s
CI / Deploy production (on server) (push) Successful in 24s
Semaine 1 — fuites et accès :
- /archive rendu accessible via sidebar (était invisible)
- Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions
- Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF
- 15 locales mises à jour

Semaine 2 — garde-fous :
- 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe)
- reactStrictMode: true (dev-only, zero impact prod)
- reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow
- auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse()

Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
2026-07-17 18:47:44 +00:00
Antigravity
e8ee53c815 fix(dashboard): audit Second Brain — pistes, erreur briefing, dark, crédits
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m16s
CI / Deploy production (on server) (push) Has been skipped
Corrige loadPaths sans argument après Echo/IA, bannière si briefing en échec,
checklist connexion inversée, skeletons dark, libellé usage = solde unique.
2026-07-16 21:08:48 +00:00
177 changed files with 10153 additions and 6348 deletions

View File

@@ -10,8 +10,8 @@
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz``deploy-prod.sh`**sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4`GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages``<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env) ; **navigateur intégré Cursor peu fiable pour le login** (focus volé, cookies isolés, OAuth Google souvent bloqué) — préférer un vrai navigateur pour la connexion manuelle, et pour l'automation (démos/Playwright) une **session JWT/cookies stockée** plutôt que de rejouer le login dans le browser Cursor.
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents…) — pas un slideshow landing seul ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture.
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
@@ -28,4 +28,4 @@
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
- Éditeur riche : `rich-text-editor.tsx``immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText``/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **`/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx``StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage.
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **`/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx``StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage ; **vidéos promo** : pipeline `promo-video/` (`make_promo.py`, `make_promo_features.py`, `SCENARIO.md`) — captures app authentifiée (Playwright + session JWT), VO EN (edge-tts), montage stable sans zoom.

View File

@@ -1,44 +0,0 @@
import { Metadata } from 'next'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { ChatContainer } from '@/components/chat/chat-container'
import { getConversations } from '@/app/actions/chat-actions'
import { getSystemConfig } from '@/lib/config'
export const metadata: Metadata = {
title: 'Chat IA | Memento',
description: 'Discutez avec vos notes et vos agents IA',
}
export default async function ChatPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
const userId = session.user.id
// Fetch initial data
const [conversations, notebooks, config] = await Promise.all([
getConversations(),
prisma.notebook.findMany({
where: { userId },
orderBy: { order: 'asc' }
}),
getSystemConfig(),
])
// Check if web search tools are configured
const webSearchAvailable = !!(
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
)
return (
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
<ChatContainer
initialConversations={conversations}
notebooks={notebooks}
webSearchAvailable={webSearchAvailable}
/>
</div>
)
}

View File

@@ -1,17 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
// D3 uses browser APIs — must be loaded client-side only
const NoteGraphView = dynamic(
() => import('@/components/note-graph-view').then(m => m.NoteGraphView),
{ ssr: false }
)
export default function GraphPage() {
return (
<div className="h-screen overflow-hidden">
<NoteGraphView />
</div>
)
}

View File

@@ -19,7 +19,6 @@ import {
ChevronRight,
ChevronDown,
Database,
ArrowRight,
Menu,
Network,
List,
@@ -28,7 +27,6 @@ import {
X,
} from 'lucide-react'
import { toast } from 'sonner'
import Link from 'next/link'
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
import { createNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
@@ -455,9 +453,6 @@ export default function InsightsPage() {
</p>
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-concrete">
<span>{t('insightsView.semanticGraphLegend')}</span>
<Link href="/graph" className="inline-flex items-center gap-0.5 text-ochre hover:underline font-medium">
{t('insightsView.openGraphMap')} <ArrowRight size={9} />
</Link>
</div>
</div>
</div>

View File

@@ -1,11 +1,11 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
import { motion } from 'motion/react'
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
@@ -27,6 +27,20 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
const { t, setLanguage: setContextLanguage } = useLanguage()
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
const router = useRouter()
const searchParams = useSearchParams()
const aiConsentSectionRef = useRef<HTMLDivElement>(null)
const highlightAiConsent = searchParams?.get('highlight') === 'aiConsent'
const [showConsentHighlight, setShowConsentHighlight] = useState(highlightAiConsent)
useEffect(() => {
if (highlightAiConsent && aiConsentSectionRef.current) {
setShowConsentHighlight(true)
aiConsentSectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
const timer = setTimeout(() => setShowConsentHighlight(false), 4000)
return () => clearTimeout(timer)
}
}, [highlightAiConsent])
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
@@ -76,6 +90,12 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
{t('generalSettings.description')}
</h3>
{highlightAiConsent && (
<div className="rounded-xl border border-brand-accent/30 bg-brand-accent/10 p-4 text-sm font-medium text-ink">
{t('consent.ai.settingsBanner')}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
<div className="flex items-center gap-5">
@@ -202,7 +222,15 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
</div>
</div>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
<div
ref={aiConsentSectionRef}
className={cn(
'bg-white/40 dark:bg-white/5 border rounded-xl p-8 space-y-6 md:col-span-2 transition-all duration-500',
showConsentHighlight
? 'border-brand-accent ring-2 ring-brand-accent/40 shadow-lg shadow-brand-accent/10'
: 'border-border'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">

View File

@@ -1,156 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useLanguage } from '@/lib/i18n';
export default function SupportPage() {
const { t } = useLanguage();
return (
<div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
{t('support.title')}
</h1>
<p className="text-muted-foreground text-lg">
{t('support.description')}
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-10">
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
{t('support.buyMeACoffee')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.donationDescription')}
</p>
<Button asChild className="w-full">
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.donateOnKofi')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.kofiDescription')}
</p>
</CardContent>
</Card>
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💚</span>
{t('support.sponsorOnGithub')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.sponsorDescription')}
</p>
<Button asChild variant="outline" className="w-full">
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.sponsorOnGithub')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.githubDescription')}
</p>
</CardContent>
</Card>
</div>
<Card className="mb-10">
<CardHeader>
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
<ul className="space-y-2 text-sm">
<li> Keeps me fueled with coffee</li>
<li>🐛 Covers hosting and server costs</li>
<li> Funds development of new features</li>
<li>📚 Improves documentation</li>
<li>🌍 Keeps Memento 100% open-source</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
<ul className="space-y-2 text-sm">
<li>🥉 $5/month - Bronze: Name in supporters list</li>
<li>🥈 $15/month - Silver: Priority feature requests</li>
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
<li>💎 $100/month - Platinum: Custom features, consulting</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>💡 {t('support.transparency')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
{t('support.transparencyDescription')}
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>{t('support.hostingServers')}</span>
<span className="font-mono">~$20/month</span>
</div>
<div className="flex justify-between">
<span>{t('support.domainSSL')}</span>
<span className="font-mono">~$15/year</span>
</div>
<div className="flex justify-between">
<span>{t('support.aiApiCosts')}</span>
<span className="font-mono">~$30/month</span>
</div>
<div className="flex justify-between border-t pt-2">
<span className="font-semibold">{t('support.totalExpenses')}</span>
<span className="font-mono font-semibold">~$50/month</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-4">
Any amount beyond these costs goes directly into improving Memento
and funding new features. Thank you for your support! 💚
</p>
</CardContent>
</Card>
<div className="mt-10 text-center">
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
<div className="flex flex-wrap justify-center gap-4">
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
{t('support.starGithub')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
🐛 {t('support.reportBug')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
📝 {t('support.contributeCode')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
🐦 {t('support.shareTwitter')}
</a>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,398 @@
import { headers } from 'next/headers'
import Link from 'next/link'
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
export const metadata = {
title: 'Privacy Policy — Memento',
description: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
alternates: { canonical: 'https://memento-note.com/privacy' },
robots: { index: true, follow: true },
}
type Locale = 'en' | 'fr'
const SUPPORTED: Locale[] = ['en', 'fr']
const RTL: Locale[] = []
async function pickLocale(searchLang?: string | string[]): Promise<Locale> {
// 1. Explicit ?lang= override
const raw = Array.isArray(searchLang) ? searchLang[0] : searchLang
if (raw && SUPPORTED.includes(raw.toLowerCase() as Locale)) return raw.toLowerCase() as Locale
// 2. Accept-Language header
try {
const h = await headers()
const accept = parseAcceptLanguage(h.get('accept-language'))
const first = accept?.[0]?.toLowerCase()
if (first && SUPPORTED.includes(first as Locale)) return first as Locale
if (first && first.startsWith('fr')) return 'fr'
} catch {
/* fall through */
}
return 'en'
}
type Section = { id: string; title: string; body: string[] }
type Doc = {
title: string
intro: string
lastUpdated: string
sections: Section[]
}
const DOCS: Record<Locale, Doc> = {
fr: {
title: 'Politique de confidentialité',
intro:
'Cette politique décrit comment Memento (Memento Labs) collecte, utilise et protège vos données, y compris via l\'application web, l\'extension navigateur Web Clipper et les fonctionnalités d\'intelligence artificielle. Nous concevons Memento comme un Second Brain personnel : par défaut, vos données vous appartiennent et ne sont pas partagées.',
lastUpdated: 'Dernière mise à jour : juillet 2026',
sections: [
{
id: 'data-collected',
title: '1. Données collectées',
body: [
'Compte et authentification : adresse e-mail, nom facultatif, mot de passe haché (bcrypt), ou identifiant Google via OAuth (Google Auth Platform).',
'Carnets et notes : contenu, titre, étiquettes, images jointes, pièces jointes (PDF, images, documents), métadonnées (dates, ordre, couleur, épinglé, archivé), et historique des versions (snapshots).',
'Rappels et notifications : date/heure, récurrence, lieu facultatif, état (fait/non fait).',
'Données IA : titre et corps des notes envoyés à votre fournisseur d\'IA configuré pour générer résumés, tags, embeddings, Memory Echo, reformulations, traduction, suggestions. Avec BYOK (votre propre clé), l\'envoi va directement vers votre fournisseur ; sinon, il transite par notre routeur IA mutualisé.',
'Extension Web Clipper (Chrome / Firefox) : lorsque vous cliquez explicitement « Clipper », l\'extension lit l\'URL active, le titre de la page, la sélection de texte et (selon votre choix) le HTML complet de la page. Aucune lecture du contenu de page n\'a lieu tant que vous n\'initiez pas l\'action.',
'Cookies techniques et de session : strictement nécessaires à l\'authentification (cookies NextAuth) et aux préférences (langue, thème, panneau de configuration de l\'extension). Aucun cookie publicitaire, aucun tracker tiers par défaut.',
'Facturation et quotas : pour les tiers payants, référence de commande et jetons d\'utilisation IA stockés pour appliquer les quotas (réservation atomique). Aucune donnée bancaire (traitée par Stripe).',
'Métadonnées techniques : adresse IP (journalisée ~30 jours à des fins anti-abus), user-agent, version de l\'extension, codes d\'erreur anonymisés.',
],
},
{
id: 'usage',
title: '2. Usage des données',
body: [
'Fournir le service : synchronisation de vos carnets, recherche, Memory Echo, Memory Echo insights, agents, publication web de notes, clips depuis l\'extension.',
'Amélioration : journaux d\'erreur anonymisés et métriques agrégées (Prometheus + dashboards Grafana internes) pour diagnostiquer les pannes et améliorer les performances.',
'Sécurité : détection d\'abus, protection contre le credential stuffing et les dépassements de quotas.',
'Nous ne vendons jamais vos données. Nous ne les partageons pas avec des annonceurs. Aucun profilage publicitaire.',
],
},
{
id: 'third-parties',
title: '3. Services tiers et sous-traitants',
body: [
'Google OAuth : authentification optionnelle (Branding → Audience « In production »).',
'Google Cloud Storage (GCP) : hébergement des fichiers joints et des pièces jointes, région Europe (eu-west-3 / europe-west9).',
'PostgreSQL (Docker self-hosted) : base principale, sauvegardes chiffrées au repos.',
'Redis (Docker self-hosted) : cache de session, rate limiting, files de tâches.',
'Fournisseurs d\'IA (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, ou votre propre clé BYOK) : envoi du titre et du contenu de la note sélectionnée pour les fonctionnalités IA. Aucun entraînement n\'est effectué sur vos données par notre intermédiaire ; voir la politique du fournisseur retenu.',
'Stripe : facturation des abonnements. Les données de paiement sont gérées exclusivement par Stripe (PCI-DSS).',
'Resend : e-mails transactionnels (vérification de compte, réinitialisation de mot de passe, alertes quota).',
'Google Fonts (CDN fonts.googleapis.com) : polices web sur la landing page et le side panel de l\'extension — adresse IP communiquée à Google.',
'Google Favicons (google.com/s2/favicons) : récupération d\'icônes de sites dans l\'extension — l\'URL du domaine est communiquée à Google.',
'Grafana Cloud (monitoring optionnel) : métriques agrégées anonymisées.',
],
},
{
id: 'extension',
title: '4. Permissions de l\'extension navigateur',
body: [
'activeTab : lecture du contenu de l\'onglet actif uniquement lorsque vous cliquez le bouton « Clipper ». Aucune lecture passive.',
'scripting : injection d\'un petit script de capture de sélection après votre clic explicite.',
'storage : mémorisation de l\'URL de votre serveur Memento et du dernier carnet utilisé.',
'sidePanel (Chrome) / sidebar_action (Firefox) : ouverture du panneau latéral pour afficher l\'interface du clipper.',
'tabs : récupération de l\'URL et du titre de l\'onglet actif pour le contexte du clip.',
'host_permissions : limitées à memento-note.com en production. Les hôtes étendus (localhost / LAN) sont disponibles uniquement en build développeur.',
'L\'extension ne lit jamais l\'historique de navigation. Elle ne communique avec aucun serveur autre que l\'instance Memento que vous avez explicitement configurée.',
],
},
{
id: 'ai',
title: '5. Intelligence artificielle et BYOK',
body: [
'Les fonctionnalités IA (résumé, tags, Memory Echo, embeddings sémantiques, agents) consomment un quota mesuré en tokens. Le quota est réservé atomiquement avant chaque appel.',
'Vous pouvez fournir votre propre clé d\'API (BYOK) pour contourner les quotas mutualisés et faire transiter les appels directement par votre fournisseur. La clé est chiffrée au repos (AES-256-GCM) et n\'est jamais affichée en clair après saisie.',
'Aucun entraînement de modèle n\'est effectué sur vos données par notre intermédiaire.',
'Vous pouvez désactiver individuellement chaque fonctionnalité IA dans Réglages → IA.',
],
},
{
id: 'retention',
title: '6. Conservation des données',
body: [
'Carnets et notes : conservés tant que votre compte est actif. Suppression définitive dans les 30 jours suivant la suppression du compte (sauf obligation légale contraire).',
'Corbeille : notes supprimées conservées 30 jours avant purge définitive.',
'Journaux d\'erreur : 90 jours maximum, agrégés ensuite.',
'Sauvegardes base de données : conservées 30 jours en rotation chiffrée.',
'Métadonnées de facturation : conservées 10 ans (obligation comptable).',
],
},
{
id: 'rights',
title: '7. Vos droits (RGPD / CCPA)',
body: [
'Accès : export complet de vos notes, carnets, étiquettes et paramètres via Réglages → Données.',
'Rectification : édition directe dans l\'interface.',
'Effacement : suppression de compte dans Réglages → Données. Effacement définitif sous 30 jours.',
'Portabilité : export JSON des notes, carnets et étiquettes (équivalent de l\'outil `export_notes` du serveur MCP).',
'Opposition au traitement IA : désactivation des fonctionnalités IA dans Réglages → IA.',
'Réclamation : vous pouvez saisir la CNIL (France) ou l\'autorité de contrôle de votre lieu de résidence.',
'Pour exercer vos droits : privacy@memento-note.com.',
],
},
{
id: 'security',
title: '8. Sécurité',
body: [
'Transport : HTTPS obligatoire, HSTS activé, en-têtes de sécurité (CSP, X-Frame-Options, X-Content-Type-Options).',
'Stockage : mots de passe hachés bcrypt, clés API chiffrées AES-256-GCM, secrets en variables d\'environnement chiffrées au repos.',
'Authentification : cookies de session NextAuth, JWT signés, expiration glissante.',
'Audit : journaux d\'audit log pour les actions sensibles (changement de mot de passe, export de données, suppression de compte).',
'Bug bounty : security@memento-note.com pour signaler une vulnérabilité. Programme responsable de divulgation.',
],
},
{
id: 'minors',
title: '9. Mineurs',
body: [
'Memento n\'est pas destiné aux enfants de moins de 16 ans (RGPD) ou 13 ans (COPPA). Nous ne collectons pas sciemment de données auprès de mineurs. Si vous pensez qu\'un mineur a fourni des données, contactez privacy@memento-note.com pour suppression.',
],
},
{
id: 'changes',
title: '10. Modifications de cette politique',
body: [
'Toute modification substantielle est annoncée par e-mail au moins 30 jours à l\'avance et par bannière dans l\'application. La date de « dernière mise à jour » en haut de cette page est mise à jour à chaque révision.',
'Les modifications non substantielles (corrections de formulation, liens mis à jour) sont publiées sans préavis. La version précédente reste accessible sur demande.',
],
},
{
id: 'contact',
title: '11. Contact et responsable de traitement',
body: [
'Memento Labs — Responsable de traitement : privacy@memento-note.com',
'Délégué à la protection des données (DPO) : dpo@memento-note.com',
'Adresse postale : Memento Labs, [adresse], France',
'Hébergeur des données : serveur self-hosted sur infrastructure Memento Labs (PostgreSQL + Redis Docker, région Europe).',
],
},
],
},
en: {
title: 'Privacy Policy',
intro:
'This policy describes how Memento (Memento Labs) collects, uses and protects your data, including through the web app, the Web Clipper browser extension, and AI features. We design Memento as a personal Second Brain: by default your data belongs to you and is not shared.',
lastUpdated: 'Last updated: July 2026',
sections: [
{
id: 'data-collected',
title: '1. Data we collect',
body: [
'Account and authentication: email address, optional name, hashed password (bcrypt), or Google identifier via OAuth (Google Auth Platform).',
'Notebooks and notes: content, title, labels, attached images and files (PDF, images, documents), metadata (dates, order, color, pinned, archived), and version history (snapshots).',
'Reminders and notifications: date/time, recurrence, optional location, status (done/not done).',
'AI data: note title and body sent to your configured AI provider to generate summaries, tags, embeddings, Memory Echo, reformulations, translations, suggestions. With BYOK (your own key), requests go directly to your provider; otherwise they transit through our shared AI router.',
'Web Clipper extension (Chrome / Firefox): when you explicitly click "Clip", the extension reads the active URL, page title, text selection and (depending on your choice) the full page HTML. No page content is read unless you initiate the action.',
'Technical and session cookies: strictly necessary for authentication (NextAuth cookies) and preferences (language, theme, extension settings panel). No advertising cookies, no third-party trackers by default.',
'Billing and quotas: for paid tiers, order reference and AI usage tokens stored to enforce quotas (atomic reservation). No bank card data (handled by Stripe).',
'Technical metadata: IP address (logged ~30 days for anti-abuse), user agent, extension version, anonymized error codes.',
],
},
{
id: 'usage',
title: '2. How we use data',
body: [
'Provide the service: notebook sync, search, Memory Echo, Memory Echo insights, agents, web publishing of notes, clips from the extension.',
'Improvement: anonymized error logs and aggregated metrics (internal Prometheus + Grafana dashboards) to diagnose failures and improve performance.',
'Security: abuse detection, protection against credential stuffing and quota overruns.',
'We never sell your data. We never share it with advertisers. No advertising profiling.',
],
},
{
id: 'third-parties',
title: '3. Third parties and sub-processors',
body: [
'Google OAuth: optional authentication (Branding → Audience "In production").',
'Google Cloud Storage (GCP): hosting of attached files and attachments, Europe region (eu-west-3 / europe-west9).',
'PostgreSQL (Docker self-hosted): main database, encrypted backups at rest.',
'Redis (Docker self-hosted): session cache, rate limiting, job queues.',
'AI providers (OpenAI, Anthropic, Google Gemini, Mistral, OpenRouter, or your own BYOK key): the title and content of the selected note are sent for AI features. No training is performed on your data through us; see the chosen provider\'s policy.',
'Stripe: subscription billing. Payment data is handled exclusively by Stripe (PCI-DSS).',
'Resend: transactional emails (account verification, password reset, quota alerts).',
'Google Fonts (CDN fonts.googleapis.com): web fonts on the landing page and the extension side panel — IP address is shared with Google.',
'Google Favicons (google.com/s2/favicons): site icon retrieval in the extension — the domain URL is shared with Google.',
'Grafana Cloud (optional monitoring): anonymized aggregated metrics.',
],
},
{
id: 'extension',
title: '4. Browser extension permissions',
body: [
'activeTab: read the active tab content only when you click the "Clip" button. No passive reading.',
'scripting: inject a small selection-capture script after your explicit click.',
'storage: remember your Memento server URL and the last-used notebook.',
'sidePanel (Chrome) / sidebar_action (Firefox): open the side panel to display the clipper UI.',
'tabs: retrieve the active tab URL and title for the clip context.',
'host_permissions: limited to memento-note.com in production. Extended hosts (localhost / LAN) are only available in the developer build.',
'The extension never reads your browsing history. It never communicates with any server other than the Memento instance you explicitly configured.',
],
},
{
id: 'ai',
title: '5. Artificial intelligence and BYOK',
body: [
'AI features (summary, tags, Memory Echo, semantic embeddings, agents) consume a token-based quota. The quota is atomically reserved before each call.',
'You can provide your own API key (BYOK) to bypass shared quotas and route calls directly through your provider. The key is encrypted at rest (AES-256-GCM) and is never displayed in clear text after entry.',
'No model training is performed on your data through us.',
'You can disable each AI feature individually in Settings → AI.',
],
},
{
id: 'retention',
title: '6. Data retention',
body: [
'Notebooks and notes: kept as long as your account is active. Permanently deleted within 30 days of account deletion (unless a legal obligation requires otherwise).',
'Trash: deleted notes kept for 30 days before permanent purge.',
'Error logs: maximum 90 days, then aggregated.',
'Database backups: kept 30 days in encrypted rotation.',
'Billing metadata: kept 10 years (accounting obligation).',
],
},
{
id: 'rights',
title: '7. Your rights (GDPR / CCPA)',
body: [
'Access: full export of your notes, notebooks, labels and settings via Settings → Data.',
'Rectification: edit directly in the interface.',
'Erasure: account deletion in Settings → Data. Definitive erasure within 30 days.',
'Portability: JSON export of notes, notebooks and labels (equivalent of the `export_notes` MCP server tool).',
'Opt-out of AI processing: disable AI features in Settings → AI.',
'Complaint: you may contact the CNIL (France) or the supervisory authority of your place of residence.',
'To exercise your rights: privacy@memento-note.com.',
],
},
{
id: 'security',
title: '8. Security',
body: [
'Transport: HTTPS enforced, HSTS enabled, security headers (CSP, X-Frame-Options, X-Content-Type-Options).',
'Storage: bcrypt-hashed passwords, AES-256-GCM-encrypted API keys, secrets in encrypted environment variables at rest.',
'Authentication: NextAuth session cookies, signed JWT, sliding expiration.',
'Audit: audit logs for sensitive actions (password change, data export, account deletion).',
'Bug bounty: security@memento-note.com to report a vulnerability. Responsible disclosure program.',
],
},
{
id: 'minors',
title: '9. Minors',
body: [
'Memento is not intended for children under 16 (GDPR) or 13 (COPPA). We do not knowingly collect data from minors. If you believe a minor has provided data, contact privacy@memento-note.com for deletion.',
],
},
{
id: 'changes',
title: '10. Changes to this policy',
body: [
'Any substantial change is announced by email at least 30 days in advance and via an in-app banner. The "Last updated" date at the top of this page is updated with each revision.',
'Non-substantial changes (wording fixes, updated links) are published without notice. The previous version remains accessible on request.',
],
},
{
id: 'contact',
title: '11. Contact and data controller',
body: [
'Memento Labs — Data controller: privacy@memento-note.com',
'Data Protection Officer (DPO): dpo@memento-note.com',
'Postal address: Memento Labs, [address], France',
'Data hosting: self-hosted on Memento Labs infrastructure (PostgreSQL + Redis Docker, Europe region).',
],
},
],
},
}
export default async function PrivacyPage({
searchParams,
}: {
searchParams?: Promise<{ lang?: string | string[] }>
}) {
const sp = searchParams ? (await searchParams) : undefined
const locale = await pickLocale(sp?.lang)
const doc = DOCS[locale]
const isRtl = RTL.includes(locale)
return (
<main
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white"
>
<header className="px-5 sm:px-8 py-6 border-b border-white/[0.06] flex items-center justify-between">
<Link href="/" className="flex items-center gap-2.5">
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
<span className="font-serif text-xl font-bold leading-none">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<nav className="flex items-center gap-3">
<a
href="?lang=fr"
aria-current={locale === 'fr' ? 'true' : undefined}
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
locale === 'fr'
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
}`}
>
FR
</a>
<a
href="?lang=en"
aria-current={locale === 'en' ? 'true' : undefined}
className={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border transition-colors ${
locale === 'en'
? 'bg-[#F4F1EA] text-[#0B0A09] border-[#F4F1EA]'
: 'border-white/15 text-white/55 hover:border-white/40 hover:text-white'
}`}
>
EN
</a>
</nav>
</header>
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">Privacy</p>
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
<p className="text-sm text-white/40 mb-3">{doc.lastUpdated}</p>
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>
<nav aria-label="Sommaire" className="mb-12 p-5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<p className="text-[11px] uppercase tracking-[0.2em] text-white/40 mb-3">
{locale === 'fr' ? 'Sommaire' : 'Contents'}
</p>
<ul className="space-y-1.5 text-sm">
{doc.sections.map((s) => (
<li key={s.id}>
<a href={`#${s.id}`} className="text-white/65 hover:text-[#D4A373] transition-colors">
{s.title}
</a>
</li>
))}
</ul>
</nav>
<div className="space-y-12">
{doc.sections.map((s) => (
<section key={s.id} id={s.id} className="scroll-mt-24">
<h2 className="font-serif text-2xl tracking-tight mb-4 text-[#F4F1EA]">{s.title}</h2>
<div className="space-y-3 text-white/70 leading-relaxed">
{s.body.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</section>
))}
</div>
<footer className="mt-20 pt-8 border-t border-white/[0.06] text-xs text-white/35 flex flex-wrap items-center justify-between gap-4">
<span>© 2026 Memento Labs</span>
<Link href="/" className="hover:text-white transition-colors">
{locale === 'fr' ? 'Retour à l\'accueil' : 'Back to home'}
</Link>
</footer>
</article>
</main>
)
}

View File

@@ -2,7 +2,6 @@
import { chatService } from '@/lib/ai/services'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
/**
@@ -38,7 +37,6 @@ export async function sendChatMessage(
try {
const result = await chatService.chat(message, conversationId, notebookId)
revalidatePath('/chat')
return { success: true, ...result }
} catch (error: any) {
console.error('[ChatAction] Error:', error)

View File

@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services'
import { getAISettings } from '@/app/actions/ai-settings'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
@@ -19,13 +19,11 @@ export async function POST(request: NextRequest) {
{ status: 401 }
)
}
const userId = session.user.id
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
// Respect user's autoLabeling toggle
@@ -61,12 +59,12 @@ export async function POST(request: NextRequest) {
}
const suggestions = await withAiQuota(
session.user.id,
userId,
'auto_tag',
() =>
autoLabelCreationService.suggestLabels(
notebookId,
session.user!.id,
userId,
language,
),
{ lane: 'tags' },

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
// Get language from request headers or body

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { describeImages } from '@/lib/ai/services/image-description.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
@@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const userSettings = await getAISettings(session.user.id)

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
/**
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
@@ -19,10 +19,7 @@ export async function GET(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenResponse()
}
// Get query parameters

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
@@ -22,7 +22,7 @@ export async function POST(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const body = await req.json()

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
@@ -21,10 +21,7 @@ export async function GET(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenResponse()
}
// Get next insight (respects frequency limits)

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { existingContent, resourceText, mode, language, format } = await request.json()

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
const userId = session.user.id
const body = await request.json()
const { notebookId, theme, purpose, audience, slideCount, language } = body
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
}
const intent =
purpose || audience || slideCount
? {
purpose: purpose || 'auto',
audience: audience || 'auto',
slideCount: typeof slideCount === 'number' ? slideCount : undefined,
template: 'auto' as const,
}
: null
const result = await withAiQuota(
userId,
'slide_generate',
() =>
generateSlideDeck({
userId,
sourceNotebookId: notebookId,
theme: theme && theme !== 'auto' ? theme : null,
lang: language === 'en' ? 'en' : 'fr',
intent,
}),
{ lane: 'chat' },
)
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to generate slides' }, { status: 500 })
}
return NextResponse.json(result)
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[NotebookSlides] Error:', error)
const message = error instanceof Error ? error.message : 'Failed to generate slides'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -2,7 +2,7 @@ import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
const body = await request.json()

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { content, title, personaId } = await request.json()

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
try {
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
// Respect user's paragraphRefactor toggle (Assistant IA)

View File

@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export const maxDuration = 30
@@ -41,10 +41,7 @@ export async function POST(req: Request) {
}
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
return aiConsentForbiddenResponse()
}
const userId = session.user.id

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
@@ -10,9 +10,10 @@ export async function POST(req: NextRequest) {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const body = await req.json()
@@ -32,11 +33,11 @@ export async function POST(req: NextRequest) {
}
const suggestedNotebook = await withAiQuota(
session.user.id,
userId,
'auto_tag',
() => notebookSuggestionService.suggestNotebook(
noteContent,
session.user!.id,
userId,
language
),
{ lane: 'tags' },

View File

@@ -6,7 +6,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
import { getSystemConfig } from '@/lib/config';
import { z } from 'zod';
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
import { hasUserAiConsent } from '@/lib/consent/server-consent';
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent';
import { getAISettings } from '@/app/actions/ai-settings';
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
return aiConsentForbiddenResponse();
}
const userSettings = await getAISettings(session.user.id);

View File

@@ -7,7 +7,7 @@ import { getAISettings } from '@/app/actions/ai-settings'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const settings = await getAISettings(session.user.id)

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { text } = await request.json()

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { text, targetLanguage } = await request.json()

View File

@@ -6,7 +6,7 @@ import { getChatProvider } from '@/lib/ai/factory'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
@@ -65,10 +65,7 @@ export async function POST(req: Request) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
return aiConsentForbiddenResponse()
}
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { extractArticleFromHtml } from '@/lib/clip/extract-article'
import { extractArticleFromHtml, makeExcerpt } from '@/lib/clip/extract-article'
import { analyzeClipContent } from '@/lib/clip/analyze-clip'
import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content'
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
tags: analysis.tags,
readingTime: analysis.readingTimeMinutes,
content: contentHtml,
excerpt: textContent.slice(0, 500),
excerpt: makeExcerpt(textContent, 500),
})
} catch (error) {
console.error('[POST /api/clip/analyze]', error)

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const userSettings = await getAISettings(session.user.id)

View File

@@ -1,195 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
const STOPWORDS = new Set([
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
'from','is','are','was','were','be','been','have','has','had','do','does',
'did','will','would','could','should','may','might','this','that','these',
'those','it','its','they','them','their','he','she','we','you','not','no',
'so','if','as','up','out','about','also','just','can','all','any','get',
])
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
}
function extractKeywords(text: string): Set<string> {
return new Set(
stripHtml(text)
.toLowerCase()
.split(/[\s\p{P}]+/u)
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
)
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const w of a) if (b.has(w)) intersection++
return intersection / (a.size + b.size - intersection)
}
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
// GET /api/graph — connexions automatiques à 3 niveaux
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const { searchParams } = new URL(request.url)
const notebookId = searchParams.get('notebookId') || undefined
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
select: {
id: true, title: true, content: true, notebookId: true, createdAt: true,
labelRelations: { select: { id: true } },
notebook: { select: { id: true, name: true } },
},
take: 500,
orderBy: { updatedAt: 'desc' },
})
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
const ids = notes.map(n => n.id)
// Query NoteLink manually created relationships
const noteLinks = await (prisma as any).noteLink.findMany({
where: {
sourceNoteId: { in: ids },
targetNoteId: { in: ids }
},
select: {
sourceNoteId: true,
targetNoteId: true,
contextSnippet: true
}
})
// Query MemoryEchoInsight semantic relationships
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
note1Id: { in: ids },
note2Id: { in: ids }
},
select: {
note1Id: true,
note2Id: true,
similarityScore: true,
insight: true
}
})
// Pré-calcul
const keywordsMap = new Map<string, Set<string>>()
const labelMap = new Map<string, Set<string>>()
for (const note of notes) {
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
}
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
explicit_link: 5,
semantic_echo: 4,
title_mention: 3,
shared_label: 2,
jaccard: 1,
}
const edgeMap = new Map<string, GraphEdge>()
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
const key = a < b ? `${a}--${b}` : `${b}--${a}`
const ex = edgeMap.get(key)
if (!ex) {
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
} else {
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
}
}
}
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
for (const noteA of notes) {
const title = (noteA.title ?? '').trim().toLowerCase()
if (title.length < 3) continue
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
for (const noteB of notes) {
if (noteA.id === noteB.id) continue
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
}
}
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const la = labelMap.get(ids[i])!
const lb = labelMap.get(ids[j])!
const shared = [...la].filter(l => lb.has(l)).length
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
}
}
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
if (notes.length <= 500) {
for (let i = 0; i < ids.length; i++) {
const kwI = keywordsMap.get(ids[i])!
const candidates: { j: number; score: number }[] = []
for (let j = i + 1; j < ids.length; j++) {
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
if (score >= 0.12) candidates.push({ j, score })
}
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
}
}
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
for (const link of noteLinks) {
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
}
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
for (const echo of echoInsights) {
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
}
const degreeMap = new Map<string, number>()
for (const e of edgeMap.values()) {
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
}
const nodes = notes.map(n => ({
id: n.id,
title: n.title || 'Sans titre',
notebookId: n.notebookId,
createdAt: n.createdAt,
degree: degreeMap.get(n.id) ?? 0,
}))
// Build clusters (notebooks)
const notebookMap = new Map<string, string>()
for (const n of notes) {
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
}
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
}

View File

@@ -1,84 +0,0 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
function extractWikilinks(content: string): { title: string; snippet: string }[] {
const plain = content.replace(/<[^>]+>/g, ' ')
const results: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
results.push({ title, snippet })
}
return results
}
/**
* POST /api/graph/sync-all
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
* Call once to populate the NoteLink table from existing notes.
*/
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Get all non-trashed notes with content
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, content: { not: '' } },
select: { id: true, content: true, notebookId: true },
})
let totalLinks = 0
for (const note of notes) {
if (!note.content.includes('[[')) continue
const wikilinks = extractWikilinks(note.content)
if (wikilinks.length === 0) continue
for (const { title, snippet } of wikilinks) {
const targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },
trashedAt: null,
},
select: { id: true },
})
if (!targetNote) {
// Skip stubs in batch sync — we only link existing notes
continue
}
if (targetNote.id === note.id) continue
try {
await (prisma as any).noteLink.upsert({
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
update: { contextSnippet: snippet },
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
})
totalLinks++
} catch {
// ignore duplicate constraint errors
}
}
}
return NextResponse.json({ synced: notes.length, links: totalLinks })
}

View File

@@ -4,7 +4,7 @@ import prisma from '@/lib/prisma'
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { isPublishTemplateId } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
@@ -115,7 +115,7 @@ export async function POST(request: NextRequest) {
if (publishMode === 'ai') {
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
if (!template || !isPublishTemplateId(template)) {
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })

View File

@@ -101,7 +101,7 @@ export default async function RootLayout({
const htmlStyle = {
'--color-brand-accent': serverAccent,
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
colorScheme: wantsDarkClass ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
} as CSSProperties
return (

View File

@@ -1,61 +0,0 @@
'use client'
import { useState } from 'react'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
export default function TestTitleSuggestionsPage() {
const [content, setContent] = useState('')
const { suggestions, isAnalyzing, error } = useTitleSuggestions({
content,
enabled: true // Always enabled for testing
})
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h1>Test Title Suggestions</h1>
<div style={{ marginBottom: '20px' }}>
<label>Content (need 50+ words):</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ width: '100%', height: '200px', marginTop: '10px' }}
placeholder="Type at least 50 words here..."
/>
</div>
<div style={{ marginBottom: '20px', padding: '10px', background: '#f0f0f0' }}>
<p><strong>Word count:</strong> {wordCount} / 50</p>
<p><strong>Status:</strong> {isAnalyzing ? 'Analyzing...' : 'Idle'}</p>
{error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
</div>
<div>
<h2>Suggestions ({suggestions.length}):</h2>
{suggestions.length > 0 ? (
<ul>
{suggestions.map((s, i) => (
<li key={i}>
<strong>{s.title}</strong> (confidence: {s.confidence}%)
{s.reasoning && <p> {s.reasoning}</p>}
</li>
))}
</ul>
) : (
<p style={{ color: '#666' }}>No suggestions yet. Type 50+ words and wait 2 seconds.</p>
)}
</div>
<div style={{ marginTop: '20px' }}>
<button onClick={() => {
setContent('word '.repeat(50))
}}>
Fill with 50 words (test)
</button>
</div>
</div>
)
}

View File

@@ -23,15 +23,12 @@ export const authConfig = {
nextUrl.pathname.startsWith('/settings') ||
nextUrl.pathname.startsWith('/lab') ||
nextUrl.pathname.startsWith('/agents') ||
nextUrl.pathname.startsWith('/chat') ||
nextUrl.pathname.startsWith('/canvas') ||
nextUrl.pathname.startsWith('/notebooks') ||
nextUrl.pathname.startsWith('/note/') ||
nextUrl.pathname.startsWith('/brainstorm') ||
nextUrl.pathname.startsWith('/insights') ||
nextUrl.pathname.startsWith('/graph') ||
nextUrl.pathname.startsWith('/revision') ||
nextUrl.pathname.startsWith('/support');
nextUrl.pathname.startsWith('/revision');
const isAdminPage = nextUrl.pathname.startsWith('/admin');
const isPublicPage = nextUrl.pathname === '/' ||
nextUrl.pathname === '/login' ||

View File

@@ -128,7 +128,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = null
setIsRunning(false)
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), {
toast.error(t('agents.toasts.runError', { error: data.errorKey ? t(data.errorKey) : (data.error || t('agents.toasts.runFailed')) }), {
id: toastId,
description: '' // Clear the loading description
})

View File

@@ -122,7 +122,7 @@ export function AutoLabelSuggestionDialog({
onLabelsCreated()
onOpenChange(false)
} else {
toast.error(data.error || t('ai.autoLabels.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.autoLabels.error')))
}
} catch (error) {
console.error('Failed to create labels:', error)

View File

@@ -149,7 +149,7 @@ export function BatchOrganizationDialog({
onNotesMoved()
onOpenChange(false)
} else {
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.batchOrganization.applyFailed')))
}
} catch (error) {
console.error('Failed to apply organization plan:', error)

View File

@@ -1,222 +0,0 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { ChatSidebar } from './chat-sidebar'
import { ChatMessages } from './chat-messages'
import { ChatInput } from './chat-input'
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
import { toast } from 'sonner'
import type { UIMessage } from 'ai'
import { useLanguage } from '@/lib/i18n'
interface ChatContainerProps {
initialConversations: any[]
notebooks: any[]
webSearchAvailable?: boolean
}
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
const { t, language } = useLanguage()
const [conversations, setConversations] = useState(initialConversations)
const [currentId, setCurrentId] = useState<string | null>(null)
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
// Prevents the useEffect from loading an empty conversation
// when we just created one via createConversation()
const skipHistoryLoad = useRef(false)
const scrollRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({
api: '/api/chat',
})).current
const {
messages,
sendMessage,
status,
setMessages,
stop,
} = useChat({
transport,
onError: (error) => {
toast.error(error.message || t('chat.assistantError'))
},
})
const isLoading = status === 'submitted' || status === 'streaming'
const refreshConversations = useCallback(async () => {
try {
const updated = await getConversations()
setConversations(updated)
} catch {}
}, [])
// Timeout warning: show toast if response takes > 30s
useEffect(() => {
if (!isLoading) return
const timer = setTimeout(() => {
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
}, 30000)
return () => clearTimeout(timer)
}, [isLoading, t])
// Sync historyMessages after each completed streaming response
// so the display doesn't revert to stale history.
// Also refresh sidebar so new conversation appears.
useEffect(() => {
if (status === 'ready' && messages.length > 0) {
setHistoryMessages([...messages])
refreshConversations()
}
}, [status, messages, refreshConversations])
// Load conversation details when the user selects a different conversation
useEffect(() => {
// Skip if we just created the conversation — useChat already has the messages
if (skipHistoryLoad.current) {
skipHistoryLoad.current = false
return
}
if (currentId) {
const loadMessages = async () => {
setIsLoadingHistory(true)
try {
const details = await getConversationDetails(currentId)
if (details) {
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
id: m.id || `hist-${i}`,
role: m.role as 'user' | 'assistant',
parts: [{ type: 'text' as const, text: m.content }],
}))
setHistoryMessages(loaded)
setMessages(loaded)
}
} catch (error) {
toast.error(t('chat.loadError'))
} finally {
setIsLoadingHistory(false)
}
}
loadMessages()
} else {
setMessages([])
setHistoryMessages([])
}
}, [currentId])
const handleSendMessage = async (content: string, notebookId?: string) => {
if (notebookId) {
setSelectedNotebook(notebookId)
}
// If no active conversation, create one BEFORE streaming
let convId = currentId
if (!convId) {
try {
const result = await createConversation(content, notebookId || selectedNotebook)
convId = result.id
// Tell the useEffect to skip — we don't want to load an empty conversation
skipHistoryLoad.current = true
setCurrentId(convId)
setHistoryMessages([])
setConversations((prev) => [
{ id: result.id, title: result.title, updatedAt: new Date() },
...prev,
])
} catch {
toast.error(t('chat.createError'))
return
}
}
try {
await sendMessage(
{ text: content },
{
body: {
conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined,
language,
webSearch: webSearchEnabled,
},
}
)
} catch (error) {
console.error('Chat send error:', error)
toast.error(t('chat.assistantError') || 'Failed to send message')
}
}
const handleNewChat = () => {
setCurrentId(null)
setMessages([])
setHistoryMessages([])
setSelectedNotebook(undefined)
setWebSearchEnabled(false)
}
const handleDeleteConversation = async (id: string) => {
try {
await deleteConversation(id)
if (currentId === id) {
handleNewChat()
}
await refreshConversations()
} catch {
toast.error(t('chat.deleteError'))
}
}
// During streaming or if useChat has more messages than history, prefer useChat
const displayMessages = isLoading || messages.length > historyMessages.length
? messages
: historyMessages
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [displayMessages])
return (
<div className="flex-1 flex overflow-hidden bg-background">
<ChatSidebar
conversations={conversations}
currentId={currentId}
onSelect={setCurrentId}
onNew={handleNewChat}
onDelete={handleDeleteConversation}
/>
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
</div>
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
<div className="w-full max-w-4xl px-4">
<ChatInput
onSend={handleSendMessage}
isLoading={isLoading}
onStop={stop}
notebooks={notebooks}
currentNotebookId={selectedNotebook || null}
webSearchEnabled={webSearchEnabled}
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
webSearchAvailable={webSearchAvailable}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,159 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
interface ChatInputProps {
onSend: (message: string, notebookId?: string) => void
isLoading?: boolean
onStop?: () => void
notebooks: any[]
currentNotebookId?: string | null
webSearchEnabled?: boolean
onToggleWebSearch?: () => void
webSearchAvailable?: boolean
}
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
const { t } = useLanguage()
const [input, setInput] = useState('')
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (currentNotebookId) {
setSelectedNotebook(currentNotebookId)
}
}, [currentNotebookId])
const handleSend = () => {
if (!input.trim() || isLoading) return
onSend(input, selectedNotebook)
setInput('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}, [input])
return (
<div className="w-full relative">
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
{/* Input Area */}
<Textarea
ref={textareaRef}
placeholder={t('chat.placeholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
/>
{/* Bottom Actions Bar */}
<div className="flex items-center justify-between px-3 pb-3 pt-1">
{/* Context Selector */}
<div className="flex items-center gap-2">
<Select
value={selectedNotebook || 'global'}
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
>
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder={t('chat.allNotebooks')} />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
{(() => {
const Icon = getNotebookIcon(nb.icon)
return <Icon className="w-3.5 h-3.5" />
})()} {nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNotebook && (
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
{t('chat.active')}
</Badge>
)}
{webSearchAvailable && (
<button
type="button"
onClick={onToggleWebSearch}
className={cn(
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
webSearchEnabled
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
)}
>
<Globe className="h-3.5 w-3.5" />
{webSearchEnabled && t('chat.webSearch')}
</button>
)}
</div>
{/* Send / Stop Button */}
{isLoading && onStop ? (
<Button
onClick={onStop}
size="icon"
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
disabled={!input.trim() || isLoading}
onClick={handleSend}
size="icon"
className={cn(
"rounded-full h-8 w-8 transition-all duration-200",
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
)}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
<div className="text-center mt-3">
<span className="text-[11px] text-muted-foreground/60 w-full block">
{t('chat.disclaimer')}
</span>
</div>
</div>
)
}

View File

@@ -1,84 +0,0 @@
'use client'
import { User, Bot, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { useLanguage } from '@/lib/i18n'
interface ChatMessagesProps {
messages: any[]
isLoading?: boolean
}
function getMessageContent(msg: any): string {
if (typeof msg.content === 'string') return msg.content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const { t } = useLanguage()
return (
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
<Bot className="h-12 w-12 text-primary opacity-60" />
</div>
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
{t('chat.welcome')}
</p>
</div>
)}
{messages.map((msg, index) => {
const content = getMessageContent(msg)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
return (
<div
key={msg.id || index}
className={cn(
"flex w-full px-4 md:px-0 py-6 my-2 group",
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
)}
>
{msg.role === 'user' ? (
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
) : (
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div dir="auto" className="flex-1 overflow-hidden pt-1">
{content ? (
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : isLastAssistant ? (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
</div>
) : null}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,127 +0,0 @@
'use client'
import { useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface ChatSidebarProps {
conversations: any[]
currentId?: string | null
onSelect: (id: string) => void
onNew: () => void
onDelete?: (id: string) => void
}
export function ChatSidebar({
conversations,
currentId,
onSelect,
onNew,
onDelete,
}: ChatSidebarProps) {
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const confirmDelete = (id: string) => {
setPendingDelete(id)
}
const cancelDelete = (e: React.MouseEvent) => {
e.stopPropagation()
setPendingDelete(null)
}
const executeDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
setPendingDelete(null)
if (onDelete) {
await onDelete(id)
}
}
return (
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
<div className="p-4 border-bottom">
<Button
onClick={onNew}
className="w-full justify-start gap-2 shadow-sm"
variant="outline"
>
<Plus className="h-4 w-4" />
{t('chat.newConversation')}
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{conversations.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{t('chat.noHistory')}
</div>
) : (
conversations.map((chat) => (
<div
key={chat.id}
onClick={() => onSelect(chat.id)}
className={cn(
"relative cursor-pointer rounded-lg transition-all group",
currentId === chat.id
? "bg-primary/10 text-primary dark:bg-primary/20"
: "hover:bg-muted/50 text-muted-foreground"
)}
>
<div className="p-3 flex flex-col gap-1">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0" />
<span className="truncate text-sm font-medium pr-6">
{chat.title || t('chat.untitled')}
</span>
</div>
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>
{/* Delete button — visible on hover or when confirming */}
{pendingDelete !== chat.id && (
<button
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{/* Inline confirmation banner */}
{pendingDelete === chat.id && (
<div
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
onClick={(e) => e.stopPropagation()}
>
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
<button
onClick={(e) => executeDelete(e, chat.id)}
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
>
{t('chat.yes')}
</button>
<button
onClick={cancelDelete}
className="p-0.5 hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
))
)}
</div>
</div>
)
}

View File

@@ -345,7 +345,7 @@ export function ContextualAIChat({
return
}
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
const descs = data.descriptions || []
let resultText = descs.map((d: any) =>
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
@@ -381,7 +381,7 @@ export function ContextualAIChat({
return
}
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
const result = data[action.resultKey] || ''
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
} catch (e: any) {
@@ -457,7 +457,7 @@ export function ContextualAIChat({
})
const data = await res.json()
if (!res.ok || !data.success) {
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
mToast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.errorShort')), { id: toastId })
setGenerateLoading(null)
return
}
@@ -577,7 +577,7 @@ export function ContextualAIChat({
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.resource.enrichError')))
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichError'))
@@ -622,7 +622,7 @@ export function ContextualAIChat({
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
setResourcePreview({ text: data.enrichedContent, source: mode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichErrorShort'))

View File

@@ -58,7 +58,7 @@ export function DashboardInboxWidget({
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : (
<button
type="button"
@@ -94,7 +94,7 @@ export function DashboardRevisionWidget({
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : (
<button
type="button"
@@ -135,7 +135,7 @@ export function DashboardStatsWidget({
>
{loading ? (
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />)}
</div>
) : (
<button type="button" onClick={onOpen} className="w-full text-start">
@@ -176,8 +176,8 @@ export function DashboardAgentActivityWidget({
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : actions.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
@@ -222,7 +222,7 @@ export function DashboardGmailWidget({
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : connected ? (
<button
type="button"
@@ -264,7 +264,7 @@ export function DashboardPinnedWidget({
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : notes.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>

View File

@@ -30,7 +30,7 @@ export function DashboardDailyReview({
>
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />)}
</div>
) : (
<ul className="space-y-1.5">
@@ -79,7 +79,7 @@ export function DashboardOpenLoops({
compact
>
{loading ? (
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-16 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : loops.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
) : (
@@ -122,7 +122,7 @@ export function DashboardDailyNoteWidget({
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : (
<button
type="button"
@@ -158,7 +158,7 @@ export function DashboardLinkSuggestions({
>
{loading ? (
<div className="space-y-2">
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : paths.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
@@ -216,7 +216,7 @@ export function DashboardBridgesWidget({
title={t('homeDashboard.widgets.bridges')}
>
{loading ? (
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-20 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : suggestions.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
) : (

View File

@@ -6,6 +6,7 @@ import { useReducedMotion } from 'motion/react'
import { Inbox, Send, Bell, Mail } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
import { createNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import { toast } from 'sonner'
@@ -246,13 +247,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const [echoRefreshing, setEchoRefreshing] = useState(false)
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
const [briefingError, setBriefingError] = useState(false)
const loadBriefing = useCallback(async () => {
try {
const res = await fetch('/api/briefing', { cache: 'no-store' })
if (res.ok) setData(await res.json())
if (res.ok) {
setData(await res.json())
setBriefingError(false)
} else {
setBriefingError(true)
}
} catch {
/* état dégradé */
setBriefingError(true)
}
}, [])
@@ -292,6 +299,36 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
}
}, [])
/** Recharge briefing puis enrichit les pistes (évite loadPaths() sans argument). */
const reloadBriefingAndPaths = useCallback(async () => {
try {
const res = await fetch('/api/briefing', { cache: 'no-store' })
if (!res.ok) {
setBriefingError(true)
return
}
const briefing = await res.json()
setData(briefing)
setBriefingError(false)
setPaths(buildFastPathsFromBriefing({
recentNotes: (briefing.recentNotes ?? []).map((n: BriefingNote) => ({
id: n.id,
title: n.title,
content: n.content,
notebookId: n.notebookId,
})),
inboxCount: briefing.inboxCount ?? 0,
dueFlashcards: briefing.dueFlashcards ?? 0,
insights: briefing.insights ?? [],
bridgeSuggestions: briefing.bridgeSuggestions ?? [],
agentSuggestions: briefing.agentSuggestions ?? [],
}))
await loadPaths(briefing)
} catch {
setBriefingError(true)
}
}, [loadPaths])
const loadSentiment = useCallback(async () => {
try {
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
@@ -483,10 +520,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
try {
const res = await fetch('/api/ai/echo')
const json = await res.json()
if (res.status === 403 && json.error === 'ai_consent_required') { await requestAiConsent(); return }
if (res.status === 403 && json.code === 'ai_consent_required') {
redirectToAiConsentSettings(router)
return
}
if (!res.ok) throw new Error(json.error)
if (json.insight) {
await Promise.all([loadBriefing(), loadPaths()])
await reloadBriefingAndPaths()
emitAiUsageChanged()
toast.success(t('homeDashboard.echoFound'))
} else {
@@ -497,12 +537,12 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
} finally {
setEchoRefreshing(false)
}
}, [requestAiConsent, loadBriefing, loadPaths, t])
}, [requestAiConsent, reloadBriefingAndPaths, t, router])
const handleEnableAi = useCallback(async () => {
await requestAiConsent()
await Promise.all([loadBriefing(), loadPaths()])
}, [requestAiConsent, loadBriefing, loadPaths])
await reloadBriefingAndPaths()
}, [requestAiConsent, reloadBriefingAndPaths])
const handleDismissBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => {
const key = `${s.clusterAId}-${s.clusterBId}`
@@ -672,7 +712,8 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
{
key: 'connection',
label: t('homeDashboard.dailyReviewConnection'),
done: pathsList.some(p => p.type === 'connect' || p.type === 'resurface'),
// Coché quand il n'y a plus de piste de connexion à traiter
done: !pathsList.some(p => p.type === 'connect' || p.type === 'resurface'),
onClick: () => {
const p = pathsList.find(x => x.type === 'connect' || x.type === 'resurface')
if (p) handlePathAction(p)
@@ -853,8 +894,8 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
/>
{briefingLoading ? (
<div className="space-y-2">
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : reminders.length === 0 ? (
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.allCaughtUp')}</p>
@@ -1021,6 +1062,20 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
{briefingError && !data && (
<div className="mb-4 rounded-xl border border-rose-500/30 bg-rose-500/5 px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p className="text-sm text-ink dark:text-dark-ink">
{t('homeDashboard.briefingLoadError')}
</p>
<button
type="button"
onClick={() => { void reloadBriefingAndPaths() }}
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-ink text-white dark:bg-white dark:text-black"
>
{t('homeDashboard.briefingRetry')}
</button>
</div>
)}
{/* ── En-tête : orientation en 2 secondes ── */}
<header className="mb-5">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">

View File

@@ -64,11 +64,7 @@ export function FlashcardGenerateDialog({
})
const data = await res.json()
if (!res.ok) {
if (data.errorKey) {
toast.error(t(data.errorKey) || data.error)
} else {
toast.error(data.error || t('flashcards.generateFailed'))
}
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.generateFailed')))
return
}
setCards(data.cards || [])

View File

@@ -353,7 +353,7 @@ export function RevisionView() {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
return
}
setActiveDeckId(deckId)
@@ -432,7 +432,7 @@ export function RevisionView() {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
setExpandedDeckId(null)
return
}

View File

@@ -69,7 +69,7 @@ export function FusionModal({
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Failed to generate fusion')
throw new Error(data.errorKey ? t(data.errorKey) : (data.error || 'Failed to generate fusion'))
}
if (!data.fusedNote) {

View File

@@ -20,7 +20,7 @@ import {
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe, Presentation } from 'lucide-react'
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
@@ -67,6 +67,10 @@ const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
)
const NotebookSlidesDialog = dynamic(
() => import('@/components/wizard/notebook-slides-dialog').then(m => ({ default: m.NotebookSlidesDialog })),
{ ssr: false }
)
const StructuredViewsIntro = dynamic(
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
{ ssr: false }
@@ -151,6 +155,7 @@ export function HomeClient({
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
const [showNotebookSite, setShowNotebookSite] = useState(false)
const [showNotebookSlides, setShowNotebookSlides] = useState(false)
const [aiMenuOpen, setAiMenuOpen] = useState(false)
const aiMenuRef = useRef<HTMLDivElement>(null)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
@@ -179,7 +184,7 @@ export function HomeClient({
toast.success(`${data.created} notes importées !`)
window.location.reload()
} else {
toast.error(data.error || 'Erreur')
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
}
}
input.click()
@@ -1136,6 +1141,11 @@ export function HomeClient({
label: t('notebookSite.shortTitle') || 'Site web',
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
},
{
icon: <Presentation size={14} />,
label: t('notebook.slides') || 'Présentation',
action: () => { setShowNotebookSlides(true); setAiMenuOpen(false) },
},
].map((item, i) => (
<button
key={i}
@@ -1452,6 +1462,14 @@ export function HomeClient({
onClose={() => setShowNotebookSite(false)}
/>
)}
{showNotebookSlides && currentNotebook && (
<NotebookSlidesDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowNotebookSlides(false)}
/>
)}
</div>
)
}

View File

@@ -568,7 +568,11 @@ export function LandingPage() {
if (!label || label.startsWith('landing.')) return null
return (
<li key={j}>
<a href={href} className="hover:text-white transition-colors">{label}</a>
{href.startsWith('/') ? (
<Link href={href} className="hover:text-white transition-colors">{label}</Link>
) : (
<a href={href} className="hover:text-white transition-colors">{label}</a>
)}
</li>
)
})}

View File

@@ -2,7 +2,9 @@
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { getLocalStorageAiConsent, setLocalStorageAiConsent, removeLocalStorageAiConsent } from '@/lib/consent/ai-consent-client'
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
import { updateAISettings } from '@/app/actions/ai-settings'
import { AiConsentModal } from './ai-consent-modal'
import { toast } from 'sonner'
@@ -24,6 +26,7 @@ interface AiConsentProviderProps {
export function AiConsentProvider({ children, initialPersistentConsent = false }: AiConsentProviderProps) {
const { data: session, update: updateSession } = useSession()
const { t } = useLanguage()
const router = useRouter()
const [persistentConsent, setPersistentConsent] = useState(false)
const [sessionConsent, setSessionConsent] = useState(false)
@@ -60,11 +63,22 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
return Promise.resolve(true)
}
setModalOpen(true)
// On settings page we keep the inline modal; elsewhere redirect to settings
// so the user sees exactly where to grant consent.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/settings/general')) {
setModalOpen(true)
return new Promise<boolean>((resolve) => {
pendingResolveRef.current = resolve
})
}
redirectToAiConsentSettings(router, { replace: false })
return new Promise<boolean>((resolve) => {
pendingResolveRef.current = resolve
pendingResolveRef.current(false)
pendingResolveRef.current = null
})
}, [hasAiConsent])
}, [hasAiConsent, router])
const handleConfirm = async (remember: boolean) => {
setModalOpen(false)

View File

@@ -426,7 +426,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const errorData = await response.json()
throw new Error(errorData.error || t('ai.titleGenerationError'))
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.titleGenerationError')))
}
const data = await response.json()
@@ -502,7 +502,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const errorData = await response.json()
throw new Error(errorData.error || t('ai.reformulationError'))
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.reformulationError')))
}
const data = await response.json()
@@ -542,7 +542,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.clarifyFailed')))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
@@ -575,7 +575,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.shortenFailed')))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
@@ -608,7 +608,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.improveFailed')))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
@@ -642,7 +642,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
body: JSON.stringify({ text: content })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.transformFailed')))
setContentImmediate(data.transformedText)
setIsMarkdown(true)

View File

@@ -165,11 +165,11 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
// Remove all action buttons, toolbars, and UI elements
clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove())
// Remove remaining UI controls (block action icons with tooltips) regardless of
// UI language — keep user content that may carry a title (links, abbreviations).
clone.querySelectorAll('[title]').forEach(el => {
const title = el.getAttribute('title')
if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) {
el.remove()
}
const tag = el.tagName.toLowerCase()
if (tag !== 'a' && tag !== 'abbr') el.remove()
})
// Render KaTeX equations properly
@@ -330,7 +330,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
duration: 6000,
})
} else {
toast.error(data.error || t('general.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
}
} catch {
toast.error(t('general.error'))
@@ -408,7 +408,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
})
} else {
toast.dismiss(toastId)
toast.error(data.error || t('general.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
}
} catch {
toast.dismiss(toastId)
@@ -437,7 +437,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
setPublishOpen(false)
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || t('general.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
}
} catch {
toast.error(t('general.error'))

View File

@@ -46,7 +46,7 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
duration: 6000,
})
} else {
toast.error(data.error || 'Erreur')
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
}
} catch { toast.error('Erreur') }
finally { setLoading(false) }

View File

@@ -1,789 +0,0 @@
'use client'
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
import { openNotePath } from '@/lib/navigation/open-note'
import {
Loader2,
Network,
Filter,
X,
ExternalLink,
Maximize2,
Calendar,
Clock,
Link2,
FileText,
Check,
Tag,
Sparkles,
ChevronRight,
BookOpen
} from 'lucide-react'
import DOMPurify from 'isomorphic-dompurify'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { useLanguage } from '@/lib/i18n'
import { LabelBadge } from './label-badge'
import { NoteChecklist } from './note-checklist'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) as any
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
ssr: false,
loading: () => <div className="h-20 w-full animate-pulse bg-concrete/5 rounded" />
})
interface GraphNode { id: string; title: string; notebookId: string | null; createdAt: string; degree: number }
interface GraphEdge { source: string; target: string; weight: number; type: string }
interface Cluster { id: string; name: string }
interface RawData { nodes: GraphNode[]; edges: GraphEdge[]; clusters: Cluster[] }
interface NotePreview {
id: string
title: string | null
content: string
createdAt: string | Date
updatedAt?: string | Date
labels?: string[] | null
type?: 'text' | 'markdown' | 'richtext' | 'checklist'
checkItems?: { id: string; text: string; checked: boolean }[] | null
isMarkdown?: boolean
}
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
type EdgeTypeKey = 'explicit_link' | 'semantic_echo' | 'title_mention' | 'shared_label' | 'jaccard'
const DEFAULT_EDGE_FILTERS: Record<EdgeTypeKey, boolean> = {
explicit_link: true,
semantic_echo: true,
title_mention: true,
shared_label: true,
jaccard: false,
}
export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
const router = useRouter()
const { notebooks } = useNotebooks()
const containerRef = useRef<HTMLDivElement>(null)
const graphRef = useRef<any>(null)
const existingNodesRef = useRef<Map<string, any>>(new Map())
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
const [rawData, setRawData] = useState<RawData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchFilter, setSearchFilter] = useState('')
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
const [edgeFilters, setEdgeFilters] = useState(DEFAULT_EDGE_FILTERS)
const [semanticMinWeight, setSemanticMinWeight] = useState(0.45)
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
const [controlsOpen, setControlsOpen] = useState(!embedded)
const { t, language } = useLanguage()
const plainText = useCallback((html: string | null | undefined) =>
(html ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/#{1,6}\s/g, '')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\s+/g, ' ').trim().slice(0, 400), [])
const htmlContent = useMemo(() => {
if (!notePreview?.content) return ''
const isMarkdown = notePreview.type === 'markdown' || notePreview.isMarkdown || (!notePreview.content.includes('<') && !notePreview.content.includes('</'))
let rawHtml = notePreview.content
if (isMarkdown) {
rawHtml = markdownToHtml(notePreview.content)
}
return DOMPurify.sanitize(rawHtml)
}, [notePreview])
const wordCount = useMemo(() => {
if (!notePreview?.content) return 0
const text = plainText(notePreview.content)
return text.split(/\s+/).filter(Boolean).length
}, [notePreview, plainText])
const charCount = useMemo(() => {
if (!notePreview?.content) return 0
return plainText(notePreview.content).length
}, [notePreview, plainText])
const isRtl = useMemo(() => {
if (!notePreview?.content) return false
const sample = plainText(notePreview.content).replace(/\s+/g, '').slice(0, 400)
const rtlChars = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
let rtl = 0
let ltr = 0
for (const ch of sample) {
if (rtlChars.test(ch)) rtl++
else if (/[A-Za-z]/.test(ch)) ltr++
}
return rtl > 0 && rtl >= ltr
}, [notePreview, plainText])
// ─── Resize ───────────────────────────────────────────────────────────────
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect
setDimensions({ width: Math.floor(width), height: Math.floor(height) })
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// ─── Fetch data ───────────────────────────────────────────────────────────
useEffect(() => {
setLoading(true)
fetch('/api/graph')
.then(r => { if (!r.ok) throw new Error(t('errors.network')); return r.json() })
.then(d => setRawData(d))
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [])
// ─── Configure forces once graph is mounted ───────────────────────────────
const forcesConfigured = useRef(false)
useEffect(() => {
if (!rawData || forcesConfigured.current) return
// Wait for the ForceGraph to mount
const timer = setTimeout(() => {
const fg = graphRef.current
if (!fg) return
fg.d3Force('charge')?.strength(-120)
fg.d3Force('link')?.distance(55)
fg.d3Force('center')?.strength(0.05)
forcesConfigured.current = true
}, 200)
return () => clearTimeout(timer)
}, [rawData])
// ─── Note preview ─────────────────────────────────────────────────────────
useEffect(() => {
if (!selectedNode) { setNotePreview(null); return }
setPreviewLoading(true)
fetch(`/api/notes/${selectedNode.id}`)
.then(r => r.ok ? r.json() : null)
.then(res => setNotePreview(res?.data ?? null))
.catch(() => setNotePreview(null))
.finally(() => setPreviewLoading(false))
}, [selectedNode])
// ─── Color map ────────────────────────────────────────────────────────────
const colorMap = useMemo(() => {
if (!rawData) return new Map<string | null, string>()
const map = new Map<string | null, string>()
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] as string[]
ids.forEach((id, i) => {
const nb = notebooks.find(n => n.id === id)
map.set(id, nb?.color || PALETTE[i % PALETTE.length])
})
return map
}, [rawData, notebooks])
const neighborIds = useMemo(() => {
if (!focusNodeId || !rawData) return null
const ids = new Set<string>([focusNodeId])
for (const edge of rawData.edges) {
if (edge.source === focusNodeId) ids.add(edge.target)
if (edge.target === focusNodeId) ids.add(edge.source)
}
return ids
}, [focusNodeId, rawData])
// ─── Graph data ───────────────────────────────────────────────────────────
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] }
let filtered = selectedNotebookId
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
: rawData.nodes
if (neighborIds) {
filtered = filtered.filter(n => neighborIds.has(n.id))
}
filtered = searchFilter.trim()
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
: filtered
const filteredIds = new Set(filtered.map(n => n.id))
const visibleEdges = rawData.edges.filter(e => {
const type = e.type as EdgeTypeKey
if (!(type in edgeFilters) || !edgeFilters[type]) return false
if (type === 'semantic_echo' && e.weight < semanticMinWeight) return false
return filteredIds.has(e.source) && filteredIds.has(e.target)
})
return {
nodes: filtered.map(n => {
const existing = existingNodesRef.current.get(n.id)
if (existing) {
existing.name = n.title
existing.val = 1 + Math.min(n.degree, 8) * 0.5
existing.color = colorMap.get(n.notebookId) ?? '#94a3b8'
existing.notebookId = n.notebookId
existing.degree = n.degree
return existing
}
const newNode = {
id: n.id,
name: n.title,
val: 1 + Math.min(n.degree, 8) * 0.5,
color: colorMap.get(n.notebookId) ?? '#94a3b8',
notebookId: n.notebookId,
degree: n.degree,
}
existingNodesRef.current.set(n.id, newNode)
return newNode
}),
links: visibleEdges.map(e => {
let color = '#cbd5e1'
let width = 2.5
let dash = false
if (e.type === 'explicit_link') {
color = '#10b981'
width = 4.5
} else if (e.type === 'semantic_echo') {
color = '#8b5cf6'
width = 3.5
dash = true
} else if (e.type === 'title_mention') {
color = '#f59e0b'
width = 3.2
} else if (e.type === 'shared_label') {
color = '#3b82f6'
width = 2.8
}
return {
source: e.source,
target: e.target,
color,
width,
dash,
type: e.type,
}
}),
}
}, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds])
const selectedNotebookName = useMemo(() => {
if (!selectedNode || !rawData) return null
return rawData.clusters.find(c => c.id === selectedNode.notebookId)?.name ?? null
}, [selectedNode, rawData])
// ─── Handlers (double-click via timer) ──────────────────────────────────
const lastClickRef = useRef<{ id: string; time: number } | null>(null)
const handleNodeClick = useCallback((node: any) => {
if (!rawData) return
const now = Date.now()
const last = lastClickRef.current
if (last && last.id === node.id && now - last.time < 350) {
lastClickRef.current = null
router.push(openNotePath(node.id))
return
}
lastClickRef.current = { id: node.id, time: now }
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
}, [rawData, router])
const handleZoomToFit = useCallback(() => {
graphRef.current?.zoomToFit(400, 50)
}, [])
const toggleEdgeFilter = useCallback((key: EdgeTypeKey) => {
setEdgeFilters(prev => ({ ...prev, [key]: !prev[key] }))
}, [])
// Zoom vers le premier nœud correspondant à la recherche
useEffect(() => {
if (!searchFilter.trim() || graphData.nodes.length === 0) return
const timer = window.setTimeout(() => {
const fg = graphRef.current
if (!fg) return
const match = fg.graphData()?.nodes?.find((n: { id: string; name?: string }) =>
(n.name ?? '').toLowerCase().includes(searchFilter.toLowerCase())
)
if (match?.x != null && match?.y != null) {
fg.centerAt(match.x, match.y, 500)
fg.zoom(2.2, 500)
}
}, 600)
return () => window.clearTimeout(timer)
}, [searchFilter, graphData.nodes.length])
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
const dataRef = useRef<{ nodes: any[]; colorMap: Map<string|null,string>; clusters: Cluster[] }>({ nodes: [], colorMap: new Map(), clusters: [] })
dataRef.current = { nodes: graphData.nodes, colorMap, clusters: rawData?.clusters ?? [] }
const paintClusters = useRef((ctx: CanvasRenderingContext2D, globalScale: number) => {
const { nodes, colorMap: cm, clusters } = dataRef.current
if (!nodes || nodes.length === 0) return
const groups = new Map<string, { x: number; y: number }[]>()
for (const node of nodes) {
if (!node.notebookId || node.x === undefined || node.y === undefined) continue
if (!groups.has(node.notebookId)) groups.set(node.notebookId, [])
groups.get(node.notebookId)!.push({ x: node.x, y: node.y })
}
for (const [nbId, pts] of groups) {
if (pts.length < 3) continue
const color = cm.get(nbId) ?? '#94a3b8'
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
let maxR = 0
for (const p of pts) {
const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2)
if (d > maxR) maxR = d
}
const r = maxR + 30
ctx.beginPath()
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.fillStyle = color + '0A'
ctx.fill()
ctx.strokeStyle = color + '30'
ctx.lineWidth = 1.5 / globalScale
ctx.setLineDash([5 / globalScale, 5 / globalScale])
ctx.stroke()
ctx.setLineDash([])
// Cluster name
if (globalScale > 0.4) {
const name = clusters.find(c => c.id === nbId)?.name ?? ''
if (name) {
const fs = Math.min(12, 9 / globalScale)
ctx.font = `600 ${fs}px -apple-system, sans-serif`
ctx.fillStyle = color + 'BB'
ctx.textAlign = 'center'
ctx.textBaseline = 'bottom'
ctx.fillText(name, cx, cy - r + 4)
}
}
}
}).current
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className={`flex flex-col h-full ${embedded ? 'bg-transparent' : 'bg-[#FAFAF9]'}`}>
{!embedded && (
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
<Network size={16} className="text-indigo-500" />
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
{rawData && (
<span className="text-[10px] text-concrete/50 font-medium">
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
{graphData.links.length !== rawData.edges.length && (
<> · {t('graphView.visibleConnections', { count: graphData.links.length })}</>
)}
</span>
)}
<div className="flex-1" />
<div className="relative">
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete/40" />
<input
type="text"
placeholder={t('graphView.searchPlaceholder')}
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="pl-7 pr-7 py-1.5 bg-white border border-border/60 rounded-md text-xs text-ink outline-none focus:border-indigo-400 w-44 placeholder:text-concrete/40"
/>
{searchFilter && (
<button onClick={() => setSearchFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-concrete/40 hover:text-ink">
<X size={12} />
</button>
)}
</div>
</div>
)}
{/* Canvas */}
<div ref={containerRef} className="flex-1 relative overflow-hidden">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#FAFAF9]">
<Loader2 size={24} className="animate-spin text-concrete/40" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-rose-500">{error}</p>
</div>
)}
{!loading && !error && graphData.nodes.length > 0 && (
<ForceGraph2D
ref={graphRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
backgroundColor="#FAFAF9"
nodeRelSize={5}
nodeVal="val"
nodeColor="color"
nodeLabel="name"
linkColor="color"
linkWidth="width"
linkOpacity={0.92}
linkDirectionalParticles={2}
linkDirectionalParticleWidth={2.5}
linkLineDash={(link: any) => link.dash ? [6, 4] : null}
onNodeClick={handleNodeClick}
onNodeHover={(node: any) => {
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
}}
onRenderFramePre={paintClusters}
nodeCanvasObjectMode={() => 'after'}
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const n = node as any
if (globalScale < 0.7) return
const name: string = n.name ?? ''
const label = name.length > 20 ? name.slice(0, 18) + '…' : name
const fontSize = 11 / globalScale
if (fontSize > 18) return
ctx.font = `${fontSize}px -apple-system, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const r = Math.sqrt(n.val ?? 1) * 5
// White background behind label
const tw = ctx.measureText(label).width
const lx = n.x - tw / 2 - 2
const ly = n.y + r + 2
ctx.fillStyle = 'rgba(250,250,249,0.85)'
ctx.fillRect(lx, ly, tw + 4, fontSize + 2)
// Label text
ctx.fillStyle = '#334155'
ctx.fillText(label, n.x, ly + 1)
}}
cooldownTicks={80}
d3AlphaDecay={0.03}
d3VelocityDecay={0.4}
/>
)}
{!loading && !error && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-concrete/40">
<Network size={32} />
<p className="text-xs">{t('graphView.noNotesFound')}</p>
</div>
)}
{/* Zoom to fit */}
{!loading && graphData.nodes.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); handleZoomToFit() }}
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors animate-fade-in"
>
<Maximize2 size={12} />
{t('graphView.globalView')}
</button>
)}
{/* Cluster legend (Interactive Notebook Filter) */}
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
<div
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[42vh] overflow-y-auto pr-1"
>
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
{(selectedNotebookId || focusNodeId) && (
<div className="flex flex-col gap-1">
{selectedNotebookId && (
<button
onClick={() => setSelectedNotebookId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
{t('graphView.resetFilter')}
</button>
)}
{focusNodeId && (
<button
onClick={() => setFocusNodeId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-indigo-200 text-indigo-600 rounded-full shadow-sm hover:bg-indigo-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
{t('graphView.resetFocus')}
</button>
)}
</div>
)}
<div className="flex flex-col gap-1.5">
{rawData.clusters.map(c => {
const isSelected = selectedNotebookId === c.id
const isAnySelected = selectedNotebookId !== null
const color = colorMap.get(c.id) ?? '#94a3b8'
return (
<button
key={c.id}
onClick={() => setSelectedNotebookId(isSelected ? null : c.id)}
className={`flex items-center gap-2 px-3 py-1 bg-white border rounded-full shadow-sm transition-all duration-200 hover:scale-105 w-fit text-left ${
isSelected
? 'border-indigo-500 ring-2 ring-indigo-500/20 font-semibold'
: isAnySelected
? 'border-border/30 opacity-40 hover:opacity-100'
: 'border-border/30 hover:border-concrete/40'
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[10px] text-concrete/80 whitespace-nowrap">{c.name}</span>
{isSelected && <X size={10} className="text-concrete/40 ml-0.5 shrink-0" />}
</button>
)
})}
</div>
</div>
)}
{/* Filtres de liens + seuil sémantique */}
{!loading && !error && rawData && (
<div
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 max-w-[220px]"
>
<button
type="button"
onClick={() => setControlsOpen(v => !v)}
className="flex items-center gap-2 px-3 py-1.5 bg-white/95 border border-border/40 rounded-lg shadow-sm text-[10px] font-semibold text-slate-700 w-fit"
>
<Filter size={12} />
{t('graphView.linkFilters')}
</button>
{controlsOpen && (
<div className="p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm space-y-2.5 select-none">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider">{t('graphView.relationshipTypes')}</h3>
{([
['explicit_link', t('graphView.edgeTypes.explicitLink')],
['semantic_echo', t('graphView.edgeTypes.semanticEcho')],
['title_mention', t('graphView.edgeTypes.titleMention')],
['shared_label', t('graphView.edgeTypes.sharedLabel')],
['jaccard', t('graphView.edgeTypes.jaccard')],
] as [EdgeTypeKey, string][]).map(([key, label]) => (
<label key={key} className="flex items-center gap-2.5 cursor-pointer text-[10px] text-slate-700">
<input
type="checkbox"
checked={edgeFilters[key]}
onChange={() => toggleEdgeFilter(key)}
className="w-3.5 h-3.5 shrink-0 rounded border-2 border-slate-300 accent-indigo-600 cursor-pointer"
/>
<span>{label}</span>
</label>
))}
{edgeFilters.semantic_echo && (
<div className="pt-1 border-t border-border/30 space-y-1">
<div className="flex items-center justify-between text-[9px] text-concrete/70">
<span>{t('graphView.semanticThreshold')}</span>
<span className="font-mono">{Math.round(semanticMinWeight * 100)}%</span>
</div>
<input
type="range"
min={0.3}
max={0.9}
step={0.05}
value={semanticMinWeight}
onChange={e => setSemanticMinWeight(Number(e.target.value))}
className="w-full h-1 accent-indigo-500"
/>
</div>
)}
</div>
)}
</div>
)}
{/* Legend of relationship types (compact) */}
{!loading && !error && graphData.nodes.length > 0 && controlsOpen && (
<div className="absolute bottom-4 right-[21rem] z-10 hidden xl:flex flex-col gap-1.5 p-2.5 bg-white/90 border border-border/40 rounded-lg shadow-sm max-w-xs select-none pointer-events-none opacity-80">
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.explicitLink')}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.semanticEcho')}</span>
</div>
</div>
)}
{/* Note detail panel */}
{selectedNode && (
<div
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="absolute inset-y-0 right-0 w-80 backdrop-blur-md bg-white/95 dark:bg-stone-900/95 border-l border-border/40 flex flex-col shadow-[0_8px_30px_rgb(0,0,0,0.06)] z-20 transition-all duration-300"
>
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-border/40">
<div className="flex-1 min-w-0 pr-3">
{selectedNotebookName && (
<button
onClick={() => setSelectedNotebookId(selectedNode.notebookId === selectedNotebookId ? null : selectedNode.notebookId)}
className="mb-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider bg-slate-100 hover:bg-slate-200/80 text-concrete transition-all border border-border/20 hover:scale-105"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(selectedNode.notebookId) ?? '#94a3b8' }} />
{selectedNotebookName}
</button>
)}
<h2
dir={isRtl ? 'rtl' : 'ltr'}
className={`text-sm font-semibold text-slate-800 dark:text-slate-100 leading-snug tracking-tight select-all ${isRtl ? 'text-right font-persian font-semibold' : 'text-left font-sans'}`}
>
{selectedNode.title || <span className="italic text-concrete/40">{t('notes.untitled')}</span>}
</h2>
</div>
<button
onClick={() => setSelectedNode(null)}
className="p-1.5 rounded-full text-concrete/40 hover:text-slate-800 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-stone-800 transition-all"
>
<X size={14} />
</button>
</div>
{/* Quick Metadata Info */}
<div className="px-5 py-3.5 bg-slate-50/50 dark:bg-stone-950/20 border-b border-border/30 grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] text-concrete/60 select-none">
<div className="flex items-center gap-1.5">
<Calendar size={11} className="text-concrete/40 shrink-0" />
<span className="truncate" title={new Date(selectedNode.createdAt).toLocaleString(language === 'fa' ? 'fa-IR' : language)}>
{new Date(selectedNode.createdAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Link2 size={11} className="text-concrete/40 shrink-0" />
<span>
{t(selectedNode.degree === 1 ? 'graphView.connections' : 'graphView.connectionsPlural', { count: selectedNode.degree })}
</span>
</div>
{!previewLoading && notePreview && (
<>
<div className="flex items-center gap-1.5">
<FileText size={11} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.words', { count: wordCount })}</span>
</div>
{notePreview.updatedAt && (
<div className="flex items-center gap-1.5 col-span-2 border-t border-border/20 pt-1.5 mt-0.5">
<Clock size={11} className="text-concrete/40 shrink-0" />
<span className="truncate">
{t('graphView.preview.updated')}{' '}
{new Date(notePreview.updatedAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}
</>
)}
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{previewLoading ? (
/* Sleek Skeleton Loader */
<div className="space-y-4 animate-pulse">
<div className="h-4 bg-stone-200/60 dark:bg-stone-800/60 rounded w-3/4" />
<div className="space-y-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-11/12" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-4/5" />
</div>
<div className="space-y-2 pt-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-5/6" />
</div>
</div>
) : !notePreview || (!notePreview.content && (!notePreview.checkItems || notePreview.checkItems.length === 0)) ? (
<div className="flex flex-col items-center justify-center py-16 text-concrete/30 gap-2.5">
<FileText size={32} className="stroke-[1.2] text-concrete/20" />
<p className="text-xs italic font-medium">{t('graphView.preview.emptyNote')}</p>
</div>
) : (
<>
{/* Note Content Renderer */}
{notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
<div className="space-y-2 select-none" dir={isRtl ? 'rtl' : 'ltr'}>
<NoteChecklist
items={notePreview.checkItems}
onToggleItem={() => {}}
/>
</div>
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
<div className="text-xs text-slate-600 dark:text-stone-300" dir={isRtl ? 'rtl' : 'ltr'}>
<MarkdownContent
content={notePreview.content}
className={`prose-h1:text-sm prose-h1:font-bold prose-h1:text-slate-800 dark:prose-h1:text-stone-100 prose-h1:mt-3 prose-h1:mb-1 prose-h2:text-xs prose-h2:font-bold prose-h2:text-slate-700 dark:prose-h2:text-stone-200 prose-h2:mt-2 prose-h2:mb-1 prose-p:text-xs prose-p:leading-relaxed prose-p:mb-2 prose-ul:list-disc prose-ul:pl-4 prose-ol:list-decimal prose-ol:pl-4 ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}`}
/>
</div>
) : (
<div
dir={isRtl ? 'rtl' : 'ltr'}
className={`text-xs text-slate-600 dark:text-stone-300 space-y-2 leading-relaxed break-words ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}
[&_h1]:text-sm [&_h1]:font-bold [&_h1]:text-slate-800 dark:[&_h1]:text-stone-100 [&_h1]:mt-4 [&_h1]:mb-1.5 [&_h1]:first:mt-0
[&_h2]:text-xs [&_h2]:font-bold [&_h2]:text-slate-700 dark:[&_h2]:text-stone-200 [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:first:mt-0
[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-slate-600 dark:[&_h3]:text-stone-300 [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:first:mt-0
[&_p]:mb-2 [&_p]:last:mb-0
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:mb-2
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:mb-2
[&_li]:mb-0.5
[&_strong]:font-semibold [&_strong]:text-slate-800 dark:[&_strong]:text-stone-100
[&_em]:italic
[&_code]:px-1 [&_code]:py-0.5 [&_code]:bg-slate-100 dark:[&_code]:bg-stone-850 [&_code]:rounded [&_code]:font-mono [&_code]:text-[10px]
[&_pre]:p-2.5 [&_pre]:bg-slate-900 [&_pre]:text-slate-100 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[10px] [&_pre]:my-2
[&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 dark:[&_blockquote]:border-stone-700 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-500 [&_blockquote]:my-2
[&_a]:text-indigo-600 dark:[&_a]:text-indigo-400 [&_a]:underline [&_a]:hover:text-indigo-500`}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
)}
{/* Refined Tags list */}
{Array.isArray(notePreview.labels) && notePreview.labels.length > 0 && (
<div className="border-t border-border/20 pt-4 mt-4 select-none">
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-800 dark:text-stone-300 uppercase tracking-wider mb-2">
<Tag size={10} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.tags')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{notePreview.labels.map((label: string) => (
<LabelBadge key={label} label={label} variant="default" />
))}
</div>
</div>
)}
</>
)}
</div>
{/* Premium Action Footer */}
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20 space-y-2">
<button
type="button"
onClick={() => setFocusNodeId(prev => prev === selectedNode.id ? null : selectedNode.id)}
className="w-full flex items-center justify-center gap-2 py-2 px-4 bg-white dark:bg-stone-900 border border-border/50 hover:border-indigo-400 text-xs font-medium rounded-lg transition-colors"
>
<Sparkles size={12} className="text-indigo-500" />
<span>{focusNodeId === selectedNode.id ? t('graphView.resetFocus') : t('graphView.exploreFromNode')}</span>
</button>
<button
onClick={() => router.push(openNotePath(selectedNode.id))}
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
>
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
<span>{t('graphView.preview.openNote')}</span>
<ChevronRight size={12} className="group-hover:translate-x-0.5 transition-transform ml-0.5" />
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -68,7 +68,7 @@ export function NotebookSummaryDialog({
if (data.success && data.data) {
setSummary(data.data)
} else {
toast.error(data.error || t('notebook.summaryError'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('notebook.summaryError')))
onOpenChange(false)
}
} catch (error) {

View File

@@ -124,7 +124,7 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('common.error'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('common.error')))
setResults(prev => new Map(prev).set(personaId, data))
setExpanded(personaId)
} catch {

View File

@@ -2005,7 +2005,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
if (!res.ok) { toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); return }
let html = data.reformulatedText || data.text || ''
// Clean up excessive whitespace
html = html

View File

@@ -565,7 +565,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
{overviewLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={12} className="animate-spin text-brand-accent" />
<span className="text-[11px] text-muted-foreground">t('searchModal.resultsSummary')</span>
<span className="text-[11px] text-muted-foreground">{t('searchModal.resultsSummary')}</span>
</div>
) : (
<p className="text-[12px] leading-relaxed text-foreground/80">{overview}</p>
@@ -687,7 +687,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
<div className="space-y-1">
<p className="text-[11.5px] font-bold">{t('searchModal.documentPreview')}</p>
<p className="text-[10px] italic opacity-60">
Sélectionnez un résultat pour explorer son contenu.
{t('searchModal.selectResultToPreview')}
</p>
</div>
</div>

View File

@@ -1536,6 +1536,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</span>
</Link>
<Link
href="/archive"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/archive'
? 'bg-amber-500/10 text-amber-500 border border-amber-500/25'
: 'text-concrete hover:text-amber-500 hover:bg-amber-500/5'
)}
>
{pathname === '/archive' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-amber-500 rounded-r-full" />}
<Archive size={16} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{t('sidebar.archive')}
</span>
</Link>
<Link
href="/home?shared=1&forceList=1"
className={cn(
@@ -1558,7 +1574,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
>
<Search size={15} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
Recherche (Ctrl+K)
{t('sidebar.searchShortcut')}
</span>
</button>
@@ -1568,7 +1584,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
>
{isDark ? <Sun size={15} /> : <Moon size={15} />}
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{isDark ? 'Mode clair' : 'Mode sombre'}
{isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
</span>
</button>

View File

@@ -100,7 +100,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
} else if (data.errorKey === 'ai.quotaExceeded') {
toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé')
} else {
toast.error(data.error || 'Erreur')
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
}
setLoading(false)
return

View File

@@ -100,7 +100,7 @@ export function NotebookSiteDialog({ notebookId, notebookName, onClose }: Notebo
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); setStep('selection'); return }
if (!res.ok) { toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); setStep('selection'); return }
setExistingSlug(data.slug)
setSiteUrl(data.url)
setStep('done')

View File

@@ -0,0 +1,237 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Presentation, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
const THEMES = [
'auto',
'Architectural SaaS',
'Midnight Cathedral',
'Aurora Borealis',
'Tokyo Neon',
'Sunlit Gallery',
'Clinical Precision',
'Venture Pitch',
'Forest Floor',
'Steel & Glass',
'Cyberpunk Terminal',
'Editorial Ink',
'Coastal Morning',
'Paper Studio',
]
const PURPOSES = [
{ value: 'auto', key: 'ai.generate.purposeAuto' },
{ value: 'course', key: 'ai.generate.purposeCourse' },
{ value: 'board', key: 'ai.generate.purposeBoard' },
{ value: 'project', key: 'ai.generate.purposeProject' },
{ value: 'strategy', key: 'ai.generate.purposeStrategy' },
{ value: 'pitch', key: 'ai.generate.purposePitch' },
{ value: 'summary', key: 'ai.generate.purposeSummary' },
]
const STATUS_KEYS = [
'notebook.slidesStatus1',
'notebook.slidesStatus2',
'notebook.slidesStatus3',
'notebook.slidesStatus4',
]
export function NotebookSlidesDialog({
notebookId,
notebookName,
onClose,
}: {
notebookId: string
notebookName: string
onClose: () => void
}) {
const { t, language } = useLanguage()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [theme, setTheme] = useState('auto')
const [purpose, setPurpose] = useState('auto')
const [progress, setProgress] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval>>(null)
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [])
const handleGenerate = async () => {
setLoading(true)
setProgress(0)
let elapsed = 0
timerRef.current = setInterval(() => {
elapsed += 0.6
setProgress(() => {
const target = 85 * (1 - Math.exp(-elapsed / 15))
return Math.min(target, 85)
})
}, 600)
try {
const res = await fetch('/api/ai/notebook-slides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notebookId,
theme,
purpose,
language: language === 'fr' ? 'fr' : 'en',
}),
})
const data = await res.json()
if (timerRef.current) clearInterval(timerRef.current)
setProgress(100)
if (!res.ok) {
toast.error(
data.errorKey === 'ai.featureLocked'
? (t('ai.featureLocked') || 'Plan requis')
: (data.error || 'Erreur'),
)
} else if (data.success && data.canvasId) {
window.dispatchEvent(new Event('ai-usage-changed'))
toast.success(t('notebook.slidesReady') || `Présentation générée (${data.slideCount} slides)`)
setTimeout(() => {
router.push(`/lab?canvas=${data.canvasId}`)
onClose()
}, 400)
} else {
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur lors de la génération'))
}
} catch (e: any) {
if (timerRef.current) clearInterval(timerRef.current)
toast.error(e.message || 'Erreur')
} finally {
setLoading(false)
}
}
const statusIdx = Math.min(Math.floor(progress / 22), STATUS_KEYS.length - 1)
return (
<div
className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
dir="auto"
onClick={loading ? undefined : onClose}
>
<div
className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5 shrink-0">
<div className="flex items-center gap-2">
<Presentation className="h-5 w-5 text-brand-accent" />
<h2 className="text-base font-semibold">
{t('notebook.slides') || 'Présentation'}
</h2>
</div>
<button
onClick={onClose}
disabled={loading}
className="p-1 rounded-lg hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-not-allowed"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6 space-y-5">
{!loading && (
<>
<p className="text-sm text-muted-foreground">
{t('notebook.slidesDescription') || `Génère une présentation à partir des notes du carnet « ${notebookName} ».`}
</p>
<div className="space-y-1.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
{t('ai.generate.theme') || 'Thème'}
</span>
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
>
{THEMES.map((th) => (
<option key={th} value={th}>
{th === 'auto' ? (t('ai.generate.themeAuto') || 'Auto') : th}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
{t('ai.generate.purpose') || 'Type de deck'}
</span>
<select
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
>
{PURPOSES.map((p) => (
<option key={p.value} value={p.value}>
{t(p.key) || p.value}
</option>
))}
</select>
</div>
<button
onClick={handleGenerate}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
>
<Presentation className="h-4 w-4" />
{t('notebook.slidesGenerate') || 'Générer la présentation'}
</button>
</>
)}
{loading && (
<div className="space-y-5 py-4">
<div className="space-y-2">
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-brand-accent to-brand-accent/70 rounded-full transition-all duration-700 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground animate-pulse">
{t(STATUS_KEYS[statusIdx])}
</span>
<span className="text-xs font-mono font-bold text-brand-accent tabular-nums">
{Math.round(progress)}%
</span>
</div>
</div>
<div className="flex items-center justify-between px-2">
{STATUS_KEYS.map((_, i) => (
<div
key={i}
className={`h-1 flex-1 mx-0.5 rounded-full transition-colors duration-300 ${
i <= statusIdx ? 'bg-brand-accent' : 'bg-muted'
}`}
/>
))}
</div>
<p className="text-[10px] text-muted-foreground/60 text-center">
{t('notebook.slidesGeneratingHint') || 'Outline + rédaction des slides'}
</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,36 +1,30 @@
# Audit dashboard Second Brain (`/home`) — 2026-07-16
## Portée
## Demande dorigine
Revue code de `dashboard-view.tsx` + widgets catalogue / paths / briefing.
« Audit analytique : réaliser un audit complet du tableau de bord. »
## Points sains
## Correctifs appliqués (code)
| Zone | Constat |
|------|---------|
| Layout v5 configurable | Catalogue widgets + `/api/dashboard/layout` |
| Prochaines pistes | Fast paths + enrich async `/api/briefing/paths` |
| Revue quotidienne | Checklist interactive (pas revue auto) |
| Quotas | `emitAiUsageChanged` + consent IA sur actions |
| Aide « ? » | `DashboardWidgetHelp` i18n |
| Empty states | Widgets catalogue gèrent listes vides |
| # | Problème | Fix |
|---|----------|-----|
| 1 | Après refresh Memory Echo / activation IA, `loadPaths()` appelé **sans argument** → pistes non rechargées | `reloadBriefingAndPaths()` charge briefing puis paths |
| 2 | Si `/api/briefing` échoue : spinner infini, aucune erreur | Bannière + bouton Réessayer |
| 3 | Checklist « connexion » cochée quand il restait des pistes | `done` = plus aucune piste connect/resurface |
| 4 | Skeletons `bg-stone-50` clairs en dark mode | Variante `dark:bg-zinc-950/40` |
| 5 | Texte widget usage « par fonctionnalité » (seaux) | Solde unique de crédits |
## Risques / écarts
## Déjà OK (revue code)
| # | Problème | Sévérité | Action |
|---|----------|----------|--------|
| 1 | Briefing dépend du cron / première visite : widgets peuvent rester vides temporairement | Moyenne | UX « chargement / aucune donnée » déjà partielle — dogfood |
| 2 | Bridges / clusters sémantiques absurdes cross-domaine | Moyenne | Produit insights (hors dashboard pur) |
| 3 | Widget usage encore mentalité « seaux » vs solde crédits | Basse | `DashboardUsageWidget` lit `/api/usage/current` (balance multi) — OK si API à jour |
| 4 | Agent suggestions vides si cron agent-suggestions off | Basse | Ops CRON_SECRET |
- Layout v5 + grille widgets
- Fast paths + enrich async
- Revue matinale interactive
- Quotas via `UsageMeter` (balance crédits)
- Aide « ? » widgets
- Empty states flashcards / activité
## Verdict
## Reste hors code (ops / produit)
Dashboard **utilisable** et aligné prototype sur larchitecture. Pas de bug bloquant code identifié dans cette passe.
**Reste dogfood** : première visite, dark mode, clics paths, quotas 402.
## Suite recommandée
1. Dogfood 10 min sur compte réel
2. Si widget usage affiche encore danciens seaux → forcer refresh API (déjà multi-feature dans usage-current)
3. Prioriser pertinence bridges (insights) plutôt que refonte dashboard
- Cron agent-suggestions pour peupler le carrousel agents
- Pertinence sémantique des ponts (logique clusters /insights)
- Dogfood manuel sur compte réel après deploy

View File

@@ -10,12 +10,22 @@ Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node ex
## Installation (dev)
1. Chrome`chrome://extensions`
### Chrome
1. `chrome://extensions`
2. **Mode développeur****Charger lextension non empaquetée** → dossier `memento-note/extension`
3. Épingle licône Memento
> Chrome **114+** requis (Side Panel API).
### Firefox
```bash
# Avec web-ext (https://extensionworkshop.com/)
npx web-ext run --source-dir memento-note/extension
```
Ou via `about:debugging`**Ce Firefox****Charger un module temporaire** → sélectionner `manifest.json`.
> Firefox **115+** requis (MV3 + sidebarAction).
## Instance Memento
- **Dev** : icône ⚙ → URL (`http://localhost:3000` ou IP LAN) → **Appliquer & reconnecter**
@@ -45,6 +55,30 @@ Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node ex
Détection automatique `dir` / `lang` (ex. BBC Persian), aperçu RTL avec Vazirmatn.
## Builds production
Deux scripts produisent des paquets prêts à publier :
```bash
# Chrome Web Store (zip)
node extension/scripts/build-chrome-store.mjs
# → memento-note/extension/memento-web-clipper-chrome-store.zip (~44 Ko, 27 fichiers)
# Firefox Add-ons (xpi)
node extension/scripts/build-firefox.mjs
# → memento-note/extension/memento-web-clipper-firefox.xpi (~44 Ko, 27 fichiers)
```
Chaque build :
- Verrouille `ALLOW_INSTANCE_CONFIG = false` (URL prod en dur)
- Restreint les `host_permissions` à `https://memento-note.com/*`
- Génère les PNG d'icônes depuis `public/icons/icon-{192,512}.svg`
- Exclut les fichiers internes (`i18n/`, `diagnose.js`, `test-sidepanel.html`, etc.)
Différences manifest :
- **Chrome** : `side_panel` (Side Panel API Chrome 114+)
- **Firefox** : `sidebar_action` + `browser_specific_settings.gecko` (Firefox 115+)
## APIs
- `GET /api/clip/notebooks`

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "مومنتو ويب كليبر"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Memento الخاصة بك - ويتصل بخادم Memento الخاص بك."
},
"extActionTitle": {
"message": "مقطع إلى مومنتو"
"message": "قص إلى Memento"
},
"webClipper": {
"message": "مقص الويب"
"message": "Web Clipper"
},
"connected": {
"message": "متصل"
@@ -36,7 +36,7 @@
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
},
"footerVersion": {
"message": "Memento Web Clipper <<<الإصدار>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
@@ -69,7 +69,7 @@
"message": "نصيحة: قم بتمييز النص الموجود على الصفحة لقص التحديد الدقيق كملاحظة."
},
"clipSelection": {
"message": "اختيار المقطع"
"message": "قص التحديد"
},
"clipPage": {
"message": "قص هذه الصفحة"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
},
"errLoginHintUrl": {
"message": "متصل بـ: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "إغلاق"
},
"summaryLabel": {
"message": "ملخص"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,12 +1,12 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Memento-Notizbüchern stellt eine Verbindung zu Ihrem eigenen Memento-Server her."
},
"extActionTitle": {
"message": "Clip auf Memento"
"message": "Zu Memento clippen"
},
"webClipper": {
"message": "Web Clipper"
@@ -36,7 +36,7 @@
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen oder öffnen Sie den Seitenbereich."
@@ -66,19 +66,19 @@
"message": "ignorieren"
},
"selectionHint": {
"message": "Tipp: Markieren Sie Text auf der Seite, um eine präzise Auswahl als Notiz auszuschneiden."
"message": "Tipp: Markiere Text auf der Seite, um eine präzise Auswahl als Notiz zu clippen."
},
"clipSelection": {
"message": "Clip-Auswahl"
"message": "Auswahl clippen"
},
"clipPage": {
"message": "Clip diese Seite aus"
"message": "Diese Seite clippen"
},
"saveLinkOnly": {
"message": "Nur Link speichern"
},
"pageNotAccessible": {
"message": "Hier kann kein Clip erstellt werden diese Seite blockiert den Zugriff auf die Erweiterung."
"message": "Hier kann nicht geclippt werden diese Seite blockiert den Zugriff durch Erweiterungen."
},
"errLoginRequired": {
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Memento an."
@@ -102,7 +102,7 @@
}
},
"restrictedPage": {
"message": "Eingeschränkte Seite Ausschneiden über die Memento-Symbolleiste oder den Seitenbereich."
"message": "Eingeschränkte Seite — clippe über die Memento-Symbolleiste oder den Seitenbereich."
},
"destinationNotebook": {
"message": "Zielnotizbuch"
@@ -153,7 +153,7 @@
"message": "In Memento ansehen"
},
"clipAnother": {
"message": "Schneiden Sie eine weitere Seite aus"
"message": "Eine weitere Seite clippen"
},
"failure": {
"message": "Konnte nicht abgeschlossen werden"
@@ -165,7 +165,7 @@
"message": "Wiederholen"
},
"errNoSelection": {
"message": "Wählen Sie zuerst den Text aus oder schneiden Sie die gesamte Seite aus."
"message": "Wähle zuerst Text aus, oder clippe die ganze Seite."
},
"errAnalyzeFailed": {
"message": "Diese Seite konnte nicht analysiert werden."
@@ -177,6 +177,29 @@
"message": "Netzwerkproblem überprüfen Sie Ihre Verbindung und Memento-URL."
},
"bannerPickText": {
"message": "Markieren Sie Text auf der Seite oder schneiden Sie die gesamte Seite aus."
"message": "Markiere Text auf der Seite, oder clippe die ganze Seite."
},
"errLoginHintUrl": {
"message": "Verbunden mit: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Schließen"
},
"summaryLabel": {
"message": "Zusammenfassung"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -36,7 +36,7 @@
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Highlight text on the page, or clip the whole page."
},
"errLoginHintUrl": {
"message": "Connected to: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Dismiss"
},
"summaryLabel": {
"message": "Summary"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Cortadora web Memento"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Capture páginas web y texto resaltado en sus cuadernos Memento: se conecta a su propio servidor Memento."
},
"extActionTitle": {
"message": "Clip al momento"
"message": "Clipar a Memento"
},
"webClipper": {
"message": "Cortadora web"
"message": "Web Clipper"
},
"connected": {
"message": "Conectado"
@@ -36,7 +36,7 @@
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIÓN>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
@@ -69,10 +69,10 @@
"message": "Consejo: resalte el texto en la página para recortar una selección precisa como nota."
},
"clipSelection": {
"message": "Selección de clips"
"message": "Clipar selección"
},
"clipPage": {
"message": "Recortar esta página"
"message": "Clipar esta página"
},
"saveLinkOnly": {
"message": "Guardar enlace solamente"
@@ -153,7 +153,7 @@
"message": "Ver en momento"
},
"clipAnother": {
"message": "Recortar otra página"
"message": "Clipar otra página"
},
"failure": {
"message": "No se pudo completar"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Resalte el texto de la página o recorte toda la página."
},
"errLoginHintUrl": {
"message": "Conectado a: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Cerrar"
},
"summaryLabel": {
"message": "Resumen"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,12 +1,12 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Memento خود ضبط کنید — به سرور Memento خودتان متصل می شود."
},
"extActionTitle": {
"message": "کلیپ به لحظه"
"message": "کلیپ در Memento"
},
"webClipper": {
"message": "Web Clipper"
@@ -36,7 +36,7 @@
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکی‌های این مرورگر ورود به سیستم را کنترل می‌کنند."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
@@ -69,10 +69,10 @@
"message": "نکته: متن را در صفحه برجسته کنید تا یک انتخاب دقیق به عنوان یادداشت بریده شود."
},
"clipSelection": {
"message": "انتخاب کلیپ"
"message": "کلیپ انتخاب"
},
"clipPage": {
"message": "این صفحه را کلیپ کنید"
"message": "کلیپ این صفحه"
},
"saveLinkOnly": {
"message": "فقط لینک را ذخیره کنید"
@@ -153,7 +153,7 @@
"message": "مشاهده در Memento"
},
"clipAnother": {
"message": "یک صفحه دیگر را کلیپ کنید"
"message": لیپ صفحه‌ای دیگر"
},
"failure": {
"message": "تکمیل نشد"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
},
"errLoginHintUrl": {
"message": "متصل به: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "بستن"
},
"summaryLabel": {
"message": "خلاصه"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -36,13 +36,13 @@
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento 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"
"message": "Carnet sans nom"
},
"noNotebooks": {
"message": "Pas encore de carnets"
@@ -63,10 +63,10 @@
"message": "Sélection détectée"
},
"ignore": {
"message": "ignorer"
"message": "Ignorer"
},
"selectionHint": {
"message": "Astuce : surlignez du texte à lécran pour clipper une sélection précise de la page en tant que note."
"message": "Astuce : surlignez du texte sur la page pour clipper une sélection précise en note."
},
"clipSelection": {
"message": "Clipper la sélection"
@@ -84,7 +84,7 @@
"message": "Veuillez d'abord vous connecter à Memento dans ce navigateur."
},
"errLoadNotebooks": {
"message": "Impossible de charger les carnets. Essayez de vous reconnecter."
"message": "Impossible de charger les blocs-notes. Essayez de vous reconnecter."
},
"notebooksLoaded": {
"message": "Carnets chargés"
@@ -102,7 +102,7 @@
}
},
"restrictedPage": {
"message": "Page restreinte : clip via la barre d'outils Memento ou le panneau latéral."
"message": "Page restreinte — utilisez le bouton Memento ou le panneau latéral pour clipper."
},
"destinationNotebook": {
"message": "Carnet de destination"
@@ -129,7 +129,7 @@
"message": "Analyse de la source"
},
"statusAnalyzing": {
"message": "Analyse…"
"message": "Analyse en cours…"
},
"statusSaving": {
"message": "Enregistrement…"
@@ -177,6 +177,29 @@
"message": "Problème de réseau : vérifiez votre connexion et l'URL Memento."
},
"bannerPickText": {
"message": "Surlignez le texte à clipper"
"message": "Surlignez le texte sur la page, ou clippez la page entière."
},
"errLoginHintUrl": {
"message": "Connecté à : $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Ignorer"
},
"summaryLabel": {
"message": "Résumé"
},
"contentScriptMissing": {
"message": "Content script non détecté — rechargez la page pour clipper."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copier"
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "मोमेंटो वेब क्लिपर"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "अपने मोमेंटो नोटबुक में वेब पेज और हाइलाइट किए गए टेक्स्ट को कैप्चर करें - यह आपके अपने मोमेंटो सर्वर से जुड़ता है।"
},
"extActionTitle": {
"message": "मोमेंटो पर क्लिप करें"
"message": "Memento में क्लिप करें"
},
"webClipper": {
"message": "वेब क्लिपर"
"message": "Web Clipper"
},
"connected": {
"message": "जुड़े हुए"
@@ -36,7 +36,7 @@
"message": "अपने मोमेंटो सर्वर का HTTPS (या LAN) URL चिपकाएँ। इस ब्राउज़र में कुकीज़ साइन-इन को संभालती हैं।"
},
"footerVersion": {
"message": "मोमेंटो वेब क्लिपर <<<संस्करण>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "मोमेंटो इस टैब तक नहीं पहुंच सकता. कीबोर्ड/साइट एक्सटेंशन अनुमतियां जांचें - या साइड पैनल खोलें।"
@@ -69,10 +69,10 @@
"message": "युक्ति: किसी सटीक चयन को नोट के रूप में क्लिप करने के लिए पृष्ठ पर टेक्स्ट को हाइलाइट करें।"
},
"clipSelection": {
"message": "क्लिप चयन"
"message": "चयन क्लिप करें"
},
"clipPage": {
"message": "इस पृष्ठ को क्लिप करें"
"message": "इस पेज को क्लिप करें"
},
"saveLinkOnly": {
"message": "केवल लिंक सहेजें"
@@ -153,7 +153,7 @@
"message": "मोमेंटो में देखें"
},
"clipAnother": {
"message": "दूसरे पेज को क्लिप करें"
"message": "एक और पेज क्लिप करें"
},
"failure": {
"message": "पूरा नहीं हो सका"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "पृष्ठ पर टेक्स्ट को हाइलाइट करें, या पूरे पृष्ठ को क्लिप करें।"
},
"errLoginHintUrl": {
"message": "से जुड़ा: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "बंद करें"
},
"summaryLabel": {
"message": "सारांश"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Memento: si connette al tuo server Memento."
},
"extActionTitle": {
"message": "Clip su Memento"
"message": "Clippa in Memento"
},
"webClipper": {
"message": "Tagliatore di fotoricettore"
"message": "Web Clipper"
},
"connected": {
"message": "Collegato"
@@ -36,7 +36,7 @@
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIONE>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
@@ -66,19 +66,19 @@
"message": "ignorare"
},
"selectionHint": {
"message": "Suggerimento: evidenzia il testo sulla pagina per ritagliare una selezione precisa come nota."
"message": "Suggerimento: evidenzia il testo sulla pagina per clipparlo come nota precisa."
},
"clipSelection": {
"message": "Selezione clip"
"message": "Clippa selezione"
},
"clipPage": {
"message": "Ritaglia questa pagina"
"message": "Clippa questa pagina"
},
"saveLinkOnly": {
"message": "Salva solo il collegamento"
},
"pageNotAccessible": {
"message": "Impossibile ritagliare qui: questa pagina blocca l'accesso all'estensione."
"message": "Impossibile clippare qui: questa pagina blocca l'accesso alle estensioni."
},
"errLoginRequired": {
"message": "Accedi prima a Memento in questo browser."
@@ -102,7 +102,7 @@
}
},
"restrictedPage": {
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Memento o il pannello laterale."
"message": "Pagina limitata — clippa tramite la barra degli strumenti Memento o il pannello laterale."
},
"destinationNotebook": {
"message": "Taccuino di destinazione"
@@ -153,7 +153,7 @@
"message": "Visualizza in Memento"
},
"clipAnother": {
"message": "Ritaglia un'altra pagina"
"message": "Clippa un'altra pagina"
},
"failure": {
"message": "Impossibile completare"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Evidenzia il testo sulla pagina o ritaglia l'intera pagina."
},
"errLoginHintUrl": {
"message": "Connesso a: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Chiudi"
},
"summaryLabel": {
"message": "Riepilogo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "モーメントウェブクリッパー"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Web ページとハイライトされたテキストを Memento ノートブックにキャプチャします。独自の Memento サーバーに接続します。"
},
"extActionTitle": {
"message": "モーメントにクリップ"
"message": "Memento にクリップ"
},
"webClipper": {
"message": "ウェブクリッパー"
"message": "Web Clipper"
},
"connected": {
"message": "接続済み"
@@ -36,7 +36,7 @@
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
},
"footerVersion": {
"message": "Memento Web クリッパー <<<バージョン>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
@@ -69,10 +69,10 @@
"message": "ヒント: ページ上のテキストをハイライト表示して、正確な選択範囲をメモとしてクリップします。"
},
"clipSelection": {
"message": "クリップの選択"
"message": "選択をクリップ"
},
"clipPage": {
"message": "このページをクリップします"
"message": "このページをクリップ"
},
"saveLinkOnly": {
"message": "リンクのみを保存"
@@ -153,7 +153,7 @@
"message": "モメントで見る"
},
"clipAnother": {
"message": "別のページをクリップする"
"message": "別のページをクリップ"
},
"failure": {
"message": "完了できませんでした"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
},
"errLoginHintUrl": {
"message": "接続先: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "閉じる"
},
"summaryLabel": {
"message": "要約"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "모멘토 웹 클리퍼"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "웹 페이지와 강조 표시된 텍스트를 Memento 노트북에 캡처하여 자체 Memento 서버에 연결합니다."
},
"extActionTitle": {
"message": "순간에 클립"
"message": "Memento로 클립"
},
"webClipper": {
"message": "웹 클리퍼"
"message": "Web Clipper"
},
"connected": {
"message": "연결됨"
@@ -36,7 +36,7 @@
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
},
"footerVersion": {
"message": "Memento Web Clipper <<<버전>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
@@ -69,7 +69,7 @@
"message": "팁: 페이지의 텍스트를 강조 표시하여 정확한 선택 항목을 메모로 자릅니다."
},
"clipSelection": {
"message": "클립 선택"
"message": "선택 영역 클립"
},
"clipPage": {
"message": "이 페이지 클립"
@@ -153,7 +153,7 @@
"message": "Memento에서 보기"
},
"clipAnother": {
"message": "다른 페이지 자르기"
"message": "다른 페이지 클립"
},
"failure": {
"message": "완료할 수 없습니다."
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
},
"errLoginHintUrl": {
"message": "연결됨: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "닫기"
},
"summaryLabel": {
"message": "요약"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento Webclipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Memento-notebooks - maakt verbinding met uw eigen Memento-server."
},
"extActionTitle": {
"message": "Clip naar Memento"
"message": "Clippen naar Memento"
},
"webClipper": {
"message": "Webclipper"
"message": "Web Clipper"
},
"connected": {
"message": "Aangesloten"
@@ -36,7 +36,7 @@
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIE>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
@@ -69,10 +69,10 @@
"message": "Tip: markeer tekst op de pagina om een precieze selectie als notitie te knippen."
},
"clipSelection": {
"message": "Clipselectie"
"message": "Selectie clippen"
},
"clipPage": {
"message": "Knip deze pagina uit"
"message": "Deze pagina clippen"
},
"saveLinkOnly": {
"message": "Alleen link opslaan"
@@ -153,7 +153,7 @@
"message": "Bekijk in Memento"
},
"clipAnother": {
"message": "Knip nog een pagina uit"
"message": "Nog een pagina clippen"
},
"failure": {
"message": "Kon niet voltooien"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Markeer tekst op de pagina of knip de hele pagina uit."
},
"errLoginHintUrl": {
"message": "Verbonden met: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Sluiten"
},
"summaryLabel": {
"message": "Samenvatting"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Narzędzie do strzyżenia sieci Memento"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Memento — łączy się z Twoim własnym serwerem Memento."
},
"extActionTitle": {
"message": "Klip do Memento"
"message": "Wklej do Memento"
},
"webClipper": {
"message": "Obcinacz sieci"
"message": "Web Clipper"
},
"connected": {
"message": "Połączony"
@@ -36,7 +36,7 @@
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Memento. Pliki cookie w tej przeglądarce obsługują logowanie."
},
"footerVersion": {
"message": "Memento Web Clipper <<<WERSJA>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
@@ -69,10 +69,10 @@
"message": "Wskazówka: zaznacz tekst na stronie, aby wyciąć dokładne zaznaczenie jako notatkę."
},
"clipSelection": {
"message": "Wybór klipu"
"message": "Wklej zaznaczenie"
},
"clipPage": {
"message": "Przytnij tę stronę"
"message": "Wklej tę stronę"
},
"saveLinkOnly": {
"message": "Zapisz tylko link"
@@ -153,7 +153,7 @@
"message": "Zobacz w Memento"
},
"clipAnother": {
"message": "Wytnij kolejną stronę"
"message": "Wklej kolejną stronę"
},
"failure": {
"message": "Nie udało się ukończyć"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Zaznacz tekst na stronie lub przytnij całą stronę."
},
"errLoginHintUrl": {
"message": "Połączono z: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Zamknij"
},
"summaryLabel": {
"message": "Streszczenie"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Memento conecte-se ao seu próprio servidor Memento."
},
"extActionTitle": {
"message": "Clipe para Memento"
"message": "Clipar para o Memento"
},
"webClipper": {
"message": "Clipper da Web"
"message": "Web Clipper"
},
"connected": {
"message": "Conectado"
@@ -36,7 +36,7 @@
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Memento. Os cookies neste navegador controlam o login."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSÃO>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site ou abra o painel lateral."
@@ -69,10 +69,10 @@
"message": "Dica: destaque o texto na página para recortar uma seleção precisa como uma nota."
},
"clipSelection": {
"message": "Seleção de clipe"
"message": "Clipar seleção"
},
"clipPage": {
"message": "Recorte esta página"
"message": "Clipar esta página"
},
"saveLinkOnly": {
"message": "Salvar apenas link"
@@ -153,7 +153,7 @@
"message": "Ver em Memento"
},
"clipAnother": {
"message": "Recortar outra página"
"message": "Clipar outra página"
},
"failure": {
"message": "Não foi possível concluir"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Destaque o texto na página ou recorte a página inteira."
},
"errLoginHintUrl": {
"message": "Conectado a: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Fechar"
},
"summaryLabel": {
"message": "Resumo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Веб-клипер Memento"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Memento — подключайтесь к вашему собственному серверу Memento."
},
"extActionTitle": {
"message": "Клип на Memento"
"message": "Сохранить в Memento"
},
"webClipper": {
"message": "Веб-клипер"
"message": "Web Clipper"
},
"connected": {
"message": "Подключено"
@@ -36,7 +36,7 @@
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Memento. Файлы cookie в этом браузере обрабатывают вход в систему."
},
"footerVersion": {
"message": "Memento Web Clipper <<<ВЕРСИЯ>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
@@ -69,10 +69,10 @@
"message": "Совет: выделите текст на странице, чтобы выделить его в виде заметки."
},
"clipSelection": {
"message": "Выбор клипа"
"message": "Сохранить выделение"
},
"clipPage": {
"message": "Вырезать эту страницу"
"message": "Сохранить эту страницу"
},
"saveLinkOnly": {
"message": "Сохранить только ссылку"
@@ -153,7 +153,7 @@
"message": "Посмотреть в Моменто"
},
"clipAnother": {
"message": "Вырезать другую страницу"
"message": "Сохранить ещё одну страницу"
},
"failure": {
"message": "Не удалось завершить"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Выделите текст на странице или вырежьте всю страницу."
},
"errLoginHintUrl": {
"message": "Подключено к: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Закрыть"
},
"summaryLabel": {
"message": "Сводка"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento 网页剪辑器"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "将网页和突出显示的文本捕获到您的 Memento 笔记本中 — 连接到您自己的 Memento 服务器。"
},
"extActionTitle": {
"message": "剪辑到时刻"
"message": "剪藏到 Memento"
},
"webClipper": {
"message": "网页剪辑器"
"message": "Web Clipper"
},
"connected": {
"message": "已连接"
@@ -36,7 +36,7 @@
"message": "粘贴 Memento 服务器的 HTTPS或 LANURL。此浏览器中的 Cookie 处理登录。"
},
"footerVersion": {
"message": "Memento Web Clipper <<<版本>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
@@ -69,10 +69,10 @@
"message": "提示:突出显示页面上的文本以将精确的选择剪辑为注释。"
},
"clipSelection": {
"message": "剪辑选择"
"message": "剪藏选中文本"
},
"clipPage": {
"message": "剪此页"
"message": "剪此页"
},
"saveLinkOnly": {
"message": "仅保存链接"
@@ -153,7 +153,7 @@
"message": "在 Memento 中查看"
},
"clipAnother": {
"message": "剪另一页"
"message": "剪另一页"
},
"failure": {
"message": "无法完成"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "突出显示页面上的文本,或剪辑整个页面。"
},
"errLoginHintUrl": {
"message": "已连接到:$URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "关闭"
},
"summaryLabel": {
"message": "摘要"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,6 +1,12 @@
/**
* Content script Memento — sélection live, surlignage, communication avec le side panel.
* Content script Momento — sélection live, surlignage, communication avec le side panel.
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si longlet était déjà ouvert.
*
* Architecture v0.4.4 :
* - La sélection courante est écrite dans chrome.storage.session/local comme source de vérité
* (filet de sécurité si le runtime.sendMessage est perdu).
* - Les messages runtime servent de notification rapide au side panel.
* - SET_PICK_MODE répond toujours avec létat pickMode + la sélection courante.
*/
;(function initMementoClipperContent() {
if (globalThis.__mementoClipperContent) return
@@ -9,10 +15,16 @@
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
const BANNER_ID = 'memento-clipper-banner-root'
const STYLE_ID = 'memento-clipper-styles'
const SESSION_KEY = 'memento_clipper_session'
let pickMode = false
let debounceTimer = null
function storageArea() {
// Firefox < 128 na pas storage.session en MV3 ; on fallback sur local.
return chrome.storage.session || chrome.storage.local
}
function getSelectionText() {
return window.getSelection()?.toString().trim() || ''
}
@@ -36,10 +48,25 @@
}
}
async function writeSession(extra = {}) {
try {
await storageArea().set({
[SESSION_KEY]: {
...getPageMeta(),
...extra,
updatedAt: Date.now(),
},
})
} catch (err) {
console.debug('[memento] storage session write failed', err)
}
}
function broadcastSelection() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = setTimeout(async () => {
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
writeSession({ source: 'selection' })
try {
chrome.runtime.sendMessage(payload).catch(() => {})
} catch {
@@ -189,7 +216,7 @@
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'PING') {
sendResponse({ ok: true })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
if (message?.type === 'GET_CONTEXT') {
@@ -201,11 +228,13 @@
}
if (message?.type === 'SET_PICK_MODE') {
setPickMode(!!message.enabled)
sendResponse({ ok: true, pickMode })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
return false
})
// Amorcer le storage dès l'injection (utile si une sélection existait avant l'ouverture du side panel).
writeSession({ source: 'init' })
broadcastSelection()
})()

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "مومنتو ويب كليبر"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Memento الخاصة بك - ويتصل بخادم Memento الخاص بك."
},
"extActionTitle": {
"message": "مقطع إلى مومنتو"
"message": "قص إلى Memento"
},
"webClipper": {
"message": "مقص الويب"
"message": "Web Clipper"
},
"connected": {
"message": "متصل"
@@ -36,7 +36,7 @@
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
},
"footerVersion": {
"message": "Memento Web Clipper <<<الإصدار>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
@@ -69,7 +69,7 @@
"message": "نصيحة: قم بتمييز النص الموجود على الصفحة لقص التحديد الدقيق كملاحظة."
},
"clipSelection": {
"message": "اختيار المقطع"
"message": "قص التحديد"
},
"clipPage": {
"message": "قص هذه الصفحة"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
},
"errLoginHintUrl": {
"message": "متصل بـ: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "إغلاق"
},
"summaryLabel": {
"message": "ملخص"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,12 +1,12 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Memento-Notizbüchern stellt eine Verbindung zu Ihrem eigenen Memento-Server her."
},
"extActionTitle": {
"message": "Clip auf Memento"
"message": "Zu Memento clippen"
},
"webClipper": {
"message": "Web Clipper"
@@ -36,7 +36,7 @@
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen oder öffnen Sie den Seitenbereich."
@@ -66,19 +66,19 @@
"message": "ignorieren"
},
"selectionHint": {
"message": "Tipp: Markieren Sie Text auf der Seite, um eine präzise Auswahl als Notiz auszuschneiden."
"message": "Tipp: Markiere Text auf der Seite, um eine präzise Auswahl als Notiz zu clippen."
},
"clipSelection": {
"message": "Clip-Auswahl"
"message": "Auswahl clippen"
},
"clipPage": {
"message": "Clip diese Seite aus"
"message": "Diese Seite clippen"
},
"saveLinkOnly": {
"message": "Nur Link speichern"
},
"pageNotAccessible": {
"message": "Hier kann kein Clip erstellt werden diese Seite blockiert den Zugriff auf die Erweiterung."
"message": "Hier kann nicht geclippt werden diese Seite blockiert den Zugriff durch Erweiterungen."
},
"errLoginRequired": {
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Memento an."
@@ -102,7 +102,7 @@
}
},
"restrictedPage": {
"message": "Eingeschränkte Seite Ausschneiden über die Memento-Symbolleiste oder den Seitenbereich."
"message": "Eingeschränkte Seite — clippe über die Memento-Symbolleiste oder den Seitenbereich."
},
"destinationNotebook": {
"message": "Zielnotizbuch"
@@ -153,7 +153,7 @@
"message": "In Memento ansehen"
},
"clipAnother": {
"message": "Schneiden Sie eine weitere Seite aus"
"message": "Eine weitere Seite clippen"
},
"failure": {
"message": "Konnte nicht abgeschlossen werden"
@@ -165,7 +165,7 @@
"message": "Wiederholen"
},
"errNoSelection": {
"message": "Wählen Sie zuerst den Text aus oder schneiden Sie die gesamte Seite aus."
"message": "Wähle zuerst Text aus, oder clippe die ganze Seite."
},
"errAnalyzeFailed": {
"message": "Diese Seite konnte nicht analysiert werden."
@@ -177,6 +177,29 @@
"message": "Netzwerkproblem überprüfen Sie Ihre Verbindung und Memento-URL."
},
"bannerPickText": {
"message": "Markieren Sie Text auf der Seite oder schneiden Sie die gesamte Seite aus."
"message": "Markiere Text auf der Seite, oder clippe die ganze Seite."
},
"errLoginHintUrl": {
"message": "Verbunden mit: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Schließen"
},
"summaryLabel": {
"message": "Zusammenfassung"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -36,7 +36,7 @@
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Highlight text on the page, or clip the whole page."
},
"errLoginHintUrl": {
"message": "Connected to: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Dismiss"
},
"summaryLabel": {
"message": "Summary"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Cortadora web Memento"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Capture páginas web y texto resaltado en sus cuadernos Memento: se conecta a su propio servidor Memento."
},
"extActionTitle": {
"message": "Clip al momento"
"message": "Clipar a Memento"
},
"webClipper": {
"message": "Cortadora web"
"message": "Web Clipper"
},
"connected": {
"message": "Conectado"
@@ -36,7 +36,7 @@
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIÓN>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
@@ -69,10 +69,10 @@
"message": "Consejo: resalte el texto en la página para recortar una selección precisa como nota."
},
"clipSelection": {
"message": "Selección de clips"
"message": "Clipar selección"
},
"clipPage": {
"message": "Recortar esta página"
"message": "Clipar esta página"
},
"saveLinkOnly": {
"message": "Guardar enlace solamente"
@@ -153,7 +153,7 @@
"message": "Ver en momento"
},
"clipAnother": {
"message": "Recortar otra página"
"message": "Clipar otra página"
},
"failure": {
"message": "No se pudo completar"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Resalte el texto de la página o recorte toda la página."
},
"errLoginHintUrl": {
"message": "Conectado a: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Cerrar"
},
"summaryLabel": {
"message": "Resumen"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,12 +1,12 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Memento خود ضبط کنید — به سرور Memento خودتان متصل می شود."
},
"extActionTitle": {
"message": "کلیپ به لحظه"
"message": "کلیپ در Memento"
},
"webClipper": {
"message": "Web Clipper"
@@ -36,7 +36,7 @@
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکی‌های این مرورگر ورود به سیستم را کنترل می‌کنند."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
@@ -69,10 +69,10 @@
"message": "نکته: متن را در صفحه برجسته کنید تا یک انتخاب دقیق به عنوان یادداشت بریده شود."
},
"clipSelection": {
"message": "انتخاب کلیپ"
"message": "کلیپ انتخاب"
},
"clipPage": {
"message": "این صفحه را کلیپ کنید"
"message": "کلیپ این صفحه"
},
"saveLinkOnly": {
"message": "فقط لینک را ذخیره کنید"
@@ -153,7 +153,7 @@
"message": "مشاهده در Memento"
},
"clipAnother": {
"message": "یک صفحه دیگر را کلیپ کنید"
"message": لیپ صفحه‌ای دیگر"
},
"failure": {
"message": "تکمیل نشد"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
},
"errLoginHintUrl": {
"message": "متصل به: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "بستن"
},
"summaryLabel": {
"message": "خلاصه"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -36,13 +36,13 @@
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
},
"footerVersion": {
"message": "Memento Web Clipper 0.3.1"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento 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"
"message": "Carnet sans nom"
},
"noNotebooks": {
"message": "Pas encore de carnets"
@@ -63,10 +63,10 @@
"message": "Sélection détectée"
},
"ignore": {
"message": "ignorer"
"message": "Ignorer"
},
"selectionHint": {
"message": "Astuce : surlignez du texte à lécran pour clipper une sélection précise de la page en tant que note."
"message": "Astuce : surlignez du texte sur la page pour clipper une sélection précise en note."
},
"clipSelection": {
"message": "Clipper la sélection"
@@ -84,7 +84,7 @@
"message": "Veuillez d'abord vous connecter à Memento dans ce navigateur."
},
"errLoadNotebooks": {
"message": "Impossible de charger les carnets. Essayez de vous reconnecter."
"message": "Impossible de charger les blocs-notes. Essayez de vous reconnecter."
},
"notebooksLoaded": {
"message": "Carnets chargés"
@@ -102,7 +102,7 @@
}
},
"restrictedPage": {
"message": "Page restreinte : clip via la barre d'outils Memento ou le panneau latéral."
"message": "Page restreinte — utilisez le bouton Memento ou le panneau latéral pour clipper."
},
"destinationNotebook": {
"message": "Carnet de destination"
@@ -129,7 +129,7 @@
"message": "Analyse de la source"
},
"statusAnalyzing": {
"message": "Analyse…"
"message": "Analyse en cours…"
},
"statusSaving": {
"message": "Enregistrement…"
@@ -177,6 +177,29 @@
"message": "Problème de réseau : vérifiez votre connexion et l'URL Memento."
},
"bannerPickText": {
"message": "Surlignez le texte à clipper"
"message": "Surlignez le texte sur la page, ou clippez la page entière."
},
"errLoginHintUrl": {
"message": "Connecté à : $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Ignorer"
},
"summaryLabel": {
"message": "Résumé"
},
"contentScriptMissing": {
"message": "Content script non détecté — rechargez la page pour clipper."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copier"
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "मोमेंटो वेब क्लिपर"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "अपने मोमेंटो नोटबुक में वेब पेज और हाइलाइट किए गए टेक्स्ट को कैप्चर करें - यह आपके अपने मोमेंटो सर्वर से जुड़ता है।"
},
"extActionTitle": {
"message": "मोमेंटो पर क्लिप करें"
"message": "Memento में क्लिप करें"
},
"webClipper": {
"message": "वेब क्लिपर"
"message": "Web Clipper"
},
"connected": {
"message": "जुड़े हुए"
@@ -36,7 +36,7 @@
"message": "अपने मोमेंटो सर्वर का HTTPS (या LAN) URL चिपकाएँ। इस ब्राउज़र में कुकीज़ साइन-इन को संभालती हैं।"
},
"footerVersion": {
"message": "मोमेंटो वेब क्लिपर <<<संस्करण>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "मोमेंटो इस टैब तक नहीं पहुंच सकता. कीबोर्ड/साइट एक्सटेंशन अनुमतियां जांचें - या साइड पैनल खोलें।"
@@ -69,10 +69,10 @@
"message": "युक्ति: किसी सटीक चयन को नोट के रूप में क्लिप करने के लिए पृष्ठ पर टेक्स्ट को हाइलाइट करें।"
},
"clipSelection": {
"message": "क्लिप चयन"
"message": "चयन क्लिप करें"
},
"clipPage": {
"message": "इस पृष्ठ को क्लिप करें"
"message": "इस पेज को क्लिप करें"
},
"saveLinkOnly": {
"message": "केवल लिंक सहेजें"
@@ -153,7 +153,7 @@
"message": "मोमेंटो में देखें"
},
"clipAnother": {
"message": "दूसरे पेज को क्लिप करें"
"message": "एक और पेज क्लिप करें"
},
"failure": {
"message": "पूरा नहीं हो सका"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "पृष्ठ पर टेक्स्ट को हाइलाइट करें, या पूरे पृष्ठ को क्लिप करें।"
},
"errLoginHintUrl": {
"message": "से जुड़ा: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "बंद करें"
},
"summaryLabel": {
"message": "सारांश"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Memento: si connette al tuo server Memento."
},
"extActionTitle": {
"message": "Clip su Memento"
"message": "Clippa in Memento"
},
"webClipper": {
"message": "Tagliatore di fotoricettore"
"message": "Web Clipper"
},
"connected": {
"message": "Collegato"
@@ -36,7 +36,7 @@
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIONE>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
@@ -66,19 +66,19 @@
"message": "ignorare"
},
"selectionHint": {
"message": "Suggerimento: evidenzia il testo sulla pagina per ritagliare una selezione precisa come nota."
"message": "Suggerimento: evidenzia il testo sulla pagina per clipparlo come nota precisa."
},
"clipSelection": {
"message": "Selezione clip"
"message": "Clippa selezione"
},
"clipPage": {
"message": "Ritaglia questa pagina"
"message": "Clippa questa pagina"
},
"saveLinkOnly": {
"message": "Salva solo il collegamento"
},
"pageNotAccessible": {
"message": "Impossibile ritagliare qui: questa pagina blocca l'accesso all'estensione."
"message": "Impossibile clippare qui: questa pagina blocca l'accesso alle estensioni."
},
"errLoginRequired": {
"message": "Accedi prima a Memento in questo browser."
@@ -102,7 +102,7 @@
}
},
"restrictedPage": {
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Memento o il pannello laterale."
"message": "Pagina limitata — clippa tramite la barra degli strumenti Memento o il pannello laterale."
},
"destinationNotebook": {
"message": "Taccuino di destinazione"
@@ -153,7 +153,7 @@
"message": "Visualizza in Memento"
},
"clipAnother": {
"message": "Ritaglia un'altra pagina"
"message": "Clippa un'altra pagina"
},
"failure": {
"message": "Impossibile completare"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Evidenzia il testo sulla pagina o ritaglia l'intera pagina."
},
"errLoginHintUrl": {
"message": "Connesso a: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Chiudi"
},
"summaryLabel": {
"message": "Riepilogo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "モーメントウェブクリッパー"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Web ページとハイライトされたテキストを Memento ノートブックにキャプチャします。独自の Memento サーバーに接続します。"
},
"extActionTitle": {
"message": "モーメントにクリップ"
"message": "Memento にクリップ"
},
"webClipper": {
"message": "ウェブクリッパー"
"message": "Web Clipper"
},
"connected": {
"message": "接続済み"
@@ -36,7 +36,7 @@
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
},
"footerVersion": {
"message": "Memento Web クリッパー <<<バージョン>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
@@ -69,10 +69,10 @@
"message": "ヒント: ページ上のテキストをハイライト表示して、正確な選択範囲をメモとしてクリップします。"
},
"clipSelection": {
"message": "クリップの選択"
"message": "選択をクリップ"
},
"clipPage": {
"message": "このページをクリップします"
"message": "このページをクリップ"
},
"saveLinkOnly": {
"message": "リンクのみを保存"
@@ -153,7 +153,7 @@
"message": "モメントで見る"
},
"clipAnother": {
"message": "別のページをクリップする"
"message": "別のページをクリップ"
},
"failure": {
"message": "完了できませんでした"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
},
"errLoginHintUrl": {
"message": "接続先: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "閉じる"
},
"summaryLabel": {
"message": "要約"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "모멘토 웹 클리퍼"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "웹 페이지와 강조 표시된 텍스트를 Memento 노트북에 캡처하여 자체 Memento 서버에 연결합니다."
},
"extActionTitle": {
"message": "순간에 클립"
"message": "Memento로 클립"
},
"webClipper": {
"message": "웹 클리퍼"
"message": "Web Clipper"
},
"connected": {
"message": "연결됨"
@@ -36,7 +36,7 @@
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
},
"footerVersion": {
"message": "Memento Web Clipper <<<버전>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
@@ -69,7 +69,7 @@
"message": "팁: 페이지의 텍스트를 강조 표시하여 정확한 선택 항목을 메모로 자릅니다."
},
"clipSelection": {
"message": "클립 선택"
"message": "선택 영역 클립"
},
"clipPage": {
"message": "이 페이지 클립"
@@ -153,7 +153,7 @@
"message": "Memento에서 보기"
},
"clipAnother": {
"message": "다른 페이지 자르기"
"message": "다른 페이지 클립"
},
"failure": {
"message": "완료할 수 없습니다."
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
},
"errLoginHintUrl": {
"message": "연결됨: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "닫기"
},
"summaryLabel": {
"message": "요약"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento Webclipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Memento-notebooks - maakt verbinding met uw eigen Memento-server."
},
"extActionTitle": {
"message": "Clip naar Memento"
"message": "Clippen naar Memento"
},
"webClipper": {
"message": "Webclipper"
"message": "Web Clipper"
},
"connected": {
"message": "Aangesloten"
@@ -36,7 +36,7 @@
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSIE>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
@@ -69,10 +69,10 @@
"message": "Tip: markeer tekst op de pagina om een precieze selectie als notitie te knippen."
},
"clipSelection": {
"message": "Clipselectie"
"message": "Selectie clippen"
},
"clipPage": {
"message": "Knip deze pagina uit"
"message": "Deze pagina clippen"
},
"saveLinkOnly": {
"message": "Alleen link opslaan"
@@ -153,7 +153,7 @@
"message": "Bekijk in Memento"
},
"clipAnother": {
"message": "Knip nog een pagina uit"
"message": "Nog een pagina clippen"
},
"failure": {
"message": "Kon niet voltooien"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Markeer tekst op de pagina of knip de hele pagina uit."
},
"errLoginHintUrl": {
"message": "Verbonden met: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Sluiten"
},
"summaryLabel": {
"message": "Samenvatting"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Narzędzie do strzyżenia sieci Memento"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Przechwytuj strony internetowe i zaznaczony tekst do swoich notatników Memento — łączy się z Twoim własnym serwerem Memento."
},
"extActionTitle": {
"message": "Klip do Memento"
"message": "Wklej do Memento"
},
"webClipper": {
"message": "Obcinacz sieci"
"message": "Web Clipper"
},
"connected": {
"message": "Połączony"
@@ -36,7 +36,7 @@
"message": "Wklej adres URL HTTPS (lub LAN) swojego serwera Memento. Pliki cookie w tej przeglądarce obsługują logowanie."
},
"footerVersion": {
"message": "Memento Web Clipper <<<WERSJA>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento nie ma dostępu do tej karty. Sprawdź uprawnienia rozszerzenia klawiatury/witryny — lub otwórz Panel boczny."
@@ -69,10 +69,10 @@
"message": "Wskazówka: zaznacz tekst na stronie, aby wyciąć dokładne zaznaczenie jako notatkę."
},
"clipSelection": {
"message": "Wybór klipu"
"message": "Wklej zaznaczenie"
},
"clipPage": {
"message": "Przytnij tę stronę"
"message": "Wklej tę stronę"
},
"saveLinkOnly": {
"message": "Zapisz tylko link"
@@ -153,7 +153,7 @@
"message": "Zobacz w Memento"
},
"clipAnother": {
"message": "Wytnij kolejną stronę"
"message": "Wklej kolejną stronę"
},
"failure": {
"message": "Nie udało się ukończyć"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Zaznacz tekst na stronie lub przytnij całą stronę."
},
"errLoginHintUrl": {
"message": "Połączono z: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Zamknij"
},
"summaryLabel": {
"message": "Streszczenie"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento Web Clipper"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Capture páginas da web e texto destacado em seus blocos de anotações Memento conecte-se ao seu próprio servidor Memento."
},
"extActionTitle": {
"message": "Clipe para Memento"
"message": "Clipar para o Memento"
},
"webClipper": {
"message": "Clipper da Web"
"message": "Web Clipper"
},
"connected": {
"message": "Conectado"
@@ -36,7 +36,7 @@
"message": "Cole o URL HTTPS (ou LAN) do seu servidor Memento. Os cookies neste navegador controlam o login."
},
"footerVersion": {
"message": "Memento Web Clipper <<<VERSÃO>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento não pode acessar esta guia. Verifique as permissões de extensão de teclado/site ou abra o painel lateral."
@@ -69,10 +69,10 @@
"message": "Dica: destaque o texto na página para recortar uma seleção precisa como uma nota."
},
"clipSelection": {
"message": "Seleção de clipe"
"message": "Clipar seleção"
},
"clipPage": {
"message": "Recorte esta página"
"message": "Clipar esta página"
},
"saveLinkOnly": {
"message": "Salvar apenas link"
@@ -153,7 +153,7 @@
"message": "Ver em Memento"
},
"clipAnother": {
"message": "Recortar outra página"
"message": "Clipar outra página"
},
"failure": {
"message": "Não foi possível concluir"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Destaque o texto na página ou recorte a página inteira."
},
"errLoginHintUrl": {
"message": "Conectado a: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Fechar"
},
"summaryLabel": {
"message": "Resumo"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Веб-клипер Memento"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "Сохраняйте веб-страницы и выделенный текст в свои блокноты Memento — подключайтесь к вашему собственному серверу Memento."
},
"extActionTitle": {
"message": "Клип на Memento"
"message": "Сохранить в Memento"
},
"webClipper": {
"message": "Веб-клипер"
"message": "Web Clipper"
},
"connected": {
"message": "Подключено"
@@ -36,7 +36,7 @@
"message": "Вставьте URL-адрес HTTPS (или LAN) вашего сервера Memento. Файлы cookie в этом браузере обрабатывают вход в систему."
},
"footerVersion": {
"message": "Memento Web Clipper <<<ВЕРСИЯ>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento не имеет доступа к этой вкладке. Проверьте разрешения для расширения клавиатуры/сайта или откройте боковую панель."
@@ -69,10 +69,10 @@
"message": "Совет: выделите текст на странице, чтобы выделить его в виде заметки."
},
"clipSelection": {
"message": "Выбор клипа"
"message": "Сохранить выделение"
},
"clipPage": {
"message": "Вырезать эту страницу"
"message": "Сохранить эту страницу"
},
"saveLinkOnly": {
"message": "Сохранить только ссылку"
@@ -153,7 +153,7 @@
"message": "Посмотреть в Моменто"
},
"clipAnother": {
"message": "Вырезать другую страницу"
"message": "Сохранить ещё одну страницу"
},
"failure": {
"message": "Не удалось завершить"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "Выделите текст на странице или вырежьте всю страницу."
},
"errLoginHintUrl": {
"message": "Подключено к: $URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "Закрыть"
},
"summaryLabel": {
"message": "Сводка"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"extName": {
"message": "Memento 网页剪辑器"
"message": "Memento · Web Clipper"
},
"extDescription": {
"message": "将网页和突出显示的文本捕获到您的 Memento 笔记本中 — 连接到您自己的 Memento 服务器。"
},
"extActionTitle": {
"message": "剪辑到时刻"
"message": "剪藏到 Memento"
},
"webClipper": {
"message": "网页剪辑器"
"message": "Web Clipper"
},
"connected": {
"message": "已连接"
@@ -36,7 +36,7 @@
"message": "粘贴 Memento 服务器的 HTTPS或 LANURL。此浏览器中的 Cookie 处理登录。"
},
"footerVersion": {
"message": "Memento Web Clipper <<<版本>>>"
"message": "Memento Web Clipper 0.4.6"
},
"errPermissionDenied": {
"message": "Memento 无法访问此选项卡。检查键盘/站点扩展权限 - 或打开侧面板。"
@@ -69,10 +69,10 @@
"message": "提示:突出显示页面上的文本以将精确的选择剪辑为注释。"
},
"clipSelection": {
"message": "剪辑选择"
"message": "剪藏选中文本"
},
"clipPage": {
"message": "剪此页"
"message": "剪此页"
},
"saveLinkOnly": {
"message": "仅保存链接"
@@ -153,7 +153,7 @@
"message": "在 Memento 中查看"
},
"clipAnother": {
"message": "剪另一页"
"message": "剪另一页"
},
"failure": {
"message": "无法完成"
@@ -178,5 +178,28 @@
},
"bannerPickText": {
"message": "突出显示页面上的文本,或剪辑整个页面。"
},
"errLoginHintUrl": {
"message": "已连接到:$URL$",
"placeholders": {
"URL": {
"content": "$1"
}
}
},
"bannerDismiss": {
"message": "关闭"
},
"summaryLabel": {
"message": "摘要"
},
"contentScriptMissing": {
"message": "Content script not detected — reload the page to clip."
},
"diagnosticsTitle": {
"message": "Diagnostic"
},
"copyDiagnostics": {
"message": "Copy"
}
}
}

View File

@@ -1,6 +1,12 @@
/**
* Content script Memento — sélection live, surlignage, communication avec le side panel.
* Content script Momento — sélection live, surlignage, communication avec le side panel.
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si longlet était déjà ouvert.
*
* Architecture v0.4.4 :
* - La sélection courante est écrite dans chrome.storage.session/local comme source de vérité
* (filet de sécurité si le runtime.sendMessage est perdu).
* - Les messages runtime servent de notification rapide au side panel.
* - SET_PICK_MODE répond toujours avec létat pickMode + la sélection courante.
*/
;(function initMementoClipperContent() {
if (globalThis.__mementoClipperContent) return
@@ -9,10 +15,16 @@
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
const BANNER_ID = 'memento-clipper-banner-root'
const STYLE_ID = 'memento-clipper-styles'
const SESSION_KEY = 'memento_clipper_session'
let pickMode = false
let debounceTimer = null
function storageArea() {
// Firefox < 128 na pas storage.session en MV3 ; on fallback sur local.
return chrome.storage.session || chrome.storage.local
}
function getSelectionText() {
return window.getSelection()?.toString().trim() || ''
}
@@ -36,10 +48,25 @@
}
}
async function writeSession(extra = {}) {
try {
await storageArea().set({
[SESSION_KEY]: {
...getPageMeta(),
...extra,
updatedAt: Date.now(),
},
})
} catch (err) {
console.debug('[memento] storage session write failed', err)
}
}
function broadcastSelection() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = setTimeout(async () => {
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
writeSession({ source: 'selection' })
try {
chrome.runtime.sendMessage(payload).catch(() => {})
} catch {
@@ -189,7 +216,7 @@
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'PING') {
sendResponse({ ok: true })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
if (message?.type === 'GET_CONTEXT') {
@@ -201,11 +228,13 @@
}
if (message?.type === 'SET_PICK_MODE') {
setPickMode(!!message.enabled)
sendResponse({ ok: true, pickMode })
sendResponse({ ok: true, pickMode, selection: getPageMeta() })
return true
}
return false
})
// Amorcer le storage dès l'injection (utile si une sélection existait avant l'ouverture du side panel).
writeSession({ source: 'init' })
broadcastSelection()
})()

View File

@@ -1,160 +0,0 @@
#!/usr/bin/env node
/**
* Script de diagnostic pour l'extension Memento
* 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 Memento\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 Memento')
console.log('4. Fermez le sidepanel - la bannière doit disparaître')
console.log('5. Ouvrez la console (F12) - pas d\'erreur CSP')

View File

@@ -44,4 +44,13 @@ function applyShellI18n() {
if (hint) hint.textContent = t('settingsHint')
const footer = document.querySelector('.footer-meta')
if (footer) footer.textContent = t('footerVersion')
if (typeof els !== 'undefined' && els.diagToggle) {
const diagTitle = t('diagnosticsTitle') || 'Diagnostic'
els.diagToggle.title = diagTitle
els.diagToggle.setAttribute('aria-label', diagTitle)
}
const diagTitleEl = document.querySelector('.diag-title')
if (diagTitleEl) diagTitleEl.textContent = t('diagnosticsTitle') || 'Diagnostic'
const copyDiagBtn = document.getElementById('copyDiagBtn')
if (copyDiagBtn) copyDiagBtn.textContent = t('copyDiagnostics') || 'Copier'
}

View File

@@ -1,215 +0,0 @@
#!/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()

Some files were not shown because too many files have changed in this diff Show More