This commit is contained in:
Axelle Abbadie 2026-05-12 01:55:27 +02:00
commit d216e81fb2
45 changed files with 12352 additions and 0 deletions

20
.eslintrc.json Normal file
View 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
View 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
View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

18
LICENSE Normal file
View 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
View 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
View 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 06 Parsers · Moteur · Commandes UI · Portées · Surlignage · Validation sélective
🔄 Phase 7 Dictionnaires — Coulmont ✅ · Dictionnaires JSON/CSV · Ressources embarquées
⏳ Phase 8 Scan automatique (détection sans règle préexistante)
⏳ Phase 9 Interface complète (panneau latéral 4 onglets)
⏳ Phase 10 Publication communauté Obsidian
```
---
---
## ✅ 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 |

1230
SPECS.md Normal file

File diff suppressed because it is too large Load diff

39
esbuild.config.mjs Normal file
View 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
View 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,
},
},
},
};

1549
main.js Normal file

File diff suppressed because it is too large Load diff

10
manifest.json Normal file
View 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

File diff suppressed because it is too large Load diff

30
package.json Normal file
View 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
View 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);
}
}

View 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();
}
}

View 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);
}
}
}

View 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
View 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
View 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;
}
}

View 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;
}

View 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);
}
}

View 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;
}

View 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;
}

View 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
View 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
View 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
View 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
View 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();
}
}

View 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 }
);
}

View 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
View 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
View 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);
}

View 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
View 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
View 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
View 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

View 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');
});
});

View 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');
});
});

View 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);
});
});

View 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.');
});
});

View 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);
});
});

View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"0.0.1": "1.4.0"
}