Pre-v0.1.0

This commit is contained in:
Axelle Abbadie 2026-05-12 22:15:36 +02:00
parent 2e1f9a1b0f
commit b9a37f8306
21 changed files with 35623 additions and 447 deletions

2
.gitignore vendored
View file

@ -2,7 +2,7 @@
CLAUDE.md
# Vault de test Obsidian — peut contenir des transcriptions avec données personnelles
test/
test_vault/
# Fichiers système
.DS_Store

272
README.md
View file

@ -1,124 +1,192 @@
# PseudObsidian-ization
**Plugin Obsidian de pseudonymisation et de correction de transcriptions d'entretiens et de corpus interactionnels.**
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).
Conçu pour les chercheurs en **analyse conversationnelle (EMCA)** et en **sciences du langage**, notamment à l'**UMR ICAR** (CNRS / Université Lyon 2 / ENS Lyon), ce plugin permet de travailler directement dans Obsidian sur des transcriptions brutes issues de terrains de recherche, en produisant des versions pseudonymisées exploitables pour l'analyse et l'archivage.
> **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.
> **English summary** — An Obsidian plugin for pseudonymizing and correcting interactional transcripts (Jefferson / ICOR conventions, SRT, CHAT/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
### Formats pris en charge
- `.srt` — sous-titres horodatés (sortie Whisper / IA)
- `.cha` / `.chat` — format CHAT / CLAN (CLAN, corpus de linguistique)
- `.md` — transcription Markdown (conventions Jefferson ou ICOR)
- `.txt` — texte brut
### Pseudonymisation manuelle
Les fichiers `.srt` et `.cha` sont **automatiquement convertis en Markdown** à l'import, pour une édition native dans Obsidian. La structure (horodatages, locuteurs, métadonnées `@`, lignes dépendantes `%`) est préservée.
Toutes les actions sont disponibles via le **clic droit** sur une sélection dans l'éditeur :
### Pseudonymisation
- **Sélection → clic droit → Pseudonymiser** : remplace immédiatement dans le fichier
- **Pseudonymiser avec Coulmont** : interroge l'outil de Baptiste Coulmont ([coulmont.com](https://coulmont.com)) pour proposer des prénoms sociologiquement équivalents (même milieu social, même décennie de popularité) — le chercheur choisit dans la liste proposée
- **Créer une règle** : ajoute une correspondance dans le mapping JSON sans modifier le texte, pour validation ultérieure
- **Scanner le fichier courant** : liste toutes les occurrences des règles avec diff avant/après, validation occurrence par occurrence
- **Portées** : fichier, dossier, vault entier
| 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,501,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 |
|---|---|
| **Candidats** | Scanner (règles existantes) · Identifier des candidats (NER) · Validation · Appliquer |
| **Mappings** | Règles actives · Modifier · Supprimer · Ajouter |
| **Dictionnaires** | Import de fichiers `.dict.json` |
| **Exports** | Pseudonymiser et exporter · Exporter la table de correspondance |
| **NER** | Visible si NER activé · Seuil de confiance · Mots fonctionnels exclus |
### 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
- Format JSON structuré (voir `SPECS.md §5`)
- Trois niveaux : `fichier.mapping.json`, `dossier.mapping.json`, `vault.mapping.json`
- Statuts : `suggested`, `validated`, `ignored`, `partial`, `conflict`, `needs_review`
- Priorité z-index : entier libre, comme le `z-index` CSS — les entités longues priment sur les plus courtes par défaut (protection `Saint-Jean-de-Luz` vs `Jean`)
- Marqueur optionnel dans l'export : `⟦Pierre⟧` pour identifier visuellement les pseudonymes
### Surlignage dans l'éditeur
- **Orange** : termes sources encore présents (à pseudonymiser)
- **Vert** : pseudonymes déjà appliqués
- 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`
### Sécurité
- Tout traitement est **local** — aucune donnée de transcription n'est envoyée à un serveur externe
- Exception documentée : "Pseudonymiser avec Coulmont" envoie le prénom à `coulmont.com` (pas le contenu de la transcription)
- Tables de correspondance strictement séparées des exports
### 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" envoie le *prénom de remplacement* (pas le contenu de la transcription) à `coulmont.com`
- Les tables de correspondance ne sont jamais incluses dans les exports pseudonymisés
---
## Statut actuel
**Version en développement actif — 0.0.1 (alpha)**
| Phase | Statut | Description |
|---|---|---|
| 0 — Boilerplate | ✅ | Plugin Obsidian TypeScript, Jest, ESLint |
| 1 — Parser SRT | ✅ | Round-trip exact, horodatages préservés |
| 2 — Parser CHAT | ✅ | Lignes `@`, `*`, `%` préservées |
| 3 — Moteur de pseudonymisation | ✅ | Spans, z-index, protection des chevauchements |
| 4 — Commandes UI | ✅ | Import, création de règles, export avec marqueur |
| 5 — Portées + surlignage | ✅ | ScopeResolver, CM6 ViewPlugin |
| 6 — Validation sélective | ✅ | OccurrencesModal, statuts partiels |
| **7 — Dictionnaires** | 🔄 **En cours** | Intégration Coulmont opérationnelle — import de dictionnaires JSON/CSV et constitution de ressources lexicales embarquées en cours |
| 8 — Scan automatique | ⏳ | Détection sans règle préexistante |
| 9 — Interface complète | ⏳ | Panneau latéral 4 onglets |
| 10 — Publication | ⏳ | Communauté Obsidian |
---
## Installation
### Pour les utilisateurs
1. Télécharger la dernière release (fichiers `main.js`, `manifest.json`, `styles.css`)
2. Copier ces trois fichiers dans `.obsidian/plugins/pseudobsidianization/` de votre vault
3. Activer le plugin dans Obsidian → Paramètres → Extensions communautaires
> Le plugin n'est pas encore soumis au répertoire communautaire Obsidian. Publication prévue à partir de la version 0.1.0.
### Pour les développeurs et contributeurs
## Pour les développeurs
```bash
git clone https://gitlab.huma-num.fr/[votre-groupe]/pseudobsidianization.git
cd pseudobsidianization
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/
```
Copier `main.js`, `manifest.json` et `styles.css` dans `.obsidian/plugins/pseudobsidianization/` d'un vault Obsidian dédié au développement.
Installer [Hot Reload](https://github.com/pjeby/hot-reload) dans ce vault et créer un fichier `.hotreload` dans le dossier du plugin pour le rechargement automatique à chaque build.
#### Structure du dépôt
Structure du dépôt :
```
pseudobsidianization/
├── src/
│ ├── main.ts # Point d'entrée Obsidian
│ ├── types.ts # Types partagés (SPECS §11.3)
│ ├── settings.ts # Paramètres
│ ├── parsers/ # SrtParser, ChatParser, TranscriptConverter
│ ├── mappings/ # MappingStore, ScopeResolver
│ ├── pseudonymizer/ # Moteur, ReplacementPlanner, SpanProtector
│ ├── scanner/ # OccurrenceScanner
│ └── ui/ # Modales, surlignage CM6
├── tests/ # Tests unitaires Jest
├── manifest.json # Métadonnées du plugin Obsidian
├── versions.json
├── styles.css
├── SPECS.md # Spécifications fonctionnelles complètes
├── ROADMAP.md # Feuille de route détaillée
└── README.md
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.0
| Phase | Statut | Description |
|---|---|---|
| 06 | ✅ | Parsers · Moteur · Commandes · Portées · Surlignage · Validation |
| 7 — Coulmont | ✅ | Suggestions de prénoms équivalents · Import JSON/CSV |
| 8 — Panneau latéral | ✅ | 4 onglets · NER embarqué · Wizard · Annulation · Surlignage export |
| 9 — Dictionnaires NER | 🔄 | Dictionnaires de remplacement pour lieux/institutions |
| 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 adaptés à des corpus spécifiques (langues régionales, périodes historiques, terrains non francophones)
- **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)
- **Adaptateurs** : connecteurs vers d'autres ressources lexicales ouvertes
- **Retours d'usage** : issues GitLab pour signaler des cas limites rencontrés sur de vrais corpus
- **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.
@ -126,29 +194,35 @@ Merci d'ouvrir une issue avant de proposer une pull request pour les fonctionnal
## Licence
### Code — *The Beerware License* (Revision 42)
### Code
*The Beerware License* (Revision 42)
```
Axelle Abbadie <https://cv.hal.science/axelle-abbadie/> a écrit ce logiciel.
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 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 de recherche implique des conventions de transcription particulières, un dialecte régional, des corpus multilingues ou des formats d'export spécifiques à votre institution, ne vous gênez pas pour adapter le code à vos besoins. C'est précisément l'esprit de cet outil.
**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.
### Documentation et spécifications — CC BY 4.0
### Plugin et dépôt
Les fichiers `SPECS.md`, `ROADMAP.md` et ce `README.md` sont placés sous licence [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/).
Vous êtes libres de les réutiliser, adapter et redistribuer, y compris à des fins commerciales, à condition de créditer la source.
[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/) (CC BY 4.0).
---
## Crédits
- **Axelle Abbadie** — conception, spécifications, direction du développement ([HAL](https://cv.hal.science/axelle-abbadie/))
- **Claude Sonnet 4.6** (Anthropic) — co-développement du code via [Claude Code](https://claude.ai/code)
- **Baptiste Coulmont** — outil de suggestion de prénoms sociologiquement équivalents ([coulmont.com](https://coulmont.com))
- **Alex Alber** (Université de Tours / UMR CITERES) — inspiration de la chaîne Whispurge → Sonal pi pour le workflow de pseudonymisation
- **UMR ICAR** (CNRS / Université Lyon 2 / ENS Lyon) — contexte scientifique et conventions de transcription ICOR
<!-- 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.

View file

@ -6,16 +6,20 @@ Chaque phase produit quelque chose de testable. Les critères d'acceptation renv
## État actuel (mai 2026)
**Phases 0 à 6 terminées.** La Phase 7 est en cours : l'intégration de l'outil Coulmont est opérationnelle (suggestion de prénoms sociologiquement équivalents via `coulmont.com`). Le travail porte maintenant sur la **constitution de dictionnaires lexicaux embarqués** (prénoms, toponymes, institutions) et leur **intégration dans le moteur de suggestion** pour les phases de scan automatique.
**Phases 0 à 7 terminées.** La Phase 8 (interface complète) est la prochaine cible.
Décision architecturale adoptée en mai 2026 : la **détection des entités identifiantes** reposera sur du **NER** (reconnaissance d'entités nommées) plutôt que sur des dictionnaires lexicaux exhaustifs. Les dictionnaires (Coulmont, etc.) restent des ressources de **remplacement** (candidats de substitution), pas de détection. La detection par listes est insuffisante pour les lieux (ambiguïté contextuelle : "Florence" la personne vs la ville).
```
✅ Phases 06 Parsers · Moteur · Commandes UI · Portées · Surlignage · Validation sélective
🔄 Phase 7 Dictionnaires — Coulmont ✅ · Dictionnaires JSON/CSV · Ressources embarquées
⏳ Phase 8 Scan automatique (détection sans règle préexistante)
⏳ Phase 9 Interface complète (panneau latéral 4 onglets)
⏳ Phase 10 Publication communauté Obsidian
✅ Phases 07 Parsers · Moteur · UI de base · Portées · Surlignage · Validation · Coulmont
🔄 Phase 8 Interface complète — panneau latéral 4 onglets
⏳ Phase 9 Détection NER + dictionnaires de remplacement (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** (déjà en cours via PR #12766).
---
---
@ -136,68 +140,116 @@ Objectif : pouvoir remplacer certaines occurrences et en ignorer d'autres.
---
## 🔄 Phase 7 — Dictionnaires : import et suggestions
## ✅ Phase 7 — Prénoms : suggestions Coulmont et import de dictionnaires
Objectif : importer des dictionnaires existants (Coulmont, INSEE, etc.) et générer des suggestions de remplacement.
Objectif : générer des suggestions de prénoms sociologiquement équivalents et permettre l'import de dictionnaires existants.
- [ ] Format interne `DictionaryEntry` avec `gender`, `decade`, `socialClass`, `replacementCandidates[]` (§6.3)
- [ ] `dictionaries/JsonDictionaryImporter.ts` : import JSON format SPECS §6.3
- [ ] `dictionaries/CsvDictionaryImporter.ts` : import CSV + mapping des colonnes (§6.4)
- [ ] `adapters/coulmont.ts` : CSV Coulmont → `DictionaryEntry[]`
- [ ] `adapters/insee.ts` : CSV INSEE prénoms → `DictionaryEntry[]`
- [ ] `adapters/geoapi.ts` : GeoAPI INSEE (villes + `sizeClass`) → `DictionaryEntry[]`
- [ ] `dictionaries/DictionaryManager.ts` : activation / désactivation, portée par dictionnaire (§6.5)
- [ ] Dictionnaires embarqués : `firstnames.json`, `lastnames.json`, `cities.json`, `ambiguous.json`, `protected.json`
- [ ] **Exploitable dans Obsidian** :
- Commande `Pseudonymisation : importer un dictionnaire` (JSON ou CSV)
- Dans la modale de création de règle : suggestions issues des dictionnaires actifs
- Tester : importer `prenoms_coulmont.json`, créer une règle pour `Jean` → candidats proposés depuis le dictionnaire
- [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
**Testable :** suggestion automatique de remplacement à partir d'un dictionnaire Coulmont importé.
> **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 — Détection automatique et chevauchements
## 🔄 Phase 8 — Interface complète (panneau latéral 4 onglets)
Objectif : scanner un fichier sans règle préexistante et détecter les entités candidates.
Objectif : interface de travail complète pour le workflow pseudonymisation.
- [ ] `scanner/DictionaryScanner.ts` : croisement texte × dictionnaires actifs (§7.2)
- [ ] `scanner/RegexScanner.ts` : heuristiques typographiques (majuscule initiale, locuteurs CHAT, formats Jefferson/ICOR)
- [ ] `mappings/ConflictDetector.ts` : détection des chevauchements (§8.5)
- [ ] Marquage automatique `needs_review` pour les occurrences chevauchantes
- [ ] `ambiguous.json` : tokens ville/prénom ambigus — signalement pour validation manuelle
- [ ] **Exploitable dans Obsidian** :
- Commande `Pseudonymisation : scanner le fichier` sans mapping préexistant → propose des candidats depuis les dictionnaires et les heuristiques
- Modale de désambiguïsation : contexte du tour + boutons Ville / Prénom / Ignorer
- Tester : scanner `entretien_01.cha`, voir `Jean` et `Saint-Jean-de-Luz` détectés, chevauchement signalé
**Testable :** scan automatique → détection de candidats → résolution manuelle de l'ambiguïté ville/prénom.
---
## ⏳ Phase 9 — Interface complète et correction des conventions
- [ ] Vue latérale complète : 4 onglets Occurrences / Mappings / Dictionnaires / Exports (§10.3)
- [ ] Onglet **Mappings** : modifier source, remplacement, catégorie, portée, priority (z-index), désactiver
- [ ] Onglet **Exports** : choix format, vérification absence de table dans l'export (§10.4)
- [ ] Vue latérale complète : 4 onglets **Occurrences / Mappings / Dictionnaires / Exports** (§10.3)
- Onglet **Occurrences** : liste des occurrences candidates, contexte, boutons Valider / Ignorer / Faux positif, prévisualisation diff
- Onglet **Mappings** : tableau des règles actives — modifier source, remplacement, catégorie, portée, priority, désactiver
- Onglet **Dictionnaires** : liste des dictionnaires chargés, activation/désactivation par portée, commande import
- Onglet **Exports** : choix du format (SRT/CHA/MD/TXT), vérification absence de table dans l'export (§14.1)
- [ ] Rapport de pseudonymisation (§14.3)
- [ ] `correction/checker.ts` : vérification des conventions Jefferson/ICOR
- [ ] Suggestions de correction au survol
- [ ] Suggestions de correction au survol des symboles de convention
- [ ] **Exploitable dans Obsidian** :
- Panneau latéral complet accessible via l'icône ruban
- Workflow complet : ouvrir → scanner → valider → pseudonymiser → exporter sans table
- Tester le workflow de bout en bout sur `entretien_01.srt` et `entretien_02.cha`
- Workflow de bout en bout : ouvrir → scanner → valider → pseudonymiser → exporter sans table
- Tester sur `entretien_01.srt` et `entretien_02.cha`
**Testable :** workflow complet depuis l'ouverture d'un fichier jusqu'à l'export pseudonymisé sans table de correspondance.
---
## ⏳ Phase 10 — Publication
## ⏳ Phase 9 — Détection NER + dictionnaires de remplacement (v0.1.0)
- [ ] README utilisateur en français avec captures d'écran
- [ ] Guide de démarrage rapide pour les chercheurs ICAR
- [ ] Trancher les questions ouvertes de SPECS §20
- [ ] Publication sur le répertoire communautaire Obsidian
Objectif : détecter automatiquement les entités identifiantes (prénoms, noms, **lieux**, institutions) sans règle préexistante, en s'appuyant sur un modèle NER pour le français plutôt que sur des dictionnaires de détection.
**Pourquoi NER plutôt que dictionnaire pour les lieux :** un dictionnaire confond "Florence" la personne et "Florence" la ville. Le NER exploite le contexte syntaxique et résout cette ambiguïté sans liste exhaustive.
**Backend retenu : transformers.js** — modèle BERT-NER multilingue (ONNX) exécuté localement dans Obsidian via `@xenova/transformers`. Téléchargement unique ~66 Mo, 100 % hors-ligne ensuite.
> **Piste spaCy (sidecar Python) — feature envisagée** : meilleure précision sur le français (`fr_core_news_sm`), temps de réponse plus rapide, mais requiert Python. Reportée aux features envisagées (voir fin de roadmap).
### 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 (motivant à créer une règle)
- [x] Onglet NER dans le panneau latéral : seuil de confiance + liste de mots fonctionnels éditables
- [x] Wizard onboarding : téléchargement des fichiers WASM + choix du backend
- [ ] `mappings/ConflictDetector.ts` : détection des chevauchements entre spans NER et règles manuelles (§8.5)
- [ ] Dictionnaires de **remplacement** pour lieux (`cities.json` avec `sizeClass`) et institutions
- [ ] `adapters/geoapi.ts` : GeoAPI INSEE → `DictionaryEntry[]` avec `sizeClass`
- [ ] `ambiguous.json` : tokens historiquement ambigus (Nancy, Florence, Lorraine…) — signalement prioritaire
- [ ] Amélioration modèle : évaluer un modèle français spécifique (CamemBERT-NER)
**Testable (v0.1.0) :** scan NER → entités surlignées en bleu → clic droit → règle créée → pseudonymisation.
---
## ⏳ Phase 10 — Affinage et stabilisation (v0.2.0)
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
- [ ] Performance : mesurer et optimiser le temps de scan NER sur un fichier de 500 tours
- [ ] Documentation utilisateur (README + guide de démarrage rapide pour chercheurs ICAR)
- [ ] Trancher les questions ouvertes persistantes (SPECS §20)
- [ ] Licence : MIT ou EUPL (selon contraintes CNRS)
**Testable (v0.2.0) :** workflow de bout en bout stable sur un corpus réel de 10 entretiens.
---
## ⏳ Phase 11 — Fonctions d'analyse interactionnelle et conversationnelle (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.
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 ICAR peut ouvrir une transcription CHAT, la naviguer tour par tour, corriger les conventions, pseudonymiser, annoter thématiquement, et exporter vers ELAN — sans quitter Obsidian.
---
## 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)
@ -208,7 +260,7 @@ Objectif : scanner un fichier sans règle préexistante et détecter les entité
| 2 | Tables de correspondance dans le vault ou hors vault par défaut ? | À trancher |
| 3 | Chiffrement des tables dès la v1 ? | À trancher |
| 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 ? | À trancher |
| 5 | NER avancé plus tard, ou rester sur dictionnaires + regex + validation humaine ? | **Décidé : NER (Phase 9)** — les dictionnaires servent au remplacement, pas à la détection |
| 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 |

View file

@ -1,12 +1,30 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const prod = process.argv[2] === "production";
const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
// Redirige onnxruntime-node vers onnxruntime-web :
// @xenova/transformers fait un import statique ESM de onnxruntime-node (levé au niveau module,
// avant tout try/catch). En le remplaçant par onnxruntime-web (backend WASM), le runtime
// s'exécute sans dépendance aux binaires natifs, ce qui est nécessaire dans Electron.
alias: {
// onnxruntime-node → ort-web.node.js :
// @xenova/transformers fait un import statique de onnxruntime-node — on le redirige.
// onnxruntime-web → ort-web.node.js (explicite) :
// évite que esbuild choisisse ort-web.min.js (browser bundle) dont la glue emscripten
// a un chemin WASM hardcodé vers electron.asar et appelle path.normalize avec path={}.
// ort-web.node.js utilise fs.readFileSync + path réel → fonctionne dans Electron.
'onnxruntime-node': resolve(__dirname, 'node_modules/onnxruntime-web/dist/ort-web.node.js'),
'onnxruntime-web': resolve(__dirname, 'node_modules/onnxruntime-web/dist/ort-web.node.js'),
},
external: [
"obsidian",
"electron",
@ -24,7 +42,16 @@ const context = await esbuild.context({
...builtins,
],
format: "cjs",
target: "es2018",
target: "es2020",
// Dans un bundle CJS, import.meta.url est undefined. @xenova/transformers/src/env.js
// en a besoin pour localiser son répertoire (RUNNING_LOCALLY). On injecte un polyfill
// basé sur __filename (disponible dans Electron) pour éviter fileURLToPath(undefined).
banner: {
js: 'var __importMetaUrl=typeof __filename!=="undefined"?require("url").pathToFileURL(__filename).href:undefined;',
},
define: {
'import.meta.url': '__importMetaUrl',
},
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,

32832
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "pseudonymizer-tool",
"name": "Pseudonymizer Tool",
"version": "0.0.3",
"version": "0.0.4",
"minAppVersion": "1.4.0",
"description": "Pseudonymize and correct interactional transcripts (Jefferson, ICOR, SRT, CHAT/CHA). Designed for qualitative researchers in linguistics and conversation analysis.",
"author": "Axelle Abbadie",

BIN
ort-wasm-simd.wasm Normal file

Binary file not shown.

BIN
ort-wasm.wasm Normal file

Binary file not shown.

792
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "pseudobsidianization",
"version": "0.0.1",
"name": "pseudonymizer-tool",
"version": "0.0.4",
"description": "Plugin Obsidian de pseudonymisation de transcriptions",
"author": "Axelle Abbadie",
"homepage": "https://cv.hal.science/axelle-abbadie/",
@ -10,7 +10,8 @@
"build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint src --ext .ts"
"lint": "eslint src --ext .ts",
"deploy": "npm run build && node -e \"const {mkdirSync,copyFileSync,existsSync}=require('fs'),d='test_vault/.obsidian/plugins/pseudonymizer-tool',w='node_modules/@xenova/transformers/dist';mkdirSync(d,{recursive:true});['main.js','manifest.json','styles.css'].forEach(f=>{if(existsSync(f))copyFileSync(f,d+'/'+f);});['ort-wasm-simd-threaded.wasm','ort-wasm-simd.wasm','ort-wasm-threaded.wasm','ort-wasm.wasm'].forEach(f=>{if(existsSync(w+'/'+f))copyFileSync(w+'/'+f,d+'/'+f);});\""
},
"devDependencies": {
"@types/jest": "^29.5.0",
@ -27,5 +28,8 @@
"ts-jest": "^29.4.9",
"tslib": "^2.6.0",
"typescript": "^5.3.0"
},
"dependencies": {
"@xenova/transformers": "^2.17.2"
}
}

View file

@ -1,4 +1,4 @@
import { Plugin, Notice, TFile, TAbstractFile, Editor, Menu, MarkdownView, requestUrl } from 'obsidian';
import { Plugin, Notice, TFile, TAbstractFile, Editor, Menu, MarkdownView, requestUrl, WorkspaceLeaf } from 'obsidian';
import { EditorView } from '@codemirror/view';
import { PseudObsSettings, DEFAULT_SETTINGS, PseudObsSettingTab } from './settings';
import { RuleModal } from './ui/RuleModal';
@ -6,6 +6,9 @@ import { QuickPseudonymizeModal } from './ui/QuickPseudonymizeModal';
import { createPseudonymHighlighter, highlightDataChanged, type HighlightData } from './ui/PseudonymHighlighter';
import { EditRuleModal } from './ui/EditRuleModal';
import { OccurrencesModal } from './ui/OccurrencesModal';
import { PseudonymizationView, VIEW_TYPE_PSEUDOBS } from './ui/PseudonymizationView';
import { OnboardingModal } from './ui/OnboardingModal';
import { OnnxNerScanner } from './scanner/OnnxNerScanner';
import { scanOccurrences } from './scanner/OccurrenceScanner';
import { SrtParser } from './parsers/SrtParser';
import { ChatParser } from './parsers/ChatParser';
@ -22,14 +25,22 @@ const CONVERTIBLE_EXTS = ['srt', 'cha', 'chat'];
export default class PseudObsPlugin extends Plugin {
settings!: PseudObsSettings;
scopeResolver!: ScopeResolver;
nerScanner!: OnnxNerScanner;
// Cache synchrone pour le surlignage CM6 (mis à jour de façon asynchrone)
private highlightData: HighlightData = { sources: [], replacements: [] };
private highlightData: HighlightData = { sources: [], replacements: [], nerCandidates: [] };
// Candidats NER par fichier (effacés au changement de fichier ou à un nouveau scan)
private nerCandidateFile: TFile | null = null;
private nerCandidates: string[] = [];
async onload(): Promise<void> {
await this.loadSettings();
this.scopeResolver = new ScopeResolver(this.app.vault, this.settings.mappingFolder);
this.nerScanner = new OnnxNerScanner(this.app);
this.addSettingTab(new PseudObsSettingTab(this.app, this));
this.registerView(VIEW_TYPE_PSEUDOBS, (leaf: WorkspaceLeaf) => new PseudonymizationView(leaf, this));
this.addRibbonIcon('eye-off', 'PseudObsidianization', () => void this.activateView());
// Extension CM6 : surlignage des termes sources (orange) et remplacements (vert)
this.registerEditorExtension(
createPseudonymHighlighter(() => this.highlightData)
@ -42,6 +53,13 @@ export default class PseudObsPlugin extends Plugin {
// Premier chargement au démarrage
void this.refreshHighlightData();
// Onboarding au premier lancement
if (!this.settings.onboardingCompleted) {
this.app.workspace.onLayoutReady(() => {
new OnboardingModal(this.app, this).open();
});
}
// Watcher : convertir automatiquement tout .srt/.cha/.chat ajouté au vault
// (drag-and-drop, copie externe, commande "Ajouter une transcription")
this.registerEvent(
@ -79,6 +97,12 @@ export default class PseudObsPlugin extends Plugin {
callback: () => this.scanCurrentFile(),
});
this.addCommand({
id: 'scan-ner',
name: 'Scanner le fichier avec détection NER',
callback: () => void this.scanCurrentFileNer(),
});
this.addCommand({
id: 'pseudonymize-selection',
name: 'Pseudonymiser la sélection',
@ -95,19 +119,40 @@ export default class PseudObsPlugin extends Plugin {
if (!selection) return;
menu.addSeparator();
const selLower = selection.toLowerCase();
const isKnown =
this.highlightData.sources.some((s) => s.toLowerCase() === selLower) ||
this.highlightData.replacements.some((r) => r.toLowerCase() === selLower);
// Extraire le terme brut : si la sélection inclut les marqueurs, les retirer
const { markerOpen: mOpen, markerClose: mClose, useMarkerInExport } = this.settings;
const bare = useMarkerInExport && selection.startsWith(mOpen) && selection.endsWith(mClose)
? selection.slice(mOpen.length, selection.length - mClose.length)
: selection;
const bareLower = bare.toLowerCase();
if (isKnown) {
// Terme connu : proposer la modification en priorité
const isSource = this.highlightData.sources.some((s) => s.toLowerCase() === bareLower);
const isReplacement = this.highlightData.replacements.some((r) => r.toLowerCase() === bareLower);
const isKnown = isSource || isReplacement;
if (isReplacement) {
// Terme déjà pseudonymisé : proposer l'annulation en premier
menu.addItem((item) =>
item
.setTitle(`Modifier la règle pour "${selection.slice(0, 25)}${selection.length > 25 ? '…' : ''}"`)
.setTitle(`Annuler la pseudonymisation de "${bare.slice(0, 25)}${bare.length > 25 ? '…' : ''}"`)
.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
editor.replaceSelection(location.rule.source);
void this.refreshHighlightData();
})
);
}
if (isKnown) {
menu.addItem((item) =>
item
.setTitle(`Modifier la règle pour "${bare.slice(0, 25)}${bare.length > 25 ? '…' : ''}"`)
.setIcon('settings')
.onClick(async () => {
const location = await this.scopeResolver.findRuleByTerm(selection);
const location = await this.scopeResolver.findRuleByTerm(bare);
if (location) {
new EditRuleModal(this.app, this, location).open();
} else {
@ -149,7 +194,19 @@ export default class PseudObsPlugin extends Plugin {
);
}
onunload(): void {}
onunload(): void {
this.app.workspace.detachLeavesOfType(VIEW_TYPE_PSEUDOBS);
}
private async activateView(): Promise<void> {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_PSEUDOBS)[0];
if (!leaf) {
leaf = workspace.getRightLeaf(false) ?? workspace.getLeaf(true);
await leaf.setViewState({ type: VIEW_TYPE_PSEUDOBS, active: true });
}
workspace.revealLeaf(leaf);
}
// --- Coulmont ---
@ -184,16 +241,38 @@ export default class PseudObsPlugin extends Plugin {
async refreshHighlightData(): Promise<void> {
const file = this.app.workspace.getActiveFile();
if (!file) {
this.highlightData = { sources: [], replacements: [] };
this.highlightData = { sources: [], replacements: [], nerCandidates: [] };
} else {
// Candidats NER : uniquement si le fichier actif est celui du dernier scan
const nerCandidates = file === this.nerCandidateFile ? this.nerCandidates : [];
try {
const rules = await this.scopeResolver.getRulesFor(file.path);
// Pour les fichiers exportés (*.pseudonymized.*), charger les règles directement
// depuis le fichier de mapping de la source : le scope.path ne correspondrait pas
// au chemin de l'export, rendant getRulesFor() inutile pour les règles fichier.
let rules: MappingRule[];
if (file.basename.endsWith('.pseudonymized')) {
const originalBasename = file.basename.slice(0, -'.pseudonymized'.length);
// Vault/dossier + règles fichier de la source (sans filtre de scope path)
const vaultFolderRules = await this.scopeResolver.getRulesFor(file.path);
const fileRules = await this.scopeResolver.getRulesFromMappingFile(
`${originalBasename}.mapping.json`
);
const seen = new Set<string>();
rules = [...vaultFolderRules, ...fileRules].filter((r) => {
const k = `${r.source}||${r.replacement}`;
return seen.has(k) ? false : (seen.add(k), true);
});
} else {
rules = await this.scopeResolver.getRulesFor(file.path);
}
this.highlightData = {
sources: rules.map((r) => r.source).filter(Boolean),
replacements: rules.map((r) => r.replacement).filter(Boolean),
nerCandidates,
};
} catch {
this.highlightData = { sources: [], replacements: [] };
this.highlightData = { sources: [], replacements: [], nerCandidates };
}
}
@ -295,7 +374,7 @@ export default class PseudObsPlugin extends Plugin {
// --- Pseudonymisation ---
private async pseudonymizeActiveFile(): Promise<void> {
async pseudonymizeActiveFile(): Promise<void> {
const file = this.app.workspace.getActiveFile();
if (!file) { new Notice('Aucun fichier actif.'); return; }
@ -357,6 +436,80 @@ export default class PseudObsPlugin extends Plugin {
new Notice(`${rules.length} règle(s) appliquée(s)\n→ ${outputPath}`);
}
async scanCurrentFileNer(): Promise<void> {
if (this.settings.nerBackend !== 'transformers-js') {
new Notice('La détection NER transformers.js n\'est pas activée.\nActivez-la dans Paramètres → Pseudonymizer Tool.');
return;
}
const file = this.app.workspace.getActiveFile();
if (!file) { new Notice('Aucun fichier actif.'); return; }
const ext = file.extension.toLowerCase();
if (!['srt', 'cha', 'chat', 'md', 'txt'].includes(ext)) {
new Notice(`Format non pris en charge : .${ext}`);
return;
}
try {
const content = await this.app.vault.read(file);
const occurrences = await this.nerScanner.scan(content, file.path, {
minScore: this.settings.nerMinScore,
functionWords: new Set(this.settings.nerFunctionWords.map((w) => w.toLowerCase())),
});
if (occurrences.length === 0) {
new Notice('Aucune entité détectée par le NER.');
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
);
} catch (e) {
new Notice(`Erreur NER : ${(e as Error).message}`);
}
}
// Efface les candidats NER pour le fichier courant (appelé après création de règle si besoin)
clearNerCandidates(): void {
this.nerCandidates = [];
this.nerCandidateFile = null;
void this.refreshHighlightData();
}
async exportMappingForFile(file: TFile): Promise<void> {
const mappingPath = `${this.settings.mappingFolder}/${file.basename}.mapping.json`;
const mappingFile = this.app.vault.getAbstractFileByPath(mappingPath);
if (!(mappingFile instanceof TFile)) {
new Notice(`Aucun mapping trouvé pour ${file.name}`);
return;
}
const content = await this.app.vault.read(mappingFile);
await this.ensureFolder(this.settings.exportsFolder);
const destPath = `${this.settings.exportsFolder}/${file.basename}.mapping.json`;
const existing = this.app.vault.getAbstractFileByPath(destPath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
} else {
await this.app.vault.create(destPath, content);
}
new Notice(`✓ Mapping exporté → ${destPath}`);
}
private async scanCurrentFile(): Promise<void> {
const file = this.app.workspace.getActiveFile();
if (!file) { new Notice('Aucun fichier actif.'); return; }

View file

@ -74,6 +74,50 @@ export class ScopeResolver {
return null;
}
// Retourne toutes les règles (tous statuts) applicables à un fichier, avec leur emplacement.
// Utilisé par l'onglet Mappings du panneau latéral pour l'édition et la suppression.
async getRulesWithLocation(filePath: string): 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: MappingFile = JSON.parse(await this.vault.read(child));
const store = MappingStore.fromJSON(data);
const applicable = store.getAll().filter((r) => {
if (r.scope.type === 'vault') return true;
if (r.scope.type === 'folder') return filePath.startsWith(r.scope.path ?? '');
return r.scope.path === filePath;
});
for (const rule of applicable) {
result.push({ rule, store, filePath: child.path });
}
} catch {
// ignorer les fichiers de mapping 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[]> {
const path = `${this.mappingFolder}/${mappingFilename}`;
const file = this.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return [];
try {
const data: MappingFile = JSON.parse(await this.vault.read(file));
const store = MappingStore.fromJSON(data);
return store.getAll().filter((r) => r.status === 'validated');
} catch {
return [];
}
}
// Sauvegarde un store modifié dans son fichier JSON.
async saveStore(store: MappingStore, filePath: string): Promise<void> {
const file = this.vault.getAbstractFileByPath(filePath);

View file

@ -0,0 +1,190 @@
import { App, FileSystemAdapter, Notice } from 'obsidian';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import type { EntityCategory, Occurrence } from '../types';
// Modèle multilingue BERT-NER pré-converti en ONNX via Xenova.
// Supporte le français. Téléchargement unique ~66 Mo, mis en cache dans
// ~/.cache/huggingface/hub (ou env.cacheDir si redéfini).
const MODEL_ID = 'Xenova/bert-base-multilingual-cased-ner-hrl';
// Tags CoNLL → catégorie interne
const TAG_TO_CATEGORY: Record<string, EntityCategory> = {
PER: 'full_name',
LOC: 'place',
ORG: 'institution',
MISC: 'custom',
};
const MIN_ENTITY_LENGTH = 2;
type NerResult = {
entity_group: string;
score: number;
word: string;
start: number;
end: number;
};
// Pipeline chargé une seule fois pour toute la session
let _pipeline: ((text: string, opts?: object) => Promise<NerResult[]>) | null = null;
let _loading = false;
let _loadError: string | null = null;
let _counter = 0;
export class OnnxNerScanner {
private app: App;
constructor(app: App) {
this.app = app;
}
private getPluginDir(): string | null {
const { adapter } = this.app.vault;
if (!(adapter instanceof FileSystemAdapter)) return null;
return path.join(
adapter.getBasePath(),
this.app.vault.configDir,
'plugins',
'pseudonymizer-tool'
);
}
// Charge le pipeline NER (une seule fois, avec notice de progression).
async loadPipeline(): Promise<void> {
if (_pipeline) return;
if (_loadError) throw new Error(_loadError);
if (_loading) {
// Attendre la fin du chargement en cours
await new Promise<void>((resolve, reject) => {
const timer = setInterval(() => {
if (_pipeline) { clearInterval(timer); resolve(); }
if (_loadError) { clearInterval(timer); reject(new Error(_loadError)); }
}, 300);
});
return;
}
_loading = true;
const notice = new Notice('Chargement du modèle NER (première utilisation — ~66 Mo)…', 0);
try {
// Import dynamique pour éviter de charger transformers au démarrage du plugin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const t: any = await import('@xenova/transformers');
const { env, pipeline } = t as { env: typeof import('@xenova/transformers')['env']; pipeline: typeof import('@xenova/transformers')['pipeline'] };
// En Electron, process.release.name === 'node' ajoute 'cpu' en tête des providers.
// Le provider CPU nécessite des binaires natifs non disponibles — on le retire.
if (Array.isArray(t.executionProviders)) {
const cpuIdx = (t.executionProviders as string[]).indexOf('cpu');
if (cpuIdx !== -1) (t.executionProviders as string[]).splice(cpuIdx, 1);
}
// Avec platform:browser, RUNNING_LOCALLY=false → env.cacheDir=null.
// On doit le fixer avant tout appel à pipeline() sinon FileCache(null)
// appelle path.join(null, ...) et lève une TypeError.
const pluginDir = this.getPluginDir();
env.cacheDir = pluginDir
? path.join(pluginDir, '.ner-cache')
: path.join(os.homedir(), '.cache', 'huggingface', 'hub');
if (pluginDir) {
// ort-web.node.js charge le WASM via fs.readFileSync(wasmPaths + filename).
// On pointe vers le dossier plugin où les WASM sont copiés au déploiement.
env.backends.onnx.wasm.wasmPaths = pluginDir + path.sep;
// numThreads=1 : SharedArrayBuffer non disponible dans Electron renderer
// sans COOP/COEP → pas de WASM threadé.
env.backends.onnx.wasm.numThreads = 1;
}
// Autoriser le téléchargement depuis HuggingFace Hub
env.allowRemoteModels = true;
env.allowLocalModels = false;
_pipeline = (await pipeline('token-classification', MODEL_ID) as unknown) as
(text: string, opts?: object) => Promise<NerResult[]>;
notice.hide();
new Notice('✓ Modèle NER chargé', 3000);
} catch (e) {
notice.hide();
const err = e as Error;
// Stack trace complète dans la console pour diagnostiquer
console.error('[PseudObs NER] Erreur complète :', err.stack ?? err.message);
_loadError = err.message;
_loading = false;
throw e;
}
_loading = false;
}
// Retourne true si le pipeline est déjà chargé.
isReady(): boolean {
return _pipeline !== null;
}
// Scanne un texte et retourne les entités détectées sous forme d'Occurrence[].
async scan(
text: string,
filePath: string,
options: { minScore?: number; functionWords?: Set<string> } = {}
): Promise<Occurrence[]> {
await this.loadPipeline();
if (!_pipeline) throw new Error('Pipeline NER non disponible.');
const minScore = options.minScore ?? 0.75;
const functionWords = options.functionWords ?? new Set<string>();
// Le modèle a une limite de tokens : traiter tour par tour (séparation par \n)
const lines = text.split('\n');
const results: Occurrence[] = [];
let offset = 0;
for (const line of lines) {
if (line.trim().length > 2) {
try {
const entities: NerResult[] = await _pipeline(line, { aggregation_strategy: 'simple' });
for (const ent of entities) {
if (ent.score < minScore) continue;
const word = ent.word.trim().replace(/^#+/, ''); // supprimer les ## de sous-mots
// Filtrer les artefacts de tokenisation BERT (mots fonctionnels, tokens trop courts)
if (word.length < MIN_ENTITY_LENGTH) continue;
if (functionWords.has(word.toLowerCase())) continue;
const category = TAG_TO_CATEGORY[ent.entity_group] ?? 'custom';
const start = offset + ent.start;
const end = offset + ent.end;
const ctxLen = 45;
results.push({
id: `ner_${Date.now()}_${++_counter}`,
file: filePath,
line: text.slice(0, start).split('\n').length,
start,
end,
text: word,
contextBefore: text.slice(Math.max(0, start - ctxLen), start),
contextAfter: text.slice(end, Math.min(text.length, end + ctxLen)),
category,
status: 'needs_review',
});
}
} catch {
// Ignorer silencieusement les lignes qui échouent (trop longues, etc.)
}
}
offset += line.length + 1; // +1 pour le \n
}
return results;
}
// Réinitialise le pipeline (utile pour changer de modèle ou libérer la mémoire).
static reset(): void {
_pipeline = null;
_loading = false;
_loadError = null;
}
}

View file

@ -1,5 +1,8 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import type PseudObsPlugin from './main';
import { OnboardingModal } from './ui/OnboardingModal';
export type NerBackend = 'none' | 'spacy' | 'transformers-js';
export interface PseudObsSettings {
transcriptionsFolder: string;
@ -16,6 +19,13 @@ export interface PseudObsSettings {
useMarkerInExport: boolean;
markerOpen: string;
markerClose: string;
// Onboarding
onboardingCompleted: boolean;
nerBackend: NerBackend;
spacyServerUrl: string;
// Paramètres du scanner NER
nerMinScore: number;
nerFunctionWords: string[];
}
export const DEFAULT_SETTINGS: PseudObsSettings = {
@ -30,9 +40,19 @@ export const DEFAULT_SETTINGS: PseudObsSettings = {
preserveCase: true,
preserveAnalyticNotation: true,
warnIfSyncedFolder: true,
useMarkerInExport: false,
markerOpen: '⟦',
markerClose: '⟧',
useMarkerInExport: true,
markerOpen: '{{',
markerClose: '}}',
onboardingCompleted: false,
nerBackend: 'none',
spacyServerUrl: 'http://localhost:5757',
nerMinScore: 0.75,
nerFunctionWords: [
'de', 'du', 'des', "d'", 'le', 'la', 'les', "l'",
'un', 'une', 'au', 'aux', 'en', 'dans', 'sur', 'sous', 'par', 'pour',
'et', 'ou', 'ni', 'mais', 'donc', 'or', 'car',
'à', 'a', 'y', 'the', 'of', 'in', 'and',
],
};
export class PseudObsSettingTab extends PluginSettingTab {
@ -46,7 +66,6 @@ export class PseudObsSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName('Pseudonymizer tool').setHeading();
new Setting(containerEl).setName('Dossiers').setHeading();
@ -154,7 +173,7 @@ export class PseudObsSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName('Marqueur ouvrant')
.setDesc('Exemple : ⟦ [ { «')
.setDesc('Exemple : {{ ⟦ [ «')
.addText((text) =>
text.setValue(this.plugin.settings.markerOpen).onChange(async (value) => {
this.plugin.settings.markerOpen = value;
@ -164,7 +183,7 @@ export class PseudObsSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName('Marqueur fermant')
.setDesc('Exemple : ⟧ ] } »')
.setDesc('Exemple : }} ⟧ ] »')
.addText((text) =>
text.setValue(this.plugin.settings.markerClose).onChange(async (value) => {
this.plugin.settings.markerClose = value;
@ -172,6 +191,31 @@ export class PseudObsSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl).setName('Détection automatique (NER)').setHeading();
new Setting(containerEl)
.setName('Moteur de détection')
.setDesc('Backend utilisé pour la détection automatique des entités nommées identifiantes')
.addDropdown((d) => {
d.addOption('none', 'Désactivé — règles manuelles uniquement');
d.addOption('transformers-js', 'transformers.js (modèle ONNX embarqué)');
d.setValue(this.plugin.settings.nerBackend);
d.onChange(async (v) => {
this.plugin.settings.nerBackend = v as PseudObsSettings['nerBackend'];
await this.plugin.saveSettings();
this.display();
});
});
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();
})
);
new Setting(containerEl).setName('Sécurité').setHeading();
new Setting(containerEl)

View file

@ -68,13 +68,13 @@ export class OccurrencesModal extends Modal {
// Boutons globaux
new Setting(contentEl)
.addButton((b) =>
b.setButtonText('✓ tout valider').onClick(() => {
b.setButtonText('Tout valider').setClass('pseudobs-btn-validate-all').onClick(() => {
for (const occ of this.occurrences) this.decisions.set(occ.id, 'validated');
this.updateAllCards();
})
)
.addButton((b) =>
b.setButtonText('✗ tout ignorer').onClick(() => {
b.setButtonText('Tout ignorer').setClass('pseudobs-btn-ignore-all').onClick(() => {
for (const occ of this.occurrences) this.decisions.set(occ.id, 'ignored');
this.updateAllCards();
})
@ -222,9 +222,14 @@ export class OccurrencesModal extends Modal {
const validated = this.occurrences.filter((o) => this.decisions.get(o.id) === 'validated');
const ignored = this.occurrences.filter((o) => this.decisions.get(o.id) === 'ignored');
const s = this.plugin.settings;
const wrap = (r: string) => s.useMarkerInExport
? `${s.markerOpen}${r}${s.markerClose}`
: r;
const spans: ReplacementSpan[] = validated.map((occ) => {
const rule = this.rules.find((r) => r.id === occ.mappingId)!;
return { start: occ.start, end: occ.end, source: occ.text, replacement: rule.replacement, mappingId: occ.mappingId ?? '', priority: rule.priority };
return { start: occ.start, end: occ.end, source: occ.text, replacement: wrap(rule.replacement), mappingId: occ.mappingId ?? '', priority: rule.priority };
});
const updated = applySpans(this.content, resolveSpans(spans));

436
src/ui/OnboardingModal.ts Normal file
View file

@ -0,0 +1,436 @@
import { App, FileSystemAdapter, Modal, Notice, Setting, TFile, requestUrl } from 'obsidian';
import * as fs from 'fs';
import * as path from 'path';
import type PseudObsPlugin from '../main';
import type { NerBackend } from '../settings';
import type { DictionaryFile } from '../types';
// Version du package @xenova/transformers embarqué — doit rester synchronisée avec package.json
const TRANSFORMERS_VERSION = '2.17.2';
const WASM_CDN_BASE = `https://cdn.jsdelivr.net/npm/@xenova/transformers@${TRANSFORMERS_VERSION}/dist`;
const WASM_FILES = [
'ort-wasm-simd-threaded.wasm',
'ort-wasm-simd.wasm',
'ort-wasm-threaded.wasm',
'ort-wasm.wasm',
] as const;
type Step = 'welcome' | 'ner' | 'dictionaries' | 'summary';
const STEPS: Step[] = ['welcome', 'ner', 'dictionaries', 'summary'];
const STEP_LABELS: Record<Step, string> = {
welcome: 'Bienvenue',
ner: 'Détection NER',
dictionaries: 'Dictionnaires',
summary: 'Résumé',
};
export class OnboardingModal extends Modal {
private plugin: PseudObsPlugin;
private currentStep: Step = 'welcome';
// État local — sauvegardé étape par étape dans les settings
private nerBackend: NerBackend;
private spacyServerUrl: string;
private importedDicts: string[] = []; // noms des fichiers copiés dans cette session
constructor(app: App, plugin: PseudObsPlugin) {
super(app);
this.plugin = plugin;
// Pré-remplir depuis les settings actuels
this.nerBackend = plugin.settings.nerBackend;
this.spacyServerUrl = plugin.settings.spacyServerUrl;
}
onOpen(): void {
this.modalEl.addClass('pseudobs-onboarding');
// Différer le premier render pour sortir du cycle d'ouverture du modal
setTimeout(() => this.render(), 0);
}
private scheduleRender(): void {
// Différer le re-render pour éviter de modifier le DOM pendant un cycle de mesure Obsidian
setTimeout(() => this.render(), 0);
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
this.renderStepIndicator(contentEl);
const body = contentEl.createDiv('pseudobs-onboarding-body');
if (this.currentStep === 'welcome') this.renderWelcome(body);
else if (this.currentStep === 'ner') this.renderNer(body);
else if (this.currentStep === 'dictionaries') this.renderDictionaries(body);
else this.renderSummary(body);
this.renderNav(contentEl);
}
// ---- Indicateur d'étapes ----------------------------------------
private renderStepIndicator(el: HTMLElement): void {
const bar = el.createDiv('pseudobs-onboarding-steps');
for (const step of STEPS) {
const dot = bar.createDiv('pseudobs-onboarding-step-dot');
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] });
}
}
// ---- É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 :',
});
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',
});
}
// ---- Étape 2 : NER ----------------------------------------------
private renderNer(el: HTMLElement): void {
el.createEl('h2', { text: 'Détection automatique des entités' });
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.',
});
// --- Carte transformers.js ---
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' });
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.',
});
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');
wasmStatus.classList.add('pseudobs-onboarding-test-ok');
}
const selectTfjs = cardTfjs.createEl('button', {
text: this.nerBackend === 'transformers-js' ? 'Sélectionné' : 'Installer et utiliser transformers.js',
cls: 'pseudobs-onboarding-select-btn',
});
if (this.nerBackend === 'transformers-js') selectTfjs.addClass('pseudobs-onboarding-select-btn-active');
selectTfjs.addEventListener('click', 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',
cls: 'pseudobs-onboarding-none-btn',
});
if (this.nerBackend === 'none') noneBtn.addClass('pseudobs-onboarding-none-btn-active');
noneBtn.addEventListener('click', async () => {
this.nerBackend = 'none';
await this.saveNerSettings();
this.scheduleRender();
});
}
// ---- Utilitaires WASM -----------------------------------------
private getPluginDir(): string | null {
const { adapter } = this.app.vault;
if (!(adapter instanceof FileSystemAdapter)) return null;
return path.join(
adapter.getBasePath(),
this.app.vault.configDir,
'plugins',
'pseudonymizer-tool'
);
}
private checkWasmFiles(): boolean {
const dir = this.getPluginDir();
if (!dir) return false;
return WASM_FILES.every((f) => fs.existsSync(path.join(dir, f)));
}
private async downloadWasmFiles(
statusEl: HTMLElement,
btn: HTMLElement
): Promise<boolean> {
const dir = this.getPluginDir();
if (!dir) {
statusEl.setText('Impossible de localiser le dossier du plugin');
statusEl.className = 'pseudobs-onboarding-test-status pseudobs-onboarding-test-err';
return false;
}
btn.setAttr('disabled', 'true');
for (let i = 0; i < WASM_FILES.length; i++) {
const f = WASM_FILES[i];
statusEl.className = 'pseudobs-onboarding-test-status';
statusEl.setText(`Téléchargement ${i + 1}/${WASM_FILES.length} : ${f}`);
try {
const response = await requestUrl({ url: `${WASM_CDN_BASE}/${f}`, method: 'GET' });
fs.writeFileSync(path.join(dir, f), Buffer.from(response.arrayBuffer));
} catch (e) {
statusEl.setText(`Échec pour ${f} — vérifiez votre connexion`);
statusEl.classList.add('pseudobs-onboarding-test-err');
btn.removeAttribute('disabled');
return false;
}
}
statusEl.setText('Fichiers .wasm installés');
statusEl.classList.add('pseudobs-onboarding-test-ok');
btn.removeAttribute('disabled');
return true;
}
private async saveNerSettings(): Promise<void> {
this.plugin.settings.nerBackend = this.nerBackend;
this.plugin.settings.spacyServerUrl = this.spacyServerUrl;
await this.plugin.saveSettings();
}
// ---- Étape 3 : Dictionnaires ------------------------------------
private renderDictionaries(el: HTMLElement): void {
el.createEl('h2', { text: 'Dictionnaires de candidats' });
el.createEl('p', {
text: 'Les dictionnaires (.dict.json) contiennent des listes de candidats de remplacement — prénoms, noms de famille, lieux — classés par genre, époque ou taille de ville. Ils alimentent les suggestions de pseudonymes.',
});
el.createEl('p', {
text: 'Si vous n\'avez pas encore de fichier .dict.json, vous pouvez passer cette étape.',
cls: 'pseudobs-onboarding-hint',
});
// Bouton import
const importRow = el.createDiv('pseudobs-onboarding-import-row');
const importBtn = importRow.createEl('button', {
text: 'Importer un dictionnaire (.dict.json)',
cls: 'pseudobs-onboarding-import-btn',
});
const importStatus = importRow.createSpan({ cls: 'pseudobs-onboarding-test-status' });
importBtn.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.multiple = true;
input.classList.add('pseudobs-hidden-input');
document.body.appendChild(input);
input.addEventListener('change', () => void this.processDictFiles(input, importStatus));
input.click();
});
// Liste des dictionnaires déjà présents dans le vault
void this.renderDictList(el);
}
private async processDictFiles(input: HTMLInputElement, statusEl: HTMLElement): Promise<void> {
const files = Array.from(input.files ?? []);
input.remove();
if (files.length === 0) return;
await this.plugin.ensureFolder(this.plugin.settings.dictionariesFolder);
let ok = 0;
let err = 0;
for (const f of files) {
try {
const text = await f.text();
const parsed = JSON.parse(text) as DictionaryFile;
if (!parsed.entries || !Array.isArray(parsed.entries)) throw new Error('Format invalide');
const dest = `${this.plugin.settings.dictionariesFolder}/${f.name}`;
const existing = this.app.vault.getAbstractFileByPath(dest);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, text);
} else {
await this.app.vault.create(dest, text);
}
this.importedDicts.push(f.name);
ok++;
} catch {
err++;
new Notice(`Erreur lors de l\'import de ${f.name} — vérifiez le format .dict.json`);
}
}
if (ok > 0) {
statusEl.setText(`${ok} dictionnaire${ok > 1 ? 's' : ''} importé${ok > 1 ? 's' : ''}`);
statusEl.addClass('pseudobs-onboarding-test-ok');
}
if (err > 0) {
statusEl.setText(`${err} fichier${err > 1 ? 's' : ''} rejeté${err > 1 ? 's' : ''}`);
statusEl.addClass('pseudobs-onboarding-test-err');
}
// Rafraîchir la liste
this.scheduleRender();
}
private async renderDictList(el: HTMLElement): Promise<void> {
const folder = this.app.vault.getAbstractFileByPath(this.plugin.settings.dictionariesFolder);
const files: TFile[] = [];
if (folder && 'children' in folder) {
for (const child of (folder as { children: unknown[] }).children) {
if (child instanceof TFile && child.name.endsWith('.json')) {
files.push(child);
}
}
}
if (files.length === 0) {
el.createEl('p', { text: 'Aucun dictionnaire importé.', cls: 'pseudobs-onboarding-hint' });
return;
}
el.createEl('p', {
text: `${files.length} dictionnaire${files.length > 1 ? 's' : ''} dans le vault :`,
cls: 'pseudobs-onboarding-dict-count',
});
const list = el.createEl('ul', { cls: 'pseudobs-onboarding-dict-list' });
for (const f of files) {
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.addEventListener('click', async () => {
await this.app.vault.delete(f);
this.scheduleRender();
});
}
}
// ---- Étape 4 : Résumé ------------------------------------------
private renderSummary(el: HTMLElement): void {
el.createEl('h2', { text: 'Configuration terminée' });
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)',
};
el.createEl('p', { text: 'Voici ce qui a été configuré :' });
const table = el.createEl('table', { cls: 'pseudobs-onboarding-summary-table' });
const rows: [string, string][] = [
['Détection NER', NER_LABELS[this.plugin.settings.nerBackend]],
];
const folder = this.app.vault.getAbstractFileByPath(this.plugin.settings.dictionariesFolder);
let dictCount = 0;
if (folder && 'children' in folder) {
for (const child of (folder as { children: unknown[] }).children) {
if (child instanceof TFile && child.name.endsWith('.json')) dictCount++;
}
}
rows.push(['Dictionnaires', `${dictCount} fichier${dictCount > 1 ? 's' : ''} dans le vault`]);
for (const [label, value] of rows) {
const tr = table.createEl('tr');
tr.createEl('td', { text: label, cls: 'pseudobs-onboarding-summary-label' });
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',
});
}
// ---- Navigation ------------------------------------------------
private renderNav(el: HTMLElement): void {
const nav = el.createDiv('pseudobs-onboarding-nav');
const idx = STEPS.indexOf(this.currentStep);
// Bouton Annuler — toujours présent sauf à l'étape Résumé
if (this.currentStep !== 'summary') {
nav.createEl('button', { text: 'Annuler', 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' })
.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' })
.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' })
.addEventListener('click', async () => {
this.plugin.settings.onboardingCompleted = true;
await this.plugin.saveSettings();
this.close();
});
} else {
// Étapes intermédiaires : Passer + Suivant
rightBtns.createEl('button', { text: 'Passer cette étape', 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' })
.addEventListener('click', () => {
this.currentStep = STEPS[idx + 1];
this.scheduleRender();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -6,8 +6,9 @@ import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@
export const highlightDataChanged = StateEffect.define<void>();
export interface HighlightData {
sources: string[]; // termes originaux encore présents → orange (à pseudonymiser)
replacements: string[]; // pseudonymes déjà appliqués → vert
sources: string[]; // termes originaux encore présents → orange (à pseudonymiser)
replacements: string[]; // pseudonymes déjà appliqués → vert + souligné
nerCandidates: string[]; // entités détectées par NER → bleu (candidats à pseudonymiser)
}
// Extension CodeMirror 6 qui surligne dans l'éditeur :
@ -33,8 +34,9 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens
}
private build(view: EditorView): DecorationSet {
const { sources, replacements } = getData();
if (sources.length === 0 && replacements.length === 0) return Decoration.none;
const { sources, replacements, nerCandidates } = getData();
if (sources.length === 0 && replacements.length === 0 && nerCandidates.length === 0)
return Decoration.none;
const text = view.state.doc.toString();
const lower = text.toLowerCase();
@ -56,6 +58,22 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens
}
};
// Les candidats NER ne sont affichés que s'ils n'ont pas déjà une règle active
// et ne sont pas des sous-chaînes d'une source composée connue
// (ex. "Jean" et "Luz" filtrés si "Saint-Jean-de-Luz" est une source).
const knownLower = new Set([
...sources.map((s) => s.toLowerCase()),
...replacements.map((r) => r.toLowerCase()),
]);
const sourcesLower = sources.map((s) => s.toLowerCase());
const freshCandidates = nerCandidates.filter((c) => {
const cl = c.toLowerCase();
if (knownLower.has(cl)) return false;
// Filtrer si c est une sous-chaîne stricte d'une source composée
return !sourcesLower.some((src) => src !== cl && src.includes(cl));
});
collect(freshCandidates, 'pseudobs-ner-candidate');
collect(sources, 'pseudobs-source');
collect(replacements, 'pseudobs-replaced');

View file

@ -0,0 +1,588 @@
import { ItemView, Notice, Setting, TFile, WorkspaceLeaf } from 'obsidian';
import type PseudObsPlugin from '../main';
import type { MappingRule, Occurrence } from '../types';
import { scanOccurrences } from '../scanner/OccurrenceScanner';
import { resolveSpans, applySpans } from '../pseudonymizer/SpanProtector';
import type { ReplacementSpan } from '../types';
import { EditRuleModal } from './EditRuleModal';
import { RuleModal } from './RuleModal';
import type { RuleLocation } from '../mappings/ScopeResolver';
export const VIEW_TYPE_PSEUDOBS = 'pseudonymization-view';
type Tab = 'occurrences' | 'mappings' | 'dictionaries' | 'exports' | 'ner';
type Decision = 'validated' | 'ignored' | 'false_positive';
interface CardRef {
card: HTMLElement;
buttons: Map<Decision, HTMLElement>;
arrow: HTMLElement;
resLine: HTMLElement;
statusLabel: HTMLElement;
}
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: '👁',
};
export class PseudonymizationView extends ItemView {
private plugin: PseudObsPlugin;
private activeTab: Tab = 'occurrences';
private panes!: Record<Tab, HTMLElement>;
private tabBtns!: Record<Tab, HTMLElement>;
// Dernier fichier markdown connu (survit au focus du panneau)
private lastFile: TFile | null = null;
// Garde contre la réentrance de onFileChange (le panneau lui-même peut devenir feuille active)
private _renderingTab = false;
// État de l'onglet Occurrences
private occScanned = false;
private occFile: TFile | null = null;
private occContent = '';
private occurrences: Occurrence[] = [];
private occRules: MappingRule[] = [];
private occDecisions: Map<string, Decision> = new Map();
private occCardRefs: Map<string, CardRef> = new Map();
constructor(leaf: WorkspaceLeaf, plugin: PseudObsPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string { return VIEW_TYPE_PSEUDOBS; }
getDisplayText(): string { return 'Pseudonymisation'; }
getIcon(): string { return 'eye-off'; }
async onOpen(): Promise<void> {
const root = this.containerEl.children[1] as HTMLElement;
root.empty();
root.addClass('pseudobs-view');
const tabBar = root.createDiv('pseudobs-view-tabs');
const content = root.createDiv('pseudobs-view-content');
const tabs: [Tab, string][] = [
['occurrences', 'Candidats'],
['mappings', 'Mappings'],
['dictionaries', 'Dictionnaires'],
['exports', 'Exports'],
];
if (this.plugin.settings.nerBackend !== 'none') {
tabs.push(['ner', 'NER']);
}
this.panes = {} as Record<Tab, HTMLElement>;
this.tabBtns = {} as Record<Tab, HTMLElement>;
for (const [id, label] of tabs) {
const pane = content.createDiv('pseudobs-view-pane');
this.panes[id] = pane;
const btn = tabBar.createEl('button', { text: label, cls: 'pseudobs-view-tab' });
btn.addEventListener('click', () => void this.switchTab(id));
this.tabBtns[id] = btn;
}
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => void this.onFileChange())
);
const f = this.app.workspace.getActiveFile();
if (f) this.lastFile = f;
await this.switchTab('occurrences');
}
private async switchTab(tab: Tab): Promise<void> {
this.activeTab = tab;
for (const [id, btn] of Object.entries(this.tabBtns) as [Tab, HTMLElement][]) {
btn.toggleClass('pseudobs-view-tab-active', id === tab);
}
for (const [id, pane] of Object.entries(this.panes) as [Tab, HTMLElement][]) {
// style.display direct — évite le passage par collapse() d'Obsidian qui
// déclenche des mesures de layout en cascade ("Measure loop")
pane.style.display = id === tab ? '' : 'none';
}
await this.renderTab(tab);
}
private async renderTab(tab: Tab): Promise<void> {
const pane = this.panes[tab];
pane.empty();
if (tab === 'occurrences') await this.renderOccurrencesTab(pane);
else if (tab === 'mappings') await this.renderMappingsTab(pane);
else if (tab === 'dictionaries') this.renderDictionariesTab(pane);
else if (tab === 'ner') await this.renderNerTab(pane);
else this.renderExportsTab(pane);
}
private async onFileChange(): Promise<void> {
// Le panneau lui-même peut devenir la feuille active sans changement de fichier
// → éviter une boucle render ↔ active-leaf-change
if (this._renderingTab) return;
const activeLeaf = this.app.workspace.activeLeaf;
if (activeLeaf && (activeLeaf as { view?: unknown }).view === this) return;
const f = this.app.workspace.getActiveFile();
if (f && f !== this.lastFile) {
this.lastFile = f;
this.occScanned = false;
this.occurrences = [];
this.occDecisions = new Map();
this.occCardRefs = new Map();
} else if (f) {
this.lastFile = f;
}
this._renderingTab = true;
try {
await this.renderTab(this.activeTab);
} finally {
this._renderingTab = false;
}
}
private getFile(): TFile | null {
return this.app.workspace.getActiveFile() ?? this.lastFile;
}
// ---- Onglet Occurrences ----------------------------------------
private async renderOccurrencesTab(el: HTMLElement): Promise<void> {
const file = this.getFile();
if (!file) {
el.createEl('p', { text: 'Aucun fichier actif.', cls: 'pseudobs-view-hint' });
return;
}
const ext = file.extension.toLowerCase();
if (!['srt', 'cha', 'chat', 'md', 'txt'].includes(ext)) {
el.createEl('p', {
text: `Format non pris en charge : .${ext}`,
cls: 'pseudobs-view-hint',
});
return;
}
const toolbar = el.createDiv('pseudobs-view-toolbar');
const scanBtn = toolbar.createEl('button', {
text: 'Scanner le fichier',
cls: 'pseudobs-view-action-btn',
});
if (this.plugin.settings.nerBackend !== 'none') {
const nerBtn = toolbar.createEl('button', {
text: 'Identifier des candidats',
cls: 'pseudobs-view-action-btn',
});
nerBtn.title = 'Détecter les entités nommées identifiantes (NER) et les surligner en bleu';
nerBtn.addEventListener('click', () => void this.plugin.scanCurrentFileNer());
}
const resultsEl = el.createDiv('pseudobs-view-results');
if (this.occScanned && this.occFile === file) {
// Reconstruire les refs sur les nouveaux éléments DOM
this.occCardRefs = new Map();
this.renderOccurrenceCards(resultsEl);
} else {
resultsEl.createEl('p', { text: `Fichier : ${file.name}`, cls: 'pseudobs-view-filename' });
resultsEl.createEl('p', {
text: 'Cliquez sur "scanner le fichier" pour détecter les occurrences des règles actives.',
cls: 'pseudobs-view-hint',
});
}
scanBtn.addEventListener('click', async () => {
scanBtn.setAttr('disabled', 'true');
scanBtn.setText('Scan en cours…');
try {
const content = await this.app.vault.read(file);
const rules = await this.plugin.scopeResolver.getRulesFor(file.path);
resultsEl.empty();
if (rules.length === 0) {
resultsEl.createEl('p', {
text: 'Aucune règle active pour ce fichier. Créez des règles via le menu contextuel ou la commande "créer une règle".',
cls: 'pseudobs-view-hint',
});
} else {
const occs = scanOccurrences(content, file.path, rules, {
caseSensitive: this.plugin.settings.caseSensitive,
wholeWordOnly: this.plugin.settings.wholeWordOnly,
});
this.occFile = file;
this.occContent = content;
this.occurrences = occs;
this.occRules = rules;
this.occDecisions = new Map();
this.occCardRefs = new Map();
for (const occ of occs) this.occDecisions.set(occ.id, 'validated');
this.occScanned = true;
this.renderOccurrenceCards(resultsEl);
}
} finally {
scanBtn.removeAttribute('disabled');
scanBtn.setText('Scanner le fichier');
}
});
}
private renderOccurrenceCards(el: HTMLElement): void {
if (this.occurrences.length === 0) {
el.createEl('p', {
text: 'Aucune occurrence trouvée avec les règles actives.',
cls: 'pseudobs-view-hint',
});
return;
}
const n = this.occurrences.length;
const nR = new Set(this.occurrences.map((o) => o.mappingId)).size;
el.createEl('p', {
text: `${n} occurrence${n > 1 ? 's' : ''}${nR} règle${nR > 1 ? 's' : ''}`,
cls: 'pseudobs-view-count',
});
const legend = el.createDiv();
legend.addClass('pseudobs-legend');
for (const [icon, label, cls] of [
['✓', 'Valider', 'pseudobs-legend-badge-validate'],
['✗', 'Conserver', 'pseudobs-legend-badge-ignore'],
['⚠', 'Faux positif', 'pseudobs-legend-badge-fp'],
] as [string, string, string][]) {
const item = legend.createSpan();
item.addClass('pseudobs-legend-item');
item.createSpan({ text: icon }).addClass('pseudobs-legend-badge', cls);
item.createSpan({ text: ` ${label}` });
}
const globalBtns = el.createDiv('pseudobs-view-global-btns');
globalBtns.createEl('button', { text: 'Tout valider', cls: 'pseudobs-btn-validate-all' }).addEventListener('click', () => {
for (const occ of this.occurrences) this.occDecisions.set(occ.id, 'validated');
for (const id of this.occCardRefs.keys()) this.updateCard(id);
});
globalBtns.createEl('button', { text: 'Tout ignorer', cls: 'pseudobs-btn-ignore-all' }).addEventListener('click', () => {
for (const occ of this.occurrences) this.occDecisions.set(occ.id, 'ignored');
for (const id of this.occCardRefs.keys()) this.updateCard(id);
});
const byRule = new Map<string, Occurrence[]>();
for (const occ of this.occurrences) {
const k = occ.mappingId ?? '';
if (!byRule.has(k)) byRule.set(k, []);
byRule.get(k)!.push(occ);
}
for (const [mid, occs] of byRule) {
const rule = this.occRules.find((r) => r.id === mid);
if (!rule) continue;
el.createDiv({
text: `${rule.source}${rule.replacement}`,
cls: 'pseudobs-occ-rule-header',
});
for (const occ of occs) this.buildCard(el, occ, rule);
}
el.createEl('hr');
el
.createEl('button', {
text: 'Appliquer les remplacements',
cls: 'pseudobs-view-apply-btn',
})
.addEventListener('click', () => void this.applyOccurrences());
}
private buildCard(container: HTMLElement, occ: Occurrence, rule: MappingRule): void {
const card = container.createDiv('pseudobs-occ-card');
const srcLine = card.createDiv();
srcLine.addClass('pseudobs-occ-line');
srcLine.createSpan({ text: occ.contextBefore, cls: 'pseudobs-ctx-side' });
srcLine.createSpan({ text: occ.text, cls: 'pseudobs-occ-term' });
srcLine.createSpan({ text: occ.contextAfter, cls: 'pseudobs-ctx-side' });
const arrow = card.createDiv({ text: '↓' });
arrow.addClass('pseudobs-occ-arrow');
const resLine = card.createDiv();
resLine.addClass('pseudobs-occ-line', 'pseudobs-occ-result-line');
resLine.createSpan({ text: occ.contextBefore, cls: 'pseudobs-ctx-side' });
resLine.createSpan({ text: rule.replacement, cls: 'pseudobs-occ-replacement' });
resLine.createSpan({ text: occ.contextAfter, cls: 'pseudobs-ctx-side' });
const statusLabel = card.createDiv();
statusLabel.addClass('pseudobs-occ-status-label');
card.createEl('small', { text: `ligne ${occ.line}`, cls: 'pseudobs-occ-meta' });
const actions = card.createDiv('pseudobs-occ-actions');
const btnRefs = new Map<Decision, HTMLElement>();
for (const [label, value, title] of [
['✓', 'validated', 'Valider'],
['✗', 'ignored', 'Ignorer'],
['⚠', 'false_positive','Faux positif'],
] as [string, Decision, string][]) {
const btn = actions.createEl('button', { text: label });
btn.title = title;
btn.addClass('pseudobs-occ-btn');
btn.addEventListener('click', () => {
this.occDecisions.set(occ.id, value);
this.updateCard(occ.id);
});
btnRefs.set(value, btn);
}
this.occCardRefs.set(occ.id, { card, buttons: btnRefs, arrow, resLine, statusLabel });
this.updateCard(occ.id);
}
private updateCard(occId: string): void {
const ref = this.occCardRefs.get(occId);
if (!ref) return;
const decision = this.occDecisions.get(occId) ?? 'validated';
ref.card.removeClass('pseudobs-occ-validated', 'pseudobs-occ-ignored', 'pseudobs-occ-false_positive');
ref.card.addClass(`pseudobs-occ-${decision}`);
for (const [value, btn] of ref.buttons) {
btn.toggleClass('pseudobs-occ-btn-active', value === decision);
}
const show = decision === 'validated';
ref.arrow.toggle(show);
ref.resLine.toggle(show);
ref.statusLabel.toggle(!show);
const labels: Record<Decision, string> = {
validated: '',
ignored: 'Conservé tel quel',
false_positive: 'Faux positif — exclu',
};
ref.statusLabel.setText(labels[decision]);
}
private async applyOccurrences(): Promise<void> {
if (!this.occFile) return;
const s = this.plugin.settings;
const wrap = (r: string) => s.useMarkerInExport
? `${s.markerOpen}${r}${s.markerClose}`
: r;
const validated = this.occurrences.filter((o) => this.occDecisions.get(o.id) === 'validated');
const spans: ReplacementSpan[] = validated.map((occ) => {
const rule = this.occRules.find((r) => r.id === occ.mappingId)!;
return {
start: occ.start, end: occ.end,
source: occ.text, replacement: wrap(rule.replacement),
mappingId: occ.mappingId ?? '', priority: rule.priority,
};
});
const updated = applySpans(this.occContent, resolveSpans(spans));
await this.app.vault.modify(this.occFile, updated);
await this.plugin.updateMappingStatuses(
this.occFile.path, this.occRules, this.occurrences, this.occDecisions
);
const nv = validated.length;
new Notice(`${nv} remplacement${nv > 1 ? 's' : ''} appliqué${nv > 1 ? 's' : ''}`);
this.occScanned = false;
void this.plugin.refreshHighlightData();
await this.renderTab('occurrences');
}
// ---- Onglet Mappings -------------------------------------------
private async renderMappingsTab(el: HTMLElement): Promise<void> {
const toolbar = el.createDiv('pseudobs-view-toolbar');
toolbar
.createEl('button', { text: 'Ajouter une règle', cls: 'pseudobs-view-add-btn' })
.addEventListener('click', () => new RuleModal(this.app, this.plugin).open());
const file = this.getFile();
if (!file) {
el.createEl('p', { text: 'Aucun fichier actif.', cls: 'pseudobs-view-hint' });
return;
}
const locations = await this.plugin.scopeResolver.getRulesWithLocation(file.path);
if (locations.length === 0) {
el.createEl('p', { text: `Aucune règle pour ${file.name}.`, 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 });
}
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 editBtn = row.createEl('td').createEl('button', {
text: '✎',
cls: 'pseudobs-mappings-edit-btn',
});
editBtn.title = 'Modifier';
editBtn.addEventListener('click', () => new EditRuleModal(this.app, this.plugin, loc).open());
}
}
// ---- Onglet Dictionnaires --------------------------------------
private renderDictionariesTab(el: HTMLElement): void {
el.createEl('p', {
text: 'La gestion avancée des dictionnaires (détection NER, listes de lieux et d\'institutions) sera disponible en Phase 9.',
cls: 'pseudobs-view-hint',
});
el.createEl('p', {
text: 'Utilisez l\'outil Coulmont via le menu contextuel (clic droit sur un prénom dans la transcription).',
cls: 'pseudobs-view-hint',
});
}
// ---- Onglet Exports --------------------------------------------
private renderExportsTab(el: HTMLElement): void {
const file = this.getFile();
if (!file) {
el.createEl('p', { text: 'Aucun fichier actif.', cls: 'pseudobs-view-hint' });
return;
}
el.createEl('p', { text: `Fichier actif : ${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é')
.addButton((btn) =>
btn.setButtonText('Exporter').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')
.addButton((btn) =>
btn.setButtonText('Exporter le mapping').onClick(() => {
void this.plugin.exportMappingForFile(file);
})
);
}
// ---- Onglet NER ---------------------------------------------------
private async renderNerTab(el: HTMLElement): Promise<void> {
const s = this.plugin.settings;
el.createEl('p', {
text: 'Paramètres du scanner de détection automatique des entités nommées.',
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',
});
const scoreRow = scoreSection.createDiv('pseudobs-ner-score-row');
const scoreDisplay = scoreRow.createEl('span', {
text: s.nerMinScore.toFixed(2),
cls: 'pseudobs-ner-score-display',
});
const slider = scoreRow.createEl('input');
slider.type = 'range';
slider.min = '0.5';
slider.max = '1';
slider.step = '0.01';
slider.value = String(s.nerMinScore);
slider.addClass('pseudobs-ner-slider');
slider.addEventListener('input', () => {
scoreDisplay.setText(parseFloat(slider.value).toFixed(2));
});
slider.addEventListener('change', async () => {
this.plugin.settings.nerMinScore = parseFloat(slider.value);
await 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',
});
const textarea = fwSection.createEl('textarea');
textarea.addClass('pseudobs-ner-fw-textarea');
textarea.value = s.nerFunctionWords.join('\n');
textarea.rows = 10;
textarea.spellcheck = false;
const saveBtn = fwSection.createEl('button', {
text: 'Enregistrer',
cls: 'pseudobs-view-action-btn',
});
saveBtn.addEventListener('click', async () => {
const words = textarea.value
.split('\n')
.map((w) => w.trim())
.filter((w) => w.length > 0);
this.plugin.settings.nerFunctionWords = words;
await this.plugin.saveSettings();
saveBtn.addClass('pseudobs-btn-saved');
saveBtn.setText('Enregistré');
setTimeout(() => { saveBtn.removeClass('pseudobs-btn-saved'); saveBtn.setText('Enregistrer'); }, 2000);
});
const resetBtn = fwSection.createEl('button', {
text: 'Réinitialiser par défaut',
cls: 'pseudobs-view-action-btn',
});
resetBtn.addClass('pseudobs-ner-reset-btn');
resetBtn.addEventListener('click', async () => {
const { DEFAULT_SETTINGS } = await import('../settings');
this.plugin.settings.nerFunctionWords = [...DEFAULT_SETTINGS.nerFunctionWords];
await this.plugin.saveSettings();
textarea.value = this.plugin.settings.nerFunctionWords.join('\n');
});
}
onClose(): Promise<void> {
return Promise.resolve();
}
}

View file

@ -140,18 +140,21 @@ export class QuickPseudonymizeModal extends Modal {
return;
}
// 1. Sauvegarder la règle dans le mapping JSON
// 1. Sauvegarder la règle dans le mapping JSON (valeur brute, sans marqueurs)
await this.saveRule(activeFile, replacement);
// 2. Appliquer dans le fichier
// 2. Appliquer dans le fichier — avec marqueurs si activés
const s = this.plugin.settings;
const marked = s.useMarkerInExport
? `${s.markerOpen}${replacement}${s.markerClose}`
: replacement;
if (this.applyScope === 'occurrence') {
// Remplacer uniquement la sélection courante dans l'éditeur
this.editor.replaceRange(replacement, this.from, this.to);
new Notice(`✓ "${this.source}" → "${replacement}" (cette occurrence)`);
this.editor.replaceRange(marked, this.from, this.to);
new Notice(`✓ "${this.source}" → "${marked}" (cette occurrence)`);
} else {
// Remplacer toutes les occurrences dans le fichier courant
const count = await this.plugin.applyRuleToFile(activeFile, this.source, replacement);
new Notice(`✓ "${this.source}" → "${replacement}" (${count} occurrence${count > 1 ? 's' : ''})`);
const count = await this.plugin.applyRuleToFile(activeFile, this.source, marked);
new Notice(`✓ "${this.source}" → "${marked}" (${count} occurrence${count > 1 ? 's' : ''})`);
}
// Rafraîchir le surlignage immédiatement

View file

@ -1,5 +1,12 @@
/* Pseudonymizer Tool — styles */
/* Candidats NER détectés automatiquement — clic droit pour créer une règle */
.pseudobs-ner-candidate {
background-color: rgba(80, 160, 255, 0.22);
border-radius: 2px;
outline: 1px solid rgba(80, 160, 255, 0.45);
}
/* Termes sources encore présents dans le texte (à pseudonymiser) */
.pseudobs-source {
background-color: rgba(255, 160, 0, 0.22);
@ -7,11 +14,14 @@
outline: 1px solid rgba(255, 160, 0, 0.4);
}
/* Termes de remplacement déjà appliqués (pseudonymes) */
/* Termes de remplacement déjà appliqués en direct (pseudonymes dans la source) */
.pseudobs-replaced {
background-color: rgba(60, 200, 100, 0.2);
border-radius: 2px;
outline: 1px solid rgba(60, 200, 100, 0.35);
text-decoration: underline;
text-decoration-color: rgba(60, 200, 100, 0.9);
text-underline-offset: 2px;
}
/* Modal de validation sélective */
@ -164,3 +174,411 @@
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
/* ---- Panneau latéral (PseudonymizationView) ---- */
.pseudobs-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.pseudobs-view-tabs {
display: flex;
border-bottom: 1px solid var(--background-modifier-border);
padding: 4px 4px 0;
gap: 2px;
flex-shrink: 0;
}
.pseudobs-view-tab {
padding: 4px 10px;
border-radius: 4px 4px 0 0;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.82em;
color: var(--text-muted);
}
.pseudobs-view-tab:hover { color: var(--text-normal); }
.pseudobs-view-tab-active {
color: var(--text-normal);
border-bottom: 2px solid var(--interactive-accent);
font-weight: 600;
}
.pseudobs-view-content {
flex: 1;
overflow-y: auto;
position: relative;
}
.pseudobs-view-pane { padding: 10px 12px; }
.pseudobs-view-toolbar { margin-bottom: 10px; }
.pseudobs-view-action-btn {
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: 0.85em;
}
.pseudobs-view-action-btn:hover { background: var(--background-secondary-alt); }
.pseudobs-view-results { /* zone de scroll des cartes, héritée du content */ }
.pseudobs-view-count {
font-size: 0.82em;
color: var(--text-muted);
margin-bottom: 4px;
}
.pseudobs-view-hint {
color: var(--text-muted);
font-style: italic;
font-size: 0.88em;
line-height: 1.5;
}
.pseudobs-view-filename {
font-family: var(--font-monospace);
font-size: 0.82em;
color: var(--text-muted);
margin-bottom: 10px;
}
.pseudobs-view-global-btns {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.pseudobs-view-global-btns button {
padding: 2px 10px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
cursor: pointer;
font-size: 0.82em;
background: var(--background-secondary);
}
.pseudobs-view-apply-btn {
margin-top: 10px;
padding: 5px 16px;
border-radius: 4px;
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
}
.pseudobs-view-apply-btn:hover { opacity: 0.9; }
/* Table de mappings */
.pseudobs-mappings-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82em;
}
.pseudobs-mappings-table th {
text-align: left;
padding: 4px 6px;
border-bottom: 1px solid var(--background-modifier-border);
font-size: 0.8em;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
.pseudobs-mappings-table td {
padding: 4px 6px;
border-bottom: 1px solid var(--background-modifier-border-hover);
vertical-align: middle;
}
.pseudobs-mappings-source { color: var(--color-orange); font-family: var(--font-monospace); }
.pseudobs-mappings-replacement { color: var(--color-green); font-family: var(--font-monospace); }
.pseudobs-mappings-edit-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 2px 6px;
border-radius: 3px;
}
.pseudobs-mappings-edit-btn:hover { color: var(--text-normal); background: var(--background-secondary); }
/* ---- Wizard onboarding ---- */
.pseudobs-onboarding { max-width: 680px; }
/* Indicateur d'étapes */
.pseudobs-onboarding-steps {
display: flex;
gap: 0;
margin-bottom: 20px;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 10px;
}
.pseudobs-onboarding-step-dot {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 14px 4px 0;
font-size: 0.82em;
color: var(--text-faint);
position: relative;
}
.pseudobs-onboarding-step-dot::before {
content: '○';
font-size: 1em;
}
.pseudobs-onboarding-step-current { color: var(--text-normal); font-weight: 600; }
.pseudobs-onboarding-step-current::before { content: '●'; color: var(--interactive-accent); }
.pseudobs-onboarding-step-done { color: var(--text-muted); }
.pseudobs-onboarding-step-done::before { content: '✓'; color: var(--color-green); }
/* Corps */
.pseudobs-onboarding-body { margin-bottom: 16px; }
.pseudobs-onboarding-body h2 { margin-top: 0; font-size: 1.2em; }
.pseudobs-onboarding-hint { color: var(--text-muted); font-size: 0.88em; line-height: 1.5; }
/* Cartes NER */
.pseudobs-onboarding-ner-card {
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
transition: border-color 0.15s;
}
.pseudobs-onboarding-ner-card-selected {
border-color: var(--interactive-accent);
background: color-mix(in srgb, var(--interactive-accent) 6%, transparent);
}
.pseudobs-onboarding-ner-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.pseudobs-onboarding-badge {
font-size: 0.75em;
padding: 2px 8px;
border-radius: 12px;
background: var(--background-secondary);
color: var(--text-muted);
}
.pseudobs-onboarding-code {
display: block;
font-family: var(--font-monospace);
font-size: 0.82em;
background: var(--background-secondary);
padding: 6px 10px;
border-radius: 4px;
margin: 6px 0;
user-select: all;
}
/* Ligne URL + test */
.pseudobs-onboarding-url-row {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
font-size: 0.88em;
}
.pseudobs-onboarding-url-row input {
flex: 1;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
font-family: var(--font-monospace);
font-size: 0.95em;
}
.pseudobs-onboarding-test-btn {
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: 0.85em;
white-space: nowrap;
}
.pseudobs-onboarding-test-status { font-size: 0.85em; }
.pseudobs-onboarding-test-ok { color: var(--color-green); }
.pseudobs-onboarding-test-ok::before { content: '✓ '; }
.pseudobs-onboarding-test-err { color: var(--color-red); }
.pseudobs-onboarding-test-err::before { content: '✗ '; }
/* Boutons de sélection dans les cartes */
.pseudobs-onboarding-select-btn {
margin-top: 8px;
padding: 4px 14px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: 0.85em;
}
.pseudobs-onboarding-select-btn-active {
border-color: var(--interactive-accent);
background: var(--interactive-accent);
color: var(--text-on-accent);
font-weight: 600;
}
.pseudobs-onboarding-select-btn-active::before { content: '✓ '; }
.pseudobs-onboarding-none-btn-active::before { content: '✓ '; }
/* Ligne "Désactivé" */
.pseudobs-onboarding-none-row { margin-top: 4px; }
.pseudobs-onboarding-none-btn {
padding: 4px 14px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: transparent;
cursor: pointer;
font-size: 0.85em;
color: var(--text-muted);
}
.pseudobs-onboarding-none-btn-active { color: var(--text-normal); font-weight: 600; }
/* Import dictionnaires */
.pseudobs-onboarding-import-row {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0;
}
.pseudobs-onboarding-import-btn {
padding: 5px 14px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: 0.88em;
}
.pseudobs-onboarding-import-btn:hover { background: var(--background-secondary-alt); }
.pseudobs-onboarding-import-btn::before { content: '+ '; }
.pseudobs-onboarding-dict-count { font-size: 0.88em; color: var(--text-muted); margin-bottom: 4px; }
.pseudobs-onboarding-dict-list {
list-style: none;
padding: 0;
margin: 0;
}
.pseudobs-onboarding-dict-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.88em;
font-family: var(--font-monospace);
}
.pseudobs-onboarding-dict-list li:hover { background: var(--background-secondary); }
.pseudobs-onboarding-dict-remove {
background: none;
border: none;
cursor: pointer;
color: var(--text-faint);
padding: 0 4px;
font-size: 0.9em;
}
.pseudobs-onboarding-dict-remove:hover { color: var(--color-red); }
/* Résumé */
.pseudobs-onboarding-summary-table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
.pseudobs-onboarding-summary-table td { padding: 6px 10px; border-bottom: 1px solid var(--background-modifier-border-hover); }
.pseudobs-onboarding-summary-label { font-weight: 600; color: var(--text-muted); width: 160px; }
/* Navigation */
.pseudobs-onboarding-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-border);
}
.pseudobs-onboarding-nav-right { display: flex; gap: 8px; align-items: center; }
.pseudobs-onboarding-cancel-btn {
padding: 4px 14px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: transparent;
cursor: pointer;
font-size: 0.88em;
color: var(--text-muted);
}
.pseudobs-onboarding-back-btn::before { content: '← '; }
.pseudobs-onboarding-back-btn {
padding: 4px 14px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: 0.88em;
}
.pseudobs-onboarding-skip-btn {
padding: 4px 14px;
border-radius: 4px;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.88em;
color: var(--text-muted);
}
.pseudobs-onboarding-skip-btn:hover { color: var(--text-normal); }
.pseudobs-onboarding-next-btn {
padding: 5px 18px;
border-radius: 4px;
border: none;
background: var(--interactive-accent);
color: var(--text-on-accent);
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
}
.pseudobs-onboarding-next-btn::after { content: ' →'; }
/* ---- Onglet NER (panneau latéral) ---- */
.pseudobs-ner-section {
margin-bottom: 16px;
}
.pseudobs-ner-section strong { display: block; margin-bottom: 4px; }
.pseudobs-ner-score-row {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 0;
}
.pseudobs-ner-slider { flex: 1; }
.pseudobs-ner-score-display {
font-family: var(--font-monospace);
font-size: 0.9em;
font-weight: 600;
min-width: 3ch;
text-align: right;
}
.pseudobs-ner-fw-textarea {
width: 100%;
font-family: var(--font-monospace);
font-size: 0.82em;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
resize: vertical;
margin-bottom: 8px;
}
/* Boutons d'action avec icônes via ::before */
.pseudobs-btn-validate-all::before { content: '✓ '; }
.pseudobs-btn-ignore-all::before { content: '✗ '; }
.pseudobs-view-add-btn::before { content: '+ '; }
.pseudobs-btn-saved::before { content: '✓ '; color: var(--color-green); }
.pseudobs-ner-reset-btn { margin-left: 8px; }

View file

@ -4,14 +4,14 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2018",
"target": "ES2020",
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"lib": ["ES2018", "DOM"]
"lib": ["ES2020", "DOM"]
},
"include": ["src/**/*.ts"]
}