mirror of
https://github.com/core-hn/pseudobsidian-ization.git
synced 2026-07-22 06:57:06 +00:00
init
This commit is contained in:
commit
d216e81fb2
45 changed files with 12352 additions and 0 deletions
20
.eslintrc.json
Normal file
20
.eslintrc.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Notes de développement internes (Claude Code)
|
||||
CLAUDE.md
|
||||
|
||||
# Vault de test Obsidian — peut contenir des transcriptions avec données personnelles
|
||||
test/
|
||||
|
||||
# Fichiers système
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Dépendances Node
|
||||
node_modules/
|
||||
|
||||
# Sourcemaps (inutiles en production)
|
||||
*.js.map
|
||||
|
||||
# Données Obsidian locales du plugin (paramètres utilisateur)
|
||||
data.json
|
||||
|
||||
# Variables d'environnement locales
|
||||
.env.local
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
18
LICENSE
Normal file
18
LICENSE
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-----------------------------------------------------------------------------
|
||||
Code source — The Beerware License (Revision 42)
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Axelle Abbadie <https://cv.hal.science/axelle-abbadie/> a écrit ce logiciel.
|
||||
Vous pouvez faire ce que vous voulez avec, tant que vous conservez cette notice.
|
||||
Si on se croise un jour et que vous pensez que ça valait le coup,
|
||||
vous pouvez m'offrir une bière.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
Documentation (SPECS.md, ROADMAP.md, README.md) — CC BY 4.0
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Creative Commons Attribution 4.0 International
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
|
||||
Vous êtes libres de partager et d'adapter ces documents, y compris à des fins
|
||||
commerciales, à condition de créditer la source (Axelle Abbadie, PseudObsidian-ization).
|
||||
154
README.md
Normal file
154
README.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# PseudObsidian-ization
|
||||
|
||||
**Plugin Obsidian de pseudonymisation et de correction de transcriptions d'entretiens et de corpus interactionnels.**
|
||||
|
||||
Conçu pour les chercheurs en **analyse conversationnelle (EMCA)** et en **sciences du langage**, notamment à l'**UMR ICAR** (CNRS / Université Lyon 2 / ENS Lyon), ce plugin permet de travailler directement dans Obsidian sur des transcriptions brutes issues de terrains de recherche, en produisant des versions pseudonymisées exploitables pour l'analyse et l'archivage.
|
||||
|
||||
> **English summary** — An Obsidian plugin for pseudonymizing and correcting interactional transcripts (Jefferson / ICOR conventions, SRT, CHAT/CHA formats). Designed for qualitative researchers in linguistics and conversation analysis. See [SPECS.md](SPECS.md) for full technical specifications.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Formats pris en charge
|
||||
- `.srt` — sous-titres horodatés (sortie Whisper / IA)
|
||||
- `.cha` / `.chat` — format CHAT / CLAN (CLAN, corpus de linguistique)
|
||||
- `.md` — transcription Markdown (conventions Jefferson ou ICOR)
|
||||
- `.txt` — texte brut
|
||||
|
||||
Les fichiers `.srt` et `.cha` sont **automatiquement convertis en Markdown** à l'import, pour une édition native dans Obsidian. La structure (horodatages, locuteurs, métadonnées `@`, lignes dépendantes `%`) est préservée.
|
||||
|
||||
### Pseudonymisation
|
||||
- **Sélection → clic droit → Pseudonymiser** : remplace immédiatement dans le fichier
|
||||
- **Pseudonymiser avec Coulmont** : interroge l'outil de Baptiste Coulmont ([coulmont.com](https://coulmont.com)) pour proposer des prénoms sociologiquement équivalents (même milieu social, même décennie de popularité) — le chercheur choisit dans la liste proposée
|
||||
- **Créer une règle** : ajoute une correspondance dans le mapping JSON sans modifier le texte, pour validation ultérieure
|
||||
- **Scanner le fichier courant** : liste toutes les occurrences des règles avec diff avant/après, validation occurrence par occurrence
|
||||
- **Portées** : fichier, dossier, vault entier
|
||||
|
||||
### Tables de correspondance
|
||||
- Format JSON structuré (voir `SPECS.md §5`)
|
||||
- Trois niveaux : `fichier.mapping.json`, `dossier.mapping.json`, `vault.mapping.json`
|
||||
- Statuts : `suggested`, `validated`, `ignored`, `partial`, `conflict`, `needs_review`
|
||||
- Priorité z-index : entier libre, comme le `z-index` CSS — les entités longues priment sur les plus courtes par défaut (protection `Saint-Jean-de-Luz` vs `Jean`)
|
||||
- Marqueur optionnel dans l'export : `⟦Pierre⟧` pour identifier visuellement les pseudonymes
|
||||
|
||||
### Surlignage dans l'éditeur
|
||||
- **Orange** : termes sources encore présents (à pseudonymiser)
|
||||
- **Vert** : pseudonymes déjà appliqués
|
||||
|
||||
### Sécurité
|
||||
- Tout traitement est **local** — aucune donnée de transcription n'est envoyée à un serveur externe
|
||||
- Exception documentée : "Pseudonymiser avec Coulmont" envoie le prénom à `coulmont.com` (pas le contenu de la transcription)
|
||||
- Tables de correspondance strictement séparées des exports
|
||||
|
||||
---
|
||||
|
||||
## Statut actuel
|
||||
|
||||
**Version en développement actif — 0.0.1 (alpha)**
|
||||
|
||||
| Phase | Statut | Description |
|
||||
|---|---|---|
|
||||
| 0 — Boilerplate | ✅ | Plugin Obsidian TypeScript, Jest, ESLint |
|
||||
| 1 — Parser SRT | ✅ | Round-trip exact, horodatages préservés |
|
||||
| 2 — Parser CHAT | ✅ | Lignes `@`, `*`, `%` préservées |
|
||||
| 3 — Moteur de pseudonymisation | ✅ | Spans, z-index, protection des chevauchements |
|
||||
| 4 — Commandes UI | ✅ | Import, création de règles, export avec marqueur |
|
||||
| 5 — Portées + surlignage | ✅ | ScopeResolver, CM6 ViewPlugin |
|
||||
| 6 — Validation sélective | ✅ | OccurrencesModal, statuts partiels |
|
||||
| **7 — Dictionnaires** | 🔄 **En cours** | Intégration Coulmont opérationnelle — import de dictionnaires JSON/CSV et constitution de ressources lexicales embarquées en cours |
|
||||
| 8 — Scan automatique | ⏳ | Détection sans règle préexistante |
|
||||
| 9 — Interface complète | ⏳ | Panneau latéral 4 onglets |
|
||||
| 10 — Publication | ⏳ | Communauté Obsidian |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Pour les utilisateurs
|
||||
|
||||
1. Télécharger la dernière release (fichiers `main.js`, `manifest.json`, `styles.css`)
|
||||
2. Copier ces trois fichiers dans `.obsidian/plugins/pseudobsidianization/` de votre vault
|
||||
3. Activer le plugin dans Obsidian → Paramètres → Extensions communautaires
|
||||
|
||||
> Le plugin n'est pas encore soumis au répertoire communautaire Obsidian. Publication prévue à partir de la version 0.1.0.
|
||||
|
||||
### Pour les développeurs et contributeurs
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.huma-num.fr/[votre-groupe]/pseudobsidianization.git
|
||||
cd pseudobsidianization
|
||||
npm install
|
||||
npm run dev # build en mode watch
|
||||
npm test # suite de tests Jest
|
||||
npm run build # build de production
|
||||
```
|
||||
|
||||
Copier `main.js`, `manifest.json` et `styles.css` dans `.obsidian/plugins/pseudobsidianization/` d'un vault Obsidian dédié au développement.
|
||||
Installer [Hot Reload](https://github.com/pjeby/hot-reload) dans ce vault et créer un fichier `.hotreload` dans le dossier du plugin pour le rechargement automatique à chaque build.
|
||||
|
||||
#### Structure du dépôt
|
||||
|
||||
```
|
||||
pseudobsidianization/
|
||||
├── src/
|
||||
│ ├── main.ts # Point d'entrée Obsidian
|
||||
│ ├── types.ts # Types partagés (SPECS §11.3)
|
||||
│ ├── settings.ts # Paramètres
|
||||
│ ├── parsers/ # SrtParser, ChatParser, TranscriptConverter
|
||||
│ ├── mappings/ # MappingStore, ScopeResolver
|
||||
│ ├── pseudonymizer/ # Moteur, ReplacementPlanner, SpanProtector
|
||||
│ ├── scanner/ # OccurrenceScanner
|
||||
│ └── ui/ # Modales, surlignage CM6
|
||||
├── tests/ # Tests unitaires Jest
|
||||
├── manifest.json # Métadonnées du plugin Obsidian
|
||||
├── versions.json
|
||||
├── styles.css
|
||||
├── SPECS.md # Spécifications fonctionnelles complètes
|
||||
├── ROADMAP.md # Feuille de route détaillée
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contribuer
|
||||
|
||||
Les contributions sont les bienvenues, en particulier :
|
||||
|
||||
- **Dictionnaires** : listes de prénoms, toponymes, institutions adaptés à des corpus spécifiques (langues régionales, périodes historiques, terrains non francophones)
|
||||
- **Conventions de transcription** : parsers pour d'autres systèmes (ELAN, Praat TextGrid, EXMARaLDA)
|
||||
- **Adaptateurs** : connecteurs vers d'autres ressources lexicales ouvertes
|
||||
- **Retours d'usage** : issues GitLab pour signaler des cas limites rencontrés sur de vrais corpus
|
||||
|
||||
Merci d'ouvrir une issue avant de proposer une pull request pour les fonctionnalités importantes.
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
### Code — *The Beerware License* (Revision 42)
|
||||
|
||||
```
|
||||
Axelle Abbadie <https://cv.hal.science/axelle-abbadie/> a écrit ce logiciel.
|
||||
Vous pouvez faire ce que vous voulez avec, tant que vous conservez cette notice.
|
||||
Si on se croise un jour et que vous pensez que ça valait le coup,
|
||||
vous pouvez m'offrir une bière.
|
||||
```
|
||||
|
||||
**Ce plugin est fait pour être modifié.** Si votre terrain de recherche implique des conventions de transcription particulières, un dialecte régional, des corpus multilingues ou des formats d'export spécifiques à votre institution, ne vous gênez pas pour adapter le code à vos besoins. C'est précisément l'esprit de cet outil.
|
||||
|
||||
### Documentation et spécifications — CC BY 4.0
|
||||
|
||||
Les fichiers `SPECS.md`, `ROADMAP.md` et ce `README.md` sont placés sous licence [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/).
|
||||
|
||||
Vous êtes libres de les réutiliser, adapter et redistribuer, y compris à des fins commerciales, à condition de créditer la source.
|
||||
|
||||
---
|
||||
|
||||
## Crédits
|
||||
|
||||
- **Axelle Abbadie** — conception, spécifications, direction du développement ([HAL](https://cv.hal.science/axelle-abbadie/))
|
||||
- **Claude Sonnet 4.6** (Anthropic) — co-développement du code via [Claude Code](https://claude.ai/code)
|
||||
- **Baptiste Coulmont** — outil de suggestion de prénoms sociologiquement équivalents ([coulmont.com](https://coulmont.com))
|
||||
- **Alex Alber** (Université de Tours / UMR CITERES) — inspiration de la chaîne Whispurge → Sonal pi pour le workflow de pseudonymisation
|
||||
- **UMR ICAR** (CNRS / Université Lyon 2 / ENS Lyon) — contexte scientifique et conventions de transcription ICOR
|
||||
215
ROADMAP.md
Normal file
215
ROADMAP.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# ROADMAP — PseudObsidian-ization
|
||||
|
||||
Plugin Obsidian de pseudonymisation et correction de transcriptions (conventions Jefferson / ICOR / SRT / CHAT).
|
||||
|
||||
Chaque phase produit quelque chose de testable. Les critères d'acceptation renvoient aux sections de SPECS.md.
|
||||
|
||||
## État actuel (mai 2026)
|
||||
|
||||
**Phases 0 à 6 terminées.** La Phase 7 est en cours : l'intégration de l'outil Coulmont est opérationnelle (suggestion de prénoms sociologiquement équivalents via `coulmont.com`). Le travail porte maintenant sur la **constitution de dictionnaires lexicaux embarqués** (prénoms, toponymes, institutions) et leur **intégration dans le moteur de suggestion** pour les phases de scan automatique.
|
||||
|
||||
```
|
||||
✅ Phases 0–6 Parsers · Moteur · Commandes UI · Portées · Surlignage · Validation sélective
|
||||
🔄 Phase 7 Dictionnaires — Coulmont ✅ · Dictionnaires JSON/CSV · Ressources embarquées
|
||||
⏳ Phase 8 Scan automatique (détection sans règle préexistante)
|
||||
⏳ Phase 9 Interface complète (panneau latéral 4 onglets)
|
||||
⏳ Phase 10 Publication communauté Obsidian
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 0 — Boilerplate
|
||||
|
||||
- [x] Initialiser le plugin avec le template officiel Obsidian (TypeScript + esbuild)
|
||||
- [x] Configurer ESLint, Prettier, Jest
|
||||
- [x] Définir les types partagés de SPECS.md §11.3 : `ScopeType`, `MappingStatus`, `EntityCategory`, `MappingRule`, `Scope`, `Occurrence`, `ReplacementSpan`
|
||||
- [x] Créer un vault de test avec des fichiers fictifs : `entretien_01.srt`, `entretien_02.cha`, `entretien_03.md`
|
||||
- [x] Mettre en place le dossier `_pseudonymisation/` dans le vault de test (§13.3)
|
||||
|
||||
**Testable :** le plugin se charge dans Obsidian sans erreur.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 1 — Parser SRT
|
||||
|
||||
Objectif : ouvrir un `.srt`, le lire sans l'altérer, identifier les zones textuelles. Priorité haute car c'est le format le plus courant en sortie de Whisper.
|
||||
|
||||
- [ ] Implémenter `parsers/SrtParser.ts` :
|
||||
- Découpage en blocs (numéro / horodatage / texte)
|
||||
- Identification des zones remplaçables (texte uniquement, pas les timestamps)
|
||||
- Représentation interne : tableau de `SrtBlock { index, start, end, lines[] }`
|
||||
- [ ] Tests unitaires sur `entretien_01.srt` fictif
|
||||
- [ ] Vérifier que la reconstruction du fichier à partir de l'AST est identique à l'original (round-trip)
|
||||
|
||||
**Testable — SPECS §17.4 :** après parse + reconstruction, les horodatages et numéros de blocs sont inchangés.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2 — Parser CHAT / CHA
|
||||
|
||||
Objectif : ouvrir un `.cha`, distinguer métadonnées, tours de parole et lignes dépendantes.
|
||||
|
||||
- [ ] Implémenter `parsers/ChatParser.ts` :
|
||||
- Lignes `@` (métadonnées) → préservées telles quelles
|
||||
- Lignes `*LOCUTEUR:` → tour de parole, zone remplaçable
|
||||
- Lignes `%` (dépendantes) → préservées ou traitées séparément selon action explicite
|
||||
- Représentation interne : `ChatLine { type: 'meta'|'turn'|'dependent', speaker?, content }`
|
||||
- [ ] Tests unitaires sur `entretien_02.cha` fictif
|
||||
- [ ] Vérifier le round-trip
|
||||
|
||||
**Testable — SPECS §17.5 :** les lignes `@`, `*` et `%` sont conservées après parse + reconstruction.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 3 — Mapping basique : sélection → règle → application fichier
|
||||
|
||||
Objectif : premier flux complet, portée fichier uniquement. C'est le cœur du MVP v0.1 (SPECS §16.1).
|
||||
|
||||
- [ ] Commande "Créer une règle depuis la sélection" (§10.1) : modale minimale (source, remplacement, catégorie, portée = fichier par défaut)
|
||||
- [ ] `mappings/MappingStore.ts` : lecture / écriture de `entretien_XX.mapping.json` (portée `file`)
|
||||
- [ ] `scanner/OccurrenceScanner.ts` : scan du fichier courant, liste des occurrences avec contexte gauche/droite (§7.2)
|
||||
- [ ] Vue latérale minimale — onglet **Occurrences** : fichier, ligne, contexte, remplacement proposé, bouton valider/ignorer (§10.3)
|
||||
- [ ] `pseudonymizer/ReplacementPlanner.ts` : construit le plan de remplacement (§12.1)
|
||||
- [ ] `pseudonymizer/SpanProtector.ts` : résolution des spans, application droite-à-gauche (§12.4, §12.5)
|
||||
- [ ] Export du fichier pseudonymisé (`nom.pseudonymized.srt` / `.cha` / `.md`) sans table de correspondance (§14.1)
|
||||
- [ ] Export de la table JSON séparément (§14.2)
|
||||
|
||||
**Testable — SPECS §17.1 :** `Bonjour Jean.` → règle Jean → Pierre → export contient `Bonjour Pierre.` et la table JSON contient le mapping.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 4 — Priorité z-index et protection des spans imbriqués
|
||||
|
||||
Objectif : garantir qu'un remplacement court ne s'applique pas à l'intérieur d'un segment long déjà traité.
|
||||
|
||||
- [x] Champ `priority` dans `MappingRule` : entier libre, défaut 0 (comme z-index CSS — §8.4)
|
||||
- [x] `sortRules` : tri par `priority` décroissant, puis longueur source décroissante, puis portée locale (§12.3)
|
||||
- [x] `resolveSpans` : élimination des chevauchements selon ce tri (§12.4)
|
||||
- [x] Tests de non-régression obligatoires (§18.2) : Jean/Saint-Jean-de-Luz, Paul/Saint-Paul, Montpellier/CHU, Marie/Sainte-Marie
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- Commande `Pseudonymisation : ajouter une transcription` (Ctrl+P) : ouvre un sélecteur de fichier natif, importe le fichier dans le vault, initialise un mapping JSON vide, ouvre le fichier
|
||||
- Commande `Pseudonymisation : pseudonymiser le fichier courant` : lit le fichier actif (SRT, CHA/CHAT, MD), charge le mapping JSON correspondant, écrit `[nom].pseudonymized.[ext]` dans `_pseudonymisation/exports/`
|
||||
- Commande `Pseudonymisation : créer une règle` (disponible sur sélection dans l'éditeur) : modale minimale — source, remplacement, catégorie, portée, priority — écrit dans le mapping JSON du fichier courant
|
||||
- Tester manuellement : importer `entretien_01.srt`, créer les règles Jean → Pierre et Saint-Jean-de-Luz → Ville littorale, pseudonymiser, vérifier l'export
|
||||
|
||||
**Testable — SPECS §17.2 :** `Jean habite Saint-Jean-de-Luz.` → `Pierre habite Ville littorale.` visible dans le fichier exporté dans Obsidian.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 5 — Portée dossier et vault + surlignage éditeur
|
||||
|
||||
Objectif : charger automatiquement les trois niveaux de mapping et visualiser dans l'éditeur les termes déjà pseudonymisés.
|
||||
|
||||
- [ ] `mappings/ScopeResolver.ts` : parcourir le dossier `_pseudonymisation/mappings/`, charger tous les fichiers JSON, filtrer et fusionner les règles applicables à un fichier donné (§4, §12.2)
|
||||
- [ ] Cascade de résolution : fichier → dossier le plus proche → vault — géré par le tri `sortRules` existant via `scopeWeight` (§4.4)
|
||||
- [ ] **Surlignage éditeur** : extension CodeMirror 6 (`registerEditorExtension`) qui marque dans le fichier ouvert :
|
||||
- en **orange** : les termes sources (encore à pseudonymiser)
|
||||
- en **vert** : les termes de remplacement (déjà pseudonymisés)
|
||||
- se met à jour à chaque changement de fichier actif
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- La commande "pseudonymiser" utilise ScopeResolver pour charger les trois niveaux automatiquement
|
||||
- Commande `Pseudonymisation : créer une règle au niveau dossier / vault`
|
||||
- Le surlignage apparaît dès qu'un mapping existe pour le fichier ouvert
|
||||
- Tester : une règle vault s'applique à deux entretiens différents ; les termes s'affichent surlignés dans les deux fichiers
|
||||
|
||||
**Testable :** ouvrir un fichier avec des règles de mapping → termes sources en orange, pseudonymes déjà appliqués en vert.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 6 — Validation sélective et statuts
|
||||
|
||||
Objectif : pouvoir remplacer certaines occurrences et en ignorer d'autres.
|
||||
|
||||
- [ ] Statuts par occurrence : `suggested`, `validated`, `ignored`, `partial`, `conflict`, `disabled`, `needs_review` (§5.4)
|
||||
- [ ] Validation occurrence par occurrence, par lot, ou globale (§10.5)
|
||||
- [ ] Mapping au statut `partial` quand certaines occurrences seulement sont remplacées (§17.3)
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- Commande `Pseudonymisation : scanner le fichier courant` → liste les occurrences candidates dans une modale
|
||||
- Chaque occurrence : bouton Valider / Ignorer / Faux positif
|
||||
- Prévisualisation diff par occurrence avant application (§7.4)
|
||||
- Menu contextuel sur sélection : Créer une règle / Pseudonymiser cette occurrence (§10.2)
|
||||
- Tester : trois occurrences de `Jean`, en valider deux, ignorer une → statut `partial` dans le JSON
|
||||
|
||||
**Testable — SPECS §17.3 :** mapping `Jean → Pierre` passe au statut `partial` après validation sélective.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Phase 7 — Dictionnaires : import et suggestions
|
||||
|
||||
Objectif : importer des dictionnaires existants (Coulmont, INSEE, etc.) et générer des suggestions de remplacement.
|
||||
|
||||
- [ ] Format interne `DictionaryEntry` avec `gender`, `decade`, `socialClass`, `replacementCandidates[]` (§6.3)
|
||||
- [ ] `dictionaries/JsonDictionaryImporter.ts` : import JSON format SPECS §6.3
|
||||
- [ ] `dictionaries/CsvDictionaryImporter.ts` : import CSV + mapping des colonnes (§6.4)
|
||||
- [ ] `adapters/coulmont.ts` : CSV Coulmont → `DictionaryEntry[]`
|
||||
- [ ] `adapters/insee.ts` : CSV INSEE prénoms → `DictionaryEntry[]`
|
||||
- [ ] `adapters/geoapi.ts` : GeoAPI INSEE (villes + `sizeClass`) → `DictionaryEntry[]`
|
||||
- [ ] `dictionaries/DictionaryManager.ts` : activation / désactivation, portée par dictionnaire (§6.5)
|
||||
- [ ] Dictionnaires embarqués : `firstnames.json`, `lastnames.json`, `cities.json`, `ambiguous.json`, `protected.json`
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- Commande `Pseudonymisation : importer un dictionnaire` (JSON ou CSV)
|
||||
- Dans la modale de création de règle : suggestions issues des dictionnaires actifs
|
||||
- Tester : importer `prenoms_coulmont.json`, créer une règle pour `Jean` → candidats proposés depuis le dictionnaire
|
||||
|
||||
**Testable :** suggestion automatique de remplacement à partir d'un dictionnaire Coulmont importé.
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Phase 8 — Détection automatique et chevauchements
|
||||
|
||||
Objectif : scanner un fichier sans règle préexistante et détecter les entités candidates.
|
||||
|
||||
- [ ] `scanner/DictionaryScanner.ts` : croisement texte × dictionnaires actifs (§7.2)
|
||||
- [ ] `scanner/RegexScanner.ts` : heuristiques typographiques (majuscule initiale, locuteurs CHAT, formats Jefferson/ICOR)
|
||||
- [ ] `mappings/ConflictDetector.ts` : détection des chevauchements (§8.5)
|
||||
- [ ] Marquage automatique `needs_review` pour les occurrences chevauchantes
|
||||
- [ ] `ambiguous.json` : tokens ville/prénom ambigus — signalement pour validation manuelle
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- Commande `Pseudonymisation : scanner le fichier` sans mapping préexistant → propose des candidats depuis les dictionnaires et les heuristiques
|
||||
- Modale de désambiguïsation : contexte du tour + boutons Ville / Prénom / Ignorer
|
||||
- Tester : scanner `entretien_01.cha`, voir `Jean` et `Saint-Jean-de-Luz` détectés, chevauchement signalé
|
||||
|
||||
**Testable :** scan automatique → détection de candidats → résolution manuelle de l'ambiguïté ville/prénom.
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Phase 9 — Interface complète et correction des conventions
|
||||
|
||||
- [ ] Vue latérale complète : 4 onglets Occurrences / Mappings / Dictionnaires / Exports (§10.3)
|
||||
- [ ] Onglet **Mappings** : modifier source, remplacement, catégorie, portée, priority (z-index), désactiver
|
||||
- [ ] Onglet **Exports** : choix format, vérification absence de table dans l'export (§10.4)
|
||||
- [ ] Rapport de pseudonymisation (§14.3)
|
||||
- [ ] `correction/checker.ts` : vérification des conventions Jefferson/ICOR
|
||||
- [ ] Suggestions de correction au survol
|
||||
- [ ] **Exploitable dans Obsidian** :
|
||||
- Panneau latéral complet accessible via l'icône ruban
|
||||
- Workflow complet : ouvrir → scanner → valider → pseudonymiser → exporter sans table
|
||||
- Tester le workflow de bout en bout sur `entretien_01.srt` et `entretien_02.cha`
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Phase 10 — Publication
|
||||
|
||||
- [ ] README utilisateur en français avec captures d'écran
|
||||
- [ ] Guide de démarrage rapide pour les chercheurs ICAR
|
||||
- [ ] Trancher les questions ouvertes de SPECS §20
|
||||
- [ ] Publication sur le répertoire communautaire Obsidian
|
||||
- [ ] Licence : MIT ou EUPL (selon contraintes CNRS)
|
||||
|
||||
---
|
||||
|
||||
## Questions ouvertes (SPECS §20)
|
||||
|
||||
| # | Question | Statut |
|
||||
|---|---|---|
|
||||
| 1 | Modifier les fichiers originaux ou fonctionner uniquement par export ? | À trancher |
|
||||
| 2 | Tables de correspondance dans le vault ou hors vault par défaut ? | À trancher |
|
||||
| 3 | Chiffrement des tables dès la v1 ? | À trancher |
|
||||
| 4 | Métadonnées CHAT dès le MVP ou à partir de la v0.3 ? | À trancher |
|
||||
| 5 | NER avancé plus tard, ou rester sur dictionnaires + regex + validation humaine ? | À trancher |
|
||||
| 6 | Compatibilité exacte à viser avec les JSON de Sonal pi / Whispurge ? | À explorer |
|
||||
| 7 | Couplage audio optionnel via fichier local (API Obsidian) ? | À explorer |
|
||||
| 8 | Export ELAN ou Praat ? | À explorer |
|
||||
| 9 | Liste canonique pour `ambiguous.json` (Nancy, Florence, Lorraine…) ? | À constituer |
|
||||
39
esbuild.config.mjs
Normal file
39
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
18
jest.config.js
Normal file
18
jest.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
'^obsidian$': '<rootDir>/tests/__mocks__/obsidian.ts',
|
||||
},
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: {
|
||||
module: 'commonjs',
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "pseudobsidianization",
|
||||
"name": "PseudObsidian-ization",
|
||||
"version": "0.0.1",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Correction et pseudonymisation de transcriptions d'entretiens (Jefferson, ICOR, SRT, CHAT/CHA).",
|
||||
"author": "Axelle Abbadie",
|
||||
"authorUrl": "https://cv.hal.science/axelle-abbadie/",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
5698
package-lock.json
generated
Normal file
5698
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
package.json
Normal file
30
package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "pseudobsidianization",
|
||||
"version": "0.0.1",
|
||||
"description": "Plugin Obsidian de pseudonymisation de transcriptions",
|
||||
"author": "Axelle Abbadie",
|
||||
"homepage": "https://cv.hal.science/axelle-abbadie/",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^18.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.20.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jest": "^29.5.0",
|
||||
"obsidian": "latest",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
458
src/main.ts
Normal file
458
src/main.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import { Plugin, Notice, TFile, TAbstractFile, Editor, Menu, MarkdownView, requestUrl } from 'obsidian';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { PseudObsSettings, DEFAULT_SETTINGS, PseudObsSettingTab } from './settings';
|
||||
import { RuleModal } from './ui/RuleModal';
|
||||
import { QuickPseudonymizeModal } from './ui/QuickPseudonymizeModal';
|
||||
import { createPseudonymHighlighter, highlightDataChanged, type HighlightData } from './ui/PseudonymHighlighter';
|
||||
import { EditRuleModal } from './ui/EditRuleModal';
|
||||
import { OccurrencesModal } from './ui/OccurrencesModal';
|
||||
import { scanOccurrences } from './scanner/OccurrenceScanner';
|
||||
import { SrtParser } from './parsers/SrtParser';
|
||||
import { ChatParser } from './parsers/ChatParser';
|
||||
import { srtToMarkdown, chatToMarkdown } from './parsers/TranscriptConverter';
|
||||
import { MappingStore } from './mappings/MappingStore';
|
||||
import { ScopeResolver } from './mappings/ScopeResolver';
|
||||
import { PseudonymizationEngine } from './pseudonymizer/PseudonymizationEngine';
|
||||
import { findSpansForRule } from './pseudonymizer/ReplacementPlanner';
|
||||
import { applySpans } from './pseudonymizer/SpanProtector';
|
||||
import type { MappingFile, MappingRule, MappingStatus, Occurrence } from './types';
|
||||
|
||||
const CONVERTIBLE_EXTS = ['srt', 'cha', 'chat'];
|
||||
|
||||
export default class PseudObsPlugin extends Plugin {
|
||||
settings!: PseudObsSettings;
|
||||
scopeResolver!: ScopeResolver;
|
||||
// Cache synchrone pour le surlignage CM6 (mis à jour de façon asynchrone)
|
||||
private highlightData: HighlightData = { sources: [], replacements: [] };
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.scopeResolver = new ScopeResolver(this.app.vault, this.settings.mappingFolder);
|
||||
this.addSettingTab(new PseudObsSettingTab(this.app, this));
|
||||
|
||||
// Extension CM6 : surlignage des termes sources (orange) et remplacements (vert)
|
||||
this.registerEditorExtension(
|
||||
createPseudonymHighlighter(() => this.highlightData)
|
||||
);
|
||||
|
||||
// Rafraîchir le cache de surlignage à chaque changement de fichier actif
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('active-leaf-change', () => this.refreshHighlightData())
|
||||
);
|
||||
// Premier chargement au démarrage
|
||||
this.refreshHighlightData();
|
||||
|
||||
// Watcher : convertir automatiquement tout .srt/.cha/.chat ajouté au vault
|
||||
// (drag-and-drop, copie externe, commande "Ajouter une transcription")
|
||||
this.registerEvent(
|
||||
this.app.vault.on('create', (file: TAbstractFile) => {
|
||||
if (!(file instanceof TFile)) return;
|
||||
if (!CONVERTIBLE_EXTS.includes(file.extension.toLowerCase())) return;
|
||||
// Délai court pour laisser Obsidian finir l'écriture du fichier
|
||||
setTimeout(() => this.autoConvert(file), 300);
|
||||
})
|
||||
);
|
||||
|
||||
this.addCommand({
|
||||
id: 'add-transcription',
|
||||
name: 'Ajouter une transcription',
|
||||
callback: () => this.openFilePicker(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'pseudonymize-current-file',
|
||||
name: 'Pseudonymiser le fichier courant',
|
||||
callback: () => this.pseudonymizeActiveFile(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'create-rule',
|
||||
name: 'Créer une règle de remplacement',
|
||||
editorCallback: (editor) => {
|
||||
new RuleModal(this.app, this, editor.getSelection()).open();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'scan-current-file',
|
||||
name: 'Scanner le fichier courant',
|
||||
callback: () => this.scanCurrentFile(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'pseudonymize-selection',
|
||||
name: 'Pseudonymiser la sélection',
|
||||
editorCheckCallback: (checking, editor) => {
|
||||
if (!editor.getSelection()) return false;
|
||||
if (!checking) new QuickPseudonymizeModal(this.app, this, editor).open();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('editor-menu', (menu: Menu, editor: Editor) => {
|
||||
const selection = editor.getSelection().trim();
|
||||
if (!selection) return;
|
||||
menu.addSeparator();
|
||||
|
||||
const selLower = selection.toLowerCase();
|
||||
const isKnown =
|
||||
this.highlightData.sources.some((s) => s.toLowerCase() === selLower) ||
|
||||
this.highlightData.replacements.some((r) => r.toLowerCase() === selLower);
|
||||
|
||||
if (isKnown) {
|
||||
// Terme connu : proposer la modification en priorité
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle(`Modifier la règle pour "${selection.slice(0, 25)}${selection.length > 25 ? '…' : ''}"`)
|
||||
.setIcon('settings')
|
||||
.onClick(async () => {
|
||||
const location = await this.scopeResolver.findRuleByTerm(selection);
|
||||
if (location) {
|
||||
new EditRuleModal(this.app, this, location).open();
|
||||
} else {
|
||||
new Notice('Règle introuvable dans les mappings.');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle(`Pseudonymiser "${selection.slice(0, 25)}${selection.length > 25 ? '…' : ''}"`)
|
||||
.setIcon('eye-off')
|
||||
.onClick(() => new QuickPseudonymizeModal(this.app, this, editor).open())
|
||||
);
|
||||
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle(`Pseudonymiser avec Coulmont…`)
|
||||
.setIcon('book-user')
|
||||
.onClick(async () => {
|
||||
const notice = new Notice('Recherche sur coulmont.com…', 0);
|
||||
const suggestions = await this.fetchCoulmont(selection);
|
||||
notice.hide();
|
||||
if (suggestions.length === 0) {
|
||||
new Notice(`Aucun résultat Coulmont pour "${selection}".`);
|
||||
return;
|
||||
}
|
||||
new RuleModal(this.app, this, selection, '', suggestions).open();
|
||||
})
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Créer une règle de remplacement…')
|
||||
.setIcon('pencil')
|
||||
.onClick(() => new RuleModal(this.app, this, selection).open())
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onunload(): void {}
|
||||
|
||||
// --- Coulmont ---
|
||||
|
||||
// Interroge l'outil de Baptiste Coulmont pour suggérer un prénom équivalent.
|
||||
// Le prénom source est envoyé à coulmont.com — ne pas utiliser pour des données
|
||||
// déjà sensibles (utiliser un prénom de substitution neutre si besoin).
|
||||
// Retourne tous les prénoms équivalents proposés par l'outil Coulmont.
|
||||
// Le jeu de données ne différencie pas M/F — l'utilisateur choisit dans la liste.
|
||||
async fetchCoulmont(prenom: string): Promise<string[]> {
|
||||
try {
|
||||
const url = `https://coulmont.com/bac/results.php?search=${encodeURIComponent(prenom)}`;
|
||||
const response = await requestUrl({ url, method: 'GET' });
|
||||
const doc = new DOMParser().parseFromString(response.text, 'text/html');
|
||||
|
||||
// Récupérer tous les liens dans les deux blocs de résultats (M et F)
|
||||
const els = doc.querySelectorAll(
|
||||
'#hero > div > div > div > div > p.mb-1.mb-md-1 > a'
|
||||
);
|
||||
const names = Array.from(els)
|
||||
.map((el) => el.textContent?.trim() ?? '')
|
||||
.filter((n) => n.length > 0);
|
||||
|
||||
// Dédoublonner en préservant l'ordre
|
||||
return [...new Set(names)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Surlignage ---
|
||||
|
||||
async refreshHighlightData(): Promise<void> {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) {
|
||||
this.highlightData = { sources: [], replacements: [] };
|
||||
} else {
|
||||
try {
|
||||
const rules = await this.scopeResolver.getRulesFor(file.path);
|
||||
this.highlightData = {
|
||||
sources: rules.map((r) => r.source).filter(Boolean),
|
||||
replacements: rules.map((r) => r.replacement).filter(Boolean),
|
||||
};
|
||||
} catch {
|
||||
this.highlightData = { sources: [], replacements: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatcher le StateEffect sur l'éditeur actif pour déclencher
|
||||
// la reconstruction des décorations CM6 (le ViewPlugin ne se déclenche
|
||||
// pas sur un changement de données externe sans ce signal)
|
||||
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
const cm = view?.editor && ((view.editor as unknown as { cm?: EditorView }).cm);
|
||||
cm?.dispatch({ effects: highlightDataChanged.of(undefined) });
|
||||
}
|
||||
|
||||
// --- Conversion automatique ---
|
||||
|
||||
private async autoConvert(file: TFile): Promise<void> {
|
||||
try {
|
||||
const raw = await this.app.vault.read(file);
|
||||
const ext = file.extension.toLowerCase();
|
||||
const basename = file.basename;
|
||||
const folder = file.parent?.path ?? '';
|
||||
const mdPath = folder ? `${folder}/${basename}.md` : `${basename}.md`;
|
||||
|
||||
let mdContent: string;
|
||||
if (ext === 'srt') {
|
||||
mdContent = srtToMarkdown(new SrtParser().parse(raw), file.name);
|
||||
} else {
|
||||
mdContent = chatToMarkdown(new ChatParser().parse(raw), file.name);
|
||||
}
|
||||
|
||||
// Si un .md du même nom existe déjà, ne pas écraser
|
||||
if (this.app.vault.getAbstractFileByPath(mdPath) instanceof TFile) {
|
||||
new Notice(`⚠ ${basename}.md existe déjà — conversion ignorée pour ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.app.vault.create(mdPath, mdContent);
|
||||
|
||||
// Mapping JSON vide
|
||||
const mappingPath = `${this.settings.mappingFolder}/${basename}.mapping.json`;
|
||||
if (!this.app.vault.getAbstractFileByPath(mappingPath)) {
|
||||
await this.ensureFolder(this.settings.mappingFolder);
|
||||
const store = new MappingStore({ type: 'file', path: mdPath });
|
||||
await this.app.vault.create(mappingPath, JSON.stringify(store.toJSON(), null, 2));
|
||||
}
|
||||
|
||||
// Supprimer le fichier source non-Markdown maintenant remplacé par le .md
|
||||
await this.app.vault.delete(file);
|
||||
|
||||
// Ouvrir le .md
|
||||
const mdFile = this.app.vault.getAbstractFileByPath(mdPath);
|
||||
if (mdFile instanceof TFile) {
|
||||
await this.app.workspace.getLeaf().openFile(mdFile);
|
||||
}
|
||||
|
||||
new Notice(`✓ ${file.name} → ${basename}.md`);
|
||||
} catch (e) {
|
||||
new Notice(`Erreur de conversion de ${file.name} : ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Commande "Ajouter une transcription" ---
|
||||
|
||||
private openFilePicker(): void {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.srt,.cha,.chat,.txt,.md';
|
||||
input.multiple = true;
|
||||
// Pas de display:none — bloque le change event dans certaines versions d'Electron
|
||||
input.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
|
||||
document.body.appendChild(input);
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.remove();
|
||||
|
||||
for (const file of files) {
|
||||
if (CONVERTIBLE_EXTS.includes(file.name.split('.').pop()?.toLowerCase() ?? '')) {
|
||||
// Copier dans le vault → le watcher s'occupe de la conversion
|
||||
await this.copyToVault(file);
|
||||
} else {
|
||||
await this.copyToVault(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
private async copyToVault(browserFile: File): Promise<void> {
|
||||
const raw = await browserFile.text();
|
||||
const destFolder = this.settings.transcriptionsFolder;
|
||||
await this.ensureFolder(destFolder);
|
||||
const destPath = `${destFolder}/${browserFile.name}`;
|
||||
|
||||
if (this.app.vault.getAbstractFileByPath(destPath) instanceof TFile) {
|
||||
new Notice(`Le fichier existe déjà dans le vault : ${browserFile.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer le fichier brut dans le vault — le watcher vault.on('create') prendra le relai
|
||||
// pour les formats convertibles (.srt, .cha, .chat)
|
||||
await this.app.vault.create(destPath, raw);
|
||||
}
|
||||
|
||||
// --- Pseudonymisation ---
|
||||
|
||||
private async pseudonymizeActiveFile(): Promise<void> {
|
||||
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);
|
||||
// Charger les règles depuis les trois niveaux (fichier + dossier + vault)
|
||||
const rules = await this.scopeResolver.getRulesFor(file.path);
|
||||
if (rules.length === 0) {
|
||||
new Notice(
|
||||
`Aucune règle validée.\nCréez des règles via Ctrl+P → "Créer une règle".\nMapping attendu : ${this.settings.mappingFolder}/${file.basename}.mapping.json`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = this.settings.useMarkerInExport
|
||||
? { open: this.settings.markerOpen, close: this.settings.markerClose }
|
||||
: undefined;
|
||||
|
||||
const engine = new PseudonymizationEngine({
|
||||
caseSensitive: this.settings.caseSensitive,
|
||||
wholeWordOnly: this.settings.wholeWordOnly,
|
||||
});
|
||||
|
||||
let pseudonymized: string;
|
||||
if (ext === 'srt') {
|
||||
const parser = new SrtParser();
|
||||
const doc = parser.parse(content);
|
||||
for (const block of doc.blocks) {
|
||||
block.lines = block.lines.map((l) => engine.pseudonymize(l, rules, marker));
|
||||
}
|
||||
pseudonymized = parser.reconstruct(doc);
|
||||
} else if (ext === 'cha' || ext === 'chat') {
|
||||
const parser = new ChatParser();
|
||||
const doc = parser.parse(content);
|
||||
for (const line of doc.lines) {
|
||||
if (line.type === 'turn' && line.content !== undefined) {
|
||||
line.content = engine.pseudonymize(line.content, rules, marker);
|
||||
}
|
||||
}
|
||||
pseudonymized = parser.reconstruct(doc);
|
||||
} else {
|
||||
pseudonymized = engine.pseudonymize(content, rules, marker);
|
||||
}
|
||||
|
||||
await this.ensureFolder(this.settings.exportsFolder);
|
||||
const outputPath = `${this.settings.exportsFolder}/${file.basename}.pseudonymized.${ext}`;
|
||||
const existing = this.app.vault.getAbstractFileByPath(outputPath);
|
||||
if (existing instanceof TFile) {
|
||||
await this.app.vault.modify(existing, pseudonymized);
|
||||
} else {
|
||||
await this.app.vault.create(outputPath, pseudonymized);
|
||||
}
|
||||
|
||||
new Notice(`✓ ${rules.length} règle(s) appliquée(s)\n→ ${outputPath}`);
|
||||
}
|
||||
|
||||
private async scanCurrentFile(): Promise<void> {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) { new Notice('Aucun fichier actif.'); return; }
|
||||
|
||||
const rules = await this.scopeResolver.getRulesFor(file.path);
|
||||
if (rules.length === 0) {
|
||||
new Notice('Aucune règle pour ce fichier.\nCréez des règles via Ctrl+P → "Créer une règle".');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.app.vault.read(file);
|
||||
const occurrences = scanOccurrences(content, file.path, rules, {
|
||||
caseSensitive: this.settings.caseSensitive,
|
||||
wholeWordOnly: this.settings.wholeWordOnly,
|
||||
});
|
||||
|
||||
if (occurrences.length === 0) {
|
||||
new Notice('Aucune occurrence trouvée pour les règles actives.');
|
||||
return;
|
||||
}
|
||||
|
||||
new OccurrencesModal(this.app, this, file, content, occurrences, rules).open();
|
||||
}
|
||||
|
||||
// Appelé par OccurrencesModal après application — met à jour les statuts des règles.
|
||||
async updateMappingStatuses(
|
||||
filePath: string,
|
||||
rules: MappingRule[],
|
||||
occurrences: Occurrence[],
|
||||
decisions: Map<string, 'validated' | 'ignored' | 'false_positive'>
|
||||
): Promise<void> {
|
||||
const folder = this.app.vault.getAbstractFileByPath(this.settings.mappingFolder);
|
||||
if (!folder) return;
|
||||
|
||||
// Pour chaque règle concernée, calculer le statut global
|
||||
for (const rule of rules) {
|
||||
const ruleOccs = occurrences.filter((o) => o.mappingId === rule.id);
|
||||
if (ruleOccs.length === 0) continue;
|
||||
|
||||
const validated = ruleOccs.filter((o) => decisions.get(o.id) === 'validated').length;
|
||||
const ignored = ruleOccs.filter((o) => decisions.get(o.id) === 'ignored').length;
|
||||
const fp = ruleOccs.filter((o) => decisions.get(o.id) === 'false_positive').length;
|
||||
|
||||
let newStatus: MappingStatus;
|
||||
if (validated > 0 && (ignored + fp) > 0) newStatus = 'partial';
|
||||
else if (validated === ruleOccs.length) newStatus = 'validated';
|
||||
else newStatus = 'ignored';
|
||||
|
||||
// Trouver et mettre à jour dans le mapping JSON
|
||||
const location = await this.scopeResolver.findRuleByTerm(rule.source);
|
||||
if (location) {
|
||||
location.store.update(rule.id, { status: newStatus });
|
||||
await this.scopeResolver.saveStore(location.store, location.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyRuleToFile(file: TFile, source: string, replacement: string): Promise<number> {
|
||||
const content = await this.app.vault.read(file);
|
||||
const fakeRule: MappingRule = {
|
||||
id: '_quick', source, replacement, category: 'custom',
|
||||
scope: { type: 'file', path: file.path }, status: 'validated',
|
||||
priority: 0, createdBy: 'user', createdAt: new Date().toISOString(),
|
||||
};
|
||||
const spans = findSpansForRule(content, fakeRule, {
|
||||
caseSensitive: this.settings.caseSensitive,
|
||||
wholeWordOnly: this.settings.wholeWordOnly,
|
||||
});
|
||||
if (spans.length === 0) return 0;
|
||||
spans.sort((a, b) => b.start - a.start);
|
||||
await this.app.vault.modify(file, applySpans(content, spans));
|
||||
return spans.length;
|
||||
}
|
||||
|
||||
// --- Utilitaires ---
|
||||
|
||||
async ensureFolder(folderPath: string): Promise<void> {
|
||||
const parts = folderPath.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!this.app.vault.getAbstractFileByPath(current)) {
|
||||
await this.app.vault.createFolder(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
102
src/mappings/MappingStore.ts
Normal file
102
src/mappings/MappingStore.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { MappingRule, MappingFile, MappingFileSettings, Scope } from '../types';
|
||||
|
||||
function generateId(): string {
|
||||
return `map_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILE_SETTINGS: MappingFileSettings = {
|
||||
caseSensitive: false,
|
||||
accentSensitive: false,
|
||||
wholeWordOnly: true,
|
||||
preserveCase: true,
|
||||
preserveGender: true,
|
||||
preserveAnalyticNotation: true,
|
||||
};
|
||||
|
||||
export class MappingStore {
|
||||
private rules: Map<string, MappingRule> = new Map();
|
||||
private fileSettings: MappingFileSettings;
|
||||
readonly scope: Scope;
|
||||
readonly project?: string;
|
||||
readonly createdAt: string;
|
||||
private updatedAt: string;
|
||||
|
||||
constructor(scope: Scope, project?: string, settings?: Partial<MappingFileSettings>) {
|
||||
this.scope = scope;
|
||||
this.project = project;
|
||||
this.createdAt = new Date().toISOString();
|
||||
this.updatedAt = this.createdAt;
|
||||
this.fileSettings = { ...DEFAULT_FILE_SETTINGS, ...settings };
|
||||
}
|
||||
|
||||
add(partial: Omit<MappingRule, 'id' | 'createdAt'>): MappingRule {
|
||||
const rule: MappingRule = {
|
||||
...partial,
|
||||
id: generateId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.rules.set(rule.id, rule);
|
||||
this.touch();
|
||||
return rule;
|
||||
}
|
||||
|
||||
get(id: string): MappingRule | undefined {
|
||||
return this.rules.get(id);
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<Omit<MappingRule, 'id' | 'createdAt'>>): boolean {
|
||||
const rule = this.rules.get(id);
|
||||
if (!rule) return false;
|
||||
this.rules.set(id, { ...rule, ...changes, updatedAt: new Date().toISOString() });
|
||||
this.touch();
|
||||
return true;
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
const deleted = this.rules.delete(id);
|
||||
if (deleted) this.touch();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
getAll(): MappingRule[] {
|
||||
return Array.from(this.rules.values());
|
||||
}
|
||||
|
||||
// Règles validées applicables à un fichier donné (cascade file → folder → vault)
|
||||
getValidatedFor(filePath: string): MappingRule[] {
|
||||
return this.getAll().filter((r) => {
|
||||
if (r.status !== 'validated') return false;
|
||||
if (r.scope.type === 'vault') return true;
|
||||
if (r.scope.type === 'folder') return filePath.startsWith(r.scope.path ?? '');
|
||||
return r.scope.path === filePath;
|
||||
});
|
||||
}
|
||||
|
||||
get settings(): MappingFileSettings {
|
||||
return this.fileSettings;
|
||||
}
|
||||
|
||||
toJSON(): MappingFile {
|
||||
return {
|
||||
schemaVersion: '1.0.0',
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
project: this.project,
|
||||
scope: this.scope,
|
||||
settings: this.fileSettings,
|
||||
mappings: this.getAll(),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(data: MappingFile): MappingStore {
|
||||
const store = new MappingStore(data.scope, data.project, data.settings);
|
||||
for (const rule of data.mappings) {
|
||||
store.rules.set(rule.id, rule);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this.updatedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
85
src/mappings/ScopeResolver.ts
Normal file
85
src/mappings/ScopeResolver.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { TFile, TFolder, Vault } from 'obsidian';
|
||||
import { MappingStore } from './MappingStore';
|
||||
import type { MappingFile, MappingRule } from '../types';
|
||||
|
||||
export interface RuleLocation {
|
||||
rule: MappingRule;
|
||||
store: MappingStore;
|
||||
filePath: string; // chemin du .mapping.json qui contient la règle
|
||||
}
|
||||
|
||||
// Parcourt le dossier de mappings, charge tous les fichiers JSON valides
|
||||
// et retourne les règles validées applicables au fichier demandé.
|
||||
// La cascade file → folder → vault est gérée par le tri sortRules (scopeWeight).
|
||||
export class ScopeResolver {
|
||||
constructor(
|
||||
private vault: Vault,
|
||||
private mappingFolder: string
|
||||
) {}
|
||||
|
||||
async getRulesFor(filePath: string): Promise<MappingRule[]> {
|
||||
const folder = this.vault.getAbstractFileByPath(this.mappingFolder);
|
||||
if (!(folder instanceof TFolder)) return [];
|
||||
|
||||
const allRules: MappingRule[] = [];
|
||||
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile)) continue;
|
||||
if (!child.name.endsWith('.mapping.json')) continue;
|
||||
|
||||
try {
|
||||
const raw = await this.vault.read(child);
|
||||
const data: MappingFile = JSON.parse(raw);
|
||||
const store = MappingStore.fromJSON(data);
|
||||
allRules.push(...store.getValidatedFor(filePath));
|
||||
} catch {
|
||||
// Ignorer silencieusement les fichiers de mapping malformés
|
||||
}
|
||||
}
|
||||
|
||||
// Dédoublonner par (source, replacement) — la même règle peut apparaître
|
||||
// dans plusieurs fichiers si elle a été copiée à plusieurs niveaux
|
||||
const seen = new Set<string>();
|
||||
return allRules.filter((r) => {
|
||||
const key = `${r.source}||${r.replacement}||${r.scope.type}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Retrouve la première règle dont la source OU le remplacement correspond au terme.
|
||||
// Retourne aussi le store et le chemin JSON pour permettre la modification.
|
||||
async findRuleByTerm(term: string): Promise<RuleLocation | null> {
|
||||
const folder = this.vault.getAbstractFileByPath(this.mappingFolder);
|
||||
if (!(folder instanceof TFolder)) return null;
|
||||
|
||||
const needle = term.toLowerCase();
|
||||
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile) || !child.name.endsWith('.mapping.json')) continue;
|
||||
try {
|
||||
const data: MappingFile = JSON.parse(await this.vault.read(child));
|
||||
const store = MappingStore.fromJSON(data);
|
||||
const rule = store.getAll().find(
|
||||
(r) =>
|
||||
r.source.toLowerCase() === needle ||
|
||||
r.replacement.toLowerCase() === needle
|
||||
);
|
||||
if (rule) return { rule, store, filePath: child.path };
|
||||
} catch {
|
||||
// ignorer les fichiers malformés
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sauvegarde un store modifié dans son fichier JSON.
|
||||
async saveStore(store: MappingStore, filePath: string): Promise<void> {
|
||||
const file = this.vault.getAbstractFileByPath(filePath);
|
||||
const json = JSON.stringify(store.toJSON(), null, 2);
|
||||
if (file instanceof TFile) {
|
||||
await this.vault.modify(file, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/parsers/BaseTranscriptParser.ts
Normal file
8
src/parsers/BaseTranscriptParser.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Interface commune à tous les parsers de transcription (SPECS §11.2)
|
||||
|
||||
export interface BaseTranscriptParser<TDocument> {
|
||||
// Transforme le contenu brut en document structuré
|
||||
parse(content: string): TDocument;
|
||||
// Reconstruit le texte original depuis le document — doit être un round-trip exact
|
||||
reconstruct(doc: TDocument): string;
|
||||
}
|
||||
76
src/parsers/ChatParser.ts
Normal file
76
src/parsers/ChatParser.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { BaseTranscriptParser } from './BaseTranscriptParser';
|
||||
|
||||
// Types de lignes du format CHAT / CHA (SPECS §3.3)
|
||||
export type ChatLineType = 'meta' | 'turn' | 'dependent' | 'continuation' | 'blank';
|
||||
|
||||
export interface ChatLine {
|
||||
type: ChatLineType;
|
||||
raw: string; // ligne originale complète — garantit le round-trip
|
||||
// Présents uniquement pour type === 'turn'
|
||||
speaker?: string; // identifiant du locuteur (ex. 'INV', 'PAR')
|
||||
prefix?: string; // "*SPEAKER: " — tout ce qui précède le contenu remplaçable
|
||||
content?: string; // texte du tour de parole (seule zone remplaçable)
|
||||
}
|
||||
|
||||
export interface ChatDocument {
|
||||
lines: ChatLine[];
|
||||
trailingNewline: boolean;
|
||||
}
|
||||
|
||||
export class ChatParser implements BaseTranscriptParser<ChatDocument> {
|
||||
parse(content: string): ChatDocument {
|
||||
const trailingNewline = content.endsWith('\n');
|
||||
const normalized = content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n');
|
||||
|
||||
const rawLines = normalized.split('\n');
|
||||
|
||||
// La dernière entrée après split est vide si le fichier finit par \n
|
||||
// On la retire pour ne pas créer une ligne 'blank' fantôme
|
||||
const linesToParse =
|
||||
trailingNewline && rawLines[rawLines.length - 1] === ''
|
||||
? rawLines.slice(0, -1)
|
||||
: rawLines;
|
||||
|
||||
return { lines: linesToParse.map((raw) => this.parseLine(raw)), trailingNewline };
|
||||
}
|
||||
|
||||
private parseLine(raw: string): ChatLine {
|
||||
if (raw === '') return { type: 'blank', raw };
|
||||
if (raw.startsWith('@')) return { type: 'meta', raw };
|
||||
if (raw.startsWith('%')) return { type: 'dependent', raw };
|
||||
if (raw.startsWith('\t')) return { type: 'continuation', raw };
|
||||
|
||||
if (raw.startsWith('*')) {
|
||||
const colonIdx = raw.indexOf(':');
|
||||
// colonIdx > 1 : il faut au moins un caractère de locuteur entre * et :
|
||||
if (colonIdx > 1) {
|
||||
const speaker = raw.slice(1, colonIdx);
|
||||
const afterColon = raw.slice(colonIdx + 1);
|
||||
// préserver le séparateur exact (espace, tabulation, ou les deux)
|
||||
const sep = afterColon.match(/^([\t ]*)/)?.[1] ?? '';
|
||||
const prefix = raw.slice(0, colonIdx + 1) + sep;
|
||||
const content = afterColon.slice(sep.length);
|
||||
return { type: 'turn', raw, speaker, prefix, content };
|
||||
}
|
||||
}
|
||||
|
||||
// Ligne non reconnue → préserver telle quelle
|
||||
return { type: 'meta', raw };
|
||||
}
|
||||
|
||||
reconstruct(doc: ChatDocument): string {
|
||||
const body = doc.lines
|
||||
.map((line) => {
|
||||
// Reconstruire les tours depuis prefix + content (éventuellement modifié)
|
||||
if (line.type === 'turn' && line.prefix !== undefined && line.content !== undefined) {
|
||||
return line.prefix + line.content;
|
||||
}
|
||||
return line.raw;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return doc.trailingNewline ? body + '\n' : body;
|
||||
}
|
||||
}
|
||||
49
src/parsers/SrtParser.ts
Normal file
49
src/parsers/SrtParser.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { BaseTranscriptParser } from './BaseTranscriptParser';
|
||||
|
||||
export interface SrtBlock {
|
||||
index: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
lines: string[]; // lignes de texte remplaçables uniquement
|
||||
}
|
||||
|
||||
export interface SrtDocument {
|
||||
blocks: SrtBlock[];
|
||||
// préservé pour le round-trip exact
|
||||
trailingNewline: boolean;
|
||||
}
|
||||
|
||||
export class SrtParser implements BaseTranscriptParser<SrtDocument> {
|
||||
parse(content: string): SrtDocument {
|
||||
const trailingNewline = content.endsWith('\n');
|
||||
// normaliser les fins de ligne et retirer le blanc final avant de découper en blocs
|
||||
// (sinon le dernier bloc capture le \n terminal dans ses lines[])
|
||||
const normalized = content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.trimEnd();
|
||||
|
||||
const rawBlocks = normalized.split(/\n\n+/).filter((b) => b.trim() !== '');
|
||||
|
||||
const blocks: SrtBlock[] = rawBlocks.map((rawBlock) => {
|
||||
const lines = rawBlock.split('\n');
|
||||
const index = parseInt(lines[0].trim(), 10);
|
||||
|
||||
// "HH:MM:SS,mmm --> HH:MM:SS,mmm"
|
||||
const arrowPos = lines[1].indexOf(' --> ');
|
||||
const startTime = lines[1].slice(0, arrowPos);
|
||||
const endTime = lines[1].slice(arrowPos + 5);
|
||||
|
||||
return { index, startTime, endTime, lines: lines.slice(2) };
|
||||
});
|
||||
|
||||
return { blocks, trailingNewline };
|
||||
}
|
||||
|
||||
reconstruct(doc: SrtDocument): string {
|
||||
const body = doc.blocks
|
||||
.map((b) => `${b.index}\n${b.startTime} --> ${b.endTime}\n${b.lines.join('\n')}`)
|
||||
.join('\n\n');
|
||||
return doc.trailingNewline ? body + '\n' : body;
|
||||
}
|
||||
}
|
||||
83
src/parsers/TranscriptConverter.ts
Normal file
83
src/parsers/TranscriptConverter.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { SrtDocument } from './SrtParser';
|
||||
import type { ChatDocument, ChatLine } from './ChatParser';
|
||||
|
||||
// Converts a SrtDocument to a structured, Obsidian-readable Markdown string.
|
||||
// Timestamps are preserved as italic headers; text lines are the editable content.
|
||||
// Frontmatter records the original format for future re-export.
|
||||
export function srtToMarkdown(doc: SrtDocument, sourceName: string): string {
|
||||
const lines: string[] = [
|
||||
'---',
|
||||
`pseudobs-format: srt`,
|
||||
`pseudobs-source: "${sourceName}"`,
|
||||
'---',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const block of doc.blocks) {
|
||||
lines.push(`**[${block.index}]** *${block.startTime} → ${block.endTime}*`);
|
||||
lines.push(...block.lines);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Retirer la dernière ligne vide superflue, puis ajouter \n final
|
||||
while (lines[lines.length - 1] === '') lines.pop();
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Converts a ChatDocument to a structured, Obsidian-readable Markdown string.
|
||||
// Meta (@) and dependent (%) lines become Markdown blockquotes (gray in preview).
|
||||
// Speaker turns become **SPEAKER** : text (bold speaker name, editable content).
|
||||
export function chatToMarkdown(doc: ChatDocument, sourceName: string): string {
|
||||
const lines: string[] = [
|
||||
'---',
|
||||
`pseudobs-format: chat`,
|
||||
`pseudobs-source: "${sourceName}"`,
|
||||
'---',
|
||||
'',
|
||||
];
|
||||
|
||||
let prevGroup: 'structural' | 'turn' | null = null;
|
||||
|
||||
for (const chatLine of doc.lines) {
|
||||
const group = lineGroup(chatLine);
|
||||
|
||||
// Ligne vide entre groupes structurels et tours de parole
|
||||
if (prevGroup !== null && prevGroup !== group) {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
switch (chatLine.type) {
|
||||
case 'meta':
|
||||
case 'dependent':
|
||||
lines.push(`> ${chatLine.raw}`);
|
||||
break;
|
||||
case 'turn':
|
||||
lines.push(`**${chatLine.speaker}** : ${chatLine.content}`);
|
||||
break;
|
||||
case 'continuation':
|
||||
// Rattacher au tour précédent (rare en pratique)
|
||||
if (lines.length > 0) {
|
||||
lines[lines.length - 1] += ` ${chatLine.raw.trim()}`;
|
||||
}
|
||||
break;
|
||||
case 'blank':
|
||||
// Les blancs du source sont absorbés par la logique de groupe ci-dessus
|
||||
break;
|
||||
}
|
||||
|
||||
if (group !== null) prevGroup = group;
|
||||
}
|
||||
|
||||
while (lines[lines.length - 1] === '') lines.pop();
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function lineGroup(line: ChatLine): 'structural' | 'turn' | null {
|
||||
if (line.type === 'meta' || line.type === 'dependent') return 'structural';
|
||||
if (line.type === 'turn' || line.type === 'continuation') return 'turn';
|
||||
return null;
|
||||
}
|
||||
24
src/pseudonymizer/PseudonymizationEngine.ts
Normal file
24
src/pseudonymizer/PseudonymizationEngine.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { MappingRule } from '../types';
|
||||
import {
|
||||
buildReplacementPlan,
|
||||
DEFAULT_PLANNER_SETTINGS,
|
||||
type PlannerSettings,
|
||||
} from './ReplacementPlanner';
|
||||
import { resolveSpans, applySpans, type ReplacementMarker } from './SpanProtector';
|
||||
|
||||
export class PseudonymizationEngine {
|
||||
private settings: PlannerSettings;
|
||||
|
||||
constructor(settings: PlannerSettings = DEFAULT_PLANNER_SETTINGS) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
// Applique toutes les règles validées sur le texte et retourne le texte pseudonymisé.
|
||||
// Si marker est fourni, chaque remplacement est encadré par marker.open / marker.close.
|
||||
pseudonymize(text: string, rules: MappingRule[], marker?: ReplacementMarker): string {
|
||||
const validated = rules.filter((r) => r.status === 'validated');
|
||||
const candidates = buildReplacementPlan(text, validated, this.settings);
|
||||
const resolved = resolveSpans(candidates);
|
||||
return applySpans(text, resolved, marker);
|
||||
}
|
||||
}
|
||||
81
src/pseudonymizer/ReplacementPlanner.ts
Normal file
81
src/pseudonymizer/ReplacementPlanner.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { MappingRule, ReplacementSpan, Scope } from '../types';
|
||||
|
||||
export interface PlannerSettings {
|
||||
caseSensitive: boolean;
|
||||
wholeWordOnly: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLANNER_SETTINGS: PlannerSettings = {
|
||||
caseSensitive: false,
|
||||
wholeWordOnly: true,
|
||||
};
|
||||
|
||||
// Tri des règles : priority desc → longueur source desc → portée locale desc (SPECS §12.3)
|
||||
export function sortRules(rules: MappingRule[]): MappingRule[] {
|
||||
return [...rules].sort((a, b) => {
|
||||
if (b.priority !== a.priority) return b.priority - a.priority;
|
||||
if (b.source.length !== a.source.length) return b.source.length - a.source.length;
|
||||
return scopeWeight(b.scope) - scopeWeight(a.scope);
|
||||
});
|
||||
}
|
||||
|
||||
function scopeWeight(scope: Scope): number {
|
||||
if (scope.type === 'file') return 3;
|
||||
if (scope.type === 'folder') return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Inclut les lettres latines accentuées pour ne pas couper les mots français
|
||||
const WORD_CHAR = /[\wÀ-ɏ]/;
|
||||
|
||||
function isWholeWord(text: string, start: number, end: number): boolean {
|
||||
if (start > 0 && WORD_CHAR.test(text[start - 1])) return false;
|
||||
if (end < text.length && WORD_CHAR.test(text[end])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trouve toutes les occurrences d'une règle dans le texte
|
||||
export function findSpansForRule(
|
||||
text: string,
|
||||
rule: MappingRule,
|
||||
settings: PlannerSettings
|
||||
): ReplacementSpan[] {
|
||||
const spans: ReplacementSpan[] = [];
|
||||
const needle = settings.caseSensitive ? rule.source : rule.source.toLowerCase();
|
||||
const haystack = settings.caseSensitive ? text : text.toLowerCase();
|
||||
const sourceLen = needle.length;
|
||||
|
||||
let pos = 0;
|
||||
while (pos <= haystack.length - sourceLen) {
|
||||
const idx = haystack.indexOf(needle, pos);
|
||||
if (idx === -1) break;
|
||||
|
||||
if (!settings.wholeWordOnly || isWholeWord(text, idx, idx + sourceLen)) {
|
||||
spans.push({
|
||||
start: idx,
|
||||
end: idx + sourceLen,
|
||||
source: text.slice(idx, idx + sourceLen),
|
||||
replacement: rule.replacement,
|
||||
mappingId: rule.id,
|
||||
priority: rule.priority,
|
||||
});
|
||||
}
|
||||
|
||||
pos = idx + 1;
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
// Construit le plan de remplacement complet pour un texte (SPECS §12.2)
|
||||
export function buildReplacementPlan(
|
||||
text: string,
|
||||
rules: MappingRule[],
|
||||
settings: PlannerSettings = DEFAULT_PLANNER_SETTINGS
|
||||
): ReplacementSpan[] {
|
||||
const allSpans: ReplacementSpan[] = [];
|
||||
for (const rule of sortRules(rules)) {
|
||||
allSpans.push(...findSpansForRule(text, rule, settings));
|
||||
}
|
||||
return allSpans;
|
||||
}
|
||||
46
src/pseudonymizer/SpanProtector.ts
Normal file
46
src/pseudonymizer/SpanProtector.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { ReplacementSpan } from '../types';
|
||||
|
||||
// Résout les chevauchements : garde les spans les plus prioritaires / les plus longs,
|
||||
// rejette ceux qui chevauchent un span déjà accepté. (SPECS §12.4)
|
||||
export function resolveSpans(candidates: ReplacementSpan[]): ReplacementSpan[] {
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
if (b.priority !== a.priority) return b.priority - a.priority;
|
||||
const lenB = b.end - b.start;
|
||||
const lenA = a.end - a.start;
|
||||
if (lenB !== lenA) return lenB - lenA;
|
||||
return a.start - b.start; // à priorité et longueur égales : position croissante
|
||||
});
|
||||
|
||||
const accepted: ReplacementSpan[] = [];
|
||||
for (const candidate of sorted) {
|
||||
const overlaps = accepted.some(
|
||||
(s) => candidate.start < s.end && candidate.end > s.start
|
||||
);
|
||||
if (!overlaps) accepted.push(candidate);
|
||||
}
|
||||
|
||||
// Trier de droite à gauche pour que l'application ne décale pas les indices
|
||||
return accepted.sort((a, b) => b.start - a.start);
|
||||
}
|
||||
|
||||
export interface ReplacementMarker {
|
||||
open: string;
|
||||
close: string;
|
||||
}
|
||||
|
||||
// Applique les spans résolus sur le texte de droite à gauche. (SPECS §12.5)
|
||||
// Si marker est fourni, chaque remplacement est encadré par marker.open / marker.close.
|
||||
export function applySpans(
|
||||
text: string,
|
||||
spans: ReplacementSpan[],
|
||||
marker?: ReplacementMarker
|
||||
): string {
|
||||
let output = text;
|
||||
for (const span of spans) {
|
||||
const value = marker
|
||||
? `${marker.open}${span.replacement}${marker.close}`
|
||||
: span.replacement;
|
||||
output = output.slice(0, span.start) + value + output.slice(span.end);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
49
src/scanner/OccurrenceScanner.ts
Normal file
49
src/scanner/OccurrenceScanner.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { MappingRule, Occurrence } from '../types';
|
||||
import { findSpansForRule, sortRules, type PlannerSettings } from '../pseudonymizer/ReplacementPlanner';
|
||||
import { DEFAULT_PLANNER_SETTINGS } from '../pseudonymizer/ReplacementPlanner';
|
||||
|
||||
const CONTEXT_LEN = 45;
|
||||
let _counter = 0;
|
||||
|
||||
function lineOf(text: string, pos: number): number {
|
||||
return text.slice(0, pos).split('\n').length;
|
||||
}
|
||||
|
||||
function context(text: string, start: number, end: number) {
|
||||
return {
|
||||
before: text.slice(Math.max(0, start - CONTEXT_LEN), start),
|
||||
after: text.slice(end, Math.min(text.length, end + CONTEXT_LEN)),
|
||||
};
|
||||
}
|
||||
|
||||
// Retourne toutes les occurrences des sources des règles dans le contenu,
|
||||
// triées par position, avec contexte gauche/droite et numéro de ligne.
|
||||
export function scanOccurrences(
|
||||
content: string,
|
||||
filePath: string,
|
||||
rules: MappingRule[],
|
||||
settings: PlannerSettings = DEFAULT_PLANNER_SETTINGS
|
||||
): Occurrence[] {
|
||||
const occurrences: Occurrence[] = [];
|
||||
|
||||
for (const rule of sortRules(rules)) {
|
||||
for (const span of findSpansForRule(content, rule, settings)) {
|
||||
const { before, after } = context(content, span.start, span.end);
|
||||
occurrences.push({
|
||||
id: `occ_${Date.now()}_${++_counter}`,
|
||||
file: filePath,
|
||||
line: lineOf(content, span.start),
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
text: span.source,
|
||||
contextBefore: before,
|
||||
contextAfter: after,
|
||||
category: rule.category,
|
||||
mappingId: rule.id,
|
||||
status: 'suggested',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return occurrences.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
187
src/settings.ts
Normal file
187
src/settings.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import type PseudObsPlugin from './main';
|
||||
|
||||
export interface PseudObsSettings {
|
||||
transcriptionsFolder: string;
|
||||
mappingFolder: string;
|
||||
dictionariesFolder: string;
|
||||
exportsFolder: string;
|
||||
reportsFolder: string;
|
||||
caseSensitive: boolean;
|
||||
accentSensitive: boolean;
|
||||
wholeWordOnly: boolean;
|
||||
preserveCase: boolean;
|
||||
preserveAnalyticNotation: boolean;
|
||||
warnIfSyncedFolder: boolean;
|
||||
useMarkerInExport: boolean;
|
||||
markerOpen: string;
|
||||
markerClose: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PseudObsSettings = {
|
||||
transcriptionsFolder: 'Transcriptions',
|
||||
mappingFolder: '_pseudonymisation/mappings',
|
||||
dictionariesFolder: '_pseudonymisation/dictionaries',
|
||||
exportsFolder: '_pseudonymisation/exports',
|
||||
reportsFolder: '_pseudonymisation/reports',
|
||||
caseSensitive: false,
|
||||
accentSensitive: false,
|
||||
wholeWordOnly: true,
|
||||
preserveCase: true,
|
||||
preserveAnalyticNotation: true,
|
||||
warnIfSyncedFolder: true,
|
||||
useMarkerInExport: false,
|
||||
markerOpen: '⟦',
|
||||
markerClose: '⟧',
|
||||
};
|
||||
|
||||
export class PseudObsSettingTab extends PluginSettingTab {
|
||||
plugin: PseudObsPlugin;
|
||||
|
||||
constructor(app: App, plugin: PseudObsPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl('h2', { text: 'PseudObsidian-ization' });
|
||||
|
||||
containerEl.createEl('h3', { text: 'Dossiers' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Transcriptions importées')
|
||||
.setDesc('Dossier de destination pour les fichiers ajoutés via la commande "Ajouter une transcription"')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.transcriptionsFolder).onChange(async (value) => {
|
||||
this.plugin.settings.transcriptionsFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Tables de correspondance')
|
||||
.setDesc('Chemin relatif dans le vault')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.mappingFolder).onChange(async (value) => {
|
||||
this.plugin.settings.mappingFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Dictionnaires')
|
||||
.setDesc('Chemin relatif dans le vault')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.dictionariesFolder).onChange(async (value) => {
|
||||
this.plugin.settings.dictionariesFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Exports')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.exportsFolder).onChange(async (value) => {
|
||||
this.plugin.settings.exportsFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl('h3', { text: 'Remplacement' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Sensible à la casse')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.caseSensitive).onChange(async (value) => {
|
||||
this.plugin.settings.caseSensitive = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Sensible aux accents')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.accentSensitive).onChange(async (value) => {
|
||||
this.plugin.settings.accentSensitive = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Mots entiers uniquement')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.wholeWordOnly).onChange(async (value) => {
|
||||
this.plugin.settings.wholeWordOnly = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Préserver la casse')
|
||||
.setDesc('Adapter la casse du remplacement à celle de la source (Jean → Pierre, JEAN → PIERRE)')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.preserveCase).onChange(async (value) => {
|
||||
this.plugin.settings.preserveCase = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Préserver les notations analytiques')
|
||||
.setDesc('Ne jamais remplacer les symboles Jefferson / ICOR')
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.preserveAnalyticNotation)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.preserveAnalyticNotation = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl('h3', { text: 'Marqueur d\'export' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Ajouter un marqueur autour des pseudonymes dans l\'export')
|
||||
.setDesc('Permet d\'identifier visuellement les termes pseudonymisés dans le fichier exporté')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.useMarkerInExport).onChange(async (value) => {
|
||||
this.plugin.settings.useMarkerInExport = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Marqueur ouvrant')
|
||||
.setDesc('Exemple : ⟦ [ { «')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.markerOpen).onChange(async (value) => {
|
||||
this.plugin.settings.markerOpen = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Marqueur fermant')
|
||||
.setDesc('Exemple : ⟧ ] } »')
|
||||
.addText((text) =>
|
||||
text.setValue(this.plugin.settings.markerClose).onChange(async (value) => {
|
||||
this.plugin.settings.markerClose = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl('h3', { text: 'Sécurité' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Avertir si le dossier est synchronisé')
|
||||
.setDesc('Alerter si les tables de correspondance sont dans un dossier Git, iCloud ou Synology Drive')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.warnIfSyncedFolder).onChange(async (value) => {
|
||||
this.plugin.settings.warnIfSyncedFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
122
src/types.ts
Normal file
122
src/types.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Types partagés — SPECS §11.3
|
||||
|
||||
export type ScopeType = 'file' | 'folder' | 'vault';
|
||||
|
||||
export type MappingStatus =
|
||||
| 'suggested'
|
||||
| 'validated'
|
||||
| 'ignored'
|
||||
| 'partial'
|
||||
| 'conflict'
|
||||
| 'disabled'
|
||||
| 'needs_review';
|
||||
|
||||
export type EntityCategory =
|
||||
| 'first_name'
|
||||
| 'last_name'
|
||||
| 'full_name'
|
||||
| 'place'
|
||||
| 'institution'
|
||||
| 'date'
|
||||
| 'age'
|
||||
| 'profession'
|
||||
| 'custom';
|
||||
|
||||
export interface Scope {
|
||||
type: ScopeType;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface OccurrenceRef {
|
||||
file: string;
|
||||
line: number;
|
||||
start: number;
|
||||
end: number;
|
||||
status: MappingStatus;
|
||||
}
|
||||
|
||||
export interface MappingRule {
|
||||
id: string;
|
||||
source: string;
|
||||
replacement: string;
|
||||
category: EntityCategory;
|
||||
scope: Scope;
|
||||
status: MappingStatus;
|
||||
// Entier libre, comme un z-index CSS. Défaut 0. Plus élevé = appliqué en premier.
|
||||
priority: number;
|
||||
sourceDictionary?: string;
|
||||
createdBy: 'user' | 'dictionary' | 'scanner';
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt?: string;
|
||||
notes?: string;
|
||||
occurrences?: OccurrenceRef[];
|
||||
}
|
||||
|
||||
export interface Occurrence {
|
||||
id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
contextBefore: string;
|
||||
contextAfter: string;
|
||||
category?: EntityCategory;
|
||||
mappingId?: string;
|
||||
status: MappingStatus;
|
||||
}
|
||||
|
||||
export interface ReplacementSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
source: string;
|
||||
replacement: string;
|
||||
mappingId: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
// Format d'un fichier de table de correspondance (SPECS §5.2)
|
||||
export interface MappingFile {
|
||||
schemaVersion: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
project?: string;
|
||||
scope: Scope;
|
||||
settings?: MappingFileSettings;
|
||||
mappings: MappingRule[];
|
||||
}
|
||||
|
||||
export interface MappingFileSettings {
|
||||
caseSensitive: boolean;
|
||||
accentSensitive: boolean;
|
||||
wholeWordOnly: boolean;
|
||||
preserveCase: boolean;
|
||||
preserveGender: boolean;
|
||||
preserveAnalyticNotation: boolean;
|
||||
}
|
||||
|
||||
// Format d'une entrée de dictionnaire (SPECS §6.3)
|
||||
export interface DictionaryEntry {
|
||||
value: string;
|
||||
type: EntityCategory;
|
||||
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')
|
||||
}
|
||||
|
||||
// Format d'un fichier dictionnaire (SPECS §6.3)
|
||||
export interface DictionaryFile {
|
||||
schemaVersion: string;
|
||||
dictionaryId: string;
|
||||
label: string;
|
||||
type: EntityCategory;
|
||||
language: string;
|
||||
source: string;
|
||||
license?: string;
|
||||
entries: DictionaryEntry[];
|
||||
}
|
||||
120
src/ui/EditRuleModal.ts
Normal file
120
src/ui/EditRuleModal.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { App, Modal, Notice, Setting } from 'obsidian';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import type { EntityCategory, MappingRule, ScopeType } from '../types';
|
||||
import type { RuleLocation } from '../mappings/ScopeResolver';
|
||||
|
||||
export class EditRuleModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
private location: RuleLocation;
|
||||
|
||||
// Valeurs éditables — initialisées depuis la règle existante
|
||||
private replacement: string;
|
||||
private category: EntityCategory;
|
||||
private scopeType: ScopeType;
|
||||
private priority: number;
|
||||
|
||||
constructor(app: App, plugin: PseudObsPlugin, location: RuleLocation) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.location = location;
|
||||
const { rule } = location;
|
||||
this.replacement = rule.replacement;
|
||||
this.category = rule.category;
|
||||
this.scopeType = rule.scope.type;
|
||||
this.priority = rule.priority;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
const { rule } = this.location;
|
||||
contentEl.createEl('h2', { text: 'Modifier la règle' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Source')
|
||||
.setDesc('Non modifiable — créez une nouvelle règle pour changer la source')
|
||||
.addText((t) => {
|
||||
t.setValue(rule.source).setDisabled(true);
|
||||
t.inputEl.style.opacity = '0.6';
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Remplacement')
|
||||
.addText((t) =>
|
||||
t.setValue(this.replacement).onChange((v) => (this.replacement = v))
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Catégorie')
|
||||
.addDropdown((d) => {
|
||||
const options: Record<EntityCategory, string> = {
|
||||
first_name: 'Prénom', last_name: 'Nom de famille', full_name: 'Nom complet',
|
||||
place: 'Lieu', institution: 'Institution', date: 'Date',
|
||||
age: 'Âge', profession: 'Profession', custom: 'Autre',
|
||||
};
|
||||
for (const [value, label] of Object.entries(options)) d.addOption(value, label);
|
||||
d.setValue(this.category);
|
||||
d.onChange((v) => (this.category = v as EntityCategory));
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Portée')
|
||||
.addDropdown((d) => {
|
||||
d.addOption('file', 'Ce fichier uniquement');
|
||||
d.addOption('folder', 'Ce dossier');
|
||||
d.addOption('vault', 'Tout le vault');
|
||||
d.setValue(this.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')
|
||||
.addText((t) =>
|
||||
t.setValue(String(this.priority)).onChange((v) => (this.priority = parseInt(v, 10) || 0))
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('Enregistrer').setCta().onClick(() => this.save())
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Supprimer la règle')
|
||||
.setWarning()
|
||||
.onClick(() => this.delete())
|
||||
);
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (!this.replacement.trim()) {
|
||||
new Notice('Le remplacement est obligatoire.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { store, filePath, rule } = this.location;
|
||||
store.update(rule.id, {
|
||||
replacement: this.replacement.trim(),
|
||||
category: this.category,
|
||||
scope: { ...rule.scope, type: this.scopeType },
|
||||
priority: this.priority,
|
||||
});
|
||||
|
||||
await this.plugin.scopeResolver.saveStore(store, filePath);
|
||||
new Notice(`✓ Règle mise à jour : "${rule.source}" → "${this.replacement.trim()}"`);
|
||||
this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async delete(): Promise<void> {
|
||||
const { store, filePath, rule } = this.location;
|
||||
store.remove(rule.id);
|
||||
await this.plugin.scopeResolver.saveStore(store, filePath);
|
||||
new Notice(`✓ Règle supprimée : "${rule.source}"`);
|
||||
this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
258
src/ui/OccurrencesModal.ts
Normal file
258
src/ui/OccurrencesModal.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { App, Modal, Notice, Setting, TFile } from 'obsidian';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import type { MappingRule, Occurrence, MappingStatus } from '../types';
|
||||
import { resolveSpans, applySpans } from '../pseudonymizer/SpanProtector';
|
||||
import type { ReplacementSpan } from '../types';
|
||||
|
||||
type Decision = 'validated' | 'ignored' | 'false_positive';
|
||||
|
||||
interface CardRef {
|
||||
card: HTMLElement;
|
||||
buttons: Map<Decision, HTMLElement>;
|
||||
arrow: HTMLElement;
|
||||
resLine: HTMLElement;
|
||||
statusLabel: HTMLElement;
|
||||
}
|
||||
|
||||
export class OccurrencesModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
private file: TFile;
|
||||
private content: string;
|
||||
private occurrences: Occurrence[];
|
||||
private rules: MappingRule[];
|
||||
private decisions: Map<string, Decision> = new Map();
|
||||
// Références stables — on ne recrée jamais les cartes
|
||||
private cardRefs: Map<string, CardRef> = new Map();
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: PseudObsPlugin,
|
||||
file: TFile,
|
||||
content: string,
|
||||
occurrences: Occurrence[],
|
||||
rules: MappingRule[]
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.file = file;
|
||||
this.content = content;
|
||||
this.occurrences = occurrences;
|
||||
this.rules = rules;
|
||||
for (const occ of occurrences) this.decisions.set(occ.id, 'validated');
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: `Scanner — ${this.file.name}` });
|
||||
|
||||
const n = this.occurrences.length;
|
||||
contentEl.createEl('p', {
|
||||
text: `${n} occurrence${n > 1 ? 's' : ''} trouvée${n > 1 ? 's' : ''} pour ${this.countRules()} règle${this.countRules() > 1 ? 's' : ''}.`,
|
||||
});
|
||||
|
||||
// Légende
|
||||
const legend = contentEl.createDiv();
|
||||
legend.style.cssText = 'display:flex;gap:16px;font-size:.8em;opacity:.65;margin-bottom:8px;flex-wrap:wrap;';
|
||||
for (const [icon, label, color] of [
|
||||
['✓', 'Valider le remplacement', 'rgba(50,205,90,.7)'],
|
||||
['✗', 'Conserver l\'original', 'rgba(150,150,150,.7)'],
|
||||
['⚠', 'Faux positif — exclure', 'rgba(255,80,80,.6)'],
|
||||
] as [string, string, string][]) {
|
||||
const item = legend.createSpan();
|
||||
item.style.cssText = `display:inline-flex;align-items:center;gap:4px;`;
|
||||
const badge = item.createSpan({ text: icon });
|
||||
badge.style.cssText = `background:${color};border-radius:3px;padding:0 5px;font-weight:700;`;
|
||||
item.createSpan({ text: ` ${label}` });
|
||||
}
|
||||
|
||||
// Boutons globaux
|
||||
new Setting(contentEl)
|
||||
.addButton((b) =>
|
||||
b.setButtonText('✓ Tout valider').onClick(() => {
|
||||
for (const occ of this.occurrences) this.decisions.set(occ.id, 'validated');
|
||||
this.updateAllCards();
|
||||
})
|
||||
)
|
||||
.addButton((b) =>
|
||||
b.setButtonText('✗ Tout ignorer').onClick(() => {
|
||||
for (const occ of this.occurrences) this.decisions.set(occ.id, 'ignored');
|
||||
this.updateAllCards();
|
||||
})
|
||||
);
|
||||
|
||||
// Rendre les cartes une seule fois
|
||||
this.renderAllCards(contentEl);
|
||||
|
||||
contentEl.createEl('hr');
|
||||
|
||||
new Setting(contentEl).addButton((b) =>
|
||||
b.setButtonText('Appliquer').setCta().onClick(() => this.apply())
|
||||
);
|
||||
}
|
||||
|
||||
private countRules(): number {
|
||||
return new Set(this.occurrences.map((o) => o.mappingId)).size;
|
||||
}
|
||||
|
||||
// Construit toutes les cartes une seule fois — ne sera plus jamais appelé après
|
||||
private renderAllCards(container: HTMLElement): void {
|
||||
const byRule = new Map<string, Occurrence[]>();
|
||||
for (const occ of this.occurrences) {
|
||||
const key = occ.mappingId ?? '';
|
||||
if (!byRule.has(key)) byRule.set(key, []);
|
||||
byRule.get(key)!.push(occ);
|
||||
}
|
||||
|
||||
for (const [mappingId, occs] of byRule) {
|
||||
const rule = this.rules.find((r) => r.id === mappingId);
|
||||
if (!rule) continue;
|
||||
|
||||
const group = container.createDiv();
|
||||
group.createEl('div', {
|
||||
text: `${rule.source} → ${rule.replacement}`,
|
||||
}).style.cssText = 'font-weight:600;font-size:.9em;padding:4px 8px;background:var(--background-secondary);border-radius:4px;margin:12px 0 6px;';
|
||||
|
||||
for (const occ of occs) {
|
||||
this.buildCard(group, occ, rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildCard(container: HTMLElement, occ: Occurrence, rule: MappingRule): void {
|
||||
const decision = this.decisions.get(occ.id) ?? 'validated';
|
||||
|
||||
const card = container.createDiv();
|
||||
this.applyCardStyle(card, decision);
|
||||
|
||||
// Ligne source : terme surligné en jaune
|
||||
const srcLine = card.createDiv();
|
||||
srcLine.style.cssText = 'font-family:var(--font-monospace);font-size:.85em;line-height:1.7;white-space:pre-wrap;word-break:break-word;';
|
||||
this.ctxSpan(srcLine, occ.contextBefore);
|
||||
const termSpan = srcLine.createSpan({ text: occ.text });
|
||||
termSpan.style.cssText = 'background:rgba(255,210,0,.6);border-radius:3px;padding:1px 5px;font-weight:700;';
|
||||
this.ctxSpan(srcLine, occ.contextAfter);
|
||||
|
||||
// Flèche + ligne résultat (masquées si ignoré ou faux positif)
|
||||
const arrow = card.createDiv();
|
||||
arrow.style.cssText = 'font-size:.75em;opacity:.35;line-height:1.2;margin:1px 0;user-select:none;';
|
||||
arrow.setText('↓');
|
||||
|
||||
const resLine = card.createDiv();
|
||||
resLine.style.cssText = 'font-family:var(--font-monospace);font-size:.85em;line-height:1.7;white-space:pre-wrap;word-break:break-word;opacity:.8;';
|
||||
this.ctxSpan(resLine, occ.contextBefore);
|
||||
const replSpan = resLine.createSpan({ text: rule.replacement });
|
||||
replSpan.style.cssText = 'background:rgba(50,205,90,.55);border-radius:3px;padding:1px 5px;font-weight:700;';
|
||||
this.ctxSpan(resLine, occ.contextAfter);
|
||||
|
||||
// Label contextuel affiché quand la ligne résultat est masquée
|
||||
const statusLabel = card.createDiv();
|
||||
statusLabel.style.cssText = 'font-size:.8em;font-style:italic;opacity:.55;margin:2px 0 4px;display:none;';
|
||||
|
||||
// Méta
|
||||
const meta = card.createEl('small');
|
||||
meta.style.cssText = 'display:block;font-size:.75em;opacity:.45;margin-top:4px;';
|
||||
meta.setText(`ligne ${occ.line}`);
|
||||
|
||||
// Boutons — créés une seule fois, mis à jour via updateCard()
|
||||
const actions = card.createDiv();
|
||||
actions.style.cssText = 'display:flex;gap:6px;margin-top:6px;';
|
||||
|
||||
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;
|
||||
this.applyBtnStyle(btn, value === decision);
|
||||
btn.addEventListener('click', () => {
|
||||
this.decisions.set(occ.id, value);
|
||||
this.updateCard(occ.id);
|
||||
});
|
||||
btnRefs.set(value, btn);
|
||||
}
|
||||
|
||||
this.cardRefs.set(occ.id, { card, buttons: btnRefs, arrow, resLine, statusLabel });
|
||||
// Appliquer l'état initial
|
||||
this.updateCard(occ.id);
|
||||
}
|
||||
|
||||
// Met à jour UNE carte sans toucher au DOM — styles + résultat en direct
|
||||
private updateCard(occId: string): void {
|
||||
const ref = this.cardRefs.get(occId);
|
||||
if (!ref) return;
|
||||
const decision = this.decisions.get(occId) ?? 'validated';
|
||||
|
||||
this.applyCardStyle(ref.card, decision);
|
||||
|
||||
for (const [value, btn] of ref.buttons) {
|
||||
this.applyBtnStyle(btn, value === decision);
|
||||
}
|
||||
|
||||
// Afficher la ligne résultat seulement si l'occurrence est validée
|
||||
const show = decision === 'validated';
|
||||
ref.arrow.style.display = show ? '' : 'none';
|
||||
ref.resLine.style.display = show ? '' : 'none';
|
||||
|
||||
// Label contextuel pour les cas ignoré / faux positif
|
||||
const labels: Record<Decision, string> = {
|
||||
validated: '',
|
||||
ignored: 'Conservé tel quel dans ce fichier',
|
||||
false_positive: 'Faux positif — exclu du remplacement',
|
||||
};
|
||||
ref.statusLabel.style.display = show ? 'none' : '';
|
||||
ref.statusLabel.setText(labels[decision]);
|
||||
}
|
||||
|
||||
// Met à jour TOUTES les cartes
|
||||
private updateAllCards(): void {
|
||||
for (const occId of this.cardRefs.keys()) {
|
||||
this.updateCard(occId);
|
||||
}
|
||||
}
|
||||
|
||||
private applyCardStyle(card: HTMLElement, decision: Decision): void {
|
||||
const borders: Record<Decision, string> = {
|
||||
validated: 'rgba(60,200,100,.7)',
|
||||
ignored: 'rgba(150,150,150,.4)',
|
||||
false_positive:'rgba(255,80,80,.6)',
|
||||
};
|
||||
const opacity = decision === 'validated' ? '1' : '0.55';
|
||||
card.style.cssText = `border:1px solid var(--background-modifier-border);border-left:3px solid ${borders[decision]};border-radius:6px;padding:8px 10px;margin:4px 0;opacity:${opacity};`;
|
||||
}
|
||||
|
||||
private applyBtnStyle(btn: HTMLElement, active: boolean): void {
|
||||
btn.style.cssText = `padding:2px 10px;border-radius:4px;cursor:pointer;font-size:.85em;border:1px solid var(--background-modifier-border);background:${active ? 'var(--interactive-accent)' : 'var(--background-primary)'};color:${active ? 'var(--text-on-accent)' : 'var(--text-normal)'};`;
|
||||
}
|
||||
|
||||
private ctxSpan(parent: HTMLElement, text: string): void {
|
||||
const s = parent.createSpan({ text });
|
||||
s.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
private async apply(): Promise<void> {
|
||||
const validated = this.occurrences.filter((o) => this.decisions.get(o.id) === 'validated');
|
||||
const ignored = this.occurrences.filter((o) => this.decisions.get(o.id) === 'ignored');
|
||||
|
||||
const spans: ReplacementSpan[] = validated.map((occ) => {
|
||||
const rule = this.rules.find((r) => r.id === occ.mappingId)!;
|
||||
return { start: occ.start, end: occ.end, source: occ.text, replacement: rule.replacement, mappingId: occ.mappingId ?? '', priority: rule.priority };
|
||||
});
|
||||
|
||||
const updated = applySpans(this.content, resolveSpans(spans));
|
||||
await this.app.vault.modify(this.file, updated);
|
||||
|
||||
await this.plugin.updateMappingStatuses(this.file.path, this.rules, this.occurrences, this.decisions);
|
||||
|
||||
const nv = validated.length, ni = ignored.length;
|
||||
new Notice(`✓ ${nv} remplacement${nv > 1 ? 's' : ''} appliqué${nv > 1 ? 's' : ''}` + (ni > 0 ? `, ${ni} ignoré${ni > 1 ? 's' : ''}` : ''));
|
||||
|
||||
this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
79
src/ui/PseudonymHighlighter.ts
Normal file
79
src/ui/PseudonymHighlighter.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Extension, RangeSetBuilder, StateEffect } from '@codemirror/state';
|
||||
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||
|
||||
// Signal externe : les données de surlignage ont changé.
|
||||
// À dispatcher sur l'EditorView après chaque refreshHighlightData().
|
||||
export const highlightDataChanged = StateEffect.define<void>();
|
||||
|
||||
export interface HighlightData {
|
||||
sources: string[]; // termes originaux encore présents → orange (à pseudonymiser)
|
||||
replacements: string[]; // pseudonymes déjà appliqués → vert
|
||||
}
|
||||
|
||||
// Extension CodeMirror 6 qui surligne dans l'éditeur :
|
||||
// - en orange : les termes sources (à pseudonymiser)
|
||||
// - en vert : les termes de remplacement (déjà pseudonymisés)
|
||||
// getData est appelé de façon synchrone à chaque mise à jour du doc.
|
||||
export function createPseudonymHighlighter(getData: () => HighlightData): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet = Decoration.none;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.build(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
const dataChanged = update.transactions.some((t) =>
|
||||
t.effects.some((e) => e.is(highlightDataChanged))
|
||||
);
|
||||
if (update.docChanged || update.viewportChanged || dataChanged) {
|
||||
this.decorations = this.build(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
private build(view: EditorView): DecorationSet {
|
||||
const { sources, replacements } = getData();
|
||||
if (sources.length === 0 && replacements.length === 0) return Decoration.none;
|
||||
|
||||
const text = view.state.doc.toString();
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
type Span = { from: number; to: number; cls: string };
|
||||
const spans: Span[] = [];
|
||||
|
||||
const collect = (terms: string[], cls: string) => {
|
||||
for (const term of terms) {
|
||||
if (!term) continue;
|
||||
const needle = term.toLowerCase();
|
||||
let pos = 0;
|
||||
while (pos < lower.length) {
|
||||
const idx = lower.indexOf(needle, pos);
|
||||
if (idx === -1) break;
|
||||
spans.push({ from: idx, to: idx + term.length, cls });
|
||||
pos = idx + term.length;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collect(sources, 'pseudobs-source');
|
||||
collect(replacements, 'pseudobs-replaced');
|
||||
|
||||
// Trier par position (RangeSetBuilder l'exige) et éliminer les chevauchements
|
||||
spans.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
let lastTo = -1;
|
||||
for (const { from, to, cls } of spans) {
|
||||
if (from >= lastTo) {
|
||||
builder.add(from, to, Decoration.mark({ class: cls }));
|
||||
lastTo = to;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
}
|
||||
200
src/ui/QuickPseudonymizeModal.ts
Normal file
200
src/ui/QuickPseudonymizeModal.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { App, Editor, EditorPosition, Modal, Notice, Setting, TFile } from 'obsidian';
|
||||
import type PseudObsPlugin from '../main';
|
||||
import { MappingStore } from '../mappings/MappingStore';
|
||||
import { PseudonymizationEngine } from '../pseudonymizer/PseudonymizationEngine';
|
||||
import type { EntityCategory, MappingFile } from '../types';
|
||||
|
||||
type ApplyScope = 'occurrence' | 'file';
|
||||
|
||||
export class QuickPseudonymizeModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
private editor: Editor;
|
||||
private source: string;
|
||||
private from: EditorPosition;
|
||||
private to: EditorPosition;
|
||||
|
||||
private replacement = '';
|
||||
private category: EntityCategory = 'custom';
|
||||
private applyScope: ApplyScope = 'file';
|
||||
private suggestions: string[];
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: PseudObsPlugin,
|
||||
editor: Editor,
|
||||
prefillReplacement = '',
|
||||
suggestions: string[] = []
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.editor = editor;
|
||||
this.source = editor.getSelection();
|
||||
this.replacement = prefillReplacement;
|
||||
this.suggestions = suggestions;
|
||||
// Coulmont ne traite que des prénoms
|
||||
if (suggestions.length > 0) this.category = 'first_name';
|
||||
this.from = editor.getCursor('from');
|
||||
this.to = editor.getCursor('to');
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: 'Pseudonymiser' });
|
||||
|
||||
// Source en lecture seule
|
||||
new Setting(contentEl)
|
||||
.setName('Expression sélectionnée')
|
||||
.setDesc('Terme à remplacer — non modifiable')
|
||||
.addText((t) => {
|
||||
t.setValue(this.source).setDisabled(true);
|
||||
t.inputEl.style.opacity = '0.6';
|
||||
});
|
||||
|
||||
// Suggestions Coulmont : boutons cliquables qui remplissent le champ
|
||||
let replacementInput: HTMLInputElement;
|
||||
if (this.suggestions.length > 0) {
|
||||
const suggBox = contentEl.createDiv();
|
||||
suggBox.style.cssText = 'margin-bottom:6px;';
|
||||
suggBox.createEl('small', { text: 'Suggestions Coulmont (le jeu de données ne différencie pas M/F) :' })
|
||||
.style.cssText = 'display:block;opacity:.6;margin-bottom:4px;font-size:.8em;';
|
||||
const tags = suggBox.createDiv();
|
||||
tags.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;';
|
||||
for (const name of this.suggestions) {
|
||||
const btn = tags.createEl('button', { text: name });
|
||||
btn.style.cssText = 'padding:2px 10px;border-radius:12px;border:1px solid var(--background-modifier-border);cursor:pointer;font-size:.85em;background:var(--background-secondary);';
|
||||
btn.addEventListener('click', () => {
|
||||
this.replacement = name;
|
||||
if (replacementInput) {
|
||||
replacementInput.value = name;
|
||||
replacementInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
// Mettre en évidence le bouton sélectionné
|
||||
tags.querySelectorAll('button').forEach((b) => {
|
||||
(b as HTMLElement).style.background = 'var(--background-secondary)';
|
||||
(b as HTMLElement).style.fontWeight = 'normal';
|
||||
});
|
||||
btn.style.background = 'var(--interactive-accent)';
|
||||
btn.style.color = 'var(--text-on-accent)';
|
||||
btn.style.fontWeight = '600';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Champ de remplacement — pré-rempli si un prénom a déjà été sélectionné
|
||||
new Setting(contentEl)
|
||||
.setName('Remplacer par')
|
||||
.addText((t) => {
|
||||
t.setPlaceholder('pseudonyme ou catégorie analytique');
|
||||
t.setValue(this.replacement);
|
||||
t.onChange((v) => (this.replacement = v));
|
||||
replacementInput = t.inputEl;
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Catégorie')
|
||||
.addDropdown((d) => {
|
||||
const options: Record<EntityCategory, string> = {
|
||||
first_name: 'Prénom',
|
||||
last_name: 'Nom de famille',
|
||||
full_name: 'Nom complet',
|
||||
place: 'Lieu',
|
||||
institution: 'Institution',
|
||||
date: 'Date',
|
||||
age: 'Âge',
|
||||
profession: 'Profession',
|
||||
custom: 'Autre',
|
||||
};
|
||||
for (const [value, label] of Object.entries(options)) {
|
||||
d.addOption(value, label);
|
||||
}
|
||||
d.setValue('custom');
|
||||
d.onChange((v) => (this.category = v as EntityCategory));
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Portée du remplacement')
|
||||
.addDropdown((d) => {
|
||||
d.addOption('file', 'Toutes les occurrences dans ce fichier');
|
||||
d.addOption('occurrence', 'Cette occurrence uniquement');
|
||||
d.setValue('file');
|
||||
d.onChange((v) => (this.applyScope = v as ApplyScope));
|
||||
});
|
||||
|
||||
new Setting(contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Pseudonymiser')
|
||||
.setCta()
|
||||
.onClick(() => this.apply())
|
||||
);
|
||||
|
||||
// Focus sur le champ de remplacement à l'ouverture
|
||||
setTimeout(() => replacementInput?.focus(), 50);
|
||||
}
|
||||
|
||||
private async apply(): Promise<void> {
|
||||
const replacement = this.replacement.trim();
|
||||
if (!replacement) {
|
||||
new Notice('Le remplacement est obligatoire.');
|
||||
return;
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
new Notice('Aucun fichier actif.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Sauvegarder la règle dans le mapping JSON
|
||||
await this.saveRule(activeFile, replacement);
|
||||
|
||||
// 2. Appliquer dans le fichier
|
||||
if (this.applyScope === 'occurrence') {
|
||||
// Remplacer uniquement la sélection courante dans l'éditeur
|
||||
this.editor.replaceRange(replacement, this.from, this.to);
|
||||
new Notice(`✓ "${this.source}" → "${replacement}" (cette occurrence)`);
|
||||
} else {
|
||||
// Remplacer toutes les occurrences dans le fichier courant
|
||||
const count = await this.plugin.applyRuleToFile(activeFile, this.source, replacement);
|
||||
new Notice(`✓ "${this.source}" → "${replacement}" (${count} occurrence${count > 1 ? 's' : ''})`);
|
||||
}
|
||||
|
||||
// Rafraîchir le surlignage immédiatement
|
||||
this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
||||
private async saveRule(activeFile: TFile, replacement: string): Promise<void> {
|
||||
const mappingPath = `${this.plugin.settings.mappingFolder}/${activeFile.basename}.mapping.json`;
|
||||
let store: MappingStore;
|
||||
|
||||
const mappingTFile = this.app.vault.getAbstractFileByPath(mappingPath);
|
||||
if (mappingTFile instanceof TFile) {
|
||||
const data: MappingFile = JSON.parse(await this.app.vault.read(mappingTFile));
|
||||
store = MappingStore.fromJSON(data);
|
||||
} else {
|
||||
await this.plugin.ensureFolder(this.plugin.settings.mappingFolder);
|
||||
store = new MappingStore({ type: 'file', path: activeFile.path });
|
||||
}
|
||||
|
||||
store.add({
|
||||
source: this.source,
|
||||
replacement,
|
||||
category: this.category,
|
||||
scope: { type: 'file', path: activeFile.path },
|
||||
status: 'validated',
|
||||
priority: 0,
|
||||
createdBy: 'user',
|
||||
});
|
||||
|
||||
const json = JSON.stringify(store.toJSON(), null, 2);
|
||||
if (mappingTFile instanceof TFile) {
|
||||
await this.app.vault.modify(mappingTFile, json);
|
||||
} else {
|
||||
await this.app.vault.create(mappingPath, json);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
187
src/ui/RuleModal.ts
Normal file
187
src/ui/RuleModal.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
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';
|
||||
|
||||
export class RuleModal extends Modal {
|
||||
private plugin: PseudObsPlugin;
|
||||
private source: string;
|
||||
private replacement: string;
|
||||
private category: EntityCategory;
|
||||
private scopeType: ScopeType = 'file';
|
||||
private priority = 0;
|
||||
private suggestions: string[];
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: PseudObsPlugin,
|
||||
prefillSource = '',
|
||||
prefillReplacement = '',
|
||||
suggestions: 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';
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: 'Créer une règle de remplacement' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Source')
|
||||
.setDesc('Terme original à remplacer')
|
||||
.addText((t) =>
|
||||
t.setValue(this.source).onChange((v) => {
|
||||
this.source = v;
|
||||
})
|
||||
);
|
||||
|
||||
// Suggestions Coulmont : boutons cliquables (présents uniquement si fournis)
|
||||
let replacementInput: HTMLInputElement | undefined;
|
||||
if (this.suggestions.length > 0) {
|
||||
const box = contentEl.createDiv();
|
||||
box.style.cssText = 'margin-bottom:6px;';
|
||||
box.createEl('small', { text: 'Suggestions Coulmont (M/F non différencié — choisissez) :' })
|
||||
.style.cssText = 'display:block;opacity:.6;margin-bottom:4px;font-size:.8em;';
|
||||
const tags = box.createDiv();
|
||||
tags.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;';
|
||||
for (const name of this.suggestions) {
|
||||
const btn = tags.createEl('button', { text: name });
|
||||
btn.style.cssText = 'padding:2px 10px;border-radius:12px;border:1px solid var(--background-modifier-border);cursor:pointer;font-size:.85em;background:var(--background-secondary);';
|
||||
btn.addEventListener('click', () => {
|
||||
this.replacement = name;
|
||||
if (replacementInput) replacementInput.value = name;
|
||||
tags.querySelectorAll('button').forEach((b) => {
|
||||
(b as HTMLElement).style.cssText = 'padding:2px 10px;border-radius:12px;border:1px solid var(--background-modifier-border);cursor:pointer;font-size:.85em;background:var(--background-secondary);';
|
||||
});
|
||||
btn.style.cssText = 'padding:2px 10px;border-radius:12px;border:1px solid var(--interactive-accent);cursor:pointer;font-size:.85em;background:var(--interactive-accent);color:var(--text-on-accent);font-weight:600;';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Remplacement')
|
||||
.setDesc('Pseudonyme ou catégorie analytique')
|
||||
.addText((t) => {
|
||||
t.setValue(this.replacement).onChange((v) => { this.replacement = v; });
|
||||
replacementInput = t.inputEl;
|
||||
});
|
||||
|
||||
// Catégorie — masquée et fixée à "Prénom" si suggestions Coulmont
|
||||
new Setting(contentEl)
|
||||
.setName('Catégorie')
|
||||
.addDropdown((d) => {
|
||||
const options: Record<EntityCategory, string> = {
|
||||
first_name: 'Prénom',
|
||||
last_name: 'Nom de famille',
|
||||
full_name: 'Nom complet',
|
||||
place: 'Lieu',
|
||||
institution: 'Institution',
|
||||
date: 'Date',
|
||||
age: 'Âge',
|
||||
profession: 'Profession',
|
||||
custom: 'Autre',
|
||||
};
|
||||
for (const [value, label] of Object.entries(options)) {
|
||||
d.addOption(value, label);
|
||||
}
|
||||
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) {
|
||||
d.selectEl.closest('.setting-item')?.setAttribute('style', 'display:none');
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Portée')
|
||||
.addDropdown((d) => {
|
||||
d.addOption('file', 'Ce fichier uniquement');
|
||||
d.addOption('folder', 'Ce dossier');
|
||||
d.addOption('vault', 'Tout le vault');
|
||||
d.setValue('file');
|
||||
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;
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Créer la règle')
|
||||
.setCta()
|
||||
.onClick(() => this.createRule())
|
||||
);
|
||||
}
|
||||
|
||||
private async createRule(): Promise<void> {
|
||||
if (!this.source.trim() || !this.replacement.trim()) {
|
||||
new Notice('La source et le remplacement sont obligatoires.');
|
||||
return;
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) {
|
||||
new Notice('Aucun fichier actif.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mappingPath = `${this.plugin.settings.mappingFolder}/${activeFile.basename}.mapping.json`;
|
||||
let store: MappingStore;
|
||||
|
||||
const mappingFile = this.app.vault.getAbstractFileByPath(mappingPath);
|
||||
if (mappingFile instanceof TFile) {
|
||||
const data: MappingFile = JSON.parse(await this.app.vault.read(mappingFile));
|
||||
store = MappingStore.fromJSON(data);
|
||||
} else {
|
||||
await this.plugin.ensureFolder(this.plugin.settings.mappingFolder);
|
||||
store = new MappingStore({ type: 'file', path: activeFile.path });
|
||||
}
|
||||
|
||||
const scopePath =
|
||||
this.scopeType === 'file'
|
||||
? activeFile.path
|
||||
: this.scopeType === 'folder'
|
||||
? (activeFile.parent?.path ?? '')
|
||||
: undefined;
|
||||
|
||||
store.add({
|
||||
source: this.source.trim(),
|
||||
replacement: this.replacement.trim(),
|
||||
category: this.category,
|
||||
scope: { type: this.scopeType, path: scopePath },
|
||||
status: 'validated',
|
||||
priority: this.priority,
|
||||
createdBy: 'user',
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
new Notice(`✓ Règle créée : "${this.source.trim()}" → "${this.replacement.trim()}"`);
|
||||
// Rafraîchir le surlignage immédiatement sans attendre un changement de fichier
|
||||
this.plugin.refreshHighlightData();
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
94
styles.css
Normal file
94
styles.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/* PseudObsidian-ization — styles */
|
||||
|
||||
/* Termes sources encore présents dans le texte (à pseudonymiser) */
|
||||
.pseudobs-source {
|
||||
background-color: rgba(255, 160, 0, 0.22);
|
||||
border-radius: 2px;
|
||||
outline: 1px solid rgba(255, 160, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Termes de remplacement déjà appliqués (pseudonymes) */
|
||||
.pseudobs-replaced {
|
||||
background-color: rgba(60, 200, 100, 0.2);
|
||||
border-radius: 2px;
|
||||
outline: 1px solid rgba(60, 200, 100, 0.35);
|
||||
}
|
||||
|
||||
/* Modal de validation sélective */
|
||||
.pseudobs-scan-summary { opacity: 0.7; margin-bottom: 0.5em; }
|
||||
|
||||
.pseudobs-occ-group { margin: 1em 0; }
|
||||
|
||||
.pseudobs-occ-rule-header {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
padding: 4px 8px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pseudobs-occ-card {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin: 4px 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.pseudobs-occ-validated { border-left: 3px solid rgba(60, 200, 100, 0.7); }
|
||||
.pseudobs-occ-ignored { border-left: 3px solid rgba(150, 150, 150, 0.5); opacity: 0.6; }
|
||||
.pseudobs-occ-false_positive { border-left: 3px solid rgba(255, 80, 80, 0.6); opacity: 0.5; }
|
||||
|
||||
.pseudobs-occ-line {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pseudobs-occ-result-line { opacity: 0.8; }
|
||||
|
||||
/* Terme source → jaune */
|
||||
.pseudobs-occ-term {
|
||||
background: rgba(255, 210, 0, 0.55);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* Remplacement proposé → vert */
|
||||
.pseudobs-occ-replacement {
|
||||
background: rgba(50, 205, 90, 0.5);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.pseudobs-ctx-side { opacity: 0.55; }
|
||||
|
||||
.pseudobs-occ-arrow {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.35;
|
||||
line-height: 1.2;
|
||||
padding-left: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pseudobs-occ-meta { display: block; font-size: 0.75em; opacity: 0.45; margin-top: 4px; }
|
||||
|
||||
.pseudobs-occ-actions { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.pseudobs-occ-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
background: var(--background-primary);
|
||||
}
|
||||
.pseudobs-occ-btn-active {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
49
tests/__mocks__/obsidian.ts
Normal file
49
tests/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Mock minimal de l'API Obsidian pour Jest (environnement Node, pas navigateur)
|
||||
|
||||
export class Plugin {
|
||||
app: App = new App();
|
||||
async loadData(): Promise<unknown> { return {}; }
|
||||
async saveData(_data: unknown): Promise<void> {}
|
||||
addSettingTab(_tab: unknown): void {}
|
||||
}
|
||||
|
||||
export class App {}
|
||||
|
||||
export class PluginSettingTab {
|
||||
app: App;
|
||||
containerEl: MockEl;
|
||||
|
||||
constructor(app: App, _plugin: unknown) {
|
||||
this.app = app;
|
||||
this.containerEl = new MockEl();
|
||||
}
|
||||
}
|
||||
|
||||
export class Setting {
|
||||
constructor(_containerEl: unknown) {}
|
||||
setName(_name: string): this { return this; }
|
||||
setDesc(_desc: string): this { return this; }
|
||||
addText(_cb: (_t: MockTextInput) => unknown): this {
|
||||
_cb(new MockTextInput());
|
||||
return this;
|
||||
}
|
||||
addToggle(_cb: (_t: MockToggle) => unknown): this {
|
||||
_cb(new MockToggle());
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class MockEl {
|
||||
empty(): void {}
|
||||
createEl(_tag: string, _opts?: unknown): MockEl { return new MockEl(); }
|
||||
}
|
||||
|
||||
class MockTextInput {
|
||||
setValue(_v: string): this { return this; }
|
||||
onChange(_cb: (_v: string) => unknown): this { return this; }
|
||||
}
|
||||
|
||||
class MockToggle {
|
||||
setValue(_v: boolean): this { return this; }
|
||||
onChange(_cb: (_v: boolean) => unknown): this { return this; }
|
||||
}
|
||||
23
tests/fixtures/entretien_01.srt
vendored
Normal file
23
tests/fixtures/entretien_01.srt
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
1
|
||||
00:00:01,000 --> 00:00:04,500
|
||||
Bonjour Jean, tu peux te présenter ?
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:09,200
|
||||
Je m'appelle Jean Dupont, j'habite Saint-Jean-de-Luz.
|
||||
|
||||
3
|
||||
00:00:10,000 --> 00:00:13,500
|
||||
Et tu travailles au CHU de Montpellier ?
|
||||
|
||||
4
|
||||
00:00:14,000 --> 00:00:17,800
|
||||
Oui, au CHU de Montpellier, depuis trois ans.
|
||||
|
||||
5
|
||||
00:00:18,200 --> 00:00:22,000
|
||||
Tu connais Paul Martin, qui travaille à Saint-Paul ?
|
||||
|
||||
6
|
||||
00:00:22,500 --> 00:00:26,300
|
||||
Oui, on s'est rencontrés à Montpellier, à la fac de médecine.
|
||||
19
tests/fixtures/entretien_02.cha
vendored
Normal file
19
tests/fixtures/entretien_02.cha
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@Begin
|
||||
@Languages: fra
|
||||
@Participants: INV Investigatrice adulte, PAR Participant adulte
|
||||
@ID: fra|corpus|INV|adulte||Investigatrice|||adulte|
|
||||
@ID: fra|corpus|PAR|adulte||Participant|||adulte|
|
||||
@Date: 11-MAY-2026
|
||||
@Location: Lyon
|
||||
@Transcriber: AA
|
||||
*INV: bonjour Marie, tu peux te présenter ?
|
||||
*PAR: je m'appelle Marie Dupont, j'ai trente-deux ans .
|
||||
*INV: et tu habites à Lyon ?
|
||||
*PAR: oui, dans le quartier de la Guillotière .
|
||||
%com: PAR marque une hésitation avant de répondre
|
||||
*INV: tu travailles dans quel secteur ?
|
||||
*PAR: à l' hôpital (.) au service de pédiatrie .
|
||||
*INV: et tu as grandi à Nancy ?
|
||||
*PAR: oui (0.5) à Nancy (.) la ville (.) pas le prénom .
|
||||
%com: INV et PAR rient
|
||||
@End
|
||||
21
tests/fixtures/entretien_03.md
vendored
Normal file
21
tests/fixtures/entretien_03.md
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Entretien 03 — Transcription Jefferson
|
||||
|
||||
**Corpus** : UJAA-2026
|
||||
**Enquêtrice** : INV
|
||||
**Participant** : PAR (Luc Bernard, 41 ans, enseignant, né à Nancy)
|
||||
|
||||
---
|
||||
|
||||
INV : bonjour [Luc,
|
||||
PAR : [oui bonjour
|
||||
|
||||
INV : tu peux me parler de ton parcours ?
|
||||
PAR : bien sûr (0.5) je suis né à Nancy en mille neuf cent quatre-vingt-cinq:
|
||||
INV : à Nancy ?
|
||||
PAR : oui (.) la ville (.) pas le prénom ((rires))
|
||||
INV : et maintenant tu vis à Lyon ?
|
||||
PAR : voilà °à Lyon° depuis deux ans=
|
||||
INV : =dans quel quartier ?
|
||||
PAR : .hh euh (1.2) la Croix-Rousse
|
||||
INV : et tu travailles à: (.) l'université ?
|
||||
PAR : oui à l'université Lyon 2 en fait
|
||||
141
tests/unit/ChatParser.test.ts
Normal file
141
tests/unit/ChatParser.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ChatParser } from '../../src/parsers/ChatParser';
|
||||
import type { ChatLine } from '../../src/parsers/ChatParser';
|
||||
|
||||
const FIXTURE = path.join(__dirname, '../fixtures/entretien_02.cha');
|
||||
|
||||
describe('ChatParser', () => {
|
||||
const parser = new ChatParser();
|
||||
let content: string;
|
||||
let lines: ChatLine[];
|
||||
|
||||
beforeAll(() => {
|
||||
content = fs.readFileSync(FIXTURE, 'utf-8');
|
||||
lines = parser.parse(content).lines;
|
||||
});
|
||||
|
||||
// --- Structure (SPECS §3.3) ---
|
||||
|
||||
it('parse le bon nombre de lignes', () => {
|
||||
// 8 meta (@Begin…@Transcriber) + 8 turns + 2 dependent + 1 meta (@End) = 19
|
||||
expect(lines).toHaveLength(19);
|
||||
});
|
||||
|
||||
it('identifie correctement les lignes @ comme meta', () => {
|
||||
const metas = lines.filter((l) => l.type === 'meta');
|
||||
expect(metas).toHaveLength(9); // @Begin, 7 @*, @End
|
||||
expect(metas[0].raw).toBe('@Begin');
|
||||
expect(metas[metas.length - 1].raw).toBe('@End');
|
||||
});
|
||||
|
||||
it('identifie correctement les tours de parole (*)', () => {
|
||||
const turns = lines.filter((l) => l.type === 'turn');
|
||||
expect(turns).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('identifie correctement les lignes dépendantes (%)', () => {
|
||||
const deps = lines.filter((l) => l.type === 'dependent');
|
||||
expect(deps).toHaveLength(2);
|
||||
expect(deps[0].raw).toContain('%com:');
|
||||
expect(deps[1].raw).toContain('%com:');
|
||||
});
|
||||
|
||||
// --- Extraction locuteur / contenu ---
|
||||
|
||||
it('extrait le locuteur et le contenu du premier tour INV', () => {
|
||||
const firstTurn = lines.find((l) => l.type === 'turn');
|
||||
expect(firstTurn?.speaker).toBe('INV');
|
||||
expect(firstTurn?.content).toBe('bonjour Marie, tu peux te présenter ?');
|
||||
});
|
||||
|
||||
it('extrait le locuteur et le contenu du premier tour PAR (Marie Dupont)', () => {
|
||||
const parTurns = lines.filter((l) => l.type === 'turn' && l.speaker === 'PAR');
|
||||
expect(parTurns[0].content).toContain('Marie Dupont');
|
||||
});
|
||||
|
||||
it('le prefix reconstitue la ligne originale avec le contenu', () => {
|
||||
const turns = lines.filter((l) => l.type === 'turn');
|
||||
for (const turn of turns) {
|
||||
expect(turn.prefix! + turn.content!).toBe(turn.raw);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Round-trip exact (SPECS §17.5) ---
|
||||
|
||||
it('round-trip exact : reconstruct(parse(content)) === content', () => {
|
||||
const doc = parser.parse(content);
|
||||
expect(parser.reconstruct(doc)).toBe(content);
|
||||
});
|
||||
|
||||
it('les lignes @ sont inchangées après reconstruction', () => {
|
||||
const doc = parser.parse(content);
|
||||
const result = parser.reconstruct(doc);
|
||||
expect(result).toContain('@Begin');
|
||||
expect(result).toContain('@End');
|
||||
expect(result).toContain('@Languages: fra');
|
||||
expect(result).toContain('@Participants:');
|
||||
});
|
||||
|
||||
it('les lignes % sont inchangées après reconstruction', () => {
|
||||
const doc = parser.parse(content);
|
||||
const result = parser.reconstruct(doc);
|
||||
expect(result).toContain('%com: PAR marque une hésitation avant de répondre');
|
||||
expect(result).toContain('%com: INV et PAR rient');
|
||||
});
|
||||
|
||||
// --- Remplacement de contenu ---
|
||||
|
||||
it('modifier le contenu ne touche pas le locuteur ni les lignes @', () => {
|
||||
const doc = parser.parse(content);
|
||||
const parTurn = doc.lines.find((l) => l.type === 'turn' && l.speaker === 'PAR');
|
||||
parTurn!.content = parTurn!.content!.replace('Marie Dupont', 'Sophie Arnaud');
|
||||
const result = parser.reconstruct(doc);
|
||||
expect(result).toContain('*PAR: je m\'appelle Sophie Arnaud');
|
||||
expect(result).toContain('@Begin');
|
||||
expect(result).toContain('@Participants:');
|
||||
expect(result).not.toContain('Marie Dupont');
|
||||
});
|
||||
|
||||
it("le cas Nancy ville/prenom : PAR thematise l'ambiguite dans son tour", () => {
|
||||
// INV mentionne Nancy en premier ; PAR explicite l'ambiguïté ville/prénom
|
||||
const parNancyTurn = lines.find(
|
||||
(l) => l.type === 'turn' && l.speaker === 'PAR' && l.content?.includes('Nancy')
|
||||
);
|
||||
expect(parNancyTurn).toBeDefined();
|
||||
expect(parNancyTurn?.content).toContain('Nancy');
|
||||
expect(parNancyTurn?.content).toContain('la ville');
|
||||
expect(parNancyTurn?.content).toContain('le prénom');
|
||||
});
|
||||
|
||||
// --- Robustesse ---
|
||||
|
||||
it('parse un fichier CHAT avec fins de ligne Windows (CRLF)', () => {
|
||||
const crlf = content.replace(/\n/g, '\r\n');
|
||||
const doc = parser.parse(crlf);
|
||||
expect(doc.lines).toHaveLength(19);
|
||||
expect(doc.lines[0].type).toBe('meta');
|
||||
expect(doc.lines[0].raw).toBe('@Begin');
|
||||
});
|
||||
|
||||
it('parse un fichier CHAT sans saut de ligne final', () => {
|
||||
const noTrailing = content.trimEnd();
|
||||
const doc = parser.parse(noTrailing);
|
||||
expect(doc.trailingNewline).toBe(false);
|
||||
expect(parser.reconstruct(doc)).toBe(noTrailing);
|
||||
});
|
||||
|
||||
it('un locuteur avec tabulation comme séparateur est correctement parsé', () => {
|
||||
const withTab = '*INV:\tbonjour tout le monde';
|
||||
const line = (parser as unknown as { parseLine: (r: string) => ChatLine }).parseLine?.(withTab)
|
||||
?? parser.parse(withTab + '\n@End\n').lines[0];
|
||||
// fallback : on teste via un mini-document
|
||||
const miniDoc = parser.parse('@Begin\n' + withTab + '\n@End\n');
|
||||
const turn = miniDoc.lines.find((l) => l.type === 'turn');
|
||||
expect(turn?.speaker).toBe('INV');
|
||||
expect(turn?.content).toBe('bonjour tout le monde');
|
||||
expect(turn?.prefix).toBe('*INV:\t');
|
||||
// round-trip du mini-document
|
||||
expect(parser.reconstruct(miniDoc)).toBe('@Begin\n' + withTab + '\n@End\n');
|
||||
});
|
||||
});
|
||||
147
tests/unit/MappingStore.test.ts
Normal file
147
tests/unit/MappingStore.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { MappingStore, DEFAULT_FILE_SETTINGS } from '../../src/mappings/MappingStore';
|
||||
import type { MappingFile } from '../../src/types';
|
||||
|
||||
const FILE_SCOPE = { type: 'file' as const, path: 'entretien_01.srt' };
|
||||
|
||||
function makeRule(source: string, replacement: string) {
|
||||
return {
|
||||
source,
|
||||
replacement,
|
||||
category: 'first_name' as const,
|
||||
scope: FILE_SCOPE,
|
||||
status: 'validated' as const,
|
||||
priority: 0,
|
||||
createdBy: 'user' as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MappingStore', () => {
|
||||
// --- CRUD ---
|
||||
|
||||
it('add retourne une règle avec un id unique', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
const rule = store.add(makeRule('Jean', 'Pierre'));
|
||||
expect(rule.id).toMatch(/^map_/);
|
||||
expect(rule.source).toBe('Jean');
|
||||
expect(rule.createdAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deux règles ajoutées ont des ids distincts', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
const r1 = store.add(makeRule('Jean', 'Pierre'));
|
||||
const r2 = store.add(makeRule('Marie', 'Sophie'));
|
||||
expect(r1.id).not.toBe(r2.id);
|
||||
});
|
||||
|
||||
it('getAll retourne toutes les règles ajoutées', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
store.add(makeRule('Jean', 'Pierre'));
|
||||
store.add(makeRule('Marie', 'Sophie'));
|
||||
expect(store.getAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('get retourne la règle par son id', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
const rule = store.add(makeRule('Jean', 'Pierre'));
|
||||
expect(store.get(rule.id)?.source).toBe('Jean');
|
||||
});
|
||||
|
||||
it('get retourne undefined pour un id inexistant', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
expect(store.get('inexistant')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('update modifie le remplacement', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
const rule = store.add(makeRule('Jean', 'Pierre'));
|
||||
store.update(rule.id, { replacement: 'Paul' });
|
||||
expect(store.get(rule.id)?.replacement).toBe('Paul');
|
||||
});
|
||||
|
||||
it('update retourne false si la règle est introuvable', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
expect(store.update('inexistant', { replacement: 'X' })).toBe(false);
|
||||
});
|
||||
|
||||
it('remove supprime la règle', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
const rule = store.add(makeRule('Jean', 'Pierre'));
|
||||
expect(store.remove(rule.id)).toBe(true);
|
||||
expect(store.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('remove retourne false si la règle est introuvable', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
expect(store.remove('inexistant')).toBe(false);
|
||||
});
|
||||
|
||||
// --- Filtrage par portée ---
|
||||
|
||||
it('getValidatedFor retourne les règles validées pour un fichier donné', () => {
|
||||
const store = new MappingStore({ type: 'vault' });
|
||||
store.add(makeRule('Jean', 'Pierre'));
|
||||
store.add({ ...makeRule('Marie', 'Sophie'), scope: { type: 'file', path: 'autre.cha' } });
|
||||
const results = store.getValidatedFor('entretien_01.srt');
|
||||
expect(results).toHaveLength(1); // seule la règle vault s'applique
|
||||
expect(results[0].source).toBe('Jean');
|
||||
});
|
||||
|
||||
it('getValidatedFor exclut les règles non validées', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
store.add({ ...makeRule('Jean', 'Pierre'), status: 'suggested' });
|
||||
expect(store.getValidatedFor('entretien_01.srt')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('getValidatedFor inclut les règles de dossier parent', () => {
|
||||
const store = new MappingStore({ type: 'vault' });
|
||||
store.add({
|
||||
...makeRule('Jean', 'Pierre'),
|
||||
scope: { type: 'folder', path: 'Transcriptions/UJAA' },
|
||||
});
|
||||
expect(store.getValidatedFor('Transcriptions/UJAA/entretien_01.cha')).toHaveLength(1);
|
||||
expect(store.getValidatedFor('Transcriptions/LIIPPS/entretien_02.cha')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// --- Sérialisation JSON ---
|
||||
|
||||
it('toJSON produit un MappingFile valide', () => {
|
||||
const store = new MappingStore(FILE_SCOPE, 'UJAA-2026');
|
||||
store.add(makeRule('Jean', 'Pierre'));
|
||||
const json = store.toJSON();
|
||||
expect(json.schemaVersion).toBe('1.0.0');
|
||||
expect(json.project).toBe('UJAA-2026');
|
||||
expect(json.mappings).toHaveLength(1);
|
||||
expect(json.settings).toEqual(DEFAULT_FILE_SETTINGS);
|
||||
});
|
||||
|
||||
it('fromJSON reconstruit un store identique', () => {
|
||||
const store = new MappingStore(FILE_SCOPE, 'UJAA-2026');
|
||||
const rule = store.add(makeRule('Jean', 'Pierre'));
|
||||
const json = store.toJSON();
|
||||
|
||||
const restored = MappingStore.fromJSON(json);
|
||||
expect(restored.getAll()).toHaveLength(1);
|
||||
expect(restored.get(rule.id)?.source).toBe('Jean');
|
||||
expect(restored.project).toBe('UJAA-2026');
|
||||
});
|
||||
|
||||
it('round-trip JSON : toJSON puis fromJSON puis toJSON est stable', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
store.add(makeRule('Jean', 'Pierre'));
|
||||
store.add(makeRule('Marie', 'Sophie'));
|
||||
const json1 = store.toJSON();
|
||||
const json2 = MappingStore.fromJSON(json1).toJSON();
|
||||
expect(json2.mappings).toHaveLength(json1.mappings.length);
|
||||
expect(json2.mappings.map((r) => r.source).sort()).toEqual(
|
||||
json1.mappings.map((r) => r.source).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('toJSON peut être sérialisé en JSON.stringify sans erreur', () => {
|
||||
const store = new MappingStore(FILE_SCOPE);
|
||||
store.add(makeRule('Jean', 'Pierre'));
|
||||
const serialized = JSON.stringify(store.toJSON());
|
||||
const parsed: MappingFile = JSON.parse(serialized);
|
||||
expect(parsed.mappings[0].source).toBe('Jean');
|
||||
});
|
||||
});
|
||||
71
tests/unit/OccurrenceScanner.test.ts
Normal file
71
tests/unit/OccurrenceScanner.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { scanOccurrences } from '../../src/scanner/OccurrenceScanner';
|
||||
import type { MappingRule } from '../../src/types';
|
||||
|
||||
const FILE = 'entretien_01.md';
|
||||
const SCOPE = { type: 'file' as const, path: FILE };
|
||||
|
||||
function rule(id: string, source: string, replacement: string, priority = 0): MappingRule {
|
||||
return {
|
||||
id, source, replacement, category: 'first_name', scope: SCOPE,
|
||||
status: 'validated', priority, createdBy: 'user', createdAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
const TEXT = 'Bonjour Jean, tu habites Saint-Jean-de-Luz. Jean était là.';
|
||||
const RULES = [
|
||||
rule('1', 'Jean', 'Pierre', 0),
|
||||
rule('2', 'Saint-Jean-de-Luz', 'Ville littorale', 0),
|
||||
];
|
||||
|
||||
describe('OccurrenceScanner', () => {
|
||||
it('trouve toutes les occurrences des sources', () => {
|
||||
const occs = scanOccurrences(TEXT, FILE, RULES);
|
||||
// Jean×3 + Saint-Jean-de-Luz×1 (dont Jean intérieur, whole-word = true donc Jean dans Saint-Jean-de-Luz est trouvé)
|
||||
const sources = occs.map((o) => o.text);
|
||||
expect(sources).toContain('Jean');
|
||||
expect(sources).toContain('Saint-Jean-de-Luz');
|
||||
});
|
||||
|
||||
it('retourne les occurrences triées par position', () => {
|
||||
const occs = scanOccurrences(TEXT, FILE, RULES);
|
||||
for (let i = 1; i < occs.length; i++) {
|
||||
expect(occs[i].start).toBeGreaterThanOrEqual(occs[i - 1].start);
|
||||
}
|
||||
});
|
||||
|
||||
it('associe chaque occurrence à son mappingId', () => {
|
||||
const occs = scanOccurrences(TEXT, FILE, RULES);
|
||||
const jeanOcc = occs.find((o) => o.text === 'Jean' && o.start === 8);
|
||||
expect(jeanOcc?.mappingId).toBe('1');
|
||||
const villOcc = occs.find((o) => o.text === 'Saint-Jean-de-Luz');
|
||||
expect(villOcc?.mappingId).toBe('2');
|
||||
});
|
||||
|
||||
it('remplit contextBefore et contextAfter', () => {
|
||||
const occs = scanOccurrences(TEXT, FILE, RULES);
|
||||
const first = occs[0];
|
||||
expect(first.contextBefore).toContain('Bonjour');
|
||||
expect(first.contextAfter.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calcule le numéro de ligne (base 1)', () => {
|
||||
const multiline = 'ligne un\nBonjour Jean ici\nligne trois';
|
||||
const occs = scanOccurrences(multiline, FILE, [rule('1', 'Jean', 'Pierre')]);
|
||||
expect(occs[0].line).toBe(2);
|
||||
});
|
||||
|
||||
it('retourne un tableau vide si aucune règle', () => {
|
||||
expect(scanOccurrences(TEXT, FILE, [])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('retourne un tableau vide si aucun terme trouvé', () => {
|
||||
const occs = scanOccurrences('Aucun terme ici.', FILE, RULES);
|
||||
expect(occs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('chaque occurrence a un id unique', () => {
|
||||
const occs = scanOccurrences(TEXT, FILE, RULES);
|
||||
const ids = occs.map((o) => o.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
215
tests/unit/PseudonymizationEngine.test.ts
Normal file
215
tests/unit/PseudonymizationEngine.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { PseudonymizationEngine } from '../../src/pseudonymizer/PseudonymizationEngine';
|
||||
import { sortRules, findSpansForRule, buildReplacementPlan } from '../../src/pseudonymizer/ReplacementPlanner';
|
||||
import { resolveSpans, applySpans } from '../../src/pseudonymizer/SpanProtector';
|
||||
import type { MappingRule } from '../../src/types';
|
||||
|
||||
const FILE_SCOPE = { type: 'file' as const, path: 'entretien_01.srt' };
|
||||
|
||||
function rule(
|
||||
id: string,
|
||||
source: string,
|
||||
replacement: string,
|
||||
priority = 0
|
||||
): MappingRule {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
replacement,
|
||||
category: 'first_name',
|
||||
scope: FILE_SCOPE,
|
||||
status: 'validated',
|
||||
priority,
|
||||
createdBy: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const engine = new PseudonymizationEngine();
|
||||
|
||||
// --- SPECS §17.1 : remplacement simple ---
|
||||
|
||||
describe('Remplacement simple (SPECS §17.1)', () => {
|
||||
it('Jean → Pierre dans une phrase courte', () => {
|
||||
expect(engine.pseudonymize('Bonjour Jean.', [rule('1', 'Jean', 'Pierre')])).toBe(
|
||||
'Bonjour Pierre.'
|
||||
);
|
||||
});
|
||||
|
||||
it('remplace toutes les occurrences dans le texte', () => {
|
||||
const result = engine.pseudonymize('Jean parle à Jean.', [rule('1', 'Jean', 'Pierre')]);
|
||||
expect(result).toBe('Pierre parle à Pierre.');
|
||||
});
|
||||
|
||||
it("n'applique pas les règles au statut 'suggested'", () => {
|
||||
const suggested: MappingRule = { ...rule('1', 'Jean', 'Pierre'), status: 'suggested' };
|
||||
expect(engine.pseudonymize('Bonjour Jean.', [suggested])).toBe('Bonjour Jean.');
|
||||
});
|
||||
});
|
||||
|
||||
// --- SPECS §17.2 : remplacement composé prioritaire ---
|
||||
|
||||
describe('Remplacement composé — jamais Saint-Pierre-de-Luz (SPECS §17.2)', () => {
|
||||
const rules = [
|
||||
rule('1', 'Jean', 'Pierre', 0),
|
||||
rule('2', 'Saint-Jean-de-Luz', 'Ville moyenne limitrophe', 0),
|
||||
];
|
||||
|
||||
it('produit Pierre habite Ville moyenne limitrophe.', () => {
|
||||
const result = engine.pseudonymize('Jean habite Saint-Jean-de-Luz.', rules);
|
||||
expect(result).toBe('Pierre habite Ville moyenne limitrophe.');
|
||||
});
|
||||
|
||||
it('ne produit jamais Saint-Pierre-de-Luz', () => {
|
||||
const result = engine.pseudonymize('Jean habite Saint-Jean-de-Luz.', rules);
|
||||
expect(result).not.toContain('Saint-Pierre-de-Luz');
|
||||
});
|
||||
|
||||
it("Jean seul (hors contexte composé) est bien remplacé par Pierre", () => {
|
||||
const result = engine.pseudonymize('Jean habite Saint-Jean-de-Luz.', rules);
|
||||
expect(result.startsWith('Pierre')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- SPECS §18.2 : tests de non-régression ---
|
||||
|
||||
describe('Non-régression (SPECS §18.2)', () => {
|
||||
it('Paul / Saint-Paul : Saint-Paul prime sur Paul', () => {
|
||||
const rules = [rule('1', 'Paul', 'Marc', 0), rule('2', 'Saint-Paul', 'Commune rurale', 0)];
|
||||
const result = engine.pseudonymize('Paul habite Saint-Paul.', rules);
|
||||
expect(result).toBe('Marc habite Commune rurale.');
|
||||
expect(result).not.toContain('Saint-Marc');
|
||||
});
|
||||
|
||||
it('Montpellier / CHU de Montpellier : entité longue prime', () => {
|
||||
const rules = [
|
||||
rule('1', 'Montpellier', 'Métropole du Sud', 0),
|
||||
rule('2', 'CHU de Montpellier', 'CHU régional', 0),
|
||||
];
|
||||
const result = engine.pseudonymize(
|
||||
'Elle travaille au CHU de Montpellier à Montpellier.',
|
||||
rules
|
||||
);
|
||||
expect(result).toBe('Elle travaille au CHU régional à Métropole du Sud.');
|
||||
expect(result).not.toContain('CHU de Métropole');
|
||||
});
|
||||
|
||||
it('Marie / Sainte-Marie : Sainte-Marie prime sur Marie', () => {
|
||||
const rules = [rule('1', 'Marie', 'Sophie', 0), rule('2', 'Sainte-Marie', 'Commune côtière', 0)];
|
||||
const result = engine.pseudonymize('Marie vient de Sainte-Marie.', rules);
|
||||
expect(result).toBe('Sophie vient de Commune côtière.');
|
||||
expect(result).not.toContain('Sainte-Sophie');
|
||||
});
|
||||
|
||||
it('Luz / Saint-Jean-de-Luz : Saint-Jean-de-Luz prime sur Luz', () => {
|
||||
const rules = [
|
||||
rule('1', 'Luz', 'Ville X', 0),
|
||||
rule('2', 'Saint-Jean-de-Luz', 'Ville moyenne limitrophe', 0),
|
||||
];
|
||||
const result = engine.pseudonymize('Il vient de Saint-Jean-de-Luz.', rules);
|
||||
expect(result).toBe('Il vient de Ville moyenne limitrophe.');
|
||||
expect(result).not.toContain('Saint-Jean-de-Ville X');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Comportement du z-index (priorité manuelle) ---
|
||||
|
||||
describe('Priorité z-index', () => {
|
||||
it('une règle courte avec priority élevée passe avant une règle longue à priority 0', () => {
|
||||
// L'utilisateur a explicitement choisi de traiter Jean avant l'entité composée
|
||||
const rules = [rule('1', 'Jean', 'Pierre', 100), rule('2', 'Saint-Jean-de-Luz', 'Ville X', 0)];
|
||||
const result = engine.pseudonymize('Jean habite Saint-Jean-de-Luz.', rules);
|
||||
// Jean [0,4] priority 100 → accepté en premier
|
||||
// Jean [18,22] inside Saint-Jean-de-Luz → accepté (priority 100, avant [12,29] priority 0)
|
||||
// Saint-Jean-de-Luz chevauche avec Jean [18,22] → rejeté
|
||||
// Résultat : Pierre habite Saint-Pierre-de-Luz. (comportement attendu quand l'utilisateur choisit ce z-index)
|
||||
expect(result).toContain('Pierre');
|
||||
expect(result).not.toContain('Jean');
|
||||
});
|
||||
|
||||
it('deux règles à même priority : la plus longue source prime', () => {
|
||||
const rules = [rule('1', 'Marie', 'Sophie', 5), rule('2', 'Sainte-Marie', 'Commune', 5)];
|
||||
const result = engine.pseudonymize('Marie vient de Sainte-Marie.', rules);
|
||||
expect(result).toBe('Sophie vient de Commune.');
|
||||
expect(result).not.toContain('Sainte-Sophie');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Whole-word matching ---
|
||||
|
||||
describe('Whole-word matching', () => {
|
||||
it('Jean ne remplace pas Jeannine', () => {
|
||||
const result = engine.pseudonymize('Bonjour Jeannine.', [rule('1', 'Jean', 'Pierre')]);
|
||||
expect(result).toBe('Bonjour Jeannine.');
|
||||
});
|
||||
|
||||
it('Jean ne remplace pas Jean-Baptiste', () => {
|
||||
const result = engine.pseudonymize('Bonjour Jean-Baptiste.', [rule('1', 'Jean', 'Pierre')]);
|
||||
// Jean est séparé par - (non alphanumérique) → considéré whole-word → remplacé
|
||||
// C'est le comportement attendu : Jean-Baptiste avec Jean comme prénom → Pierre-Baptiste
|
||||
expect(result).toBe('Bonjour Pierre-Baptiste.');
|
||||
});
|
||||
|
||||
it('Marie ne remplace pas Mariette', () => {
|
||||
const result = engine.pseudonymize('Bonjour Mariette.', [rule('1', 'Marie', 'Sophie')]);
|
||||
expect(result).toBe('Bonjour Mariette.');
|
||||
});
|
||||
});
|
||||
|
||||
// --- ReplacementPlanner unitaire ---
|
||||
|
||||
describe('ReplacementPlanner.sortRules', () => {
|
||||
it('trie par priority décroissante', () => {
|
||||
const rules = [rule('1', 'Jean', 'Pierre', 0), rule('2', 'Marie', 'Sophie', 10)];
|
||||
const sorted = sortRules(rules);
|
||||
expect(sorted[0].source).toBe('Marie');
|
||||
});
|
||||
|
||||
it('à priority égale, trie par longueur source décroissante', () => {
|
||||
const rules = [rule('1', 'Jean', 'Pierre', 0), rule('2', 'Saint-Jean-de-Luz', 'X', 0)];
|
||||
const sorted = sortRules(rules);
|
||||
expect(sorted[0].source).toBe('Saint-Jean-de-Luz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplacementPlanner.findSpansForRule', () => {
|
||||
const r = rule('1', 'jean', 'Pierre', 0);
|
||||
|
||||
it('trouve les occurrences en mode case-insensitive (défaut)', () => {
|
||||
const spans = findSpansForRule('Bonjour Jean et jean.', r, {
|
||||
caseSensitive: false,
|
||||
wholeWordOnly: false,
|
||||
});
|
||||
expect(spans).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('whole-word : ne trouve pas jean dans Jeannine', () => {
|
||||
const spans = findSpansForRule('Bonjour Jeannine.', r, {
|
||||
caseSensitive: false,
|
||||
wholeWordOnly: true,
|
||||
});
|
||||
expect(spans).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- SpanProtector unitaire ---
|
||||
|
||||
describe('SpanProtector', () => {
|
||||
it('resolveSpans élimine un span court qui chevauche un span long', () => {
|
||||
const spans = [
|
||||
{ start: 0, end: 4, source: 'Jean', replacement: 'Pierre', mappingId: '1', priority: 0 },
|
||||
{ start: 0, end: 17, source: 'Jean Dupont', replacement: 'Sophie Arnaud', mappingId: '2', priority: 0 },
|
||||
];
|
||||
const resolved = resolveSpans(spans);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe('Jean Dupont');
|
||||
});
|
||||
|
||||
it('applySpans applique de droite à gauche sans décaler les indices', () => {
|
||||
const text = 'Jean habite Lyon.';
|
||||
const spans = resolveSpans([
|
||||
{ start: 0, end: 4, source: 'Jean', replacement: 'Pierre', mappingId: '1', priority: 0 },
|
||||
{ start: 12, end: 16, source: 'Lyon', replacement: 'Grenoble', mappingId: '2', priority: 0 },
|
||||
]);
|
||||
expect(applySpans(text, spans)).toBe('Pierre habite Grenoble.');
|
||||
});
|
||||
});
|
||||
111
tests/unit/SrtParser.test.ts
Normal file
111
tests/unit/SrtParser.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { SrtParser } from '../../src/parsers/SrtParser';
|
||||
|
||||
const FIXTURE = path.join(__dirname, '../fixtures/entretien_01.srt');
|
||||
|
||||
describe('SrtParser', () => {
|
||||
const parser = new SrtParser();
|
||||
let content: string;
|
||||
|
||||
beforeAll(() => {
|
||||
content = fs.readFileSync(FIXTURE, 'utf-8');
|
||||
});
|
||||
|
||||
// --- Structure ---
|
||||
|
||||
it('parse le bon nombre de blocs', () => {
|
||||
const doc = parser.parse(content);
|
||||
expect(doc.blocks).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('extrait index, timestamps et texte du bloc 1', () => {
|
||||
const { blocks } = parser.parse(content);
|
||||
expect(blocks[0].index).toBe(1);
|
||||
expect(blocks[0].startTime).toBe('00:00:01,000');
|
||||
expect(blocks[0].endTime).toBe('00:00:04,500');
|
||||
expect(blocks[0].lines).toEqual(['Bonjour Jean, tu peux te présenter ?']);
|
||||
});
|
||||
|
||||
it('extrait correctement le bloc 2 (Jean Dupont / Saint-Jean-de-Luz)', () => {
|
||||
const { blocks } = parser.parse(content);
|
||||
expect(blocks[1].index).toBe(2);
|
||||
expect(blocks[1].startTime).toBe('00:00:05,000');
|
||||
expect(blocks[1].endTime).toBe('00:00:09,200');
|
||||
expect(blocks[1].lines[0]).toContain('Jean Dupont');
|
||||
expect(blocks[1].lines[0]).toContain('Saint-Jean-de-Luz');
|
||||
});
|
||||
|
||||
it('les blocs sont numérotés séquentiellement', () => {
|
||||
const { blocks } = parser.parse(content);
|
||||
blocks.forEach((b, i) => expect(b.index).toBe(i + 1));
|
||||
});
|
||||
|
||||
// --- Round-trip (SPECS §17.4) ---
|
||||
|
||||
it('round-trip exact : reconstruct(parse(content)) === content', () => {
|
||||
const doc = parser.parse(content);
|
||||
expect(parser.reconstruct(doc)).toBe(content);
|
||||
});
|
||||
|
||||
it('les timestamps sont inchangés après reconstruction', () => {
|
||||
const doc = parser.parse(content);
|
||||
const lines = parser.reconstruct(doc).split('\n');
|
||||
expect(lines[1]).toBe('00:00:01,000 --> 00:00:04,500');
|
||||
expect(lines[5]).toBe('00:00:05,000 --> 00:00:09,200');
|
||||
});
|
||||
|
||||
it('les numéros de blocs sont inchangés après reconstruction', () => {
|
||||
const doc = parser.parse(content);
|
||||
const lines = parser.reconstruct(doc).split('\n');
|
||||
expect(lines[0]).toBe('1');
|
||||
expect(lines[4]).toBe('2');
|
||||
});
|
||||
|
||||
// --- Remplacement de texte sans toucher la structure ---
|
||||
|
||||
it("modifier le texte d'un bloc ne touche pas son timestamp", () => {
|
||||
const doc = parser.parse(content);
|
||||
doc.blocks[0].lines[0] = 'Bonjour Pierre, tu peux te présenter ?';
|
||||
const lines = parser.reconstruct(doc).split('\n');
|
||||
expect(lines[1]).toBe('00:00:01,000 --> 00:00:04,500');
|
||||
expect(lines[2]).toBe('Bonjour Pierre, tu peux te présenter ?');
|
||||
});
|
||||
|
||||
it('remplacer Jean par Pierre dans le bloc 2 préserve Saint-Jean-de-Luz', () => {
|
||||
const doc = parser.parse(content);
|
||||
// Remplacement correct (entité longue d'abord) : Saint-Jean-de-Luz → Ville littorale,
|
||||
// puis Jean → Pierre sur les occurrences restantes
|
||||
doc.blocks[1].lines[0] = doc.blocks[1].lines[0]
|
||||
.replace('Saint-Jean-de-Luz', 'Ville littorale')
|
||||
.replace('Jean Dupont', 'Pierre Martin');
|
||||
const result = parser.reconstruct(doc);
|
||||
expect(result).not.toContain('Saint-Pierre-de-Luz');
|
||||
expect(result).toContain('Ville littorale');
|
||||
expect(result).toContain('Pierre Martin');
|
||||
});
|
||||
|
||||
// --- Robustesse ---
|
||||
|
||||
it('parse un SRT avec fins de ligne Windows (CRLF)', () => {
|
||||
const crlf = content.replace(/\n/g, '\r\n');
|
||||
const doc = parser.parse(crlf);
|
||||
expect(doc.blocks).toHaveLength(6);
|
||||
expect(doc.blocks[0].startTime).toBe('00:00:01,000');
|
||||
expect(doc.blocks[0].endTime).toBe('00:00:04,500');
|
||||
});
|
||||
|
||||
it('parse un SRT avec plusieurs lignes vides entre blocs', () => {
|
||||
const extraBlank = content.replace(/\n\n/g, '\n\n\n');
|
||||
const doc = parser.parse(extraBlank);
|
||||
expect(doc.blocks).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('parse un SRT sans saut de ligne final', () => {
|
||||
const noTrailing = content.trimEnd();
|
||||
const doc = parser.parse(noTrailing);
|
||||
expect(doc.trailingNewline).toBe(false);
|
||||
expect(doc.blocks).toHaveLength(6);
|
||||
expect(parser.reconstruct(doc)).toBe(noTrailing);
|
||||
});
|
||||
});
|
||||
93
tests/unit/TranscriptConverter.test.ts
Normal file
93
tests/unit/TranscriptConverter.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { SrtParser } from '../../src/parsers/SrtParser';
|
||||
import { ChatParser } from '../../src/parsers/ChatParser';
|
||||
import { srtToMarkdown, chatToMarkdown } from '../../src/parsers/TranscriptConverter';
|
||||
|
||||
const SRT_FIXTURE = path.join(__dirname, '../fixtures/entretien_01.srt');
|
||||
const CHA_FIXTURE = path.join(__dirname, '../fixtures/entretien_02.cha');
|
||||
|
||||
describe('srtToMarkdown', () => {
|
||||
let md: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const content = fs.readFileSync(SRT_FIXTURE, 'utf-8');
|
||||
const doc = new SrtParser().parse(content);
|
||||
md = srtToMarkdown(doc, 'entretien_01.srt');
|
||||
});
|
||||
|
||||
it('produit un frontmatter avec le format et la source', () => {
|
||||
expect(md).toContain('pseudobs-format: srt');
|
||||
expect(md).toContain('pseudobs-source: "entretien_01.srt"');
|
||||
});
|
||||
|
||||
it('contient le texte de chaque bloc SRT', () => {
|
||||
expect(md).toContain('Bonjour Jean, tu peux te présenter ?');
|
||||
expect(md).toContain("Saint-Jean-de-Luz");
|
||||
expect(md).toContain('CHU de Montpellier');
|
||||
});
|
||||
|
||||
it('affiche les timestamps en italique', () => {
|
||||
expect(md).toContain('*00:00:01,000 → 00:00:04,500*');
|
||||
expect(md).toContain('*00:00:05,000 → 00:00:09,200*');
|
||||
});
|
||||
|
||||
it('affiche les numéros de blocs en gras entre crochets', () => {
|
||||
expect(md).toContain('**[1]**');
|
||||
expect(md).toContain('**[6]**');
|
||||
});
|
||||
|
||||
it('ne contient pas de syntaxe SRT brute (-->) en dehors du frontmatter', () => {
|
||||
const body = md.split('---').slice(2).join('---');
|
||||
expect(body).not.toContain('-->');
|
||||
});
|
||||
|
||||
it('termine par un saut de ligne', () => {
|
||||
expect(md.endsWith('\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatToMarkdown', () => {
|
||||
let md: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const content = fs.readFileSync(CHA_FIXTURE, 'utf-8');
|
||||
const doc = new ChatParser().parse(content);
|
||||
md = chatToMarkdown(doc, 'entretien_02.cha');
|
||||
});
|
||||
|
||||
it('produit un frontmatter avec le format et la source', () => {
|
||||
expect(md).toContain('pseudobs-format: chat');
|
||||
expect(md).toContain('pseudobs-source: "entretien_02.cha"');
|
||||
});
|
||||
|
||||
it('convertit les tours de parole en **SPEAKER** : texte', () => {
|
||||
expect(md).toContain('**INV** : bonjour Marie, tu peux te présenter ?');
|
||||
expect(md).toContain('**PAR** : je m\'appelle Marie Dupont');
|
||||
});
|
||||
|
||||
it('convertit les lignes @ en blockquote', () => {
|
||||
expect(md).toContain('> @Begin');
|
||||
expect(md).toContain('> @Languages: fra');
|
||||
expect(md).toContain('> @End');
|
||||
});
|
||||
|
||||
it('convertit les lignes % en blockquote', () => {
|
||||
expect(md).toContain('> %com: PAR marque une hésitation');
|
||||
});
|
||||
|
||||
it('contient le cas Nancy ville/prenom dans un tour PAR', () => {
|
||||
expect(md).toContain('**PAR** : oui (0.5) à Nancy');
|
||||
expect(md).toContain('la ville');
|
||||
expect(md).toContain('le prénom');
|
||||
});
|
||||
|
||||
it('ne contient pas de syntaxe CHA brute (*SPEAKER:) en dehors du frontmatter', () => {
|
||||
const body = md.split('---').slice(2).join('---');
|
||||
expect(body).not.toMatch(/^\*[A-Z]+:/m);
|
||||
});
|
||||
|
||||
it('termine par un saut de ligne', () => {
|
||||
expect(md.endsWith('\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
125
tests/unit/types.test.ts
Normal file
125
tests/unit/types.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type {
|
||||
MappingRule,
|
||||
MappingStatus,
|
||||
EntityCategory,
|
||||
ScopeType,
|
||||
Occurrence,
|
||||
ReplacementSpan,
|
||||
DictionaryEntry,
|
||||
MappingFile,
|
||||
} from '../../src/types';
|
||||
|
||||
// Smoke test Phase 0 : les types sont cohérents et les valeurs par défaut correctes
|
||||
|
||||
describe('Types partagés', () => {
|
||||
it('MappingRule accepte priority = 0 par défaut (z-index)', () => {
|
||||
const rule: MappingRule = {
|
||||
id: 'map_000001',
|
||||
source: 'Jean',
|
||||
replacement: 'Pierre',
|
||||
category: 'first_name',
|
||||
scope: { type: 'file', path: 'entretien_01.srt' },
|
||||
status: 'validated',
|
||||
priority: 0,
|
||||
createdBy: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
expect(rule.priority).toBe(0);
|
||||
expect(rule.source).toBe('Jean');
|
||||
});
|
||||
|
||||
it('MappingRule avec priority élevée passe avant priority basse', () => {
|
||||
const rules: MappingRule[] = [
|
||||
{ id: '1', source: 'Jean', replacement: 'Pierre', category: 'first_name',
|
||||
scope: { type: 'file' }, status: 'validated', priority: 0,
|
||||
createdBy: 'user', createdAt: '' },
|
||||
{ id: '2', source: 'Saint-Jean-de-Luz', replacement: 'Ville moyenne littorale',
|
||||
category: 'place', scope: { type: 'file' }, status: 'validated', priority: 10,
|
||||
createdBy: 'user', createdAt: '' },
|
||||
];
|
||||
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
|
||||
expect(sorted[0].source).toBe('Saint-Jean-de-Luz');
|
||||
});
|
||||
|
||||
it('ScopeType couvre les trois portées', () => {
|
||||
const portées: ScopeType[] = ['file', 'folder', 'vault'];
|
||||
expect(portées).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('MappingStatus couvre tous les statuts définis dans SPECS §5.4', () => {
|
||||
const statuts: MappingStatus[] = [
|
||||
'suggested', 'validated', 'ignored', 'partial',
|
||||
'conflict', 'disabled', 'needs_review',
|
||||
];
|
||||
expect(statuts).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('EntityCategory couvre toutes les catégories', () => {
|
||||
const catégories: EntityCategory[] = [
|
||||
'first_name', 'last_name', 'full_name', 'place',
|
||||
'institution', 'date', 'age', 'profession', 'custom',
|
||||
];
|
||||
expect(catégories).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('ReplacementSpan positionne correctement un remplacement', () => {
|
||||
const span: ReplacementSpan = {
|
||||
start: 8,
|
||||
end: 12,
|
||||
source: 'Jean',
|
||||
replacement: 'Pierre',
|
||||
mappingId: 'map_000001',
|
||||
priority: 0,
|
||||
};
|
||||
expect(span.end - span.start).toBe(span.source.length);
|
||||
});
|
||||
|
||||
it('DictionaryEntry Coulmont contient les métadonnées sociologiques', () => {
|
||||
const entry: DictionaryEntry = {
|
||||
value: 'Thibault',
|
||||
type: 'first_name',
|
||||
gender: 'masculine',
|
||||
decade: 1989,
|
||||
socialClass: 'supérieur',
|
||||
origin: 'fr',
|
||||
replacementCandidates: ['Édouard', 'Gauthier', 'Clément'],
|
||||
};
|
||||
expect(entry.socialClass).toBe('supérieur');
|
||||
expect(entry.decade).toBe(1989);
|
||||
});
|
||||
|
||||
it('MappingFile respecte le schéma SPECS §5.2', () => {
|
||||
const table: MappingFile = {
|
||||
schemaVersion: '1.0.0',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
project: 'UJAA-2026',
|
||||
scope: { type: 'folder', path: 'Transcriptions/UJAA' },
|
||||
mappings: [],
|
||||
};
|
||||
expect(table.schemaVersion).toBe('1.0.0');
|
||||
expect(table.scope.type).toBe('folder');
|
||||
});
|
||||
|
||||
it('cas non-régression : Jean ne doit jamais remplacer Saint-Jean-de-Luz avant lui', () => {
|
||||
// Le span le plus long avec priority >= span court doit être appliqué en premier
|
||||
const spans: ReplacementSpan[] = [
|
||||
{ start: 14, end: 31, source: 'Saint-Jean-de-Luz', replacement: 'Ville moyenne littorale', mappingId: '2', priority: 10 },
|
||||
{ start: 21, end: 25, source: 'Jean', replacement: 'Pierre', mappingId: '1', priority: 0 },
|
||||
];
|
||||
// Après résolution, seul le span englobant doit être retenu
|
||||
const sorted = [...spans].sort((a, b) => {
|
||||
if (b.priority !== a.priority) return b.priority - a.priority;
|
||||
return (b.end - b.start) - (a.end - a.start);
|
||||
});
|
||||
const accepted: ReplacementSpan[] = [];
|
||||
for (const candidate of sorted) {
|
||||
const overlaps = accepted.some(
|
||||
(s) => candidate.start < s.end && candidate.end > s.start
|
||||
);
|
||||
if (!overlaps) accepted.push(candidate);
|
||||
}
|
||||
expect(accepted).toHaveLength(1);
|
||||
expect(accepted[0].source).toBe('Saint-Jean-de-Luz');
|
||||
});
|
||||
});
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2018",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"lib": ["ES2018", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.0.1": "1.4.0"
|
||||
}
|
||||
Loading…
Reference in a new issue