mirror of
https://github.com/core-hn/pseudobsidian-ization.git
synced 2026-07-22 06:57:06 +00:00
Pre-v0.1.0
This commit is contained in:
parent
2e1f9a1b0f
commit
b9a37f8306
21 changed files with 35623 additions and 447 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
272
README.md
|
|
@ -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,50–1,00) : augmenter réduit les faux positifs
|
||||
- **Mots fonctionnels exclus** : liste éditable des tokens à toujours ignorer ("de", "du", "la"…)
|
||||
|
||||
### Panneau latéral
|
||||
|
||||
Accessible via l'icône dans le ruban ou `Ctrl+P → Pseudonymisation : ouvrir le panneau`.
|
||||
|
||||
| Onglet | Contenu |
|
||||
|---|---|
|
||||
| **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 |
|
||||
|---|---|---|
|
||||
| 0–6 | ✅ | 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.
|
||||
|
|
|
|||
154
ROADMAP.md
154
ROADMAP.md
|
|
@ -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 0–6 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 0–7 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 |
|
||||
|
|
|
|||
|
|
@ -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
32832
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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
BIN
ort-wasm-simd.wasm
Normal file
Binary file not shown.
BIN
ort-wasm.wasm
Normal file
BIN
ort-wasm.wasm
Normal file
Binary file not shown.
792
package-lock.json
generated
792
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
src/main.ts
183
src/main.ts
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
190
src/scanner/OnnxNerScanner.ts
Normal file
190
src/scanner/OnnxNerScanner.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
436
src/ui/OnboardingModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
588
src/ui/PseudonymizationView.ts
Normal file
588
src/ui/PseudonymizationView.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
420
styles.css
420
styles.css
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue