mirror of
https://github.com/core-hn/pseudobsidian-ization.git
synced 2026-07-22 06:57:06 +00:00
feat: i18n, corpus organization, settings redesign, scope warning (Phase 10)
- i18n: all UI strings externalized in locales/en.json + fr.json; language selectable in wizard (new Language step) and settings - Wizard: new Storage step with 1-vault-per-corpus recommendation - Settings: reorganized in 6 sections ordered by frequency of use; language dropdown at the top - Corpus organization: CorpusModal + ClassSelectModal; named classes with mirrored transcriptions/mappings/exports folders; class selection prompt on import - Broad-scope warning for name rules (RuleModal + EditRuleModal): warning callout for folder/vault scope; success callout for file scope - Mappings tab: grouped by scope (File/Folder/Vault); active-file-only filter checkbox (checked by default); getAllRulesWithLocation() added to ScopeResolver - EditRuleModal, QuickPseudonymizeModal fully migrated to t() - README, CHANGELOG, ROADMAP translated to English; French versions archived as *.fr.md - esbuild: dev mode outputs directly to test_vault for hot reload
This commit is contained in:
parent
411a8888e2
commit
503588e7fc
25 changed files with 9760 additions and 7025 deletions
140
CHANGELOG.fr.md
Normal file
140
CHANGELOG.fr.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Changelog
|
||||
|
||||
## [prod] v0.1.3 — 14 mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.1.3`
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
- **Système de dictionnaires structurés** — format `DictionaryFile` v1.1 : champs `roles` (détection / remplacement / classes), `configSchema` (variables configurables avec valeurs possibles), `config` (valeurs actives), `author`, `doi`
|
||||
- **`DictionaryLoader`** — service de chargement, index de détection normalisé, résolution de classes par conditions/regex/word-to-word, calcul d'index incrémental par portée
|
||||
- **Repo dédié [`pseudobsidian-dictionaries`](https://github.com/core-hn/pseudobsidian-dictionaries)** — catalogue de dictionnaires téléchargeables, premier dictionnaire : communes françaises GeoAPI INSEE (34 957 entrées, classes Village → Métropole)
|
||||
- **Wizard — catalogue de dictionnaires** : tableau scrollable avec bouton icône `cloud-download` → `refresh-cw` (spin) → `cloud-check` ; installation depuis le repo dédié sans quitter Obsidian
|
||||
- **`scripts/build-cities.mjs`** — script one-shot de génération du dictionnaire communes depuis GeoAPI INSEE
|
||||
- **Scan par dictionnaire** (`Onglet Dictionnaires → Scanner`) : fenêtre glissante de n-grammes, filtre par dictionnaires cochés, commande Ctrl+P disponible
|
||||
- **`DictScanReviewModal`** — modale de révision en cards : terme source, catégorie (tiny), extrait de contexte avec terme surligné, préfixe éditable + index calculé en lecture seule (recalcul dynamique si décoché), checkbox par card
|
||||
- **`MappingScanReviewModal`** — modale de révision pour le scan par règles : tableau source → remplacement · occurrences, application directe dans le fichier source
|
||||
- **Onglet Mappings** — bouton "Scanner le fichier" ouvre `MappingScanReviewModal`
|
||||
- **Onglet NER** — bouton "Identifier des candidats" en haut de l'onglet
|
||||
- **`RuleModal`** — suggestions de remplacement par classe (dictionnaire) séparées des suggestions Coulmont ; modal Coulmont n'affiche plus les suggestions dictionnaire (prénoms ≠ lieux)
|
||||
|
||||
### Suppressions
|
||||
- **Onglet "Candidats"** supprimé — le scan par règles est maintenant dans Mappings, le scan NER dans NER
|
||||
|
||||
### Corrections
|
||||
- `DictScanReviewModal` : l'index n'est plus modifiable manuellement — seul le préfixe l'est, l'index se recalcule quand on coche/décoche des items
|
||||
- CSS : animation spin sur l'icône de téléchargement en cours, mini cards dictionnaires dans le panneau
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.1.2 — 13 mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.1.2`
|
||||
|
||||
### Corrections
|
||||
- `setInterval`/`clearInterval` remplacés par partage de Promise dans `OnnxNerScanner` — plus de polling, appels concurrents correctement gérés
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.1.1 — 13 mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.1.1`
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
- **Onglet Dictionnaires** — liste les `.dict.json` du vault avec nombre d'entrées, import et suppression
|
||||
|
||||
### Corrections portail communautaire Obsidian
|
||||
- `onunload` : suppression de `detachLeavesOfType`
|
||||
- `eslint-disable` et `any` : type `TransformersModule` explicite dans `OnnxNerScanner`
|
||||
- `window.setInterval`/`clearInterval`, `window.setTimeout`, `activeDocument`
|
||||
- `FileManager.trashFile()` à la place de `Vault.delete()`
|
||||
- `activeLeaf` déprécié → `getActiveViewOfType(ItemView)`
|
||||
- Imports inutilisés supprimés (`Setting`, `RuleLocation`, `fs`)
|
||||
- Promises dans les event listeners wrappées avec `void (async () => {})()`
|
||||
- CSS `text-decoration-color` → `border-bottom` (support partiel)
|
||||
- Workflow `.github/workflows/release.yml` — attestations GitHub Actions
|
||||
- Régénération `package-lock.json` (erreur CI `concat-map` 404)
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.1.0 — 13 mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.1.0`
|
||||
|
||||
## [dev] v0.1.0 — en cours
|
||||
|
||||
**Branche :** `dev` | **Publié :** non
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
- **Panneau latéral** (`PseudonymizationView`) — 5 onglets : Candidats, Mappings, Dictionnaires, Exports, NER
|
||||
- **Détection NER** (`OnnxNerScanner`) — modèle BERT-NER multilingue via `transformers.js` (WASM, 100 % local)
|
||||
- Surlignage bleu des candidats dans l'éditeur
|
||||
- Filtrage des sous-termes de règles composées (Saint-Jean-de-Luz filtre Jean/Luz)
|
||||
- Paramètres ajustables : seuil de confiance, mots fonctionnels exclus (onglet NER)
|
||||
- **Wizard onboarding** — 3 étapes : bienvenue, setup NER (téléchargement WASM ~19 Mo), import dictionnaires
|
||||
- **Marqueurs `{{...}}`** activés par défaut dans les remplacements en direct et les exports
|
||||
- **Clic droit → Annuler la pseudonymisation** — restaure le terme original (sur termes verts)
|
||||
- **Surlignage** : vert + souligné pour les remplacements en direct ; fichiers `*.pseudonymized.*` surlignés avec les règles du fichier source
|
||||
- **Import de dictionnaires `.dict.json`** depuis le wizard
|
||||
- **Paramètres NER** : `nerBackend`, `nerMinScore`, `nerFunctionWords` dans les settings
|
||||
- **eslint_violations.md** — référence du lint Obsidian et commande de test local
|
||||
|
||||
### Corrections
|
||||
- `style.marginLeft` → classe CSS (`no-static-styles-assignment`)
|
||||
- Icônes UI (✓ ✗ ← → +) déplacées en CSS `::before`/`::after`
|
||||
- `getActiveFile()` polyfill pour le panneau latéral (garde le dernier fichier connu)
|
||||
- `import.meta.url` polyfill dans esbuild pour `@xenova/transformers/src/env.js`
|
||||
- Alias `onnxruntime-node → ort-web.node.js` pour éviter les binaires natifs non bundlables
|
||||
- `env.cacheDir` fixé avant `pipeline()` (évite `path.join(null, ...)`)
|
||||
- Références institutionnelles incorrectes supprimées (ICAR, CNRS)
|
||||
|
||||
### Technique
|
||||
- `@xenova/transformers` v2.17.2 ajouté comme dépendance
|
||||
- esbuild : `target: es2020`, `platform: browser` retiré, banner polyfill `import.meta.url`
|
||||
- `tsconfig.json` : target ES2020, lib ES2020
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.0.4 — 12 mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.0.4` | **Publié :** GitLab + GitHub (`core-hn/pseudobsidian-ization`)
|
||||
|
||||
### Corrections bot Obsidian
|
||||
- Suppression du heading "Pseudonymizer tool" en tête des settings (`settings-tab/no-manual-html-headings`)
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.0.3 — mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.0.3`
|
||||
|
||||
### Corrections bot Obsidian (Required)
|
||||
- `style.cssText` et `setAttribute('style')` → classes CSS (`no-static-styles-assignment`)
|
||||
- Headings `<h2>`/`<h3>` dans les settings → `new Setting().setHeading()` (`settings-tab/no-manual-html-headings`)
|
||||
- Promesses flottantes → opérateur `void` (`no-floating-promises`)
|
||||
- Sentence case sur les labels UI
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.0.2 — mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.0.2`
|
||||
|
||||
### Changements
|
||||
- Rename du plugin : `pseudobsidianization` → `pseudonymizer-tool` (exigence répertoire communautaire)
|
||||
- Description `manifest.json` en anglais
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.0.1 — mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.0.1`
|
||||
|
||||
### Première release
|
||||
- Parsers SRT et CHAT/CHA avec round-trip garanti
|
||||
- Moteur de pseudonymisation (spans, priorité z-index, protection chevauchements)
|
||||
- Portées fichier / dossier / vault
|
||||
- Surlignage CM6 (orange = sources, vert = remplacements)
|
||||
- Validation sélective par occurrence (OccurrencesModal)
|
||||
- Import de transcriptions avec conversion automatique
|
||||
- Intégration Coulmont (suggestions de prénoms sociologiquement équivalents)
|
||||
- Soumission PR #12766 au répertoire communautaire Obsidian
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,5 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
> Previous entries in French: [CHANGELOG.fr.md](CHANGELOG.fr.md)
|
||||
|
||||
## [dev] v0.1.4 — 16 May 2026
|
||||
|
||||
**Branch:** `main` | **Tag:** upcoming
|
||||
|
||||
### New features
|
||||
- **Internationalization (i18n)** — all UI strings externalized into `src/i18n/locales/en.json` and `fr.json`; language switchable from wizard and settings without restart
|
||||
- **Language step in wizard** — first step after welcome; bilingual display before a language is chosen
|
||||
- **Storage step in wizard** — folder configuration + "one vault per corpus" recommendation
|
||||
- **Settings reorganization** — 6 sections in order of usage frequency: General · Text detection · Pseudonymization · NER · Storage · Security; language dropdown at the top
|
||||
- **Corpus organization** (`Organize corpus` command) — named classes (sub-folders) mirrored across transcriptions/, mappings/ and exports/; class selection on import
|
||||
- **Broad-scope warning for name rules** — dynamic callout in `RuleModal` and `EditRuleModal` when a first name/surname rule is set to folder or vault scope; green success callout for file scope
|
||||
- **Mappings tab — grouped by scope** — three sections (File / Folder / Vault) with "Active file only" checkbox (checked by default)
|
||||
|
||||
### Changes
|
||||
- README, CHANGELOG, ROADMAP now in English; French versions archived as `*.fr.md`
|
||||
- `EditRuleModal`, `QuickPseudonymizeModal` fully migrated to `t()`
|
||||
|
||||
---
|
||||
|
||||
## [prod] v0.1.3 — 14 mai 2026
|
||||
|
||||
**Branche :** `main` | **Tag :** `0.1.3`
|
||||
|
|
|
|||
248
README.fr.md
Normal file
248
README.fr.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Pseudonymizer Tool
|
||||
alias `PseudObsidian-ization` pour les intimes.
|
||||
|
||||
Plugin Obsidian de **pseudonymisation et de correction de transcriptions** pour chercheurs en sciences du langage. Il comble un manque : les outils existants (Sonal, Whispurge) ne s'intègrent pas dans un environnement de prise de notes et d'analyse, et peu de logiciels acceptent les conventions de transcription multimodales (Jefferson, ICOR).
|
||||
|
||||
> **English summary** — An Obsidian plugin for pseudonymizing and correcting interactional transcripts (Jefferson / ICOR conventions, .srt, .cha formats). Designed for qualitative researchers in linguistics and conversation analysis. See [SPECS.md](SPECS.md) for full technical specifications.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Transcription brute (.srt, .cha, .md, .txt)
|
||||
↓ import automatique
|
||||
Obsidian — édition native Markdown
|
||||
↓ pseudonymisation (manuelle ou NER)
|
||||
Fichier source annoté + table de correspondance (séparée)
|
||||
↓ export
|
||||
Transcription pseudonymisée (.pseudonymized.*)
|
||||
```
|
||||
|
||||
Deux approches, combinables librement :
|
||||
|
||||
- **Manuelle** — clic droit sur un terme → choisir un pseudonyme → le plugin remplace et enregistre la règle
|
||||
- **Automatique (NER)** — un modèle d'IA détecte les entités identifiantes et les surligne en bleu dans l'éditeur → le chercheur valide ou rejette chaque candidat par clic droit
|
||||
|
||||
Les pseudonymes sont encadrés de marqueurs `{{...}}` dans le fichier source et l'export pour rester visuellement distincts des données brutes. Les tables de correspondance ne sont jamais incluses dans les exports.
|
||||
|
||||
---
|
||||
|
||||
## Prise en main
|
||||
|
||||
### Premier lancement
|
||||
|
||||
Au premier chargement, un assistant de configuration s'ouvre automatiquement en trois étapes :
|
||||
|
||||
1. **Bienvenue** — présentation du plugin
|
||||
2. **Détection NER** — choisir d'activer le modèle IA local (téléchargement WASM ~19 Mo) ou de travailler uniquement avec des règles manuelles
|
||||
3. **Dictionnaires** — importer des fichiers `.dict.json` existants si vous avez déjà des listes de candidats de remplacement
|
||||
|
||||
L'assistant est relançable à tout moment : Paramètres → Pseudonymizer tool → Reconfigurer.
|
||||
|
||||
### Formats pris en charge
|
||||
|
||||
| Format | Extension | Notes |
|
||||
|---|---|---|
|
||||
| Sous-titres horodatés | `.srt` | Sortie Whisper / IA — horodatages et structure préservés |
|
||||
| CHAT / CLAN | `.cha`, `.chat` | Lignes `@`, `*`, `%` préservées |
|
||||
| Markdown annoté | `.md` | Conventions Jefferson ou ICOR |
|
||||
| Texte brut | `.txt` | Sans marqueurs de convention |
|
||||
|
||||
Les fichiers `.srt` et `.cha` sont automatiquement convertis en Markdown à l'import. Un fichier de mapping JSON vide est créé en même temps.
|
||||
|
||||
> Installez le plugin [Data Files Editor](https://github.com/zuktol/obsidian-data-files-editor) pour visionner les fichiers de mapping JSON directement depuis votre vault.
|
||||
|
||||
### Installation
|
||||
|
||||
**Via le répertoire communautaire Obsidian** (validation en cours) :
|
||||
1. Obsidian → Paramètres → Extensions communautaires → Parcourir → "Pseudonymizer Tool"
|
||||
|
||||
**Installation manuelle** depuis la [dernière release GitHub](https://github.com/core-hn/pseudobsidian-ization/releases) :
|
||||
1. Télécharger `main.js`, `manifest.json`, `styles.css`
|
||||
2. Copier dans `.obsidian/plugins/pseudonymizer-tool/` de votre vault
|
||||
3. Activer dans Paramètres → Extensions communautaires
|
||||
|
||||
> Les fichiers WASM (NER) sont téléchargés automatiquement par l'assistant au premier lancement si vous activez la détection automatique. Vous pouvez aussi les télécharger manuellement depuis la release.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Pseudonymisation manuelle
|
||||
|
||||
Toutes les actions sont disponibles via le **clic droit** sur une sélection dans l'éditeur :
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| **Pseudonymiser** | Remplace le terme (cette occurrence ou toutes) avec marqueurs `{{...}}` |
|
||||
| **Pseudonymiser avec Pr Baptiste Coulmont** | Interroge [coulmont.com](https://coulmont.com) — propose des prénoms sociologiquement équivalents (même milieu social, même décennie) |
|
||||
| **Créer une règle** | Enregistre la correspondance dans le mapping JSON sans modifier le texte |
|
||||
| **Modifier la règle** | Modifie une règle existante (disponible sur les termes en orange ou en vert) |
|
||||
| **Annuler la pseudonymisation** | Restaure le terme original pour cette occurrence (disponible sur les termes en vert) |
|
||||
|
||||
### Détection automatique par NER
|
||||
|
||||
Le moteur de **reconnaissance d'entités nommées** détecte prénoms, noms, lieux et institutions sans liste préexistante, en exploitant le contexte syntaxique. Contrairement à une approche par dictionnaire, il distingue "Florence" la personne de "Florence" la ville. Les sous-termes d'une entité composée connue sont filtrés automatiquement (si "Saint-Jean-de-Luz" est une règle, "Jean" et "Luz" ne remontent pas comme candidats NER).
|
||||
|
||||
> Pour comprendre ce que sont les modèles NER : [blog Vaniila](https://blog.vaniila.ai/NER/).
|
||||
|
||||
**Utilisation :**
|
||||
1. Panneau latéral → onglet **Candidats** → **Identifier des candidats**
|
||||
2. Les entités apparaissent **surlignées en bleu** dans l'éditeur
|
||||
3. Clic droit sur un terme bleu → **Pseudonymiser** ou **Créer une règle**
|
||||
|
||||
**Modèle :** `Xenova/bert-base-multilingual-cased-ner-hrl` via `transformers.js`. Exécution 100 % locale. Téléchargements uniques au premier usage : WASM (~19 Mo) + modèle NER (~66 Mo). **Fonctionnement hors-ligne après le premier téléchargement.**
|
||||
|
||||
**Paramètres (onglet NER du panneau)** :
|
||||
- **Seuil de confiance** (0,50–1,00) : augmenter réduit les faux positifs
|
||||
- **Mots fonctionnels exclus** : liste éditable des tokens à toujours ignorer ("de", "du", "la"…)
|
||||
|
||||
### Panneau latéral
|
||||
|
||||
Accessible via l'icône dans le ruban ou `Ctrl+P → Pseudonymisation : ouvrir le panneau`.
|
||||
|
||||
| Onglet | Contenu |
|
||||
|---|---|
|
||||
| **Mappings** | Règles actives · Modifier · Supprimer · Ajouter · Scanner le fichier |
|
||||
| **Dictionnaires** | Mini cards · Scan par dictionnaire · Import local |
|
||||
| **Exports** | Pseudonymiser et exporter · Exporter la table de correspondance |
|
||||
| **NER** | Visible si NER activé · Identifier des candidats · Seuil de confiance · Mots fonctionnels |
|
||||
|
||||
### Surlignage et marqueurs
|
||||
|
||||
Le surlignage est actif dans tout fichier ouvert, y compris les fichiers export `.pseudonymized.*` qui héritent automatiquement des règles du fichier source.
|
||||
|
||||
| Couleur | Signification |
|
||||
|---|---|
|
||||
| 🟠 Orange + contour | Terme source encore présent — à pseudonymiser |
|
||||
| 🟢 Vert + souligné | Pseudonyme appliqué en direct dans le fichier |
|
||||
| 🔵 Bleu + contour | Candidat NER — pas encore de règle |
|
||||
|
||||
Dans les fichiers exportés, les pseudonymes sont encadrés de marqueurs `{{Pierre}}` pour les distinguer des données brutes (activé par défaut, configurable dans les paramètres).
|
||||
|
||||
### Tables de correspondance
|
||||
|
||||
- Trois niveaux de portée : `fichier.mapping.json` · `dossier.mapping.json` · `vault.mapping.json`
|
||||
- Statuts par occurrence : `validated`, `ignored`, `partial`, `conflict`, `needs_review`
|
||||
- **Priorité z-index** : entier libre — les entités longues priment (`Saint-Jean-de-Luz` > `Jean`)
|
||||
- Format JSON documenté dans `SPECS.md §5`
|
||||
|
||||
### Dictionnaires de pseudonymisation
|
||||
|
||||
Les dictionnaires fournissent des **candidats de remplacement** et alimentent la **détection automatique** de termes à pseudonymiser. Ils sont hébergés dans un dépôt dédié et téléchargés dans votre vault — aucun texte de transcription ne quitte Obsidian.
|
||||
|
||||
**Installation depuis le catalogue :**
|
||||
1. Wizard → étape Dictionnaires → choisir dans le catalogue en ligne
|
||||
2. Ou : Paramètres → Reconfigurer → étape Dictionnaires
|
||||
|
||||
**Utilisation :**
|
||||
1. Panneau latéral → onglet **Dictionnaires**
|
||||
2. Cocher les dictionnaires à activer pour le scan
|
||||
3. Bouton **Scanner le fichier actif** (ou le bouton loupe sur chaque card pour un dictionnaire seul)
|
||||
4. Une modale de révision s'ouvre : chaque entité détectée est présentée avec son extrait de contexte, sa classe proposée et son remplacement calculé automatiquement (`Ville_1`, `Métropole_2`…)
|
||||
5. Décocher les faux positifs · modifier le préfixe si besoin · **Créer les règles**
|
||||
|
||||
**Dictionnaire disponible — Communes françaises (GeoAPI INSEE) :**
|
||||
- 34 957 communes françaises, 5 classes : Village · Petite_Ville · Ville · Grande_Ville · Métropole
|
||||
- Rôles : détection + remplacement par classe avec index d'incrémentation
|
||||
- Portée recommandée : fichier (chaque fichier reçoit sa propre numérotation)
|
||||
|
||||
> Dépôt dédié : [core-hn/pseudobsidian-dictionaries](https://github.com/core-hn/pseudobsidian-dictionaries) — contributions bienvenues.
|
||||
|
||||
### Sécurité et confidentialité
|
||||
|
||||
- Tout traitement est **local** — aucun texte de transcription n'est envoyé à un serveur externe
|
||||
- Le modèle NER s'exécute dans Obsidian via WASM, sans appel réseau
|
||||
- **Exception documentée :** "Pseudonymiser avec Coulmont" propose des *prénoms de remplacement* à partir d'une requête du *prénom à pseudonymiser* (pas le contenu de la transcription) à l'outil `coulmont.com/bac`. Sur son site web, B. Coulmont précise que "recherches ne sont pas enregistrées".
|
||||
- Les tables de correspondance ne sont jamais incluses dans les exports pseudonymisés.
|
||||
|
||||
---
|
||||
|
||||
## Pour les développeurs
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.huma-num.fr/aabbadie/pseudobsidian-ization.git
|
||||
cd pseudobsidian-ization
|
||||
npm install
|
||||
npm run dev # build en mode watch
|
||||
npm test # suite de tests Jest
|
||||
npm run build # build de production
|
||||
npm run deploy # build + copie dans test_vault/
|
||||
```
|
||||
|
||||
Structure du dépôt :
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Point d'entrée Obsidian
|
||||
├── settings.ts # Paramètres persistants
|
||||
├── types.ts # Types partagés
|
||||
├── parsers/ # SrtParser, ChatParser, TranscriptConverter
|
||||
├── mappings/ # MappingStore, ScopeResolver
|
||||
├── pseudonymizer/ # Moteur, ReplacementPlanner, SpanProtector
|
||||
├── scanner/ # OccurrenceScanner, OnnxNerScanner
|
||||
└── ui/ # PseudonymizationView, modales, surlignage CM6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Statut — v0.1.x
|
||||
|
||||
| Phase | Statut | Description |
|
||||
|---|---|---|
|
||||
| 0–6 | ✅ | Parsers · Moteur · Commandes · Portées · Surlignage · Validation |
|
||||
| 7 — Coulmont | ✅ | Suggestions de prénoms équivalents · Import JSON/CSV |
|
||||
| 8 — Panneau latéral | ✅ | 3 onglets · NER embarqué · Wizard · Annulation · Surlignage export |
|
||||
| 9 — Dictionnaires structurés | 🔄 | Format v1.1 · DictionaryLoader · Scan par dictionnaire · Modale de révision · Communes françaises |
|
||||
| 10 — Affinage | ⏳ | Stabilisation v0.2.0 |
|
||||
| 11 — Fonctions EMCA | ⏳ | Navigation tours · Correction Jefferson/ICOR · Export ELAN |
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) pour le détail des phases et les features envisagées.
|
||||
|
||||
---
|
||||
|
||||
## Contribuer
|
||||
|
||||
Les contributions sont les bienvenues, en particulier :
|
||||
|
||||
- **Dictionnaires** : listes de prénoms, toponymes, institutions pour des corpus spécifiques (langues régionales, terrains non francophones, périodes historiques)
|
||||
- **Conventions de transcription** : parsers pour d'autres systèmes (ELAN, Praat TextGrid, EXMARaLDA)
|
||||
- **Retours d'usage** : issues pour signaler des cas limites rencontrés sur de vrais corpus
|
||||
|
||||
Merci d'ouvrir une issue avant de proposer une pull request pour les fonctionnalités importantes.
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
GPL 3.0
|
||||
|
||||
### Code
|
||||
|
||||
*The Beerware License* (Revision 42)
|
||||
|
||||
```
|
||||
Axelle Abbadie a conçu ce code. Vous pouvez faire ce que vous voulez avec,
|
||||
tant que vous conservez cette notice. Si on se croise un jour et que vous
|
||||
pensez que ça valait le coup, vous pouvez m'offrir une bière.
|
||||
```
|
||||
|
||||
**Ce plugin est fait pour être modifié.** Si votre terrain implique des conventions de transcription particulières, un dialecte régional, des corpus multilingues ou des formats d'export spécifiques à votre institution, adaptez le code à vos besoins.
|
||||
|
||||
---
|
||||
|
||||
## Crédits
|
||||
|
||||
<!-- Suggestions — à compléter par l'auteure : -->
|
||||
### Propriété intellectuelle
|
||||
- **Axelle Abbadie** — conception, spécifications, direction du développement, recherche UX. ([cvHAL](https://cv.hal.science/axelle-abbadie))
|
||||
- Vibe-coding avec **Claude Sonnet 4.6** (Anthropic)
|
||||
|
||||
### Inspiration
|
||||
- **Sonal pi** — À la suite d'une rencontre avec [Maxime Beligné](https://umr5600.cnrs.fr/fr/lequipe/name/max-beligne/) au cours de [la journée "Pseudonymiser, Anonymiser ?" organisée par la MSH-Sud](https://www.mshsud.org/agenda/anonymiser-pseudonymiser/). Lien vers le logiciel : [Sonal-pi](https://www.sonal-info.com/), développé par depuis 2008 par Alex Alber.
|
||||
|
||||
### Travaux valorisés
|
||||
- **Baptiste Coulmont** — outil de pseudonymisation ([coulmont.com/bac](https://coulmont.com/bac)) utilisé pour la suggestion de prénoms.
|
||||
- **Stefan Schweter** (Bayerische Staatsbibliothek) — modèle NER multilingue [`bert-base-multilingual-cased-ner-hrl`](https://huggingface.co/stefan-it/bert-base-multilingual-cased-ner-hrl), utilisé pour la détection automatique des entités nommées.
|
||||
- **Joshua Lochner / Xenova** — conversion ONNX du modèle et bibliothèque [`transformers.js`](https://github.com/xenova/transformers.js), qui permettent l'exécution locale dans Obsidian sans dépendance Python.
|
||||
275
README.md
275
README.md
|
|
@ -1,216 +1,224 @@
|
|||
# Pseudonymizer Tool
|
||||
alias `PseudObsidian-ization` pour les intimes.
|
||||
|
||||
Plugin Obsidian de **pseudonymisation et de correction de transcriptions** pour chercheurs en sciences du langage. Il comble un manque : les outils existants (Sonal, Whispurge) ne s'intègrent pas dans un environnement de prise de notes et d'analyse, et peu de logiciels acceptent les conventions de transcription multimodales (Jefferson, ICOR).
|
||||
An Obsidian plugin for **pseudonymizing and correcting interactional transcripts** for researchers in linguistics and conversation analysis. It fills a gap: existing tools (Sonal, Whispurge) do not integrate into a note-taking and analysis environment, and few applications support multimodal transcription conventions (Jefferson, ICOR).
|
||||
|
||||
> **English summary** — An Obsidian plugin for pseudonymizing and correcting interactional transcripts (Jefferson / ICOR conventions, .srt, .cha formats). Designed for qualitative researchers in linguistics and conversation analysis. See [SPECS.md](SPECS.md) for full technical specifications.
|
||||
> **Français** — [README.fr.md](README.fr.md)
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Transcription brute (.srt, .cha, .md, .txt)
|
||||
↓ import automatique
|
||||
Obsidian — édition native Markdown
|
||||
↓ pseudonymisation (manuelle ou NER)
|
||||
Fichier source annoté + table de correspondance (séparée)
|
||||
Raw transcript (.srt, .cha, .md, .txt)
|
||||
↓ automatic import
|
||||
Obsidian — native Markdown editing
|
||||
↓ pseudonymization (manual or NER)
|
||||
Annotated source file + correspondence table (separate)
|
||||
↓ export
|
||||
Transcription pseudonymisée (.pseudonymized.*)
|
||||
Pseudonymized transcript (.pseudonymized.*)
|
||||
```
|
||||
|
||||
Deux approches, combinables librement :
|
||||
Two approaches, freely combined:
|
||||
|
||||
- **Manuelle** — clic droit sur un terme → choisir un pseudonyme → le plugin remplace et enregistre la règle
|
||||
- **Automatique (NER)** — un modèle d'IA détecte les entités identifiantes et les surligne en bleu dans l'éditeur → le chercheur valide ou rejette chaque candidat par clic droit
|
||||
- **Manual** — right-click a term → choose a pseudonym → the plugin replaces it and saves the rule
|
||||
- **Automatic (NER)** — an AI model detects identifying entities and highlights them in blue in the editor → the researcher validates or rejects each candidate
|
||||
|
||||
Les pseudonymes sont encadrés de marqueurs `{{...}}` dans le fichier source et l'export pour rester visuellement distincts des données brutes. Les tables de correspondance ne sont jamais incluses dans les exports.
|
||||
Pseudonyms are wrapped in `{{...}}` markers in the source file and export to remain visually distinct from raw data. Correspondence tables are never included in exports.
|
||||
|
||||
---
|
||||
|
||||
## Prise en main
|
||||
## Getting started
|
||||
|
||||
### Premier lancement
|
||||
### First launch
|
||||
|
||||
Au premier chargement, un assistant de configuration s'ouvre automatiquement en trois étapes :
|
||||
A setup wizard opens automatically on first load:
|
||||
|
||||
1. **Bienvenue** — présentation du plugin
|
||||
2. **Détection NER** — choisir d'activer le modèle IA local (téléchargement WASM ~19 Mo) ou de travailler uniquement avec des règles manuelles
|
||||
3. **Dictionnaires** — importer des fichiers `.dict.json` existants si vous avez déjà des listes de candidats de remplacement
|
||||
1. **Welcome** — plugin overview
|
||||
2. **Language** — choose the interface language
|
||||
3. **Storage** — configure vault folders (we recommend one vault per corpus)
|
||||
4. **NER detection** — enable the local AI model (WASM download ~19 MB) or work with manual rules only
|
||||
5. **Dictionaries** — install replacement candidate dictionaries from the online catalogue
|
||||
|
||||
L'assistant est relançable à tout moment : Paramètres → Pseudonymizer tool → Reconfigurer.
|
||||
The wizard can be relaunched at any time: Settings → Pseudonymizer Tool → Setup wizard.
|
||||
|
||||
### Formats pris en charge
|
||||
### Supported formats
|
||||
|
||||
| Format | Extension | Notes |
|
||||
|---|---|---|
|
||||
| Sous-titres horodatés | `.srt` | Sortie Whisper / IA — horodatages et structure préservés |
|
||||
| CHAT / CLAN | `.cha`, `.chat` | Lignes `@`, `*`, `%` préservées |
|
||||
| Markdown annoté | `.md` | Conventions Jefferson ou ICOR |
|
||||
| Texte brut | `.txt` | Sans marqueurs de convention |
|
||||
| Timestamped subtitles | `.srt` | Whisper / AI output — timestamps and structure preserved |
|
||||
| CHAT / CLAN | `.cha`, `.chat` | `@`, `*`, `%` lines preserved |
|
||||
| Annotated Markdown | `.md` | Jefferson or ICOR conventions |
|
||||
| Plain text | `.txt` | No convention markers |
|
||||
|
||||
Les fichiers `.srt` et `.cha` sont automatiquement convertis en Markdown à l'import. Un fichier de mapping JSON vide est créé en même temps.
|
||||
`.srt` and `.cha` files are automatically converted to Markdown on import. An empty mapping JSON file is created at the same time.
|
||||
|
||||
> Installez le plugin [Data Files Editor](https://github.com/zuktol/obsidian-data-files-editor) pour visionner les fichiers de mapping JSON directement depuis votre vault.
|
||||
> Install the [Data Files Editor](https://github.com/zuktol/obsidian-data-files-editor) plugin to view mapping JSON files directly in your vault.
|
||||
|
||||
### Installation
|
||||
|
||||
**Via le répertoire communautaire Obsidian** (validation en cours) :
|
||||
1. Obsidian → Paramètres → Extensions communautaires → Parcourir → "Pseudonymizer Tool"
|
||||
**Via the Obsidian community plugins directory** (under review):
|
||||
1. Obsidian → Settings → Community plugins → Browse → "Pseudonymizer Tool"
|
||||
|
||||
**Installation manuelle** depuis la [dernière release GitHub](https://github.com/core-hn/pseudobsidian-ization/releases) :
|
||||
1. Télécharger `main.js`, `manifest.json`, `styles.css`
|
||||
2. Copier dans `.obsidian/plugins/pseudonymizer-tool/` de votre vault
|
||||
3. Activer dans Paramètres → Extensions communautaires
|
||||
**Manual installation** from the [latest GitHub release](https://github.com/core-hn/pseudobsidian-ization/releases):
|
||||
1. Download `main.js`, `manifest.json`, `styles.css`
|
||||
2. Copy into `.obsidian/plugins/pseudonymizer-tool/` in your vault
|
||||
3. Enable in Settings → Community plugins
|
||||
|
||||
> Les fichiers WASM (NER) sont téléchargés automatiquement par l'assistant au premier lancement si vous activez la détection automatique. Vous pouvez aussi les télécharger manuellement depuis la release.
|
||||
> WASM files (NER) are downloaded automatically by the wizard on first launch if you enable automatic detection. They can also be downloaded manually from the release.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
## Features
|
||||
|
||||
### Pseudonymisation manuelle
|
||||
### Manual pseudonymization
|
||||
|
||||
Toutes les actions sont disponibles via le **clic droit** sur une sélection dans l'éditeur :
|
||||
All actions are available via **right-click** on a selection in the editor:
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| **Pseudonymiser** | Remplace le terme (cette occurrence ou toutes) avec marqueurs `{{...}}` |
|
||||
| **Pseudonymiser avec Pr Baptiste Coulmont** | Interroge [coulmont.com](https://coulmont.com) — propose des prénoms sociologiquement équivalents (même milieu social, même décennie) |
|
||||
| **Créer une règle** | Enregistre la correspondance dans le mapping JSON sans modifier le texte |
|
||||
| **Modifier la règle** | Modifie une règle existante (disponible sur les termes en orange ou en vert) |
|
||||
| **Annuler la pseudonymisation** | Restaure le terme original pour cette occurrence (disponible sur les termes en vert) |
|
||||
| **Pseudonymize** | Replace the term (this occurrence or all) with `{{...}}` markers |
|
||||
| **Pseudonymize with Prof. Baptiste Coulmont** | Queries [coulmont.com](https://coulmont.com) — suggests sociologically equivalent first names (same social background, same decade) |
|
||||
| **Create a rule** | Saves the correspondence in the mapping JSON without modifying the text |
|
||||
| **Edit rule** | Modifies an existing rule (available on orange or green highlighted terms) |
|
||||
| **Cancel pseudonymization** | Restores the original term for this occurrence (available on green terms) |
|
||||
|
||||
### Détection automatique par NER
|
||||
### Automatic NER detection
|
||||
|
||||
Le moteur de **reconnaissance d'entités nommées** détecte prénoms, noms, lieux et institutions sans liste préexistante, en exploitant le contexte syntaxique. Contrairement à une approche par dictionnaire, il distingue "Florence" la personne de "Florence" la ville. Les sous-termes d'une entité composée connue sont filtrés automatiquement (si "Saint-Jean-de-Luz" est une règle, "Jean" et "Luz" ne remontent pas comme candidats NER).
|
||||
The **named entity recognition** engine detects first names, surnames, places and institutions without a pre-existing list, using syntactic context. Unlike a dictionary approach, it distinguishes "Florence" the person from "Florence" the city. Sub-terms of a known compound entity are automatically filtered (if "Saint-Jean-de-Luz" is a rule, "Jean" and "Luz" do not appear as NER candidates).
|
||||
|
||||
> Pour comprendre ce que sont les modèles NER : [blog Vaniila](https://blog.vaniila.ai/NER/).
|
||||
**Usage:**
|
||||
1. Side panel → **NER** tab → **Identify candidates**
|
||||
2. Entities appear **highlighted in blue** in the editor
|
||||
3. Right-click a blue term → **Pseudonymize** or **Create a rule**
|
||||
|
||||
**Utilisation :**
|
||||
1. Panneau latéral → onglet **Candidats** → **Identifier des candidats**
|
||||
2. Les entités apparaissent **surlignées en bleu** dans l'éditeur
|
||||
3. Clic droit sur un terme bleu → **Pseudonymiser** ou **Créer une règle**
|
||||
**Model:** `Xenova/bert-base-multilingual-cased-ner-hrl` via `transformers.js`. 100% local execution. One-time downloads on first use: WASM (~19 MB) + NER model (~66 MB). **Works offline after the first download.**
|
||||
|
||||
**Modèle :** `Xenova/bert-base-multilingual-cased-ner-hrl` via `transformers.js`. Exécution 100 % locale. Téléchargements uniques au premier usage : WASM (~19 Mo) + modèle NER (~66 Mo). **Fonctionnement hors-ligne après le premier téléchargement.**
|
||||
**Settings (NER tab in the panel):**
|
||||
- **Confidence threshold** (0.50–1.00): increasing it reduces false positives
|
||||
- **Excluded function words**: editable list of tokens to always ignore ("de", "du", "la"…)
|
||||
|
||||
**Paramètres (onglet NER du panneau)** :
|
||||
- **Seuil de confiance** (0,50–1,00) : augmenter réduit les faux positifs
|
||||
- **Mots fonctionnels exclus** : liste éditable des tokens à toujours ignorer ("de", "du", "la"…)
|
||||
### Side panel
|
||||
|
||||
### Panneau latéral
|
||||
Accessible via the ribbon icon or `Ctrl+P → Pseudonymization: open panel`.
|
||||
|
||||
Accessible via l'icône dans le ruban ou `Ctrl+P → Pseudonymisation : ouvrir le panneau`.
|
||||
|
||||
| Onglet | Contenu |
|
||||
| Tab | Content |
|
||||
|---|---|
|
||||
| **Mappings** | Règles actives · Modifier · Supprimer · Ajouter · Scanner le fichier |
|
||||
| **Dictionnaires** | Mini cards · Scan par dictionnaire · Import local |
|
||||
| **Exports** | Pseudonymiser et exporter · Exporter la table de correspondance |
|
||||
| **NER** | Visible si NER activé · Identifier des candidats · Seuil de confiance · Mots fonctionnels |
|
||||
| **Mappings** | Active rules · Edit · Delete · Add · Scan file |
|
||||
| **Dictionaries** | Mini cards · Dictionary scan · Local import |
|
||||
| **Exports** | Pseudonymize and export · Export correspondence table |
|
||||
| **NER** | Visible if NER enabled · Identify candidates · Confidence threshold · Function words |
|
||||
|
||||
### Surlignage et marqueurs
|
||||
### Highlighting and markers
|
||||
|
||||
Le surlignage est actif dans tout fichier ouvert, y compris les fichiers export `.pseudonymized.*` qui héritent automatiquement des règles du fichier source.
|
||||
Highlighting is active in all open files, including `.pseudonymized.*` export files, which automatically inherit rules from the source file.
|
||||
|
||||
| Couleur | Signification |
|
||||
| Colour | Meaning |
|
||||
|---|---|
|
||||
| 🟠 Orange + contour | Terme source encore présent — à pseudonymiser |
|
||||
| 🟢 Vert + souligné | Pseudonyme appliqué en direct dans le fichier |
|
||||
| 🔵 Bleu + contour | Candidat NER — pas encore de règle |
|
||||
| 🟠 Orange + outline | Source term still present — to be pseudonymized |
|
||||
| 🟢 Green + underline | Pseudonym applied directly in the file |
|
||||
| 🔵 Blue + outline | NER candidate — no rule yet |
|
||||
|
||||
Dans les fichiers exportés, les pseudonymes sont encadrés de marqueurs `{{Pierre}}` pour les distinguer des données brutes (activé par défaut, configurable dans les paramètres).
|
||||
In exported files, pseudonyms are wrapped in `{{Pierre}}` markers to distinguish them from raw data (enabled by default, configurable in settings).
|
||||
|
||||
### Tables de correspondance
|
||||
### Correspondence tables
|
||||
|
||||
- Trois niveaux de portée : `fichier.mapping.json` · `dossier.mapping.json` · `vault.mapping.json`
|
||||
- Statuts par occurrence : `validated`, `ignored`, `partial`, `conflict`, `needs_review`
|
||||
- **Priorité z-index** : entier libre — les entités longues priment (`Saint-Jean-de-Luz` > `Jean`)
|
||||
- Format JSON documenté dans `SPECS.md §5`
|
||||
- Three scope levels: `file.mapping.json` · `folder.mapping.json` · `vault.mapping.json`
|
||||
- Per-occurrence statuses: `validated`, `ignored`, `partial`, `conflict`, `needs_review`
|
||||
- **Z-index priority**: free integer — longer entities take precedence (`Saint-Jean-de-Luz` > `Jean`)
|
||||
- JSON format documented in `SPECS.md §5`
|
||||
|
||||
### Dictionnaires de pseudonymisation
|
||||
### Pseudonymization dictionaries
|
||||
|
||||
Les dictionnaires fournissent des **candidats de remplacement** et alimentent la **détection automatique** de termes à pseudonymiser. Ils sont hébergés dans un dépôt dédié et téléchargés dans votre vault — aucun texte de transcription ne quitte Obsidian.
|
||||
Dictionaries provide **replacement candidates** and feed **automatic detection**. They are hosted in a dedicated repository and downloaded into your vault — no transcript text ever leaves Obsidian.
|
||||
|
||||
**Installation depuis le catalogue :**
|
||||
1. Wizard → étape Dictionnaires → choisir dans le catalogue en ligne
|
||||
2. Ou : Paramètres → Reconfigurer → étape Dictionnaires
|
||||
**Installing from the catalogue:**
|
||||
1. Wizard → Dictionaries step → choose from the online catalogue
|
||||
2. Or: Settings → Setup wizard → Dictionaries step
|
||||
|
||||
**Utilisation :**
|
||||
1. Panneau latéral → onglet **Dictionnaires**
|
||||
2. Cocher les dictionnaires à activer pour le scan
|
||||
3. Bouton **Scanner le fichier actif** (ou le bouton loupe sur chaque card pour un dictionnaire seul)
|
||||
4. Une modale de révision s'ouvre : chaque entité détectée est présentée avec son extrait de contexte, sa classe proposée et son remplacement calculé automatiquement (`Ville_1`, `Métropole_2`…)
|
||||
5. Décocher les faux positifs · modifier le préfixe si besoin · **Créer les règles**
|
||||
**Usage:**
|
||||
1. Side panel → **Dictionaries** tab
|
||||
2. Check the dictionaries to activate for the scan
|
||||
3. Click **Scan active file** (or the magnifier button on each card for a single dictionary)
|
||||
4. A review modal opens: each detected entity is shown with its context excerpt, proposed class and automatically computed replacement (`Ville_1`, `Métropole_2`…)
|
||||
5. Uncheck false positives · edit the prefix if needed · **Create rules**
|
||||
|
||||
**Dictionnaire disponible — Communes françaises (GeoAPI INSEE) :**
|
||||
- 34 957 communes françaises, 5 classes : Village · Petite_Ville · Ville · Grande_Ville · Métropole
|
||||
- Rôles : détection + remplacement par classe avec index d'incrémentation
|
||||
- Portée recommandée : fichier (chaque fichier reçoit sa propre numérotation)
|
||||
**Available dictionary — French communes (GeoAPI INSEE):**
|
||||
- 34,957 French communes, 5 classes: Village · Petite_Ville · Ville · Grande_Ville · Métropole
|
||||
- Roles: detection + class-based replacement with incremental index
|
||||
- Recommended scope: file (each file gets its own numbering)
|
||||
|
||||
> Dépôt dédié : [core-hn/pseudobsidian-dictionaries](https://github.com/core-hn/pseudobsidian-dictionaries) — contributions bienvenues.
|
||||
> Dedicated repository: [core-hn/pseudobsidian-dictionaries](https://github.com/core-hn/pseudobsidian-dictionaries) — contributions welcome.
|
||||
|
||||
### Sécurité et confidentialité
|
||||
### Corpus organization
|
||||
|
||||
- Tout traitement est **local** — aucun texte de transcription n'est envoyé à un serveur externe
|
||||
- Le modèle NER s'exécute dans Obsidian via WASM, sans appel réseau
|
||||
- **Exception documentée :** "Pseudonymiser avec Coulmont" propose des *prénoms de remplacement* à partir d'une requête du *prénom à pseudonymiser* (pas le contenu de la transcription) à l'outil `coulmont.com/bac`. Sur son site web, B. Coulmont précise que "recherches ne sont pas enregistrées".
|
||||
- Les tables de correspondance ne sont jamais incluses dans les exports pseudonymisés.
|
||||
The plugin helps you structure your corpus in folders before starting work. Use the command **Organize corpus** (Ctrl+P) to define classes (sub-folders). Each class automatically creates mirrored folders for transcriptions, mapping tables and exports. When adding a transcription, you are prompted to select a class.
|
||||
|
||||
> We recommend **one Obsidian vault per corpus**. This keeps mapping files, dictionaries and exports together with their transcriptions, and makes archiving or sharing a corpus straightforward.
|
||||
|
||||
### Privacy and security
|
||||
|
||||
- All processing is **local** — no transcript text is sent to an external server
|
||||
- The NER model runs in Obsidian via WASM, without any network call
|
||||
- **Documented exception:** "Pseudonymize with Coulmont" sends the *source first name* (not transcript content) to `coulmont.com/bac` to suggest equivalent names. B. Coulmont states on his website that searches are not logged.
|
||||
- Correspondence tables are never included in pseudonymized exports.
|
||||
|
||||
---
|
||||
|
||||
## Pour les développeurs
|
||||
## For developers
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.huma-num.fr/aabbadie/pseudobsidian-ization.git
|
||||
cd pseudobsidian-ization
|
||||
npm install
|
||||
npm run dev # build en mode watch
|
||||
npm test # suite de tests Jest
|
||||
npm run build # build de production
|
||||
npm run deploy # build + copie dans test_vault/
|
||||
npm run dev # watch build (esbuild)
|
||||
npm test # Jest test suite
|
||||
npm run build # production build
|
||||
npm run deploy # build + copy to test_vault/
|
||||
npm run build:cities # regenerate assets/cities.dict.json from GeoAPI INSEE
|
||||
```
|
||||
|
||||
Structure du dépôt :
|
||||
Repository structure:
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Point d'entrée Obsidian
|
||||
├── settings.ts # Paramètres persistants
|
||||
├── types.ts # Types partagés
|
||||
├── main.ts # Obsidian entry point
|
||||
├── settings.ts # Persistent settings
|
||||
├── types.ts # Shared types
|
||||
├── i18n/ # Internationalization (en, fr)
|
||||
├── parsers/ # SrtParser, ChatParser, TranscriptConverter
|
||||
├── mappings/ # MappingStore, ScopeResolver
|
||||
├── pseudonymizer/ # Moteur, ReplacementPlanner, SpanProtector
|
||||
├── pseudonymizer/ # Engine, ReplacementPlanner, SpanProtector
|
||||
├── scanner/ # OccurrenceScanner, OnnxNerScanner
|
||||
└── ui/ # PseudonymizationView, modales, surlignage CM6
|
||||
├── dictionaries/ # DictionaryLoader
|
||||
└── ui/ # PseudonymizationView, modals, CM6 highlighting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Statut — v0.1.x
|
||||
## Status — v0.1.x
|
||||
|
||||
| Phase | Statut | Description |
|
||||
| Phase | Status | Description |
|
||||
|---|---|---|
|
||||
| 0–6 | ✅ | Parsers · Moteur · Commandes · Portées · Surlignage · Validation |
|
||||
| 7 — Coulmont | ✅ | Suggestions de prénoms équivalents · Import JSON/CSV |
|
||||
| 8 — Panneau latéral | ✅ | 3 onglets · NER embarqué · Wizard · Annulation · Surlignage export |
|
||||
| 9 — Dictionnaires structurés | 🔄 | Format v1.1 · DictionaryLoader · Scan par dictionnaire · Modale de révision · Communes françaises |
|
||||
| 10 — Affinage | ⏳ | Stabilisation v0.2.0 |
|
||||
| 11 — Fonctions EMCA | ⏳ | Navigation tours · Correction Jefferson/ICOR · Export ELAN |
|
||||
| 0–6 | ✅ | Parsers · Engine · Commands · Scopes · Highlighting · Validation |
|
||||
| 7 — Coulmont | ✅ | Equivalent first name suggestions · JSON/CSV import |
|
||||
| 8 — Side panel | ✅ | 3 tabs · Embedded NER · Wizard · Cancellation · Export highlighting |
|
||||
| 9 — Structured dictionaries | ✅ | Format v1.1 · DictionaryLoader · Dictionary scan · Review modal · French communes |
|
||||
| 10 — Refinement | 🔄 | i18n · Corpus organization · Settings redesign · Phase in progress |
|
||||
| 11 — EMCA functions | ⏳ | Turn navigation · Jefferson/ICOR correction · ELAN export |
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) pour le détail des phases et les features envisagées.
|
||||
See [ROADMAP.md](ROADMAP.md) for the full phase breakdown and planned features.
|
||||
|
||||
---
|
||||
|
||||
## Contribuer
|
||||
## Contributing
|
||||
|
||||
Les contributions sont les bienvenues, en particulier :
|
||||
Contributions are welcome, particularly:
|
||||
|
||||
- **Dictionnaires** : listes de prénoms, toponymes, institutions pour des corpus spécifiques (langues régionales, terrains non francophones, périodes historiques)
|
||||
- **Conventions de transcription** : parsers pour d'autres systèmes (ELAN, Praat TextGrid, EXMARaLDA)
|
||||
- **Retours d'usage** : issues pour signaler des cas limites rencontrés sur de vrais corpus
|
||||
- **Dictionaries**: first name lists, place names, institutions for specific corpora (regional languages, non-French fields, historical periods) — see [core-hn/pseudobsidian-dictionaries](https://github.com/core-hn/pseudobsidian-dictionaries)
|
||||
- **Transcription conventions**: parsers for other systems (ELAN, Praat TextGrid, EXMARaLDA)
|
||||
- **Usage feedback**: issues to report edge cases encountered on real corpora
|
||||
|
||||
Merci d'ouvrir une issue avant de proposer une pull request pour les fonctionnalités importantes.
|
||||
Please open an issue before submitting a pull request for significant features.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -223,26 +231,25 @@ GPL 3.0
|
|||
*The Beerware License* (Revision 42)
|
||||
|
||||
```
|
||||
Axelle Abbadie a conçu ce code. Vous pouvez faire ce que vous voulez avec,
|
||||
tant que vous conservez cette notice. Si on se croise un jour et que vous
|
||||
pensez que ça valait le coup, vous pouvez m'offrir une bière.
|
||||
Axelle Abbadie wrote this code. You can do whatever you want with it
|
||||
as long as you keep this notice. If we meet someday, and you think
|
||||
it was worth it, you can buy me a beer.
|
||||
```
|
||||
|
||||
**Ce plugin est fait pour être modifié.** Si votre terrain implique des conventions de transcription particulières, un dialecte régional, des corpus multilingues ou des formats d'export spécifiques à votre institution, adaptez le code à vos besoins.
|
||||
**This plugin is made to be modified.** If your fieldwork involves particular transcription conventions, a regional dialect, multilingual corpora or institution-specific export formats, adapt the code to your needs.
|
||||
|
||||
---
|
||||
|
||||
## Crédits
|
||||
## Credits
|
||||
|
||||
<!-- Suggestions — à compléter par l'auteure : -->
|
||||
### Propriété intellectuelle
|
||||
- **Axelle Abbadie** — conception, spécifications, direction du développement, recherche UX. ([cvHAL](https://cv.hal.science/axelle-abbadie))
|
||||
- Vibe-coding avec **Claude Sonnet 4.6** (Anthropic)
|
||||
### Intellectual property
|
||||
- **Axelle Abbadie** — design, specifications, development direction, UX research. ([cvHAL](https://cv.hal.science/axelle-abbadie))
|
||||
- Vibe-coded with **Claude Sonnet 4.6** (Anthropic)
|
||||
|
||||
### Inspiration
|
||||
- **Sonal pi** — À la suite d'une rencontre avec [Maxime Beligné](https://umr5600.cnrs.fr/fr/lequipe/name/max-beligne/) au cours de [la journée "Pseudonymiser, Anonymiser ?" organisée par la MSH-Sud](https://www.mshsud.org/agenda/anonymiser-pseudonymiser/). Lien vers le logiciel : [Sonal-pi](https://www.sonal-info.com/), développé par depuis 2008 par Alex Alber.
|
||||
- **Sonal pi** — Following a meeting with [Maxime Beligné](https://umr5600.cnrs.fr/fr/lequipe/name/max-beligne/) at the ["Pseudonymiser, Anonymiser?" day organized by MSH-Sud](https://www.mshsud.org/agenda/anonymiser-pseudonymiser/). Software: [Sonal-pi](https://www.sonal-info.com/), developed since 2008 by Alex Alber.
|
||||
|
||||
### Travaux valorisés
|
||||
- **Baptiste Coulmont** — outil de pseudonymisation ([coulmont.com/bac](https://coulmont.com/bac)) utilisé pour la suggestion de prénoms.
|
||||
- **Stefan Schweter** (Bayerische Staatsbibliothek) — modèle NER multilingue [`bert-base-multilingual-cased-ner-hrl`](https://huggingface.co/stefan-it/bert-base-multilingual-cased-ner-hrl), utilisé pour la détection automatique des entités nommées.
|
||||
- **Joshua Lochner / Xenova** — conversion ONNX du modèle et bibliothèque [`transformers.js`](https://github.com/xenova/transformers.js), qui permettent l'exécution locale dans Obsidian sans dépendance Python.
|
||||
### Acknowledged work
|
||||
- **Baptiste Coulmont** — pseudonymization tool ([coulmont.com/bac](https://coulmont.com/bac)) used for first name suggestions.
|
||||
- **Stefan Schweter** (Bayerische Staatsbibliothek) — multilingual NER model [`bert-base-multilingual-cased-ner-hrl`](https://huggingface.co/stefan-it/bert-base-multilingual-cased-ner-hrl), used for automatic entity detection.
|
||||
- **Joshua Lochner / Xenova** — ONNX model conversion and [`transformers.js`](https://github.com/xenova/transformers.js) library, enabling local execution in Obsidian without a Python dependency.
|
||||
|
|
|
|||
281
ROADMAP.fr.md
Normal file
281
ROADMAP.fr.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# ROADMAP — PseudObsidian-ization
|
||||
|
||||
Plugin Obsidian de pseudonymisation et correction de transcriptions (conventions Jefferson / ICOR / SRT / CHAT).
|
||||
|
||||
Chaque phase produit quelque chose de testable. Les critères d'acceptation renvoient aux sections de SPECS.md.
|
||||
|
||||
## État actuel (mai 2026) — v0.1.0
|
||||
|
||||
**Phases 0 à 8 terminées.** La Phase 9 (dictionnaires de remplacement pour lieux/institutions) est la prochaine cible.
|
||||
|
||||
Décision architecturale adoptée en mai 2026 : la **détection des entités identifiantes** repose sur du **NER** (`transformers.js` + `bert-base-multilingual-cased-ner-hrl`) plutôt que sur des dictionnaires lexicaux exhaustifs. Les dictionnaires restent des ressources de **remplacement** (candidats de substitution), pas de détection.
|
||||
|
||||
```
|
||||
✅ Phases 0–8 Parsers · Moteur · UI · Portées · Surlignage · Validation · Coulmont · Panneau · NER · Wizard
|
||||
🔄 Phase 9 Dictionnaires de remplacement (lieux/institutions) (v0.1.0)
|
||||
⏳ Phase 10 Affinage et stabilisation (v0.2.0)
|
||||
⏳ Phase 11 Fonctions d'analyse interactionnelle et conversationnelle (v1.0.0)
|
||||
```
|
||||
|
||||
La publication sur le répertoire communautaire Obsidian se fait **au fil des versions** (PR #12766 en cours de validation).
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 0 — Boilerplate
|
||||
|
||||
- [x] Initialiser le plugin avec le template officiel Obsidian (TypeScript + esbuild)
|
||||
- [x] Configurer ESLint, Prettier, Jest
|
||||
- [x] Définir les types partagés de SPECS.md §11.3 : `ScopeType`, `MappingStatus`, `EntityCategory`, `MappingRule`, `Scope`, `Occurrence`, `ReplacementSpan`
|
||||
- [x] Créer un vault de test avec des fichiers fictifs : `entretien_01.srt`, `entretien_02.cha`, `entretien_03.md`
|
||||
- [x] Mettre en place le dossier `_pseudonymisation/` dans le vault de test (§13.3)
|
||||
|
||||
**Testable :** le plugin se charge dans Obsidian sans erreur.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 1 — Parser SRT
|
||||
|
||||
Objectif : ouvrir un `.srt`, le lire sans l'altérer, identifier les zones textuelles. Priorité haute car c'est le format le plus courant en sortie de Whisper.
|
||||
|
||||
- [ ] Implémenter `parsers/SrtParser.ts` :
|
||||
- Découpage en blocs (numéro / horodatage / texte)
|
||||
- Identification des zones remplaçables (texte uniquement, pas les timestamps)
|
||||
- Représentation interne : tableau de `SrtBlock { index, start, end, lines[] }`
|
||||
- [ ] Tests unitaires sur `entretien_01.srt` fictif
|
||||
- [ ] Vérifier que la reconstruction du fichier à partir de l'AST est identique à l'original (round-trip)
|
||||
|
||||
**Testable — SPECS §17.4 :** après parse + reconstruction, les horodatages et numéros de blocs sont inchangés.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2 — Parser CHAT / CHA
|
||||
|
||||
Objectif : ouvrir un `.cha`, distinguer métadonnées, tours de parole et lignes dépendantes.
|
||||
|
||||
- [ ] Implémenter `parsers/ChatParser.ts` :
|
||||
- Lignes `@` (métadonnées) → préservées telles quelles
|
||||
- Lignes `*LOCUTEUR:` → tour de parole, zone remplaçable
|
||||
- Lignes `%` (dépendantes) → préservées ou traitées séparément selon action explicite
|
||||
- Représentation interne : `ChatLine { type: 'meta'|'turn'|'dependent', speaker?, content }`
|
||||
- [ ] Tests unitaires sur `entretien_02.cha` fictif
|
||||
- [ ] Vérifier le round-trip
|
||||
|
||||
**Testable — SPECS §17.5 :** les lignes `@`, `*` et `%` sont conservées après parse + reconstruction.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 3 — Mapping basique : sélection → règle → application fichier
|
||||
|
||||
Objectif : premier flux complet, portée fichier uniquement. C'est le cœur du MVP v0.1 (SPECS §16.1).
|
||||
|
||||
- [ ] Commande "Créer une règle depuis la sélection" (§10.1) : modale minimale (source, remplacement, catégorie, portée = fichier par défaut)
|
||||
- [ ] `mappings/MappingStore.ts` : lecture / écriture de `entretien_XX.mapping.json` (portée `file`)
|
||||
- [ ] `scanner/OccurrenceScanner.ts` : scan du fichier courant, liste des occurrences avec contexte gauche/droite (§7.2)
|
||||
- [ ] Vue latérale minimale — onglet **Occurrences** : fichier, ligne, contexte, remplacement proposé, bouton valider/ignorer (§10.3)
|
||||
- [ ] `pseudonymizer/ReplacementPlanner.ts` : construit le plan de remplacement (§12.1)
|
||||
- [ ] `pseudonymizer/SpanProtector.ts` : résolution des spans, application droite-à-gauche (§12.4, §12.5)
|
||||
- [ ] Export du fichier pseudonymisé (`nom.pseudonymized.srt` / `.cha` / `.md`) sans table de correspondance (§14.1)
|
||||
- [ ] Export de la table JSON séparément (§14.2)
|
||||
|
||||
**Testable — SPECS §17.1 :** `Bonjour Jean.` → règle Jean → Pierre → export contient `Bonjour Pierre.` et la table JSON contient le mapping.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 4 — Priorité z-index et protection des spans imbriqués
|
||||
|
||||
Objectif : garantir qu'un remplacement court ne s'applique pas à l'intérieur d'un segment long déjà traité.
|
||||
|
||||
- [x] Champ `priority` dans `MappingRule` : entier libre, défaut 0 (comme z-index CSS — §8.4)
|
||||
- [x] `sortRules` : tri par `priority` décroissant, puis longueur source décroissante, puis portée locale (§12.3)
|
||||
- [x] `resolveSpans` : élimination des chevauchements selon ce tri (§12.4)
|
||||
- [x] Tests de non-régression obligatoires (§18.2) : Jean/Saint-Jean-de-Luz, Paul/Saint-Paul, Montpellier/CHU, Marie/Sainte-Marie
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- Commande `Pseudonymisation : ajouter une transcription` (Ctrl+P) : ouvre un sélecteur de fichier natif, importe le fichier dans le vault, initialise un mapping JSON vide, ouvre le fichier
|
||||
- Commande `Pseudonymisation : pseudonymiser le fichier courant` : lit le fichier actif (SRT, CHA/CHAT, MD), charge le mapping JSON correspondant, écrit `[nom].pseudonymized.[ext]` dans `_pseudonymisation/exports/`
|
||||
- Commande `Pseudonymisation : créer une règle` (disponible sur sélection dans l'éditeur) : modale minimale — source, remplacement, catégorie, portée, priority — écrit dans le mapping JSON du fichier courant
|
||||
- Tester manuellement : importer `entretien_01.srt`, créer les règles Jean → Pierre et Saint-Jean-de-Luz → Ville littorale, pseudonymiser, vérifier l'export
|
||||
|
||||
**Testable — SPECS §17.2 :** `Jean habite Saint-Jean-de-Luz.` → `Pierre habite Ville littorale.` visible dans le fichier exporté dans Obsidian.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 5 — Portée dossier et vault + surlignage éditeur
|
||||
|
||||
Objectif : charger automatiquement les trois niveaux de mapping et visualiser dans l'éditeur les termes déjà pseudonymisés.
|
||||
|
||||
- [ ] `mappings/ScopeResolver.ts` : parcourir le dossier `_pseudonymisation/mappings/`, charger tous les fichiers JSON, filtrer et fusionner les règles applicables à un fichier donné (§4, §12.2)
|
||||
- [ ] Cascade de résolution : fichier → dossier le plus proche → vault — géré par le tri `sortRules` existant via `scopeWeight` (§4.4)
|
||||
- [ ] **Surlignage éditeur** : extension CodeMirror 6 (`registerEditorExtension`) qui marque dans le fichier ouvert :
|
||||
- en **orange** : les termes sources (encore à pseudonymiser)
|
||||
- en **vert** : les termes de remplacement (déjà pseudonymisés)
|
||||
- se met à jour à chaque changement de fichier actif
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- La commande "pseudonymiser" utilise ScopeResolver pour charger les trois niveaux automatiquement
|
||||
- Commande `Pseudonymisation : créer une règle au niveau dossier / vault`
|
||||
- Le surlignage apparaît dès qu'un mapping existe pour le fichier ouvert
|
||||
- Tester : une règle vault s'applique à deux entretiens différents ; les termes s'affichent surlignés dans les deux fichiers
|
||||
|
||||
**Testable :** ouvrir un fichier avec des règles de mapping → termes sources en orange, pseudonymes déjà appliqués en vert.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 6 — Validation sélective et statuts
|
||||
|
||||
Objectif : pouvoir remplacer certaines occurrences et en ignorer d'autres.
|
||||
|
||||
- [ ] Statuts par occurrence : `suggested`, `validated`, `ignored`, `partial`, `conflict`, `disabled`, `needs_review` (§5.4)
|
||||
- [ ] Validation occurrence par occurrence, par lot, ou globale (§10.5)
|
||||
- [ ] Mapping au statut `partial` quand certaines occurrences seulement sont remplacées (§17.3)
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- Commande `Pseudonymisation : scanner le fichier courant` → liste les occurrences candidates dans une modale
|
||||
- Chaque occurrence : bouton Valider / Ignorer / Faux positif
|
||||
- Prévisualisation diff par occurrence avant application (§7.4)
|
||||
- Menu contextuel sur sélection : Créer une règle / Pseudonymiser cette occurrence (§10.2)
|
||||
- Tester : trois occurrences de `Jean`, en valider deux, ignorer une → statut `partial` dans le JSON
|
||||
|
||||
**Testable — SPECS §17.3 :** mapping `Jean → Pierre` passe au statut `partial` après validation sélective.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 7 — Prénoms : suggestions Coulmont et import de dictionnaires
|
||||
|
||||
Objectif : générer des suggestions de prénoms sociologiquement équivalents et permettre l'import de dictionnaires existants.
|
||||
|
||||
- [x] Intégration de l'outil Coulmont : appel HTTP → suggestions de prénoms équivalents (genre, décennie, milieu social)
|
||||
- [x] Format interne `DictionaryEntry` avec `gender`, `decade`, `socialClass`, `replacementCandidates[]` (§6.3)
|
||||
- [x] `dictionaries/JsonDictionaryImporter.ts` : import JSON format SPECS §6.3
|
||||
- [x] `dictionaries/CsvDictionaryImporter.ts` : import CSV + mapping des colonnes (§6.4)
|
||||
- [x] `adapters/coulmont.ts` : CSV Coulmont → `DictionaryEntry[]`
|
||||
- [x] `dictionaries/DictionaryManager.ts` : activation / désactivation, portée par dictionnaire (§6.5)
|
||||
- [x] `adapters/insee.ts` : CSV INSEE prénoms → `DictionaryEntry[]`
|
||||
- [x] Dans la modale de création de règle : suggestions Coulmont affichées sous forme de boutons cliquables
|
||||
|
||||
> **Note :** les dictionnaires embarqués massifs (`cities.json`, `lastnames.json`, etc.) sont **dépriorisés** — la détection des entités sera déléguée au NER en Phase 9. Seuls les dictionnaires de **remplacement** (prénoms Coulmont/INSEE) sont maintenus ici.
|
||||
|
||||
**Testable :** sélectionner un prénom dans une transcription → suggestions Coulmont affichées → cliquer pour pré-remplir le champ de remplacement.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 8 — Interface complète (panneau latéral 4 onglets)
|
||||
|
||||
Objectif : interface de travail complète pour le workflow pseudonymisation.
|
||||
|
||||
- [x] Vue latérale 5 onglets : **Candidats / Mappings / Dictionnaires / Exports / NER**
|
||||
- Onglet **Candidats** (ex-Occurrences) : scanner le fichier + identifier des candidats NER · Valider / Ignorer / Faux positif · Appliquer
|
||||
- Onglet **Mappings** : tableau des règles actives — modifier, supprimer, ajouter
|
||||
- Onglet **Dictionnaires** : import de fichiers `.dict.json`
|
||||
- Onglet **Exports** : pseudonymiser + exporter · exporter la table de correspondance
|
||||
- Onglet **NER** : seuil de confiance + mots fonctionnels exclus (visible si NER activé)
|
||||
- [x] Surlignage tri-couleur dans l'éditeur : orange (sources) · vert souligné (remplacements) · bleu (candidats NER)
|
||||
- [x] Surlignage actif dans les fichiers exportés `.pseudonymized.*`
|
||||
- [x] Clic droit → Annuler la pseudonymisation (sur termes verts)
|
||||
- [x] Marqueurs `{{...}}` activés par défaut dans les remplacements en direct et les exports
|
||||
- [x] Wizard onboarding (3 étapes) avec téléchargement WASM et import de dictionnaires
|
||||
- [x] NER embarqué via `transformers.js` + `bert-base-multilingual-cased-ner-hrl`
|
||||
- [x] Filtrage des sous-termes de règles composées (Saint-Jean-de-Luz filtre Jean/Luz en NER)
|
||||
- [ ] `correction/checker.ts` : vérification des conventions Jefferson/ICOR *(reporté Phase 10)*
|
||||
|
||||
**Livré en v0.1.0.**
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Phase 9 — Dictionnaires structurés (v0.1.3)
|
||||
|
||||
Objectif : permettre la détection et le remplacement automatiques des entités identifiantes à partir de dictionnaires locaux, téléchargeables hors-ligne depuis un dépôt dédié.
|
||||
|
||||
**Décision architecturale :** le NER (transformers.js) reste le moteur de détection principal pour les entités contextuelles (prénoms, noms, institutions). Les dictionnaires ajoutent une détection exhaustive par liste pour les types dénombrables (communes, institutions connues) et fournissent les remplacements structurés (classes + index).
|
||||
|
||||
### Tâches
|
||||
|
||||
- [x] `scanner/OnnxNerScanner.ts` : pipeline BERT-NER via `@xenova/transformers`, filtres score et mots fonctionnels
|
||||
- [x] Surlignage bleu des entités détectées dans l'éditeur
|
||||
- [x] Onglet NER dans le panneau latéral : seuil de confiance + mots fonctionnels · bouton "Identifier des candidats"
|
||||
- [x] Wizard : téléchargement WASM + catalogue de dictionnaires depuis le repo dédié
|
||||
- [x] Format `DictionaryFile` v1.1 : `roles`, `configSchema`, `config`, `author`, `doi`
|
||||
- [x] `src/dictionaries/DictionaryLoader.ts` : chargement, index de détection, résolution de classes (conditions / regex / word-to-word), `nextReplacement()`, `scanText()` (fenêtre glissante n-grammes)
|
||||
- [x] Repo dédié [`pseudobsidian-dictionaries`](https://github.com/core-hn/pseudobsidian-dictionaries) + `fr-communes.dict.json` (34 957 communes GeoAPI INSEE)
|
||||
- [x] `scripts/build-cities.mjs` : génération du dictionnaire communes depuis GeoAPI
|
||||
- [x] Onglet Dictionnaires : mini cards (checkbox · scan individuel · suppression) + scan groupé
|
||||
- [x] `DictScanReviewModal` : modale de révision en cards (contexte, préfixe éditable, index calculé)
|
||||
- [x] `MappingScanReviewModal` : modale de révision pour le scan par règles existantes
|
||||
- [x] Scan par dictionnaire accessible depuis l'onglet Dictionnaires et commande Ctrl+P
|
||||
- [x] Suppression onglet "Candidats" — scan règles → Mappings · scan NER → NER
|
||||
|
||||
|
||||
**Testable (v0.1.3) :** installer le dictionnaire communes → scanner un fichier → modale de révision → décocher les faux positifs → créer les règles → pseudonymiser.
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Phase 10 — Affinage et stabilisation (v0.1.x)
|
||||
|
||||
Objectif : consolider l'ensemble des features en place avant d'aborder les fonctions avancées.
|
||||
|
||||
- [ ] Couverture de tests unitaires ≥ 80 % sur parsers, moteur, NER scanner
|
||||
- [ ] Tests de non-régression Phase 4 (§18.2) maintenus verts avec les règles NER actives
|
||||
- [ ] Correction des conventions Jefferson / ICOR : suggestions au survol, highlighting éditeur
|
||||
- [ ] Exports dans le format d'origine pour l'ouverture de corpus
|
||||
- [ ] Exports pour publication en EMCA (PNG)
|
||||
- [ ] Performance : mesurer et optimiser le temps de scan NER sur un fichier de 500 tours
|
||||
- [ ] **Internationalisation (i18n)** : externaliser toutes les chaînes UI dans un fichier de traduction (`locales/fr.json`, `locales/en.json`) — architecture à définir (standard Obsidian ou `i18next`)
|
||||
- [ ] Intégration [Meld Encrypt](https://github.com/meld-cp/obsidian-encrypt) dans l'onglet Exports pour le chiffrement des tables de correspondance et des exports pseudonymisés
|
||||
- [ ] Trancher les questions ouvertes persistantes (SPECS §20)
|
||||
- [ ] Comportement UI (view) conditionnel au dossier du fichier actif (rel aux dossiers d'export et de mapping)
|
||||
|
||||
**Testable (v0.2.0) :** workflow de bout en bout stable sur un corpus synthétique de 10 entretiens.
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Phase 11 — Fonctions d'analyse interactionnelle et conversationnelle (v0.2.0)
|
||||
|
||||
Objectif : aller au-delà de la pseudonymisation pour offrir des fonctions adaptées aux besoins spécifiques de l'EMCA et de l'analyse conversationnelle.
|
||||
|
||||
Périmètre à définir lors de la Phase 10 — pistes envisagées :
|
||||
|
||||
- Vérification et correction assistée des conventions Jefferson / ICOR (chevauchements `[`, enchaînements `=`, pauses `(0.5)`, allongements `:`, prosodie `.` `,` `?`)
|
||||
- Navigation structurée par tour de parole / séquence (vue panoptique inspirée de Sonal)
|
||||
- Annotation thématique des tours (codes libres, portée fichier/dossier/vault)
|
||||
- Export ELAN (`.eaf`) ou Praat (`.TextGrid`) depuis les fichiers annotés
|
||||
- Couplage audio optionnel via fichier local (Obsidian API `app.vault`) — synchronisation tour ↔ segment audio
|
||||
- Compatibilité avec le JSON d'échange Whispurge / Sonal pi (SPECS §20.6)
|
||||
|
||||
**Testable (v1.0.0) :** un chercheur peut ouvrir une transcription CHAT, la naviguer tour par tour, corriger les conventions, pseudonymiser, annoter thématiquement, et exporter vers ELAN — sans quitter Obsidian.
|
||||
|
||||
---
|
||||
|
||||
## Pahse 12 – Processus d'affinage de nettoyage des données
|
||||
- [ ] `mappings/ConflictDetector.ts` : détection des chevauchements entre spans NER et règles manuelles (§8.5)
|
||||
- [ ] `ambiguous.json` : tokens historiquement ambigus prénom/lieu (Florence, Nancy, Lorraine…) — badge ⚠ dans la modale de révision
|
||||
- [ ] Seuil de population minimum configurable pour la détection (exclure les communes < N hab)
|
||||
- [ ] Amélioration modèle : évaluer CamemBERT-NER (`Jean-Baptiste/camembert-ner`)
|
||||
|
||||
---
|
||||
|
||||
## Features envisagées (hors phases planifiées)
|
||||
|
||||
Ces fonctionnalités sont identifiées comme utiles mais non planifiées dans les phases actuelles.
|
||||
|
||||
| Feature | Description | Prérequis |
|
||||
|---|---|---|
|
||||
| **Backend spaCy** | Serveur Python sidecar (`fr_core_news_sm`) appelé via HTTP — meilleure précision sur le français, pas de téléchargement de modèle, temps de réponse plus rapide. Requiert Python ≥ 3.9 côté utilisateur. | Phase 9 stable |
|
||||
| **Modèle CamemBERT-NER** | Remplacer le modèle multilingue BERT par `Jean-Baptiste/camembert-ner` ou `cmarkea/distilcamembert-base-ner` — spécifiquement entraîné sur le français. Nécessite une conversion ONNX et un hébergement HuggingFace. | Phase 9 stable |
|
||||
| **Score de confiance spaCy** | Exposer les scores d'entités de spaCy (via `displacy` ou scorer) pour filtrer comme avec transformers.js. | Backend spaCy |
|
||||
| **Désambiguïsation interactive** | Modale contextuelle pour les tokens ambigus ville/prénom (Nancy, Florence…) : affichage du contexte + boutons Personne / Lieu / Ignorer. | Phase 9 |
|
||||
|
||||
---
|
||||
|
||||
## Questions ouvertes (SPECS §20)
|
||||
|
||||
| # | Question | Statut |
|
||||
|---|---|---|
|
||||
| 1 | Modifier les fichiers originaux ou fonctionner uniquement par export ? | **Décidé** — export en `.md` pour relire dans Obsidian, puis re-export dans le format inscrit dans les métadonnées du fichier source. L'onglet Exports affiche conditionnellement une option de chiffrement via [Meld Encrypt](https://github.com/meld-cp/obsidian-encrypt) quand on est dans un `*.pseudonymized.*`. |
|
||||
| 2 | Tables de correspondance dans le vault ou hors vault par défaut ? | À trancher |
|
||||
| 3 | Chiffrement des tables dès la v1 ? | **Décidé** — recommander le plugin [Meld Encrypt](https://github.com/meld-cp/obsidian-encrypt) pour le chiffrement des tables et des exports. Intégration dans l'onglet Exports (Phase 10). |
|
||||
| 4 | Métadonnées CHAT dès le MVP ou à partir de la v0.3 ? | À trancher |
|
||||
| 5 | NER avancé plus tard, ou rester sur dictionnaires + regex + validation humaine ? | **Décidé : NER (Phase 9)** — l'objectif du dictionnaire (détection ou remplacement) est déterminé à l'import. Le NER assure la détection ; les dictionnaires fournissent les candidats de substitution. |
|
||||
| 6 | Compatibilité exacte à viser avec les JSON de Sonal pi / Whispurge ? | À explorer |
|
||||
| 7 | Couplage audio optionnel via fichier local (API Obsidian) ? | À explorer |
|
||||
| 8 | Export ELAN ou Praat ? | À explorer |
|
||||
| 9 | Liste canonique pour `ambiguous.json` (Nancy, Florence, Lorraine…) ? | À constituer |
|
||||
| 10 | Internationalisation (i18n) du plugin ? | **Décidé** — l'architecture doit permettre la traduction de l'interface. Toutes les chaînes UI doivent être externalisées dans un fichier de traduction. Implémentation en Phase 10. |
|
||||
93
ROADMAP.md
93
ROADMAP.md
|
|
@ -1,23 +1,25 @@
|
|||
# ROADMAP — PseudObsidian-ization
|
||||
|
||||
Plugin Obsidian de pseudonymisation et correction de transcriptions (conventions Jefferson / ICOR / SRT / CHAT).
|
||||
An Obsidian plugin for pseudonymizing and correcting interactional transcripts (Jefferson / ICOR / SRT / CHAT conventions).
|
||||
|
||||
Chaque phase produit quelque chose de testable. Les critères d'acceptation renvoient aux sections de SPECS.md.
|
||||
> French version: [ROADMAP.fr.md](ROADMAP.fr.md)
|
||||
|
||||
## État actuel (mai 2026) — v0.1.0
|
||||
Each phase produces something testable. Acceptance criteria reference SPECS.md sections.
|
||||
|
||||
**Phases 0 à 8 terminées.** La Phase 9 (dictionnaires de remplacement pour lieux/institutions) est la prochaine cible.
|
||||
## Current status (May 2026) — v0.1.x
|
||||
|
||||
Décision architecturale adoptée en mai 2026 : la **détection des entités identifiantes** repose sur du **NER** (`transformers.js` + `bert-base-multilingual-cased-ner-hrl`) plutôt que sur des dictionnaires lexicaux exhaustifs. Les dictionnaires restent des ressources de **remplacement** (candidats de substitution), pas de détection.
|
||||
**Phases 0–9 complete.** Phase 10 (refinement & EMCA specialization) is the current target.
|
||||
|
||||
Architectural decision (May 2026): **identifying entity detection** is handled by **NER** (`transformers.js` + `bert-base-multilingual-cased-ner-hrl`) rather than exhaustive lexical dictionaries. Dictionaries serve as **replacement** resources (substitution candidates), not detection.
|
||||
|
||||
```
|
||||
✅ Phases 0–8 Parsers · Moteur · UI · Portées · Surlignage · Validation · Coulmont · Panneau · NER · Wizard
|
||||
🔄 Phase 9 Dictionnaires de remplacement (lieux/institutions) (v0.1.0)
|
||||
⏳ Phase 10 Affinage et stabilisation (v0.2.0)
|
||||
⏳ Phase 11 Fonctions d'analyse interactionnelle et conversationnelle (v1.0.0)
|
||||
✅ Phases 0–8 Parsers · Engine · UI · Scopes · Highlighting · Validation · Coulmont · Panel · NER · Wizard
|
||||
✅ Phase 9 Structured dictionaries · DictionaryLoader · Review modals · French communes
|
||||
🔄 Phase 10 Refinement & EMCA specialization (v0.1.x → v0.2.0)
|
||||
⏳ Phase 11 Interactional analysis functions (v1.0.0)
|
||||
```
|
||||
|
||||
La publication sur le répertoire communautaire Obsidian se fait **au fil des versions** (PR #12766 en cours de validation).
|
||||
Community portal publication is ongoing with each version (PR #12766 under review).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -202,58 +204,67 @@ Objectif : permettre la détection et le remplacement automatiques des entités
|
|||
- [x] `MappingScanReviewModal` : modale de révision pour le scan par règles existantes
|
||||
- [x] Scan par dictionnaire accessible depuis l'onglet Dictionnaires et commande Ctrl+P
|
||||
- [x] Suppression onglet "Candidats" — scan règles → Mappings · scan NER → NER
|
||||
- [ ] `mappings/ConflictDetector.ts` : détection des chevauchements entre spans NER et règles manuelles (§8.5)
|
||||
- [ ] `ambiguous.json` : tokens historiquement ambigus prénom/lieu (Florence, Nancy, Lorraine…) — badge ⚠ dans la modale de révision
|
||||
- [ ] Seuil de population minimum configurable pour la détection (exclure les communes < N hab)
|
||||
- [ ] Amélioration modèle : évaluer CamemBERT-NER (`Jean-Baptiste/camembert-ner`)
|
||||
|
||||
|
||||
**Testable (v0.1.3) :** installer le dictionnaire communes → scanner un fichier → modale de révision → décocher les faux positifs → créer les règles → pseudonymiser.
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Phase 10 — Affinage et stabilisation (v0.2.0)
|
||||
## 🔄 Phase 10 — Refinement & EMCA specialization (v0.1.x → v0.2.0)
|
||||
|
||||
Objectif : consolider l'ensemble des features en place avant d'aborder les fonctions avancées.
|
||||
Goal: consolidate all existing features and add the EMCA-specific functions that make the plugin genuinely useful for interactional research.
|
||||
|
||||
- [ ] Couverture de tests unitaires ≥ 80 % sur parsers, moteur, NER scanner
|
||||
- [ ] Tests de non-régression Phase 4 (§18.2) maintenus verts avec les règles NER actives
|
||||
- [ ] Correction des conventions Jefferson / ICOR : suggestions au survol, highlighting éditeur
|
||||
- [ ] Performance : mesurer et optimiser le temps de scan NER sur un fichier de 500 tours
|
||||
- [ ] **Internationalisation (i18n)** : externaliser toutes les chaînes UI dans un fichier de traduction (`locales/fr.json`, `locales/en.json`) — architecture à définir (standard Obsidian ou `i18next`)
|
||||
- [ ] Intégration [Meld Encrypt](https://github.com/meld-cp/obsidian-encrypt) dans l'onglet Exports pour le chiffrement des tables de correspondance et des exports pseudonymisés
|
||||
- [ ] Trancher les questions ouvertes persistantes (SPECS §20)
|
||||
- [x] **i18n** — all UI strings externalized in `locales/en.json` and `fr.json`; language selectable in wizard and settings
|
||||
- [x] **Corpus organization** — named classes with mirrored folder structure; class selection on import
|
||||
- [x] **Settings redesign** — 6 sections ordered by frequency of use
|
||||
- [x] **Broad-scope warning** — callout in RuleModal / EditRuleModal for name rules with folder/vault scope
|
||||
- [x] **Mappings tab grouped by scope** — File / Folder / Vault sections with active-file filter
|
||||
- [ ] Unit test coverage ≥ 80% for parsers, engine, NER scanner, DictionaryLoader
|
||||
- [ ] Jefferson / ICOR convention checker: hover suggestions, editor highlighting
|
||||
- [ ] Exports in original format for corpus re-opening
|
||||
- [ ] EMCA publication exports (PNG)
|
||||
- [ ] NER performance: measure and optimize on a 500-turn file
|
||||
- [ ] [Meld Encrypt](https://github.com/meld-cp/obsidian-encrypt) integration in Exports tab for encrypting correspondence tables and pseudonymized exports
|
||||
- [ ] Resolve remaining open questions (SPECS §20)
|
||||
|
||||
**Testable (v0.2.0) :** workflow de bout en bout stable sur un corpus réel de 10 entretiens.
|
||||
**Testable (v0.2.0):** stable end-to-end workflow on a real corpus of 10 interviews.
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Phase 11 — Fonctions d'analyse interactionnelle et conversationnelle (v1.0.0)
|
||||
## ⏳ Phase 11 — Interactional analysis functions (v1.0.0)
|
||||
|
||||
Objectif : aller au-delà de la pseudonymisation pour offrir des fonctions adaptées aux besoins spécifiques de l'EMCA et de l'analyse conversationnelle.
|
||||
Goal: go beyond pseudonymization to offer functions tailored to EMCA and conversation analysis research.
|
||||
|
||||
Périmètre à définir lors de la Phase 10 — pistes envisagées :
|
||||
Scope to be defined during Phase 10 — planned directions:
|
||||
|
||||
- Vérification et correction assistée des conventions Jefferson / ICOR (chevauchements `[`, enchaînements `=`, pauses `(0.5)`, allongements `:`, prosodie `.` `,` `?`)
|
||||
- Navigation structurée par tour de parole / séquence (vue panoptique inspirée de Sonal)
|
||||
- Annotation thématique des tours (codes libres, portée fichier/dossier/vault)
|
||||
- Export ELAN (`.eaf`) ou Praat (`.TextGrid`) depuis les fichiers annotés
|
||||
- Couplage audio optionnel via fichier local (Obsidian API `app.vault`) — synchronisation tour ↔ segment audio
|
||||
- Compatibilité avec le JSON d'échange Whispurge / Sonal pi (SPECS §20.6)
|
||||
- Assisted verification and correction of Jefferson / ICOR conventions (overlaps `[`, latching `=`, pauses `(0.5)`, lengthening `:`, prosody `.` `,` `?`)
|
||||
- Structured navigation by turn / sequence (panoptic view inspired by Sonal)
|
||||
- Thematic annotation of turns (free codes, file/folder/vault scope)
|
||||
- ELAN (`.eaf`) or Praat (`.TextGrid`) export from annotated files
|
||||
- Optional audio coupling via local file (Obsidian API `app.vault`) — turn ↔ audio segment synchronization
|
||||
- Compatibility with Whispurge / Sonal pi exchange JSON (SPECS §20.6)
|
||||
|
||||
**Testable (v1.0.0) :** un chercheur peut ouvrir une transcription CHAT, la naviguer tour par tour, corriger les conventions, pseudonymiser, annoter thématiquement, et exporter vers ELAN — sans quitter Obsidian.
|
||||
**Testable (v1.0.0):** a researcher can open a CHAT transcript, navigate turn by turn, correct conventions, pseudonymize, annotate thematically, and export to ELAN — without leaving Obsidian.
|
||||
|
||||
---
|
||||
|
||||
## Features envisagées (hors phases planifiées)
|
||||
## Phase 12 — Data quality refinement
|
||||
|
||||
Ces fonctionnalités sont identifiées comme utiles mais non planifiées dans les phases actuelles.
|
||||
- [ ] `mappings/ConflictDetector.ts`: overlap detection between NER spans and manual rules (§8.5)
|
||||
- [ ] `ambiguous.json`: historically ambiguous first name / place tokens (Florence, Nancy, Lorraine…) — ⚠ badge in the review modal
|
||||
- [ ] Configurable minimum population threshold for detection (exclude communes < N inhabitants)
|
||||
- [ ] Model evaluation: CamemBERT-NER (`Jean-Baptiste/camembert-ner`)
|
||||
|
||||
| Feature | Description | Prérequis |
|
||||
---
|
||||
|
||||
## Planned features (outside current phases)
|
||||
|
||||
| Feature | Description | Prerequisite |
|
||||
|---|---|---|
|
||||
| **Backend spaCy** | Serveur Python sidecar (`fr_core_news_sm`) appelé via HTTP — meilleure précision sur le français, pas de téléchargement de modèle, temps de réponse plus rapide. Requiert Python ≥ 3.9 côté utilisateur. | Phase 9 stable |
|
||||
| **Modèle CamemBERT-NER** | Remplacer le modèle multilingue BERT par `Jean-Baptiste/camembert-ner` ou `cmarkea/distilcamembert-base-ner` — spécifiquement entraîné sur le français. Nécessite une conversion ONNX et un hébergement HuggingFace. | Phase 9 stable |
|
||||
| **Score de confiance spaCy** | Exposer les scores d'entités de spaCy (via `displacy` ou scorer) pour filtrer comme avec transformers.js. | Backend spaCy |
|
||||
| **Désambiguïsation interactive** | Modale contextuelle pour les tokens ambigus ville/prénom (Nancy, Florence…) : affichage du contexte + boutons Personne / Lieu / Ignorer. | Phase 9 |
|
||||
| **spaCy backend** | Python sidecar server (`fr_core_news_sm`) called via HTTP — better precision on French, no model download, faster response. Requires Python ≥ 3.9. | Phase 9 stable |
|
||||
| **CamemBERT-NER** | Replace the multilingual BERT model with `Jean-Baptiste/camembert-ner` or `cmarkea/distilcamembert-base-ner` — specifically trained on French. Requires ONNX conversion and HuggingFace hosting. | Phase 9 stable |
|
||||
| **spaCy confidence scores** | Expose spaCy entity scores for filtering, as with transformers.js. | spaCy backend |
|
||||
| **Interactive disambiguation** | Contextual modal for ambiguous city/first-name tokens (Nancy, Florence…): context display + Person / Place / Ignore buttons. | Phase 9 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ const context = await esbuild.context({
|
|||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
outfile: prod
|
||||
? "main.js"
|
||||
: "test_vault/.obsidian/plugins/pseudonymizer-tool/main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
|
|
|
|||
13627
main.js
13627
main.js
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,6 @@ import type {
|
|||
DictionaryCondition,
|
||||
DictionaryPattern,
|
||||
Occurrence,
|
||||
EntityCategory,
|
||||
} from '../types';
|
||||
|
||||
// Séquence de tokens avec positions dans la ligne d'origine
|
||||
|
|
|
|||
40
src/i18n/index.ts
Normal file
40
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import en from './locales/en.json';
|
||||
import fr from './locales/fr.json';
|
||||
|
||||
type Strings = Record<string, string>;
|
||||
|
||||
const LOCALES: Record<string, Strings> = { en, fr };
|
||||
const FALLBACK = 'en';
|
||||
|
||||
let _locale = FALLBACK;
|
||||
|
||||
/** Définit la locale active. Appelé au chargement du plugin depuis les settings. */
|
||||
export function setLocale(lang: string): void {
|
||||
_locale = LOCALES[lang] ? lang : FALLBACK;
|
||||
}
|
||||
|
||||
export function getLocale(): string {
|
||||
return _locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la chaîne traduite pour la clé donnée.
|
||||
* Les arguments {0}, {1}… sont remplacés par les valeurs correspondantes.
|
||||
*
|
||||
* @example
|
||||
* t('notice.ruleCreated', 'Jean', 'Pierre') // "✓ Règle créée : "Jean" → "Pierre""
|
||||
*/
|
||||
export function t(key: string, ...args: (string | number)[]): string {
|
||||
const locale = LOCALES[_locale] ?? LOCALES[FALLBACK];
|
||||
let s = locale[key] ?? LOCALES[FALLBACK][key] ?? key;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
s = s.replace(`{${i}}`, String(args[i]));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Langues disponibles avec leur nom natif. */
|
||||
export const AVAILABLE_LANGUAGES: Record<string, string> = {
|
||||
en: 'English',
|
||||
fr: 'Français',
|
||||
};
|
||||
309
src/i18n/locales/en.json
Normal file
309
src/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
{
|
||||
"corpus.modal.title": "Organize corpus",
|
||||
"corpus.modal.hint": "Each class creates mirrored folders for transcriptions, mappings and exports.",
|
||||
"corpus.modal.noClasses": "No classes defined. Add one to organize your documents by folder.",
|
||||
"corpus.modal.addClass": "Add a class",
|
||||
"corpus.modal.classNamePlaceholder": "Class name (e.g. Session_A, Informant_1…)",
|
||||
"corpus.modal.classNameEmpty": "Class name cannot be empty.",
|
||||
"corpus.modal.classExists": "A class with this name already exists.",
|
||||
"corpus.modal.deleteConfirm": "Delete class \"{0}\"? The folders will be removed from the vault (files inside will be trashed).",
|
||||
"corpus.modal.files": "{0} file(s)",
|
||||
"corpus.modal.close": "Close",
|
||||
"corpus.select.title": "Add to which class?",
|
||||
"corpus.select.hint": "Choose the class (folder) for this transcription.",
|
||||
"corpus.select.none": "No class (root)",
|
||||
"corpus.select.confirm": "Add",
|
||||
"quickModal.title": "Pseudonymize",
|
||||
"quickModal.source": "Selected expression",
|
||||
"quickModal.sourceDesc": "Term to replace — cannot be modified",
|
||||
"quickModal.replaceBy": "Replace with",
|
||||
"quickModal.replacementPlaceholder": "Pseudonym or analytical category",
|
||||
"quickModal.scope": "Replacement scope",
|
||||
"quickModal.scopeFile": "All occurrences in this file",
|
||||
"quickModal.scopeOccurrence": "This occurrence only",
|
||||
"quickModal.submit": "Pseudonymize",
|
||||
"notice.appliedOccurrence": "✓ \"{0}\" → \"{1}\" (this occurrence)",
|
||||
"notice.appliedFile": "✓ \"{0}\" → \"{1}\" ({2} occurrence{3})",
|
||||
"onboarding.dict.importOk": "{0} dictionary imported",
|
||||
"onboarding.dict.importOkMany": "{0} dictionaries imported",
|
||||
"onboarding.dict.importErr": "{0} file rejected",
|
||||
"onboarding.dict.importErrMany": "{0} files rejected",
|
||||
"onboarding.dict.importError": "Error importing {0} — check the .dict.json format",
|
||||
"notice.noActiveFile": "No active file.",
|
||||
"notice.formatUnsupported": "Unsupported format: .{0}",
|
||||
"notice.noRules": "No rules for this file.\nCreate rules via Ctrl+P → \"Create a rule\".",
|
||||
"notice.noOccurrences": "No occurrences found for the active rules.",
|
||||
"notice.noNerEntities": "No entities detected by NER.",
|
||||
"notice.nerEntitiesFound": "✓ {0} {1} detected — highlighted in blue.\nRight-click to create a rule.",
|
||||
"notice.nerEntitiesFound.entity": "entity",
|
||||
"notice.nerEntitiesFound.entities": "entities",
|
||||
"notice.nerNotEnabled": "transformers.js NER detection is not enabled.\nEnable it in Settings → Pseudonymizer Tool.",
|
||||
"notice.noDictDetection": "No detection dictionary loaded.\nInstall a dictionary from the Dictionaries panel.",
|
||||
"notice.noDictEntities": "No entities found in the detection dictionaries.",
|
||||
"notice.noDictReplacements": "No replacements available for the found entities.",
|
||||
"notice.ruleCreated": "✓ Rule created: \"{0}\" → \"{1}\"",
|
||||
"notice.rulesCreated": "✓ {0} {1} created",
|
||||
"notice.rulesCreated.rule": "rule",
|
||||
"notice.rulesCreated.rules": "rules",
|
||||
"notice.occurrencesPseudonymized": "✓ {0} {1} pseudonymized in \"{2}\"",
|
||||
"notice.occurrencesPseudonymized.occurrence": "occurrence",
|
||||
"notice.occurrencesPseudonymized.occurrences": "occurrences",
|
||||
"notice.converted": "✓ {0} → {1}",
|
||||
"notice.conversionError": "Conversion error for {0}: {1}",
|
||||
"notice.fileExists": "File already exists in vault: {0}",
|
||||
"notice.conversionSkipped": "⚠ {0}.md already exists — conversion skipped for {1}",
|
||||
"notice.exportDone": "✓ {0} rule(s) applied\n→ {1}",
|
||||
"notice.mappingExported": "✓ Mapping exported → {0}",
|
||||
"notice.noMapping": "No mapping found for {0}",
|
||||
"notice.invalidFormat": "Invalid format: {0}",
|
||||
"notice.dictImported": "✓ {0} {1} imported",
|
||||
"notice.dictImported.one": "dictionary",
|
||||
"notice.dictImported.many": "dictionaries",
|
||||
"notice.dictRejected": "{0} {1} rejected",
|
||||
"notice.dictRejected.one": "file",
|
||||
"notice.dictRejected.many": "files",
|
||||
"notice.nerModelLoaded": "✓ NER model loaded",
|
||||
"notice.nerModelLoading": "Loading NER model (first use — ~66 MB)…",
|
||||
"notice.ruleNotFound": "Rule not found in mappings.",
|
||||
"notice.noCheckedDicts": "No dictionary checked.",
|
||||
|
||||
"command.organizeCorpus": "Organize corpus",
|
||||
"command.addTranscription": "Add a transcription",
|
||||
"command.pseudonymizeFile": "Pseudonymize current file",
|
||||
"command.createRule": "Create a replacement rule",
|
||||
"command.scanFile": "Scan current file",
|
||||
"command.scanNer": "Scan file with NER detection",
|
||||
"command.scanDictionaries": "Scan file with dictionaries",
|
||||
"command.pseudonymizeSelection": "Pseudonymize selection",
|
||||
"command.openPanel": "Pseudonymization: open panel",
|
||||
|
||||
"contextMenu.cancelPseudonymization": "Cancel pseudonymization of \"{0}\"",
|
||||
"contextMenu.editRule": "Edit rule for \"{0}\"",
|
||||
"contextMenu.pseudonymize": "Pseudonymize \"{0}\"",
|
||||
"ruleModal.delete": "Delete rule",
|
||||
"ruleModal.scopeWarnTitle": "Longitudinal scope",
|
||||
"ruleModal.scopeOkTitle": "File scope",
|
||||
"ruleModal.scopeOk": "Good practice. This pseudonym will only apply to this transcription — each file remains independent.",
|
||||
"contextMenu.coulmont": "Pseudonymize with Prof. Baptiste Coulmont",
|
||||
"contextMenu.createRule": "Create a replacement rule…",
|
||||
|
||||
"ruleModal.title": "Create a replacement rule",
|
||||
"ruleModal.source": "Source",
|
||||
"ruleModal.sourceDesc": "Original term to replace",
|
||||
"ruleModal.replacement": "Replacement",
|
||||
"ruleModal.replacementDesc": "Pseudonym or analytical category",
|
||||
"ruleModal.replacementDescClass": "The exact index will be calculated at creation based on existing rules in the scope.",
|
||||
"ruleModal.category": "Category",
|
||||
"ruleModal.scope": "Scope",
|
||||
"ruleModal.scopeFile": "This file only",
|
||||
"ruleModal.scopeFolder": "This folder",
|
||||
"ruleModal.scopeVault": "Entire vault",
|
||||
"ruleModal.priority": "Priority",
|
||||
"ruleModal.priorityDesc": "Free integer, like a CSS z-index — default 0, higher = applied first",
|
||||
"ruleModal.submit": "Create rule",
|
||||
"ruleModal.errorMissing": "Source and replacement are required.",
|
||||
"ruleModal.errorNoFile": "No active file.",
|
||||
"ruleModal.coulomontLabel": "Equivalent first name suggestions — choose:",
|
||||
"ruleModal.dictLabel": "Dictionary: \"{0}\" → class {1}",
|
||||
"ruleModal.dictUseClass": "Use \"{0}\" (scope: {1})",
|
||||
|
||||
"category.first_name": "First name",
|
||||
"category.last_name": "Last name",
|
||||
"category.full_name": "Full name",
|
||||
"category.place": "Place",
|
||||
"category.institution": "Institution",
|
||||
"category.date": "Date",
|
||||
"category.age": "Age",
|
||||
"category.profession": "Profession",
|
||||
"category.custom": "Other",
|
||||
|
||||
"scope.file": "File",
|
||||
"scope.folder": "Folder",
|
||||
"scope.vault": "Vault",
|
||||
|
||||
"status.validated": "✓",
|
||||
"status.ignored": "✗",
|
||||
"status.partial": "◑",
|
||||
"status.suggested": "?",
|
||||
"status.conflict": "⚠",
|
||||
"status.disabled": "–",
|
||||
"status.needs_review": "👁",
|
||||
|
||||
"panel.tab.mappings": "Mappings",
|
||||
"panel.tab.dictionaries": "Dictionaries",
|
||||
"panel.tab.exports": "Exports",
|
||||
"panel.tab.ner": "NER",
|
||||
|
||||
"panel.mappings.filterActive": "Active file only",
|
||||
"panel.mappings.warnBroadName": "⚠ Generalizing a first name or surname beyond file scope should only be done as a deliberate analytical choice — for example, to consistently track the same participant in a longitudinal study. Even when the same individual appears in multiple files, file-level pseudonymization is generally preferable: it keeps each transcription independent and reduces the risk of cross-file identification.",
|
||||
"panel.mappings.addRule": "Add a rule",
|
||||
"panel.mappings.scanFile": "Scan file",
|
||||
"panel.mappings.scanning": "Scanning…",
|
||||
"panel.mappings.noFile": "No active file.",
|
||||
"panel.mappings.noRules": "No rules for {0}.",
|
||||
"panel.mappings.noRulesHint": "No active rules for this file. Create rules via the context menu or the \"create a rule\" command.",
|
||||
"panel.mappings.col.source": "Source",
|
||||
"panel.mappings.col.replacement": "Replacement",
|
||||
"panel.mappings.col.category": "Category",
|
||||
"panel.mappings.col.scope": "Scope",
|
||||
"panel.mappings.col.priority": "P.",
|
||||
"panel.mappings.col.status": "Status",
|
||||
|
||||
"panel.dict.noneInstalled": "No dictionary installed. Install one from the wizard (Settings → Reconfigure) or import a local file.",
|
||||
"panel.dict.checkbox": "Include in grouped scan",
|
||||
"panel.dict.scanAll": "Scan checked dictionaries",
|
||||
"panel.dict.importLocal": "Import a local file (.dict.json)",
|
||||
"panel.dict.scanWith": "Scan active file with \"{0}\"",
|
||||
"panel.dict.remove": "Remove this dictionary",
|
||||
|
||||
"panel.exports.pseudonymize": "Pseudonymize and export",
|
||||
"panel.exports.exportMapping": "Export correspondence table",
|
||||
|
||||
"panel.ner.scanBtn": "Identify candidates",
|
||||
"panel.ner.hint": "Settings for the automatic named entity detection scanner.",
|
||||
"panel.ner.threshold": "Confidence threshold",
|
||||
"panel.ner.thresholdDesc": "Entities with a score below this value are ignored. Higher = fewer false positives but fewer detections.",
|
||||
"panel.ner.functionWords": "Excluded function words",
|
||||
"panel.ner.functionWordsDesc": "One word per line. These tokens will never be retained as named entities, even if detected by the model.",
|
||||
"panel.ner.save": "Save",
|
||||
"panel.ner.saved": "Saved",
|
||||
"panel.ner.reset": "Reset to defaults",
|
||||
|
||||
"dictScanModal.title": "Dictionary scan review",
|
||||
"dictScanModal.summary": "{0} {1} detected · {2}",
|
||||
"dictScanModal.summary.entity": "entity",
|
||||
"dictScanModal.summary.entities": "entities",
|
||||
"dictScanModal.hint": "Uncheck false positives. The prefix is editable; the index is calculated automatically.",
|
||||
"dictScanModal.apply": "Create {0} {1}",
|
||||
"dictScanModal.apply.rule": "rule",
|
||||
"dictScanModal.apply.rules": "rules",
|
||||
"dictScanModal.noRules": "No rules to apply",
|
||||
"dictScanModal.cancel": "Cancel",
|
||||
|
||||
"mappingScanModal.title": "Mapping scan review",
|
||||
"mappingScanModal.summary": "{0} active {1} with occurrences in \"{2}\"",
|
||||
"mappingScanModal.summary.rule": "rule",
|
||||
"mappingScanModal.summary.rules": "rules",
|
||||
"mappingScanModal.hint": "Uncheck rules not to apply. Replacements will be written to the source file.",
|
||||
"mappingScanModal.col.source": "",
|
||||
"mappingScanModal.col.replacement": "Replacement",
|
||||
"mappingScanModal.col.occurrences": "Occ.",
|
||||
"mappingScanModal.apply": "Pseudonymize ({0} {1}, {2} {3})",
|
||||
"mappingScanModal.apply.rule": "rule",
|
||||
"mappingScanModal.apply.rules": "rules",
|
||||
"mappingScanModal.apply.occurrence": "occurrence",
|
||||
"mappingScanModal.apply.occurrences": "occurrences",
|
||||
"mappingScanModal.noRules": "No rules to apply",
|
||||
"mappingScanModal.cancel": "Cancel",
|
||||
|
||||
"onboarding.step.storage": "Storage",
|
||||
"onboarding.step.language": "Language",
|
||||
"onboarding.step.welcome": "Welcome",
|
||||
"onboarding.step.ner": "NER Detection",
|
||||
"onboarding.step.dictionaries": "Dictionaries",
|
||||
"onboarding.step.summary": "Summary",
|
||||
"onboarding.welcome.title": "Pseudonymizer Tool",
|
||||
"onboarding.welcome.desc": "This plugin helps you correct and pseudonymize transcripts of interviews and interactional corpora (Jefferson, ICOR, .srt, .cha formats).",
|
||||
"onboarding.welcome.steps": "The wizard will guide you in a few quick steps:",
|
||||
"onboarding.welcome.step1": "Choose the interface language.",
|
||||
"onboarding.welcome.step2": "Choose an automatic named entity detection engine (optional).",
|
||||
"onboarding.welcome.step3": "Install replacement candidate dictionaries if you need them.",
|
||||
"onboarding.welcome.hint": "You can reconfigure these options at any time via the plugin settings.",
|
||||
"onboarding.storage.title": "Storage",
|
||||
"onboarding.storage.vaultPerCorpus": "One vault per corpus",
|
||||
"onboarding.storage.vaultPerCorpusDesc": "We recommend using a dedicated Obsidian vault for each corpus. This keeps mapping files, dictionaries and exports together with their transcriptions, and makes it easier to share or archive a corpus.",
|
||||
"onboarding.storage.transcriptionsFolder": "Transcriptions folder",
|
||||
"onboarding.storage.mappingFolder": "Correspondence tables",
|
||||
"onboarding.storage.dictionariesFolder": "Dictionaries",
|
||||
"onboarding.storage.exportsFolder": "Exports",
|
||||
"onboarding.storage.hint": "These paths are relative to the vault root. You can change them later in Settings.",
|
||||
"onboarding.language.title": "Interface language",
|
||||
"onboarding.language.hint": "The interface language does not affect transcript analysis. Conventions (Jefferson, ICOR) are language-independent.",
|
||||
"onboarding.ner.desc": "A NER model (named entity recognition) can automatically detect first names, surnames, places and institutions in your transcripts — without an exhaustive list, running locally in Obsidian.",
|
||||
"onboarding.ner.tfjsTitle": "transformers.js — embedded ONNX model",
|
||||
"onboarding.ner.tfjsBadge": "No Python required",
|
||||
"onboarding.ner.tfjsDesc": "Multilingual NER model running locally in the vault. WASM files (~19 MB) are downloaded once. The model (~66 MB) is downloaded on first scan.",
|
||||
"onboarding.ner.wasmReady": "WASM files already installed",
|
||||
"onboarding.ner.installBtn": "Install and use transformers.js",
|
||||
"onboarding.ner.selectedBtn": "Selected",
|
||||
"onboarding.ner.noneActive": "Disabled (manual rules only)",
|
||||
"onboarding.ner.noneSkip": "Skip — I will work manually",
|
||||
|
||||
"onboarding.dict.title": "Replacement candidate dictionaries",
|
||||
"onboarding.dict.desc": "Dictionaries provide replacement candidates (cities, first names…) and feed automatic detection. They are hosted in a dedicated repository and downloaded into your vault — no transcript data leaves Obsidian.",
|
||||
"onboarding.dict.catalogueLoading": "Loading catalogue…",
|
||||
"onboarding.dict.catalogueError": "Unable to reach the online catalogue. Check your connection or import a local file below.",
|
||||
"onboarding.dict.recommended": "Recommended",
|
||||
"onboarding.dict.col.dict": "Dictionary",
|
||||
"onboarding.dict.col.lang": "Language",
|
||||
"onboarding.dict.col.roles": "Roles",
|
||||
"onboarding.dict.col.size": "Size",
|
||||
"onboarding.dict.role.detection": "detection",
|
||||
"onboarding.dict.role.replacement": "replacement",
|
||||
"onboarding.dict.role.classes": "classes",
|
||||
"onboarding.dict.install": "Install",
|
||||
"onboarding.dict.reinstall": "Reinstall",
|
||||
"onboarding.dict.installed": "Installed",
|
||||
"onboarding.dict.manualHint": "Already have a .dict.json file? Import it manually:",
|
||||
"onboarding.dict.importBtn": "Import a local file (.dict.json)",
|
||||
"onboarding.dict.listTitle": "Dictionaries installed in this vault ({0}):",
|
||||
"onboarding.dict.remove": "Remove this dictionary",
|
||||
|
||||
"onboarding.summary.ner.none": "Disabled — manual rules only",
|
||||
"onboarding.summary.ner.tfjs": "transformers.js — ONNX model (download on first scan)",
|
||||
|
||||
"onboarding.nav.cancel": "Cancel",
|
||||
"onboarding.nav.back": "Back",
|
||||
"onboarding.nav.skip": "Skip this step",
|
||||
"onboarding.nav.next": "Next",
|
||||
"onboarding.nav.start": "Get started",
|
||||
"onboarding.nav.finish": "Start working",
|
||||
"onboarding.summary.title": "Configuration complete",
|
||||
"onboarding.summary.intro": "Here is what was configured:",
|
||||
"onboarding.summary.ner": "NER Detection",
|
||||
"onboarding.summary.dicts": "Dictionaries",
|
||||
"onboarding.summary.dicts.count": "{0} file(s) in vault",
|
||||
"onboarding.summary.hint": "These settings can be changed at any time via plugin settings: Reconfigure.",
|
||||
|
||||
"settings.heading.general": "General",
|
||||
"settings.heading.textDetection": "Text detection",
|
||||
"settings.heading.pseudonymization": "Pseudonymization",
|
||||
"settings.heading.ner": "Automatic detection (NER)",
|
||||
"settings.heading.storage": "Storage",
|
||||
"settings.heading.security": "Security",
|
||||
"settings.language": "Interface language",
|
||||
"settings.languageDesc": "Does not affect transcript analysis. Conventions (Jefferson, ICOR) are language-independent.",
|
||||
"settings.vaultPerCorpus": "One vault per corpus",
|
||||
"settings.vaultPerCorpusDesc": "We recommend using a dedicated Obsidian vault for each corpus. This ensures mapping files, dictionaries and exports stay together with their transcriptions.",
|
||||
"settings.transcriptionsFolder": "Imported transcriptions",
|
||||
"settings.transcriptionsFolderDesc": "Destination folder for imported transcriptions.",
|
||||
"settings.mappingFolder": "Correspondence tables",
|
||||
"settings.mappingFolderDesc": "Relative path in the vault.",
|
||||
"settings.dictionariesFolder": "Dictionaries",
|
||||
"settings.dictionariesFolderDesc": "Relative path in the vault.",
|
||||
"settings.exportsFolder": "Exports",
|
||||
"settings.accentSensitive": "Accent sensitive",
|
||||
"settings.preserveCase": "Preserve case",
|
||||
"settings.preserveCaseDesc": "Adapt the replacement case to match the source (e.g. JEAN → PIERRE, jean → pierre, Jean → Pierre).",
|
||||
"settings.preserveAnalyticNotation": "Preserve analytical notation",
|
||||
"settings.preserveAnalyticNotationDesc": "Never replace Jefferson or ICOR analytical convention symbols.",
|
||||
"settings.useMarkerInExport": "Add marker around pseudonyms in export",
|
||||
"settings.useMarkerInExportDesc": "Allows visual identification of pseudonymized terms in the exported file.",
|
||||
"settings.markerOpen": "Opening marker",
|
||||
"settings.markerOpenDesc": "Example: {{ ⟦ [ «",
|
||||
"settings.markerClose": "Closing marker",
|
||||
"settings.markerCloseDesc": "Example: }} ⟧ ] »",
|
||||
"settings.nerBackend": "Detection engine",
|
||||
"settings.nerBackendDesc": "Backend used for automatic detection of identifying named entities.",
|
||||
"settings.nerBackend.none": "Disabled — manual rules only",
|
||||
"settings.nerBackend.tfjs": "transformers.js (embedded ONNX model)",
|
||||
"settings.reconfigure": "Setup wizard",
|
||||
"settings.reconfigureDesc2": "Relaunch the wizard to reconfigure NER detection and dictionaries.",
|
||||
"settings.reconfigureBtn2": "Reconfigure…",
|
||||
"settings.warnIfSyncedFolder": "Warn if folder is synced",
|
||||
"settings.warnIfSyncedFolderDesc": "Alert if correspondence tables are in a Git, iCloud or Synology Drive folder.",
|
||||
"settings.caseSensitive": "Case sensitive",
|
||||
"settings.caseSensitiveDesc": "If enabled, \"Jean\" and \"jean\" are treated as distinct.",
|
||||
"settings.wholeWordOnly": "Whole words only",
|
||||
"settings.wholeWordOnlyDesc": "If enabled, only complete word occurrences are replaced."
|
||||
}
|
||||
309
src/i18n/locales/fr.json
Normal file
309
src/i18n/locales/fr.json
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
{
|
||||
"corpus.modal.title": "Organiser le corpus",
|
||||
"corpus.modal.hint": "Chaque classe crée des dossiers en miroir pour les transcriptions, les mappings et les exports.",
|
||||
"corpus.modal.noClasses": "Aucune classe définie. Ajoutez-en une pour organiser vos documents par dossier.",
|
||||
"corpus.modal.addClass": "Ajouter une classe",
|
||||
"corpus.modal.classNamePlaceholder": "Nom de la classe (ex. Session_A, Informateur_1…)",
|
||||
"corpus.modal.classNameEmpty": "Le nom de la classe ne peut pas être vide.",
|
||||
"corpus.modal.classExists": "Une classe avec ce nom existe déjà.",
|
||||
"corpus.modal.deleteConfirm": "Supprimer la classe « {0} » ? Les dossiers seront retirés du vault (les fichiers à l'intérieur seront mis à la corbeille).",
|
||||
"corpus.modal.files": "{0} fichier(s)",
|
||||
"corpus.modal.close": "Fermer",
|
||||
"corpus.select.title": "Ajouter dans quelle classe ?",
|
||||
"corpus.select.hint": "Choisissez la classe (dossier) pour cette transcription.",
|
||||
"corpus.select.none": "Sans classe (racine)",
|
||||
"corpus.select.confirm": "Ajouter",
|
||||
"quickModal.title": "Pseudonymiser",
|
||||
"quickModal.source": "Expression sélectionnée",
|
||||
"quickModal.sourceDesc": "Terme à remplacer — non modifiable",
|
||||
"quickModal.replaceBy": "Remplacer par",
|
||||
"quickModal.replacementPlaceholder": "Pseudonyme ou catégorie analytique",
|
||||
"quickModal.scope": "Portée du remplacement",
|
||||
"quickModal.scopeFile": "Toutes les occurrences dans ce fichier",
|
||||
"quickModal.scopeOccurrence": "Cette occurrence uniquement",
|
||||
"quickModal.submit": "Pseudonymiser",
|
||||
"notice.appliedOccurrence": "✓ \"{0}\" → \"{1}\" (cette occurrence)",
|
||||
"notice.appliedFile": "✓ \"{0}\" → \"{1}\" ({2} occurrence{3})",
|
||||
"onboarding.dict.importOk": "{0} dictionnaire importé",
|
||||
"onboarding.dict.importOkMany": "{0} dictionnaires importés",
|
||||
"onboarding.dict.importErr": "{0} fichier rejeté",
|
||||
"onboarding.dict.importErrMany": "{0} fichiers rejetés",
|
||||
"onboarding.dict.importError": "Erreur lors de l'import de {0} — vérifiez le format .dict.json",
|
||||
"notice.noActiveFile": "Aucun fichier actif.",
|
||||
"notice.formatUnsupported": "Format non pris en charge : .{0}",
|
||||
"notice.noRules": "Aucune règle pour ce fichier.\nCréez des règles via Ctrl+P → \"Créer une règle\".",
|
||||
"notice.noOccurrences": "Aucune occurrence trouvée pour les règles actives.",
|
||||
"notice.noNerEntities": "Aucune entité détectée par le NER.",
|
||||
"notice.nerEntitiesFound": "✓ {0} {1} détectée{2} — surlignée{2} en bleu.\nClic droit pour créer une règle.",
|
||||
"notice.nerEntitiesFound.entity": "entité",
|
||||
"notice.nerEntitiesFound.entities": "entités",
|
||||
"notice.nerNotEnabled": "La détection NER transformers.js n'est pas activée.\nActivez-la dans Paramètres → Pseudonymizer Tool.",
|
||||
"notice.noDictDetection": "Aucun dictionnaire de détection chargé.\nInstallez un dictionnaire depuis le panneau Dictionnaires.",
|
||||
"notice.noDictEntities": "Aucune entité trouvée dans les dictionnaires de détection.",
|
||||
"notice.noDictReplacements": "Aucun remplacement disponible pour les entités trouvées.",
|
||||
"notice.ruleCreated": "✓ Règle créée : \"{0}\" → \"{1}\"",
|
||||
"notice.rulesCreated": "✓ {0} règle{1} créée{1}",
|
||||
"notice.rulesCreated.rule": "",
|
||||
"notice.rulesCreated.rules": "s",
|
||||
"notice.occurrencesPseudonymized": "✓ {0} occurrence{1} pseudonymisée{1} dans \"{2}\"",
|
||||
"notice.occurrencesPseudonymized.occurrence": "",
|
||||
"notice.occurrencesPseudonymized.occurrences": "s",
|
||||
"notice.converted": "✓ {0} → {1}",
|
||||
"notice.conversionError": "Erreur de conversion de {0} : {1}",
|
||||
"notice.fileExists": "Le fichier existe déjà dans le vault : {0}",
|
||||
"notice.conversionSkipped": "⚠ {0}.md existe déjà — conversion ignorée pour {1}",
|
||||
"notice.exportDone": "✓ {0} règle(s) appliquée(s)\n→ {1}",
|
||||
"notice.mappingExported": "✓ Mapping exporté → {0}",
|
||||
"notice.noMapping": "Aucun mapping trouvé pour {0}",
|
||||
"notice.invalidFormat": "Format invalide : {0}",
|
||||
"notice.dictImported": "✓ {0} dictionnaire{1} importé{1}",
|
||||
"notice.dictImported.one": "",
|
||||
"notice.dictImported.many": "s",
|
||||
"notice.dictRejected": "{0} fichier{1} rejeté{1}",
|
||||
"notice.dictRejected.one": "",
|
||||
"notice.dictRejected.many": "s",
|
||||
"notice.nerModelLoaded": "✓ Modèle NER chargé",
|
||||
"notice.nerModelLoading": "Chargement du modèle NER (première utilisation — ~66 Mo)…",
|
||||
"notice.ruleNotFound": "Règle introuvable dans les mappings.",
|
||||
"notice.noCheckedDicts": "Aucun dictionnaire coché.",
|
||||
|
||||
"command.organizeCorpus": "Organiser le corpus",
|
||||
"command.addTranscription": "Ajouter une transcription",
|
||||
"command.pseudonymizeFile": "Pseudonymiser le fichier courant",
|
||||
"command.createRule": "Créer une règle de remplacement",
|
||||
"command.scanFile": "Scanner le fichier courant",
|
||||
"command.scanNer": "Scanner le fichier avec détection NER",
|
||||
"command.scanDictionaries": "Scanner le fichier avec les dictionnaires",
|
||||
"command.pseudonymizeSelection": "Pseudonymiser la sélection",
|
||||
"command.openPanel": "Pseudonymisation : ouvrir le panneau",
|
||||
|
||||
"contextMenu.cancelPseudonymization": "Annuler la pseudonymisation de \"{0}\"",
|
||||
"contextMenu.editRule": "Modifier la règle pour \"{0}\"",
|
||||
"contextMenu.pseudonymize": "Pseudonymiser \"{0}\"",
|
||||
"ruleModal.delete": "Supprimer la règle",
|
||||
"ruleModal.scopeWarnTitle": "Portée longitudinale",
|
||||
"ruleModal.scopeOkTitle": "Portée fichier",
|
||||
"ruleModal.scopeOk": "Bonne pratique. Ce pseudonyme ne s'appliquera qu'à cette transcription — chaque fichier reste indépendant.",
|
||||
"contextMenu.coulmont": "Pseudonymiser avec Pr Baptiste Coulmont",
|
||||
"contextMenu.createRule": "Créer une règle de remplacement…",
|
||||
|
||||
"ruleModal.title": "Créer une règle de remplacement",
|
||||
"ruleModal.source": "Source",
|
||||
"ruleModal.sourceDesc": "Terme original à remplacer",
|
||||
"ruleModal.replacement": "Remplacement",
|
||||
"ruleModal.replacementDesc": "Pseudonyme ou catégorie analytique",
|
||||
"ruleModal.replacementDescClass": "L'index exact sera calculé à la création selon les règles existantes dans la portée.",
|
||||
"ruleModal.category": "Catégorie",
|
||||
"ruleModal.scope": "Portée",
|
||||
"ruleModal.scopeFile": "Ce fichier uniquement",
|
||||
"ruleModal.scopeFolder": "Ce dossier",
|
||||
"ruleModal.scopeVault": "Tout le vault",
|
||||
"ruleModal.priority": "Priorité",
|
||||
"ruleModal.priorityDesc": "Entier libre, comme un z-index CSS — défaut 0, plus grand = appliqué en premier",
|
||||
"ruleModal.submit": "Créer la règle",
|
||||
"ruleModal.errorMissing": "La source et le remplacement sont obligatoires.",
|
||||
"ruleModal.errorNoFile": "Aucun fichier actif.",
|
||||
"ruleModal.coulomontLabel": "Suggestions de prénoms équivalents — choisissez :",
|
||||
"ruleModal.dictLabel": "Dictionnaire : \"{0}\" → classe {1}",
|
||||
"ruleModal.dictUseClass": "Utiliser \"{0}\" (portée : {1})",
|
||||
|
||||
"category.first_name": "Prénom",
|
||||
"category.last_name": "Nom de famille",
|
||||
"category.full_name": "Nom complet",
|
||||
"category.place": "Lieu",
|
||||
"category.institution": "Institution",
|
||||
"category.date": "Date",
|
||||
"category.age": "Âge",
|
||||
"category.profession": "Profession",
|
||||
"category.custom": "Autre",
|
||||
|
||||
"scope.file": "Fichier",
|
||||
"scope.folder": "Dossier",
|
||||
"scope.vault": "Vault",
|
||||
|
||||
"status.validated": "✓",
|
||||
"status.ignored": "✗",
|
||||
"status.partial": "◑",
|
||||
"status.suggested": "?",
|
||||
"status.conflict": "⚠",
|
||||
"status.disabled": "–",
|
||||
"status.needs_review": "👁",
|
||||
|
||||
"panel.tab.mappings": "Mappings",
|
||||
"panel.tab.dictionaries": "Dictionnaires",
|
||||
"panel.tab.exports": "Exports",
|
||||
"panel.tab.ner": "NER",
|
||||
|
||||
"panel.mappings.filterActive": "Fichier actif uniquement",
|
||||
"panel.mappings.warnBroadName": "⚠ Généraliser un prénom ou un nom de famille au-delà du fichier ne doit être fait que comme choix analytique délibéré — par exemple pour suivre de manière cohérente le même participant dans une étude longitudinale. Même si le même individu apparaît dans plusieurs fichiers, la pseudonymisation à l'échelle du fichier reste préférable : elle préserve l'indépendance de chaque transcription et réduit le risque d'identification croisée.",
|
||||
"panel.mappings.addRule": "Ajouter une règle",
|
||||
"panel.mappings.scanFile": "Scanner le fichier",
|
||||
"panel.mappings.scanning": "Scan en cours…",
|
||||
"panel.mappings.noFile": "Aucun fichier actif.",
|
||||
"panel.mappings.noRules": "Aucune règle pour {0}.",
|
||||
"panel.mappings.noRulesHint": "Aucune règle active pour ce fichier. Créez des règles via le menu contextuel ou la commande \"créer une règle\".",
|
||||
"panel.mappings.col.source": "Source",
|
||||
"panel.mappings.col.replacement": "Remplacement",
|
||||
"panel.mappings.col.category": "Catégorie",
|
||||
"panel.mappings.col.scope": "Portée",
|
||||
"panel.mappings.col.priority": "P.",
|
||||
"panel.mappings.col.status": "Statut",
|
||||
|
||||
"panel.dict.noneInstalled": "Aucun dictionnaire installé. Installez-en un depuis le wizard (Paramètres → Reconfigurer) ou importez un fichier local.",
|
||||
"panel.dict.checkbox": "Inclure dans le scan groupé",
|
||||
"panel.dict.scanAll": "Scanner les dictionnaires cochés",
|
||||
"panel.dict.importLocal": "Importer un fichier local (.dict.json)",
|
||||
"panel.dict.scanWith": "Scanner le fichier actif avec \"{0}\"",
|
||||
"panel.dict.remove": "Supprimer ce dictionnaire",
|
||||
|
||||
"panel.exports.pseudonymize": "Pseudonymiser et exporter",
|
||||
"panel.exports.exportMapping": "Exporter la table de correspondance",
|
||||
|
||||
"panel.ner.scanBtn": "Identifier des candidats",
|
||||
"panel.ner.hint": "Paramètres du scanner de détection automatique des entités nommées.",
|
||||
"panel.ner.threshold": "Seuil de confiance",
|
||||
"panel.ner.thresholdDesc": "Les entités dont le score est inférieur à cette valeur sont ignorées. Plus la valeur est haute, moins de faux positifs mais aussi moins de détections.",
|
||||
"panel.ner.functionWords": "Mots fonctionnels exclus",
|
||||
"panel.ner.functionWordsDesc": "Un mot par ligne. Ces termes ne seront jamais retenus comme entités nommées, même si le modèle les détecte (artefacts de tokenisation).",
|
||||
"panel.ner.save": "Enregistrer",
|
||||
"panel.ner.saved": "Enregistré",
|
||||
"panel.ner.reset": "Réinitialiser par défaut",
|
||||
|
||||
"dictScanModal.title": "Révision du scan — dictionnaires",
|
||||
"dictScanModal.summary": "{0} entité{1} détectée{1} · {2}",
|
||||
"dictScanModal.summary.entity": "",
|
||||
"dictScanModal.summary.entities": "s",
|
||||
"dictScanModal.hint": "Décochez les faux positifs. Le préfixe est éditable ; l'index est calculé automatiquement.",
|
||||
"dictScanModal.apply": "Créer {0} règle{1}",
|
||||
"dictScanModal.apply.rule": "",
|
||||
"dictScanModal.apply.rules": "s",
|
||||
"dictScanModal.noRules": "Aucune règle à appliquer",
|
||||
"dictScanModal.cancel": "Annuler",
|
||||
|
||||
"mappingScanModal.title": "Révision du scan — mappings",
|
||||
"mappingScanModal.summary": "{0} règle{1} active{1} avec des occurrences dans \"{2}\"",
|
||||
"mappingScanModal.summary.rule": "",
|
||||
"mappingScanModal.summary.rules": "s",
|
||||
"mappingScanModal.hint": "Décochez les règles à ne pas appliquer. Les remplacements seront écrits dans le fichier source.",
|
||||
"mappingScanModal.col.source": "",
|
||||
"mappingScanModal.col.replacement": "Remplacement",
|
||||
"mappingScanModal.col.occurrences": "Occ.",
|
||||
"mappingScanModal.apply": "Pseudonymiser ({0} règle{1}, {2} occurrence{3})",
|
||||
"mappingScanModal.apply.rule": "",
|
||||
"mappingScanModal.apply.rules": "s",
|
||||
"mappingScanModal.apply.occurrence": "",
|
||||
"mappingScanModal.apply.occurrences": "s",
|
||||
"mappingScanModal.noRules": "Aucune règle à appliquer",
|
||||
"mappingScanModal.cancel": "Annuler",
|
||||
|
||||
"onboarding.step.storage": "Stockage",
|
||||
"onboarding.step.language": "Langue",
|
||||
"onboarding.step.welcome": "Bienvenue",
|
||||
"onboarding.step.ner": "Détection NER",
|
||||
"onboarding.step.dictionaries": "Dictionnaires",
|
||||
"onboarding.step.summary": "Résumé",
|
||||
"onboarding.welcome.title": "Pseudonymizer Tool",
|
||||
"onboarding.welcome.desc": "Ce plugin vous aide à corriger et pseudonymiser des transcriptions d'entretiens et de corpus interactionnels (conventions Jefferson, ICOR, formats .srt, .cha).",
|
||||
"onboarding.welcome.steps": "L'assistant va vous guider en quelques étapes rapides :",
|
||||
"onboarding.welcome.step1": "Choisir la langue de l'interface.",
|
||||
"onboarding.welcome.step2": "Choisir un moteur de détection automatique des entités nommées (optionnel).",
|
||||
"onboarding.welcome.step3": "Installer des dictionnaires de candidats de remplacement si vous en avez besoin.",
|
||||
"onboarding.welcome.hint": "Vous pourrez reconfigurer ces options à tout moment via les paramètres du plugin.",
|
||||
"onboarding.storage.title": "Stockage",
|
||||
"onboarding.storage.vaultPerCorpus": "Un vault par corpus",
|
||||
"onboarding.storage.vaultPerCorpusDesc": "Nous recommandons d'utiliser un vault Obsidian dédié par corpus. Cela garantit que les fichiers de mapping, les dictionnaires et les exports restent avec leurs transcriptions, et facilite le partage ou l'archivage d'un corpus.",
|
||||
"onboarding.storage.transcriptionsFolder": "Transcriptions",
|
||||
"onboarding.storage.mappingFolder": "Tables de correspondance",
|
||||
"onboarding.storage.dictionariesFolder": "Dictionnaires",
|
||||
"onboarding.storage.exportsFolder": "Exports",
|
||||
"onboarding.storage.hint": "Ces chemins sont relatifs à la racine du vault. Vous pouvez les modifier ultérieurement dans les Paramètres.",
|
||||
"onboarding.language.title": "Langue de l'interface",
|
||||
"onboarding.language.hint": "La langue de l'interface n'a aucun effet sur l'analyse des transcriptions. Les conventions (Jefferson, ICOR) sont indépendantes de la langue.",
|
||||
"onboarding.ner.desc": "Un modèle NER (reconnaissance d'entités nommées) peut détecter automatiquement les prénoms, noms, lieux et institutions dans vos transcriptions — sans liste exhaustive, directement dans Obsidian.",
|
||||
"onboarding.ner.tfjsTitle": "transformers.js — modèle ONNX embarqué",
|
||||
"onboarding.ner.tfjsBadge": "Python n'est pas requis",
|
||||
"onboarding.ner.tfjsDesc": "Modèle NER multilingue exécuté localement dans le vault. Les fichiers .wasm (~19 Mo) sont téléchargés une seule fois ici. Le modèle (~66 Mo) est téléchargé au premier scan.",
|
||||
"onboarding.ner.wasmReady": "Fichiers .wasm déjà installés",
|
||||
"onboarding.ner.installBtn": "Installer et utiliser transformers.js",
|
||||
"onboarding.ner.selectedBtn": "Sélectionné",
|
||||
"onboarding.ner.noneActive": "Désactivé (règles manuelles uniquement)",
|
||||
"onboarding.ner.noneSkip": "Passer — je travaillerai manuellement",
|
||||
|
||||
"onboarding.dict.title": "Dictionnaires de candidats de remplacement",
|
||||
"onboarding.dict.desc": "Les dictionnaires fournissent des candidats de remplacement (villes, prénoms…) et alimentent la détection automatique. Ils sont hébergés dans un dépôt dédié et téléchargés dans votre vault — aucune donnée de transcription ne quitte Obsidian.",
|
||||
"onboarding.dict.catalogueLoading": "Chargement du catalogue…",
|
||||
"onboarding.dict.catalogueError": "Impossible de contacter le catalogue en ligne. Vérifiez votre connexion ou importez un fichier local ci-dessous.",
|
||||
"onboarding.dict.recommended": "Recommandé",
|
||||
"onboarding.dict.col.dict": "Dictionnaire",
|
||||
"onboarding.dict.col.lang": "Langue",
|
||||
"onboarding.dict.col.roles": "Rôles",
|
||||
"onboarding.dict.col.size": "Taille",
|
||||
"onboarding.dict.role.detection": "détection",
|
||||
"onboarding.dict.role.replacement": "remplacement",
|
||||
"onboarding.dict.role.classes": "classes",
|
||||
"onboarding.dict.install": "Installer",
|
||||
"onboarding.dict.reinstall": "Réinstaller",
|
||||
"onboarding.dict.installed": "Installé",
|
||||
"onboarding.dict.manualHint": "Vous avez déjà un fichier .dict.json ? Importez-le manuellement :",
|
||||
"onboarding.dict.importBtn": "Importer un fichier local (.dict.json)",
|
||||
"onboarding.dict.listTitle": "Dictionnaires installés dans ce vault ({0}) :",
|
||||
"onboarding.dict.remove": "Retirer ce dictionnaire",
|
||||
|
||||
"onboarding.summary.ner.none": "Désactivé — règles manuelles uniquement",
|
||||
"onboarding.summary.ner.tfjs": "transformers.js — modèle ONNX (téléchargement au premier scan)",
|
||||
|
||||
"onboarding.nav.cancel": "Annuler",
|
||||
"onboarding.nav.back": "Retour",
|
||||
"onboarding.nav.skip": "Passer cette étape",
|
||||
"onboarding.nav.next": "Suivant",
|
||||
"onboarding.nav.start": "Commencer",
|
||||
"onboarding.nav.finish": "Commencer à travailler",
|
||||
"onboarding.summary.title": "Configuration terminée",
|
||||
"onboarding.summary.intro": "Voici ce qui a été configuré :",
|
||||
"onboarding.summary.ner": "Détection NER",
|
||||
"onboarding.summary.dicts": "Dictionnaires",
|
||||
"onboarding.summary.dicts.count": "{0} fichier(s) dans le vault",
|
||||
"onboarding.summary.hint": "Ces paramètres sont modifiables à tout moment via les paramètres du plugin : Reconfigurer.",
|
||||
|
||||
"settings.heading.general": "Général",
|
||||
"settings.heading.textDetection": "Détection du texte",
|
||||
"settings.heading.pseudonymization": "Pseudonymisation",
|
||||
"settings.heading.ner": "Détection automatique (NER)",
|
||||
"settings.heading.storage": "Stockage",
|
||||
"settings.heading.security": "Sécurité",
|
||||
"settings.language": "Langue de l'interface",
|
||||
"settings.languageDesc": "N'affecte pas l'analyse des transcriptions. Les conventions (Jefferson, ICOR) sont indépendantes de la langue.",
|
||||
"settings.vaultPerCorpus": "Un vault par corpus",
|
||||
"settings.vaultPerCorpusDesc": "Nous recommandons d'utiliser un vault Obsidian dédié par corpus. Cela garantit que les fichiers de mapping, les dictionnaires et les exports restent avec leurs transcriptions.",
|
||||
"settings.transcriptionsFolder": "Transcriptions importées",
|
||||
"settings.transcriptionsFolderDesc": "Dossier de destination des transcriptions importées.",
|
||||
"settings.mappingFolder": "Tables de correspondance",
|
||||
"settings.mappingFolderDesc": "Chemin relatif dans le vault.",
|
||||
"settings.dictionariesFolder": "Dictionnaires",
|
||||
"settings.dictionariesFolderDesc": "Chemin relatif dans le vault.",
|
||||
"settings.exportsFolder": "Exports",
|
||||
"settings.accentSensitive": "Sensible aux accents",
|
||||
"settings.preserveCase": "Préserver la casse",
|
||||
"settings.preserveCaseDesc": "Adapter la casse du remplacement à celle de la source (ex. : JEAN → PIERRE, jean → pierre, Jean → Pierre).",
|
||||
"settings.preserveAnalyticNotation": "Préserver les notations analytiques",
|
||||
"settings.preserveAnalyticNotationDesc": "Ne jamais remplacer les symboles de convention analytique de type Jefferson ou ICOR.",
|
||||
"settings.useMarkerInExport": "Ajouter un marqueur autour des pseudonymes dans l'export",
|
||||
"settings.useMarkerInExportDesc": "Permet d'identifier visuellement les termes pseudonymisés dans le fichier exporté.",
|
||||
"settings.markerOpen": "Marqueur ouvrant",
|
||||
"settings.markerOpenDesc": "Exemple : {{ ⟦ [ «",
|
||||
"settings.markerClose": "Marqueur fermant",
|
||||
"settings.markerCloseDesc": "Exemple : }} ⟧ ] »",
|
||||
"settings.nerBackend": "Moteur de détection",
|
||||
"settings.nerBackendDesc": "Backend utilisé pour la détection automatique des entités nommées identifiantes.",
|
||||
"settings.nerBackend.none": "Désactivé — règles manuelles uniquement",
|
||||
"settings.nerBackend.tfjs": "transformers.js (modèle ONNX embarqué)",
|
||||
"settings.reconfigure": "Assistant de configuration",
|
||||
"settings.reconfigureDesc2": "Relancer l'assistant pour reconfigurer la détection NER et les dictionnaires.",
|
||||
"settings.reconfigureBtn2": "Reconfigurer…",
|
||||
"settings.warnIfSyncedFolder": "Avertir si le dossier est synchronisé",
|
||||
"settings.warnIfSyncedFolderDesc": "Alerter si les tables de correspondance sont dans un dossier Git, iCloud ou Synology Drive.",
|
||||
"settings.caseSensitive": "Sensible à la casse",
|
||||
"settings.caseSensitiveDesc": "Si activé, \"Jean\" et \"jean\" sont traités comme distincts.",
|
||||
"settings.wholeWordOnly": "Mots entiers uniquement",
|
||||
"settings.wholeWordOnlyDesc": "Si activé, seules les occurrences de mots complets sont remplacées."
|
||||
}
|
||||
116
src/main.ts
116
src/main.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { Plugin, Notice, TFile, TAbstractFile, Editor, Menu, MarkdownView, requestUrl, WorkspaceLeaf } from 'obsidian';
|
||||
import { t, setLocale } from './i18n';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { PseudObsSettings, DEFAULT_SETTINGS, PseudObsSettingTab } from './settings';
|
||||
import { RuleModal } from './ui/RuleModal';
|
||||
|
|
@ -10,6 +11,7 @@ import { OnboardingModal } from './ui/OnboardingModal';
|
|||
import { OnnxNerScanner } from './scanner/OnnxNerScanner';
|
||||
import { DictionaryLoader } from './dictionaries/DictionaryLoader';
|
||||
import { DictScanReviewModal } from './ui/DictScanReviewModal';
|
||||
import { CorpusModal, ClassSelectModal, getCorpusClasses } from './ui/CorpusModal';
|
||||
import type { DictScanResultItem } from './ui/DictScanReviewModal';
|
||||
import { MappingScanReviewModal } from './ui/MappingScanReviewModal';
|
||||
import type { MappingRuleResult } from './ui/MappingScanReviewModal';
|
||||
|
|
@ -39,6 +41,7 @@ export default class PseudObsPlugin extends Plugin {
|
|||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
setLocale(this.settings.language);
|
||||
this.scopeResolver = new ScopeResolver(this.app.vault, this.settings.mappingFolder);
|
||||
this.nerScanner = new OnnxNerScanner(this.app);
|
||||
this.dictionaryLoader = new DictionaryLoader(this.app, this);
|
||||
|
|
@ -79,21 +82,27 @@ export default class PseudObsPlugin extends Plugin {
|
|||
})
|
||||
);
|
||||
|
||||
this.addCommand({
|
||||
id: 'organize-corpus',
|
||||
name: t('command.organizeCorpus'),
|
||||
callback: () => new CorpusModal(this.app, this).open(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'add-transcription',
|
||||
name: 'Ajouter une transcription',
|
||||
name: t('command.addTranscription'),
|
||||
callback: () => this.openFilePicker(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'pseudonymize-current-file',
|
||||
name: 'Pseudonymiser le fichier courant',
|
||||
name: t('command.pseudonymizeFile'),
|
||||
callback: () => this.pseudonymizeActiveFile(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'create-rule',
|
||||
name: 'Créer une règle de remplacement',
|
||||
name: t('command.createRule'),
|
||||
editorCallback: (editor) => {
|
||||
new RuleModal(this.app, this, editor.getSelection()).open();
|
||||
},
|
||||
|
|
@ -101,25 +110,25 @@ export default class PseudObsPlugin extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'scan-current-file',
|
||||
name: 'Scanner le fichier courant',
|
||||
name: t('command.scanFile'),
|
||||
callback: () => this.scanCurrentFile(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'scan-ner',
|
||||
name: 'Scanner le fichier avec détection NER',
|
||||
name: t('command.scanNer'),
|
||||
callback: () => void this.scanCurrentFileNer(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'scan-dictionaries',
|
||||
name: 'Scanner le fichier avec les dictionnaires',
|
||||
name: t('command.scanDictionaries'),
|
||||
callback: () => void this.scanCurrentFileWithDictionaries(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'pseudonymize-selection',
|
||||
name: 'Pseudonymiser la sélection',
|
||||
name: t('command.pseudonymizeSelection'),
|
||||
editorCheckCallback: (checking, editor) => {
|
||||
if (!editor.getSelection()) return false;
|
||||
if (!checking) new QuickPseudonymizeModal(this.app, this, editor).open();
|
||||
|
|
@ -144,16 +153,16 @@ export default class PseudObsPlugin extends Plugin {
|
|||
const isReplacement = this.highlightData.replacements.some((r) => r.toLowerCase() === bareLower);
|
||||
const isKnown = isSource || isReplacement;
|
||||
|
||||
const truncate = (s: string) => s.slice(0, 25) + (s.length > 25 ? '…' : '');
|
||||
|
||||
if (isReplacement) {
|
||||
// Terme déjà pseudonymisé : proposer l'annulation en premier
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle(`Annuler la pseudonymisation de "${bare.slice(0, 25)}${bare.length > 25 ? '…' : ''}"`)
|
||||
.setTitle(t('contextMenu.cancelPseudonymization', truncate(bare)))
|
||||
.setIcon('undo')
|
||||
.onClick(async () => {
|
||||
const location = await this.scopeResolver.findRuleByTerm(bare);
|
||||
if (!location) { new Notice('Règle introuvable dans les mappings.'); return; }
|
||||
// Remplacer la sélection (avec ou sans marqueurs) par la source originale
|
||||
if (!location) { new Notice(t('notice.ruleNotFound')); return; }
|
||||
editor.replaceSelection(location.rule.source);
|
||||
void this.refreshHighlightData();
|
||||
})
|
||||
|
|
@ -163,14 +172,14 @@ export default class PseudObsPlugin extends Plugin {
|
|||
if (isKnown) {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle(`Modifier la règle pour "${bare.slice(0, 25)}${bare.length > 25 ? '…' : ''}"`)
|
||||
.setTitle(t('contextMenu.editRule', truncate(bare)))
|
||||
.setIcon('settings')
|
||||
.onClick(async () => {
|
||||
const location = await this.scopeResolver.findRuleByTerm(bare);
|
||||
if (location) {
|
||||
new EditRuleModal(this.app, this, location).open();
|
||||
} else {
|
||||
new Notice('Règle introuvable dans les mappings.');
|
||||
new Notice(t('notice.ruleNotFound'));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
@ -178,14 +187,14 @@ export default class PseudObsPlugin extends Plugin {
|
|||
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle(`Pseudonymiser "${selection.slice(0, 25)}${selection.length > 25 ? '…' : ''}"`)
|
||||
.setTitle(t('contextMenu.pseudonymize', truncate(selection)))
|
||||
.setIcon('eye-off')
|
||||
.onClick(() => new QuickPseudonymizeModal(this.app, this, editor).open())
|
||||
);
|
||||
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle(`Pseudonymiser avec Pr Baptiste Coulmont`)
|
||||
.setTitle(t('contextMenu.coulmont'))
|
||||
.setIcon('book-user')
|
||||
.onClick(async () => {
|
||||
const notice = new Notice('Recherche sur coulmont.com…', 0);
|
||||
|
|
@ -200,7 +209,7 @@ export default class PseudObsPlugin extends Plugin {
|
|||
);
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Créer une règle de remplacement…')
|
||||
.setTitle(t('contextMenu.createRule'))
|
||||
.setIcon('pencil')
|
||||
.onClick(() => new RuleModal(this.app, this, selection).open())
|
||||
);
|
||||
|
|
@ -315,13 +324,12 @@ export default class PseudObsPlugin extends Plugin {
|
|||
|
||||
// Si un .md du même nom existe déjà, ne pas écraser
|
||||
if (this.app.vault.getAbstractFileByPath(mdPath) instanceof TFile) {
|
||||
new Notice(`⚠ ${basename}.md existe déjà — conversion ignorée pour ${file.name}`);
|
||||
new Notice(t('notice.conversionSkipped', basename, file.name));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.app.vault.create(mdPath, mdContent);
|
||||
|
||||
// Mapping JSON vide
|
||||
const mappingPath = `${this.settings.mappingFolder}/${basename}.mapping.json`;
|
||||
if (!this.app.vault.getAbstractFileByPath(mappingPath)) {
|
||||
await this.ensureFolder(this.settings.mappingFolder);
|
||||
|
|
@ -329,18 +337,16 @@ export default class PseudObsPlugin extends Plugin {
|
|||
await this.app.vault.create(mappingPath, JSON.stringify(store.toJSON(), null, 2));
|
||||
}
|
||||
|
||||
// Supprimer le fichier source non-Markdown maintenant remplacé par le .md
|
||||
await this.app.fileManager.trashFile(file);
|
||||
|
||||
// Ouvrir le .md
|
||||
const mdFile = this.app.vault.getAbstractFileByPath(mdPath);
|
||||
if (mdFile instanceof TFile) {
|
||||
await this.app.workspace.getLeaf().openFile(mdFile);
|
||||
}
|
||||
|
||||
new Notice(`✓ ${file.name} → ${basename}.md`);
|
||||
new Notice(t('notice.converted', file.name, `${basename}.md`));
|
||||
} catch (e) {
|
||||
new Notice(`Erreur de conversion de ${file.name} : ${(e as Error).message}`);
|
||||
new Notice(t('notice.conversionError', file.name, (e as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,17 +376,26 @@ export default class PseudObsPlugin extends Plugin {
|
|||
|
||||
private async copyToVault(browserFile: File): Promise<void> {
|
||||
const raw = await browserFile.text();
|
||||
const destFolder = this.settings.transcriptionsFolder;
|
||||
await this.ensureFolder(destFolder);
|
||||
const destPath = `${destFolder}/${browserFile.name}`;
|
||||
|
||||
// Sélection de classe si le corpus est organisé en sous-dossiers
|
||||
const classes = getCorpusClasses(this.app, this.settings.transcriptionsFolder);
|
||||
let targetFolder = this.settings.transcriptionsFolder;
|
||||
|
||||
if (classes.length > 0) {
|
||||
const modal = new ClassSelectModal(this.app, this, classes);
|
||||
const chosen = await modal.prompt();
|
||||
if (chosen === undefined) return; // annulé
|
||||
if (chosen !== null) targetFolder = `${this.settings.transcriptionsFolder}/${chosen}`;
|
||||
}
|
||||
|
||||
await this.ensureFolder(targetFolder);
|
||||
const destPath = `${targetFolder}/${browserFile.name}`;
|
||||
|
||||
if (this.app.vault.getAbstractFileByPath(destPath) instanceof TFile) {
|
||||
new Notice(`Le fichier existe déjà dans le vault : ${browserFile.name}`);
|
||||
new Notice(t('notice.fileExists', browserFile.name));
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer le fichier brut dans le vault — le watcher vault.on('create') prendra le relai
|
||||
// pour les formats convertibles (.srt, .cha, .chat)
|
||||
await this.app.vault.create(destPath, raw);
|
||||
}
|
||||
|
||||
|
|
@ -388,21 +403,18 @@ export default class PseudObsPlugin extends Plugin {
|
|||
|
||||
async pseudonymizeActiveFile(): Promise<void> {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) { new Notice('Aucun fichier actif.'); return; }
|
||||
if (!file) { new Notice(t('notice.noActiveFile')); return; }
|
||||
|
||||
const ext = file.extension.toLowerCase();
|
||||
if (!['srt', 'cha', 'chat', 'md', 'txt'].includes(ext)) {
|
||||
new Notice(`Format non pris en charge : .${ext}`);
|
||||
new Notice(t('notice.formatUnsupported', ext));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.app.vault.read(file);
|
||||
// Charger les règles depuis les trois niveaux (fichier + dossier + vault)
|
||||
const rules = await this.scopeResolver.getRulesFor(file.path);
|
||||
if (rules.length === 0) {
|
||||
new Notice(
|
||||
`Aucune règle validée.\nCréez des règles via Ctrl+P → "Créer une règle".\nMapping attendu : ${this.settings.mappingFolder}/${file.basename}.mapping.json`
|
||||
);
|
||||
new Notice(t('notice.noRules'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -445,7 +457,7 @@ export default class PseudObsPlugin extends Plugin {
|
|||
await this.app.vault.create(outputPath, pseudonymized);
|
||||
}
|
||||
|
||||
new Notice(`✓ ${rules.length} règle(s) appliquée(s)\n→ ${outputPath}`);
|
||||
new Notice(t('notice.exportDone', String(rules.length), outputPath));
|
||||
}
|
||||
|
||||
async scanCurrentFileNer(): Promise<void> {
|
||||
|
|
@ -455,11 +467,11 @@ export default class PseudObsPlugin extends Plugin {
|
|||
}
|
||||
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) { new Notice('Aucun fichier actif.'); return; }
|
||||
if (!file) { new Notice(t('notice.noActiveFile')); return; }
|
||||
|
||||
const ext = file.extension.toLowerCase();
|
||||
if (!['srt', 'cha', 'chat', 'md', 'txt'].includes(ext)) {
|
||||
new Notice(`Format non pris en charge : .${ext}`);
|
||||
new Notice(t('notice.formatUnsupported', ext));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -471,39 +483,33 @@ export default class PseudObsPlugin extends Plugin {
|
|||
});
|
||||
|
||||
if (occurrences.length === 0) {
|
||||
new Notice('Aucune entité détectée par le NER.');
|
||||
new Notice(t('notice.noNerEntities'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Dédoublonner — un terme peut apparaître plusieurs fois dans le texte
|
||||
const unique = [...new Set(occurrences.map((o) => o.text).filter(Boolean))];
|
||||
|
||||
this.nerCandidateFile = file;
|
||||
this.nerCandidates = unique;
|
||||
|
||||
void this.refreshHighlightData();
|
||||
|
||||
new Notice(
|
||||
`✓ ${unique.length} entité${unique.length > 1 ? 's' : ''} détectée${unique.length > 1 ? 's' : ''} — surlignée${unique.length > 1 ? 's' : ''} en bleu.\nClic droit sur un terme pour créer une règle.`,
|
||||
6000
|
||||
);
|
||||
new Notice(t('notice.nerEntitiesFound', String(unique.length), unique.length > 1 ? t('notice.nerEntitiesFound.entities') : t('notice.nerEntitiesFound.entity')), 6000);
|
||||
} catch (e) {
|
||||
new Notice(`Erreur NER : ${(e as Error).message}`);
|
||||
new Notice(`NER error: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async scanCurrentFileWithDictionaries(dictIds?: string[]): Promise<void> {
|
||||
if (!this.dictionaryLoader.hasDetection()) {
|
||||
new Notice('Aucun dictionnaire de détection chargé.\nInstallez un dictionnaire depuis le panneau Dictionnaires.');
|
||||
new Notice(t('notice.noDictDetection'));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) { new Notice('Aucun fichier actif.'); return; }
|
||||
if (!file) { new Notice(t('notice.noActiveFile')); return; }
|
||||
|
||||
const ext = file.extension.toLowerCase();
|
||||
if (!['srt', 'cha', 'chat', 'md', 'txt'].includes(ext)) {
|
||||
new Notice(`Format non pris en charge : .${ext}`);
|
||||
new Notice(t('notice.formatUnsupported', ext));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -514,7 +520,7 @@ export default class PseudObsPlugin extends Plugin {
|
|||
const occurrences = this.dictionaryLoader.scanText(content, file.path, existingSources, dictIds);
|
||||
|
||||
if (occurrences.length === 0) {
|
||||
new Notice('Aucune entité trouvée dans les dictionnaires de détection.');
|
||||
new Notice(t('notice.noDictEntities'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +593,7 @@ export default class PseudObsPlugin extends Plugin {
|
|||
const mappingFile = this.app.vault.getAbstractFileByPath(mappingPath);
|
||||
|
||||
if (!(mappingFile instanceof TFile)) {
|
||||
new Notice(`Aucun mapping trouvé pour ${file.name}`);
|
||||
new Notice(t('notice.noMapping', file.name));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -602,16 +608,16 @@ export default class PseudObsPlugin extends Plugin {
|
|||
await this.app.vault.create(destPath, content);
|
||||
}
|
||||
|
||||
new Notice(`✓ Mapping exporté → ${destPath}`);
|
||||
new Notice(t('notice.mappingExported', destPath));
|
||||
}
|
||||
|
||||
private async scanCurrentFile(): Promise<void> {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) { new Notice('Aucun fichier actif.'); return; }
|
||||
if (!file) { new Notice(t('notice.noActiveFile')); return; }
|
||||
|
||||
const rules = await this.scopeResolver.getRulesFor(file.path);
|
||||
if (rules.length === 0) {
|
||||
new Notice('Aucune règle pour ce fichier.\nCréez des règles via Ctrl+P → "Créer une règle".');
|
||||
new Notice(t('notice.noRules'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -622,7 +628,7 @@ export default class PseudObsPlugin extends Plugin {
|
|||
});
|
||||
|
||||
if (occurrences.length === 0) {
|
||||
new Notice('Aucune occurrence trouvée pour les règles actives.');
|
||||
new Notice(t('notice.noOccurrences'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,30 @@ export class ScopeResolver {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Retourne TOUTES les règles de TOUS les fichiers de mapping, sans filtre de scope.
|
||||
// Utilisé par l'onglet Mappings en mode "toutes les règles".
|
||||
async getAllRulesWithLocation(): Promise<RuleLocation[]> {
|
||||
const folder = this.vault.getAbstractFileByPath(this.mappingFolder);
|
||||
if (!(folder instanceof TFolder)) return [];
|
||||
|
||||
const result: RuleLocation[] = [];
|
||||
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile) || !child.name.endsWith('.mapping.json')) continue;
|
||||
try {
|
||||
const data = JSON.parse(await this.vault.read(child)) as MappingFile;
|
||||
const store = MappingStore.fromJSON(data);
|
||||
for (const rule of store.getAll()) {
|
||||
result.push({ rule, store, filePath: child.path });
|
||||
}
|
||||
} catch {
|
||||
// ignorer les fichiers malformés
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Charge les règles validées d'un fichier de mapping spécifique, sans filtre de scope.
|
||||
// Utilisé pour les fichiers exportés (*.pseudonymized.*) dont le scope path ne correspond pas.
|
||||
async getRulesFromMappingFile(mappingFilename: string): Promise<MappingRule[]> {
|
||||
|
|
|
|||
194
src/settings.ts
194
src/settings.ts
|
|
@ -1,6 +1,7 @@
|
|||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import type PseudObsPlugin from './main';
|
||||
import { OnboardingModal } from './ui/OnboardingModal';
|
||||
import { t, setLocale, AVAILABLE_LANGUAGES } from './i18n';
|
||||
|
||||
export type NerBackend = 'none' | 'spacy' | 'transformers-js';
|
||||
|
||||
|
|
@ -19,6 +20,8 @@ export interface PseudObsSettings {
|
|||
useMarkerInExport: boolean;
|
||||
markerOpen: string;
|
||||
markerClose: string;
|
||||
// Langue de l'interface
|
||||
language: string;
|
||||
// Onboarding
|
||||
onboardingCompleted: boolean;
|
||||
nerBackend: NerBackend;
|
||||
|
|
@ -43,6 +46,7 @@ export const DEFAULT_SETTINGS: PseudObsSettings = {
|
|||
useMarkerInExport: true,
|
||||
markerOpen: '{{',
|
||||
markerClose: '}}',
|
||||
language: 'en',
|
||||
onboardingCompleted: false,
|
||||
nerBackend: 'none',
|
||||
spacyServerUrl: 'http://localhost:5757',
|
||||
|
|
@ -67,69 +71,40 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl).setName('Dossiers').setHeading();
|
||||
// ---- Général -------------------------------------------------------
|
||||
new Setting(containerEl).setName(t('settings.heading.general')).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Transcriptions importées')
|
||||
.setDesc('Dossier de destination des transcriptions importées')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.transcriptionsFolder).onChange(async (value) => {
|
||||
this.plugin.settings.transcriptionsFolder = value;
|
||||
.setName(t('settings.language'))
|
||||
.setDesc(t('settings.languageDesc'))
|
||||
.addDropdown((d) => {
|
||||
for (const [code, name] of Object.entries(AVAILABLE_LANGUAGES)) {
|
||||
d.addOption(code, name);
|
||||
}
|
||||
d.setValue(this.plugin.settings.language);
|
||||
d.onChange(async (v) => {
|
||||
this.plugin.settings.language = v;
|
||||
await this.plugin.saveSettings();
|
||||
setLocale(v);
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.reconfigure'))
|
||||
.setDesc(t('settings.reconfigureDesc2'))
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText(t('settings.reconfigureBtn2')).onClick(() => {
|
||||
new OnboardingModal(this.app, this.plugin).open();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Tables de correspondance')
|
||||
.setDesc('Chemin relatif dans le vault')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.mappingFolder).onChange(async (value) => {
|
||||
this.plugin.settings.mappingFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
// ---- Détection du texte --------------------------------------------
|
||||
new Setting(containerEl).setName(t('settings.heading.textDetection')).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Dictionnaires')
|
||||
.setDesc('Chemin relatif dans le vault')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.dictionariesFolder).onChange(async (value) => {
|
||||
this.plugin.settings.dictionariesFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Exports')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.exportsFolder).onChange(async (value) => {
|
||||
this.plugin.settings.exportsFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName('Remplacement').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Sensible à la casse')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.caseSensitive).onChange(async (value) => {
|
||||
this.plugin.settings.caseSensitive = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Sensible aux accents')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.accentSensitive).onChange(async (value) => {
|
||||
this.plugin.settings.accentSensitive = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Mots entiers uniquement')
|
||||
.setName(t('settings.wholeWordOnly'))
|
||||
.setDesc(t('settings.wholeWordOnlyDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.wholeWordOnly).onChange(async (value) => {
|
||||
this.plugin.settings.wholeWordOnly = value;
|
||||
|
|
@ -138,8 +113,30 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Préserver la casse')
|
||||
.setDesc('Adapter la casse du remplacement à celle de la source (ex. : JEAN → PIERRE, jean → pierre, Jean → Pierre)')
|
||||
.setName(t('settings.caseSensitive'))
|
||||
.setDesc(t('settings.caseSensitiveDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.caseSensitive).onChange(async (value) => {
|
||||
this.plugin.settings.caseSensitive = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.accentSensitive'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.accentSensitive).onChange(async (value) => {
|
||||
this.plugin.settings.accentSensitive = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// ---- Pseudonymisation ----------------------------------------------
|
||||
new Setting(containerEl).setName(t('settings.heading.pseudonymization')).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.preserveCase'))
|
||||
.setDesc(t('settings.preserveCaseDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.preserveCase).onChange(async (value) => {
|
||||
this.plugin.settings.preserveCase = value;
|
||||
|
|
@ -148,8 +145,8 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Préserver les notations analytiques')
|
||||
.setDesc('Ne jamais remplacer les symboles de convention analytique de type Jefferson ou ICOR')
|
||||
.setName(t('settings.preserveAnalyticNotation'))
|
||||
.setDesc(t('settings.preserveAnalyticNotationDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.preserveAnalyticNotation)
|
||||
|
|
@ -159,11 +156,9 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName("Marqueur d'export").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Ajouter un marqueur autour des pseudonymes dans l\'export')
|
||||
.setDesc('Permet d\'identifier visuellement les termes pseudonymisés dans le fichier exporté')
|
||||
.setName(t('settings.useMarkerInExport'))
|
||||
.setDesc(t('settings.useMarkerInExportDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.useMarkerInExport).onChange(async (value) => {
|
||||
this.plugin.settings.useMarkerInExport = value;
|
||||
|
|
@ -172,8 +167,8 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Marqueur ouvrant')
|
||||
.setDesc('Exemple : {{ ⟦ [ «')
|
||||
.setName(t('settings.markerOpen'))
|
||||
.setDesc(t('settings.markerOpenDesc'))
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.markerOpen).onChange(async (value) => {
|
||||
this.plugin.settings.markerOpen = value;
|
||||
|
|
@ -182,8 +177,8 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Marqueur fermant')
|
||||
.setDesc('Exemple : }} ⟧ ] »')
|
||||
.setName(t('settings.markerClose'))
|
||||
.setDesc(t('settings.markerCloseDesc'))
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.markerClose).onChange(async (value) => {
|
||||
this.plugin.settings.markerClose = value;
|
||||
|
|
@ -191,14 +186,15 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName('Détection automatique (NER)').setHeading();
|
||||
// ---- Détection NER -------------------------------------------------
|
||||
new Setting(containerEl).setName(t('settings.heading.ner')).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Moteur de détection')
|
||||
.setDesc('Backend utilisé pour la détection automatique des entités nommées identifiantes')
|
||||
.setName(t('settings.nerBackend'))
|
||||
.setDesc(t('settings.nerBackendDesc'))
|
||||
.addDropdown((d) => {
|
||||
d.addOption('none', 'Désactivé — règles manuelles uniquement');
|
||||
d.addOption('transformers-js', 'transformers.js (modèle ONNX embarqué)');
|
||||
d.addOption('none', t('settings.nerBackend.none'));
|
||||
d.addOption('transformers-js', t('settings.nerBackend.tfjs'));
|
||||
d.setValue(this.plugin.settings.nerBackend);
|
||||
d.onChange(async (v) => {
|
||||
this.plugin.settings.nerBackend = v as PseudObsSettings['nerBackend'];
|
||||
|
|
@ -207,20 +203,58 @@ export class PseudObsSettingTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
// ---- Stockage -------------------------------------------------------
|
||||
new Setting(containerEl).setName(t('settings.heading.storage')).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Assistant de configuration')
|
||||
.setDesc('Relancer l\'assistant pour reconfigurer la détection NER et les dictionnaires')
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('Reconfigurer…').onClick(() => {
|
||||
new OnboardingModal(this.app, this.plugin).open();
|
||||
.setName(t('settings.transcriptionsFolder'))
|
||||
.setDesc(t('settings.transcriptionsFolderDesc'))
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.transcriptionsFolder).onChange(async (value) => {
|
||||
this.plugin.settings.transcriptionsFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName('Sécurité').setHeading();
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.mappingFolder'))
|
||||
.setDesc(t('settings.mappingFolderDesc'))
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.mappingFolder).onChange(async (value) => {
|
||||
this.plugin.settings.mappingFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Avertir si le dossier est synchronisé')
|
||||
.setDesc('Alerter si les tables de correspondance sont dans un dossier Git, iCloud ou Synology Drive')
|
||||
.setName(t('settings.dictionariesFolder'))
|
||||
.setDesc(t('settings.dictionariesFolderDesc'))
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.dictionariesFolder).onChange(async (value) => {
|
||||
this.plugin.settings.dictionariesFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.exportsFolder'))
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.exportsFolder).onChange(async (value) => {
|
||||
this.plugin.settings.exportsFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// ---- Sécurité -------------------------------------------------------
|
||||
new Setting(containerEl).setName(t('settings.heading.security')).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.vaultPerCorpus'))
|
||||
.setDesc(t('settings.vaultPerCorpusDesc'));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.warnIfSyncedFolder'))
|
||||
.setDesc(t('settings.warnIfSyncedFolderDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.warnIfSyncedFolder).onChange(async (value) => {
|
||||
this.plugin.settings.warnIfSyncedFolder = value;
|
||||
|
|
|
|||
227
src/ui/CorpusModal.ts
Normal file
227
src/ui/CorpusModal.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { App, Modal, Notice, TFolder } from 'obsidian';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import { t } from '../i18n';
|
||||
|
||||
/**
|
||||
* Retourne les sous-dossiers directs d'un dossier du vault.
|
||||
* Ce sont les "classes" de l'organisation du corpus.
|
||||
*/
|
||||
export function getCorpusClasses(app: App, transcriptionsFolder: string): string[] {
|
||||
const folder = app.vault.getAbstractFileByPath(transcriptionsFolder);
|
||||
if (!(folder instanceof TFolder)) return [];
|
||||
return folder.children
|
||||
.filter((c): c is TFolder => c instanceof TFolder)
|
||||
.map((c) => c.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modale de gestion de l'organisation du corpus par classes (sous-dossiers).
|
||||
* Créer une classe = créer le sous-dossier dans Transcriptions/ + miroir dans mappings/ et exports/.
|
||||
*/
|
||||
export class CorpusModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
|
||||
constructor(app: App, plugin: PseudObsPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('pseudobs-corpus-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: t('corpus.modal.title') });
|
||||
contentEl.createEl('p', { text: t('corpus.modal.hint'), cls: 'pseudobs-view-hint' });
|
||||
|
||||
const classes = getCorpusClasses(this.app, this.plugin.settings.transcriptionsFolder);
|
||||
|
||||
if (classes.length === 0) {
|
||||
contentEl.createEl('p', { text: t('corpus.modal.noClasses'), cls: 'pseudobs-view-hint' });
|
||||
} else {
|
||||
const list = contentEl.createEl('ul', { cls: 'pseudobs-corpus-class-list' });
|
||||
for (const cls of classes) {
|
||||
this.renderClassRow(list, cls);
|
||||
}
|
||||
}
|
||||
|
||||
// Bouton ajouter
|
||||
const addRow = contentEl.createDiv('pseudobs-corpus-add-row');
|
||||
const input = addRow.createEl('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = t('corpus.modal.classNamePlaceholder');
|
||||
input.addClass('pseudobs-corpus-add-input');
|
||||
|
||||
const addBtn = addRow.createEl('button', {
|
||||
text: t('corpus.modal.addClass'),
|
||||
cls: 'pseudobs-view-add-btn mod-cta',
|
||||
});
|
||||
addBtn.addEventListener('click', () => void this.addClass(input.value));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') void this.addClass(input.value);
|
||||
});
|
||||
|
||||
// Bouton fermer
|
||||
contentEl.createEl('hr');
|
||||
contentEl.createEl('button', { text: t('corpus.modal.close'), cls: 'pseudobs-view-action-btn' })
|
||||
.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
private renderClassRow(list: HTMLElement, cls: string): void {
|
||||
const s = this.plugin.settings;
|
||||
const transcPath = `${s.transcriptionsFolder}/${cls}`;
|
||||
|
||||
// Compter les fichiers dans le dossier transcriptions
|
||||
const folder = this.app.vault.getAbstractFileByPath(transcPath);
|
||||
const fileCount = folder instanceof TFolder
|
||||
? folder.children.filter((c) => !(c instanceof TFolder)).length
|
||||
: 0;
|
||||
|
||||
const li = list.createEl('li', { cls: 'pseudobs-corpus-class-item' });
|
||||
|
||||
const nameWrap = li.createDiv('pseudobs-corpus-class-name');
|
||||
nameWrap.createEl('strong', { text: cls });
|
||||
nameWrap.createEl('small', {
|
||||
text: t('corpus.modal.files', String(fileCount)),
|
||||
cls: 'pseudobs-corpus-class-count',
|
||||
});
|
||||
|
||||
// Miroir : indiquer les dossiers associés
|
||||
const mirrorPaths = [
|
||||
`${s.mappingFolder}/${cls}`,
|
||||
`${s.exportsFolder}/${cls}`,
|
||||
];
|
||||
const mirrorEl = li.createEl('small', { cls: 'pseudobs-corpus-class-mirror' });
|
||||
mirrorEl.setText(mirrorPaths.join(' · '));
|
||||
|
||||
// Bouton supprimer
|
||||
const delBtn = li.createEl('button', { cls: 'pseudobs-dict-card-remove' });
|
||||
delBtn.setText('✕');
|
||||
delBtn.title = t('corpus.modal.deleteConfirm', cls);
|
||||
delBtn.addEventListener('click', () => void this.deleteClass(cls));
|
||||
}
|
||||
|
||||
private async addClass(name: string): Promise<void> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
new Notice(t('corpus.modal.classNameEmpty'));
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = getCorpusClasses(this.app, this.plugin.settings.transcriptionsFolder);
|
||||
if (existing.includes(trimmed)) {
|
||||
new Notice(t('corpus.modal.classExists'));
|
||||
return;
|
||||
}
|
||||
|
||||
const s = this.plugin.settings;
|
||||
await this.plugin.ensureFolder(`${s.transcriptionsFolder}/${trimmed}`);
|
||||
await this.plugin.ensureFolder(`${s.mappingFolder}/${trimmed}`);
|
||||
await this.plugin.ensureFolder(`${s.exportsFolder}/${trimmed}`);
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async deleteClass(name: string): Promise<void> {
|
||||
const s = this.plugin.settings;
|
||||
const paths = [
|
||||
`${s.transcriptionsFolder}/${name}`,
|
||||
`${s.mappingFolder}/${name}`,
|
||||
`${s.exportsFolder}/${name}`,
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
const item = this.app.vault.getAbstractFileByPath(p);
|
||||
if (item) await this.app.fileManager.trashFile(item);
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal de sélection de classe à l'import d'une transcription.
|
||||
* Retourne le nom de la classe choisie, ou null si l'utilisateur choisit "sans classe".
|
||||
* Retourne undefined si l'utilisateur annule.
|
||||
*/
|
||||
export class ClassSelectModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
private classes: string[];
|
||||
private resolve!: (value: string | null | undefined) => void;
|
||||
|
||||
constructor(app: App, plugin: PseudObsPlugin, classes: string[]) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.classes = classes;
|
||||
}
|
||||
|
||||
open(): this {
|
||||
super.open();
|
||||
return this;
|
||||
}
|
||||
|
||||
prompt(): Promise<string | null | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
this.resolve = resolve;
|
||||
super.open();
|
||||
});
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: t('corpus.select.title') });
|
||||
contentEl.createEl('p', { text: t('corpus.select.hint'), cls: 'pseudobs-view-hint' });
|
||||
|
||||
let selected: string | null = null;
|
||||
|
||||
const list = contentEl.createEl('ul', { cls: 'pseudobs-corpus-class-list' });
|
||||
|
||||
// Option "sans classe"
|
||||
const noneItem = list.createEl('li', { cls: 'pseudobs-corpus-select-item' });
|
||||
const noneBtn = noneItem.createEl('button', {
|
||||
text: t('corpus.select.none'),
|
||||
cls: 'pseudobs-onboarding-none-btn',
|
||||
});
|
||||
noneBtn.addEventListener('click', () => {
|
||||
selected = null;
|
||||
list.querySelectorAll('.pseudobs-corpus-select-item-active').forEach((el) =>
|
||||
el.removeClass('pseudobs-corpus-select-item-active')
|
||||
);
|
||||
noneItem.addClass('pseudobs-corpus-select-item-active');
|
||||
});
|
||||
|
||||
for (const cls of this.classes) {
|
||||
const item = list.createEl('li', { cls: 'pseudobs-corpus-select-item' });
|
||||
const btn = item.createEl('button', { text: cls, cls: 'pseudobs-onboarding-select-btn' });
|
||||
btn.addEventListener('click', () => {
|
||||
selected = cls;
|
||||
list.querySelectorAll('.pseudobs-corpus-select-item-active').forEach((el) =>
|
||||
el.removeClass('pseudobs-corpus-select-item-active')
|
||||
);
|
||||
item.addClass('pseudobs-corpus-select-item-active');
|
||||
btn.addClass('pseudobs-onboarding-select-btn-active');
|
||||
});
|
||||
}
|
||||
|
||||
const footer = contentEl.createDiv('pseudobs-dict-review-footer');
|
||||
footer.createEl('button', { text: t('corpus.modal.close') })
|
||||
.addEventListener('click', () => { this.resolve(undefined); this.close(); });
|
||||
footer.createEl('button', { text: t('corpus.select.confirm'), cls: 'mod-cta' })
|
||||
.addEventListener('click', () => { this.resolve(selected); this.close(); });
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
// Si fermé sans confirmation (croix), résoudre avec undefined
|
||||
if (this.resolve) this.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, Modal, Notice, TFile } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import { MappingStore } from '../mappings/MappingStore';
|
||||
import type { EntityCategory, MappingFile } from '../types';
|
||||
|
|
@ -53,11 +54,12 @@ export class DictScanReviewModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
contentEl.addClass('pseudobs-dict-review-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: 'Révision du scan — dictionnaires' });
|
||||
contentEl.createEl('h2', { text: t('dictScanModal.title') });
|
||||
|
||||
const dicts = [...new Set(this.results.map((r) => r.dictLabel))];
|
||||
const n = this.results.length;
|
||||
contentEl.createEl('p', {
|
||||
text: `${this.results.length} entité${this.results.length > 1 ? 's' : ''} détectée${this.results.length > 1 ? 's' : ''} · ${dicts.join(', ')}`,
|
||||
text: t('dictScanModal.summary', String(n), n > 1 ? t('dictScanModal.summary.entities') : t('dictScanModal.summary.entity'), dicts.join(', ')),
|
||||
cls: 'pseudobs-scan-summary',
|
||||
});
|
||||
|
||||
|
|
@ -72,7 +74,7 @@ export class DictScanReviewModal extends Modal {
|
|||
const termWrap = header.createDiv('pseudobs-dict-review-card-term-wrap');
|
||||
termWrap.createEl('span', { text: item.term, cls: 'pseudobs-dict-review-term' });
|
||||
termWrap.createEl('span', {
|
||||
text: item.category.replace('_', ' '),
|
||||
text: t(`category.${item.category}`),
|
||||
cls: 'pseudobs-dict-review-cat',
|
||||
});
|
||||
|
||||
|
|
@ -139,7 +141,7 @@ export class DictScanReviewModal extends Modal {
|
|||
|
||||
// Pied de page
|
||||
const footer = contentEl.createDiv('pseudobs-dict-review-footer');
|
||||
footer.createEl('button', { text: 'Annuler' })
|
||||
footer.createEl('button', { text: t('dictScanModal.cancel') })
|
||||
.addEventListener('click', () => this.close());
|
||||
|
||||
this.applyBtn = footer.createEl('button', { cls: 'mod-cta' });
|
||||
|
|
@ -198,8 +200,8 @@ export class DictScanReviewModal extends Modal {
|
|||
private updateApplyLabel(): void {
|
||||
const n = this.checked.filter(Boolean).length;
|
||||
this.applyBtn.textContent = n === 0
|
||||
? 'Aucune règle à appliquer'
|
||||
: `Créer ${n} règle${n > 1 ? 's' : ''}`;
|
||||
? t('dictScanModal.noRules')
|
||||
: t('dictScanModal.apply', String(n), n > 1 ? t('dictScanModal.apply.rules') : t('dictScanModal.apply.rule'));
|
||||
this.applyBtn.toggleClass('pseudobs-dict-review-btn-empty', n === 0);
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +247,7 @@ export class DictScanReviewModal extends Modal {
|
|||
}
|
||||
|
||||
const n = toCreate.length;
|
||||
new Notice(`✓ ${n} règle${n > 1 ? 's' : ''} créée${n > 1 ? 's' : ''}`);
|
||||
new Notice(t('notice.rulesCreated', String(n), n > 1 ? t('notice.rulesCreated.rules') : t('notice.rulesCreated.rule')));
|
||||
void this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { App, Modal, Notice, Setting } from 'obsidian';
|
|||
import type PseudObsPlugin from '../main';
|
||||
import type { EntityCategory, ScopeType } from '../types';
|
||||
import type { RuleLocation } from '../mappings/ScopeResolver';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export class EditRuleModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
private location: RuleLocation;
|
||||
|
||||
// Valeurs éditables — initialisées depuis la règle existante
|
||||
private replacement: string;
|
||||
private category: EntityCategory;
|
||||
private scopeType: ScopeType;
|
||||
|
|
@ -27,67 +27,94 @@ export class EditRuleModal extends Modal {
|
|||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
const { rule } = this.location;
|
||||
contentEl.createEl('h2', { text: 'Modifier la règle' });
|
||||
contentEl.createEl('h2', { text: t('ruleModal.title') });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Source')
|
||||
.setName(t('ruleModal.source'))
|
||||
.setDesc('Non modifiable — créez une nouvelle règle pour changer la source')
|
||||
.addText((t) => {
|
||||
t.setValue(rule.source).setDisabled(true);
|
||||
t.inputEl.addClass('pseudobs-disabled-input');
|
||||
.addText((tx) => {
|
||||
tx.setValue(rule.source).setDisabled(true);
|
||||
tx.inputEl.addClass('pseudobs-disabled-input');
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Remplacement')
|
||||
.addText((t) =>
|
||||
t.setValue(this.replacement).onChange((v) => (this.replacement = v))
|
||||
.setName(t('ruleModal.replacement'))
|
||||
.addText((tx) =>
|
||||
tx.setValue(this.replacement).onChange((v) => (this.replacement = v))
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Catégorie')
|
||||
.setName(t('ruleModal.category'))
|
||||
.addDropdown((d) => {
|
||||
const options: Record<EntityCategory, string> = {
|
||||
first_name: 'Prénom', last_name: 'Nom de famille', full_name: 'Nom complet',
|
||||
place: 'Lieu', institution: 'Institution', date: 'Date',
|
||||
age: 'Âge', profession: 'Profession', custom: 'Autre',
|
||||
};
|
||||
for (const [value, label] of Object.entries(options)) d.addOption(value, label);
|
||||
const cats: EntityCategory[] = ['first_name','last_name','full_name','place','institution','date','age','profession','custom'];
|
||||
for (const cat of cats) d.addOption(cat, t(`category.${cat}`));
|
||||
d.setValue(this.category);
|
||||
d.onChange((v) => (this.category = v as EntityCategory));
|
||||
d.onChange((v) => {
|
||||
this.category = v as EntityCategory;
|
||||
updateWarn();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Portée')
|
||||
.setName(t('ruleModal.scope'))
|
||||
.addDropdown((d) => {
|
||||
d.addOption('file', 'Ce fichier uniquement');
|
||||
d.addOption('folder', 'Ce dossier');
|
||||
d.addOption('vault', 'Tout le vault');
|
||||
d.addOption('file', t('ruleModal.scopeFile'));
|
||||
d.addOption('folder', t('ruleModal.scopeFolder'));
|
||||
d.addOption('vault', t('ruleModal.scopeVault'));
|
||||
d.setValue(this.scopeType);
|
||||
d.onChange((v) => (this.scopeType = v as ScopeType));
|
||||
d.onChange((v) => {
|
||||
this.scopeType = v as ScopeType;
|
||||
updateWarn();
|
||||
});
|
||||
});
|
||||
|
||||
// Callout dynamique
|
||||
const calloutEl = contentEl.createDiv();
|
||||
const isNameCat = () => ['first_name', 'last_name', 'full_name'].includes(this.category);
|
||||
const isBroad = () => this.scopeType !== 'file';
|
||||
const updateWarn = () => {
|
||||
if (!isNameCat()) { calloutEl.hide(); return; }
|
||||
calloutEl.empty();
|
||||
calloutEl.show();
|
||||
if (isBroad()) {
|
||||
calloutEl.setAttribute('data-callout', 'warning');
|
||||
calloutEl.className = 'callout pseudobs-rule-callout';
|
||||
calloutEl.createDiv('callout-title').createSpan({ text: t('ruleModal.scopeWarnTitle') });
|
||||
calloutEl.createDiv('callout-content').createEl('p', { text: t('panel.mappings.warnBroadName') });
|
||||
} else {
|
||||
calloutEl.setAttribute('data-callout', 'success');
|
||||
calloutEl.className = 'callout pseudobs-rule-callout';
|
||||
calloutEl.createDiv('callout-title').createSpan({ text: t('ruleModal.scopeOkTitle') });
|
||||
calloutEl.createDiv('callout-content').createEl('p', { text: t('ruleModal.scopeOk') });
|
||||
}
|
||||
};
|
||||
// État initial
|
||||
if (isNameCat()) {
|
||||
calloutEl.show();
|
||||
updateWarn();
|
||||
} else {
|
||||
calloutEl.hide();
|
||||
}
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Priorité')
|
||||
.setDesc('Entier libre, comme un z-index CSS — défaut 0')
|
||||
.addText((t) =>
|
||||
t.setValue(String(this.priority)).onChange((v) => (this.priority = parseInt(v, 10) || 0))
|
||||
.setName(t('ruleModal.priority'))
|
||||
.setDesc(t('ruleModal.priorityDesc'))
|
||||
.addText((tx) =>
|
||||
tx.setValue(String(this.priority)).onChange((v) => (this.priority = parseInt(v, 10) || 0))
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('Enregistrer').setCta().onClick(() => this.save())
|
||||
btn.setButtonText(t('panel.ner.save')).setCta().onClick(() => void this.save())
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Supprimer la règle')
|
||||
.setWarning()
|
||||
.onClick(() => this.delete())
|
||||
btn.setButtonText(t('ruleModal.delete')).setWarning().onClick(() => void this.delete())
|
||||
);
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (!this.replacement.trim()) {
|
||||
new Notice('Le remplacement est obligatoire.');
|
||||
new Notice(t('ruleModal.errorMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +127,7 @@ export class EditRuleModal extends Modal {
|
|||
});
|
||||
|
||||
await this.plugin.scopeResolver.saveStore(store, filePath);
|
||||
new Notice(`✓ Règle mise à jour : "${rule.source}" → "${this.replacement.trim()}"`);
|
||||
new Notice(t('notice.ruleCreated', rule.source, this.replacement.trim()));
|
||||
void this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
|
@ -109,7 +136,7 @@ export class EditRuleModal extends Modal {
|
|||
const { store, filePath, rule } = this.location;
|
||||
store.remove(rule.id);
|
||||
await this.plugin.scopeResolver.saveStore(store, filePath);
|
||||
new Notice(`✓ Règle supprimée : "${rule.source}"`);
|
||||
new Notice(`✓ "${rule.source}"`);
|
||||
void this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, Modal, Notice, TFile } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import type { MappingRule } from '../types';
|
||||
import { findSpansForRule } from '../pseudonymizer/ReplacementPlanner';
|
||||
|
|
@ -37,22 +38,20 @@ export class MappingScanReviewModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
contentEl.addClass('pseudobs-dict-review-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: 'Révision du scan — mappings' });
|
||||
contentEl.createEl('h2', { text: t('mappingScanModal.title') });
|
||||
const nr = this.ruleResults.length;
|
||||
contentEl.createEl('p', {
|
||||
text: `${this.ruleResults.length} règle${this.ruleResults.length > 1 ? 's' : ''} active${this.ruleResults.length > 1 ? 's' : ''} avec des occurrences dans "${this.file.name}"`,
|
||||
text: t('mappingScanModal.summary', String(nr), nr > 1 ? t('mappingScanModal.summary.rules') : t('mappingScanModal.summary.rule'), this.file.name),
|
||||
cls: 'pseudobs-scan-summary',
|
||||
});
|
||||
contentEl.createEl('p', {
|
||||
text: 'Décochez les règles à ne pas appliquer. Les remplacements seront écrits dans le fichier source.',
|
||||
cls: 'pseudobs-view-hint',
|
||||
});
|
||||
contentEl.createEl('p', { text: t('mappingScanModal.hint'), cls: 'pseudobs-view-hint' });
|
||||
|
||||
const scroll = contentEl.createDiv('pseudobs-dict-review-scroll');
|
||||
const table = scroll.createEl('table', { cls: 'pseudobs-dict-review-table' });
|
||||
|
||||
const thead = table.createEl('thead');
|
||||
const hr = thead.createEl('tr');
|
||||
['', 'Source', '', 'Remplacement', 'Occ.'].forEach((h) =>
|
||||
['', 'Source', '', t('mappingScanModal.col.replacement'), t('mappingScanModal.col.occurrences')].forEach((h) =>
|
||||
hr.createEl('th', { text: h })
|
||||
);
|
||||
|
||||
|
|
@ -90,7 +89,7 @@ export class MappingScanReviewModal extends Modal {
|
|||
});
|
||||
|
||||
const footer = contentEl.createDiv('pseudobs-dict-review-footer');
|
||||
footer.createEl('button', { text: 'Annuler' })
|
||||
footer.createEl('button', { text: t('mappingScanModal.cancel') })
|
||||
.addEventListener('click', () => this.close());
|
||||
|
||||
this.applyBtn = footer.createEl('button', { cls: 'mod-cta' });
|
||||
|
|
@ -104,8 +103,10 @@ export class MappingScanReviewModal extends Modal {
|
|||
.filter((_, i) => this.checked[i])
|
||||
.reduce((sum, r) => sum + r.matchCount, 0);
|
||||
this.applyBtn.textContent = n === 0
|
||||
? 'Aucune règle à appliquer'
|
||||
: `Pseudonymiser (${n} règle${n > 1 ? 's' : ''}, ${total} occurrence${total > 1 ? 's' : ''})`;
|
||||
? t('mappingScanModal.noRules')
|
||||
: t('mappingScanModal.apply',
|
||||
String(n), n > 1 ? t('mappingScanModal.apply.rules') : t('mappingScanModal.apply.rule'),
|
||||
String(total), total > 1 ? t('mappingScanModal.apply.occurrences') : t('mappingScanModal.apply.occurrence'));
|
||||
this.applyBtn.toggleClass('pseudobs-dict-review-btn-empty', n === 0);
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +138,7 @@ export class MappingScanReviewModal extends Modal {
|
|||
|
||||
const resolved = resolveSpans(allSpans);
|
||||
if (resolved.length === 0) {
|
||||
new Notice('Aucune occurrence à remplacer.');
|
||||
new Notice(t('notice.noOccurrences'));
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
|
@ -147,7 +148,10 @@ export class MappingScanReviewModal extends Modal {
|
|||
void this.plugin.refreshHighlightData();
|
||||
|
||||
const total = resolved.length;
|
||||
new Notice(`✓ ${total} occurrence${total > 1 ? 's' : ''} pseudonymisée${total > 1 ? 's' : ''} dans "${this.file.name}"`);
|
||||
new Notice(t('notice.occurrencesPseudonymized',
|
||||
String(total),
|
||||
total > 1 ? t('notice.occurrencesPseudonymized.occurrences') : t('notice.occurrencesPseudonymized.occurrence'),
|
||||
this.file.name));
|
||||
this.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as path from 'path';
|
|||
import type PseudObsPlugin from '../main';
|
||||
import type { NerBackend } from '../settings';
|
||||
import type { DictionaryFile, DictionaryManifest, DictionaryManifestEntry } from '../types';
|
||||
import { t, setLocale, AVAILABLE_LANGUAGES } from '../i18n';
|
||||
|
||||
// Version du package @xenova/transformers embarqué — doit rester synchronisée avec package.json
|
||||
const TRANSFORMERS_VERSION = '2.17.2';
|
||||
|
|
@ -16,16 +17,20 @@ const WASM_FILES = [
|
|||
'ort-wasm.wasm',
|
||||
] as const;
|
||||
|
||||
type Step = 'welcome' | 'ner' | 'dictionaries' | 'summary';
|
||||
type Step = 'welcome' | 'language' | 'storage' | 'ner' | 'dictionaries' | 'summary';
|
||||
|
||||
const STEPS: Step[] = ['welcome', 'ner', 'dictionaries', 'summary'];
|
||||
const STEPS: Step[] = ['welcome', 'language', 'storage', 'ner', 'dictionaries', 'summary'];
|
||||
|
||||
const STEP_LABELS: Record<Step, string> = {
|
||||
welcome: 'Bienvenue',
|
||||
ner: 'Détection NER',
|
||||
dictionaries: 'Dictionnaires',
|
||||
summary: 'Résumé',
|
||||
};
|
||||
function stepLabels(): Record<Step, string> {
|
||||
return {
|
||||
welcome: t('onboarding.step.welcome'),
|
||||
language: t('onboarding.step.language'),
|
||||
storage: t('onboarding.step.storage'),
|
||||
ner: t('onboarding.step.ner'),
|
||||
dictionaries: t('onboarding.step.dictionaries'),
|
||||
summary: t('onboarding.step.summary'),
|
||||
};
|
||||
}
|
||||
|
||||
export class OnboardingModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
|
|
@ -63,10 +68,12 @@ export class OnboardingModal extends Modal {
|
|||
|
||||
const body = contentEl.createDiv('pseudobs-onboarding-body');
|
||||
|
||||
if (this.currentStep === 'welcome') this.renderWelcome(body);
|
||||
else if (this.currentStep === 'ner') this.renderNer(body);
|
||||
if (this.currentStep === 'welcome') this.renderWelcome(body);
|
||||
else if (this.currentStep === 'language') this.renderLanguage(body);
|
||||
else if (this.currentStep === 'storage') this.renderStorage(body);
|
||||
else if (this.currentStep === 'ner') this.renderNer(body);
|
||||
else if (this.currentStep === 'dictionaries') this.renderDictionaries(body);
|
||||
else this.renderSummary(body);
|
||||
else this.renderSummary(body);
|
||||
|
||||
this.renderNav(contentEl);
|
||||
}
|
||||
|
|
@ -80,60 +87,115 @@ export class OnboardingModal extends Modal {
|
|||
if (step === this.currentStep) dot.addClass('pseudobs-onboarding-step-current');
|
||||
else if (STEPS.indexOf(step) < STEPS.indexOf(this.currentStep))
|
||||
dot.addClass('pseudobs-onboarding-step-done');
|
||||
dot.createSpan({ text: STEP_LABELS[step] });
|
||||
dot.createSpan({ text: stepLabels()[step] });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Étape 1 : Bienvenue ----------------------------------------
|
||||
|
||||
private renderWelcome(el: HTMLElement): void {
|
||||
el.createEl('h2', { text: 'Pseudonymizer tool' });
|
||||
el.createEl('p', {
|
||||
text: 'Ce plugin vous aide à corriger et pseudonymiser des transcriptions d\'entretiens et de corpus interactionnels (formats Jefferson, ICOR, .srt, .cha).',
|
||||
});
|
||||
el.createEl('p', {
|
||||
text: 'L\'assistant va vous guider en deux étapes rapides :',
|
||||
});
|
||||
el.createEl('h2', { text: t('onboarding.welcome.title') });
|
||||
el.createEl('p', { text: t('onboarding.welcome.desc') });
|
||||
el.createEl('p', { text: t('onboarding.welcome.steps') });
|
||||
const list = el.createEl('ul');
|
||||
list.createEl('li', { text: 'Choisir un moteur de détection automatique des entités nommées (optionnel).' });
|
||||
list.createEl('li', { text: 'Importer des dictionnaires de candidats de remplacement (.dict.json) si vous en avez.' });
|
||||
el.createEl('p', {
|
||||
text: 'Vous pourrez reconfigurer ces options à tout moment via les paramètres du plugin.',
|
||||
cls: 'pseudobs-onboarding-hint',
|
||||
});
|
||||
list.createEl('li', { text: t('onboarding.welcome.step1') });
|
||||
list.createEl('li', { text: t('onboarding.welcome.step2') });
|
||||
list.createEl('li', { text: t('onboarding.welcome.step3') });
|
||||
el.createEl('p', { text: t('onboarding.welcome.hint'), cls: 'pseudobs-onboarding-hint' });
|
||||
}
|
||||
|
||||
// ---- Étape 2 : NER ----------------------------------------------
|
||||
// ---- Étape 2 : Langue -------------------------------------------
|
||||
|
||||
private renderNer(el: HTMLElement): void {
|
||||
el.createEl('h2', { text: 'Détection automatique des entités' });
|
||||
private renderLanguage(el: HTMLElement): void {
|
||||
// Titre toujours bilingue — l'utilisateur n'a pas encore choisi de langue
|
||||
el.createEl('h2', { text: 'Interface language / Langue de l\'interface' });
|
||||
el.createEl('p', {
|
||||
text: 'Un modèle NER (reconnaissance d\'entités nommées) peut détecter automatiquement les prénoms, noms, lieux et institutions dans vos transcriptions — sans liste exhaustive, directement dans Obsidian.',
|
||||
text: 'The interface language does not affect transcript analysis. / La langue de l\'interface n\'affecte pas l\'analyse des transcriptions.',
|
||||
cls: 'pseudobs-onboarding-hint',
|
||||
});
|
||||
|
||||
// --- Carte transformers.js ---
|
||||
const currentLang = this.plugin.settings.language;
|
||||
|
||||
for (const [code, name] of Object.entries(AVAILABLE_LANGUAGES)) {
|
||||
const card = el.createDiv('pseudobs-onboarding-ner-card');
|
||||
if (currentLang === code) card.addClass('pseudobs-onboarding-ner-card-selected');
|
||||
|
||||
card.createEl('strong', { text: name });
|
||||
|
||||
const btn = card.createEl('button', {
|
||||
text: currentLang === code ? 'Selected' : 'Select',
|
||||
cls: 'pseudobs-onboarding-select-btn',
|
||||
});
|
||||
if (currentLang === code) btn.addClass('pseudobs-onboarding-select-btn-active');
|
||||
|
||||
btn.addEventListener('click', () => { void (async () => {
|
||||
this.plugin.settings.language = code;
|
||||
await this.plugin.saveSettings();
|
||||
setLocale(code);
|
||||
this.scheduleRender();
|
||||
})(); });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Étape 3 : Stockage -----------------------------------------
|
||||
|
||||
private renderStorage(el: HTMLElement): void {
|
||||
el.createEl('h2', { text: t('onboarding.storage.title') });
|
||||
|
||||
// Recommandation 1 vault / corpus
|
||||
const rec = el.createDiv('pseudobs-onboarding-ner-card pseudobs-onboarding-ner-card-selected');
|
||||
const recHeader = rec.createDiv('pseudobs-onboarding-ner-card-header');
|
||||
recHeader.createEl('strong', { text: t('onboarding.storage.vaultPerCorpus') });
|
||||
rec.createEl('p', { text: t('onboarding.storage.vaultPerCorpusDesc') });
|
||||
|
||||
el.createEl('hr');
|
||||
el.createEl('p', { text: t('onboarding.storage.hint'), cls: 'pseudobs-onboarding-hint' });
|
||||
|
||||
const fields: [string, keyof typeof this.plugin.settings][] = [
|
||||
[t('onboarding.storage.transcriptionsFolder'), 'transcriptionsFolder'],
|
||||
[t('onboarding.storage.mappingFolder'), 'mappingFolder'],
|
||||
[t('onboarding.storage.dictionariesFolder'), 'dictionariesFolder'],
|
||||
[t('onboarding.storage.exportsFolder'), 'exportsFolder'],
|
||||
];
|
||||
|
||||
for (const [label, key] of fields) {
|
||||
const row = el.createDiv('pseudobs-onboarding-url-row');
|
||||
row.createEl('small', { text: label });
|
||||
const input = row.createEl('input');
|
||||
input.type = 'text';
|
||||
input.value = String(this.plugin.settings[key]);
|
||||
input.addEventListener('change', () => { void (async () => {
|
||||
(this.plugin.settings as unknown as Record<string, unknown>)[key] = input.value.trim() || String(this.plugin.settings[key]);
|
||||
await this.plugin.saveSettings();
|
||||
})(); });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Étape 4 : NER ----------------------------------------------
|
||||
|
||||
private renderNer(el: HTMLElement): void {
|
||||
el.createEl('h2', { text: t('onboarding.step.ner') });
|
||||
el.createEl('p', { text: t('onboarding.ner.desc') });
|
||||
|
||||
const cardTfjs = el.createDiv('pseudobs-onboarding-ner-card');
|
||||
if (this.nerBackend === 'transformers-js') cardTfjs.addClass('pseudobs-onboarding-ner-card-selected');
|
||||
|
||||
const tfjsHeader = cardTfjs.createDiv('pseudobs-onboarding-ner-card-header');
|
||||
tfjsHeader.createEl('strong', { text: 'transformers.js — modèle ONNX embarqué' });
|
||||
tfjsHeader.createEl('span', { text: 'Python n\'est pas requis', cls: 'pseudobs-onboarding-badge' });
|
||||
tfjsHeader.createEl('strong', { text: t('onboarding.ner.tfjsTitle') });
|
||||
tfjsHeader.createEl('span', { text: t('onboarding.ner.tfjsBadge'), cls: 'pseudobs-onboarding-badge' });
|
||||
|
||||
cardTfjs.createEl('p', {
|
||||
text: 'Modèle NER multilingue exécuté localement dans le vault. Les fichiers .wasm (~19 Mo) sont téléchargés une seule fois ici. Le modèle (~66 Mo) est téléchargé au premier scan.',
|
||||
});
|
||||
cardTfjs.createEl('p', { text: t('onboarding.ner.tfjsDesc') });
|
||||
|
||||
const wasmRow = cardTfjs.createDiv('pseudobs-onboarding-url-row');
|
||||
const wasmStatus = wasmRow.createSpan({ cls: 'pseudobs-onboarding-test-status' });
|
||||
|
||||
const wasmAlreadyPresent = this.checkWasmFiles();
|
||||
if (wasmAlreadyPresent) {
|
||||
wasmStatus.setText('Fichiers .wasm déjà installés');
|
||||
if (this.checkWasmFiles()) {
|
||||
wasmStatus.setText(t('onboarding.ner.wasmReady'));
|
||||
wasmStatus.classList.add('pseudobs-onboarding-test-ok');
|
||||
}
|
||||
|
||||
const selectTfjs = cardTfjs.createEl('button', {
|
||||
text: this.nerBackend === 'transformers-js' ? 'Sélectionné' : 'Installer et utiliser transformers.js',
|
||||
text: this.nerBackend === 'transformers-js' ? t('onboarding.ner.selectedBtn') : t('onboarding.ner.installBtn'),
|
||||
cls: 'pseudobs-onboarding-select-btn',
|
||||
});
|
||||
if (this.nerBackend === 'transformers-js') selectTfjs.addClass('pseudobs-onboarding-select-btn-active');
|
||||
|
|
@ -141,19 +203,16 @@ export class OnboardingModal extends Modal {
|
|||
selectTfjs.addEventListener('click', () => { void (async () => {
|
||||
this.nerBackend = 'transformers-js';
|
||||
await this.saveNerSettings();
|
||||
|
||||
if (!this.checkWasmFiles()) {
|
||||
const ok = await this.downloadWasmFiles(wasmStatus, selectTfjs);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
this.scheduleRender();
|
||||
})(); });
|
||||
|
||||
// --- Désactiver ---
|
||||
const noneRow = el.createDiv('pseudobs-onboarding-none-row');
|
||||
const noneBtn = noneRow.createEl('button', {
|
||||
text: this.nerBackend === 'none' ? 'Désactivé (règles manuelles uniquement)' : 'Passer — je travaillerai manuellement',
|
||||
text: this.nerBackend === 'none' ? t('onboarding.ner.noneActive') : t('onboarding.ner.noneSkip'),
|
||||
cls: 'pseudobs-onboarding-none-btn',
|
||||
});
|
||||
if (this.nerBackend === 'none') noneBtn.addClass('pseudobs-onboarding-none-btn-active');
|
||||
|
|
@ -227,27 +286,19 @@ export class OnboardingModal extends Modal {
|
|||
// ---- Étape 3 : Dictionnaires ------------------------------------
|
||||
|
||||
private renderDictionaries(el: HTMLElement): void {
|
||||
el.createEl('h2', { text: 'Dictionnaires de candidats' });
|
||||
el.createEl('p', {
|
||||
text: 'Les dictionnaires proposent des candidats de remplacement (villes, prénoms…) et alimentent la détection. Ils sont hébergés dans un dépôt dédié et téléchargés dans votre vault — aucune donnée ne quitte Obsidian.',
|
||||
});
|
||||
el.createEl('h2', { text: t('onboarding.dict.title') });
|
||||
el.createEl('p', { text: t('onboarding.dict.desc') });
|
||||
|
||||
// Zone catalogue (remplie de façon asynchrone)
|
||||
const catalogueEl = el.createDiv('pseudobs-onboarding-catalogue');
|
||||
catalogueEl.createEl('p', { text: 'Chargement du catalogue…', cls: 'pseudobs-onboarding-hint' });
|
||||
catalogueEl.createEl('p', { text: t('onboarding.dict.catalogueLoading'), cls: 'pseudobs-onboarding-hint' });
|
||||
void this.renderCatalogue(catalogueEl);
|
||||
|
||||
// Séparateur
|
||||
el.createEl('hr');
|
||||
|
||||
// Import manuel (fallback offline)
|
||||
const manualRow = el.createDiv('pseudobs-onboarding-import-row');
|
||||
manualRow.createEl('small', {
|
||||
text: 'Vous avez déjà un fichier .dict.json ? Importez-le manuellement :',
|
||||
cls: 'pseudobs-onboarding-hint',
|
||||
});
|
||||
manualRow.createEl('small', { text: t('onboarding.dict.manualHint'), cls: 'pseudobs-onboarding-hint' });
|
||||
const importBtn = manualRow.createEl('button', {
|
||||
text: 'Importer un fichier local (.dict.json)',
|
||||
text: t('onboarding.dict.importBtn'),
|
||||
cls: 'pseudobs-onboarding-import-btn',
|
||||
});
|
||||
const importStatus = manualRow.createSpan({ cls: 'pseudobs-onboarding-test-status' });
|
||||
|
|
@ -273,10 +324,7 @@ export class OnboardingModal extends Modal {
|
|||
manifest = res.json as DictionaryManifest;
|
||||
} catch {
|
||||
container.empty();
|
||||
container.createEl('p', {
|
||||
text: 'Impossible de contacter le catalogue en ligne. Vérifiez votre connexion ou importez un fichier local ci-dessous.',
|
||||
cls: 'pseudobs-onboarding-hint',
|
||||
});
|
||||
container.createEl('p', { text: t('onboarding.dict.catalogueError'), cls: 'pseudobs-onboarding-hint' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -287,7 +335,7 @@ export class OnboardingModal extends Modal {
|
|||
|
||||
const thead = table.createEl('thead');
|
||||
const headerRow = thead.createEl('tr');
|
||||
['Dictionnaire', 'Langue', 'Rôles', 'Taille', ''].forEach((h) =>
|
||||
[t('onboarding.dict.col.dict'), t('onboarding.dict.col.lang'), t('onboarding.dict.col.roles'), t('onboarding.dict.col.size'), ''].forEach((h) =>
|
||||
headerRow.createEl('th', { text: h })
|
||||
);
|
||||
|
||||
|
|
@ -306,26 +354,22 @@ export class OnboardingModal extends Modal {
|
|||
const nameCell = tr.createEl('td', { cls: 'pseudobs-onboarding-dict-name-cell' });
|
||||
nameCell.createEl('span', { text: entry.label });
|
||||
if (entry.recommended) {
|
||||
nameCell.createEl('span', { text: 'Recommandé', cls: 'pseudobs-onboarding-badge' });
|
||||
nameCell.createEl('span', { text: t('onboarding.dict.recommended'), cls: 'pseudobs-onboarding-badge' });
|
||||
}
|
||||
|
||||
// Langue
|
||||
tr.createEl('td', { text: entry.language.toUpperCase() });
|
||||
|
||||
// Rôles
|
||||
const roles: string[] = [];
|
||||
if (entry.roles.detection) roles.push('détection');
|
||||
if (entry.roles.replacement) roles.push('remplacement');
|
||||
if (entry.roles.classes) roles.push('classes');
|
||||
if (entry.roles.detection) roles.push(t('onboarding.dict.role.detection'));
|
||||
if (entry.roles.replacement) roles.push(t('onboarding.dict.role.replacement'));
|
||||
if (entry.roles.classes) roles.push(t('onboarding.dict.role.classes'));
|
||||
tr.createEl('td', { text: roles.join(' · '), cls: 'pseudobs-onboarding-dict-roles' });
|
||||
|
||||
// Taille
|
||||
tr.createEl('td', { text: this.formatSize(entry.size), cls: 'pseudobs-onboarding-dict-size' });
|
||||
|
||||
// Bouton icône uniquement
|
||||
const actionCell = tr.createEl('td', { cls: 'pseudobs-onboarding-dict-action' });
|
||||
const btn = actionCell.createEl('button', { cls: 'pseudobs-onboarding-icon-btn' });
|
||||
btn.setAttribute('aria-label', alreadyInstalled ? 'Réinstaller' : 'Installer');
|
||||
btn.setAttribute('aria-label', alreadyInstalled ? t('onboarding.dict.reinstall') : t('onboarding.dict.install'));
|
||||
setIcon(btn, alreadyInstalled ? 'cloud-check' : 'cloud-download');
|
||||
if (alreadyInstalled) btn.addClass('pseudobs-onboarding-icon-btn-done');
|
||||
|
||||
|
|
@ -411,17 +455,17 @@ export class OnboardingModal extends Modal {
|
|||
ok++;
|
||||
} catch {
|
||||
err++;
|
||||
new Notice(`Erreur lors de l'import de ${f.name} — vérifiez le format .dict.json`);
|
||||
new Notice(t('onboarding.dict.importError', f.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (ok > 0) {
|
||||
statusEl.setText(`${ok} dictionnaire${ok > 1 ? 's' : ''} importé${ok > 1 ? 's' : ''}`);
|
||||
statusEl.setText(ok > 1 ? t('onboarding.dict.importOkMany', String(ok)) : t('onboarding.dict.importOk', String(ok)));
|
||||
statusEl.addClass('pseudobs-onboarding-test-ok');
|
||||
void this.plugin.dictionaryLoader.load();
|
||||
}
|
||||
if (err > 0) {
|
||||
statusEl.setText(`${err} fichier${err > 1 ? 's' : ''} rejeté${err > 1 ? 's' : ''}`);
|
||||
statusEl.setText(err > 1 ? t('onboarding.dict.importErrMany', String(err)) : t('onboarding.dict.importErr', String(err)));
|
||||
statusEl.addClass('pseudobs-onboarding-test-err');
|
||||
}
|
||||
|
||||
|
|
@ -443,7 +487,7 @@ export class OnboardingModal extends Modal {
|
|||
if (files.length === 0) return;
|
||||
|
||||
el.createEl('p', {
|
||||
text: `Dictionnaires installés dans ce vault (${files.length}) :`,
|
||||
text: t('onboarding.dict.listTitle', String(files.length)),
|
||||
cls: 'pseudobs-onboarding-dict-count',
|
||||
});
|
||||
|
||||
|
|
@ -452,7 +496,7 @@ export class OnboardingModal extends Modal {
|
|||
const li = list.createEl('li');
|
||||
li.createSpan({ text: f.name });
|
||||
const removeBtn = li.createEl('button', { text: '✕', cls: 'pseudobs-onboarding-dict-remove' });
|
||||
removeBtn.title = 'Retirer ce dictionnaire du vault';
|
||||
removeBtn.title = t('onboarding.dict.remove');
|
||||
removeBtn.addEventListener('click', () => { void (async () => {
|
||||
await this.app.fileManager.trashFile(f);
|
||||
void this.plugin.dictionaryLoader.load();
|
||||
|
|
@ -464,20 +508,20 @@ export class OnboardingModal extends Modal {
|
|||
// ---- Étape 4 : Résumé ------------------------------------------
|
||||
|
||||
private renderSummary(el: HTMLElement): void {
|
||||
el.createEl('h2', { text: 'Configuration terminée' });
|
||||
el.createEl('h2', { text: t('onboarding.summary.title') });
|
||||
|
||||
const NER_LABELS: Record<NerBackend, string> = {
|
||||
none: 'Désactivé — règles manuelles uniquement',
|
||||
spacy: `spaCy local (${this.plugin.settings.spacyServerUrl})`,
|
||||
'transformers-js': 'transformers.js — modèle ONNX (téléchargement au premier scan)',
|
||||
none: t('onboarding.summary.ner.none'),
|
||||
spacy: `spaCy local (${this.plugin.settings.spacyServerUrl})`,
|
||||
'transformers-js': t('onboarding.summary.ner.tfjs'),
|
||||
};
|
||||
|
||||
el.createEl('p', { text: 'Voici ce qui a été configuré :' });
|
||||
el.createEl('p', { text: t('onboarding.summary.intro') });
|
||||
|
||||
const table = el.createEl('table', { cls: 'pseudobs-onboarding-summary-table' });
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Détection NER', NER_LABELS[this.plugin.settings.nerBackend]],
|
||||
[t('onboarding.summary.ner'), NER_LABELS[this.plugin.settings.nerBackend]],
|
||||
];
|
||||
|
||||
const folder = this.app.vault.getAbstractFileByPath(this.plugin.settings.dictionariesFolder);
|
||||
|
|
@ -487,7 +531,7 @@ export class OnboardingModal extends Modal {
|
|||
if (child instanceof TFile && child.name.endsWith('.json')) dictCount++;
|
||||
}
|
||||
}
|
||||
rows.push(['Dictionnaires', `${dictCount} fichier${dictCount > 1 ? 's' : ''} dans le vault`]);
|
||||
rows.push([t('onboarding.summary.dicts'), t('onboarding.summary.dicts.count', String(dictCount))]);
|
||||
|
||||
for (const [label, value] of rows) {
|
||||
const tr = table.createEl('tr');
|
||||
|
|
@ -495,10 +539,7 @@ export class OnboardingModal extends Modal {
|
|||
tr.createEl('td', { text: value });
|
||||
}
|
||||
|
||||
el.createEl('p', {
|
||||
text: 'Ces paramètres sont modifiables à tout moment via les paramètres du plugin : Reconfigurer.',
|
||||
cls: 'pseudobs-onboarding-hint',
|
||||
});
|
||||
el.createEl('p', { text: t('onboarding.summary.hint'), cls: 'pseudobs-onboarding-hint' });
|
||||
}
|
||||
|
||||
// ---- Navigation ------------------------------------------------
|
||||
|
|
@ -509,31 +550,29 @@ export class OnboardingModal extends Modal {
|
|||
|
||||
// Bouton Annuler — toujours présent sauf à l'étape Résumé
|
||||
if (this.currentStep !== 'summary') {
|
||||
nav.createEl('button', { text: 'Annuler', cls: 'pseudobs-onboarding-cancel-btn' })
|
||||
nav.createEl('button', { text: t('onboarding.nav.cancel'), cls: 'pseudobs-onboarding-cancel-btn' })
|
||||
.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
const rightBtns = nav.createDiv('pseudobs-onboarding-nav-right');
|
||||
|
||||
// Bouton Retour
|
||||
if (idx > 0) {
|
||||
rightBtns.createEl('button', { text: 'Retour', cls: 'pseudobs-onboarding-back-btn' })
|
||||
rightBtns.createEl('button', { text: t('onboarding.nav.back'), cls: 'pseudobs-onboarding-back-btn' })
|
||||
.addEventListener('click', () => {
|
||||
this.currentStep = STEPS[idx - 1];
|
||||
this.scheduleRender();
|
||||
});
|
||||
}
|
||||
|
||||
// Bouton principal
|
||||
if (this.currentStep === 'welcome') {
|
||||
rightBtns.createEl('button', { text: 'Commencer', cls: 'pseudobs-onboarding-next-btn mod-cta' })
|
||||
rightBtns.createEl('button', { text: t('onboarding.nav.start'), cls: 'pseudobs-onboarding-next-btn mod-cta' })
|
||||
.addEventListener('click', () => {
|
||||
this.currentStep = STEPS[idx + 1];
|
||||
this.scheduleRender();
|
||||
});
|
||||
|
||||
} else if (this.currentStep === 'summary') {
|
||||
rightBtns.createEl('button', { text: 'Commencer à travailler', cls: 'pseudobs-onboarding-next-btn mod-cta' })
|
||||
rightBtns.createEl('button', { text: t('onboarding.nav.finish'), cls: 'pseudobs-onboarding-next-btn mod-cta' })
|
||||
.addEventListener('click', () => { void (async () => {
|
||||
this.plugin.settings.onboardingCompleted = true;
|
||||
await this.plugin.saveSettings();
|
||||
|
|
@ -541,13 +580,12 @@ export class OnboardingModal extends Modal {
|
|||
})(); });
|
||||
|
||||
} else {
|
||||
// Étapes intermédiaires : Passer + Suivant
|
||||
rightBtns.createEl('button', { text: 'Passer cette étape', cls: 'pseudobs-onboarding-skip-btn' })
|
||||
rightBtns.createEl('button', { text: t('onboarding.nav.skip'), cls: 'pseudobs-onboarding-skip-btn' })
|
||||
.addEventListener('click', () => {
|
||||
this.currentStep = STEPS[idx + 1];
|
||||
this.scheduleRender();
|
||||
});
|
||||
rightBtns.createEl('button', { text: 'Suivant', cls: 'pseudobs-onboarding-next-btn mod-cta' })
|
||||
rightBtns.createEl('button', { text: t('onboarding.nav.next'), cls: 'pseudobs-onboarding-next-btn mod-cta' })
|
||||
.addEventListener('click', () => {
|
||||
this.currentStep = STEPS[idx + 1];
|
||||
this.scheduleRender();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ItemView, Notice, Setting, TFile, WorkspaceLeaf, setIcon } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
import type { DictionaryFile } from '../types';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import { scanOccurrences } from '../scanner/OccurrenceScanner';
|
||||
|
|
@ -11,20 +12,9 @@ export const VIEW_TYPE_PSEUDOBS = 'pseudonymization-view';
|
|||
|
||||
type Tab = 'mappings' | 'dictionaries' | 'exports' | 'ner';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
first_name: 'Prénom', last_name: 'Nom', full_name: 'Nom complet',
|
||||
place: 'Lieu', institution: 'Institution', date: 'Date',
|
||||
age: 'Âge', profession: 'Profession', custom: 'Autre',
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
file: 'Fichier', folder: 'Dossier', vault: 'Vault',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
validated: '✓', ignored: '✗', partial: '◑',
|
||||
suggested: '?', conflict: '⚠', disabled: '–', needs_review: '👁',
|
||||
};
|
||||
function categoryLabel(cat: string): string { return t(`category.${cat}`) || cat; }
|
||||
function scopeLabel(s: string): string { return t(`scope.${s}`) || s; }
|
||||
function statusLabel(s: string): string { return t(`status.${s}`) || s; }
|
||||
|
||||
export class PseudonymizationView extends ItemView {
|
||||
private plugin: PseudObsPlugin;
|
||||
|
|
@ -36,6 +26,8 @@ export class PseudonymizationView extends ItemView {
|
|||
private lastFile: TFile | null = null;
|
||||
// IDs des dictionnaires cochés pour le scan groupé (tous cochés par défaut)
|
||||
private checkedDicts = new Set<string>();
|
||||
// Onglet Mappings : filtrer sur le fichier actif (coché par défaut)
|
||||
private mappingsFilterActive = true;
|
||||
// Garde contre la réentrance de onFileChange (le panneau lui-même peut devenir feuille active)
|
||||
private _renderingTab = false;
|
||||
|
||||
|
|
@ -57,12 +49,12 @@ export class PseudonymizationView extends ItemView {
|
|||
const content = root.createDiv('pseudobs-view-content');
|
||||
|
||||
const tabs: [Tab, string][] = [
|
||||
['mappings', 'Mappings'],
|
||||
['dictionaries', 'Dictionnaires'],
|
||||
['exports', 'Exports'],
|
||||
['mappings', t('panel.tab.mappings')],
|
||||
['dictionaries', t('panel.tab.dictionaries')],
|
||||
['exports', t('panel.tab.exports')],
|
||||
];
|
||||
if (this.plugin.settings.nerBackend !== 'none') {
|
||||
tabs.push(['ner', 'NER']);
|
||||
tabs.push(['ner', t('panel.tab.ner')]);
|
||||
}
|
||||
|
||||
this.panes = {} as Record<Tab, HTMLElement>;
|
||||
|
|
@ -137,34 +129,30 @@ export class PseudonymizationView extends ItemView {
|
|||
private async renderMappingsTab(el: HTMLElement): Promise<void> {
|
||||
const file = this.getFile();
|
||||
|
||||
// ---- Toolbar ----
|
||||
const toolbar = el.createDiv('pseudobs-view-toolbar');
|
||||
// Bouton ajouter règle
|
||||
|
||||
const addRuleBtn = toolbar.createEl('button', { cls: 'pseudobs-view-action-btn' });
|
||||
setIcon(addRuleBtn, 'list-plus');
|
||||
addRuleBtn.createSpan({ text: 'Ajouter une règle' });
|
||||
addRuleBtn.createSpan({ text: t('panel.mappings.addRule') });
|
||||
addRuleBtn.addEventListener('click', () => new RuleModal(this.app, this.plugin).open());
|
||||
|
||||
// Bouton scan — ouvre MappingScanReviewModal
|
||||
|
||||
const scanBtn = toolbar.createEl('button', { cls: 'pseudobs-view-action-btn' });
|
||||
setIcon(scanBtn, 'scan-search');
|
||||
scanBtn.createSpan({ text: 'Scanner le fichier' });
|
||||
scanBtn.createSpan({ text: t('panel.mappings.scanFile') });
|
||||
if (!file) scanBtn.setAttr('disabled', 'true');
|
||||
scanBtn.addEventListener('click', () => { void (async () => {
|
||||
if (!file) return;
|
||||
scanBtn.setAttr('disabled', 'true');
|
||||
scanBtn.setText('Scan en cours…');
|
||||
scanBtn.querySelector('span')?.setText(t('panel.mappings.scanning'));
|
||||
try {
|
||||
const content = await this.app.vault.read(file);
|
||||
const rules = await this.plugin.scopeResolver.getRulesFor(file.path);
|
||||
if (rules.length === 0) {
|
||||
new Notice('Aucune règle active pour ce fichier. Créez des règles via le menu contextuel.');
|
||||
return;
|
||||
}
|
||||
if (rules.length === 0) { new Notice(t('panel.mappings.noRulesHint')); return; }
|
||||
const occs = scanOccurrences(content, file.path, rules, {
|
||||
caseSensitive: this.plugin.settings.caseSensitive,
|
||||
wholeWordOnly: this.plugin.settings.wholeWordOnly,
|
||||
});
|
||||
// Grouper par règle
|
||||
const countByRule = new Map<string, number>();
|
||||
for (const occ of occs) {
|
||||
const id = occ.mappingId ?? '';
|
||||
|
|
@ -173,50 +161,84 @@ export class PseudonymizationView extends ItemView {
|
|||
const ruleResults: MappingRuleResult[] = rules
|
||||
.filter((r) => countByRule.has(r.id))
|
||||
.map((r) => ({ rule: r, matchCount: countByRule.get(r.id)! }));
|
||||
if (ruleResults.length === 0) {
|
||||
new Notice('Aucune occurrence trouvée pour les règles actives.');
|
||||
return;
|
||||
}
|
||||
if (ruleResults.length === 0) { new Notice(t('notice.noOccurrences')); return; }
|
||||
new MappingScanReviewModal(this.app, this.plugin, file, content, ruleResults).open();
|
||||
} finally {
|
||||
scanBtn.removeAttribute('disabled');
|
||||
scanBtn.setText('Scanner le fichier');
|
||||
scanBtn.querySelector('span')?.setText(t('panel.mappings.scanFile'));
|
||||
}
|
||||
})(); });
|
||||
|
||||
if (!file) {
|
||||
el.createEl('p', { text: 'Aucun fichier actif.', cls: 'pseudobs-view-hint' });
|
||||
// ---- Checkbox filtre fichier actif ----
|
||||
const filterRow = el.createDiv('pseudobs-mappings-filter-row');
|
||||
const cb = filterRow.createEl('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = this.mappingsFilterActive;
|
||||
cb.addClass('pseudobs-dict-review-cb');
|
||||
filterRow.createEl('label', { text: t('panel.mappings.filterActive') });
|
||||
cb.addEventListener('change', () => {
|
||||
this.mappingsFilterActive = cb.checked;
|
||||
void this.renderTab('mappings');
|
||||
});
|
||||
|
||||
if (!file && this.mappingsFilterActive) {
|
||||
el.createEl('p', { text: t('panel.mappings.noFile'), cls: 'pseudobs-view-hint' });
|
||||
return;
|
||||
}
|
||||
|
||||
const locations = await this.plugin.scopeResolver.getRulesWithLocation(file.path);
|
||||
// ---- Chargement des règles ----
|
||||
const locations = this.mappingsFilterActive && file
|
||||
? await this.plugin.scopeResolver.getRulesWithLocation(file.path)
|
||||
: await this.plugin.scopeResolver.getAllRulesWithLocation();
|
||||
|
||||
if (locations.length === 0) {
|
||||
el.createEl('p', { text: `Aucune règle pour ${file.name}.`, cls: 'pseudobs-view-hint' });
|
||||
el.createEl('p', {
|
||||
text: this.mappingsFilterActive && file
|
||||
? t('panel.mappings.noRules', file.name)
|
||||
: t('panel.mappings.noRules', '—'),
|
||||
cls: 'pseudobs-view-hint',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const table = el.createEl('table', { cls: 'pseudobs-mappings-table' });
|
||||
const headerRow = table.createEl('thead').createEl('tr');
|
||||
for (const col of ['Source', 'Remplacement', 'Catégorie', 'Portée', 'P.', 'Statut', '']) {
|
||||
headerRow.createEl('th', { text: col });
|
||||
// ---- Grouper par portée ----
|
||||
const byScope: Record<string, typeof locations> = { file: [], folder: [], vault: [] };
|
||||
for (const loc of locations) {
|
||||
const s = loc.rule.scope.type;
|
||||
(byScope[s] ??= []).push(loc);
|
||||
}
|
||||
|
||||
const tbody = table.createEl('tbody');
|
||||
for (const loc of locations) {
|
||||
const { rule } = loc;
|
||||
const row = tbody.createEl('tr');
|
||||
row.createEl('td', { text: rule.source, cls: 'pseudobs-mappings-source' });
|
||||
row.createEl('td', { text: rule.replacement, cls: 'pseudobs-mappings-replacement' });
|
||||
row.createEl('td', { text: CATEGORY_LABELS[rule.category] ?? rule.category });
|
||||
row.createEl('td', { text: SCOPE_LABELS[rule.scope.type] ?? rule.scope.type });
|
||||
row.createEl('td', { text: String(rule.priority) });
|
||||
row.createEl('td', { text: STATUS_LABELS[rule.status] ?? rule.status });
|
||||
const scopeOrder: Array<'file' | 'folder' | 'vault'> = ['file', 'folder', 'vault'];
|
||||
const cols = [
|
||||
t('panel.mappings.col.source'), t('panel.mappings.col.replacement'),
|
||||
t('panel.mappings.col.category'), t('panel.mappings.col.priority'),
|
||||
t('panel.mappings.col.status'), '',
|
||||
];
|
||||
|
||||
const editBtn = row.createEl('td').createEl('button', { cls: 'pseudobs-mappings-edit-btn' });
|
||||
setIcon(editBtn, "pencil");
|
||||
editBtn.title = 'Modifier';
|
||||
editBtn.addEventListener('click', () => new EditRuleModal(this.app, this.plugin, loc).open());
|
||||
for (const scopeType of scopeOrder) {
|
||||
const locs = byScope[scopeType];
|
||||
if (!locs || locs.length === 0) continue;
|
||||
|
||||
el.createEl('h3', { text: scopeLabel(scopeType), cls: 'pseudobs-mappings-scope-heading' });
|
||||
|
||||
const table = el.createEl('table', { cls: 'pseudobs-mappings-table' });
|
||||
const headerRow = table.createEl('thead').createEl('tr');
|
||||
for (const col of cols) headerRow.createEl('th', { text: col });
|
||||
|
||||
const tbody = table.createEl('tbody');
|
||||
for (const loc of locs) {
|
||||
const { rule } = loc;
|
||||
const row = tbody.createEl('tr');
|
||||
row.createEl('td', { text: rule.source, cls: 'pseudobs-mappings-source' });
|
||||
row.createEl('td', { text: rule.replacement, cls: 'pseudobs-mappings-replacement' });
|
||||
row.createEl('td', { text: categoryLabel(rule.category) });
|
||||
row.createEl('td', { text: String(rule.priority) });
|
||||
row.createEl('td', { text: statusLabel(rule.status) });
|
||||
|
||||
const editBtn = row.createEl('td').createEl('button', { cls: 'pseudobs-mappings-edit-btn' });
|
||||
setIcon(editBtn, 'pencil');
|
||||
editBtn.addEventListener('click', () => new EditRuleModal(this.app, this.plugin, loc).open());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,47 +253,39 @@ export class PseudonymizationView extends ItemView {
|
|||
}
|
||||
|
||||
if (dicts.length === 0) {
|
||||
el.createEl('p', {
|
||||
text: 'Aucun dictionnaire installé. Installez-en un depuis le wizard (Paramètres → Reconfigurer) ou importez un fichier local.',
|
||||
cls: 'pseudobs-view-hint',
|
||||
});
|
||||
el.createEl('p', { text: t('panel.dict.noneInstalled'), cls: 'pseudobs-view-hint' });
|
||||
} else {
|
||||
// Mini cards
|
||||
for (const dict of dicts) {
|
||||
const card = el.createDiv('pseudobs-dict-card');
|
||||
|
||||
// Checkbox scan groupé
|
||||
const checkbox = card.createEl('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = this.checkedDicts.has(dict.dictionaryId);
|
||||
checkbox.addClass('pseudobs-dict-card-checkbox');
|
||||
checkbox.title = 'Inclure dans le scan groupé';
|
||||
checkbox.title = t('panel.dict.checkbox');
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) this.checkedDicts.add(dict.dictionaryId);
|
||||
else this.checkedDicts.delete(dict.dictionaryId);
|
||||
});
|
||||
|
||||
// Infos
|
||||
const info = card.createDiv('pseudobs-dict-card-info');
|
||||
info.createEl('strong', { text: dict.label, cls: 'pseudobs-dict-card-title' });
|
||||
info.createEl('small', { text: `${dict.dictionaryId}.dict.json`, cls: 'pseudobs-dict-card-filename' });
|
||||
|
||||
// Bouton scan individuel
|
||||
if (dict.roles?.detection) {
|
||||
const scanBtn = card.createEl('button', { cls: 'pseudobs-dict-card-scan mod-cta' });
|
||||
setIcon(scanBtn, 'scan-search');
|
||||
scanBtn.setAttribute('aria-label', `Scanner avec ${dict.label}`);
|
||||
scanBtn.title = `Scanner le fichier actif avec "${dict.label}"`;
|
||||
scanBtn.setAttribute('aria-label', t('panel.dict.scanWith', dict.label));
|
||||
scanBtn.title = t('panel.dict.scanWith', dict.label);
|
||||
scanBtn.addEventListener('click', () => {
|
||||
void this.plugin.scanCurrentFileWithDictionaries([dict.dictionaryId]);
|
||||
});
|
||||
}
|
||||
|
||||
// Bouton suppression
|
||||
const removeBtn = card.createEl('button', { cls: 'pseudobs-dict-card-remove' });
|
||||
setIcon(removeBtn, 'trash-2');
|
||||
removeBtn.setAttribute('aria-label', 'Supprimer ce dictionnaire');
|
||||
removeBtn.title = 'Supprimer ce dictionnaire du vault';
|
||||
removeBtn.setAttribute('aria-label', t('panel.dict.remove'));
|
||||
removeBtn.title = t('panel.dict.remove');
|
||||
removeBtn.addEventListener('click', () => { void (async () => {
|
||||
const f = this.app.vault.getAbstractFileByPath(
|
||||
`${this.plugin.settings.dictionariesFolder}/${dict.dictionaryId}.dict.json`
|
||||
|
|
@ -283,22 +297,20 @@ export class PseudonymizationView extends ItemView {
|
|||
})(); });
|
||||
}
|
||||
|
||||
// Bouton scan groupé
|
||||
el.createEl('hr');
|
||||
const groupScanBtn = el.createEl('button', { cls: 'pseudobs-dict-group-scan mod-cta' });
|
||||
setIcon(groupScanBtn, 'scan-search');
|
||||
groupScanBtn.createSpan({ text: 'Scanner les dictionnaires cochés' });
|
||||
groupScanBtn.createSpan({ text: t('panel.dict.scanAll') });
|
||||
groupScanBtn.addEventListener('click', () => {
|
||||
const ids = [...this.checkedDicts];
|
||||
if (ids.length === 0) { new Notice('Aucun dictionnaire coché.'); return; }
|
||||
if (ids.length === 0) { new Notice(t('notice.noCheckedDicts')); return; }
|
||||
void this.plugin.scanCurrentFileWithDictionaries(ids);
|
||||
});
|
||||
}
|
||||
|
||||
// Import manuel
|
||||
el.createEl('hr');
|
||||
const importBtn = el.createEl('button', {
|
||||
text: 'Importer un fichier local (.dict.json)',
|
||||
text: t('panel.dict.importLocal'),
|
||||
cls: 'pseudobs-view-add-btn',
|
||||
});
|
||||
importBtn.addEventListener('click', () => {
|
||||
|
|
@ -327,10 +339,10 @@ export class PseudonymizationView extends ItemView {
|
|||
await this.app.vault.create(dest, text);
|
||||
}
|
||||
ok++;
|
||||
} catch { new Notice(`Format invalide : ${f.name}`); }
|
||||
} catch { new Notice(t('notice.invalidFormat', f.name)); }
|
||||
}
|
||||
if (ok > 0) {
|
||||
new Notice(`✓ ${ok} dictionnaire${ok > 1 ? 's' : ''} importé${ok > 1 ? 's' : ''}`);
|
||||
new Notice(t('notice.dictImported', String(ok), ok > 1 ? t('notice.dictImported.many') : t('notice.dictImported.one')));
|
||||
await this.plugin.dictionaryLoader.load();
|
||||
}
|
||||
await this.renderTab('dictionaries');
|
||||
|
|
@ -345,26 +357,24 @@ export class PseudonymizationView extends ItemView {
|
|||
const file = this.getFile();
|
||||
|
||||
if (!file) {
|
||||
el.createEl('p', { text: 'Aucun fichier actif.', cls: 'pseudobs-view-hint' });
|
||||
el.createEl('p', { text: t('panel.mappings.noFile'), cls: 'pseudobs-view-hint' });
|
||||
return;
|
||||
}
|
||||
|
||||
el.createEl('p', { text: `Fichier actif : ${file.name}`, cls: 'pseudobs-view-filename' });
|
||||
el.createEl('p', { text: `${file.name}`, cls: 'pseudobs-view-filename' });
|
||||
|
||||
new Setting(el)
|
||||
.setName('Pseudonymiser et exporter')
|
||||
.setDesc('Génère un fichier .pseudonymized.[ext] dans le dossier d\'exports configuré')
|
||||
.setName(t('panel.exports.pseudonymize'))
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('Exporter').setCta().onClick(() => {
|
||||
btn.setButtonText(t('panel.exports.pseudonymize')).setCta().onClick(() => {
|
||||
void this.plugin.pseudonymizeActiveFile();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName('Exporter la table de correspondance')
|
||||
.setDesc('Copie le mapping JSON dans le dossier d\'exports')
|
||||
.setName(t('panel.exports.exportMapping'))
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('Exporter le mapping').onClick(() => {
|
||||
btn.setButtonText(t('panel.exports.exportMapping')).onClick(() => {
|
||||
void this.plugin.exportMappingForFile(file);
|
||||
})
|
||||
);
|
||||
|
|
@ -375,27 +385,19 @@ export class PseudonymizationView extends ItemView {
|
|||
private async renderNerTab(el: HTMLElement): Promise<void> {
|
||||
const s = this.plugin.settings;
|
||||
|
||||
// Bouton scan NER
|
||||
const nerScanBtn = el.createEl('button', { cls: 'pseudobs-view-action-btn mod-cta' });
|
||||
setIcon(nerScanBtn, 'scan-search');
|
||||
nerScanBtn.createSpan({ text: 'Identifier des candidats' });
|
||||
nerScanBtn.title = 'Détecter les entités nommées dans le fichier actif et les surligner en bleu';
|
||||
nerScanBtn.createSpan({ text: t('panel.ner.scanBtn') });
|
||||
nerScanBtn.title = t('panel.ner.scanBtn');
|
||||
nerScanBtn.addEventListener('click', () => void this.plugin.scanCurrentFileNer());
|
||||
|
||||
el.createEl('hr');
|
||||
|
||||
el.createEl('p', {
|
||||
text: 'Paramètres du scanner de détection automatique des entités nommées.',
|
||||
cls: 'pseudobs-view-hint',
|
||||
});
|
||||
el.createEl('p', { text: t('panel.ner.hint'), cls: 'pseudobs-view-hint' });
|
||||
|
||||
// --- Seuil de confiance ---
|
||||
const scoreSection = el.createDiv('pseudobs-ner-section');
|
||||
scoreSection.createEl('strong', { text: 'Seuil de confiance' });
|
||||
scoreSection.createEl('p', {
|
||||
text: 'Les entités dont le score est inférieur à cette valeur sont ignorées. Plus la valeur est haute, moins de faux positifs mais aussi moins de détections.',
|
||||
cls: 'pseudobs-view-hint',
|
||||
});
|
||||
scoreSection.createEl('strong', { text: t('panel.ner.threshold') });
|
||||
scoreSection.createEl('p', { text: t('panel.ner.thresholdDesc'), cls: 'pseudobs-view-hint' });
|
||||
|
||||
const scoreRow = scoreSection.createDiv('pseudobs-ner-score-row');
|
||||
const scoreDisplay = scoreRow.createEl('span', {
|
||||
|
|
@ -417,13 +419,9 @@ export class PseudonymizationView extends ItemView {
|
|||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
// --- Mots fonctionnels ---
|
||||
const fwSection = el.createDiv('pseudobs-ner-section');
|
||||
fwSection.createEl('strong', { text: 'Mots fonctionnels exclus' });
|
||||
fwSection.createEl('p', {
|
||||
text: 'Un mot par ligne. Ces termes ne seront jamais retenus comme entités nommées, même si le modèle les détecte (artefacts de tokenisation).',
|
||||
cls: 'pseudobs-view-hint',
|
||||
});
|
||||
fwSection.createEl('strong', { text: t('panel.ner.functionWords') });
|
||||
fwSection.createEl('p', { text: t('panel.ner.functionWordsDesc'), cls: 'pseudobs-view-hint' });
|
||||
|
||||
const textarea = fwSection.createEl('textarea');
|
||||
textarea.addClass('pseudobs-ner-fw-textarea');
|
||||
|
|
@ -432,7 +430,7 @@ export class PseudonymizationView extends ItemView {
|
|||
textarea.spellcheck = false;
|
||||
|
||||
const saveBtn = fwSection.createEl('button', {
|
||||
text: 'Enregistrer',
|
||||
text: t('panel.ner.save'),
|
||||
cls: 'pseudobs-view-action-btn',
|
||||
});
|
||||
saveBtn.addEventListener('click', () => { void (async () => {
|
||||
|
|
@ -443,13 +441,13 @@ export class PseudonymizationView extends ItemView {
|
|||
this.plugin.settings.nerFunctionWords = words;
|
||||
await this.plugin.saveSettings();
|
||||
saveBtn.addClass('pseudobs-btn-saved');
|
||||
saveBtn.setText('Enregistré');
|
||||
window.setTimeout(() => { saveBtn.removeClass('pseudobs-btn-saved'); saveBtn.setText('Enregistrer'); }, 2000);
|
||||
saveBtn.setText(t('panel.ner.saved'));
|
||||
window.setTimeout(() => { saveBtn.removeClass('pseudobs-btn-saved'); saveBtn.setText(t('panel.ner.save')); }, 2000);
|
||||
})(); });
|
||||
|
||||
const resetBtn = fwSection.createEl('button', { cls: 'pseudobs-view-action-btn' });
|
||||
setIcon(resetBtn, 'rotate-ccw');
|
||||
resetBtn.createSpan({ text: 'Réinitialiser par défaut' });
|
||||
resetBtn.createSpan({ text: t('panel.ner.reset') });
|
||||
resetBtn.addClass('pseudobs-ner-reset-btn');
|
||||
resetBtn.addEventListener('click', () => { void (async () => {
|
||||
const { DEFAULT_SETTINGS } = await import('../settings');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { App, Editor, EditorPosition, Modal, Notice, Setting, TFile } from 'obsi
|
|||
import type PseudObsPlugin from '../main';
|
||||
import { MappingStore } from '../mappings/MappingStore';
|
||||
import type { EntityCategory, MappingFile } from '../types';
|
||||
import { t } from '../i18n';
|
||||
|
||||
type ApplyScope = 'occurrence' | 'file';
|
||||
|
||||
|
|
@ -30,7 +31,6 @@ export class QuickPseudonymizeModal extends Modal {
|
|||
this.source = editor.getSelection();
|
||||
this.replacement = prefillReplacement;
|
||||
this.suggestions = suggestions;
|
||||
// Coulmont ne traite que des prénoms
|
||||
if (suggestions.length > 0) this.category = 'first_name';
|
||||
this.from = editor.getCursor('from');
|
||||
this.to = editor.getCursor('to');
|
||||
|
|
@ -38,30 +38,26 @@ export class QuickPseudonymizeModal extends Modal {
|
|||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: 'Pseudonymiser' });
|
||||
contentEl.createEl('h2', { text: t('quickModal.title') });
|
||||
|
||||
// Source en lecture seule
|
||||
new Setting(contentEl)
|
||||
.setName('Expression sélectionnée')
|
||||
.setDesc('Terme à remplacer — non modifiable')
|
||||
.addText((t) => {
|
||||
t.setValue(this.source).setDisabled(true);
|
||||
t.inputEl.addClass('pseudobs-disabled-input');
|
||||
.setName(t('quickModal.source'))
|
||||
.setDesc(t('quickModal.sourceDesc'))
|
||||
.addText((tx) => {
|
||||
tx.setValue(this.source).setDisabled(true);
|
||||
tx.inputEl.addClass('pseudobs-disabled-input');
|
||||
});
|
||||
|
||||
// Suggestions Coulmont : boutons cliquables qui remplissent le champ
|
||||
let replacementInput: HTMLInputElement;
|
||||
|
||||
if (this.suggestions.length > 0) {
|
||||
const suggBox = contentEl.createDiv();
|
||||
suggBox.addClass('pseudobs-suggestions-box');
|
||||
suggBox.createEl('small', { text: 'Suggestions de prénoms équivalents (m/f non différenciés) :' })
|
||||
const suggBox = contentEl.createDiv('pseudobs-suggestions-box');
|
||||
suggBox.createEl('small', { text: t('ruleModal.coulomontLabel') })
|
||||
.addClass('pseudobs-suggestions-label');
|
||||
const tags = suggBox.createDiv();
|
||||
tags.addClass('pseudobs-suggestions-tags');
|
||||
const tags = suggBox.createDiv('pseudobs-suggestions-tags');
|
||||
const btnEls: HTMLElement[] = [];
|
||||
for (const name of this.suggestions) {
|
||||
const btn = tags.createEl('button', { text: name });
|
||||
btn.addClass('pseudobs-suggestion-btn');
|
||||
const btn = tags.createEl('button', { text: name, cls: 'pseudobs-suggestion-btn' });
|
||||
btn.addEventListener('click', () => {
|
||||
this.replacement = name;
|
||||
if (replacementInput) {
|
||||
|
|
@ -75,74 +71,55 @@ export class QuickPseudonymizeModal extends Modal {
|
|||
}
|
||||
}
|
||||
|
||||
// Champ de remplacement — pré-rempli si un prénom a déjà été sélectionné
|
||||
new Setting(contentEl)
|
||||
.setName('Remplacer par')
|
||||
.addText((t) => {
|
||||
t.setPlaceholder('Pseudonyme ou catégorie analytique');
|
||||
t.setValue(this.replacement);
|
||||
t.onChange((v) => (this.replacement = v));
|
||||
replacementInput = t.inputEl;
|
||||
.setName(t('quickModal.replaceBy'))
|
||||
.addText((tx) => {
|
||||
tx.setPlaceholder(t('quickModal.replacementPlaceholder'));
|
||||
tx.setValue(this.replacement);
|
||||
tx.onChange((v) => (this.replacement = v));
|
||||
replacementInput = tx.inputEl;
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Catégorie')
|
||||
.setName(t('ruleModal.category'))
|
||||
.addDropdown((d) => {
|
||||
const options: Record<EntityCategory, string> = {
|
||||
first_name: 'Prénom',
|
||||
last_name: 'Nom de famille',
|
||||
full_name: 'Nom complet',
|
||||
place: 'Lieu',
|
||||
institution: 'Institution',
|
||||
date: 'Date',
|
||||
age: 'Âge',
|
||||
profession: 'Profession',
|
||||
custom: 'Autre',
|
||||
};
|
||||
for (const [value, label] of Object.entries(options)) {
|
||||
d.addOption(value, label);
|
||||
}
|
||||
const cats: EntityCategory[] = ['first_name','last_name','full_name','place','institution','date','age','profession','custom'];
|
||||
for (const cat of cats) d.addOption(cat, t(`category.${cat}`));
|
||||
d.setValue('custom');
|
||||
d.onChange((v) => (this.category = v as EntityCategory));
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Portée du remplacement')
|
||||
.setName(t('quickModal.scope'))
|
||||
.addDropdown((d) => {
|
||||
d.addOption('file', 'Toutes les occurrences dans ce fichier');
|
||||
d.addOption('occurrence', 'Cette occurrence uniquement');
|
||||
d.addOption('file', t('quickModal.scopeFile'));
|
||||
d.addOption('occurrence', t('quickModal.scopeOccurrence'));
|
||||
d.setValue('file');
|
||||
d.onChange((v) => (this.applyScope = v as ApplyScope));
|
||||
});
|
||||
|
||||
new Setting(contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Pseudonymiser')
|
||||
.setCta()
|
||||
.onClick(() => this.apply())
|
||||
btn.setButtonText(t('quickModal.submit')).setCta().onClick(() => void this.apply())
|
||||
);
|
||||
|
||||
// Focus sur le champ de remplacement à l'ouverture
|
||||
window.setTimeout(() => replacementInput?.focus(), 50);
|
||||
}
|
||||
|
||||
private async apply(): Promise<void> {
|
||||
const replacement = this.replacement.trim();
|
||||
if (!replacement) {
|
||||
new Notice('Le remplacement est obligatoire.');
|
||||
new Notice(t('ruleModal.errorMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
new Notice('Aucun fichier actif.');
|
||||
new Notice(t('notice.noActiveFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Sauvegarder la règle dans le mapping JSON (valeur brute, sans marqueurs)
|
||||
await this.saveRule(activeFile, replacement);
|
||||
|
||||
// 2. Appliquer dans le fichier — avec marqueurs si activés
|
||||
const s = this.plugin.settings;
|
||||
const marked = s.useMarkerInExport
|
||||
? `${s.markerOpen}${replacement}${s.markerClose}`
|
||||
|
|
@ -150,13 +127,12 @@ export class QuickPseudonymizeModal extends Modal {
|
|||
|
||||
if (this.applyScope === 'occurrence') {
|
||||
this.editor.replaceRange(marked, this.from, this.to);
|
||||
new Notice(`✓ "${this.source}" → "${marked}" (cette occurrence)`);
|
||||
new Notice(t('notice.appliedOccurrence', this.source, marked));
|
||||
} else {
|
||||
const count = await this.plugin.applyRuleToFile(activeFile, this.source, marked);
|
||||
new Notice(`✓ "${this.source}" → "${marked}" (${count} occurrence${count > 1 ? 's' : ''})`);
|
||||
new Notice(t('notice.appliedFile', this.source, marked, String(count), count > 1 ? 's' : ''));
|
||||
}
|
||||
|
||||
// Rafraîchir le surlignage immédiatement
|
||||
void this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { App, Modal, Setting, TFile, Notice } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import { MappingStore } from '../mappings/MappingStore';
|
||||
import type { EntityCategory, MappingFile, MappingRule, ScopeType } from '../types';
|
||||
import type { EntityCategory, MappingFile, ScopeType } from '../types';
|
||||
|
||||
export class RuleModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
|
|
@ -70,15 +71,13 @@ export class RuleModal extends Modal {
|
|||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: 'Créer une règle de remplacement' });
|
||||
contentEl.createEl('h2', { text: t('ruleModal.title') });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Source')
|
||||
.setDesc('Terme original à remplacer')
|
||||
.addText((t) =>
|
||||
t.setValue(this.source).onChange((v) => {
|
||||
this.source = v;
|
||||
})
|
||||
.setName(t('ruleModal.source'))
|
||||
.setDesc(t('ruleModal.sourceDesc'))
|
||||
.addText((tx) =>
|
||||
tx.setValue(this.source).onChange((v) => { this.source = v; })
|
||||
);
|
||||
|
||||
// --- Suggestions Coulmont ---
|
||||
|
|
@ -86,7 +85,7 @@ export class RuleModal extends Modal {
|
|||
if (this.coulomontSuggestions.length > 0) {
|
||||
const box = contentEl.createDiv();
|
||||
box.addClass('pseudobs-suggestions-box');
|
||||
box.createEl('small', { text: 'Suggestions de prénoms équivalents — choisissez :' })
|
||||
box.createEl('small', { text: t('ruleModal.coulomontLabel') })
|
||||
.addClass('pseudobs-suggestions-label');
|
||||
const tags = box.createDiv();
|
||||
tags.addClass('pseudobs-suggestions-tags');
|
||||
|
|
@ -116,12 +115,12 @@ export class RuleModal extends Modal {
|
|||
box.addClass('pseudobs-suggestions-box');
|
||||
const label = box.createEl('small');
|
||||
label.addClass('pseudobs-suggestions-label');
|
||||
label.setText(`Dictionnaire : "${this.source}" → classe ${this.dictEntryClass}`);
|
||||
label.setText(t('ruleModal.dictLabel', this.source, this.dictEntryClass));
|
||||
|
||||
const row = box.createDiv();
|
||||
row.addClass('pseudobs-suggestions-tags');
|
||||
|
||||
const classBtn = row.createEl('button', { text: `Utiliser "${preview}" (portée : ${scope})` });
|
||||
const classBtn = row.createEl('button', { text: t('ruleModal.dictUseClass', preview, scope) });
|
||||
classBtn.addClass('pseudobs-suggestion-btn');
|
||||
classBtn.addEventListener('click', () => {
|
||||
this.useClass = true;
|
||||
|
|
@ -129,7 +128,6 @@ export class RuleModal extends Modal {
|
|||
classBtn.addClass('pseudobs-suggestion-btn-selected');
|
||||
});
|
||||
|
||||
// Pré-sélectionner automatiquement si aucune suggestion Coulmont
|
||||
if (this.coulomontSuggestions.length === 0) {
|
||||
this.useClass = true;
|
||||
if (replacementInput) replacementInput.value = preview;
|
||||
|
|
@ -138,45 +136,33 @@ export class RuleModal extends Modal {
|
|||
}
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Remplacement')
|
||||
.setDesc(
|
||||
this.dictEntryClass
|
||||
? 'L\'index exact sera calculé à la création selon les règles existantes dans la portée.'
|
||||
: 'Pseudonyme ou catégorie analytique'
|
||||
)
|
||||
.addText((t) => {
|
||||
.setName(t('ruleModal.replacement'))
|
||||
.setDesc(this.dictEntryClass ? t('ruleModal.replacementDescClass') : t('ruleModal.replacementDesc'))
|
||||
.addText((tx) => {
|
||||
const preview = this.dictEntryClass
|
||||
? (this.plugin.dictionaryLoader?.getById(this.dictId ?? '')?.config?.replacementPattern ?? '{class}_{index}')
|
||||
.replace('{class}', this.dictEntryClass)
|
||||
.replace('{index}', 'N')
|
||||
: this.replacement;
|
||||
t.setValue(preview).onChange((v) => {
|
||||
tx.setValue(preview).onChange((v) => {
|
||||
this.replacement = v;
|
||||
this.useClass = false; // saisie manuelle désactive le mode classe
|
||||
this.useClass = false;
|
||||
});
|
||||
replacementInput = t.inputEl;
|
||||
replacementInput = tx.inputEl;
|
||||
});
|
||||
|
||||
// Catégorie — masquée si Coulmont impose 'first_name'
|
||||
new Setting(contentEl)
|
||||
.setName('Catégorie')
|
||||
.setName(t('ruleModal.category'))
|
||||
.addDropdown((d) => {
|
||||
const options: Record<EntityCategory, string> = {
|
||||
first_name: 'Prénom',
|
||||
last_name: 'Nom de famille',
|
||||
full_name: 'Nom complet',
|
||||
place: 'Lieu',
|
||||
institution: 'Institution',
|
||||
date: 'Date',
|
||||
age: 'Âge',
|
||||
profession: 'Profession',
|
||||
custom: 'Autre',
|
||||
};
|
||||
for (const [value, label] of Object.entries(options)) {
|
||||
d.addOption(value, label);
|
||||
const cats: EntityCategory[] = ['first_name','last_name','full_name','place','institution','date','age','profession','custom'];
|
||||
for (const cat of cats) {
|
||||
d.addOption(cat, t(`category.${cat}`));
|
||||
}
|
||||
d.setValue(this.category);
|
||||
d.onChange((v) => { this.category = v as EntityCategory; });
|
||||
d.onChange((v) => {
|
||||
this.category = v as EntityCategory;
|
||||
updateBroadScopeWarning();
|
||||
});
|
||||
if (this.coulomontSuggestions.length > 0) {
|
||||
const settingItem = d.selectEl.closest('.setting-item');
|
||||
if (settingItem instanceof HTMLElement) settingItem.hide();
|
||||
|
|
@ -184,36 +170,61 @@ export class RuleModal extends Modal {
|
|||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Portée')
|
||||
.setName(t('ruleModal.scope'))
|
||||
.addDropdown((d) => {
|
||||
d.addOption('file', 'Ce fichier uniquement');
|
||||
d.addOption('folder', 'Ce dossier');
|
||||
d.addOption('vault', 'Tout le vault');
|
||||
d.addOption('file', t('ruleModal.scopeFile'));
|
||||
d.addOption('folder', t('ruleModal.scopeFolder'));
|
||||
d.addOption('vault', t('ruleModal.scopeVault'));
|
||||
d.setValue('file');
|
||||
d.onChange((v) => { this.scopeType = v as ScopeType; });
|
||||
d.onChange((v) => {
|
||||
this.scopeType = v as ScopeType;
|
||||
updateBroadScopeWarning();
|
||||
});
|
||||
});
|
||||
|
||||
// Callout dynamique — visible uniquement pour les catégories noms/prénoms
|
||||
const calloutEl = contentEl.createDiv();
|
||||
calloutEl.hide();
|
||||
const isNameCategory = () => ['first_name', 'last_name', 'full_name'].includes(this.category);
|
||||
const isBroadScope = () => this.scopeType !== 'file';
|
||||
const updateBroadScopeWarning = () => {
|
||||
if (!isNameCategory()) { calloutEl.hide(); return; }
|
||||
calloutEl.empty();
|
||||
calloutEl.show();
|
||||
if (isBroadScope()) {
|
||||
calloutEl.setAttribute('data-callout', 'warning');
|
||||
calloutEl.className = 'callout pseudobs-rule-callout';
|
||||
calloutEl.createDiv('callout-title').createSpan({ text: t('ruleModal.scopeWarnTitle') });
|
||||
calloutEl.createDiv('callout-content').createEl('p', { text: t('panel.mappings.warnBroadName') });
|
||||
} else {
|
||||
calloutEl.setAttribute('data-callout', 'success');
|
||||
calloutEl.className = 'callout pseudobs-rule-callout';
|
||||
calloutEl.createDiv('callout-title').createSpan({ text: t('ruleModal.scopeOkTitle') });
|
||||
calloutEl.createDiv('callout-content').createEl('p', { text: t('ruleModal.scopeOk') });
|
||||
}
|
||||
};
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Priorité')
|
||||
.setDesc('Entier libre, comme un z-index CSS — défaut 0, plus grand = appliqué en premier')
|
||||
.addText((t) =>
|
||||
t.setValue('0').onChange((v) => { this.priority = parseInt(v, 10) || 0; })
|
||||
.setName(t('ruleModal.priority'))
|
||||
.setDesc(t('ruleModal.priorityDesc'))
|
||||
.addText((tx) =>
|
||||
tx.setValue('0').onChange((v) => { this.priority = parseInt(v, 10) || 0; })
|
||||
);
|
||||
|
||||
new Setting(contentEl).addButton((btn) =>
|
||||
btn.setButtonText('Créer la règle').setCta().onClick(() => void this.createRule())
|
||||
btn.setButtonText(t('ruleModal.submit')).setCta().onClick(() => void this.createRule())
|
||||
);
|
||||
}
|
||||
|
||||
private async createRule(): Promise<void> {
|
||||
if (!this.source.trim()) {
|
||||
new Notice('La source est obligatoire.');
|
||||
new Notice(t('ruleModal.errorMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
new Notice('Aucun fichier actif.');
|
||||
new Notice(t('ruleModal.errorNoFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +241,7 @@ export class RuleModal extends Modal {
|
|||
}
|
||||
|
||||
if (!this.replacement.trim()) {
|
||||
new Notice('Le remplacement est obligatoire.');
|
||||
new Notice(t('ruleModal.errorMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +282,7 @@ export class RuleModal extends Modal {
|
|||
await this.app.vault.create(mappingPath, json);
|
||||
}
|
||||
|
||||
new Notice(`✓ Règle créée : "${this.source.trim()}" → "${this.replacement.trim()}"`);
|
||||
new Notice(t('notice.ruleCreated', this.source.trim(), this.replacement.trim()));
|
||||
void this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
|
|
|||
93
styles.css
93
styles.css
|
|
@ -955,3 +955,96 @@
|
|||
}
|
||||
.pseudobs-onboarding-icon-btn-loading { cursor: wait; }
|
||||
.pseudobs-onboarding-icon-btn-loading svg { animation: pseudobs-spin 0.9s linear infinite; }
|
||||
|
||||
/* ---- CorpusModal ---- */
|
||||
|
||||
.pseudobs-corpus-class-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pseudobs-corpus-class-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.pseudobs-corpus-class-item:hover { border-color: var(--background-modifier-border-hover); }
|
||||
|
||||
.pseudobs-corpus-class-name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.pseudobs-corpus-class-count {
|
||||
font-size: 0.78em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.pseudobs-corpus-class-mirror {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.72em;
|
||||
color: var(--text-faint);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pseudobs-corpus-add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.pseudobs-corpus-add-input {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.pseudobs-corpus-add-input:focus { border-color: var(--interactive-accent); outline: none; }
|
||||
|
||||
.pseudobs-corpus-select-item { list-style: none; }
|
||||
.pseudobs-corpus-select-item-active button {
|
||||
border-color: var(--interactive-accent);
|
||||
background: color-mix(in srgb, var(--interactive-accent) 10%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Onglet Mappings — filtre et sous-titres de portée */
|
||||
.pseudobs-mappings-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pseudobs-mappings-scope-heading {
|
||||
font-size: 0.8em;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 12px 0 4px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.pseudobs-mappings-scope-heading:first-of-type { margin-top: 0; }
|
||||
|
||||
/* Callout portée dans RuleModal / EditRuleModal */
|
||||
.pseudobs-rule-callout {
|
||||
margin: 6px 0 12px;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.pseudobs-rule-callout .callout-content p {
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
"noImplicitAny": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"lib": ["ES2020", "DOM"]
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.json"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue