feat: système de dictionnaires structurés v1.1 (Phase 9)

- Format DictionaryFile v1.1 : roles, configSchema, config, author, doi
- DictionaryLoader : index de détection, résolution de classes, scanText (n-grammes), nextReplacement
- Repo dédié pseudobsidian-dictionaries + fr-communes.dict.json (34 957 communes GeoAPI INSEE)
- Wizard : catalogue de dictionnaires téléchargeables depuis le repo dédié (icônes cloud-download/check)
- scripts/build-cities.mjs : génération du dictionnaire communes
- DictScanReviewModal : modale de révision en cards (contexte, préfixe éditable, index calculé)
- MappingScanReviewModal : modale de révision pour le scan par règles existantes
- Onglet Dictionnaires : mini cards avec checkbox, scan individuel, scan groupé
- Suppression onglet Candidats : scan règles → Mappings, scan NER → NER
- RuleModal : suggestions dictionnaire séparées de Coulmont
- docs : README, CHANGELOG, ROADMAP, SPECS §6 mis à jour
This commit is contained in:
Axelle Abbadie 2026-05-14 01:08:25 +02:00
parent 991bc0f14f
commit 4e91c166e8
17 changed files with 3247 additions and 1031 deletions

View file

@ -1,5 +1,31 @@
# Changelog
## [dev] v0.1.3 — 14 mai 2026
**Branche :** `main` | **Tag :** à venir
### Nouvelles fonctionnalités
- **Système de dictionnaires structurés** — format `DictionaryFile` v1.1 : champs `roles` (détection / remplacement / classes), `configSchema` (variables configurables avec valeurs possibles), `config` (valeurs actives), `author`, `doi`
- **`DictionaryLoader`** — service de chargement, index de détection normalisé, résolution de classes par conditions/regex/word-to-word, calcul d'index incrémental par portée
- **Repo dédié [`pseudobsidian-dictionaries`](https://github.com/core-hn/pseudobsidian-dictionaries)** — catalogue de dictionnaires téléchargeables, premier dictionnaire : communes françaises GeoAPI INSEE (34 957 entrées, classes Village → Métropole)
- **Wizard — catalogue de dictionnaires** : tableau scrollable avec bouton icône `cloud-download``refresh-cw` (spin) → `cloud-check` ; installation depuis le repo dédié sans quitter Obsidian
- **`scripts/build-cities.mjs`** — script one-shot de génération du dictionnaire communes depuis GeoAPI INSEE
- **Scan par dictionnaire** (`Onglet Dictionnaires → Scanner`) : fenêtre glissante de n-grammes, filtre par dictionnaires cochés, commande Ctrl+P disponible
- **`DictScanReviewModal`** — modale de révision en cards : terme source, catégorie (tiny), extrait de contexte avec terme surligné, préfixe éditable + index calculé en lecture seule (recalcul dynamique si décoché), checkbox par card
- **`MappingScanReviewModal`** — modale de révision pour le scan par règles : tableau source → remplacement · occurrences, application directe dans le fichier source
- **Onglet Mappings** — bouton "Scanner le fichier" ouvre `MappingScanReviewModal`
- **Onglet NER** — bouton "Identifier des candidats" en haut de l'onglet
- **`RuleModal`** — suggestions de remplacement par classe (dictionnaire) séparées des suggestions Coulmont ; modal Coulmont n'affiche plus les suggestions dictionnaire (prénoms ≠ lieux)
### Suppressions
- **Onglet "Candidats"** supprimé — le scan par règles est maintenant dans Mappings, le scan NER dans NER
### Corrections
- `DictScanReviewModal` : l'index n'est plus modifiable manuellement — seul le préfixe l'est, l'index se recalcule quand on coche/décoche des items
- CSS : animation spin sur l'icône de téléchargement en cours, mini cards dictionnaires dans le panneau
---
## [prod] v0.1.2 — 13 mai 2026
**Branche :** `main` | **Tag :** `0.1.2`

View file

@ -104,11 +104,10 @@ Accessible via l'icône dans le ruban ou `Ctrl+P → Pseudonymisation : ouvrir l
| 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` |
| **Mappings** | Règles actives · Modifier · Supprimer · Ajouter · Scanner le fichier |
| **Dictionnaires** | Mini cards · Scan par dictionnaire · Import local |
| **Exports** | Pseudonymiser et exporter · Exporter la table de correspondance |
| **NER** | Visible si NER activé · Seuil de confiance · Mots fonctionnels exclus |
| **NER** | Visible si NER activé · Identifier des candidats · Seuil de confiance · Mots fonctionnels |
### Surlignage et marqueurs
@ -129,6 +128,28 @@ Dans les fichiers exportés, les pseudonymes sont encadrés de marqueurs `{{Pier
- **Priorité z-index** : entier libre — les entités longues priment (`Saint-Jean-de-Luz` > `Jean`)
- Format JSON documenté dans `SPECS.md §5`
### Dictionnaires de pseudonymisation
Les dictionnaires fournissent des **candidats de remplacement** et alimentent la **détection automatique** de termes à pseudonymiser. Ils sont hébergés dans un dépôt dédié et téléchargés dans votre vault — aucun texte de transcription ne quitte Obsidian.
**Installation depuis le catalogue :**
1. Wizard → étape Dictionnaires → choisir dans le catalogue en ligne
2. Ou : Paramètres → Reconfigurer → étape Dictionnaires
**Utilisation :**
1. Panneau latéral → onglet **Dictionnaires**
2. Cocher les dictionnaires à activer pour le scan
3. Bouton **Scanner le fichier actif** (ou le bouton loupe sur chaque card pour un dictionnaire seul)
4. Une modale de révision s'ouvre : chaque entité détectée est présentée avec son extrait de contexte, sa classe proposée et son remplacement calculé automatiquement (`Ville_1`, `Métropole_2`…)
5. Décocher les faux positifs · modifier le préfixe si besoin · **Créer les règles**
**Dictionnaire disponible — Communes françaises (GeoAPI INSEE) :**
- 34 957 communes françaises, 5 classes : Village · Petite_Ville · Ville · Grande_Ville · Métropole
- Rôles : détection + remplacement par classe avec index d'incrémentation
- Portée recommandée : fichier (chaque fichier reçoit sa propre numérotation)
> Dépôt dédié : [core-hn/pseudobsidian-dictionaries](https://github.com/core-hn/pseudobsidian-dictionaries) — contributions bienvenues.
### Sécurité et confidentialité
- Tout traitement est **local** — aucun texte de transcription n'est envoyé à un serveur externe
@ -172,8 +193,8 @@ src/
|---|---|---|
| 06 | ✅ | Parsers · Moteur · Commandes · Portées · Surlignage · Validation |
| 7 — Coulmont | ✅ | Suggestions de prénoms équivalents · Import JSON/CSV |
| 8 — Panneau latéral | ✅ | 4 onglets · NER embarqué · Wizard · Annulation · Surlignage export |
| 9 — Dictionnaires de candidats NER & spaCy | 🔄 | Dictionnaires de d'identification des mots candidats pour lieux/institutions à pseudonymiser |
| 8 — Panneau latéral | ✅ | 3 onglets · NER embarqué · Wizard · Annulation · Surlignage export |
| 9 — Dictionnaires structurés | 🔄 | Format v1.1 · DictionaryLoader · Scan par dictionnaire · Modale de révision · Communes françaises |
| 10 — Affinage | ⏳ | Stabilisation v0.2.0 |
| 11 — Fonctions EMCA | ⏳ | Navigation tours · Correction Jefferson/ICOR · Export ELAN |
@ -195,6 +216,8 @@ Merci d'ouvrir une issue avant de proposer une pull request pour les fonctionnal
## Licence
GPL 3.0
### Code
*The Beerware License* (Revision 42)
@ -207,10 +230,6 @@ pensez que ça valait le coup, vous pouvez m'offrir une bière.
**Ce plugin est fait pour être modifié.** Si votre terrain implique des conventions de transcription particulières, un dialecte régional, des corpus multilingues ou des formats d'export spécifiques à votre institution, adaptez le code à vos besoins.
### Plugin et dépôt
[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/) (CC BY 4.0).
---
## Crédits

View file

@ -181,29 +181,33 @@ Objectif : interface de travail complète pour le workflow pseudonymisation.
---
## ⏳ Phase 9 — Détection NER + dictionnaires de remplacement (v0.1.0)
## 🔄 Phase 9 — Dictionnaires structurés (v0.1.3)
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.
Objectif : permettre la détection et le remplacement automatiques des entités identifiantes à partir de dictionnaires locaux, téléchargeables hors-ligne depuis un dépôt dédié.
**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).
**Décision architecturale :** le NER (transformers.js) reste le moteur de détection principal pour les entités contextuelles (prénoms, noms, institutions). Les dictionnaires ajoutent une détection exhaustive par liste pour les types dénombrables (communes, institutions connues) et fournissent les remplacements structurés (classes + index).
### Tâches
- [x] `scanner/OnnxNerScanner.ts` : pipeline BERT-NER via `@xenova/transformers`, filtres score et mots fonctionnels
- [x] Surlignage bleu des entités détectées dans l'éditeur (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
- [x] Surlignage bleu des entités détectées dans l'éditeur
- [x] Onglet NER dans le panneau latéral : seuil de confiance + mots fonctionnels · bouton "Identifier des candidats"
- [x] Wizard : téléchargement WASM + catalogue de dictionnaires depuis le repo dédié
- [x] Format `DictionaryFile` v1.1 : `roles`, `configSchema`, `config`, `author`, `doi`
- [x] `src/dictionaries/DictionaryLoader.ts` : chargement, index de détection, résolution de classes (conditions / regex / word-to-word), `nextReplacement()`, `scanText()` (fenêtre glissante n-grammes)
- [x] Repo dédié [`pseudobsidian-dictionaries`](https://github.com/core-hn/pseudobsidian-dictionaries) + `fr-communes.dict.json` (34 957 communes GeoAPI INSEE)
- [x] `scripts/build-cities.mjs` : génération du dictionnaire communes depuis GeoAPI
- [x] Onglet Dictionnaires : mini cards (checkbox · scan individuel · suppression) + scan groupé
- [x] `DictScanReviewModal` : modale de révision en cards (contexte, préfixe éditable, index calculé)
- [x] `MappingScanReviewModal` : modale de révision pour le scan par règles existantes
- [x] Scan par dictionnaire accessible depuis l'onglet Dictionnaires et commande Ctrl+P
- [x] Suppression onglet "Candidats" — scan règles → Mappings · scan NER → NER
- [ ] `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 — signalement prioritaire
- [ ] Amélioration modèle : évaluer un modèle français spécifique (CamemBERT-NER)
- [ ] `ambiguous.json` : tokens historiquement ambigus prénom/lieu (Florence, Nancy, Lorraine…) — badge ⚠ dans la modale de révision
- [ ] Seuil de population minimum configurable pour la détection (exclure les communes < N hab)
- [ ] Amélioration modèle : évaluer CamemBERT-NER (`Jean-Baptiste/camembert-ner`)
**Testable (v0.1.0) :** scan NER → entités surlignées en bleu → clic droit → règle créée → pseudonymisation.
**Testable (v0.1.3) :** installer le dictionnaire communes → scanner un fichier → modale de révision → décocher les faux positifs → créer les règles → pseudonymiser.
---

140
SPECS.md
View file

@ -327,62 +327,126 @@ Le plugin doit permettre dimporter au moins les types suivants :
* institutions ;
* catégories libres définies par lutilisateur.
6.3 Format recommandé pour un dictionnaire
6.3 Format `DictionaryFile` v1.1
Schéma JSON complet. Les champs `roles` et `entries` sont obligatoires.
```json
{
"schemaVersion": "1.0.0",
"dictionaryId": "prenoms_coulmont",
"label": "Prénoms — Baptiste Coulmont",
"type": "first_name",
"schemaVersion": "1.1",
"dictionaryId": "fr-communes",
"label": "Communes françaises (GeoAPI INSEE)",
"type": "place",
"language": "fr",
"source": "imported",
"license": "à renseigner par lutilisateur",
"entries": [
"source": "https://geo.api.gouv.fr/communes",
"license": "Licence Ouverte v2.0",
"author": "INSEE",
"doi": null,
"roles": {
"detection": true,
"replacement": true,
"classes": true
},
"configSchema": [
{
"value": "Jean",
"gender": "masculine",
"frequencyRank": 12,
"replacementCandidates": ["Pierre", "Paul", "Louis"]
"key": "incrementScope",
"label": "Portée de lincrémentation",
"type": "enum",
"values": ["file", "folder", "vault"],
"default": "file",
"recommended": "file",
"description": "Portée dans laquelle les index ({index}) sont uniques."
},
{
"value": "Marie",
"gender": "feminine",
"frequencyRank": 3,
"replacementCandidates": ["Claire", "Anne", "Sophie"]
"key": "replacementPattern",
"label": "Format du pseudonyme généré",
"type": "string",
"default": "{class}_{index}",
"description": "Variables : {class} (classe), {index} (numéro dans la portée)."
},
{
"key": "caseSensitive",
"label": "Sensible à la casse",
"type": "boolean",
"values": [true, false],
"default": false
}
],
"config": {
"classificationMode": "conditions",
"conditions": [
{ "field": "population", "op": "lt", "value": 2000, "class": "Village" },
{ "field": "population", "op": "lt", "value": 10000, "class": "Petite_Ville" },
{ "field": "population", "op": "lt", "value": 100000, "class": "Ville" },
{ "field": "population", "op": "lt", "value": 500000, "class": "Grande_Ville" },
{ "field": "population", "op": "gte", "value": 500000, "class": "Métropole" }
],
"incrementScope": "file",
"replacementPattern": "{class}_{index}",
"caseSensitive": false
},
"entries": [
{ "value": "Saint-Jean-de-Luz", "type": "place", "population": 14857, "departement": "64" }
]
}
```
6.4 Import de dictionnaires
**Champs de len-tête :**
Linterface doit permettre :
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `schemaVersion` | string | oui | Version du format — actuellement `"1.1"` |
| `dictionaryId` | string | oui | Identifiant unique, slug kebab-case |
| `label` | string | oui | Nom affiché dans linterface |
| `type` | EntityCategory | oui | Type principal des entrées (`place`, `first_name`, `institution`…) |
| `language` | string | oui | Code langue ISO 639-1 (`fr`, `en`…) |
| `source` | string | oui | URL ou description de la source |
| `license` | string | non | Licence des données |
| `author` | string | non | Auteur ou organisme producteur |
| `doi` | string\|null | non | DOI de la ressource si disponible |
| `roles.detection` | bool | oui | `true` si les entrées alimentent la détection |
| `roles.replacement` | bool | oui | `true` si les entrées alimentent les suggestions de remplacement |
| `roles.classes` | bool | oui | `true` si le remplacement utilise un système de classes avec incrémentation |
| `configSchema` | array | non | Décrit les variables configurables et leurs valeurs possibles |
| `config` | object | non | Valeurs actives (modifiables par lutilisateur) |
* dimporter un dictionnaire JSON ;
* dimporter un CSV ;
* de mapper les colonnes dun CSV vers les champs attendus ;
* dactiver ou désactiver un dictionnaire ;
* de choisir son ordre de priorité ;
* de définir sa portée par défaut.
**Modes de classification (`config.classificationMode`) :**
6.5 Sélection des dictionnaires actifs
| Mode | Usage |
|---|---|
| `conditions` | Règles sur un champ numérique ou catégoriel des entrées (ex. `population`) |
| `regex` | Patterns regex sur la valeur de lentrée (ex. `^CHU`, `^Université`) |
| `word-to-word` | Remplacement fixe par entrée (champ `replacement` dans lentrée) |
Dans linterface de traitement, lutilisateur doit pouvoir sélectionner les dictionnaires à appliquer.
**Champs libres dans `DictionaryEntry` :** toute propriété supplémentaire (`population`, `departement`, `platform`…) est transmise aux conditions de classification.
Exemple dinterface :
6.4 Import et installation de dictionnaires
* [x] Prénoms — Baptiste Coulmont
* [x] Villes françaises — catégories INSEE
* [ ] Patronymes — dictionnaire interne
* [x] Institutions — LIIPPS
Trois voies dinstallation :
Pour chaque dictionnaire, on doit pouvoir choisir :
1. **Catalogue en ligne** (wizard ou Paramètres → Reconfigurer) — télécharge depuis le dépôt [`pseudobsidian-dictionaries`](https://github.com/core-hn/pseudobsidian-dictionaries) dans `_pseudonymisation/dictionaries/`
2. **Import local** (onglet Dictionnaires) — importe un fichier `.dict.json` local
3. **Dépôt dédié** — les dictionnaires sont versionnés dans un repo séparé du plugin ; format et schéma documentés dans ce repo
* appliquer au fichier courant ;
* appliquer au dossier courant ;
* appliquer au vault ;
* utiliser seulement pour les suggestions ;
* utiliser automatiquement avec validation globale ;
* désactiver.
6.5 Sélection et scan
Longlet **Dictionnaires** du panneau latéral liste les dictionnaires installés sous forme de mini cards. Pour chaque card :
- **Checkbox** — inclure ou exclure du scan groupé
- **Bouton scan** (loupe) — scanner le fichier actif avec ce seul dictionnaire
- **Bouton scan groupé** — scanner avec tous les dictionnaires cochés
La **modale de révision** (`DictScanReviewModal`) présente chaque entité détectée avec :
- son extrait de contexte (±50 caractères) pour aider à distinguer les faux positifs
- sa classe proposée (ex. `Ville`)
- son remplacement calculé : préfixe éditable + index en lecture seule (`Ville_1`)
- une checkbox pour linclure ou lexclure
Lindex est recalculé dynamiquement : décocher `Ville_2` redistribue les indices suivants.

1
assets/cities.dict.json Normal file

File diff suppressed because one or more lines are too long

1712
main.js

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint src --ext .ts",
"build:cities": "node scripts/build-cities.mjs",
"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": {

146
scripts/build-cities.mjs Normal file
View file

@ -0,0 +1,146 @@
/**
* Script one-shot : génère assets/cities.dict.json depuis GeoAPI INSEE.
* Usage : node scripts/build-cities.mjs
* Requiert Node >= 18 (fetch natif).
*/
import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_PATH = join(__dirname, '..', 'assets', 'cities.dict.json');
const GEOAPI_URL =
'https://geo.api.gouv.fr/communes?fields=nom,population,departement,region&format=json';
// Conditions de classification — ordre important : elles sont évaluées en séquence,
// la première condition vraie l'emporte.
const CONDITIONS = [
{ field: 'population', op: 'lt', value: 2000, class: 'Village' },
{ field: 'population', op: 'lt', value: 10000, class: 'Petite_Ville' },
{ field: 'population', op: 'lt', value: 100000, class: 'Ville' },
{ field: 'population', op: 'lt', value: 500000, class: 'Grande_Ville' },
{ field: 'population', op: 'gte', value: 500000, class: 'Métropole' },
];
function applyOp(entryValue, op, condValue) {
switch (op) {
case 'lt': return entryValue < condValue;
case 'lte': return entryValue <= condValue;
case 'gt': return entryValue > condValue;
case 'gte': return entryValue >= condValue;
case 'eq': return entryValue === condValue;
case 'neq': return entryValue !== condValue;
case 'contains': return String(entryValue).includes(String(condValue));
default: return false;
}
}
function resolveClass(entry) {
for (const cond of CONDITIONS) {
const v = entry[cond.field];
if (v != null && applyOp(v, cond.op, cond.value)) return cond.class;
}
return null;
}
async function main() {
console.log('Téléchargement GeoAPI INSEE…');
const res = await fetch(GEOAPI_URL);
if (!res.ok) throw new Error(`GeoAPI HTTP ${res.status}`);
/** @type {Array<{nom:string, population?:number, departement?:{code:string,nom:string}, region?:{code:string,nom:string}}>} */
const communes = await res.json();
console.log(`${communes.length} communes reçues.`);
const entries = communes
.filter((c) => c.population != null && c.population > 0)
.map((c) => {
const entry = {
value: c.nom,
type: 'place',
population: c.population,
departement: c.departement?.code ?? null,
region: c.region?.code ?? null,
};
const resolved = resolveClass(entry);
if (resolved) entry.sizeClass = resolved;
return entry;
});
console.log(`${entries.length} entrées avec population.`);
const dict = {
schemaVersion: '1.1',
dictionaryId: 'fr-communes',
label: 'Communes françaises (GeoAPI INSEE)',
type: 'place',
language: 'fr',
source: 'https://geo.api.gouv.fr/communes',
license: 'Licence Ouverte v2.0 / Open Licence v2.0',
author: 'Institut national de la statistique et des études économiques (INSEE)',
doi: null,
roles: {
detection: true,
replacement: true,
classes: true,
},
configSchema: [
{
key: 'incrementScope',
label: "Portée de l'incrémentation",
type: 'enum',
values: ['file', 'folder', 'vault'],
default: 'file',
recommended: 'file',
description:
"Définit dans quelle portée les index ({index}) sont uniques. 'file' est recommandé pour maximiser l'anonymisation.",
},
{
key: 'replacementPattern',
label: 'Format du pseudonyme généré',
type: 'string',
default: '{class}_{index}',
description:
'Variables disponibles : {class} (classe attribuée), {index} (numéro d\'incrémentation dans la portée).',
},
{
key: 'caseSensitive',
label: 'Sensible à la casse',
type: 'boolean',
values: [true, false],
default: false,
description: "Si false, 'paris' et 'Paris' sont détectés comme la même entité.",
},
],
config: {
classificationMode: 'conditions',
conditions: CONDITIONS,
incrementScope: 'file',
replacementPattern: '{class}_{index}',
caseSensitive: false,
},
entries,
};
mkdirSync(join(__dirname, '..', 'assets'), { recursive: true });
writeFileSync(OUT_PATH, JSON.stringify(dict), 'utf-8');
console.log(`Dictionnaire écrit : ${OUT_PATH}`);
// Statistiques par classe
const stats = {};
for (const e of entries) {
const cls = e.sizeClass ?? '(sans classe)';
stats[cls] = (stats[cls] ?? 0) + 1;
}
console.log('\nRépartition par classe :');
for (const [cls, count] of Object.entries(stats).sort((a, b) => b[1] - a[1])) {
console.log(` ${cls.padEnd(15)} ${count}`);
}
}
main().catch((e) => { console.error(e); process.exit(1); });

View file

@ -0,0 +1,379 @@
import { App } from 'obsidian';
import type PseudObsPlugin from '../main';
import type {
DictionaryFile,
DictionaryEntry,
DictionaryConfig,
DictionaryCondition,
DictionaryPattern,
Occurrence,
EntityCategory,
} from '../types';
// Séquence de tokens avec positions dans la ligne d'origine
interface Token { word: string; start: number; end: number; }
// Longueur max des n-grammes testés (couvre "Saint-Martin-de-Ré" en 1 token,
// "Aix-en-Provence" en 1 token, mais aussi "La Roche-sur-Yon" en 4 tokens)
const MAX_NGRAM = 4;
let _dictScanCounter = 0;
interface DetectionHit {
entry: DictionaryEntry;
dict: DictionaryFile;
}
export class DictionaryLoader {
private dictionaries: DictionaryFile[] = [];
// clé : valeur normalisée → toutes les entrées de détection correspondantes
private detectionIndex = new Map<string, DetectionHit[]>();
constructor(private app: App, private plugin: PseudObsPlugin) {}
// --- Chargement ---
async load(): Promise<void> {
this.dictionaries = [];
this.detectionIndex.clear();
// Les dictionnaires sont téléchargés par le wizard dans dictionariesFolder
const loaded = await this.readDictFilesFrom(this.plugin.settings.dictionariesFolder);
this.dictionaries = loaded;
this.buildDetectionIndex();
}
private async readDictFilesFrom(dir: string): Promise<DictionaryFile[]> {
const result: DictionaryFile[] = [];
try {
const listing = await this.app.vault.adapter.list(dir);
for (const filePath of listing.files) {
if (!filePath.endsWith('.dict.json')) continue;
try {
const raw = await this.app.vault.adapter.read(filePath);
const dict = JSON.parse(raw) as DictionaryFile;
if (dict.dictionaryId && Array.isArray(dict.entries)) {
result.push(dict);
}
} catch {
// fichier illisible ou mal formé — on ignore
}
}
} catch {
// dossier inexistant — normal
}
return result;
}
private buildDetectionIndex(): void {
for (const dict of this.dictionaries) {
if (!dict.roles?.detection) continue;
const cs = dict.config?.caseSensitive ?? false;
const strip = dict.config?.stripPrefix ?? [];
for (const entry of dict.entries) {
const key = this.normalizeKey(entry.value, cs, strip);
const hits = this.detectionIndex.get(key) ?? [];
hits.push({ entry, dict });
this.detectionIndex.set(key, hits);
}
}
}
private normalizeKey(value: string, caseSensitive: boolean, stripPrefix: string[]): string {
let v = value;
for (const prefix of stripPrefix) {
if (v.startsWith(prefix)) { v = v.slice(prefix.length); break; }
}
return caseSensitive ? v : v.toLowerCase();
}
// --- Détection ---
contains(value: string): boolean {
// teste dans tous les dictionnaires de détection (avec leurs propres options de normalisation)
for (const dict of this.dictionaries) {
if (!dict.roles?.detection) continue;
const cs = dict.config?.caseSensitive ?? false;
const strip = dict.config?.stripPrefix ?? [];
const key = this.normalizeKey(value, cs, strip);
if (this.detectionIndex.has(key)) return true;
}
return false;
}
getDetectionHits(value: string): DetectionHit[] {
const all: DetectionHit[] = [];
for (const dict of this.dictionaries) {
if (!dict.roles?.detection) continue;
const cs = dict.config?.caseSensitive ?? false;
const strip = dict.config?.stripPrefix ?? [];
const key = this.normalizeKey(value, cs, strip);
const hits = this.detectionIndex.get(key);
if (hits) all.push(...hits);
}
return all;
}
// --- Classification et remplacement ---
/**
* Résout la classe d'une entrée selon le classificationMode du dictionnaire.
* Retourne null si aucune classe n'est trouvée ou si le dictionnaire n'utilise pas les classes.
*/
resolveClass(entry: DictionaryEntry, dict: DictionaryFile): string | null {
if (!dict.roles?.classes || !dict.config) return null;
const { classificationMode, conditions, patterns } = dict.config;
if (classificationMode === 'conditions' && conditions) {
return this.resolveByConditions(entry, conditions);
}
if (classificationMode === 'regex' && patterns) {
return this.resolveByRegex(entry.value, patterns, dict.config);
}
// word-to-word sans classes : pas de classe à attribuer
return null;
}
private resolveByConditions(entry: DictionaryEntry, conditions: DictionaryCondition[]): string | null {
for (const cond of conditions) {
const v = entry[cond.field];
if (v == null) continue;
if (this.applyOp(v as string | number, cond.op, cond.value)) return cond.class;
}
return null;
}
private resolveByRegex(value: string, patterns: DictionaryPattern[], config: DictionaryConfig): string | null {
for (const p of patterns) {
const flags = p.flags ?? (config.caseSensitive === false ? 'i' : '');
try {
if (new RegExp(p.pattern, flags).test(value)) return p.class;
} catch {
// regex invalide — ignorée
}
}
return null;
}
private applyOp(entryVal: string | number, op: DictionaryCondition['op'], condVal: string | number): boolean {
switch (op) {
case 'lt': return (entryVal as number) < (condVal as number);
case 'lte': return (entryVal as number) <= (condVal as number);
case 'gt': return (entryVal as number) > (condVal as number);
case 'gte': return (entryVal as number) >= (condVal as number);
case 'eq': return entryVal === condVal;
case 'neq': return entryVal !== condVal;
case 'contains': return String(entryVal).includes(String(condVal));
}
}
/**
* Génère le prochain remplacement pour une classe donnée.
* existingReplacements : liste des remplacements déjà utilisés dans la portée concernée.
* Format résultant : '{class}_{index}' ou le pattern configuré dans dict.config.
*/
nextReplacement(dict: DictionaryFile, entryClass: string, existingReplacements: string[]): string {
const pattern = dict.config?.replacementPattern ?? '{class}_{index}';
// Trouver le plus grand index déjà utilisé pour cette classe dans la portée
const prefix = pattern.replace('{class}', entryClass).replace('_{index}', '_');
let maxIndex = 0;
for (const r of existingReplacements) {
if (r.startsWith(prefix)) {
const suffix = r.slice(prefix.length);
const n = parseInt(suffix, 10);
if (!isNaN(n) && n > maxIndex) maxIndex = n;
}
}
const nextIndex = maxIndex + 1;
return pattern.replace('{class}', entryClass).replace('{index}', String(nextIndex));
}
/**
* Résout le remplacement d'une valeur source depuis un dictionnaire de remplacement.
* - Si word-to-word et entry.replacement présent retourne entry.replacement directement.
* - Si classes génère via nextReplacement.
* - Si word-to-word sans remplacement fixe retourne null (l'utilisateur saisit manuellement).
*/
resolveReplacement(
sourceValue: string,
existingReplacements: string[],
): { replacement: string; dictionaryId: string; entryClass: string | null } | null {
for (const dict of this.dictionaries) {
if (!dict.roles?.replacement) continue;
const cs = dict.config?.caseSensitive ?? false;
const strip = dict.config?.stripPrefix ?? [];
const key = this.normalizeKey(sourceValue, cs, strip);
const hits = this.detectionIndex.get(key) ?? [];
const hit = hits.find((h) => h.dict.dictionaryId === dict.dictionaryId);
if (!hit) continue;
const entryClass = this.resolveClass(hit.entry, dict);
if (entryClass) {
const replacement = this.nextReplacement(dict, entryClass, existingReplacements);
return { replacement, dictionaryId: dict.dictionaryId, entryClass };
}
if (hit.entry.replacement) {
return { replacement: hit.entry.replacement, dictionaryId: dict.dictionaryId, entryClass: null };
}
}
return null;
}
/**
* Retourne des suggestions de remplacement (pour l'UI de RuleModal).
* Pour les dictionnaires avec classes : liste les classes disponibles + exemples.
* Pour word-to-word : retourne les replacementCandidates de l'entrée correspondante.
*/
getSuggestions(
sourceValue: string,
count = 8,
): { label: string; value: string; fromClass: boolean }[] {
const results: { label: string; value: string; fromClass: boolean }[] = [];
for (const dict of this.dictionaries) {
if (!dict.roles?.replacement) continue;
const cs = dict.config?.caseSensitive ?? false;
const strip = dict.config?.stripPrefix ?? [];
const key = this.normalizeKey(sourceValue, cs, strip);
const hits = this.detectionIndex.get(key) ?? [];
const hit = hits.find((h) => h.dict.dictionaryId === dict.dictionaryId);
if (hit) {
const entryClass = this.resolveClass(hit.entry, dict);
if (entryClass) {
// Mode classes : afficher la classe comme suggestion principale
results.push({
label: `${entryClass}_N (${dict.label})`,
value: entryClass,
fromClass: true,
});
}
if (hit.entry.replacementCandidates) {
for (const c of hit.entry.replacementCandidates.slice(0, count)) {
results.push({ label: c, value: c, fromClass: false });
}
}
}
if (results.length >= count) break;
}
return results.slice(0, count);
}
// --- Accès aux dictionnaires chargés ---
getAll(): DictionaryFile[] {
return this.dictionaries;
}
getById(id: string): DictionaryFile | undefined {
return this.dictionaries.find((d) => d.dictionaryId === id);
}
/** Retourne vrai si au moins un dictionnaire de détection est chargé. */
hasDetection(): boolean {
return this.dictionaries.some((d) => d.roles?.detection);
}
/** Retourne vrai si au moins un dictionnaire de remplacement est chargé. */
hasReplacement(): boolean {
return this.dictionaries.some((d) => d.roles?.replacement);
}
get size(): number {
return this.dictionaries.length;
}
// --- Scan de texte ---
/**
* Scanne un texte complet et retourne les entités trouvées dans les dictionnaires
* dont `roles.detection = true`. Fenêtre glissante de n-grammes (1..MAX_NGRAM tokens)
* pour couvrir les noms composés ("La Rochelle", "Le Puy-en-Velay").
* Les entités déjà couvertes par `existingSources` sont exclues (déjà en orange).
*/
scanText(
text: string,
filePath: string,
existingSources: Set<string> = new Set(),
dictIds?: string[], // si fourni, limite le scan à ces dictionnaires
): Occurrence[] {
if (!this.hasDetection()) return [];
const results: Occurrence[] = [];
const seen = new Set<string>(); // dédoublonnage par valeur normalisée
const lines = text.split('\n');
let lineOffset = 0;
for (const line of lines) {
const tokens = this.tokenize(line);
for (let i = 0; i < tokens.length; i++) {
let matched = false;
// Du plus long au plus court pour préférer "La Rochelle" à "La"
for (let n = Math.min(MAX_NGRAM, tokens.length - i); n >= 1; n--) {
const phrase = tokens.slice(i, i + n).map((t) => t.word).join(' ');
const allHits = this.getDetectionHits(phrase);
const hits = dictIds
? allHits.filter((h) => dictIds.includes(h.dict.dictionaryId))
: allHits;
if (hits.length === 0) continue;
const hit = hits[0];
const valueNorm = (hit.dict.config?.caseSensitive ?? false)
? phrase : phrase.toLowerCase();
// Ignorer si déjà une règle source pour ce terme
if (existingSources.has(valueNorm)) { matched = true; break; }
// Ignorer les doublons dans ce scan
if (seen.has(valueNorm)) { matched = true; break; }
seen.add(valueNorm);
const startInLine = tokens[i].start;
const endInLine = tokens[i + n - 1].end;
const start = lineOffset + startInLine;
const end = lineOffset + endInLine;
const ctxLen = 45;
results.push({
id: `dict_${Date.now()}_${++_dictScanCounter}`,
file: filePath,
line: lines.indexOf(line) + 1,
start,
end,
text: phrase,
contextBefore: text.slice(Math.max(0, start - ctxLen), start),
contextAfter: text.slice(end, Math.min(text.length, end + ctxLen)),
category: (hit.dict.type ?? 'place') as EntityCategory,
status: 'needs_review',
});
i += n - 1; // sauter les tokens déjà consommés
matched = true;
break;
}
void matched;
}
lineOffset += line.length + 1; // +1 pour le \n
}
return results;
}
private tokenize(line: string): Token[] {
const tokens: Token[] = [];
// Séparation sur les espaces — les tirets restent attachés au mot ("Saint-Jean")
const re = /\S+/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
tokens.push({ word: m[0], start: m.index, end: m.index + m[0].length });
}
return tokens;
}
}

View file

@ -9,6 +9,11 @@ import { OccurrencesModal } from './ui/OccurrencesModal';
import { PseudonymizationView, VIEW_TYPE_PSEUDOBS } from './ui/PseudonymizationView';
import { OnboardingModal } from './ui/OnboardingModal';
import { OnnxNerScanner } from './scanner/OnnxNerScanner';
import { DictionaryLoader } from './dictionaries/DictionaryLoader';
import { DictScanReviewModal } from './ui/DictScanReviewModal';
import type { DictScanResultItem } from './ui/DictScanReviewModal';
import { MappingScanReviewModal } from './ui/MappingScanReviewModal';
import type { MappingRuleResult } from './ui/MappingScanReviewModal';
import { scanOccurrences } from './scanner/OccurrenceScanner';
import { SrtParser } from './parsers/SrtParser';
import { ChatParser } from './parsers/ChatParser';
@ -26,6 +31,7 @@ export default class PseudObsPlugin extends Plugin {
settings!: PseudObsSettings;
scopeResolver!: ScopeResolver;
nerScanner!: OnnxNerScanner;
dictionaryLoader!: DictionaryLoader;
// Cache synchrone pour le surlignage CM6 (mis à jour de façon asynchrone)
private highlightData: HighlightData = { sources: [], replacements: [], nerCandidates: [] };
// Candidats NER par fichier (effacés au changement de fichier ou à un nouveau scan)
@ -36,6 +42,9 @@ export default class PseudObsPlugin extends Plugin {
await this.loadSettings();
this.scopeResolver = new ScopeResolver(this.app.vault, this.settings.mappingFolder);
this.nerScanner = new OnnxNerScanner(this.app);
this.dictionaryLoader = new DictionaryLoader(this.app, this);
// Chargement différé : le dossier plugin est accessible après layout ready
this.app.workspace.onLayoutReady(() => { void this.dictionaryLoader.load(); });
this.addSettingTab(new PseudObsSettingTab(this.app, this));
this.registerView(VIEW_TYPE_PSEUDOBS, (leaf: WorkspaceLeaf) => new PseudonymizationView(leaf, this));
@ -103,6 +112,12 @@ export default class PseudObsPlugin extends Plugin {
callback: () => void this.scanCurrentFileNer(),
});
this.addCommand({
id: 'scan-dictionaries',
name: 'Scanner le fichier avec les dictionnaires',
callback: () => void this.scanCurrentFileWithDictionaries(),
});
this.addCommand({
id: 'pseudonymize-selection',
name: 'Pseudonymiser la sélection',
@ -478,6 +493,89 @@ export default class PseudObsPlugin extends Plugin {
}
}
async scanCurrentFileWithDictionaries(dictIds?: string[]): Promise<void> {
if (!this.dictionaryLoader.hasDetection()) {
new Notice('Aucun dictionnaire de détection chargé.\nInstallez un dictionnaire depuis le panneau Dictionnaires.');
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;
}
const content = await this.app.vault.read(file);
const rules = await this.scopeResolver.getRulesFor(file.path);
const existingSources = new Set(rules.map((r) => r.source.toLowerCase()));
const occurrences = this.dictionaryLoader.scanText(content, file.path, existingSources, dictIds);
if (occurrences.length === 0) {
new Notice('Aucune entité trouvée dans les dictionnaires de détection.');
return;
}
// Grouper par terme (ordre d'apparition dans le texte, déjà garanti par scanText)
const seenTerms = new Map<string, { occ: typeof occurrences[0]; count: number }>();
for (const occ of occurrences) {
const key = occ.text.toLowerCase();
const existing = seenTerms.get(key);
if (existing) { existing.count++; }
else { seenTerms.set(key, { occ, count: 1 }); }
}
// Pré-calculer les remplacements en séquence dans l'ordre d'apparition
// (garantit Ville_1 → Ville_2 dans l'ordre du texte, pas aléatoire)
const existingReplacements = rules.map((r) => r.replacement);
const usedReplacements = [...existingReplacements];
const results: DictScanResultItem[] = [];
for (const [, { occ, count }] of seenTerms) {
const hits = this.dictionaryLoader.getDetectionHits(occ.text);
if (!hits.length) continue;
const { entry, dict } = hits.find((h) => !dictIds || dictIds.includes(h.dict.dictionaryId)) ?? hits[0];
const entryClass = this.dictionaryLoader.resolveClass(entry, dict);
let proposedReplacement: string;
if (entryClass) {
proposedReplacement = this.dictionaryLoader.nextReplacement(dict, entryClass, usedReplacements);
usedReplacements.push(proposedReplacement);
} else if (entry.replacement) {
proposedReplacement = entry.replacement;
} else {
continue;
}
results.push({
term: occ.text,
dictId: dict.dictionaryId,
dictLabel: dict.label,
entryClass,
proposedReplacement,
occurrenceCount: count,
category: occ.category ?? dict.type ?? 'place',
contextBefore: occ.contextBefore,
contextAfter: occ.contextAfter,
});
}
if (results.length === 0) {
new Notice('Aucun remplacement disponible pour les entités trouvées.');
return;
}
// Surlignage bleu préventif (aperçu pendant que la modale est ouverte)
this.nerCandidateFile = file;
this.nerCandidates = results.map((r) => r.term);
void this.refreshHighlightData();
new DictScanReviewModal(this.app, this, file, results, existingReplacements).open();
}
// Efface les candidats NER pour le fichier courant (appelé après création de règle si besoin)
clearNerCandidates(): void {
this.nerCandidates = [];
@ -529,7 +627,16 @@ export default class PseudObsPlugin extends Plugin {
return;
}
new OccurrencesModal(this.app, this, file, content, occurrences, rules).open();
const countByRule = new Map<string, number>();
for (const occ of occurrences) {
const id = occ.mappingId ?? '';
countByRule.set(id, (countByRule.get(id) ?? 0) + 1);
}
const ruleResults: MappingRuleResult[] = rules
.filter((r) => countByRule.has(r.id))
.map((r) => ({ rule: r, matchCount: countByRule.get(r.id)! }));
new MappingScanReviewModal(this.app, this, file, content, ruleResults).open();
}
// Appelé par OccurrencesModal après application — met à jour les statuts des règles.

View file

@ -99,14 +99,76 @@ export interface MappingFileSettings {
export interface DictionaryEntry {
value: string;
type: EntityCategory;
replacement?: string; // remplacement fixe (mode word-to-word sans classes)
gender?: 'masculine' | 'feminine' | 'mixed';
decade?: number; // décennie de pic de popularité (ex. 1980)
socialClass?: 'populaire' | 'intermédiaire' | 'supérieur';
origin?: string; // 'fr' | 'maghreb' | 'afrique-sub' | 'anglo' | …
sizeClass?: string; // pour les villes : 'village' | 'petite-ville' | …
frequencyRank?: number;
replacementCandidates?: string[];
ambiguous?: string[]; // types alternatifs possibles (ex. ['place'] pour 'Florence')
ambiguous?: boolean; // true si le token est aussi un autre type (Florence = prénom + lieu)
[key: string]: unknown; // champs libres utilisés par les conditions (population, platform…)
}
// --- Types pour le système de dictionnaires structurés ---
export interface DictionaryRoles {
detection: boolean; // sert à identifier des entités dans le texte
replacement: boolean; // sert à proposer des candidats de substitution
classes: boolean; // utilise un système de classes avec incrémentation
}
export type ConfigFieldType = 'enum' | 'string' | 'boolean' | 'number';
export interface ConfigSchemaField {
key: string;
label: string;
type: ConfigFieldType;
values?: (string | number | boolean)[]; // valeurs possibles pour enum et boolean
default: string | number | boolean;
recommended?: string | number | boolean;
description?: string;
}
export interface DictionaryCondition {
field: string; // champ d'une entrée : "population", "platform"…
op: 'lt' | 'lte' | 'gt' | 'gte' | 'eq' | 'neq' | 'contains';
value: string | number;
class: string; // classe attribuée si la condition est vraie
}
export interface DictionaryPattern {
pattern: string; // expression régulière appliquée à entry.value
flags?: string; // ex: 'i' pour case-insensitive
class: string;
}
export interface DictionaryConfig {
classificationMode: 'word-to-word' | 'regex' | 'conditions';
conditions?: DictionaryCondition[]; // actif si classificationMode = 'conditions'
patterns?: DictionaryPattern[]; // actif si classificationMode = 'regex'
incrementScope?: ScopeType; // portée de l'index — défaut 'file'
replacementPattern?: string; // ex: '{class}_{index}' — défaut '{class}_{index}'
caseSensitive?: boolean;
stripPrefix?: string[]; // préfixes à retirer avant correspondance ex: ['@', '#']
}
// Manifeste du repo de dictionnaires
export interface DictionaryManifestEntry {
id: string;
label: string;
description: string;
type: EntityCategory;
language: string;
roles: DictionaryRoles;
size: number; // octets
url: string; // URL de téléchargement du .dict.json
recommended?: boolean;
}
export interface DictionaryManifest {
version: string;
dictionaries: DictionaryManifestEntry[];
}
// Format d'un fichier dictionnaire (SPECS §6.3)
@ -118,5 +180,10 @@ export interface DictionaryFile {
language: string;
source: string;
license?: string;
author?: string;
doi?: string | null;
roles: DictionaryRoles;
configSchema?: ConfigSchemaField[]; // décrit les variables configurables et leurs valeurs possibles
config?: DictionaryConfig; // valeurs actives (écrase les defaults du configSchema)
entries: DictionaryEntry[];
}

View file

@ -0,0 +1,255 @@
import { App, Modal, Notice, TFile } from 'obsidian';
import type PseudObsPlugin from '../main';
import { MappingStore } from '../mappings/MappingStore';
import type { EntityCategory, MappingFile } from '../types';
export interface DictScanResultItem {
term: string;
dictId: string;
dictLabel: string;
entryClass: string | null;
proposedReplacement: string;
occurrenceCount: number;
category: EntityCategory;
contextBefore?: string;
contextAfter?: string;
}
export class DictScanReviewModal extends Modal {
private plugin: PseudObsPlugin;
private file: TFile;
private results: DictScanResultItem[];
private existingReplacements: string[]; // règles déjà dans le mapping
// État mutable
private checked: boolean[];
private prefixes: string[]; // préfixe éditable (ex: "Ville")
private fixedReps: string[]; // remplacement fixe pour les entrées word-to-word
// Références DOM pour les mises à jour dynamiques
private indexSpans: (HTMLElement | null)[] = [];
private applyBtn!: HTMLButtonElement;
constructor(
app: App,
plugin: PseudObsPlugin,
file: TFile,
results: DictScanResultItem[],
existingReplacements: string[],
) {
super(app);
this.plugin = plugin;
this.file = file;
this.results = results;
this.existingReplacements = existingReplacements;
this.checked = results.map(() => true);
this.prefixes = results.map((r) => r.entryClass ?? r.proposedReplacement);
this.fixedReps = results.map((r) => r.proposedReplacement);
this.indexSpans = results.map(() => null);
}
onOpen(): void {
const { contentEl } = this;
contentEl.addClass('pseudobs-dict-review-modal');
contentEl.createEl('h2', { text: 'Révision du scan — dictionnaires' });
const dicts = [...new Set(this.results.map((r) => r.dictLabel))];
contentEl.createEl('p', {
text: `${this.results.length} entité${this.results.length > 1 ? 's' : ''} détectée${this.results.length > 1 ? 's' : ''} · ${dicts.join(', ')}`,
cls: 'pseudobs-scan-summary',
});
const scroll = contentEl.createDiv('pseudobs-dict-review-scroll');
this.results.forEach((item, i) => {
const card = scroll.createDiv('pseudobs-dict-review-card');
// En-tête : terme + catégorie + count + checkbox
const header = card.createDiv('pseudobs-dict-review-card-header');
const termWrap = header.createDiv('pseudobs-dict-review-card-term-wrap');
termWrap.createEl('span', { text: item.term, cls: 'pseudobs-dict-review-term' });
termWrap.createEl('span', {
text: item.category.replace('_', ' '),
cls: 'pseudobs-dict-review-cat',
});
const meta = header.createDiv('pseudobs-dict-review-card-meta');
meta.createEl('span', {
text: `${item.occurrenceCount}×`,
cls: 'pseudobs-dict-review-count',
});
const cb = meta.createEl('input');
cb.type = 'checkbox';
cb.checked = true;
cb.addClass('pseudobs-dict-review-cb');
// Contexte (avant ··· terme ··· après)
if (item.contextBefore !== undefined || item.contextAfter !== undefined) {
const ctx = card.createDiv('pseudobs-dict-review-ctx');
ctx.createEl('span', {
text: (item.contextBefore ?? '').slice(-50),
cls: 'pseudobs-dict-review-ctx-side',
});
ctx.createEl('mark', { text: item.term, cls: 'pseudobs-dict-review-ctx-term' });
ctx.createEl('span', {
text: (item.contextAfter ?? '').slice(0, 50),
cls: 'pseudobs-dict-review-ctx-side',
});
}
// Remplacement
const repRow = card.createDiv('pseudobs-dict-review-rep-row');
repRow.createEl('span', { text: '→', cls: 'pseudobs-dict-review-arrow' });
const repCell = repRow.createDiv('pseudobs-dict-review-rep-cell');
if (item.entryClass) {
const prefixInput = repCell.createEl('input');
prefixInput.type = 'text';
prefixInput.value = item.entryClass;
prefixInput.addClass('pseudobs-dict-review-prefix');
prefixInput.addEventListener('input', () => {
this.prefixes[i] = prefixInput.value.trim() || item.entryClass!;
this.recomputeIndices();
});
const indexSpan = repCell.createEl('span', { cls: 'pseudobs-dict-review-index' });
this.indexSpans[i] = indexSpan;
} else {
const inp = repCell.createEl('input');
inp.type = 'text';
inp.value = item.proposedReplacement;
inp.addClass('pseudobs-dict-review-prefix');
inp.addEventListener('input', () => { this.fixedReps[i] = inp.value; });
}
// Checkbox → dimmer la card + désactiver les inputs
cb.addEventListener('change', () => {
this.checked[i] = cb.checked;
card.toggleClass('pseudobs-dict-review-card-off', !cb.checked);
card.querySelectorAll('input[type="text"]').forEach((el) => {
(el as HTMLInputElement).disabled = !cb.checked;
});
this.recomputeIndices();
this.updateApplyLabel();
});
});
// Pied de page
const footer = contentEl.createDiv('pseudobs-dict-review-footer');
footer.createEl('button', { text: 'Annuler' })
.addEventListener('click', () => this.close());
this.applyBtn = footer.createEl('button', { cls: 'mod-cta' }) as HTMLButtonElement;
this.applyBtn.addEventListener('click', () => void this.apply());
this.recomputeIndices();
this.updateApplyLabel();
}
/**
* Recalcule les index de chaque item coché dans l'ordre d'apparition.
* Les items décochés n'incrémentent pas le compteur si Village_2 est
* décoché, le Village suivant devient Village_2, pas Village_3.
*/
private recomputeIndices(): void {
// Compter les remplacements déjà utilisés dans le mapping existant
const usedCounts: Record<string, number> = {};
for (const r of this.existingReplacements) {
const m = /^(.+?)_(\d+)$/.exec(r);
if (m) {
const prefix = m[1];
const idx = parseInt(m[2], 10);
usedCounts[prefix] = Math.max(usedCounts[prefix] ?? 0, idx);
}
}
// Compteur courant par préfixe (dans cette session de scan)
const sessionCounts: Record<string, number> = {};
for (let i = 0; i < this.results.length; i++) {
const span = this.indexSpans[i];
if (!span) continue; // word-to-word — pas d'index
if (!this.checked[i]) {
span.setText('');
continue;
}
const prefix = this.prefixes[i];
sessionCounts[prefix] = (sessionCounts[prefix] ?? 0) + 1;
const base = usedCounts[prefix] ?? 0;
const idx = base + sessionCounts[prefix];
span.setText(`_${idx}`);
}
}
/** Replacement final pour l'item i (préfixe + index calculé ou mot-à-mot). */
private getFinalReplacement(i: number): string {
if (this.results[i].entryClass && this.indexSpans[i]) {
const indexText = this.indexSpans[i]!.getText();
return (this.prefixes[i] || this.results[i].entryClass!) + indexText;
}
return this.fixedReps[i];
}
private updateApplyLabel(): void {
const n = this.checked.filter(Boolean).length;
this.applyBtn.textContent = n === 0
? 'Aucune règle à appliquer'
: `Créer ${n} règle${n > 1 ? 's' : ''}`;
this.applyBtn.toggleClass('pseudobs-dict-review-btn-empty', n === 0);
}
private async apply(): Promise<void> {
const toCreate = this.results.filter((_, i) => this.checked[i]);
if (toCreate.length === 0) { this.close(); return; }
this.applyBtn.setAttr('disabled', 'true');
const mappingPath = `${this.plugin.settings.mappingFolder}/${this.file.basename}.mapping.json`;
let store: MappingStore;
const mappingFile = this.app.vault.getAbstractFileByPath(mappingPath);
if (mappingFile instanceof TFile) {
const data = JSON.parse(await this.app.vault.read(mappingFile)) as MappingFile;
store = MappingStore.fromJSON(data);
} else {
await this.plugin.ensureFolder(this.plugin.settings.mappingFolder);
store = new MappingStore({ type: 'file', path: this.file.path });
}
for (let i = 0; i < this.results.length; i++) {
if (!this.checked[i]) continue;
const replacement = this.getFinalReplacement(i);
if (!replacement.trim()) continue;
const item = this.results[i];
store.add({
source: item.term,
replacement: replacement.trim(),
category: item.category,
scope: { type: 'file', path: this.file.path },
status: 'validated',
priority: 0,
createdBy: 'dictionary',
sourceDictionary: item.dictId,
});
}
const json = JSON.stringify(store.toJSON(), null, 2);
if (mappingFile instanceof TFile) {
await this.app.vault.modify(mappingFile, json);
} else {
await this.app.vault.create(mappingPath, json);
}
const n = toCreate.length;
new Notice(`${n} règle${n > 1 ? 's' : ''} créée${n > 1 ? 's' : ''}`);
void this.plugin.refreshHighlightData();
this.close();
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,156 @@
import { App, Modal, Notice, TFile } from 'obsidian';
import type PseudObsPlugin from '../main';
import type { MappingRule } from '../types';
import { findSpansForRule } from '../pseudonymizer/ReplacementPlanner';
import { resolveSpans, applySpans } from '../pseudonymizer/SpanProtector';
export interface MappingRuleResult {
rule: MappingRule;
matchCount: number;
}
export class MappingScanReviewModal extends Modal {
private plugin: PseudObsPlugin;
private file: TFile;
private content: string;
private ruleResults: MappingRuleResult[];
private checked: boolean[];
private applyBtn!: HTMLButtonElement;
constructor(
app: App,
plugin: PseudObsPlugin,
file: TFile,
content: string,
ruleResults: MappingRuleResult[],
) {
super(app);
this.plugin = plugin;
this.file = file;
this.content = content;
this.ruleResults = ruleResults;
this.checked = ruleResults.map(() => true);
}
onOpen(): void {
const { contentEl } = this;
contentEl.addClass('pseudobs-dict-review-modal');
contentEl.createEl('h2', { text: 'Révision du scan — mappings' });
contentEl.createEl('p', {
text: `${this.ruleResults.length} règle${this.ruleResults.length > 1 ? 's' : ''} active${this.ruleResults.length > 1 ? 's' : ''} avec des occurrences dans "${this.file.name}"`,
cls: 'pseudobs-scan-summary',
});
contentEl.createEl('p', {
text: 'Décochez les règles à ne pas appliquer. Les remplacements seront écrits dans le fichier source.',
cls: 'pseudobs-view-hint',
});
const scroll = contentEl.createDiv('pseudobs-dict-review-scroll');
const table = scroll.createEl('table', { cls: 'pseudobs-dict-review-table' });
const thead = table.createEl('thead');
const hr = thead.createEl('tr');
['', 'Source', '', 'Remplacement', 'Occ.'].forEach((h) =>
hr.createEl('th', { text: h })
);
const tbody = table.createEl('tbody');
this.ruleResults.forEach(({ rule, matchCount }, i) => {
const tr = tbody.createEl('tr', { cls: 'pseudobs-dict-review-row' });
// Checkbox
const cb = tr.createEl('td').createEl('input');
cb.type = 'checkbox';
cb.checked = true;
cb.addClass('pseudobs-dict-review-cb');
cb.addEventListener('change', () => {
this.checked[i] = cb.checked;
tr.toggleClass('pseudobs-dict-review-row-off', !cb.checked);
this.updateApplyLabel();
});
// Source
tr.createEl('td', { text: rule.source, cls: 'pseudobs-dict-review-term' });
// Flèche
tr.createEl('td', { text: '→', cls: 'pseudobs-dict-review-arrow' });
// Remplacement (lecture seule — déjà défini dans la règle)
const repCell = tr.createEl('td');
const s = this.plugin.settings;
const displayRep = s.useMarkerInExport
? `${s.markerOpen}${rule.replacement}${s.markerClose}`
: rule.replacement;
repCell.createEl('span', { text: displayRep, cls: 'pseudobs-dict-review-rep-static' });
// Occurrences
tr.createEl('td', { text: String(matchCount), cls: 'pseudobs-dict-review-count' });
});
const footer = contentEl.createDiv('pseudobs-dict-review-footer');
footer.createEl('button', { text: 'Annuler' })
.addEventListener('click', () => this.close());
this.applyBtn = footer.createEl('button', { cls: 'mod-cta' }) as HTMLButtonElement;
this.applyBtn.addEventListener('click', () => void this.apply());
this.updateApplyLabel();
}
private updateApplyLabel(): void {
const n = this.checked.filter(Boolean).length;
const total = this.ruleResults
.filter((_, i) => this.checked[i])
.reduce((sum, r) => sum + r.matchCount, 0);
this.applyBtn.textContent = n === 0
? 'Aucune règle à appliquer'
: `Pseudonymiser (${n} règle${n > 1 ? 's' : ''}, ${total} occurrence${total > 1 ? 's' : ''})`;
this.applyBtn.toggleClass('pseudobs-dict-review-btn-empty', n === 0);
}
private async apply(): Promise<void> {
const checkedRules = this.ruleResults
.filter((_, i) => this.checked[i])
.map((r) => r.rule);
if (checkedRules.length === 0) { this.close(); return; }
this.applyBtn.setAttr('disabled', 'true');
const s = this.plugin.settings;
const marker = s.useMarkerInExport
? { open: s.markerOpen, close: s.markerClose }
: undefined;
// Collecter tous les spans (toutes règles cochées), résoudre les chevauchements,
// appliquer de droite à gauche comme le moteur principal
const allSpans = checkedRules.flatMap((rule) =>
findSpansForRule(this.content, rule, {
caseSensitive: s.caseSensitive,
wholeWordOnly: s.wholeWordOnly,
}).map((span) =>
marker
? { ...span, replacement: `${marker.open}${span.replacement}${marker.close}` }
: span
)
);
const resolved = resolveSpans(allSpans);
if (resolved.length === 0) {
new Notice('Aucune occurrence à remplacer.');
this.close();
return;
}
const modified = applySpans(this.content, resolved);
await this.app.vault.modify(this.file, modified);
void this.plugin.refreshHighlightData();
const total = resolved.length;
new Notice(`${total} occurrence${total > 1 ? 's' : ''} pseudonymisée${total > 1 ? 's' : ''} dans "${this.file.name}"`);
this.close();
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -1,13 +1,14 @@
import { App, FileSystemAdapter, Modal, Notice, TFile, requestUrl } from 'obsidian';
import { App, FileSystemAdapter, Modal, Notice, TFile, requestUrl, setIcon } 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';
import type { DictionaryFile, DictionaryManifest, DictionaryManifestEntry } 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 DICT_MANIFEST_URL = 'https://raw.githubusercontent.com/core-hn/pseudobsidian-dictionaries/main/index.json';
const WASM_FILES = [
'ort-wasm-simd-threaded.wasm',
'ort-wasm-simd.wasm',
@ -228,21 +229,28 @@ export class OnboardingModal extends Modal {
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.',
text: 'Les dictionnaires proposent des candidats de remplacement (villes, prénoms…) et alimentent la détection. Ils sont hébergés dans un dépôt dédié et téléchargés dans votre vault — aucune donnée ne quitte Obsidian.',
});
el.createEl('p', {
text: 'Si vous n\'avez pas encore de fichier .dict.json, vous pouvez passer cette étape.',
// Zone catalogue (remplie de façon asynchrone)
const catalogueEl = el.createDiv('pseudobs-onboarding-catalogue');
catalogueEl.createEl('p', { text: 'Chargement du catalogue…', cls: 'pseudobs-onboarding-hint' });
void this.renderCatalogue(catalogueEl);
// Séparateur
el.createEl('hr');
// Import manuel (fallback offline)
const manualRow = el.createDiv('pseudobs-onboarding-import-row');
manualRow.createEl('small', {
text: 'Vous avez déjà un fichier .dict.json ? Importez-le manuellement :',
cls: 'pseudobs-onboarding-hint',
});
// Bouton import
const importRow = el.createDiv('pseudobs-onboarding-import-row');
const importBtn = importRow.createEl('button', {
text: 'Importer un dictionnaire (.dict.json)',
const importBtn = manualRow.createEl('button', {
text: 'Importer un fichier local (.dict.json)',
cls: 'pseudobs-onboarding-import-btn',
});
const importStatus = importRow.createSpan({ cls: 'pseudobs-onboarding-test-status' });
const importStatus = manualRow.createSpan({ cls: 'pseudobs-onboarding-test-status' });
importBtn.addEventListener('click', () => {
const input = activeDocument.createElement('input');
input.type = 'file';
@ -255,7 +263,126 @@ export class OnboardingModal extends Modal {
});
// Liste des dictionnaires déjà présents dans le vault
void this.renderDictList(el);
void this.renderInstalledDictList(el);
}
private async renderCatalogue(container: HTMLElement): Promise<void> {
let manifest: DictionaryManifest;
try {
const res = await requestUrl({ url: DICT_MANIFEST_URL, method: 'GET' });
manifest = res.json as DictionaryManifest;
} catch {
container.empty();
container.createEl('p', {
text: 'Impossible de contacter le catalogue en ligne. Vérifiez votre connexion ou importez un fichier local ci-dessous.',
cls: 'pseudobs-onboarding-hint',
});
return;
}
container.empty();
const scroll = container.createDiv('pseudobs-onboarding-catalogue-scroll');
const table = scroll.createEl('table', { cls: 'pseudobs-onboarding-dict-table' });
const thead = table.createEl('thead');
const headerRow = thead.createEl('tr');
['Dictionnaire', 'Langue', 'Rôles', 'Taille', ''].forEach((h) =>
headerRow.createEl('th', { text: h })
);
const tbody = table.createEl('tbody');
for (const entry of manifest.dictionaries) {
this.renderCatalogueRow(tbody, entry);
}
}
private renderCatalogueRow(tbody: HTMLElement, entry: DictionaryManifestEntry): void {
const alreadyInstalled = this.isDictInstalled(entry.id);
const tr = tbody.createEl('tr');
if (alreadyInstalled) tr.addClass('pseudobs-onboarding-dict-row-installed');
// Nom + badge
const nameCell = tr.createEl('td', { cls: 'pseudobs-onboarding-dict-name-cell' });
nameCell.createEl('span', { text: entry.label });
if (entry.recommended) {
nameCell.createEl('span', { text: 'Recommandé', cls: 'pseudobs-onboarding-badge' });
}
// Langue
tr.createEl('td', { text: entry.language.toUpperCase() });
// Rôles
const roles: string[] = [];
if (entry.roles.detection) roles.push('détection');
if (entry.roles.replacement) roles.push('remplacement');
if (entry.roles.classes) roles.push('classes');
tr.createEl('td', { text: roles.join(' · '), cls: 'pseudobs-onboarding-dict-roles' });
// Taille
tr.createEl('td', { text: this.formatSize(entry.size), cls: 'pseudobs-onboarding-dict-size' });
// Bouton icône uniquement
const actionCell = tr.createEl('td', { cls: 'pseudobs-onboarding-dict-action' });
const btn = actionCell.createEl('button', { cls: 'pseudobs-onboarding-icon-btn' });
btn.setAttribute('aria-label', alreadyInstalled ? 'Réinstaller' : 'Installer');
setIcon(btn, alreadyInstalled ? 'cloud-check' : 'cloud-download');
if (alreadyInstalled) btn.addClass('pseudobs-onboarding-icon-btn-done');
btn.addEventListener('click', () => { void (async () => {
btn.setAttr('disabled', 'true');
btn.removeClass('pseudobs-onboarding-icon-btn-done');
setIcon(btn, 'refresh-cw');
btn.addClass('pseudobs-onboarding-icon-btn-loading');
const ok = await this.downloadDict(entry);
btn.removeAttribute('disabled');
btn.removeClass('pseudobs-onboarding-icon-btn-loading');
if (ok) {
setIcon(btn, 'cloud-check');
btn.addClass('pseudobs-onboarding-icon-btn-done');
tr.addClass('pseudobs-onboarding-dict-row-installed');
void this.plugin.dictionaryLoader.load();
} else {
setIcon(btn, 'cloud-download');
}
})(); });
}
private isDictInstalled(id: string): boolean {
const folder = this.plugin.settings.dictionariesFolder;
const path = `${folder}/${id}.dict.json`;
return this.app.vault.getAbstractFileByPath(path) instanceof TFile;
}
private async downloadDict(entry: DictionaryManifestEntry): Promise<boolean> {
try {
const res = await requestUrl({ url: entry.url, method: 'GET' });
const text = res.text;
const parsed = JSON.parse(text) as DictionaryFile;
if (!Array.isArray(parsed.entries)) throw new Error('Format invalide');
await this.plugin.ensureFolder(this.plugin.settings.dictionariesFolder);
const dest = `${this.plugin.settings.dictionariesFolder}/${entry.id}.dict.json`;
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(`${entry.id}.dict.json`);
return true;
} catch (e) {
new Notice(`Échec du téléchargement de ${entry.label} : ${(e as Error).message}`);
return false;
}
}
private formatSize(bytes: number): string {
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} Mo`;
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} Ko`;
return `${bytes} o`;
}
private async processDictFiles(input: HTMLInputElement, statusEl: HTMLElement): Promise<void> {
@ -291,17 +418,17 @@ export class OnboardingModal extends Modal {
if (ok > 0) {
statusEl.setText(`${ok} dictionnaire${ok > 1 ? 's' : ''} importé${ok > 1 ? 's' : ''}`);
statusEl.addClass('pseudobs-onboarding-test-ok');
void this.plugin.dictionaryLoader.load();
}
if (err > 0) {
statusEl.setText(`${err} fichier${err > 1 ? 's' : ''} rejeté${err > 1 ? 's' : ''}`);
statusEl.addClass('pseudobs-onboarding-test-err');
}
// Rafraîchir la liste
this.scheduleRender();
}
private async renderDictList(el: HTMLElement): Promise<void> {
private async renderInstalledDictList(el: HTMLElement): Promise<void> {
const folder = this.app.vault.getAbstractFileByPath(this.plugin.settings.dictionariesFolder);
const files: TFile[] = [];
@ -313,13 +440,10 @@ export class OnboardingModal extends Modal {
}
}
if (files.length === 0) {
el.createEl('p', { text: 'Aucun dictionnaire importé.', cls: 'pseudobs-onboarding-hint' });
return;
}
if (files.length === 0) return;
el.createEl('p', {
text: `${files.length} dictionnaire${files.length > 1 ? 's' : ''} dans le vault :`,
text: `Dictionnaires installés dans ce vault (${files.length}) :`,
cls: 'pseudobs-onboarding-dict-count',
});
@ -331,6 +455,7 @@ export class OnboardingModal extends Modal {
removeBtn.title = 'Retirer ce dictionnaire du vault';
removeBtn.addEventListener('click', () => { void (async () => {
await this.app.fileManager.trashFile(f);
void this.plugin.dictionaryLoader.load();
this.scheduleRender();
})(); });
}

View file

@ -1,25 +1,16 @@
import { ItemView, Notice, Setting, TFile, WorkspaceLeaf } from 'obsidian';
import { ItemView, Notice, Setting, TFile, WorkspaceLeaf, setIcon } from 'obsidian';
import type { DictionaryFile } from '../types';
import type PseudObsPlugin from '../main';
import type { MappingRule, Occurrence } from '../types';
import type { MappingRule } 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 { MappingScanReviewModal } from './MappingScanReviewModal';
import type { MappingRuleResult } from './MappingScanReviewModal';
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;
}
type Tab = 'mappings' | 'dictionaries' | 'exports' | 'ner';
const CATEGORY_LABELS: Record<string, string> = {
first_name: 'Prénom', last_name: 'Nom', full_name: 'Nom complet',
@ -38,24 +29,17 @@ const STATUS_LABELS: Record<string, string> = {
export class PseudonymizationView extends ItemView {
private plugin: PseudObsPlugin;
private activeTab: Tab = 'occurrences';
private activeTab: Tab = 'mappings';
private panes!: Record<Tab, HTMLElement>;
private tabBtns!: Record<Tab, HTMLElement>;
// Dernier fichier markdown connu (survit au focus du panneau)
private lastFile: TFile | null = null;
// IDs des dictionnaires cochés pour le scan groupé (tous cochés par défaut)
private checkedDicts = new Set<string>();
// 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;
@ -74,7 +58,6 @@ export class PseudonymizationView extends ItemView {
const content = root.createDiv('pseudobs-view-content');
const tabs: [Tab, string][] = [
['occurrences', 'Candidats'],
['mappings', 'Mappings'],
['dictionaries', 'Dictionnaires'],
['exports', 'Exports'],
@ -102,7 +85,7 @@ export class PseudonymizationView extends ItemView {
const f = this.app.workspace.getActiveFile();
if (f) this.lastFile = f;
await this.switchTab('occurrences');
await this.switchTab('mappings');
}
private async switchTab(tab: Tab): Promise<void> {
@ -123,8 +106,7 @@ export class PseudonymizationView extends ItemView {
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);
if (tab === 'mappings') await this.renderMappingsTab(pane);
else if (tab === 'dictionaries') await this.renderDictionariesTab(pane);
else if (tab === 'ner') await this.renderNerTab(pane);
else this.renderExportsTab(pane);
@ -137,15 +119,7 @@ export class PseudonymizationView extends ItemView {
if (this.app.workspace.getActiveViewOfType(ItemView) === 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;
}
if (f) this.lastFile = f;
this._renderingTab = true;
try {
@ -159,265 +133,58 @@ export class PseudonymizationView extends ItemView {
return this.app.workspace.getActiveFile() ?? this.lastFile;
}
// ---- Onglet Occurrences ----------------------------------------
// ---- Onglet Mappings -------------------------------------------
private async renderOccurrencesTab(el: HTMLElement): Promise<void> {
private async renderMappingsTab(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',
});
}
// Bouton ajouter règle
const addRuleBtn = toolbar.createEl('button', { cls: 'pseudobs-view-action-btn' });
setIcon(addRuleBtn, 'list-plus');
addRuleBtn.createSpan({ text: 'Ajouter une règle' });
addRuleBtn.addEventListener('click', () => new RuleModal(this.app, this.plugin).open());
// Bouton scan — ouvre MappingScanReviewModal
const scanBtn = toolbar.createEl('button', { cls: 'pseudobs-view-action-btn' });
setIcon(scanBtn, 'scan-search');
scanBtn.createSpan({ text: 'Scanner le fichier' });
if (!file) scanBtn.setAttr('disabled', 'true');
scanBtn.addEventListener('click', () => { void (async () => {
if (!file) return;
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);
new Notice('Aucune règle active pour ce fichier. Créez des règles via le menu contextuel.');
return;
}
const occs = scanOccurrences(content, file.path, rules, {
caseSensitive: this.plugin.settings.caseSensitive,
wholeWordOnly: this.plugin.settings.wholeWordOnly,
});
// Grouper par règle
const countByRule = new Map<string, number>();
for (const occ of occs) {
const id = occ.mappingId ?? '';
countByRule.set(id, (countByRule.get(id) ?? 0) + 1);
}
const ruleResults: MappingRuleResult[] = rules
.filter((r) => countByRule.has(r.id))
.map((r) => ({ rule: r, matchCount: countByRule.get(r.id)! }));
if (ruleResults.length === 0) {
new Notice('Aucune occurrence trouvée pour les règles actives.');
return;
}
new MappingScanReviewModal(this.app, this.plugin, file, content, ruleResults).open();
} 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;
@ -447,10 +214,8 @@ export class PseudonymizationView extends ItemView {
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',
});
const editBtn = row.createEl('td').createEl('button', { cls: 'pseudobs-mappings-edit-btn' });
setIcon(editBtn, "pencil");
editBtn.title = 'Modifier';
editBtn.addEventListener('click', () => new EditRuleModal(this.app, this.plugin, loc).open());
}
@ -459,13 +224,84 @@ export class PseudonymizationView extends ItemView {
// ---- Onglet Dictionnaires --------------------------------------
private async renderDictionariesTab(el: HTMLElement): Promise<void> {
const toolbar = el.createDiv('pseudobs-view-toolbar');
const importBtn = toolbar.createEl('button', {
text: 'Importer un dictionnaire (.dict.json)',
const dicts = this.plugin.dictionaryLoader.getAll();
// Initialiser checkedDicts avec tous les IDs au premier rendu
if (this.checkedDicts.size === 0 && dicts.length > 0) {
dicts.forEach((d) => this.checkedDicts.add(d.dictionaryId));
}
if (dicts.length === 0) {
el.createEl('p', {
text: 'Aucun dictionnaire installé. Installez-en un depuis le wizard (Paramètres → Reconfigurer) ou importez un fichier local.',
cls: 'pseudobs-view-hint',
});
} else {
// Mini cards
for (const dict of dicts) {
const card = el.createDiv('pseudobs-dict-card');
// Checkbox scan groupé
const checkbox = card.createEl('input');
checkbox.type = 'checkbox';
checkbox.checked = this.checkedDicts.has(dict.dictionaryId);
checkbox.addClass('pseudobs-dict-card-checkbox');
checkbox.title = 'Inclure dans le scan groupé';
checkbox.addEventListener('change', () => {
if (checkbox.checked) this.checkedDicts.add(dict.dictionaryId);
else this.checkedDicts.delete(dict.dictionaryId);
});
// Infos
const info = card.createDiv('pseudobs-dict-card-info');
info.createEl('strong', { text: dict.label, cls: 'pseudobs-dict-card-title' });
info.createEl('small', { text: `${dict.dictionaryId}.dict.json`, cls: 'pseudobs-dict-card-filename' });
// Bouton scan individuel
if (dict.roles?.detection) {
const scanBtn = card.createEl('button', { cls: 'pseudobs-dict-card-scan mod-cta' });
setIcon(scanBtn, 'scan-search');
scanBtn.setAttribute('aria-label', `Scanner avec ${dict.label}`);
scanBtn.title = `Scanner le fichier actif avec "${dict.label}"`;
scanBtn.addEventListener('click', () => {
void this.plugin.scanCurrentFileWithDictionaries([dict.dictionaryId]);
});
}
// Bouton suppression
const removeBtn = card.createEl('button', { cls: 'pseudobs-dict-card-remove' });
setIcon(removeBtn, 'trash-2');
removeBtn.setAttribute('aria-label', 'Supprimer ce dictionnaire');
removeBtn.title = 'Supprimer ce dictionnaire du vault';
removeBtn.addEventListener('click', () => { void (async () => {
const f = this.app.vault.getAbstractFileByPath(
`${this.plugin.settings.dictionariesFolder}/${dict.dictionaryId}.dict.json`
);
if (f instanceof TFile) await this.app.fileManager.trashFile(f);
this.checkedDicts.delete(dict.dictionaryId);
await this.plugin.dictionaryLoader.load();
await this.renderTab('dictionaries');
})(); });
}
// Bouton scan groupé
el.createEl('hr');
const groupScanBtn = el.createEl('button', { cls: 'pseudobs-dict-group-scan mod-cta' });
setIcon(groupScanBtn, 'scan-search');
groupScanBtn.createSpan({ text: 'Scanner les dictionnaires cochés' });
groupScanBtn.addEventListener('click', () => {
const ids = [...this.checkedDicts];
if (ids.length === 0) { new Notice('Aucun dictionnaire coché.'); return; }
void this.plugin.scanCurrentFileWithDictionaries(ids);
});
}
// Import manuel
el.createEl('hr');
const importBtn = el.createEl('button', {
text: 'Importer un fichier local (.dict.json)',
cls: 'pseudobs-view-add-btn',
});
const statusEl = toolbar.createSpan({ cls: 'pseudobs-view-dict-status' });
importBtn.addEventListener('click', () => {
const input = activeDocument.createElement('input');
input.type = 'file';
@ -477,7 +313,6 @@ export class PseudonymizationView extends ItemView {
const files = Array.from(input.files ?? []);
input.remove();
if (files.length === 0) return;
await this.plugin.ensureFolder(this.plugin.settings.dictionariesFolder);
let ok = 0;
for (const f of files) {
@ -493,56 +328,16 @@ export class PseudonymizationView extends ItemView {
await this.app.vault.create(dest, text);
}
ok++;
} catch {
new Notice(`Format invalide : ${f.name}`);
}
} catch { new Notice(`Format invalide : ${f.name}`); }
}
if (ok > 0) {
new Notice(`${ok} dictionnaire${ok > 1 ? 's' : ''} importé${ok > 1 ? 's' : ''}`);
await this.plugin.dictionaryLoader.load();
}
if (ok > 0) new Notice(`${ok} dictionnaire${ok > 1 ? 's' : ''} importé${ok > 1 ? 's' : ''}`);
await this.renderTab('dictionaries');
})(); });
input.click();
});
// Liste des dictionnaires présents dans le vault
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-view-hint' });
return;
}
el.createEl('p', {
text: `${files.length} dictionnaire${files.length > 1 ? 's' : ''} chargé${files.length > 1 ? 's' : ''} :`,
cls: 'pseudobs-view-count',
});
const list = el.createEl('ul', { cls: 'pseudobs-dict-list' });
for (const f of files) {
let entryCount = '?';
try {
const raw = await this.app.vault.read(f);
const parsed = JSON.parse(raw) as DictionaryFile;
entryCount = String(parsed.entries?.length ?? 0);
} catch { /* fichier malformé */ }
const li = list.createEl('li', { cls: 'pseudobs-dict-item' });
li.createSpan({ text: f.basename, cls: 'pseudobs-dict-name' });
li.createSpan({ text: `${entryCount} entrées`, cls: 'pseudobs-dict-count' });
const removeBtn = li.createEl('button', { text: '✕', cls: 'pseudobs-dict-remove' });
removeBtn.title = 'Supprimer ce dictionnaire';
removeBtn.addEventListener('click', () => { void (async () => {
await this.app.fileManager.trashFile(f);
await this.renderTab('dictionaries');
})(); });
}
void statusEl;
}
// ---- Onglet Exports --------------------------------------------
@ -581,6 +376,15 @@ export class PseudonymizationView extends ItemView {
private async renderNerTab(el: HTMLElement): Promise<void> {
const s = this.plugin.settings;
// Bouton scan NER
const nerScanBtn = el.createEl('button', { cls: 'pseudobs-view-action-btn mod-cta' });
setIcon(nerScanBtn, 'scan-search');
nerScanBtn.createSpan({ text: 'Identifier des candidats' });
nerScanBtn.title = 'Détecter les entités nommées dans le fichier actif et les surligner en bleu';
nerScanBtn.addEventListener('click', () => void this.plugin.scanCurrentFileNer());
el.createEl('hr');
el.createEl('p', {
text: 'Paramètres du scanner de détection automatique des entités nommées.',
cls: 'pseudobs-view-hint',
@ -644,10 +448,9 @@ export class PseudonymizationView extends ItemView {
window.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',
});
const resetBtn = fwSection.createEl('button', { cls: 'pseudobs-view-action-btn' });
setIcon(resetBtn, 'rotate-ccw');
resetBtn.createSpan({ text: 'Réinitialiser par défaut' });
resetBtn.addClass('pseudobs-ner-reset-btn');
resetBtn.addEventListener('click', () => { void (async () => {
const { DEFAULT_SETTINGS } = await import('../settings');

View file

@ -1,7 +1,7 @@
import { App, Modal, Setting, TFile, Notice } from 'obsidian';
import type PseudObsPlugin from '../main';
import { MappingStore } from '../mappings/MappingStore';
import type { EntityCategory, MappingFile, ScopeType } from '../types';
import type { EntityCategory, MappingFile, MappingRule, ScopeType } from '../types';
export class RuleModal extends Modal {
private plugin: PseudObsPlugin;
@ -10,22 +10,62 @@ export class RuleModal extends Modal {
private category: EntityCategory;
private scopeType: ScopeType = 'file';
private priority = 0;
private suggestions: string[];
// Suggestions Coulmont (prénoms équivalents)
private coulomontSuggestions: string[];
// Suggestions dictionnaire
private useClass = false;
private dictEntryClass: string | null = null;
private dictId: string | null = null;
constructor(
app: App,
plugin: PseudObsPlugin,
prefillSource = '',
prefillReplacement = '',
suggestions: string[] = []
coulomontSuggestions: string[] = [],
) {
super(app);
this.plugin = plugin;
this.source = prefillSource;
this.replacement = prefillReplacement;
this.suggestions = suggestions;
// Coulmont ne fournit que des prénoms
this.category = suggestions.length > 0 ? 'first_name' : 'custom';
this.coulomontSuggestions = coulomontSuggestions;
this.category = coulomontSuggestions.length > 0 ? 'first_name' : 'custom';
// Suggestions dictionnaire uniquement si pas de suggestions Coulmont
// (Coulmont = prénoms uniquement, pas de lieux)
if (prefillSource && coulomontSuggestions.length === 0) {
this.resolveDictSuggestion(prefillSource);
}
}
private resolveDictSuggestion(value: string): void {
const loader = this.plugin.dictionaryLoader;
if (!loader) return;
const hits = loader.getDetectionHits(value);
for (const { entry, dict } of hits) {
if (!dict.roles?.replacement) continue;
const cls = loader.resolveClass(entry, dict);
if (cls) {
this.dictEntryClass = cls;
this.dictId = dict.dictionaryId;
// Pré-régler la catégorie si l'entrée est un lieu
if (this.category === 'custom' && dict.type === 'place') {
this.category = 'place';
}
return;
}
// word-to-word avec remplacement fixe
if (entry.replacement) {
this.replacement = entry.replacement;
this.dictId = dict.dictionaryId;
if (this.category === 'custom' && dict.type === 'place') {
this.category = 'place';
}
return;
}
}
}
onOpen(): void {
@ -41,9 +81,9 @@ export class RuleModal extends Modal {
})
);
// Suggestions Coulmont : boutons cliquables (présents uniquement si fournis)
// --- Suggestions Coulmont ---
let replacementInput: HTMLInputElement | undefined;
if (this.suggestions.length > 0) {
if (this.coulomontSuggestions.length > 0) {
const box = contentEl.createDiv();
box.addClass('pseudobs-suggestions-box');
box.createEl('small', { text: 'Suggestions de prénoms équivalents — choisissez :' })
@ -51,11 +91,12 @@ export class RuleModal extends Modal {
const tags = box.createDiv();
tags.addClass('pseudobs-suggestions-tags');
const btnEls: HTMLElement[] = [];
for (const name of this.suggestions) {
for (const name of this.coulomontSuggestions) {
const btn = tags.createEl('button', { text: name });
btn.addClass('pseudobs-suggestion-btn');
btn.addEventListener('click', () => {
this.replacement = name;
this.useClass = false;
if (replacementInput) replacementInput.value = name;
btnEls.forEach((b) => b.removeClass('pseudobs-suggestion-btn-selected'));
btn.addClass('pseudobs-suggestion-btn-selected');
@ -64,15 +105,59 @@ export class RuleModal extends Modal {
}
}
// --- Suggestion dictionnaire (classes) ---
if (this.dictEntryClass && this.dictId) {
const dict = this.plugin.dictionaryLoader?.getById(this.dictId);
const pattern = dict?.config?.replacementPattern ?? '{class}_{index}';
const preview = pattern.replace('{class}', this.dictEntryClass).replace('{index}', 'N');
const scope = dict?.config?.incrementScope ?? 'file';
const box = contentEl.createDiv();
box.addClass('pseudobs-suggestions-box');
const label = box.createEl('small');
label.addClass('pseudobs-suggestions-label');
label.setText(`Dictionnaire : "${this.source}" → classe ${this.dictEntryClass}`);
const row = box.createDiv();
row.addClass('pseudobs-suggestions-tags');
const classBtn = row.createEl('button', { text: `Utiliser "${preview}" (portée : ${scope})` });
classBtn.addClass('pseudobs-suggestion-btn');
classBtn.addEventListener('click', () => {
this.useClass = true;
if (replacementInput) replacementInput.value = preview;
classBtn.addClass('pseudobs-suggestion-btn-selected');
});
// Pré-sélectionner automatiquement si aucune suggestion Coulmont
if (this.coulomontSuggestions.length === 0) {
this.useClass = true;
if (replacementInput) replacementInput.value = preview;
classBtn.addClass('pseudobs-suggestion-btn-selected');
}
}
new Setting(contentEl)
.setName('Remplacement')
.setDesc('Pseudonyme ou catégorie analytique')
.setDesc(
this.dictEntryClass
? 'L\'index exact sera calculé à la création selon les règles existantes dans la portée.'
: 'Pseudonyme ou catégorie analytique'
)
.addText((t) => {
t.setValue(this.replacement).onChange((v) => { this.replacement = v; });
const preview = this.dictEntryClass
? (this.plugin.dictionaryLoader?.getById(this.dictId ?? '')?.config?.replacementPattern ?? '{class}_{index}')
.replace('{class}', this.dictEntryClass)
.replace('{index}', 'N')
: this.replacement;
t.setValue(preview).onChange((v) => {
this.replacement = v;
this.useClass = false; // saisie manuelle désactive le mode classe
});
replacementInput = t.inputEl;
});
// Catégorie — masquée et fixée à "Prénom" si suggestions Coulmont
// Catégorie — masquée si Coulmont impose 'first_name'
new Setting(contentEl)
.setName('Catégorie')
.addDropdown((d) => {
@ -92,8 +177,7 @@ export class RuleModal extends Modal {
}
d.setValue(this.category);
d.onChange((v) => { this.category = v as EntityCategory; });
// Masquer le dropdown si la catégorie est imposée par Coulmont
if (this.suggestions.length > 0) {
if (this.coulomontSuggestions.length > 0) {
const settingItem = d.selectEl.closest('.setting-item');
if (settingItem instanceof HTMLElement) settingItem.hide();
}
@ -106,31 +190,24 @@ export class RuleModal extends Modal {
d.addOption('folder', 'Ce dossier');
d.addOption('vault', 'Tout le vault');
d.setValue('file');
d.onChange((v) => {
this.scopeType = v as ScopeType;
});
d.onChange((v) => { this.scopeType = v as ScopeType; });
});
new Setting(contentEl)
.setName('Priorité')
.setDesc('Entier libre, comme un z-index CSS — défaut 0, plus grand = appliqué en premier')
.addText((t) =>
t.setValue('0').onChange((v) => {
this.priority = parseInt(v, 10) || 0;
})
t.setValue('0').onChange((v) => { this.priority = parseInt(v, 10) || 0; })
);
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText('Créer la règle')
.setCta()
.onClick(() => this.createRule())
btn.setButtonText('Créer la règle').setCta().onClick(() => void this.createRule())
);
}
private async createRule(): Promise<void> {
if (!this.source.trim() || !this.replacement.trim()) {
new Notice('La source et le remplacement sont obligatoires.');
if (!this.source.trim()) {
new Notice('La source est obligatoire.');
return;
}
@ -140,6 +217,23 @@ export class RuleModal extends Modal {
return;
}
// Résoudre le remplacement si mode classe activé
if (this.useClass && this.dictEntryClass && this.dictId) {
const dict = this.plugin.dictionaryLoader?.getById(this.dictId);
if (dict) {
const incrementScope = dict.config?.incrementScope ?? 'file';
const existingReplacements = await this.collectExistingReplacements(activeFile, incrementScope);
this.replacement = this.plugin.dictionaryLoader!.nextReplacement(
dict, this.dictEntryClass, existingReplacements
);
}
}
if (!this.replacement.trim()) {
new Notice('Le remplacement est obligatoire.');
return;
}
const mappingPath = `${this.plugin.settings.mappingFolder}/${activeFile.basename}.mapping.json`;
let store: MappingStore;
@ -166,7 +260,8 @@ export class RuleModal extends Modal {
scope: { type: this.scopeType, path: scopePath },
status: 'validated',
priority: this.priority,
createdBy: 'user',
createdBy: 'dictionary',
sourceDictionary: this.dictId ?? undefined,
});
const json = JSON.stringify(store.toJSON(), null, 2);
@ -177,11 +272,46 @@ export class RuleModal extends Modal {
}
new Notice(`✓ Règle créée : "${this.source.trim()}" → "${this.replacement.trim()}"`);
// Rafraîchir le surlignage immédiatement sans attendre un changement de fichier
void this.plugin.refreshHighlightData();
this.close();
}
/**
* Collecte les remplacements déjà utilisés dans la portée d'incrémentation du dictionnaire.
* Utilisé pour calculer le prochain index de classe ({class}_{N}).
*/
private async collectExistingReplacements(
activeFile: TFile,
incrementScope: ScopeType,
): Promise<string[]> {
const mappingFolder = this.plugin.settings.mappingFolder;
let mappingPaths: string[] = [];
if (incrementScope === 'file') {
mappingPaths = [`${mappingFolder}/${activeFile.basename}.mapping.json`];
} else if (incrementScope === 'folder') {
const folderName = activeFile.parent?.name ?? 'folder';
mappingPaths = [`${mappingFolder}/${folderName}.mapping.json`];
} else {
mappingPaths = [`${mappingFolder}/vault.mapping.json`];
}
const replacements: string[] = [];
for (const p of mappingPaths) {
const f = this.app.vault.getAbstractFileByPath(p);
if (!(f instanceof TFile)) continue;
try {
const data = JSON.parse(await this.app.vault.read(f)) as MappingFile;
for (const rule of data.mappings as MappingRule[]) {
if (rule.sourceDictionary === this.dictId && rule.replacement) {
replacements.push(rule.replacement);
}
}
} catch { /* mapping illisible — ignoré */ }
}
return replacements;
}
onClose(): void {
this.contentEl.empty();
}

View file

@ -216,13 +216,26 @@
.pseudobs-view-toolbar { margin-bottom: 10px; }
.pseudobs-view-add-btn {
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
cursor: pointer;
font-size: 0.85em;
}
.pseudobs-view-add-btn:hover { opacity: 0.85; }
.pseudobs-view-add-btn svg { width: 15px; height: 15px; }
.pseudobs-view-action-btn {
margin-left: 10px;
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: 0.85em;
gap: 6px;
}
.pseudobs-view-action-btn:hover { background: var(--background-secondary-alt); }
@ -577,25 +590,369 @@
/* 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; }
/* Le bouton scan groupé a déjà une icône SVG — pas de ::before texte */
/* Onglet Dictionnaires — panneau latéral */
.pseudobs-dict-list { list-style: none; padding: 0; margin: 6px 0; }
.pseudobs-dict-item {
/* ---- Onglet Dictionnaires — panneau latéral (mini cards) ---- */
.pseudobs-dict-card {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px;
padding: 7px 8px;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
margin-bottom: 6px;
transition: border-color 0.15s, background 0.15s;
}
.pseudobs-dict-card:hover {
border-color: var(--background-modifier-border-hover);
background: var(--background-secondary);
}
.pseudobs-dict-card-checkbox {
flex-shrink: 0;
cursor: pointer;
width: 14px;
height: 14px;
accent-color: var(--interactive-accent);
}
.pseudobs-dict-card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
}
.pseudobs-dict-card-title {
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.pseudobs-dict-card-filename {
font-family: var(--font-monospace);
font-size: 0.75em;
color: var(--text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
/* Bouton scan individuel — icône uniquement, accentué */
.pseudobs-dict-card-scan {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border-radius: 5px;
border: none;
background: var(--interactive-accent);
color: var(--text-on-accent);
cursor: pointer;
transition: opacity 0.15s;
}
.pseudobs-dict-card-scan:hover { opacity: 0.85; }
.pseudobs-dict-card-scan svg { width: 15px; height: 15px; }
/* Bouton suppression — icône uniquement, discret */
.pseudobs-dict-card-remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border-radius: 4px;
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
}
.pseudobs-dict-card-remove:hover { color: var(--color-red); background: var(--background-secondary-alt); }
.pseudobs-dict-card-remove svg { width: 13px; height: 13px; }
/* Bouton scan groupé */
.pseudobs-dict-group-scan {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 5px;
border: none;
background: var(--interactive-accent);
color: var(--text-on-accent);
cursor: pointer;
font-size: 0.88em;
font-weight: 600;
margin-top: 2px;
}
.pseudobs-dict-group-scan:hover { opacity: 0.88; }
.pseudobs-dict-group-scan svg { width: 15px; height: 15px; }
/* ---- Wizard — tableau catalogue de dictionnaires ---- */
.pseudobs-onboarding-catalogue-scroll {
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
margin-bottom: 12px;
}
.pseudobs-onboarding-dict-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
}
.pseudobs-dict-item:hover { background: var(--background-secondary); }
.pseudobs-dict-name { flex: 1; font-family: var(--font-monospace); }
.pseudobs-dict-count { color: var(--text-muted); font-size: 0.8em; white-space: nowrap; }
.pseudobs-dict-remove {
background: none; border: none; cursor: pointer;
color: var(--text-faint); padding: 0 4px;
.pseudobs-onboarding-dict-table thead {
position: sticky;
top: 0;
z-index: 1;
background: var(--background-secondary);
}
.pseudobs-dict-remove:hover { color: var(--color-red); }
.pseudobs-onboarding-dict-table th {
text-align: left;
padding: 6px 10px;
font-size: 0.8em;
font-weight: 600;
color: var(--text-muted);
border-bottom: 1px solid var(--background-modifier-border);
white-space: nowrap;
}
.pseudobs-onboarding-dict-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--background-modifier-border-hover);
vertical-align: middle;
}
.pseudobs-onboarding-dict-table tbody tr:last-child td { border-bottom: none; }
.pseudobs-onboarding-dict-table tbody tr:hover { background: var(--background-secondary); }
.pseudobs-onboarding-dict-row-installed { background: color-mix(in srgb, var(--color-green) 5%, transparent); }
.pseudobs-onboarding-dict-row-installed:hover { background: color-mix(in srgb, var(--color-green) 8%, transparent); }
.pseudobs-onboarding-dict-name-cell { max-width: 220px; }
.pseudobs-onboarding-dict-name-cell span:first-child { display: block; font-weight: 500; }
.pseudobs-onboarding-dict-roles { color: var(--text-muted); font-size: 0.82em; }
.pseudobs-onboarding-dict-size { color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.pseudobs-onboarding-dict-action { text-align: center; width: 44px; }
/* Bouton icône uniquement (téléchargement) */
.pseudobs-onboarding-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.pseudobs-onboarding-icon-btn:hover { color: var(--text-normal); background: var(--background-secondary-alt); }
.pseudobs-onboarding-icon-btn svg { width: 15px; height: 15px; }
.pseudobs-onboarding-icon-btn-done {
color: var(--color-green);
border-color: color-mix(in srgb, var(--color-green) 40%, transparent);
background: color-mix(in srgb, var(--color-green) 8%, transparent);
}
.pseudobs-onboarding-icon-btn-done:hover { opacity: 0.85; }
/* ---- Modale de révision du scan dictionnaire (cards) ---- */
.pseudobs-dict-review-modal {
width: min(620px, 92vw);
}
.pseudobs-dict-review-modal .modal-content { width: 100%; }
.pseudobs-dict-review-modal h2 { margin-top: 0; font-size: 1.15em; }
.modal:has(.pseudobs-dict-review-modal){ width: fit-content; }
.pseudobs-dict-review-scroll {
max-height: min(520px, 60vh);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
margin: 10px 0;
padding-right: 2px;
}
/* Card individuelle */
.pseudobs-dict-review-card {
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 10px 12px;
transition: border-color 0.12s, opacity 0.12s;
}
.pseudobs-dict-review-card:hover {
border-color: var(--background-modifier-border-hover);
}
.pseudobs-dict-review-card-off { opacity: 0.38; }
.pseudobs-dict-review-card-off .pseudobs-dict-review-term { text-decoration: line-through; }
/* En-tête */
.pseudobs-dict-review-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.pseudobs-dict-review-card-term-wrap {
display: flex;
flex-direction: column;
gap: 2px;
}
.pseudobs-dict-review-term {
font-size: 1em;
font-weight: 600;
color: var(--color-orange);
font-family: var(--font-monospace);
}
.pseudobs-dict-review-cat {
font-size: 0.68em;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pseudobs-dict-review-card-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.pseudobs-dict-review-count {
font-size: 0.78em;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.pseudobs-dict-review-cb {
cursor: pointer;
accent-color: var(--interactive-accent);
width: 15px;
height: 15px;
}
/* Contexte */
.pseudobs-dict-review-ctx {
font-size: 0.82em;
line-height: 1.55;
color: var(--text-muted);
background: var(--background-secondary);
border-radius: 5px;
padding: 5px 9px;
margin-bottom: 8px;
font-family: var(--font-monospace);
white-space: pre-wrap;
word-break: break-word;
}
.pseudobs-dict-review-ctx-side { opacity: 0.6; }
.pseudobs-dict-review-ctx-term {
background: rgba(255, 160, 0, 0.28);
border-radius: 3px;
padding: 0 3px;
font-weight: 700;
color: var(--text-normal);
}
/* Ligne remplacement */
.pseudobs-dict-review-rep-row {
display: flex;
align-items: center;
gap: 8px;
}
.pseudobs-dict-review-arrow { color: var(--text-faint); font-size: 0.9em; flex-shrink: 0; }
.pseudobs-dict-review-rep-cell { display: flex; align-items: center; }
/* Préfixe éditable */
.pseudobs-dict-review-prefix {
width: 130px;
padding: 4px 8px;
border-radius: 4px 0 0 4px;
border: 1px solid var(--background-modifier-border);
border-right: none;
background: var(--background-primary);
color: var(--color-green);
font-family: var(--font-monospace);
font-size: 0.88em;
}
.pseudobs-dict-review-prefix:focus {
border-color: var(--interactive-accent);
outline: none;
z-index: 1;
position: relative;
}
.pseudobs-dict-review-prefix:disabled { opacity: 0.35; cursor: not-allowed; }
/* Index calculé — lecture seule */
.pseudobs-dict-review-index {
display: inline-flex;
align-items: center;
padding: 4px 10px 4px 5px;
border: 1px solid var(--background-modifier-border);
border-left: none;
border-radius: 0 4px 4px 0;
background: var(--background-secondary);
color: var(--color-green);
font-family: var(--font-monospace);
font-size: 0.88em;
white-space: nowrap;
min-width: 30px;
opacity: 0.75;
user-select: none;
}
/* Remplacement statique (MappingScanReviewModal) */
.pseudobs-dict-review-rep-static {
color: var(--color-green);
font-family: var(--font-monospace);
font-size: 0.88em;
}
/* Pied de page commun aux deux modales de révision */
.pseudobs-dict-review-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-border);
margin-top: 4px;
}
.pseudobs-dict-review-footer button {
padding: 5px 16px;
border-radius: 5px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
cursor: pointer;
font-size: 0.9em;
}
.pseudobs-dict-review-footer .mod-cta {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
font-weight: 600;
}
.pseudobs-dict-review-footer .mod-cta:disabled,
.pseudobs-dict-review-btn-empty { opacity: 0.5; cursor: not-allowed; }
/* Animation de rotation pendant le téléchargement */
@keyframes pseudobs-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.pseudobs-onboarding-icon-btn-loading { cursor: wait; }
.pseudobs-onboarding-icon-btn-loading svg { animation: pseudobs-spin 0.9s linear infinite; }