Compare commits
16 Commits
fix/bugs-b
...
f385d43d5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f385d43d5d | ||
|
|
324bf40658 | ||
|
|
86505659cf | ||
|
|
4946c0d44a | ||
|
|
0ee2afbd62 | ||
|
|
bff060ad20 | ||
|
|
1987b084c3 | ||
|
|
07ac42cbee | ||
|
|
1e9b1b30c9 | ||
|
|
d5c44aafb6 | ||
|
|
9d10815415 | ||
|
|
cf3b5483d2 | ||
|
|
6eb46fd1b7 | ||
|
|
a7c15e9fc3 | ||
|
|
88a7d2ad0a | ||
|
|
e8ee53c815 |
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
398
memento-note/app/(public)/privacy/page.tsx
Normal file
398
memento-note/app/(public)/privacy/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal file
62
memento-note/app/api/ai/notebook-slides/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
237
memento-note/components/wizard/notebook-slides-dialog.tsx
Normal file
237
memento-note/components/wizard/notebook-slides-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,30 @@
|
||||
# Audit dashboard Second Brain (`/home`) — 2026-07-16
|
||||
|
||||
## Portée
|
||||
## Demande d’origine
|
||||
|
||||
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 l’architecture. 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 d’anciens 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
|
||||
|
||||
@@ -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 l’extension non empaquetée** → dossier `memento-note/extension`
|
||||
3. Épingle l’icô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`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(或 LAN)URL。此浏览器中的 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 l’onglet é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 n’a 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()
|
||||
})()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(或 LAN)URL。此浏览器中的 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 l’onglet é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 n’a 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()
|
||||
})()
|
||||
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
H4sIAOZLE2oC/+V9W3MUR7buX6lwTIRnIhDEfjgvxI4dgTGzx2c84A3YjuO3UnchCrW6tKu7pZEmfEKtO0gYbCNuFgYZgS4WqCUk1IAQEVv2u3iTPC/E
|
||||
CF1AIuYvnG+tzKxLX6qyNTY8nJgxSN1ZmSvX5VvrW1lV/O09K/3eQeNv71l/zR41myz+ucnKZMwG+vm9vzhNVjrrGJ9b9cbhlN3cbLnvfbnPoOEfWpmE
|
||||
azdnbSddetVhszmbcy2jFVc147OMYaaTxmm74XQK/2WtpJHFBIZNM7c5OddQy6SdrFXvOI0Z4x8dl4yEk05biWzGUMOc1rQ3NGO5LZa7X0lzKEGCnLSz
|
||||
qbI9kNw0hbySr4Bkajslo0t3KoWwkmXTel/QsKSdqTryqJM1EqHRdjqTNdMJ64SVzdrphkw1tX96/OPQ+E/d1MdmvZUqHf9/gkpUg72rm10rY2U/cZ1k
|
||||
LlHJXP43hhhq/M+C0WTxbHVkkv0Jp4lnMpubU23HLbmZ0nkO0bdsatcbQlc5zVZaKb/kkmP4KmSYjNTIn+AcZXKamaxlZE9bxp9OnvzkhPF7xzU+PnT0
|
||||
D7RRwzkVdiXpH8ZhuJMND7TTuNLOGPWu04rvjNOQM2UZGbshXWenhR+dcrBZ9zPLzVTQUoVQMP7WIsZ+KdzQdT+x3CY7Qx99aKXtcldQkyTM9PtZw0wk
|
||||
8JWQK2vWQ9jTVqLRaLTa6h3TTR7I2NgvnNtKZ9g43uQiPrB9Ui1r5ISdtIxPzLSVEltRkfRpOo2wLpPj03SWQiXpRZy86KgKwHIfDgRnmyUM61pmEsY6
|
||||
aTdZx9Jlcfd//81ostM5bIHGlV0Aqd0KHuQ6f91v/O7wsU+PnvwdXS8uJi9OmQnrtJNKQuV8HY/hn+Bs0BH5y3u/+7f3GBDMpmZGgvf+13tffikcK2Wx
|
||||
j39oZStG6Qk1wEiqERx6DWnHLdub/DQ0cSWXPWk3H/SBT8CeIyxGwEiwlCB4MinyEnYGDumJYQI2WenCojTOk7EixnmXeuM/EV9XgENyOZJAbMFssT62
|
||||
043H0qm2MrXgOyOFLyE3vmU8wVdwlEPsvXZ9BcRl7+aNwcgWO6u3olGfchJwIt+vRRjsVzH0sdNgp49b/52z3XIrfZKyzIwIWw5pJwB74QA/ZbuZbGBS
|
||||
M1nVuQ87uRSHgpHCMN/R9xsn3TYfzOC54eDK0KwV8V4GSkp8H8gjmKNKIsE3/+i4G845Jx0gftXEQ7v/HbDvdxXDg9A/LjhOZ7PNmYMHDpSD/ZciXDNZ
|
||||
16alKjnSce9bYVdO2WTzFttkD1eWyTpOqt50Ca9KYSqJOey0SU6rtFa6zIf+kDBamdBZi1VJskP8je/fiK0W22r9wDqFoCWHLt8KfW/U8wAD4UBmUpbm
|
||||
oqJi2hXlhqhAEpbbnK046oj4zou0k06VZMihVlKo1JuJMo18QJ+xBtJmqq0dsp5A5kuUq0F9bWTE9yxB1szmMt5XVa9RvijGnxAqKRc4MBLQzYCQbgDE
|
||||
mnaZHv7TSluuSX6OZNeQ2Qd0y1hNJjJRwsjkmppMt20fVw9WU72VpDyR8cONdFMx0MhaMsQy0NpJp5of8QwcMkePnTzywbFjf64cN+rb2OA5jsRkpMws
|
||||
FYocLuRDH1WrdD4jB7PDxQ5FyyHsrkImZJQ2xXe+I5+CWnPl2cjHL8QupMuK0Q2kcDtxxHWdsvlPQAzAJWzRCnGMVtfBj0i1Cf6srVI9uV+m8KzbVh4/
|
||||
9KGE2qNO1TQlvhBpkPF5H4FCQmQkyziVS6V4sx5sC3e0/oh9V6rA1bZFIFh+kvEmIKvHXU0eJLbsZ1vah5VtddwyP5IfG6jEchL0uGrjCRTKU1aDJwfq
|
||||
+P0ynPG9+4mdaDwJHZRO/aeqhUJYT63wV0vtk/3C1SFxqOpr43FH0q7VYAPlrXYURRnJ5YjVgSMkcywlAjDnQuj08oSRNFGZtjgZlLdu2gJxUysH2ByG
|
||||
Ld/CIEyKmsdpJrylUt13OF1ORwU41d+axO6kk05aOdQO0IEWs1ue0GB2aX9fWszuUBJJNWOFZI5jd0RvkDaFzjx2F5whnuEtT+D/DSnK080+3ds7z7P/
|
||||
Owf1ww286kh6VBTZy7W4tqtN9w47qRQcL/U+7V8xvpxgfJ46Sn3H+BgempCcD6OgqDSyVANgGqMalsddgruUKcz2VyjhbVO/NCxg5bIIpgxVvssTyDwU
|
||||
EgmLIr4B6L3f+Azmsk/ZtHtqnuSyjmtnuATKUNwlUtiS5R7wS+gklcRZgqNvDejIgab5YqyCbZo5ylOY09Rjh4c5fIGLWA48URKdCHYIZm5Y6QQVT6Rz
|
||||
Ef0aFPEfZ/sNjyXiSsoOOblcHFekawM8ETNkglO8Dca4POFRxuWJLIOAFUsa3XjWeCiTzcFxDypoJfhVaAtPSf2j49ryRMI100YzJx3pkjm4VsaXqRkR
|
||||
z5SStGKKGp26Bai4DERvLdSSZscU/uSxDJOugEdnLT2W6Wcal7w2ZUPQXJpAhoJGj3V+1NTsiG9ox7BIwsmRGHbC/p9bBwPSEAMlBaTep/gbpwD7q09H
|
||||
NYnoZ1bOZnxKvm/WO24SeJTLeG0+jmgV8ZwWQ0CkR0vDG0qcNt0GVk+GKXSGIRsc9QguaOMMLWQIILIWXT0skzUvsDyR0aGrBJw1sVWR9t8hW6XPDB5i
|
||||
IeIs9ghFVuHa4KcEXu87uayd8gsXYGlVENXgrhJIyR39wTHklQUVX+uRV5UsXMNsodhOvm/50aTHYl0dFpt1TVuDxQZDmT0/js5+6GS02ayHZjUyWpQJ
|
||||
OoR2+Sz8z2myLW1OuzyRJpcQFT/ydBN+JGyhclmwXKAw2C38H+jJbJe+hlMkzFQil+KB2oT3OPiyy9crJauME819j/jDhU3eEQd27Ao+EcOCVWaDdl0r
|
||||
lgqHMTNLNVlaRkEUHf4UCwCOqUZEoFLebbHSlCIcNyM9DkDThJSUkLZusRJVKvIongxzMVq7OmTZy7ZcACDpi0STsmQpAHDiFNceSO9Zm0pcPf4c1NX7
|
||||
pgyTQJ7UINGhKQKwI1Wjw6fBWupTy+NNbDAKFiAtoXOLVwOLubyCnYJHkIKQwqOp9V9oV+1U/8C29S5SN5vM0yU+9bQYqVeB/L/+mekR95QJYpg2TthM
|
||||
UTMW6nkqgpLUQ29xkPtPO/Vw4bRxUhyeGh+ddv3TszrEvt1ev7yYwPA0E4FM1kqlsgbynWWA2dTb6WQu3WC05/jKJnzRwPOpGU7wkRktp32sauZO/drn
|
||||
qiQpdm2l49m3nTidpRZAYLzmyWqd7skqacpT0Ec8uL2upoPVRqZtMKBFa8EiZIM9Eu90q0U7Za+AlYlGtgjDyu1HsO/ly6dOpaV/6VLwPy4vNshLkrY8
|
||||
dq0DCyfKCh5e9weKQlJRpsSJMuR1ocNXXJ+Bz30gT2cwxnTrhY/T1IfSTVaK3PNt0/FGwIaRZk8iZ2YxjeOMY5bbaLrAhvZcA4x3ysKGlofrUSG5y4un
|
||||
Amo5aXJN4dYdOIH91B1xW2lfLjaTwWgLU9sN+MUSUcnKW37oG4MMeoI1QaMhiRZHF+GeS5w2nNOIb4QonDf2EBfDGxkPgmihd5b7F8HSG5CuMtLb4ij6
|
||||
YTN0lLvfu/gtkPNDuUyreTplkBHTkkJG0HKUz3JTcee5zQeNv5huI1/A9mM4Zt+BYdmQ+4xck4BduMpoO3FwJY4JcsG6xxWZ9lwmAePZ8ABNIl4n54k/
|
||||
4RWOzOLQUrEM/CgSIH1nZJrJB4Eueqz7T0Q8OIjIsTgeoXOZelotNyndPigPnwDjuiz7/hcIL/vUKRV+RiB+9Kj4BzZVLIQf0iIZbABRm14eTZzOZCug
|
||||
D4DHo3hmWo+N+6e6jUBnwi2BGvBpkxYWe91PmTaDsBSi7OMdBZJvK6DIcqFe2J50pCwff6TshataMYanf6YSgx5P99IuIjX7Lon6EewFZdJpRE66Matc
|
||||
hlwIvu/Fi7G8WE8E009mdSfamuqpZUzILVC2CrJqEPcvbCuVVggbd9rcyKfNvJAmY3dItibjhAo1sRuZV/Qou0T7KMp+iBCmIZ6xfxS4qy4U/JXY+hc5
|
||||
SKl3/vxfOXJwcTiXCQCsDl/n4Vpn0M1mYKTWGbSA7hYwiZPM0AmpXXUWneHQ/SKXMZugEqrJKWyp4IJj1qNW4kwefzANhIcbS3VmNTj6f/qjsRy1EN8N
|
||||
SQ94A5i6ddpKx7H0E15QEvhy3hMIXpp/qnD2PzOeqiKsnqM/5TAZEqAaS98/sOwm5A2OcUsQI7e0ajeACySc8UfrdIqaZblTDeDoKB/SEYz9c4HXTkpK
|
||||
EU3ZP18exdxCD+05SoOMQbJCyAhUyoTURQiGHZtN2YC29Pj7h4GU2hjSoQohlYF1mDwRHeG3obkCTlw6WfUD8naMbGwmYg+UI+gO4htvm5cL5MWcf1pe
|
||||
p3laHleF1aJt3lEmitUfdtysmXRck8+/gwRK717o5uURlDCmOD5v466DQ/1oM0XTisZExkjkkNndtOPxqYPAJWp9wN9MumEmx4fmtri/1YY8tZ6aowI1
|
||||
mrQYe2jHsSfmJGDS0TgxV7tJOlqEXRx+h2WOI+0fm4Y8M8/mVHvONun32g7NLfMMVWl0oTg0T9jLD/Z+ap6yE6ZryBsKSQWxJ+YeENdTMGvcIW015LhZ
|
||||
GTwy907M4TxlXmN8bPrn5XBCi/abBko0kOnJVlnXSZn4IgVN2gl4Hs1kZUgTe+Tq//7v//7ZkeMnPlr+9uh//Md/1Hx+7hjNOYAy3zZKEW6S0KbRTH8u
|
||||
z5hcgdunuM2OLCLvmna4jSsP+ciI5FMZG3Y/AP6YohAkNcPZ8TnfoCgypu6JuYxbTJk2ssv3srmUE8fGDy0/pjsajdNmmx/3NdxX7fjH3abm3dV8czXU
|
||||
f/jY0ZOHjkP56nqjafle2m4yjd9XuwX7D2/tHuyEtE+WUcKMY+6mxoE68CljnQGYCsC1yMgCgeHy1OQV4GygljQ5PIF8dOpginuqhUTiDm0+COCb4U0N
|
||||
zh7aEB84ZiKp+3G1tnBpIVYsd//PnOkm6aI02cfIIGApViw9Bn+UkomMKW/v5n/nlu8dDIkhj805QviWbQQMKc8PKs1buF04qOsIOPGwxPAb0eU4pMvQ
|
||||
xUaQ5F26Mct0G7ATwgAvvPYbH7Gb0lF5qoXBQ2FxxtI7LfcyNM+fdDTOy2n+dNLRPjAX6RGyvcvzcml0MSrdYCfNg9I9qNeStE3Sojw559wKxHHNJhtL
|
||||
mZlArgWuWnRXZxmk6pyfK1j1TtCduNNzKTaPMPXv/s6Qz6flPUQNIp50iHgA6mNPzxMqg0eRcT+WQyVPJSp+KOsuj2ifndvt5IPGqZyHDBpEXFykw8MP
|
||||
nXbAzGq7F9wDRotPxeE3+8jfck1UDVtNyyN0bzjcB1CRcFGJ4QIEh8Zd4aY0ocweMcRbDGXYeUfn4laZvSPotpcjHPhUKEdU4dceLjrq/nAz/kT8UKoB
|
||||
l8EDlh8YTSY1ro1UyiJANatV1ZE3iLfonXmLfOmkAym6WeYLx4OfQMZWG9JjzAFNmMK7w5lWgycHppAwQerwy4GYg26riTWGtHiQzsBknZrJcRr6K8dD
|
||||
myrgSzUbTYePl5Y2yZCifO1lnaQZ+EayXzv7WzwHnOXiEiWIaArVG3TenqEU30KcvN02+Y5YGzp08AfKihzKggD5tcW5PzX44YEYJR/srJn2QsWa94mb
|
||||
DSkSi24Hs41TDt17S/dEqOf9IkgwB0jW0b9tPJP5DUnw+/Kq1Ps2N8BM2k+NBNim+zuyZjtHJC5nGix++xfuHUflBD81oFdpW1BHbSrcrMWEP0onYA6z
|
||||
5O5xRYUrepLxkSTCdGCEqCQ3VQ8TNlDxQQZ1xF2jZLl/kf0eO3pkT+wXRCS3PKvYL8JCyGpSp8tKmnTozpydNm/Je8fb29mENpFhRBn2gux7gGpsvuXV
|
||||
UmwYINvcTN0qE6Br2Clx52Mq5ajKzdJiwydFHJOGye2ymDieDh+1qNWuMIAAGnBl1saHbWw5q82HE+Hjab7WeVs0VwSRixTTYmY1SK7Gw8Yncg0NFiVL
|
||||
hk4PYcmOAnQzuVRKorHJruDaWUY7WFyRXSFXgOtatXBdcTUNi6a5cl3lukIkjWeRUy106yf8EXtKCLwN4JnuHeJQenDroKkHw5LwCXXC9GMdySf1vh8v
|
||||
ekSXBGHMpMaKGXxQOYwutd0NTsIDQAWI+jlzvwEgb6F1ArCatbU47UmVd8W8WVvnFnAGK6uGm8BZje/4FnCybcpugu2zRGeVG2aJuIbpLD43MBlxkawd
|
||||
4LLVQVGDz3rACLeQwzli4m8JZ06brYHTYirP9/BDhkLHe5An5mzZ0WG0UI6Z1WG0MmpzsTeCf5RO2iAPjjajzdhc35io0mritExq9+/fH0tqj9uZZhOJ
|
||||
uUZe65VLcC8wWttqtlMO0Sn5uLOktI7bDGRnB9OhtGzCrKn1qDONZKR5Vw87Z3JC0TU88uwlhVz6fTAZN5QWYm73ZlBU3Db+6ef/gnQJB/lteZye0yVd
|
||||
AY9d5iIpwzUbGmw65ncZEbI501AVtAbTtZsJhzW5LnmKKaPUS9OOD0yo3elWAKWK2u7rtuXNF2QHTnOBDFfDvd00jYQPUkdtVNc26IT9oDpISpnes5ci
|
||||
fxhW7fdyH4mpbCL1x1Gc/e3eeeWd8yZNj/AKNk79K24zNQEIcuIhLnEsRc/5m8tjy/NW8JHpb2Vr2qrLYAihBx3+Lj+At+z9+NcSZxx6VFhRFmzl81/x
|
||||
CHj59p4OgZ2a6C9fQmnH5F7V8g+iWeXUyoCT1ilURjDObcc7Bxa//WvHwNaejoG1uO9hejuAY1R6bFp4Ufkp8DH/EDhd5Qi4CVOmqNz913jvctexvdBe
|
||||
Unizw6e+9K1s2zXk7NB5r6mOezMcS96JrzSePOk9EHxUms98Mbdp13zmq458EdC6R742ko3YzOnlEZTbez30tWs99A09Ke1kgrO8JcKrQuhXPNb9EJF0
|
||||
UCArWd+RUJuueqDbJDiuECV0nktf1cBzPTygkZbGka5V24kuV3AuNbsoldDrtzTPckms5QmDMvfyvRYqZgInujY7fYUzXZtCwBRMd/mWHzR6PPePyF2m
|
||||
wAa6n8F/0UEYSLiPb7uO5mEu7eSUYwe2kqBndOkEAv4bfEcXn+f6aKr50LN/iuta/9+c48Ly5ALqKAD8AzmM7ukJnOOe4nNceYzrBB+FTlbGSa0Hofd6
|
||||
jlvjMa7ln+KKmvVXPMQ9Sc/y6BJeNxAKVRnvZ05KpX8tvitOcGsiuxndA1yWOjA2nuq6pgeD8lFnOrvlhIhyq5TnivpWm+hq0lwup9/hqW2TNrv1Tm1z
|
||||
use2FRDQSSdSwGC909skSj2KZYIgh95SRdgJeK9aE0fQWgJZ9umWwK1FGme5zPGc4Emu7TKWKAjyUxExNdvVPcktV44p3L32E93yqQR0sKL2cLIrasvg
|
||||
6a64y+ivtJBlSDJTA+H9MKLCiVElR1tKk+4mtNnuxxa9OK5esOr3+c7RBno3QaNFt4hDysZM1mgxxcNXuVbv1m7/HbJ1RpNpNnrPr9Jb55qsLI3lx5K9
|
||||
K2p8z3PaNF39J5ITOg8kHzLpgaQUJNd5JNkSW3J+syeSP/W1KYZmbauu1lc9Wy2O48rnke09P4980rGaxUPr+L/TDAIH82k/kOwVFs30FHr8vc0ps5EC
|
||||
TFHaU/67n5G3gk5W4dXPSavd8g5z2+lhenoAyKGH3clFUbt6D/Xs+UB3L8e5py3rFD1jQe9lcoDP9G5MJ2sk7Sy9ELoebNU7z7X4gTaDnydW0uOaLNRP
|
||||
r4WoY1ZbJ2/GtOQbok+JN0TTLtvtM3QTnuYLoo+aZlPKEYW2DX+pd6zGM1b8A8YNYjOhq2rgt7ksaGm75uPFxxCXLRZZOniKS88Yt7+dZ4xl6qNHSuQ7
|
||||
o4F+EcQ2bTVoP2Z80JBwKsHUaeb2k+xwNhkWP1c7gP8zjyX/zihx6CFjaQGDng1Kk6PqPmKsZokktX9O0yPGtKiUKBd4NVA1UnsolSKpxcukmzMp09R8
|
||||
xvjPiO/T9JxxmuBVbudgaH3w2EbSFr9sKRhKXkDocdm/WKmkgbKJn92CgEa9fSZ4chsEEj0iS8KHw0Fsg5/h5VPberJyCXzyW3It+EBjDU8IB5b4zZ4R
|
||||
dsQzwtY7fUb4AwvA25j1rB969bR/21wdPYZWb6YaCQgrYKAGcf2AmhhNTZSUypEw+g3UVosVPMGK5a8BnCdwT5qohmScZH+1Z4I/tbNZF4Aih0by2GMi
|
||||
RkuP8CoR2ePyOeMYFvuByy/GFW8aquVxYN2ngWGuZtOt4aXU8glgccOxev4X9T+222JyMUK1DSol0C76jQtdlOokkMZDwITATjOVj/gwrcFpj3mD393p
|
||||
7QdWo32msYaTW84GaeR+0mNJQqj+zK+AwRYnlXUcW+Mh3yOuYaOYo1cVorZqAMRLeKbI5gf8G6kuCteCXokcwW2PSeilB1Y9t9R5kTWFqsgUMkef4uQk
|
||||
3sSIZJe0G4IJUo/ZUr4IZjbWkh8zGoT2z0IFqgLgCWS+1Xm5tffortXkndvSTsPJKUDRanhet1o94yvutJUq0RmHcSSFPWq67T9dS7bTA750ku+2t/28
|
||||
iLrbNJD4E3YNT+t+4rajvm5ty+bO0DxOuo25NL3F0Wml263azfa0mWinL8Q+aMFWh144Qp2CbNpuXH7QGnrx9c+dP/Uk2tsgy0/XjHbjJEY3Ga0/d5qZ
|
||||
dBsdBbvQuN9H0mG6f+YXvOj+i0bH6hM2ySyUEc11P3GEtNifDtelI7nwBVrvv2bSBjXbKVuL8voXQXlnrDPq6YMztd3P/Hkm+9O15rRl0KMkrTY7CJ/n
|
||||
Nibazuz5juYvzEzWycBfqIEklIEf0k5rWtbQUW/EzrYuP3Dbtd/H9XljCrs3PXVILpzK1QsynCH18I1G5FT+7SrGJym70Va3N7fCdc9g5+1WQ+qnnqSJ
|
||||
RG049ZmfO3MNuTM/9dBJjtNqphVm7oUQfw5C/L8P7eWg16aiF87NtuIjBxKWXsTVtt840eyarcmfHxs5+iEtTOg67Zl2C7uhXxpTsCy9jKvtQKsNrEec
|
||||
UgyShhypbPF0b72TaE+36b5qiwMbSabdyAIboKlYJiz2gVK8PQEs/3nRTKbbwiBRw6mvaQBA4PG23pnvsUY4ohOmxU6Spsi9DVr8eVuj2wZbtrbVQ+Fx
|
||||
B71wtZ/64inx55lGsx1aazQPKhCWCEw3LhNU09uWzPo2LJuwf+r5qQ++0wiUTSKy5QXkecYZs1E8vptt/OmaBif+nHcBx7Kbc5GcGLmjDaY9Y8BzhUQ/
|
||||
XYvlxF+YzXaGvCoFqWo45yX3cn5eTNPtaVmTo1ltm/+1JakTQYxzZywVURRQwYDRY8VHzTPN4N+tUCTAgXIj5bLA4WAFSNE85sVGckkT7irzo0kmY58I
|
||||
xgqH/vKDeiytQLYNI/gSBba6NJnntP2V0lYMS/7CS3F6NNlPog7y/TukySeEC7QbToML8OAAMJts9hDyZ+gA2mtyEgD9ZhMBhliSlZRnWUJOfng3JyAT
|
||||
6UWbPHvAmQQJSzmtbfFv0mprVeGsyZqpYjsD5xfel8SWmunM07aadDgzg3ksaf6jazZ476GPpMwynFtjCfMXUHyrC+Ga9J/fFUnZ+PkxIgFll6l/wzPS
|
||||
+jUd+nyMshXsn3Z+/u6nvppuepbCZc0GhOs+KgzpLLjV/LlbMus2yrfIgLbhZMxku+mBT1UC/Wmr2WAqg+ocCH8hhhIcvRvm/IVTT3mpVZc4fy4SRiMI
|
||||
1pk0gjCYNaqdCpcAZg65vpvRMJZAH4ZV6ZCznS4WxmokQyXa+U6PBD0UlhYHwtXq7AgWLQBaIHeo/o1m0oHUEkzqBDutQjsJk+BU6UbzeNjPjwQLpoof
|
||||
yEaJSlA7nbNhfx7hhyotNdqah8KG5F3YAcFuRlWwXKp7yYwrEztQ2ddwOvxFlWpIQLcqSsq0yIbMRdHqlW9W5ldm6lYerzxZKa48W5l/3lEDlV4ZWVl6
|
||||
3vO8Y6Ww8vT5hZVHzztX5o2VWZ7yef55p/hmpfi89/mAsVLEV88HVubw9RP89xTXDKw8Muiilcc0HN8bz/O4fonGzmDUEgR7ijU6cX2Qb0PSJUwEqZ9/
|
||||
9bwPi/DSmGPQWHlMEhSe92PWxeddNOHSygxNz4I9pfnUF9gtffi8A7/WwM1XrgttGZiroMnQKyg6mqav3AxtkSRfiuXrKzfIAM+qXKlxC3YddDkHrUCX
|
||||
xsoiaYquh+4Kundkh2ZQhniwsmQ8Pw+RFsgqEPDJ8wvkHDVxe2yuIL1qaeURJoOTPONfipiXvGZOmrmwR54vdV7AGnCkBfxwAbM9hnfwWvhyFvMvYU2o
|
||||
tFTPRboKAlyI6wisfI9JHsPpBsQ6wwE1L8X2B1a+4U0WIMogx1uJzmW/ADqBo4mWQdgMAbf3DbDfWBmlMMJeBlQPgaLxPMm0sohopNHPu8iAtJDBQUXh
|
||||
PcPqGuA1vuIYft5DepHBXGRpORT33mpA8Ayt3ALaXF2Z3kPDAdaCwEVW8jyJOAcbkiN1wYQFQgy5z0ekKorQAtt1Hlq5yQ4mQELom3e9wNru5+AqPr9A
|
||||
U8Kl+TvM3I/POvzvxIyzkIBU1UWWPwDVCNQqGNJUBHPkFiSHANIZxj9yuq7nX5G7EZgCPJ8PavU0Vr4OIihNN7+ywJjF4SKQ+UJclyM8C0kjHP8xSf6U
|
||||
9KnX5ID2i3DxLt7y8z52Cl+AuHYH26FIOcFveYgZw3O9hdYHAnCATINMSUFA+qQEsvKQhIjohEDYB6zEDvaOJY4YAEB8X4TSLLtg58FgCi0KdwykT0pH
|
||||
4by7Mr9PKIjCdaDkagIfAQpkVPLPOXI74B6FCnnjSlGjexJUiEpvhcg2Cl3BAbIgsZbij1NyqGboim2tBAsQuSPeK0KEIocmzEO4J4Thel0XyqCcSxY4
|
||||
/T0USC/AzpcXP8zJeoPKEYaPMtUX/BqmyB5Sij2MPOWI8ZVewwZ7J9ej6ucJrTXLWXFOugUs6sFfBI7rdXC4rMBFc7zWkqizyE0e8L4WlDOVlGwEn/hd
|
||||
5OgZDJS49kysXS1/ajV5INISgwnNy6WALw9H4vOBmJZPhQKruDKv1/6pVJyRNd9hI2hlpMT5qP5aIo3Iz/q8mnsxnAHJL7ionvWjUpqK5uABGH4W1glm
|
||||
IdI5ITo7PSbySpig68nU5mUykWQDc2g3mkqzmUhjIgKCmaR662nlImCgEyLN8mUXyuJVrxtVoSTwPHqO6YaHSNKnVhY1+lQrV8h/Cbuknh7H9qyokKRi
|
||||
IjA8snNVAS0J+Mvrz0rdLKq+2TPmdLpZ0PVTBosilRyiCFyCpUgfj6WmY9pawSl0Gluh3ZVEc1x7a+VbvoJLYngCVW2cV8kes/u4ZoaKCqwijiR8Qv7M
|
||||
BTWHzPOvSIeC5LIJH0oZHsV0wNjuKt0Wyv2nsF+jK1a2d07p76ZBRtiIXSxyDqAqubqfRbTNykoEIjhAmQdcC1csFKp006omLxHB/SISYjtrK2Mkdh39
|
||||
wZiIC5/Qj09FDVagvgNlOkkUn0BOlVSK+kQ4ovnGOUfxTxm9zzi9DjBx6NLoxJXVDBHFpIRulRQeKqybxdflFtDr2VW0hagPCn60h+vjasWhRmuv0nKh
|
||||
ACt6TF+FYGDeKk0/if8z3MRaFImWSTWVL19VpIwVOjJFARYl5L2GliBX3bUQgT1YlKRoP63zZMnW069fjcxvnJ3cXvp649q4Ru9wo9ArL/pmcGsyv9H3
|
||||
ePPq0tbo463r3ZuX+zaHpzbPD21/tbDRX9jsHMeHXk2xNXVp+34BA14W73Hdsv3s+82v7oiB232TGwszweGbw+c3zo1ApvWOTo3entwCJrvycKP/SUxX
|
||||
r9K2qzbnNhZmhaixbbzN4cnAyLi2nZAUomzc6NDq0pUodOP+9y+XBgzd50s2+h++fDr86ofu7ftLe+yzbTy+tHVpfOPRw1d95zcvFwJ7jWibbZ79dmOx
|
||||
I2CWqC7Z1uzV7bm5ciegfXOH7J+L/Zv9l6k/9s/Fs9g6vGPz3ujm/IXtsX6Mg2/RUPF4ibEx2r11sXfr2pONp0PKjfbSx9o62w+/3UMHa/PKrc0HQ9v3
|
||||
n726ch9ivuo4+2rk0cb5EZL6dsfmzTuvLt3f+u7qga0fr211Pto8O7ExM7R5o+vVtYtGnYF9Ct29XBp7deOHzRvP1B5iGki06q2+Vz9c9UIurlm0/ezq
|
||||
5uzI5vDZ8BXRzaFXV3u2p59sPb5r/Jux0d/76pubWi2hjdExXIOteA2hzeGOjcLXYrrNqZHNocLb6AeRAeYHgBiwyubAREQHaOPZ062hO/H9ns0LFwGE
|
||||
/1y8HsRF4AyM97J4zgPIl0/uEIjOLm2N3MeHYnmBRS+LjzcfjL/qO6csHdnAEdcExK/WtxEDyQFH5mN7My+f9Lx8dmPj3tVX3y6p8I5tv2BuBJtwd7Ea
|
||||
Q7xYEvt/dfXJ5r0fhINvjQ9sPL4ggsID95imyfb0wkZP/8bweGmwc2wrvAjNFtEXkXKeu7X99Knn8bh4o3Bje3ooCG6lEVelq+FNglwhpo1pYmzeu43N
|
||||
iDX0WhdetnyXzYqNC1eATcKkbOBXHde3n/X5KWnhzkbPwuatC8AuD7W2n323PTIovELpU6NxsPUdRcfGcCGMSdX7BJtzTzbOjQvh9FoCws83zp4HEm8N
|
||||
D2iwfYGrsQx/8+urm+e645m9FCBUtVQi8RsXv3rV0aFD4AHEm99f3LxxZ/PxRR22LhxRXAVH1CHsQmhdir516eZm/0Xobeve0npHfnv63stHZ6Gf7bt5
|
||||
VJAb84MbPaEoq0i2BSgiuMTaGsxaDNzue/BuyDR06gUFYCrgXxHcWdajF+6+LHYopK5CjiWC3R+EbmMpcMW6EQlq66sCIBj+rPRfkb8CDgGKGiT11d3L
|
||||
wGiRjkSm++ci5Lsss8/Q3MvipIjOAE5H0k65S+HSKpUELq7OIMWVwgnE9gWKBC6uQhNRj289GRZ6ETmMCzUxiQThbwY9ZYoSVIP3RdcEkZqiyc+YUXRu
|
||||
veuH9a7F9a6R9a4H613965131zvH1rsur3dOr3dNrnd1rXd9jQEa9I4q3/Wu6zRbZ3E9P77edWG9c3S9a4L/7F/PD613Dq7nb6539a533lvvfMTLfePp
|
||||
Y73rK5akn1bHuhAg/yON7Lq93nVlvStPP+SvrOeX1vPXsLutgSlwv/X8fX+Gznme4SL9mf8R+t56eD14iQYbLNUHieBp4koMOYzUXnXux3JuFrHYs3ii
|
||||
KDcllHl3Pd/Nu/tuvfNbLepYtr/7uq8o8LU8StfCfp3P5A+wgS6XZFPCph2kos6F9a4xFuQKqavzCWsMDvBkDxTzVX4CFJPNfXej97xS1LX1zoE4olkW
|
||||
BN+8ujywnr8QSzore959dUsGm+bmen7af6sB5t6eRYSce/nk6nr+a8811/Pf4kJ2/Ql2o4c0j2Sj6/lBWkEqXvz5zUbfXVDUMvfWZqklPkpsVW4A4ctm
|
||||
2QNtxV7lTsg5LnP4/MCmfsKOMraeP+85LG2b4huKG+Y/zx5Q2+zfHBjZWJzfnLi53fUU022OT6B8xK63fni8PXlemHU9P4C6QF1y1uCNnF/v+lEZ8HxY
|
||||
NTHkd6t75NXIIFtBIlEc+eXpb/nARZvHls6FQzKaC2/NdQsWDOVsTwIX80JoLUaMixUVxgx0ef7ZxgXAz7m3wYNf5Yub576Hc26ODiNDKVBaYp+8GUGL
|
||||
oentu5cDsRlJjte7vhFxedDPL8iAbKhQKinJONsj48iYAhDgJ6hX4Tzr+Ukh9tZ098Z3s3zVCMV/XkJHEO7LgiuSVIevvC+WiWTXKuhV0oQwlVaPJd58
|
||||
yQPOmPfhAZgnUO3GMnCW4luOzenw+qFo5ZKmVOL8dDhQixz6gYgn/V5mxBcZ/UogaV3T5fA8+noliBzzkz9BTRgj5WIXKEbhm1hVk+VXKEVoHwKqzpai
|
||||
GP1KDk9tAD/viLWfVZEgpitQqRYaDAhQHmgRDQMWCFRCr1vAPQKjrHzihd5N56D/IUG/Xz5K32N/9IvHbtbNjyoHy8RbOTm8fDKgEewanYaN+9+BuJRb
|
||||
K6bfICOE0Os2Z8lJb1e1tB8EgG6chS/+qPJiqctrdCYoVYsSvevH+P7EueGt2YH4/kR5HR2ApEqNis3+JyobxDQq1jufsq4IXATDg3PrdCy8wfq9isDg
|
||||
uHYFq7FAiab7wsbXD6houdu5fTdPJUr+3HondPBg4+YAEvz20qJAadHhKHO8qs0MlaoGlf3LcSC6t+G3NAzPIqWzvMVeBzuJ5yFj23cHlAtE9Tr670Qm
|
||||
Ta+oqNIA2bg/+PJxbzUQj22JRNGgH+lMIH9JyPDq+nf0ydAFLioHt649hrmDcBrZO+EN9YtaRqODQgch/TcoB4ZLIlWiBcplpbiNnvGXT7+tVnPoNlrK
|
||||
SxgRZRFJMr4HI/1clTEac1VpyTDiC0o5zQJShSRMIjo0MseNB7syRoBmVKsgojs20WXqxuLC9uQzVZ3uzTIkQ6MT1djZmRzfmbj6pveWsfvdI+NNfm7n
|
||||
7tSb8zqNHB7/1dXdG3O7Yx271zqM14Wh3ZGC8ebr4d2B4Z2Lw8abnvO750bfnCvu3Fn0NLfTU6RPHuZ3r1w0dh+P7M5efTN0dffKlLH7/cXdWf9odrd7
|
||||
eGe2m0ddKbyeLbwZmtgZ6N8ZGNV5Eme3f/h1gS/mLT2N6ciU771qZ0UIs3NxPLYHo0ZehX6M3aFzuzcGtXovvkl0ey6vFzpIhzeu+sq7USTVd8/hT+2m
|
||||
y5tL3Tsjwzvnh3Z7rhr0y92p3Z6BPR7l797K716fgFivH9wyYDX4hGfIq7vdxd3rl+L6LQHfvDL3uljQ7rNI14E+uMPy+52rF3fOXfK6K7s3uo2dh9fg
|
||||
czvdt3fPzUu3gtLmjJ3i4M6Nxd3rhd1bHTTB7rNbbzpH6Wqo5vVCkRSLy+G15Cxhl9zLyT/Jeau79hYKSUTyvum6xyFy6+vXC4tvhqCqfgh9pTewLZL/
|
||||
wdzOt8MHdjuncA3Cz3hzbWj3+zvG64eDb4aGaUP0wY0imWumsNOJKYojOxOYfnB8p/si7/jK0+BeY5oku7eGdyYnSBBWHcd8XJtkd6h7dywvB78udIS3
|
||||
ofUwyc5DCHrjqfKVuL7IzmDHzvfz3oMj8mqK07fQFtntvvWm64bxZugeNEWmfF24CKQgvLjSX7Lvyv2Rnak5RBVshnHx/ZE3g/mDAcCGb4fwOYTeCpFv
|
||||
DcEv4CFGibCIn4lLFJ4jwwTbO3d7gtJGtj8EHssJI9se7N5K3iCOR/U4dsbyb/JTO2ODBgXw95p3F2Cz8BnAhlymciCFBCK/llEkcBPgsPP9OIB+hNDt
|
||||
3CgpdbcwvjMwXpq5YtoYO+OLkL0cja5cJAFl/FPQe4jkIareUxt+CiZDAvG/Ha684RLYhjwP5oILxbQnAuvMyXUqenf1poRY2dgd/bqGroRfMlCWfifN
|
||||
CMAfooZrIM+Bg32InQvdr+enDEQQwMaQ6akMct/0zpPSlUuGnEij6wB02+3K+wVXTLfhzbXh3e4ZX2C9FoOIMsobRdjpFpdcr2c7kLTDaT66tyDSRWxX
|
||||
YacwvLswGN9V8AoHkRo9HKjUUNj5ZnR38ikG6vQUdnsHqaBCotjtvkFuqdNP8EfrNBR2b/XvDj3V7Sa86SII2AewuLozXTSgbVxs7BQuAJa7d2Zv7Vxi
|
||||
tN7tugHblrhQ1f6BwHbOwKy7ikGr20HwTVAtGn+jroEPlASbQC5VFUQ0DbC7nTvFQNBScrvjXVmlVbB7rXvn9mBFCNXuEwQL9iDnucr10/mCsTNVRJSQ
|
||||
USgKum68udxfVhxVaBBICJfgrQqF6C6BTECh8kDk60B9iFCfDaZDGlNWCcS1BcIZFVOIWKmuysh+gKxJSBKJShHTVGkF7HTTjne/G0QVIXVu1ElrcFwF
|
||||
OgChujmYF6Npv34Npq9rxi43iuiv9az147/e1cm1fgM/nlsdN9a61rrph9UZDb6/OrbWjYs7VxeM1fm1/Oq91bHVSYM+VbP18y+9q/Pi0x7879zqrLGW
|
||||
XztnrE7jElywOmPg4278eG+1yDMohfL89/HR/OqEsTq+1gW988yTWK4bn/CX02s9Fa7g8Tp9AayNDaw+MlbvQMKzRlArMT0CvnQ+sOHoPsFajxA8tk2w
|
||||
+hiTkVYC4+NaBKuP1npZ3b3ciWJlh/cR1zKoMMPqj2vn1rqraVerg7B6Z/XR6jSZifbzANd10tTd0DZEw+dTe+wm4OIFzHaO5uvnVbAG+ckY+xI0Rz/F
|
||||
NRQwJo+x98rNHtVV4DXmsXKJzsIK6lB3c6zeRoBxq0F+HeWyBsSZpN1QUHBkiDAx+NMZbDiv9jiDj8bx94KKrv7VKfxHWuAIW+tbLayOyS/Zl/K8U3L2
|
||||
Sdhiiq3Lk02v3sf+u/feteBZ7kDpZOuZGroXtBMD/v6ADIq/H4U0g+3Mk2AqOqHwblKNMjQUoLAGm13rY/XdW+skr4A1jdXb+LQfZiUNMszcEw7YD7SZ
|
||||
UHpjBWOWeweUIhHYj7g+Z9t5XsJfBy9enSLjkx+uTmi1QRj1CPNwDe3c96C4ZogYPklGRjBVR0/8MEmaIul+kNPGvnID03VSLJHrdMKxxlZHVie0Oiac
|
||||
AGY4Z4z5L9yoPN9b6KFAnB5hpq7VOQqVR+QFwlXuQapzq9MR/RPOLWTSPgkdkf0TSmzkNasTB7FLWneclse+KcmVpj4ZnOzCyj4ia06QQ6pM4sspI1Mo
|
||||
kjKzb+cJjbYKht5nrx4jbxMycKaLbLGwHCKWQiLGtlkQWPnVoriG7D0OWAIKYUW9hovEAQjZJS0mRaHg5VCsIBVHO/QxUxksBCY95PBFaOg1Wyj3wlRn
|
||||
q2Gkml3BVATUEngwXg3otWEY4gu0GwKxHrG0H+jSiIFg30/RPialqpICtRozImw0V41p0VAAARHOl8ih169RRY9IoO/wuRM/NnsoAlmxdV6YK/eEzsdJ
|
||||
R3MMgQuUb6YJzoNVrMwgcYlDo3/jJw/h7VNwu4mYJo6/j15IuSDHx3ZxpCdTJE9hkwzj4yoSONQ1mjglyS2qkcPgRBVA3vOwyIaOgpsqRXulvg7CF7WU
|
||||
TlOHA6Hbi3zogeqaGZ3WTtj5vXl0+jwkPIHJ6oxur4eL6DlKrmI5VRxxsdPvBe59lVqm+ftuwqt+Kd9DDvheHx6r9oAERPgoH0xHGv0fLzHLKRg2382T
|
||||
MlRHrz6UwF3qORG9IBHxXuq5DcXOrJ2N6gOVJLU7lMRZd/GvZ6UqYPVHA2ssrN6mon2MVvyRTTVSMd2V8rWIHhBmeiQLDFwlU4g0Y3QniMWaDhQ3Xopb
|
||||
HRRQJyHSL2g8hY3je65iAFtjeu2gEgX6cVmhGNDoB5VMJz3Rd+TJtdh3OkBfc9QlUSSLJZgj6sYJooR+eNlvrYtiLpou1vBKh3+p3KzdUsLFNf/dUEUL
|
||||
dTpIftsIwlLDiLse0A8t/PdBMszfB4nUzAG8YO4ZCoNpIj2kyEmSvv+XCTnaT7n3xdYfchX6y8RaLyaZFnxunKZ5gMDtxyzhC6gfQUurEmStB3PC2vhK
|
||||
p5OEdboh8pJYg4ButSgDPfKfHQ3pK7Z7xKqIbSH5e6CtP5AZNbaJ9APpdvWBJ7xBatV9nSvZhNC0d81/OhfaA3JpdYrWliDpHDHc3tUi9NjDZJdUCtPt
|
||||
sUdEu7nP9RhU4DsCFWRUpo1zru4zGEm72dT0ve4/VSq8kSaZ9eaO7R0F/qEWjB8TfaEqDilXmCIr/DJNDGN1Bi6p1tpPP8H9/z74j45BGQIcNezDPOEv
|
||||
HCv9/Mu0cn7hEaRgmp5mo6JSOjymok/w2fQeu0F/axFjv6z1GSDyH9rCpMBIT2CxJSq0f2EQmOZm0QxtxGCvmVbbgEqwcwK+WU8fQJhZ6ZUCbvCDCNXV
|
||||
6QOYSQKMtOWMnNfzlTqDzbS2RCKRk8jCneauYP+amkC/sFDj5Iu0Qf1WUB+PnC1FwWCsx7R8DNHzgWY7KQB6UGWICrIv7Mp67R8MhxpC3R85swg/Mg22
|
||||
9Tb6P2PszZRVxymgezn2YWOG8ujmD42dpp2TEhA4ZCRpjJg+0C8TGNl3UCUvP015HseuNcVx52uYqoUx+NcvzAkDckv9Sf/3qwZyRaq/8Occ251Vz0ER
|
||||
SFMxbaHAQiplRbaEVPx5W5GoIZJdGPgiGkSiGWTwZb20Y1ZSYfU+rp4pdbvYdpHQLktGEVmKHUH5sBCFcdk2gjBCmiT7I4H1GSXQQa6rop3PMKYZTHk9
|
||||
iTp6jaVuQW/XBgzuj00ybnHgVEHtMRY9kEv7fYiJ6yUJaACaC3CQGULAFRYgpRN8En8EamJXj4KoF5UetRpK5fVZ6dJc0vVKe0d0k9jQRFG6jVqbSRw+
|
||||
4rmkcPn0DjtKAm3vSScit6TifYFUQqyhV5hdeMgs/6jsXzUPlcVizQ0lTkQl4CJON1entfpLtKs8n1p16/WXvHYS7d4HgQoZ+NftMyG0ZgVkavSYAnJN
|
||||
+4ao3mDiCoWUp9lkmiJZOMAktSXGw8wRUz3SajcFo6PKhFq9p+A83ra1+1B+oc466KWDXg56opr3mV7PC8d/RChL2RjfkJRTIj+T3IbfFqjahCpNgAEL
|
||||
zXnFd2QbijYaeJqpdIa32IaiTgIdNa1OV3KwiD6UKBj8PMbkYCYqL1dpT61OYlSPcLpeT4FRXSmqPLHYmCAPDziSeiXhrcb+yOxzQi5Dkbwf8Nl0VIMq
|
||||
mH+4pznJ3tkb3ldMr8rPsqHCrKQC4ungjoyvAYogj+Arq1WzfVVSlVQspaoCgShdNNpapVEh1B5wbZnmNVpbtFGKhHH80CfSk0y5JFzg3yIKU9Ry2Nbq
|
||||
YtVYLrOFhIRhDdLoOSpa+uTStPZpO6ph9WL0/os7A/xn34vRzhejN/Gr8WJ0nn+fwk9DL+6cfzE6+2L02YvRyRejBY1+1ovRHh47jkmMaiuM86c3sciL
|
||||
O3ksY3hDQstP8k/D+OkSFsdfj16MLr0Y7WORxA83WUqS7wJ+uiL+usnXCeGL/KcYxmvjr36eGh9fF9NigoJcvg6/TmMh/NXL+xgS+9DYVJFnOU8b4FmL
|
||||
YvAwb3Hkxejii9FRiM3bgAh3RjUaatXWmlSCB+wT2khMzy3WyFXbaqEN8QYf8e8XYhty5VeO88UdwuysFCl4XJeumlqmX9zpZMMVIBD2pNW38y3Nxqk8
|
||||
s/hFOpP6rlhtybhGn/K/Aq81omzwECoxAt91CCci991b948FE5a+K+YOuLoXVZNsFhijSwwYD4TPzXKvimgLVtPeZf50NjBJVH9wT9HG1pNdRQ7hJf/F
|
||||
QRzozzwjs6HyCEBhV7LilFQ5fXmWHJWitRCCpSEBVWwV+l7IIFBovE785aNMkWW9xwNmKfDvdPg+rmI/sq9YOzrTTWhqZeGpbLnR2zXcixbh/0WBrP1i
|
||||
8VEB26EQnlRggOuuCyUMCdDbbyjVTfH0wnQjBwI6JE+74KO2H2UyKnrYIfIkGk35TBhZLDwsfiI7h2B8KWikEeHq/Sz0bKlXKpvE9C1JHNqG2MCCUEKB
|
||||
MxMkGq2Q2+I6mbyze8I/pFKHeIr+yonS17jefW2ssGd83U1hox8EAFeaoVKtzS52hZ35CgWNanFWmXh06W00OTmkpz1MWxICyPx6XRQIEY1O6U5ARdLD
|
||||
ZXGZiJZxdWVkw5OBP6/clfzxoFeJFIUxhQ93CKsFpQ2WQIZXYRQ4kUyWYk55fle4KK+b9aofOHcX+SRdc8svEiKroaoVVSEcFpEN1XJB5XYjm6oeqJTK
|
||||
7RdqUcVNVKNVKIdzw6ynok5hiSLHTx9XI95ksc1WURV6gFMqWwgGhzx0ktAUgsJgiekXuFHIF/jujlSZVNGUFOPOWQUc5B+VqsyYxizP2MUuM+1F0yMB
|
||||
jjVlycrZw//eT5lGIGWWeltcZ7ciMM6KYqpCXTmgDMCf7dctefxAkxqRuxd5pxiSXKsjHCN2mMfENIUrii33KvKR8oH9+/drv7vKYy2VdeK79RXx07tp
|
||||
IwdsISqBKXa2MS8HhxGlrqpf3mTMneUJloQrx1YNQ6oqpQFjvASuuR9UXTXcUu6t0ZZmDXfy/ubFGtVKi+p9aeX0QlPPxBwh1eg1qkOYqYJCbjYEE0XW
|
||||
cIdadCGQVn2oje5jl9dXsf1szuWElvHtbC18Ks8OlTrdrEkI+h3G6fS6Ve4VlG/Uh5V5Ns1DheR9vOvbOp3vatdKnZcCAbabV1gQ1QgPmzuQwyrDSlxn
|
||||
XJGGK/uEep+F1E+/DnnxVlB1/EOfnV7g8efZ6fuYLItcfoXrZZp52gtev4ILlvMV2+jBCqx0vwrcanopWBUvikXL36i1Hu/qqvb1/Tyi4c7DOxXt7gvV
|
||||
Lnr1WpUGPE/UKUwflbqlOaI68yUtuxobOZJ7eRw2z1Ws34MItQG+E0YVXb0p1dPq8LphXuFVsbXv1R8H48oKjRZ/CQZXrvavy3qHJ91nBKq8gO5DFo1J
|
||||
YXFt/4jiPgL19CvpJY0zgZLGXvV4D61ZZZkqBwZKqTdFI0p0XVQqlAaYVlW/amuptBissGStf8krqDXamxU6H8o+0acOvw1XrOBXCilq43e8iS+//H/O
|
||||
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
Reference in New Issue
Block a user