diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e1e63a6..ccfabfc 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -1,5 +1,40 @@ # Changelog +## [prod] v0.1.5 — 17 mai 2026 + +### Nouvelles fonctionnalités + +**Intégration noScribe** +- **`VttParser`** — WebVTT standard avec word timestamps Whisper (`<00:00:01.240>mot`), tags ``, round-trip garanti +- **`NoScribeHtmlParser`** — HTML Qt Rich Text noScribe : ancres `ts_DEBUT_FIN_SXX` (ms), labels locuteurs (`S00:`, `S01:`), timestamps d'affichage filtrés, entités HTML décodées ; `extractAudioSource()` lit `` +- **`NoScribeVttParser`** — VTT noScribe v0.7 : cues splitées (label / timestamp affichage / texte), détection locuteur par préfixe `SXX:`, entités HTML, source audio depuis `NOTE media:` +- **Format Markdown noScribe** — `**S00** [HH:MM:SS] : texte` par tour ; `pseudobs-format: html|vtt` et `pseudobs-audio` optionnel en frontmatter ; timestamps word-level dans `.words.json` +- **Import audio automatique** — depuis la meta tag HTML ou le dossier source (VTT, un seul fichier audio) +- **`Redaction.ts`** — caviardage par syllabes avec `🀫` ; `generateRedaction()` et `isRedaction()` +- **Re-export VTT** (commande "Exporter en VTT") — WebVTT propre depuis `.md` pseudonymisé + `.words.json` + +**Modale de scan — candidats par occurrence** +- **`MappingScanReviewModal`** — colonne occurrences cliquable (compteur mis à jour en direct : `N / total` si exceptions) +- **`OccurrencesContextModal`** — cartes par occurrence (✓ / ✗ / ⚠) avec contexte, callback "Confirmer la sélection", sans application immédiate +- **Bouton "Enregistrer les exceptions"** — sauvegarde les décisions dans le `mapping.json` sans pseudonymiser +- `MappingRuleResult` porte maintenant `occurrences: Occurrence[]` + +**Exceptions** +- Type **`IgnoredOccurrence`** (`{text, contextBefore, contextAfter}`) sur `MappingRule.ignoredOccurrences` +- Persistance dans `mapping.json`, chargement au démarrage — surlignage rouge sans re-scan +- **Surlignage rouge** (`pseudobs-exception`, sensible à la casse, priorité 0) +- **Section "Exceptions" dans l'onglet Mappings** — cartes contextuelles avec bouton supprimer + +### Modifications +- Labels de statut clarifiés : `validated` → "Actif", `ignored` → "Ignoré", `partial` → "Partiel", `suggested` → "Suggéré" ; colonne renommée "État" +- `getValidatedFor()` inclut maintenant `partial` comme état actif (avec `validated`) +- Bouton "Scanner" de `PseudonymizationView` corrigé (`occurrences` manquant dans `MappingRuleResult`) + +### Tests +149 tests · 10 suites · `VttParser`, `NoScribeHtmlParser`, `NoScribeVttParser`, `markdownToVtt`, `extractWordData` couverts ; fixtures : `fight_club.vtt`, `juste-leblanc.html` + +--- + ## [prod] v0.1.3 — 14 mai 2026 **Branche :** `main` | **Tag :** `0.1.3` diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c2611..338176d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ > Previous entries in French: [CHANGELOG.fr.md](CHANGELOG.fr.md) +## [prod] v0.1.5 — 17 May 2026 + +### New features + +**noScribe integration** +- **`VttParser`** — standard WebVTT with Whisper word-level timestamps (`<00:00:01.240>word`), speaker tags ``, round-trip guarantee +- **`NoScribeHtmlParser`** — Qt Rich Text HTML produced by noScribe: `ts_START_END_SXX` anchors (ms), speaker labels (`S00:`, `S01:`), display timestamps filtered, HTML entities decoded; `extractAudioSource()` reads `` +- **`NoScribeVttParser`** — noScribe VTT v0.7: split cues (label / display timestamp / text), speaker detection from `SXX:` prefix, HTML entity decoding, audio source from `NOTE media:` +- **noScribe Markdown format** — `**S00** [HH:MM:SS] : text` per turn; `pseudobs-format: html|vtt` and optional `pseudobs-audio` in frontmatter; word timestamps in `.words.json` +- **Audio import** — automatic on HTML import (via `audio_source` meta) and on VTT import via file picker (single audio file in source folder) +- **`Redaction.ts`** — syllable-count redaction with `🀫`; `generateRedaction()` and `isRedaction()` +- **VTT re-export** (`Export as VTT` command) — clean WebVTT from pseudonymized `.md` + `.words.json` + +**Scan modal — per-occurrence candidates** +- **`MappingScanReviewModal`** — occurrences column is a clickable button (count updated live to `N / total` when some are ignored) +- **`OccurrencesContextModal`** — per-occurrence cards (✓ / ✗ / ⚠) with context, "Confirm selection" callback, no immediate application +- **"Save exceptions" button** — saves ignored decisions to `mapping.json` without pseudonymizing +- `MappingRuleResult` now carries `occurrences: Occurrence[]` + +**Exceptions** +- **`IgnoredOccurrence`** type (`{text, contextBefore, contextAfter}`) on `MappingRule.ignoredOccurrences` +- Persisted in `mapping.json`, loaded at startup — red highlighting without re-scanning +- **Red highlighting** (`pseudobs-exception`, case-sensitive, priority 0) +- **Exceptions section in Mappings tab** — context cards with delete button + +### Changes +- Status labels: `validated` → "Active", `ignored` → "Ignored", `partial` → "Partial", `suggested` → "Suggested"; column renamed "State" +- `getValidatedFor()` now includes `partial` as active (alongside `validated`) +- `PseudonymizationView` scan button fixed (missing `occurrences` in `MappingRuleResult`) + +### Tests +149 tests · 10 suites · `VttParser`, `NoScribeHtmlParser`, `NoScribeVttParser`, `markdownToVtt`, `extractWordData` covered; fixtures: `fight_club.vtt`, `juste-leblanc.html` + +--- + ## [prod] v0.1.4 — 16 May 2026 **Branch:** `main` | **Tag:** upcoming diff --git a/README.fr.md b/README.fr.md index b759c4d..ae89eba 100644 --- a/README.fr.md +++ b/README.fr.md @@ -10,13 +10,13 @@ Plugin Obsidian de **pseudonymisation et de correction de transcriptions** pour ## Workflow ``` -Transcription brute (.srt, .cha, .md, .txt) - ↓ import automatique -Obsidian — édition native Markdown - ↓ pseudonymisation (manuelle ou NER) -Fichier source annoté + table de correspondance (séparée) +noScribe (.html, .vtt) ou transcription brute (.srt, .cha, .md, .txt) + ↓ import automatique (fichier audio importé en même temps) +Obsidian — édition native Markdown (**S00** [HH:MM:SS] : texte) + ↓ pseudonymisation (manuelle · NER · scan dictionnaires) +Fichier source annoté + table de correspondance + timestamps (.words.json) ↓ export -Transcription pseudonymisée (.pseudonymized.*) +Transcription pseudonymisée (.pseudonymized.md / .pseudonymized.vtt) ``` Deux approches, combinables librement : @@ -44,12 +44,17 @@ L'assistant est relançable à tout moment : Paramètres → Pseudonymizer tool | Format | Extension | Notes | |---|---|---| +| **HTML noScribe** | `.html` | Qt Rich Text produit par noScribe — locuteurs, timestamps word-level, chemin audio | +| **VTT noScribe** | `.vtt` | Sortie noScribe v0.7 — aussi WebVTT Whisper standard avec timestamps mot par mot | | Sous-titres horodatés | `.srt` | Sortie Whisper / IA — horodatages et structure préservés | | CHAT / CLAN | `.cha`, `.chat` | Lignes `@`, `*`, `%` préservées | | Markdown annoté | `.md` | Conventions Jefferson ou ICOR | | Texte brut | `.txt` | Sans marqueurs de convention | -Les fichiers `.srt` et `.cha` sont automatiquement convertis en Markdown à l'import. Un fichier de mapping JSON vide est créé en même temps. +Tous les formats sont automatiquement convertis en Markdown à l'import. En parallèle, le plugin crée : +- `.mapping.json` — règles de pseudonymisation +- `.words.json` — timestamps word-level (noScribe / Whisper uniquement), utilisé pour le re-export VTT +- Si un fichier audio est référencé dans la transcription, il est importé dans le vault automatiquement. > Installez le plugin [Data Files Editor](https://github.com/zuktol/obsidian-data-files-editor) pour visionner les fichiers de mapping JSON directement depuis votre vault. diff --git a/README.md b/README.md index 9787789..30627ab 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ An Obsidian plugin for **pseudonymizing and correcting interactional transcripts ## Workflow ``` -Raw transcript (.srt, .cha, .md, .txt) - ↓ automatic import -Obsidian — native Markdown editing - ↓ pseudonymization (manual or NER) -Annotated source file + correspondence table (separate) +noScribe (.html, .vtt) or raw transcript (.srt, .cha, .md, .txt) + ↓ automatic import (audio file imported alongside) +Obsidian — native Markdown editing (**S00** [HH:MM:SS] : text) + ↓ pseudonymization (manual · NER · dictionary scan) +Annotated source file + correspondence table + word timestamps (.words.json) ↓ export -Pseudonymized transcript (.pseudonymized.*) +Pseudonymized transcript (.pseudonymized.md / .pseudonymized.vtt) ``` Two approaches, freely combined: @@ -45,12 +45,17 @@ The wizard can be relaunched at any time: Settings → Pseudonymizer Tool → Se | Format | Extension | Notes | |---|---|---| +| **noScribe HTML** | `.html` | Qt Rich Text from noScribe — speaker labels, word timestamps, audio path | +| **noScribe VTT** | `.vtt` | noScribe v0.7 output — also standard Whisper WebVTT with word timestamps | | Timestamped subtitles | `.srt` | Whisper / AI output — timestamps and structure preserved | | CHAT / CLAN | `.cha`, `.chat` | `@`, `*`, `%` lines preserved | | Annotated Markdown | `.md` | Jefferson or ICOR conventions | | Plain text | `.txt` | No convention markers | -`.srt` and `.cha` files are automatically converted to Markdown on import. An empty mapping JSON file is created at the same time. +All formats are automatically converted to Markdown on import. Alongside the `.md`, the plugin creates: +- `.mapping.json` — pseudonymization rules +- `.words.json` — word-level timestamps (noScribe / Whisper only), used for VTT re-export +- If an audio file is referenced in the transcript, it is imported to the vault automatically. > Install the [Data Files Editor](https://github.com/zuktol/obsidian-data-files-editor) plugin to view mapping JSON files directly in your vault. @@ -103,9 +108,9 @@ Accessible via the ribbon icon or `Ctrl+P → Pseudonymization: open panel`. | Tab | Content | |---|---| -| **Mappings** | Active rules · Edit · Delete · Add · Scan file | +| **Mappings** | Active rules · Edit · Delete · Add · Scan file · **Exceptions section** | | **Dictionaries** | Mini cards · Dictionary scan · Local import | -| **Exports** | Pseudonymize and export · Export correspondence table | +| **Exports** | Pseudonymize and export · Export correspondence table · **Export as VTT** | | **NER** | Visible if NER enabled · Identify candidates · Confidence threshold · Function words | ### Highlighting and markers @@ -117,13 +122,15 @@ Highlighting is active in all open files, including `.pseudonymized.*` export fi | 🟠 Orange + outline | Source term still present — to be pseudonymized | | 🟢 Green + underline | Pseudonym applied directly in the file | | 🔵 Blue + outline | NER candidate — no rule yet | +| 🔴 Red + underline | **Exception** — occurrence explicitly ignored during scan (case-sensitive; persisted in mapping) | In exported files, pseudonyms are wrapped in `{{Pierre}}` markers to distinguish them from raw data (enabled by default, configurable in settings). ### Correspondence tables - Three scope levels: `file.mapping.json` · `folder.mapping.json` · `vault.mapping.json` -- Per-occurrence statuses: `validated`, `ignored`, `partial`, `conflict`, `needs_review` +- Per-rule statuses: **Active** (validated), **Partial**, **Ignored**, **Suggested** +- Per-occurrence ignored exceptions: `IgnoredOccurrence {text, contextBefore, contextAfter}` stored in the rule - **Z-index priority**: free integer — longer entities take precedence (`Saint-Jean-de-Luz` > `Jean`) - JSON format documented in `SPECS.md §5` @@ -183,14 +190,16 @@ Repository structure: src/ ├── main.ts # Obsidian entry point ├── settings.ts # Persistent settings -├── types.ts # Shared types +├── types.ts # Shared types (MappingRule, IgnoredOccurrence, …) ├── i18n/ # Internationalization (en, fr) -├── parsers/ # SrtParser, ChatParser, TranscriptConverter +├── parsers/ # SrtParser, ChatParser, VttParser, +│ # NoScribeHtmlParser, NoScribeVttParser, TranscriptConverter ├── mappings/ # MappingStore, ScopeResolver -├── pseudonymizer/ # Engine, ReplacementPlanner, SpanProtector +├── pseudonymizer/ # Engine, ReplacementPlanner, SpanProtector, Redaction ├── scanner/ # OccurrenceScanner, OnnxNerScanner ├── dictionaries/ # DictionaryLoader -└── ui/ # PseudonymizationView, modals, CM6 highlighting +└── ui/ # PseudonymizationView, modals (incl. OccurrencesContextModal), + # CM6 highlighting (PseudonymHighlighter) ``` --- @@ -203,7 +212,7 @@ src/ | 7 — Coulmont | ✅ | Equivalent first name suggestions · JSON/CSV import | | 8 — Side panel | ✅ | 3 tabs · Embedded NER · Wizard · Cancellation · Export highlighting | | 9 — Structured dictionaries | ✅ | Format v1.1 · DictionaryLoader · Dictionary scan · Review modal · French communes | -| 10 — Refinement | 🔄 | i18n · Corpus organization · Settings redesign · Phase in progress | +| 10 — Refinement & noScribe | 🔄 | i18n · Corpus org · noScribe HTML/VTT import · per-occurrence scan · exceptions · VTT re-export | | 11 — EMCA functions | ⏳ | Turn navigation · Jefferson/ICOR correction · ELAN export | See [ROADMAP.md](ROADMAP.md) for the full phase breakdown and planned features. diff --git a/ROADMAP.md b/ROADMAP.md index d0d8edd..1a59732 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,9 @@ Architectural decision (May 2026): **identifying entity detection** is handled b ``` ✅ Phases 0–8 Parsers · Engine · UI · Scopes · Highlighting · Validation · Coulmont · Panel · NER · Wizard ✅ Phase 9 Structured dictionaries · DictionaryLoader · Review modals · French communes -🔄 Phase 10 Refinement & EMCA specialization (v0.1.x → v0.2.0) +🔄 Phase 10 Refinement & noScribe integration (v0.1.x → v0.2.0) + ✅ i18n · Corpus org · noScribe HTML/VTT/audio · Scan candidates · Exceptions · VTT re-export + ⏳ Test coverage · Jefferson/ICOR checker · Audio redaction · Timestamp UI ⏳ Phase 11 Interactional analysis functions (v1.0.0) ``` @@ -219,9 +221,11 @@ Goal: consolidate all existing features and add the EMCA-specific functions that - [x] **Settings redesign** — 6 sections ordered by frequency of use - [x] **Broad-scope warning** — callout in RuleModal / EditRuleModal for name rules with folder/vault scope - [x] **Mappings tab grouped by scope** — File / Folder / Vault sections with active-file filter +- [x] **Status labels clarified** — "Active / Partial / Ignored / Suggested" instead of ✓/◑/✗/?; `getValidatedFor()` includes `partial` +- [x] **Scan modal — per-occurrence candidates** — `MappingScanReviewModal` count column opens `OccurrencesContextModal` (✓/✗/⚠ per occurrence, "Save exceptions" button) +- [x] **Exceptions** — `IgnoredOccurrence` on `MappingRule`, persisted in mapping.json, red highlighting (`pseudobs-exception`, case-sensitive, priority 0), Exceptions section in Mappings tab - [ ] Unit test coverage ≥ 80% for parsers, engine, NER scanner, DictionaryLoader - [ ] Jefferson / ICOR convention checker: hover suggestions, editor highlighting -- [ ] Exports in original format for corpus re-opening - [ ] EMCA publication exports (PNG) - [ ] NER performance: measure and optimize on a 500-turn file - [ ] [Meld Encrypt](https://github.com/meld-cp/obsidian-encrypt) integration in Exports tab for encrypting correspondence tables and pseudonymized exports @@ -229,14 +233,20 @@ Goal: consolidate all existing features and add the EMCA-specific functions that ### noScribe integration & audio pseudonymization -[noScribe](https://github.com/kaixxx/noScribe) is a local transcription tool (Whisper + pyannote) widely used in qualitative research. It produces VTT files with **word-level timestamps** and **speaker diarization**. +[noScribe](https://github.com/kaixxx/noScribe) is a local transcription tool (Whisper + pyannote) widely used in qualitative research. It produces HTML and VTT files with **word-level timestamps** and **speaker diarization**. -- [ ] **`VttParser.ts`** — import noScribe VTT: word-level timestamps, speaker labels, round-trip guarantee; auto-conversion to `.md` on import alongside `.mapping.json` -- [ ] **Timestamp adjustment UI** — when a word timestamp from Whisper is imprecise, the researcher can fine-tune it: playback of ±1 s around the word (Web Audio API, no external dependency), editable start/end fields, saved back to the mapping metadata -- [ ] **Audio bleep export** — once terms are pseudonymized and timestamps are validated, generate an audio export where each pseudonymized occurrence is replaced by a sine tone at the exact word position; export as WAV via Web Audio API -- [ ] **Speaker-aware pseudonymization** — rules can be scoped to a specific speaker label (e.g. `SPEAKER_1`); when a name is pseudonymized in one turn, offer to apply the rule to all turns by the same speaker +- [x] **`VttParser.ts`** — standard WebVTT with Whisper word-level timestamps, speaker tags, round-trip guarantee; auto-conversion to `.md` + `.words.json` +- [x] **`NoScribeHtmlParser.ts`** — Qt Rich Text HTML: `ts_START_END_SXX` anchors, speaker extraction, audio source meta, HTML entities +- [x] **`NoScribeVttParser.ts`** — noScribe VTT v0.7: split-cue format, `SXX:` speaker detection, `NOTE media:` audio source +- [x] **noScribe Markdown format** — `**S00** [HH:MM:SS] : text`, `pseudobs-format: html|vtt`, `pseudobs-audio`; word timestamps in `.words.json` +- [x] **Audio import** — automatic from `audio_source` meta (HTML) or single audio in source folder (VTT) +- [x] **`Redaction.ts`** — syllable-count redaction with `🀫` +- [x] **VTT re-export** — `Export as VTT` command: clean WebVTT from pseudonymized `.md` + `.words.json` +- [ ] **Timestamp adjustment UI** — playback of ±1 s around a word (Web Audio API), editable start/end, saved to mapping metadata +- [ ] **Audio redaction export** — WAV export with bleep / silence / white noise at pseudonymized positions +- [ ] **Speaker-aware pseudonymization** — rules scoped to a speaker label (e.g. `S01`) -**Testable (v0.2.0):** stable end-to-end workflow on a real corpus of 10 interviews, including noScribe VTT import, audio bleep export, and timestamp adjustment. +**Testable (v0.2.0):** stable end-to-end workflow on a real corpus of 10 interviews, including noScribe import, audio bleep export, and timestamp adjustment. --- diff --git a/main.js b/main.js index fb392eb..273a73c 100644 --- a/main.js +++ b/main.js @@ -86,6 +86,7 @@ var init_en = __esm({ "notice.noDictDetection": "No detection dictionary loaded.\nInstall a dictionary from the Dictionaries panel.", "notice.noDictEntities": "No entities found in the detection dictionaries.", "notice.noDictReplacements": "No replacements available for the found entities.", + "notice.ruleDeleted": '\u2713 Rule deleted \u2014 "{0}" restored in active file', "notice.ruleCreated": '\u2713 Rule created: "{0}" \u2192 "{1}"', "notice.rulesCreated": "\u2713 {0} {1} created", "notice.rulesCreated.rule": "rule", @@ -111,6 +112,14 @@ var init_en = __esm({ "notice.nerModelLoading": "Loading NER model (first use \u2014 ~66 MB)\u2026", "notice.ruleNotFound": "Rule not found in mappings.", "notice.noCheckedDicts": "No dictionary checked.", + "mappingScanModal.saveExceptions": "Save exceptions", + "mappingScanModal.exceptionsSaved": "Exceptions saved to mapping.", + "panel.mappings.exceptions": "Exceptions", + "panel.mappings.exceptions.hint": "Occurrences explicitly ignored for this rule.", + "notice.notNoScribeFormat": "This file is not a noScribe transcript (pseudobs-format: vtt or html expected).", + "notice.wordsJsonMissing": "Timestamp file not found: {0}.words.json", + "notice.vttMismatch": "Warning: Markdown cue count and words.json cue count differ \u2014 exported VTT may be incomplete.", + "notice.vttExported": "VTT exported: {0}", "command.organizeCorpus": "Organize corpus", "command.addTranscription": "Add a transcription", "command.pseudonymizeFile": "Pseudonymize current file", @@ -119,6 +128,7 @@ var init_en = __esm({ "command.scanNer": "Scan file with NER detection", "command.scanDictionaries": "Scan file with dictionaries", "command.pseudonymizeSelection": "Pseudonymize selection", + "command.exportAsVtt": "Export as VTT", "command.openPanel": "Pseudonymization: open panel", "contextMenu.cancelPseudonymization": 'Cancel pseudonymization of "{0}"', "contextMenu.editRule": 'Edit rule for "{0}"', @@ -127,6 +137,9 @@ var init_en = __esm({ "ruleModal.scopeWarnTitle": "Longitudinal scope", "ruleModal.scopeOkTitle": "File scope", "ruleModal.scopeOk": "Good practice. This pseudonym will only apply to this transcription \u2014 each file remains independent.", + "contextMenu.redact": 'Redact "{0}"', + "redaction.checkbox": "Redact (\u{1F02B})", + "redaction.checkboxDesc": "Replace with \u{1F02B} symbols (one per syllable) \u2014 for non-essential identifying content", "contextMenu.coulmont": "Pseudonymize with Prof. Baptiste Coulmont", "contextMenu.createRule": "Create a replacement rule\u2026", "ruleModal.title": "Create a replacement rule", @@ -160,13 +173,13 @@ var init_en = __esm({ "scope.file": "File", "scope.folder": "Folder", "scope.vault": "Vault", - "status.validated": "\u2713", - "status.ignored": "\u2717", - "status.partial": "\u25D1", - "status.suggested": "?", - "status.conflict": "\u26A0", - "status.disabled": "\u2013", - "status.needs_review": "\u{1F441}", + "status.validated": "Active", + "status.ignored": "Ignored", + "status.partial": "Partial", + "status.suggested": "Suggested", + "status.conflict": "Conflict", + "status.disabled": "Inactive", + "status.needs_review": "Review", "panel.tab.mappings": "Mappings", "panel.tab.dictionaries": "Dictionaries", "panel.tab.exports": "Exports", @@ -384,6 +397,7 @@ var init_fr = __esm({ "notice.noDictDetection": "Aucun dictionnaire de d\xE9tection charg\xE9.\nInstallez un dictionnaire depuis le panneau Dictionnaires.", "notice.noDictEntities": "Aucune entit\xE9 trouv\xE9e dans les dictionnaires de d\xE9tection.", "notice.noDictReplacements": "Aucun remplacement disponible pour les entit\xE9s trouv\xE9es.", + "notice.ruleDeleted": '\u2713 R\xE8gle supprim\xE9e \u2014 "{0}" r\xE9tabli dans le fichier actif', "notice.ruleCreated": '\u2713 R\xE8gle cr\xE9\xE9e : "{0}" \u2192 "{1}"', "notice.rulesCreated": "\u2713 {0} r\xE8gle{1} cr\xE9\xE9e{1}", "notice.rulesCreated.rule": "", @@ -409,6 +423,14 @@ var init_fr = __esm({ "notice.nerModelLoading": "Chargement du mod\xE8le NER (premi\xE8re utilisation \u2014 ~66 Mo)\u2026", "notice.ruleNotFound": "R\xE8gle introuvable dans les mappings.", "notice.noCheckedDicts": "Aucun dictionnaire coch\xE9.", + "mappingScanModal.saveExceptions": "Enregistrer les exceptions", + "mappingScanModal.exceptionsSaved": "Exceptions enregistr\xE9es dans le mapping.", + "panel.mappings.exceptions": "Exceptions", + "panel.mappings.exceptions.hint": "Occurrences explicitement ignor\xE9es pour cette r\xE8gle.", + "notice.notNoScribeFormat": "Ce fichier n'est pas une transcription noScribe (pseudobs-format: vtt ou html attendu).", + "notice.wordsJsonMissing": "Fichier de timestamps introuvable : {0}.words.json", + "notice.vttMismatch": "Attention : le nombre de cues du Markdown et du words.json diff\xE8re \u2014 le VTT export\xE9 peut \xEAtre incomplet.", + "notice.vttExported": "VTT export\xE9 : {0}", "command.organizeCorpus": "Organiser le corpus", "command.addTranscription": "Ajouter une transcription", "command.pseudonymizeFile": "Pseudonymiser le fichier courant", @@ -417,6 +439,7 @@ var init_fr = __esm({ "command.scanNer": "Scanner le fichier avec d\xE9tection NER", "command.scanDictionaries": "Scanner le fichier avec les dictionnaires", "command.pseudonymizeSelection": "Pseudonymiser la s\xE9lection", + "command.exportAsVtt": "Exporter en VTT", "command.openPanel": "Pseudonymisation : ouvrir le panneau", "contextMenu.cancelPseudonymization": 'Annuler la pseudonymisation de "{0}"', "contextMenu.editRule": 'Modifier la r\xE8gle pour "{0}"', @@ -425,6 +448,9 @@ var init_fr = __esm({ "ruleModal.scopeWarnTitle": "Port\xE9e longitudinale", "ruleModal.scopeOkTitle": "Port\xE9e fichier", "ruleModal.scopeOk": "Bonne pratique. Ce pseudonyme ne s'appliquera qu'\xE0 cette transcription \u2014 chaque fichier reste ind\xE9pendant.", + "contextMenu.redact": 'Caviarder "{0}"', + "redaction.checkbox": "Caviardage (\u{1F02B})", + "redaction.checkboxDesc": "Remplace par des \u{1F02B} (1 par syllabe) \u2014 pour les informations identifiantes non essentielles", "contextMenu.coulmont": "Pseudonymiser avec Pr Baptiste Coulmont", "contextMenu.createRule": "Cr\xE9er une r\xE8gle de remplacement\u2026", "ruleModal.title": "Cr\xE9er une r\xE8gle de remplacement", @@ -458,13 +484,13 @@ var init_fr = __esm({ "scope.file": "Fichier", "scope.folder": "Dossier", "scope.vault": "Vault", - "status.validated": "\u2713", - "status.ignored": "\u2717", - "status.partial": "\u25D1", - "status.suggested": "?", - "status.conflict": "\u26A0", - "status.disabled": "\u2013", - "status.needs_review": "\u{1F441}", + "status.validated": "Actif", + "status.ignored": "Ignor\xE9", + "status.partial": "Partiel", + "status.suggested": "Sugg\xE9r\xE9", + "status.conflict": "Conflit", + "status.disabled": "Inactif", + "status.needs_review": "\xC0 r\xE9viser", "panel.tab.mappings": "Mappings", "panel.tab.dictionaries": "Dictionnaires", "panel.tab.exports": "Exports", @@ -482,7 +508,7 @@ var init_fr = __esm({ "panel.mappings.col.category": "Cat\xE9gorie", "panel.mappings.col.scope": "Port\xE9e", "panel.mappings.col.priority": "P.", - "panel.mappings.col.status": "Statut", + "panel.mappings.col.status": "\xC9tat", "panel.dict.noneInstalled": "Aucun dictionnaire install\xE9. Installez-en un depuis le wizard (Param\xE8tres \u2192 Reconfigurer) ou importez un fichier local.", "panel.dict.checkbox": "Inclure dans le scan group\xE9", "panel.dict.scanAll": "Scanner les dictionnaires coch\xE9s", @@ -32445,7 +32471,7 @@ __export(main_exports, { default: () => PseudObsPlugin }); module.exports = __toCommonJS(main_exports); -var import_obsidian12 = require("obsidian"); +var import_obsidian13 = require("obsidian"); init_i18n(); init_settings(); @@ -32453,6 +32479,20 @@ init_settings(); var import_obsidian3 = require("obsidian"); init_i18n(); +// src/pseudonymizer/Redaction.ts +var REDACTION_CHAR = "\u{1F02B}"; +function countSyllables(text) { + const groups = text.match(/[aeiouyàâäéèêëîïôùûüœæAEIOUYÀÂÄÉÈÊËÎÏÔÙÛÜŒÆ]+/g); + return Math.max(1, groups?.length ?? 1); +} +function generateRedaction(text) { + return text.split(/( +)/).map((part) => { + if (/^ +$/.test(part)) + return part; + return REDACTION_CHAR.repeat(countSyllables(part)); + }).join(""); +} + // src/mappings/MappingStore.ts function generateId() { return `map_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; @@ -32506,8 +32546,9 @@ var MappingStore = class _MappingStore { } // Règles validées applicables à un fichier donné (cascade file → folder → vault) getValidatedFor(filePath) { + const ACTIVE = /* @__PURE__ */ new Set(["validated", "partial"]); return this.getAll().filter((r) => { - if (r.status !== "validated") + if (!ACTIVE.has(r.status)) return false; if (r.scope.type === "vault") return true; @@ -32707,6 +32748,27 @@ var RuleModal = class extends import_obsidian3.Modal { this.priority = parseInt(v, 10) || 0; }) ); + const redactRow = contentEl.createDiv("pseudobs-redact-row"); + const redactCb = redactRow.createEl("input"); + redactCb.type = "checkbox"; + redactCb.addClass("pseudobs-dict-review-cb"); + redactRow.createSpan({ text: ` ${t("redaction.checkbox")}` }).title = t("redaction.checkboxDesc"); + redactCb.addEventListener("change", () => { + if (redactCb.checked) { + this.replacement = generateRedaction(this.source || REDACTION_CHAR); + this.useClass = false; + if (replacementInput) { + replacementInput.value = this.replacement; + replacementInput.setAttr("disabled", "true"); + } + } else { + this.replacement = ""; + if (replacementInput) { + replacementInput.value = ""; + replacementInput.removeAttribute("disabled"); + } + } + }); new import_obsidian3.Setting(contentEl).addButton( (btn) => btn.setButtonText(t("ruleModal.submit")).setCta().onClick(() => void this.createRule()) ); @@ -32765,7 +32827,7 @@ var RuleModal = class extends import_obsidian3.Modal { await this.app.vault.create(mappingPath, json); } new import_obsidian3.Notice(t("notice.ruleCreated", this.source.trim(), this.replacement.trim())); - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } /** @@ -32809,7 +32871,7 @@ var RuleModal = class extends import_obsidian3.Modal { var import_obsidian4 = require("obsidian"); init_i18n(); var QuickPseudonymizeModal = class extends import_obsidian4.Modal { - constructor(app, plugin, editor, prefillReplacement = "", suggestions = []) { + constructor(app, plugin, editor, prefillReplacement = "", suggestions = [], isRedactionMode = false) { super(app); this.replacement = ""; this.category = "custom"; @@ -32817,8 +32879,9 @@ var QuickPseudonymizeModal = class extends import_obsidian4.Modal { this.plugin = plugin; this.editor = editor; this.source = editor.getSelection(); - this.replacement = prefillReplacement; + this.replacement = prefillReplacement || (isRedactionMode ? generateRedaction(this.source) : ""); this.suggestions = suggestions; + this.isRedactionMode = isRedactionMode; if (suggestions.length > 0) this.category = "first_name"; this.from = editor.getCursor("from"); @@ -32870,6 +32933,30 @@ var QuickPseudonymizeModal = class extends import_obsidian4.Modal { d.setValue("file"); d.onChange((v) => this.applyScope = v); }); + const redactRow = contentEl.createDiv("pseudobs-redact-row"); + const redactCb = redactRow.createEl("input"); + redactCb.type = "checkbox"; + redactCb.checked = this.isRedactionMode; + redactCb.addClass("pseudobs-dict-review-cb"); + const redactLabel = redactRow.createSpan({ text: ` ${t("redaction.checkbox")}` }); + redactLabel.title = t("redaction.checkboxDesc"); + redactCb.addEventListener("change", () => { + this.isRedactionMode = redactCb.checked; + if (this.isRedactionMode) { + this.replacement = generateRedaction(this.source); + if (replacementInput) + replacementInput.value = this.replacement; + replacementInput?.setAttr("disabled", "true"); + } else { + this.replacement = ""; + if (replacementInput) + replacementInput.value = ""; + replacementInput?.removeAttribute("disabled"); + } + }); + if (this.isRedactionMode && replacementInput) { + replacementInput.setAttr("disabled", "true"); + } new import_obsidian4.Setting(contentEl).addButton( (btn) => btn.setButtonText(t("quickModal.submit")).setCta().onClick(() => void this.apply()) ); @@ -32896,7 +32983,7 @@ var QuickPseudonymizeModal = class extends import_obsidian4.Modal { const count = await this.plugin.applyRuleToFile(activeFile, this.source, marked); new import_obsidian4.Notice(t("notice.appliedFile", this.source, marked, String(count), count > 1 ? "s" : "")); } - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } async saveRule(activeFile, replacement) { @@ -32951,13 +33038,13 @@ function createPseudonymHighlighter(getData) { } } build(view) { - const { sources, replacements, nerCandidates } = getData(); - if (sources.length === 0 && replacements.length === 0 && nerCandidates.length === 0) + const { sources, replacements, nerCandidates, ignoredTerms } = getData(); + if (sources.length === 0 && replacements.length === 0 && nerCandidates.length === 0 && ignoredTerms.length === 0) return import_view.Decoration.none; const text = view.state.doc.toString(); const lower = text.toLowerCase(); const spans = []; - const collect = (terms, cls) => { + const collect = (terms, cls, prio) => { for (const term of terms) { if (!term) continue; @@ -32967,7 +33054,21 @@ function createPseudonymHighlighter(getData) { const idx = lower.indexOf(needle, pos); if (idx === -1) break; - spans.push({ from: idx, to: idx + term.length, cls }); + spans.push({ from: idx, to: idx + term.length, cls, prio }); + pos = idx + term.length; + } + } + }; + const collectExact = (terms, cls, prio) => { + for (const term of terms) { + if (!term) + continue; + let pos = 0; + while (pos < text.length) { + const idx = text.indexOf(term, pos); + if (idx === -1) + break; + spans.push({ from: idx, to: idx + term.length, cls, prio }); pos = idx + term.length; } } @@ -32983,10 +33084,11 @@ function createPseudonymHighlighter(getData) { return false; return !sourcesLower.some((src) => src !== cl && src.includes(cl)); }); - collect(freshCandidates, "pseudobs-ner-candidate"); - collect(sources, "pseudobs-source"); - collect(replacements, "pseudobs-replaced"); - spans.sort((a, b) => a.from - b.from || a.to - b.to); + collectExact(ignoredTerms, "pseudobs-exception", 0); + collect(replacements, "pseudobs-replaced", 1); + collect(sources, "pseudobs-source", 2); + collect(freshCandidates, "pseudobs-ner-candidate", 3); + spans.sort((a, b) => a.from - b.from || a.prio - b.prio || a.to - b.to); const builder = new import_state.RangeSetBuilder(); let lastTo = -1; for (const { from, to, cls } of spans) { @@ -33098,15 +33200,15 @@ var EditRuleModal = class extends import_obsidian5.Modal { }); await this.plugin.scopeResolver.saveStore(store, filePath); new import_obsidian5.Notice(t("notice.ruleCreated", rule.source, this.replacement.trim())); - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } async delete() { const { store, filePath, rule } = this.location; store.remove(rule.id); await this.plugin.scopeResolver.saveStore(store, filePath); - new import_obsidian5.Notice(`\u2713 "${rule.source}"`); - void this.plugin.refreshHighlightData(); + await this.plugin.revertRuleInFile(rule.source, rule.replacement); + new import_obsidian5.Notice(t("notice.ruleDeleted", rule.source)); this.close(); } onClose() { @@ -33115,7 +33217,7 @@ var EditRuleModal = class extends import_obsidian5.Modal { }; // src/ui/PseudonymizationView.ts -var import_obsidian7 = require("obsidian"); +var import_obsidian8 = require("obsidian"); init_i18n(); // src/pseudonymizer/ReplacementPlanner.ts @@ -33215,7 +33317,7 @@ function scanOccurrences(content, filePath, rules, settings = DEFAULT_PLANNER_SE } // src/ui/MappingScanReviewModal.ts -var import_obsidian6 = require("obsidian"); +var import_obsidian7 = require("obsidian"); init_i18n(); // src/pseudonymizer/SpanProtector.ts @@ -33248,15 +33350,134 @@ function applySpans(text, spans, marker) { return output; } +// src/ui/OccurrencesContextModal.ts +var import_obsidian6 = require("obsidian"); +var OccurrencesContextModal = class extends import_obsidian6.Modal { + constructor(app, rule, occurrences, existingDecisions, onConfirm) { + super(app); + this.cardRefs = /* @__PURE__ */ new Map(); + this.rule = rule; + this.occurrences = occurrences; + this.decisions = new Map(existingDecisions); + this.onConfirm = onConfirm; + } + onOpen() { + const { contentEl } = this; + contentEl.addClass("pseudobs-ctx-modal"); + contentEl.createEl("h3", { + text: `${this.rule.source} \u2192 ${this.rule.replacement}`, + cls: "pseudobs-ctx-modal-title" + }); + contentEl.createEl("p", { + text: `${this.occurrences.length} occurrence${this.occurrences.length > 1 ? "s" : ""} \u2014 s\xE9lectionnez celles \xE0 remplacer.`, + cls: "pseudobs-view-hint" + }); + new import_obsidian6.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(); + }) + ); + const scroll = contentEl.createDiv("pseudobs-ctx-modal-scroll"); + for (const occ of this.occurrences) { + this.buildCard(scroll, occ); + } + contentEl.createEl("hr"); + new import_obsidian6.Setting(contentEl).addButton( + (b) => b.setButtonText("Annuler").onClick(() => this.close()) + ).addButton( + (b) => b.setButtonText("Confirmer la s\xE9lection").setCta().onClick(() => { + this.onConfirm(new Map(this.decisions)); + this.close(); + }) + ); + } + buildCard(container, occ) { + const card = container.createDiv("pseudobs-occ-card"); + const srcLine = card.createDiv("pseudobs-occ-line"); + srcLine.createSpan({ text: occ.contextBefore, cls: "pseudobs-ctx-side" }); + srcLine.createSpan({ text: occ.text, cls: "pseudobs-occ-term" }); + srcLine.createSpan({ text: occ.contextAfter, cls: "pseudobs-ctx-side" }); + const arrow = card.createDiv("pseudobs-occ-arrow"); + arrow.setText("\u2193"); + const resLine = card.createDiv("pseudobs-occ-line pseudobs-occ-result-line"); + resLine.createSpan({ text: occ.contextBefore, cls: "pseudobs-ctx-side" }); + resLine.createSpan({ text: this.rule.replacement, cls: "pseudobs-occ-replacement" }); + resLine.createSpan({ text: occ.contextAfter, cls: "pseudobs-ctx-side" }); + const statusLabel2 = card.createDiv("pseudobs-occ-status-label"); + card.createEl("small", { text: `ligne ${occ.line}`, cls: "pseudobs-occ-meta" }); + const actions = card.createDiv("pseudobs-occ-actions"); + const btnRefs = /* @__PURE__ */ new Map(); + for (const [label, value, title] of [ + ["\u2713", "validated", "Valider"], + ["\u2717", "ignored", "Ignorer"], + ["\u26A0", "false_positive", "Faux positif"] + ]) { + const btn = actions.createEl("button", { text: label }); + btn.title = title; + btn.addClass("pseudobs-occ-btn"); + 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: statusLabel2 }); + this.updateCard(occ.id); + } + updateCard(occId) { + const ref = this.cardRefs.get(occId); + if (!ref) + return; + const decision = this.decisions.get(occId) ?? "validated"; + ref.card.removeClass("pseudobs-occ-validated", "pseudobs-occ-ignored", "pseudobs-occ-false_positive"); + ref.card.addClass(`pseudobs-occ-${decision}`); + for (const [value, btn] of ref.buttons) { + btn.toggleClass("pseudobs-occ-btn-active", value === decision); + } + const show = decision === "validated"; + ref.arrow.toggle(show); + ref.resLine.toggle(show); + ref.statusLabel.toggle(!show); + ref.statusLabel.setText( + decision === "ignored" ? "Conserv\xE9 tel quel" : decision === "false_positive" ? "Faux positif \u2014 exclu" : "" + ); + } + updateAllCards() { + for (const occId of this.cardRefs.keys()) + this.updateCard(occId); + } + onClose() { + this.contentEl.empty(); + } +}; + // src/ui/MappingScanReviewModal.ts -var MappingScanReviewModal = class extends import_obsidian6.Modal { +var MappingScanReviewModal = class extends import_obsidian7.Modal { constructor(app, plugin, file, content, ruleResults) { super(app); + // Décisions par règle : ruleId → occId → decision + this.decisionsMap = /* @__PURE__ */ new Map(); + // Cellules de comptage — pour mise à jour live + this.countCells = []; this.plugin = plugin; this.file = file; this.content = content; this.ruleResults = ruleResults; this.checked = ruleResults.map(() => true); + for (const { rule, occurrences } of ruleResults) { + const map = /* @__PURE__ */ new Map(); + for (const occ of occurrences) + map.set(occ.id, "validated"); + this.decisionsMap.set(rule.id, map); + } } onOpen() { this.modalEl.addClass("pseudobs-modal-review-outer"); @@ -33265,7 +33486,12 @@ var MappingScanReviewModal = class extends import_obsidian6.Modal { contentEl.createEl("h2", { text: t("mappingScanModal.title") }); const nr = this.ruleResults.length; contentEl.createEl("p", { - text: t("mappingScanModal.summary", String(nr), nr > 1 ? t("mappingScanModal.summary.rules") : t("mappingScanModal.summary.rule"), this.file.name), + text: t( + "mappingScanModal.summary", + String(nr), + nr > 1 ? t("mappingScanModal.summary.rules") : t("mappingScanModal.summary.rule"), + this.file.name + ), cls: "pseudobs-scan-summary" }); contentEl.createEl("p", { text: t("mappingScanModal.hint"), cls: "pseudobs-view-hint" }); @@ -33277,7 +33503,7 @@ var MappingScanReviewModal = class extends import_obsidian6.Modal { (h) => hr.createEl("th", { text: h }) ); const tbody = table.createEl("tbody"); - this.ruleResults.forEach(({ rule, matchCount }, i2) => { + this.ruleResults.forEach(({ rule, occurrences }, i2) => { const tr = tbody.createEl("tr", { cls: "pseudobs-dict-review-row" }); const cb = tr.createEl("td").createEl("input"); cb.type = "checkbox"; @@ -33294,54 +33520,129 @@ var MappingScanReviewModal = class extends import_obsidian6.Modal { const s = this.plugin.settings; const displayRep = s.useMarkerInExport ? `${s.markerOpen}${rule.replacement}${s.markerClose}` : rule.replacement; repCell.createEl("span", { text: displayRep, cls: "pseudobs-dict-review-rep-static" }); - tr.createEl("td", { text: String(matchCount), cls: "pseudobs-dict-review-count" }); + const countCell = tr.createEl("td", { cls: "pseudobs-dict-review-count" }); + this.countCells.push(countCell); + this.renderCountCell(countCell, rule, occurrences, i2); }); const footer = contentEl.createDiv("pseudobs-dict-review-footer"); footer.createEl("button", { text: t("mappingScanModal.cancel") }).addEventListener("click", () => this.close()); + footer.createEl("button", { text: t("mappingScanModal.saveExceptions"), cls: "pseudobs-save-exceptions-btn" }).addEventListener("click", () => void this.saveExceptions()); this.applyBtn = footer.createEl("button", { cls: "mod-cta" }); this.applyBtn.addEventListener("click", () => void this.apply()); this.updateApplyLabel(); } + renderCountCell(cell, rule, occurrences, ruleIndex) { + cell.empty(); + const decisions = this.decisionsMap.get(rule.id); + const validated = occurrences.filter((o) => decisions.get(o.id) === "validated").length; + const total = occurrences.length; + if (total === 0) { + cell.createSpan({ text: "0" }); + return; + } + const btn = cell.createEl("button", { cls: "pseudobs-count-btn" }); + btn.setText(validated < total ? `${validated} / ${total}` : String(total)); + btn.title = "Voir et s\xE9lectionner les candidats"; + btn.addEventListener("click", () => { + new OccurrencesContextModal( + this.app, + rule, + occurrences, + decisions, + (newDecisions) => { + this.decisionsMap.set(rule.id, newDecisions); + this.renderCountCell(cell, rule, occurrences, ruleIndex); + this.updateApplyLabel(); + } + ).open(); + }); + } + countValidated() { + return this.ruleResults.reduce((sum, { rule, occurrences }, i2) => { + if (!this.checked[i2]) + return sum; + const decisions = this.decisionsMap.get(rule.id); + return sum + occurrences.filter((o) => decisions.get(o.id) === "validated").length; + }, 0); + } updateApplyLabel() { - const n = this.checked.filter(Boolean).length; - const total = this.ruleResults.filter((_, i2) => this.checked[i2]).reduce((sum, r) => sum + r.matchCount, 0); - this.applyBtn.textContent = n === 0 ? t("mappingScanModal.noRules") : t( + const rules = this.checked.filter(Boolean).length; + const total = this.countValidated(); + this.applyBtn.textContent = rules === 0 ? t("mappingScanModal.noRules") : t( "mappingScanModal.apply", - String(n), - n > 1 ? t("mappingScanModal.apply.rules") : t("mappingScanModal.apply.rule"), + String(rules), + rules > 1 ? t("mappingScanModal.apply.rules") : t("mappingScanModal.apply.rule"), String(total), total > 1 ? t("mappingScanModal.apply.occurrences") : t("mappingScanModal.apply.occurrence") ); - this.applyBtn.toggleClass("pseudobs-dict-review-btn-empty", n === 0); + this.applyBtn.toggleClass("pseudobs-dict-review-btn-empty", rules === 0); + } + /** Enregistre les exceptions dans le mapping sans appliquer de remplacements. */ + async saveExceptions() { + await this.persistIgnoredOccurrences(); + new import_obsidian7.Notice(t("mappingScanModal.exceptionsSaved")); + this.close(); + } + /** + * Persiste les occurrences ignorées (✗ et ⚠) dans le mapping.json de chaque règle. + * S'appuie sur findRuleByTerm pour localiser le bon mapping file. + * Les nouvelles exceptions sont fusionnées avec les existantes (déduplication par texte). + */ + async persistIgnoredOccurrences() { + for (let i2 = 0; i2 < this.ruleResults.length; i2++) { + const { rule, occurrences } = this.ruleResults[i2]; + const decisions = this.decisionsMap.get(rule.id); + const newIgnored = occurrences.filter((occ) => { + const d = decisions.get(occ.id) ?? "validated"; + return d === "ignored" || d === "false_positive"; + }).map((occ) => ({ text: occ.text, contextBefore: occ.contextBefore, contextAfter: occ.contextAfter })); + if (newIgnored.length === 0) + continue; + const location = await this.plugin.scopeResolver.findRuleByTerm(rule.source); + if (!location) + continue; + const existing = location.rule.ignoredOccurrences ?? []; + const existingTexts = new Set(existing.map((o) => o.text)); + const merged = [...existing, ...newIgnored.filter((o) => !existingTexts.has(o.text))]; + location.store.update(rule.id, { ignoredOccurrences: merged }); + await this.plugin.scopeResolver.saveStore(location.store, location.filePath); + } + void this.plugin.refresh(); } async apply() { - const checkedRules = this.ruleResults.filter((_, i2) => this.checked[i2]).map((r) => r.rule); - if (checkedRules.length === 0) { + const s = this.plugin.settings; + const wrap = (r) => s.useMarkerInExport ? `${s.markerOpen}${r}${s.markerClose}` : r; + const spans = []; + for (let i2 = 0; i2 < this.ruleResults.length; i2++) { + if (!this.checked[i2]) + continue; + const { rule, occurrences } = this.ruleResults[i2]; + const decisions = this.decisionsMap.get(rule.id); + for (const occ of occurrences) { + if ((decisions.get(occ.id) ?? "validated") !== "validated") + continue; + spans.push({ + start: occ.start, + end: occ.end, + source: occ.text, + replacement: wrap(rule.replacement), + mappingId: rule.id, + priority: rule.priority + }); + } + } + if (spans.length === 0) { + new import_obsidian7.Notice(t("notice.noOccurrences")); this.close(); return; } this.applyBtn.setAttr("disabled", "true"); - const s = this.plugin.settings; - const marker = s.useMarkerInExport ? { open: s.markerOpen, close: s.markerClose } : void 0; - const allSpans = checkedRules.flatMap( - (rule) => findSpansForRule(this.content, rule, { - caseSensitive: s.caseSensitive, - wholeWordOnly: s.wholeWordOnly - }).map( - (span) => marker ? { ...span, replacement: `${marker.open}${span.replacement}${marker.close}` } : span - ) - ); - const resolved = resolveSpans(allSpans); - if (resolved.length === 0) { - new import_obsidian6.Notice(t("notice.noOccurrences")); - this.close(); - return; - } + const resolved = resolveSpans(spans); const modified = applySpans(this.content, resolved); await this.app.vault.modify(this.file, modified); - void this.plugin.refreshHighlightData(); + await this.persistIgnoredOccurrences(); const total = resolved.length; - new import_obsidian6.Notice(t( + new import_obsidian7.Notice(t( "notice.occurrencesPseudonymized", String(total), total > 1 ? t("notice.occurrencesPseudonymized.occurrences") : t("notice.occurrencesPseudonymized.occurrence"), @@ -33365,7 +33666,7 @@ function scopeLabel(s) { function statusLabel(s) { return t(`status.${s}`) || s; } -var PseudonymizationView = class extends import_obsidian7.ItemView { +var PseudonymizationView = class extends import_obsidian8.ItemView { constructor(leaf, plugin) { super(leaf); this.activeTab = "mappings"; @@ -33429,6 +33730,11 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { } await this.renderTab(tab); } + /** Appelé par le plugin pour forcer un re-rendu de l'onglet actif. */ + async refreshActiveTab() { + if (!this._renderingTab) + await this.renderTab(this.activeTab); + } async renderTab(tab) { const pane = this.panes[tab]; pane.empty(); @@ -33444,7 +33750,7 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { async onFileChange() { if (this._renderingTab) return; - if (this.app.workspace.getActiveViewOfType(import_obsidian7.ItemView) === this) + if (this.app.workspace.getActiveViewOfType(import_obsidian8.ItemView) === this) return; const f = this.app.workspace.getActiveFile(); if (f) @@ -33464,11 +33770,11 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { const file = this.getFile(); const toolbar = el.createDiv("pseudobs-view-toolbar"); const addRuleBtn = toolbar.createEl("button", { cls: "pseudobs-view-action-btn" }); - (0, import_obsidian7.setIcon)(addRuleBtn, "list-plus"); + (0, import_obsidian8.setIcon)(addRuleBtn, "list-plus"); addRuleBtn.createSpan({ text: t("panel.mappings.addRule") }); addRuleBtn.addEventListener("click", () => new RuleModal(this.app, this.plugin).open()); const scanBtn = toolbar.createEl("button", { cls: "pseudobs-view-action-btn" }); - (0, import_obsidian7.setIcon)(scanBtn, "scan-search"); + (0, import_obsidian8.setIcon)(scanBtn, "scan-search"); scanBtn.createSpan({ text: t("panel.mappings.scanFile") }); if (!file) scanBtn.setAttr("disabled", "true"); @@ -33482,21 +33788,27 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { const content = await this.app.vault.read(file); const rules = await this.plugin.scopeResolver.getRulesFor(file.path); if (rules.length === 0) { - new import_obsidian7.Notice(t("panel.mappings.noRulesHint")); + new import_obsidian8.Notice(t("panel.mappings.noRulesHint")); return; } const occs = scanOccurrences(content, file.path, rules, { caseSensitive: this.plugin.settings.caseSensitive, wholeWordOnly: this.plugin.settings.wholeWordOnly }); - const countByRule = /* @__PURE__ */ new Map(); + const occsByRule = /* @__PURE__ */ new Map(); for (const occ of occs) { const id = occ.mappingId ?? ""; - countByRule.set(id, (countByRule.get(id) ?? 0) + 1); + if (!occsByRule.has(id)) + occsByRule.set(id, []); + occsByRule.get(id).push(occ); } - const ruleResults = rules.filter((r) => countByRule.has(r.id)).map((r) => ({ rule: r, matchCount: countByRule.get(r.id) })); + const ruleResults = rules.filter((r) => occsByRule.has(r.id)).map((r) => ({ + rule: r, + matchCount: occsByRule.get(r.id).length, + occurrences: occsByRule.get(r.id) + })); if (ruleResults.length === 0) { - new import_obsidian7.Notice(t("notice.noOccurrences")); + new import_obsidian8.Notice(t("notice.noOccurrences")); return; } new MappingScanReviewModal(this.app, this.plugin, file, content, ruleResults).open(); @@ -33561,10 +33873,39 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { row.createEl("td", { text: String(rule.priority) }); row.createEl("td", { text: statusLabel(rule.status) }); const editBtn = row.createEl("td").createEl("button", { cls: "pseudobs-mappings-edit-btn" }); - (0, import_obsidian7.setIcon)(editBtn, "pencil"); + (0, import_obsidian8.setIcon)(editBtn, "pencil"); editBtn.addEventListener("click", () => new EditRuleModal(this.app, this.plugin, loc).open()); } } + const allIgnored = locations.flatMap( + ({ rule, store, filePath }) => (rule.ignoredOccurrences ?? []).map((occ) => ({ occ, rule, store, filePath })) + ); + if (allIgnored.length > 0) { + el.createEl("h3", { text: t("panel.mappings.exceptions"), cls: "pseudobs-mappings-scope-heading" }); + el.createEl("p", { text: t("panel.mappings.exceptions.hint"), cls: "pseudobs-view-hint" }); + const exceptionsGrid = el.createDiv("pseudobs-exceptions-grid"); + for (const { occ, rule, store, filePath } of allIgnored) { + const card = exceptionsGrid.createDiv("pseudobs-exception-card"); + card.createEl("div", { + text: `${rule.source} \u2192 ${rule.replacement}`, + cls: "pseudobs-exception-card-rule" + }); + const ctx = card.createDiv("pseudobs-exception-card-ctx"); + ctx.createSpan({ text: occ.contextBefore, cls: "pseudobs-ctx-side" }); + ctx.createSpan({ text: occ.text, cls: "pseudobs-exception-card-term" }); + ctx.createSpan({ text: occ.contextAfter, cls: "pseudobs-ctx-side" }); + const delBtn = card.createEl("button", { cls: "pseudobs-exception-card-del" }); + (0, import_obsidian8.setIcon)(delBtn, "x"); + delBtn.title = "Supprimer cette exception"; + delBtn.addEventListener("click", async () => { + const updated = (rule.ignoredOccurrences ?? []).filter((o) => o.text !== occ.text); + store.update(rule.id, { ignoredOccurrences: updated }); + await this.plugin.scopeResolver.saveStore(store, filePath); + void this.plugin.refresh(); + void this.renderTab("mappings"); + }); + } + } } // ---- Onglet Dictionnaires -------------------------------------- async renderDictionariesTab(el) { @@ -33593,7 +33934,7 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { info.createEl("small", { text: `${dict.dictionaryId}.dict.json`, cls: "pseudobs-dict-card-filename" }); if (dict.roles?.detection) { const scanBtn = card.createEl("button", { cls: "pseudobs-dict-card-scan mod-cta" }); - (0, import_obsidian7.setIcon)(scanBtn, "scan-search"); + (0, import_obsidian8.setIcon)(scanBtn, "scan-search"); scanBtn.setAttribute("aria-label", t("panel.dict.scanWith", dict.label)); scanBtn.title = t("panel.dict.scanWith", dict.label); scanBtn.addEventListener("click", () => { @@ -33601,7 +33942,7 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { }); } const removeBtn = card.createEl("button", { cls: "pseudobs-dict-card-remove" }); - (0, import_obsidian7.setIcon)(removeBtn, "trash-2"); + (0, import_obsidian8.setIcon)(removeBtn, "trash-2"); removeBtn.setAttribute("aria-label", t("panel.dict.remove")); removeBtn.title = t("panel.dict.remove"); removeBtn.addEventListener("click", () => { @@ -33609,7 +33950,7 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { const f = this.app.vault.getAbstractFileByPath( `${this.plugin.settings.dictionariesFolder}/${dict.dictionaryId}.dict.json` ); - if (f instanceof import_obsidian7.TFile) + if (f instanceof import_obsidian8.TFile) await this.app.fileManager.trashFile(f); this.checkedDicts.delete(dict.dictionaryId); await this.plugin.dictionaryLoader.load(); @@ -33619,12 +33960,12 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { } el.createEl("hr"); const groupScanBtn = el.createEl("button", { cls: "pseudobs-dict-group-scan mod-cta" }); - (0, import_obsidian7.setIcon)(groupScanBtn, "scan-search"); + (0, import_obsidian8.setIcon)(groupScanBtn, "scan-search"); groupScanBtn.createSpan({ text: t("panel.dict.scanAll") }); groupScanBtn.addEventListener("click", () => { const ids = [...this.checkedDicts]; if (ids.length === 0) { - new import_obsidian7.Notice(t("notice.noCheckedDicts")); + new import_obsidian8.Notice(t("notice.noCheckedDicts")); return; } void this.plugin.scanCurrentFileWithDictionaries(ids); @@ -33658,18 +33999,18 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { throw new Error("Format invalide"); const dest = `${this.plugin.settings.dictionariesFolder}/${f.name}`; const existing = this.app.vault.getAbstractFileByPath(dest); - if (existing instanceof import_obsidian7.TFile) { + if (existing instanceof import_obsidian8.TFile) { await this.app.vault.modify(existing, text); } else { await this.app.vault.create(dest, text); } ok++; } catch { - new import_obsidian7.Notice(t("notice.invalidFormat", f.name)); + new import_obsidian8.Notice(t("notice.invalidFormat", f.name)); } } if (ok > 0) { - new import_obsidian7.Notice(t("notice.dictImported", String(ok), ok > 1 ? t("notice.dictImported.many") : t("notice.dictImported.one"))); + new import_obsidian8.Notice(t("notice.dictImported", String(ok), ok > 1 ? t("notice.dictImported.many") : t("notice.dictImported.one"))); await this.plugin.dictionaryLoader.load(); } await this.renderTab("dictionaries"); @@ -33686,12 +34027,12 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { return; } el.createEl("p", { text: `${file.name}`, cls: "pseudobs-view-filename" }); - new import_obsidian7.Setting(el).setName(t("panel.exports.pseudonymize")).addButton( + new import_obsidian8.Setting(el).setName(t("panel.exports.pseudonymize")).addButton( (btn) => btn.setButtonText(t("panel.exports.pseudonymize")).setCta().onClick(() => { void this.plugin.pseudonymizeActiveFile(); }) ); - new import_obsidian7.Setting(el).setName(t("panel.exports.exportMapping")).addButton( + new import_obsidian8.Setting(el).setName(t("panel.exports.exportMapping")).addButton( (btn) => btn.setButtonText(t("panel.exports.exportMapping")).onClick(() => { void this.plugin.exportMappingForFile(file); }) @@ -33701,10 +34042,24 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { async renderNerTab(el) { const s = this.plugin.settings; const nerScanBtn = el.createEl("button", { cls: "pseudobs-view-action-btn mod-cta" }); - (0, import_obsidian7.setIcon)(nerScanBtn, "scan-search"); - nerScanBtn.createSpan({ text: t("panel.ner.scanBtn") }); + const nerScanIcon = nerScanBtn.createSpan(); + (0, import_obsidian8.setIcon)(nerScanIcon, "scan-search"); + nerScanBtn.createSpan({ text: ` ${t("panel.ner.scanBtn")}` }); nerScanBtn.title = t("panel.ner.scanBtn"); - nerScanBtn.addEventListener("click", () => void this.plugin.scanCurrentFileNer()); + nerScanBtn.addEventListener("click", () => { + void (async () => { + nerScanBtn.setAttr("disabled", "true"); + (0, import_obsidian8.setIcon)(nerScanIcon, "loader-circle"); + nerScanIcon.addClass("pseudobs-spin"); + try { + await this.plugin.scanCurrentFileNer(); + } finally { + nerScanBtn.removeAttribute("disabled"); + (0, import_obsidian8.setIcon)(nerScanIcon, "scan-search"); + nerScanIcon.removeClass("pseudobs-spin"); + } + })(); + }); el.createEl("hr"); el.createEl("p", { text: t("panel.ner.hint"), cls: "pseudobs-view-hint" }); const scoreSection = el.createDiv("pseudobs-ner-section"); @@ -33755,7 +34110,7 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { })(); }); const resetBtn = fwSection.createEl("button", { cls: "pseudobs-view-action-btn" }); - (0, import_obsidian7.setIcon)(resetBtn, "rotate-ccw"); + (0, import_obsidian8.setIcon)(resetBtn, "rotate-ccw"); resetBtn.createSpan({ text: t("panel.ner.reset") }); resetBtn.addClass("pseudobs-ner-reset-btn"); resetBtn.addEventListener("click", () => { @@ -33776,7 +34131,7 @@ var PseudonymizationView = class extends import_obsidian7.ItemView { init_OnboardingModal(); // src/scanner/OnnxNerScanner.ts -var import_obsidian8 = require("obsidian"); +var import_obsidian9 = require("obsidian"); var path3 = __toESM(require("path")); var os = __toESM(require("os")); var MODEL_ID = "Xenova/bert-base-multilingual-cased-ner-hrl"; @@ -33787,6 +34142,36 @@ var TAG_TO_CATEGORY = { MISC: "custom" }; var MIN_ENTITY_LENGTH = 2; +var PREAMBLE_PATTERNS = [ + // VTT noScribe : *HH:MM:SS.mmm → HH:MM:SS.mmm* **SPEAKER** + /^\*[\d:.]+\s*→\s*[\d:.]+\*(?:\s*\*\*[^*]+\*\*)?\s*/, + // SRT bloc de texte : **[N]** *HH:MM:SS,mmm → HH:MM:SS,mmm* (ligne à ignorer entièrement) + /^\*\*\[\d+\]\*\*\s*\*[^*]+\*/, + // CHAT tour de parole : **SPEAKER** : + /^\*\*[A-Z0-9_]+\*\*\s*:\s*/ +]; +var SKIP_PATTERNS = [ + /^---/, + // frontmatter YAML + /^pseudobs-/, + // clés frontmatter du plugin + /^> /, + // métadonnées CHAT (@, %) + /^\*\*\[\d+\]\*\*/ + // en-tête de bloc SRT (index + timestamp sur la même ligne) +]; +function stripMarkdownPreamble(line) { + for (const skip of SKIP_PATTERNS) { + if (skip.test(line)) + return { cleanText: "", preambleLength: line.length }; + } + for (const pat of PREAMBLE_PATTERNS) { + const m = pat.exec(line); + if (m) + return { cleanText: line.slice(m[0].length), preambleLength: m[0].length }; + } + return { cleanText: line, preambleLength: 0 }; +} var _pipeline = null; var _loadingPromise = null; var _loadError = null; @@ -33797,7 +34182,7 @@ var OnnxNerScanner = class { } getPluginDir() { const { adapter } = this.app.vault; - if (!(adapter instanceof import_obsidian8.FileSystemAdapter)) + if (!(adapter instanceof import_obsidian9.FileSystemAdapter)) return null; return path3.join( adapter.getBasePath(), @@ -33823,7 +34208,7 @@ var OnnxNerScanner = class { } } async _doLoad() { - const notice = new import_obsidian8.Notice("Chargement du mod\xE8le NER (premi\xE8re utilisation \u2014 ~66 Mo)\u2026", 0); + const notice = new import_obsidian9.Notice("Chargement du mod\xE8le NER (premi\xE8re utilisation \u2014 ~66 Mo)\u2026", 0); try { const t2 = await Promise.resolve().then(() => (init_transformers(), transformers_exports)); const { env: env3, pipeline: pipeline2 } = t2; @@ -33837,12 +34222,13 @@ var OnnxNerScanner = class { if (pluginDir) { env3.backends.onnx.wasm.wasmPaths = pluginDir + path3.sep; env3.backends.onnx.wasm.numThreads = 1; + env3.backends.onnx.wasm.proxy = true; } env3.allowRemoteModels = true; env3.allowLocalModels = false; _pipeline = await pipeline2("token-classification", MODEL_ID); notice.hide(); - new import_obsidian8.Notice("\u2713 Mod\xE8le NER charg\xE9", 3e3); + new import_obsidian9.Notice("\u2713 Mod\xE8le NER charg\xE9", 3e3); } catch (e) { notice.hide(); const err = e; @@ -33866,9 +34252,10 @@ var OnnxNerScanner = class { const results = []; let offset = 0; for (const line of lines) { - if (line.trim().length > 2) { + const { cleanText, preambleLength } = stripMarkdownPreamble(line); + if (cleanText.trim().length > 2) { try { - const entities = await _pipeline(line, { aggregation_strategy: "simple" }); + const entities = await _pipeline(cleanText, { aggregation_strategy: "simple" }); for (const ent of entities) { if (ent.score < minScore) continue; @@ -33878,8 +34265,8 @@ var OnnxNerScanner = class { if (functionWords.has(word.toLowerCase())) continue; const category = TAG_TO_CATEGORY[ent.entity_group] ?? "custom"; - const start = offset + ent.start; - const end = offset + ent.end; + const start = offset + preambleLength + ent.start; + const end = offset + preambleLength + ent.end; const ctxLen = 45; results.push({ id: `ner_${Date.now()}_${++_counter2}`, @@ -34229,9 +34616,9 @@ var DictionaryLoader = class { }; // src/ui/DictScanReviewModal.ts -var import_obsidian9 = require("obsidian"); +var import_obsidian10 = require("obsidian"); init_i18n(); -var DictScanReviewModal = class extends import_obsidian9.Modal { +var DictScanReviewModal = class extends import_obsidian10.Modal { constructor(app, plugin, file, results, existingReplacements) { super(app); // remplacement fixe pour les entrées word-to-word @@ -34382,7 +34769,7 @@ var DictScanReviewModal = class extends import_obsidian9.Modal { const mappingPath = `${this.plugin.settings.mappingFolder}/${this.file.basename}.mapping.json`; let store; const mappingFile = this.app.vault.getAbstractFileByPath(mappingPath); - if (mappingFile instanceof import_obsidian9.TFile) { + if (mappingFile instanceof import_obsidian10.TFile) { const data = JSON.parse(await this.app.vault.read(mappingFile)); store = MappingStore.fromJSON(data); } else { @@ -34408,14 +34795,14 @@ var DictScanReviewModal = class extends import_obsidian9.Modal { }); } const json = JSON.stringify(store.toJSON(), null, 2); - if (mappingFile instanceof import_obsidian9.TFile) { + if (mappingFile instanceof import_obsidian10.TFile) { await this.app.vault.modify(mappingFile, json); } else { await this.app.vault.create(mappingPath, json); } const n = toCreate.length; - new import_obsidian9.Notice(t("notice.rulesCreated", String(n), n > 1 ? t("notice.rulesCreated.rules") : t("notice.rulesCreated.rule"))); - void this.plugin.refreshHighlightData(); + new import_obsidian10.Notice(t("notice.rulesCreated", String(n), n > 1 ? t("notice.rulesCreated.rules") : t("notice.rulesCreated.rule"))); + void this.plugin.refresh(); this.close(); } onClose() { @@ -34424,15 +34811,15 @@ var DictScanReviewModal = class extends import_obsidian9.Modal { }; // src/ui/CorpusModal.ts -var import_obsidian10 = require("obsidian"); +var import_obsidian11 = require("obsidian"); init_i18n(); function getCorpusClasses(app, transcriptionsFolder) { const folder = app.vault.getAbstractFileByPath(transcriptionsFolder); - if (!(folder instanceof import_obsidian10.TFolder)) + if (!(folder instanceof import_obsidian11.TFolder)) return []; - return folder.children.filter((c) => c instanceof import_obsidian10.TFolder).map((c) => c.name).sort(); + return folder.children.filter((c) => c instanceof import_obsidian11.TFolder).map((c) => c.name).sort(); } -var CorpusModal = class extends import_obsidian10.Modal { +var CorpusModal = class extends import_obsidian11.Modal { constructor(app, plugin) { super(app); this.plugin = plugin; @@ -34476,7 +34863,7 @@ var CorpusModal = class extends import_obsidian10.Modal { const s = this.plugin.settings; const transcPath = `${s.transcriptionsFolder}/${cls}`; const folder = this.app.vault.getAbstractFileByPath(transcPath); - const fileCount = folder instanceof import_obsidian10.TFolder ? folder.children.filter((c) => !(c instanceof import_obsidian10.TFolder)).length : 0; + const fileCount = folder instanceof import_obsidian11.TFolder ? folder.children.filter((c) => !(c instanceof import_obsidian11.TFolder)).length : 0; const li = list.createEl("li", { cls: "pseudobs-corpus-class-item" }); const nameWrap = li.createDiv("pseudobs-corpus-class-name"); nameWrap.createEl("strong", { text: cls }); @@ -34498,12 +34885,12 @@ var CorpusModal = class extends import_obsidian10.Modal { async addClass(name2) { const trimmed = name2.trim(); if (!trimmed) { - new import_obsidian10.Notice(t("corpus.modal.classNameEmpty")); + new import_obsidian11.Notice(t("corpus.modal.classNameEmpty")); return; } const existing = getCorpusClasses(this.app, this.plugin.settings.transcriptionsFolder); if (existing.includes(trimmed)) { - new import_obsidian10.Notice(t("corpus.modal.classExists")); + new import_obsidian11.Notice(t("corpus.modal.classExists")); return; } const s = this.plugin.settings; @@ -34530,7 +34917,7 @@ var CorpusModal = class extends import_obsidian10.Modal { this.contentEl.empty(); } }; -var ClassSelectModal = class extends import_obsidian10.Modal { +var ClassSelectModal = class extends import_obsidian11.Modal { constructor(app, plugin, classes) { super(app); this.plugin = plugin; @@ -34659,6 +35046,402 @@ var ChatParser = class { } }; +// src/parsers/VttParser.ts +var TIME_RE = /(\d{1,2}:\d{2}:\d{2}\.\d{3}|\d{2}:\d{2}\.\d{3})/; +var TIMESTAMP_LINE_RE = new RegExp( + `^${TIME_RE.source}\\s+-->\\s+${TIME_RE.source}\\s*(.*)$` +); +var WORD_TIME_RE = /<(\d{1,2}:\d{2}:\d{2}\.\d{3}|\d{2}:\d{2}\.\d{3})>/g; +var CLASS_TAG_RE = /<\/?c>/g; +var SPEAKER_V_RE = /^]+)?\s+([^>]+)>/; +var SPEAKER_BRACKET_RE = /^\[([^\]]+)\]/; +var ALL_TAGS_RE = /<[^>]+>/g; +function normalizeTime(t2) { + return t2.includes(":") && t2.split(":").length === 2 ? `00:${t2}` : t2; +} +function stripTags(text) { + return text.replace(ALL_TAGS_RE, "").trim(); +} +function extractWords(rawText) { + const hasWordTimes = WORD_TIME_RE.test(rawText); + WORD_TIME_RE.lastIndex = 0; + if (!hasWordTimes) { + const clean = stripTags(rawText); + return clean ? [{ text: clean, time: "" }] : []; + } + const words = []; + const remaining = rawText; + let currentTime = ""; + const parts = remaining.split(WORD_TIME_RE); + for (let i2 = 0; i2 < parts.length; i2++) { + const part = parts[i2]; + if (TIME_RE.test(part) && part.match(/^\d/)) { + currentTime = normalizeTime(part); + } else { + const clean = part.replace(CLASS_TAG_RE, "").replace(ALL_TAGS_RE, ""); + if (clean.trim()) { + words.push({ text: clean, time: currentTime }); + currentTime = ""; + } + } + } + return words; +} +function extractSpeakerAndText(line) { + const vMatch = SPEAKER_V_RE.exec(line); + if (vMatch) { + return { speaker: vMatch[1].trim(), text: line.slice(vMatch[0].length) }; + } + const bMatch = SPEAKER_BRACKET_RE.exec(line); + if (bMatch) { + return { speaker: bMatch[1].trim(), text: line.slice(bMatch[0].length).trim() }; + } + return { speaker: void 0, text: line }; +} +var VttParser = class { + parse(content) { + const trailingNewline = content.endsWith("\n"); + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trimEnd(); + const lines = normalized.split("\n"); + let i2 = 0; + while (i2 < lines.length && !lines[i2].startsWith("WEBVTT")) + i2++; + i2++; + const cues = []; + while (i2 < lines.length) { + while (i2 < lines.length && lines[i2].trim() === "") + i2++; + if (i2 >= lines.length) + break; + let cueId; + if (i2 < lines.length && !TIMESTAMP_LINE_RE.test(lines[i2])) { + cueId = lines[i2].trim(); + i2++; + } + if (i2 >= lines.length) + break; + const tsMatch = TIMESTAMP_LINE_RE.exec(lines[i2]); + if (!tsMatch) { + i2++; + continue; + } + const startTime = normalizeTime(tsMatch[1]); + const endTime = normalizeTime(tsMatch[2]); + i2++; + const rawLines = []; + while (i2 < lines.length && lines[i2].trim() !== "") { + rawLines.push(lines[i2]); + i2++; + } + if (rawLines.length === 0) + continue; + const firstLineResult = extractSpeakerAndText(rawLines[0]); + const speaker = firstLineResult.speaker; + const textLines = [firstLineResult.text, ...rawLines.slice(1)]; + const fullRaw = textLines.join("\n"); + const words = extractWords(fullRaw); + const text = words.map((w) => w.text).join(""); + cues.push({ id: cueId, startTime, endTime, speaker, text, words, rawLines }); + } + return { cues, trailingNewline }; + } + reconstruct(doc) { + const parts = ["WEBVTT", ""]; + for (const cue of doc.cues) { + if (cue.id !== void 0) + parts.push(cue.id); + parts.push(`${cue.startTime} --> ${cue.endTime}`); + if (cue.words.length > 0 && cue.words.some((w) => w.time !== "")) { + const line = this.reconstructWithWordTimestamps(cue); + parts.push(line); + } else { + const speaker = cue.speaker; + const textLine = speaker ? `${cue.text}` : cue.text; + parts.push(textLine); + } + parts.push(""); + } + const body = parts.join("\n"); + return doc.trailingNewline ? body : body.trimEnd(); + } + reconstructWithWordTimestamps(cue) { + let line = cue.speaker ? `` : ""; + for (const word of cue.words) { + if (word.time) + line += `<${word.time}>`; + line += `${word.text}`; + } + return line; + } + /** + * Met à jour le texte d'une cue après pseudonymisation. + * Propage le remplacement dans le tableau words pour maintenir le round-trip. + */ + static applyTextToWords(cue, newText) { + cue.text = newText; + if (cue.words.length === 0 || cue.words.every((w) => w.time === "")) { + cue.words = [{ text: newText, time: "" }]; + return; + } + cue.words[0].text = newText; + for (let i2 = 1; i2 < cue.words.length; i2++) { + cue.words[i2].text = ""; + } + } +}; + +// src/parsers/NoScribeHtmlParser.ts +var PARA_RE = /]*)?>(?.*?)<\/p>/gs; +var ANCHOR_RE = /]*>(.*?)<\/a>/gs; +var TS_NAME_RE = /^ts_(\d+)_(\d+)_(\w*)$/; +var DISPLAY_TS_RE = /^\[\d{2}:\d{2}:\d{2}\]$/; +var SPEAKER_RE = /^(.+?)\s*:\s*/; +var ALL_TAGS_RE2 = /<[^>]+>/g; +var HTML_ENTITIES = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + "'": "'", + " ": "\xA0" +}; +function decodeEntities(text) { + return text.replace(/&[^;]+;/g, (m) => HTML_ENTITIES[m] ?? m); +} +function stripTags2(html) { + return decodeEntities(html.replace(ALL_TAGS_RE2, "")); +} +function pad2(n) { + return String(n).padStart(2, "0"); +} +function pad3(n) { + return String(n).padStart(3, "0"); +} +function msToTimestamp(ms) { + const h = Math.floor(ms / 36e5); + const m = Math.floor(ms % 36e5 / 6e4); + const s = Math.floor(ms % 6e4 / 1e3); + const mss = ms % 1e3; + return `${pad2(h)}:${pad2(m)}:${pad2(s)}.${pad3(mss)}`; +} +var NoScribeHtmlParser = class { + /** Vérifie qu'un contenu HTML est une sortie noScribe. */ + static isNoScribeHtml(content) { + return content.includes("qrichtext") && content.includes(' w.text.trim().length > 0); + if (words.length === 0) + continue; + const startMs = grouped.get(groupOrder[0]).startMs; + const endMs = grouped.get(groupOrder[groupOrder.length - 1]).endMs; + cues.push({ + startTime: msToTimestamp(startMs), + endTime: msToTimestamp(endMs), + speaker, + text: words.map((w) => w.text).join(""), + words, + rawLines: [] + }); + } + return { cues, trailingNewline: false }; + } +}; + +// src/parsers/NoScribeVttParser.ts +var TIMESTAMP_LINE_RE2 = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})/; +var SPEAKER_V_TAG_RE = /^/; +var SPEAKER_LABEL_RE = /^(S\d+)\s*:\s*/; +var DISPLAY_TS_RE2 = /^\[\d{2}:\d{2}:\d{2}\]\s*/; +var HTML_ENTITIES2 = { + "'": "'", + "'": "'", + "&": "&", + "<": "<", + ">": ">", + """: '"', + " ": " " +}; +function decodeEntities2(text) { + return text.replace(/&#?x?[0-9a-zA-Z]+;/g, (m) => HTML_ENTITIES2[m] ?? m); +} +function stripSpeakerVTag(line) { + return SPEAKER_V_TAG_RE.test(line) ? line.replace(SPEAKER_V_TAG_RE, "") : line; +} +var NoScribeVttParser = class { + /** Détecte un VTT produit par noScribe (présence de NOTE noScribe). */ + static isNoScribeVtt(content) { + return content.startsWith("WEBVTT") && content.includes("noScribe"); + } + /** Extrait le chemin audio depuis la ligne NOTE media. */ + static extractAudioSource(content) { + const m = /^NOTE\s+media:\s*(.+)$/m.exec(content); + return m ? m[1].trim() : null; + } + parse(content) { + const rawCues = this.parseRawCues(content); + const fragments = this.buildFragments(rawCues); + const cues = this.mergeIntoCues(fragments); + return { cues, trailingNewline: content.endsWith("\n") }; + } + // --- Étape 1 : parser les cues brutes ------------------------------------ + parseRawCues(content) { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = normalized.split("\n"); + const cues = []; + let i2 = 0; + while (i2 < lines.length) { + const tsMatch = TIMESTAMP_LINE_RE2.exec(lines[i2]); + if (tsMatch) { + const startTime = tsMatch[1]; + const endTime = tsMatch[2]; + i2++; + const textLines = []; + while (i2 < lines.length && lines[i2].trim() !== "") { + const cleaned = decodeEntities2(stripSpeakerVTag(lines[i2])); + textLines.push(cleaned); + i2++; + } + const text = textLines.join(" ").trim(); + cues.push({ startTime, endTime, text }); + } else { + i2++; + } + } + return cues; + } + // --- Étape 2 : convertir en fragments (speaker + text + timestamp) -------- + buildFragments(cues) { + const fragments = []; + let currentSpeaker; + for (const cue of cues) { + let text = cue.text; + const labelMatch = SPEAKER_LABEL_RE.exec(text); + if (labelMatch) { + currentSpeaker = labelMatch[1]; + text = text.slice(labelMatch[0].length); + } + text = text.replace(DISPLAY_TS_RE2, "").trim(); + if (!text || /^\[[\d:]+\]$/.test(text)) + continue; + fragments.push({ + startTime: cue.startTime, + endTime: cue.endTime, + speaker: currentSpeaker, + text + }); + } + return fragments; + } + // --- Étape 3 : fusionner les fragments en VttCues ------------------------- + // + // Un "tour" commence à chaque nouveau label de locuteur dans l'étape 2. + // Les fragments sans changement de locuteur sont rattachés au tour courant. + // Heuristique de fusion : on regroupe les fragments temporellement proches + // (gap < 2 s) appartenant au même locuteur. + mergeIntoCues(fragments) { + if (fragments.length === 0) + return []; + const cues = []; + let batch = [fragments[0]]; + const flushBatch = () => { + if (batch.length === 0) + return; + const text = batch.map((f) => f.text).join(" ").replace(/\s+/g, " ").trim(); + if (text) { + cues.push({ + startTime: batch[0].startTime, + endTime: batch[batch.length - 1].endTime, + speaker: batch[0].speaker, + text, + words: [{ text, time: batch[0].startTime }], + rawLines: [] + }); + } + batch = []; + }; + for (let i2 = 1; i2 < fragments.length; i2++) { + const prev = batch[batch.length - 1]; + const curr = fragments[i2]; + const speakerChanged = curr.speaker !== prev.speaker; + const prevEndSec = timeToSeconds(prev.endTime); + const currStartSec = timeToSeconds(curr.startTime); + const bigGap = !speakerChanged && currStartSec - prevEndSec > 2; + if (speakerChanged || bigGap) { + flushBatch(); + batch = [curr]; + } else { + batch.push(curr); + } + } + flushBatch(); + return cues; + } +}; +function timeToSeconds(ts) { + const [h, m, s] = ts.split(":"); + return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); +} + // src/parsers/TranscriptConverter.ts function srtToMarkdown(doc, sourceName) { const lines = [ @@ -34716,6 +35499,64 @@ function chatToMarkdown(doc, sourceName) { lines.push(""); return lines.join("\n"); } +function extractWordData(doc) { + return doc.cues.map((cue, index) => ({ + index, + startTime: cue.startTime, + endTime: cue.endTime, + speaker: cue.speaker, + words: cue.words + })).filter((c) => c.words.some((w) => w.time !== "")); +} +var MD_CUE_RE = /^(?:\*\*([^*]+)\*\*\s+)?\[[\d:]+\]\s*(?::\s*)?(.*)$/; +function markdownToVtt(mdContent, wordData) { + const bodyMatch = /^---\n[\s\S]*?\n---\n+([\s\S]*)$/.exec(mdContent); + const body = bodyMatch ? bodyMatch[1] : mdContent; + const cueLines = body.split("\n").filter((l) => MD_CUE_RE.test(l.trim())); + const mismatch = cueLines.length !== wordData.length; + const parts = ["WEBVTT", ""]; + const count = Math.min(cueLines.length, wordData.length); + for (let i2 = 0; i2 < count; i2++) { + const m = MD_CUE_RE.exec(cueLines[i2].trim()); + const speaker = m[1]?.trim() || wordData[i2].speaker; + const text = m[2]?.trim() ?? ""; + if (!text) + continue; + parts.push(String(i2 + 1)); + parts.push(`${wordData[i2].startTime} --> ${wordData[i2].endTime}`); + parts.push(speaker ? `${text}` : text); + parts.push(""); + } + return { vtt: parts.join("\n"), mismatch }; +} +function displayTime(ts) { + return `[${ts.slice(0, 8)}]`; +} +function vttDocToMarkdown(doc, sourceName, format, audioFilename) { + const lines = ["---", `pseudobs-format: ${format}`, `pseudobs-source: "${sourceName}"`]; + if (audioFilename) + lines.push(`pseudobs-audio: "${audioFilename}"`); + lines.push("---", ""); + for (const cue of doc.cues) { + const ts = displayTime(cue.startTime); + if (cue.speaker) { + lines.push(`**${cue.speaker}** ${ts} : ${cue.text}`); + } else { + lines.push(`${ts} ${cue.text}`); + } + lines.push(""); + } + while (lines[lines.length - 1] === "") + lines.pop(); + lines.push(""); + return lines.join("\n"); +} +function vttToMarkdown(doc, sourceName, audioFilename) { + return vttDocToMarkdown(doc, sourceName, "vtt", audioFilename); +} +function noScribeHtmlToMarkdown(doc, sourceName, audioFilename) { + return vttDocToMarkdown(doc, sourceName, "html", audioFilename); +} function lineGroup(line) { if (line.type === "meta" || line.type === "dependent") return "structural"; @@ -34725,22 +35566,30 @@ function lineGroup(line) { } // src/mappings/ScopeResolver.ts -var import_obsidian11 = require("obsidian"); +var import_obsidian12 = require("obsidian"); var ScopeResolver = class { constructor(vault, mappingFolder) { this.vault = vault; this.mappingFolder = mappingFolder; } + /** Collecte récursivement tous les fichiers .mapping.json dans un dossier. */ + collectMappingFiles(folder) { + const files = []; + for (const child of folder.children) { + if (child instanceof import_obsidian12.TFile && child.name.endsWith(".mapping.json")) { + files.push(child); + } else if (child instanceof import_obsidian12.TFolder) { + files.push(...this.collectMappingFiles(child)); + } + } + return files; + } async getRulesFor(filePath) { const folder = this.vault.getAbstractFileByPath(this.mappingFolder); - if (!(folder instanceof import_obsidian11.TFolder)) + if (!(folder instanceof import_obsidian12.TFolder)) return []; const allRules = []; - for (const child of folder.children) { - if (!(child instanceof import_obsidian11.TFile)) - continue; - if (!child.name.endsWith(".mapping.json")) - continue; + for (const child of this.collectMappingFiles(folder)) { try { const raw = await this.vault.read(child); const data = JSON.parse(raw); @@ -34762,12 +35611,10 @@ var ScopeResolver = class { // Retourne aussi le store et le chemin JSON pour permettre la modification. async findRuleByTerm(term) { const folder = this.vault.getAbstractFileByPath(this.mappingFolder); - if (!(folder instanceof import_obsidian11.TFolder)) + if (!(folder instanceof import_obsidian12.TFolder)) return null; const needle = term.toLowerCase(); - for (const child of folder.children) { - if (!(child instanceof import_obsidian11.TFile) || !child.name.endsWith(".mapping.json")) - continue; + for (const child of this.collectMappingFiles(folder)) { try { const data = JSON.parse(await this.vault.read(child)); const store = MappingStore.fromJSON(data); @@ -34785,12 +35632,10 @@ var ScopeResolver = class { // Utilisé par l'onglet Mappings du panneau latéral pour l'édition et la suppression. async getRulesWithLocation(filePath) { const folder = this.vault.getAbstractFileByPath(this.mappingFolder); - if (!(folder instanceof import_obsidian11.TFolder)) + if (!(folder instanceof import_obsidian12.TFolder)) return []; const result = []; - for (const child of folder.children) { - if (!(child instanceof import_obsidian11.TFile) || !child.name.endsWith(".mapping.json")) - continue; + for (const child of this.collectMappingFiles(folder)) { try { const data = JSON.parse(await this.vault.read(child)); const store = MappingStore.fromJSON(data); @@ -34813,12 +35658,10 @@ var ScopeResolver = class { // Utilisé par l'onglet Mappings en mode "toutes les règles". async getAllRulesWithLocation() { const folder = this.vault.getAbstractFileByPath(this.mappingFolder); - if (!(folder instanceof import_obsidian11.TFolder)) + if (!(folder instanceof import_obsidian12.TFolder)) return []; const result = []; - for (const child of folder.children) { - if (!(child instanceof import_obsidian11.TFile) || !child.name.endsWith(".mapping.json")) - continue; + for (const child of this.collectMappingFiles(folder)) { try { const data = JSON.parse(await this.vault.read(child)); const store = MappingStore.fromJSON(data); @@ -34835,7 +35678,7 @@ var ScopeResolver = class { async getRulesFromMappingFile(mappingFilename) { const path4 = `${this.mappingFolder}/${mappingFilename}`; const file = this.vault.getAbstractFileByPath(path4); - if (!(file instanceof import_obsidian11.TFile)) + if (!(file instanceof import_obsidian12.TFile)) return []; try { const data = JSON.parse(await this.vault.read(file)); @@ -34849,7 +35692,7 @@ var ScopeResolver = class { async saveStore(store, filePath) { const file = this.vault.getAbstractFileByPath(filePath); const json = JSON.stringify(store.toJSON(), null, 2); - if (file instanceof import_obsidian11.TFile) { + if (file instanceof import_obsidian12.TFile) { await this.vault.modify(file, json); } } @@ -34871,15 +35714,19 @@ var PseudonymizationEngine = class { }; // src/main.ts -var CONVERTIBLE_EXTS = ["srt", "cha", "chat"]; -var PseudObsPlugin = class extends import_obsidian12.Plugin { +var CONVERTIBLE_EXTS = ["srt", "cha", "chat", "vtt", "html"]; +var PseudObsPlugin = class extends import_obsidian13.Plugin { constructor() { super(...arguments); // Cache synchrone pour le surlignage CM6 (mis à jour de façon asynchrone) - this.highlightData = { sources: [], replacements: [], nerCandidates: [] }; + this.highlightData = { sources: [], replacements: [], nerCandidates: [], ignoredTerms: [] }; // Candidats NER par fichier (effacés au changement de fichier ou à un nouveau scan) this.nerCandidateFile = null; this.nerCandidates = []; + // Dernière MarkdownView connue — pour mettre à jour le surlignage même + // quand le panneau latéral a le focus (getActiveViewOfType retourne null dans ce cas) + this.lastMarkdownView = null; + this._viewRefreshTimer = null; } async onload() { await this.loadSettings(); @@ -34897,11 +35744,21 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { createPseudonymHighlighter(() => this.highlightData) ); this.registerEvent( - this.app.workspace.on("active-leaf-change", () => { - void this.refreshHighlightData(); + this.app.workspace.on("active-leaf-change", (leaf) => { + const v = leaf?.view; + if (v instanceof import_obsidian13.MarkdownView) + this.lastMarkdownView = v; + void this.refresh(); }) ); - void this.refreshHighlightData(); + this.registerEvent( + this.app.vault.on("modify", (file) => { + if (file instanceof import_obsidian13.TFile && !file.name.endsWith(".mapping.json") && file === this.app.workspace.getActiveFile()) { + void this.refresh(); + } + }) + ); + void this.refresh(); if (!this.settings.onboardingCompleted) { this.app.workspace.onLayoutReady(() => { new OnboardingModal(this.app, this).open(); @@ -34909,7 +35766,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { } this.registerEvent( this.app.vault.on("create", (file) => { - if (!(file instanceof import_obsidian12.TFile)) + if (!(file instanceof import_obsidian13.TFile)) return; if (!CONVERTIBLE_EXTS.includes(file.extension.toLowerCase())) return; @@ -34955,6 +35812,11 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { name: t("command.scanDictionaries"), callback: () => void this.scanCurrentFileWithDictionaries() }); + this.addCommand({ + id: "export-as-vtt", + name: t("command.exportAsVtt"), + callback: () => void this.exportCurrentFileAsVtt() + }); this.addCommand({ id: "pseudonymize-selection", name: t("command.pseudonymizeSelection"), @@ -34984,11 +35846,11 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { (item) => item.setTitle(t("contextMenu.cancelPseudonymization", truncate(bare))).setIcon("undo").onClick(async () => { const location = await this.scopeResolver.findRuleByTerm(bare); if (!location) { - new import_obsidian12.Notice(t("notice.ruleNotFound")); + new import_obsidian13.Notice(t("notice.ruleNotFound")); return; } editor.replaceSelection(location.rule.source); - void this.refreshHighlightData(); + void this.refresh(); }) ); } @@ -34999,7 +35861,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { if (location) { new EditRuleModal(this.app, this, location).open(); } else { - new import_obsidian12.Notice(t("notice.ruleNotFound")); + new import_obsidian13.Notice(t("notice.ruleNotFound")); } }) ); @@ -35007,13 +35869,16 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { menu.addItem( (item) => item.setTitle(t("contextMenu.pseudonymize", truncate(selection))).setIcon("eye-off").onClick(() => new QuickPseudonymizeModal(this.app, this, editor).open()) ); + menu.addItem( + (item) => item.setTitle(t("contextMenu.redact", truncate(selection))).setIcon("square").onClick(() => new QuickPseudonymizeModal(this.app, this, editor, generateRedaction(selection), [], true).open()) + ); menu.addItem( (item) => item.setTitle(t("contextMenu.coulmont")).setIcon("book-user").onClick(async () => { - const notice = new import_obsidian12.Notice("Recherche sur coulmont.com\u2026", 0); + const notice = new import_obsidian13.Notice("Recherche sur coulmont.com\u2026", 0); const suggestions = await this.fetchCoulmont(selection); notice.hide(); if (suggestions.length === 0) { - new import_obsidian12.Notice(`Aucun r\xE9sultat Coulmont pour "${selection}".`); + new import_obsidian13.Notice(`Aucun r\xE9sultat Coulmont pour "${selection}".`); return; } new RuleModal(this.app, this, selection, "", suggestions).open(); @@ -35045,7 +35910,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { async fetchCoulmont(prenom) { try { const url2 = `https://coulmont.com/bac/results.php?search=${encodeURIComponent(prenom)}`; - const response = await (0, import_obsidian12.requestUrl)({ url: url2, method: "GET" }); + const response = await (0, import_obsidian13.requestUrl)({ url: url2, method: "GET" }); const doc = new DOMParser().parseFromString(response.text, "text/html"); const els = doc.querySelectorAll( "#hero > div > div > div > div > p.mb-1.mb-md-1 > a" @@ -35060,7 +35925,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { async refreshHighlightData() { const file = this.app.workspace.getActiveFile(); if (!file) { - this.highlightData = { sources: [], replacements: [], nerCandidates: [] }; + this.highlightData = { sources: [], replacements: [], nerCandidates: [], ignoredTerms: [] }; } else { const nerCandidates = file === this.nerCandidateFile ? this.nerCandidates : []; try { @@ -35079,19 +35944,51 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { } else { rules = await this.scopeResolver.getRulesFor(file.path); } + const ignoredTerms = rules.flatMap( + (r) => (r.ignoredOccurrences ?? []).map((o) => o.text) + ); this.highlightData = { sources: rules.map((r) => r.source).filter(Boolean), replacements: rules.map((r) => r.replacement).filter(Boolean), - nerCandidates + nerCandidates, + ignoredTerms }; } catch { - this.highlightData = { sources: [], replacements: [], nerCandidates }; + this.highlightData = { sources: [], replacements: [], nerCandidates, ignoredTerms: [] }; } } - const view = this.app.workspace.getActiveViewOfType(import_obsidian12.MarkdownView); + const view = this.app.workspace.getActiveViewOfType(import_obsidian13.MarkdownView) ?? this.lastMarkdownView; const cm = view?.editor && view.editor.cm; cm?.dispatch({ effects: highlightDataChanged.of(void 0) }); } + /** + * Rafraîchit à la fois le surlignage CM6 ET le panneau latéral. + * À appeler après toute action qui crée, modifie ou supprime une règle. + */ + async refresh() { + await this.refreshHighlightData(); + this.refreshView(); + } + /** + * Demande au panneau latéral ouvert de re-rendre son onglet actif. + * Debounce 80 ms pour coalescer les appels multiples rapides + * (vault watcher + appel explicite dans la même action). + */ + refreshView() { + if (this._viewRefreshTimer !== null) { + window.clearTimeout(this._viewRefreshTimer); + } + this._viewRefreshTimer = window.setTimeout(() => { + this._viewRefreshTimer = null; + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PSEUDOBS); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof PseudonymizationView) { + void view.refreshActiveTab(); + } + } + }, 80); + } // --- Conversion automatique --- async autoConvert(file) { try { @@ -35100,38 +35997,108 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { const basename = file.basename; const folder = file.parent?.path ?? ""; const mdPath = folder ? `${folder}/${basename}.md` : `${basename}.md`; + if (this.app.vault.getAbstractFileByPath(mdPath) instanceof import_obsidian13.TFile) { + new import_obsidian13.Notice(t("notice.conversionSkipped", basename, file.name)); + return; + } + let audioFilename = this.findAudioInVaultFolder(folder); let mdContent; + let wordData = null; if (ext === "srt") { mdContent = srtToMarkdown(new SrtParser().parse(raw), file.name); + } else if (ext === "vtt") { + const doc = NoScribeVttParser.isNoScribeVtt(raw) ? new NoScribeVttParser().parse(raw) : new VttParser().parse(raw); + wordData = extractWordData(doc); + if (!audioFilename && NoScribeVttParser.isNoScribeVtt(raw)) { + const audioSource = NoScribeVttParser.extractAudioSource(raw); + if (audioSource) + audioFilename = await this.importAudioFromPath(audioSource, folder || this.settings.transcriptionsFolder); + } + const toMd = NoScribeVttParser.isNoScribeVtt(raw) ? noScribeHtmlToMarkdown : vttToMarkdown; + mdContent = toMd(doc, file.name, audioFilename ?? void 0); + } else if (ext === "html") { + if (!NoScribeHtmlParser.isNoScribeHtml(raw)) + return; + const doc = new NoScribeHtmlParser().parse(raw); + wordData = extractWordData(doc); + if (!audioFilename) { + const audioSource = NoScribeHtmlParser.extractAudioSource(raw); + if (audioSource) { + audioFilename = await this.importAudioFromPath(audioSource, folder || this.settings.transcriptionsFolder); + } + } + mdContent = noScribeHtmlToMarkdown(doc, file.name, audioFilename ?? void 0); } else { mdContent = chatToMarkdown(new ChatParser().parse(raw), file.name); } - if (this.app.vault.getAbstractFileByPath(mdPath) instanceof import_obsidian12.TFile) { - new import_obsidian12.Notice(t("notice.conversionSkipped", basename, file.name)); - return; - } await this.app.vault.create(mdPath, mdContent); - const mappingPath = `${this.settings.mappingFolder}/${basename}.mapping.json`; + const transcRoot = this.settings.transcriptionsFolder; + const fileFolder = file.parent?.path ?? ""; + const relSubFolder = fileFolder.startsWith(transcRoot) ? fileFolder.slice(transcRoot.length).replace(/^\//, "") : ""; + const mappingDir = relSubFolder ? `${this.settings.mappingFolder}/${relSubFolder}` : this.settings.mappingFolder; + await this.ensureFolder(mappingDir); + const mappingPath = `${mappingDir}/${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)); } + if (wordData && wordData.length > 0) { + const wordsPath = `${mappingDir}/${basename}.words.json`; + if (!this.app.vault.getAbstractFileByPath(wordsPath)) { + await this.app.vault.create(wordsPath, JSON.stringify(wordData, null, 2)); + } + } await this.app.fileManager.trashFile(file); const mdFile = this.app.vault.getAbstractFileByPath(mdPath); - if (mdFile instanceof import_obsidian12.TFile) { + if (mdFile instanceof import_obsidian13.TFile) { await this.app.workspace.getLeaf().openFile(mdFile); } - new import_obsidian12.Notice(t("notice.converted", file.name, `${basename}.md`)); + new import_obsidian13.Notice(t("notice.converted", file.name, `${basename}.md`)); } catch (e) { - new import_obsidian12.Notice(t("notice.conversionError", file.name, e.message)); + new import_obsidian13.Notice(t("notice.conversionError", file.name, e.message)); + } + } + /** Retourne le nom du premier fichier audio trouvé dans un dossier du vault. */ + findAudioInVaultFolder(folderPath) { + const AUDIO_EXTS = /* @__PURE__ */ new Set(["m4a", "mp3", "wav", "ogg", "flac", "mp4", "aac", "aiff"]); + const folder = this.app.vault.getAbstractFileByPath(folderPath || "/"); + if (!(folder instanceof import_obsidian13.TFolder)) + return null; + const audioFile = folder.children.find( + (f) => f instanceof import_obsidian13.TFile && AUDIO_EXTS.has(f.extension.toLowerCase()) + ); + return audioFile?.name ?? null; + } + /** + * Copie un fichier audio externe (chemin absolu sur disque) dans le vault. + * Utilise l'API Node.js fs — desktop uniquement. + * Retourne le nom du fichier importé, ou null en cas d'échec. + */ + async importAudioFromPath(sourcePath, targetFolder) { + try { + const nodeFs = require("fs"); + if (!nodeFs.existsSync(sourcePath)) + return null; + const audioFilename = sourcePath.replace(/\\/g, "/").split("/").pop(); + const destPath = targetFolder ? `${targetFolder}/${audioFilename}` : audioFilename; + if (this.app.vault.getAbstractFileByPath(destPath) instanceof import_obsidian13.TFile) { + return audioFilename; + } + const buffer = await nodeFs.promises.readFile(sourcePath); + await this.ensureFolder(targetFolder); + const arrayBuf = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + await this.app.vault.createBinary(destPath, arrayBuf); + new import_obsidian13.Notice(`Audio import\xE9 : ${audioFilename}`); + return audioFilename; + } catch { + return null; } } // --- Commande "Ajouter une transcription" --- openFilePicker() { const input = activeDocument.createElement("input"); input.type = "file"; - input.accept = ".srt,.cha,.chat,.txt,.md"; + input.accept = ".srt,.vtt,.cha,.chat,.html,.txt,.md"; input.multiple = true; input.classList.add("pseudobs-hidden-input"); activeDocument.body.appendChild(input); @@ -35149,6 +36116,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { } async copyToVault(browserFile) { const raw = await browserFile.text(); + const ext = browserFile.name.split(".").pop()?.toLowerCase() ?? ""; const classes = getCorpusClasses(this.app, this.settings.transcriptionsFolder); let targetFolder = this.settings.transcriptionsFolder; if (classes.length > 0) { @@ -35161,28 +36129,57 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { } await this.ensureFolder(targetFolder); const destPath = `${targetFolder}/${browserFile.name}`; - if (this.app.vault.getAbstractFileByPath(destPath) instanceof import_obsidian12.TFile) { - new import_obsidian12.Notice(t("notice.fileExists", browserFile.name)); + if (this.app.vault.getAbstractFileByPath(destPath) instanceof import_obsidian13.TFile) { + new import_obsidian13.Notice(t("notice.fileExists", browserFile.name)); return; } await this.app.vault.create(destPath, raw); + if (ext === "vtt") { + const sourcePath = browserFile.path; + if (sourcePath) { + const sourceDir = sourcePath.replace(/\\/g, "/").replace(/\/[^/]+$/, ""); + const audioPath = await this.findAudioInSourceFolder(sourceDir); + if (audioPath) + await this.importAudioFromPath(audioPath, targetFolder); + } + } + } + /** + * Cherche un fichier audio dans un dossier sur le disque (hors vault). + * Retourne le chemin absolu du seul fichier audio trouvé, ou null si 0 ou >1. + */ + async findAudioInSourceFolder(folderPath) { + try { + const nodeFs = require("fs"); + const AUDIO_EXTS = /* @__PURE__ */ new Set(["m4a", "mp3", "wav", "ogg", "flac", "mp4", "aac", "aiff"]); + const entries = await nodeFs.promises.readdir(folderPath); + const audioFiles = entries.filter((f) => { + const ext = f.split(".").pop()?.toLowerCase() ?? ""; + return AUDIO_EXTS.has(ext); + }); + if (audioFiles.length === 1) + return `${folderPath}/${audioFiles[0]}`; + return null; + } catch { + return null; + } } // --- Pseudonymisation --- async pseudonymizeActiveFile() { const file = this.app.workspace.getActiveFile(); if (!file) { - new import_obsidian12.Notice(t("notice.noActiveFile")); + new import_obsidian13.Notice(t("notice.noActiveFile")); return; } const ext = file.extension.toLowerCase(); if (!["srt", "cha", "chat", "md", "txt"].includes(ext)) { - new import_obsidian12.Notice(t("notice.formatUnsupported", ext)); + new import_obsidian13.Notice(t("notice.formatUnsupported", ext)); return; } const content = await this.app.vault.read(file); const rules = await this.scopeResolver.getRulesFor(file.path); if (rules.length === 0) { - new import_obsidian12.Notice(t("notice.noRules")); + new import_obsidian13.Notice(t("notice.noRules")); return; } const marker = this.settings.useMarkerInExport ? { open: this.settings.markerOpen, close: this.settings.markerClose } : void 0; @@ -35213,26 +36210,26 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { 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 import_obsidian12.TFile) { + if (existing instanceof import_obsidian13.TFile) { await this.app.vault.modify(existing, pseudonymized); } else { await this.app.vault.create(outputPath, pseudonymized); } - new import_obsidian12.Notice(t("notice.exportDone", String(rules.length), outputPath)); + new import_obsidian13.Notice(t("notice.exportDone", String(rules.length), outputPath)); } async scanCurrentFileNer() { if (this.settings.nerBackend !== "transformers-js") { - new import_obsidian12.Notice("La d\xE9tection NER transformers.js n'est pas activ\xE9e.\nActivez-la dans Param\xE8tres \u2192 Pseudonymizer Tool."); + new import_obsidian13.Notice("La d\xE9tection NER transformers.js n'est pas activ\xE9e.\nActivez-la dans Param\xE8tres \u2192 Pseudonymizer Tool."); return; } const file = this.app.workspace.getActiveFile(); if (!file) { - new import_obsidian12.Notice(t("notice.noActiveFile")); + new import_obsidian13.Notice(t("notice.noActiveFile")); return; } const ext = file.extension.toLowerCase(); if (!["srt", "cha", "chat", "md", "txt"].includes(ext)) { - new import_obsidian12.Notice(t("notice.formatUnsupported", ext)); + new import_obsidian13.Notice(t("notice.formatUnsupported", ext)); return; } try { @@ -35242,31 +36239,31 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { functionWords: new Set(this.settings.nerFunctionWords.map((w) => w.toLowerCase())) }); if (occurrences.length === 0) { - new import_obsidian12.Notice(t("notice.noNerEntities")); + new import_obsidian13.Notice(t("notice.noNerEntities")); return; } const unique = [...new Set(occurrences.map((o) => o.text).filter(Boolean))]; this.nerCandidateFile = file; this.nerCandidates = unique; - void this.refreshHighlightData(); - new import_obsidian12.Notice(t("notice.nerEntitiesFound", String(unique.length), unique.length > 1 ? t("notice.nerEntitiesFound.entities") : t("notice.nerEntitiesFound.entity")), 6e3); + void this.refresh(); + new import_obsidian13.Notice(t("notice.nerEntitiesFound", String(unique.length), unique.length > 1 ? t("notice.nerEntitiesFound.entities") : t("notice.nerEntitiesFound.entity")), 6e3); } catch (e) { - new import_obsidian12.Notice(`NER error: ${e.message}`); + new import_obsidian13.Notice(`NER error: ${e.message}`); } } async scanCurrentFileWithDictionaries(dictIds) { if (!this.dictionaryLoader.hasDetection()) { - new import_obsidian12.Notice(t("notice.noDictDetection")); + new import_obsidian13.Notice(t("notice.noDictDetection")); return; } const file = this.app.workspace.getActiveFile(); if (!file) { - new import_obsidian12.Notice(t("notice.noActiveFile")); + new import_obsidian13.Notice(t("notice.noActiveFile")); return; } const ext = file.extension.toLowerCase(); if (!["srt", "cha", "chat", "md", "txt"].includes(ext)) { - new import_obsidian12.Notice(t("notice.formatUnsupported", ext)); + new import_obsidian13.Notice(t("notice.formatUnsupported", ext)); return; } const content = await this.app.vault.read(file); @@ -35274,7 +36271,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { const existingSources = new Set(rules.map((r) => r.source.toLowerCase())); const occurrences = this.dictionaryLoader.scanText(content, file.path, existingSources, dictIds); if (occurrences.length === 0) { - new import_obsidian12.Notice(t("notice.noDictEntities")); + new import_obsidian13.Notice(t("notice.noDictEntities")); return; } const seenTerms = /* @__PURE__ */ new Map(); @@ -35318,47 +36315,106 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { }); } if (results.length === 0) { - new import_obsidian12.Notice("Aucun remplacement disponible pour les entit\xE9s trouv\xE9es."); + new import_obsidian13.Notice("Aucun remplacement disponible pour les entit\xE9s trouv\xE9es."); return; } this.nerCandidateFile = file; this.nerCandidates = results.map((r) => r.term); - void this.refreshHighlightData(); + void this.refresh(); new DictScanReviewModal(this.app, this, file, results, existingReplacements).open(); } // Efface les candidats NER pour le fichier courant (appelé après création de règle si besoin) clearNerCandidates() { this.nerCandidates = []; this.nerCandidateFile = null; - void this.refreshHighlightData(); + void this.refresh(); } async exportMappingForFile(file) { const mappingPath = `${this.settings.mappingFolder}/${file.basename}.mapping.json`; const mappingFile = this.app.vault.getAbstractFileByPath(mappingPath); - if (!(mappingFile instanceof import_obsidian12.TFile)) { - new import_obsidian12.Notice(t("notice.noMapping", file.name)); + if (!(mappingFile instanceof import_obsidian13.TFile)) { + new import_obsidian13.Notice(t("notice.noMapping", file.name)); return; } const content = await this.app.vault.read(mappingFile); await this.ensureFolder(this.settings.exportsFolder); const destPath = `${this.settings.exportsFolder}/${file.basename}.mapping.json`; const existing = this.app.vault.getAbstractFileByPath(destPath); - if (existing instanceof import_obsidian12.TFile) { + if (existing instanceof import_obsidian13.TFile) { await this.app.vault.modify(existing, content); } else { await this.app.vault.create(destPath, content); } - new import_obsidian12.Notice(t("notice.mappingExported", destPath)); + new import_obsidian13.Notice(t("notice.mappingExported", destPath)); + } + /** + * Exporte le fichier Markdown noScribe actif en WebVTT pseudonymisé. + * Lit le .words.json correspondant pour les timestamps précis. + */ + async exportCurrentFileAsVtt() { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== "md") { + new import_obsidian13.Notice(t("notice.noActiveFile")); + return; + } + const content = await this.app.vault.read(file); + const formatMatch = /^pseudobs-format:\s*(\w+)/m.exec(content); + const format = formatMatch?.[1]; + if (format !== "vtt" && format !== "html") { + new import_obsidian13.Notice(t("notice.notNoScribeFormat")); + return; + } + const rawBasename = file.basename.replace(/\.pseudonymized$/, ""); + const wordsJson = await this.findWordsJson(rawBasename); + if (!wordsJson) { + new import_obsidian13.Notice(t("notice.wordsJsonMissing", rawBasename)); + return; + } + const wordData = JSON.parse(wordsJson); + const { vtt, mismatch } = markdownToVtt(content, wordData); + if (mismatch) { + new import_obsidian13.Notice(t("notice.vttMismatch")); + } + await this.ensureFolder(this.settings.exportsFolder); + const outputPath = `${this.settings.exportsFolder}/${rawBasename}.pseudonymized.vtt`; + const existing = this.app.vault.getAbstractFileByPath(outputPath); + if (existing instanceof import_obsidian13.TFile) { + await this.app.vault.modify(existing, vtt); + } else { + await this.app.vault.create(outputPath, vtt); + } + new import_obsidian13.Notice(t("notice.vttExported", outputPath)); + } + /** Cherche .words.json dans le dossier mappings et ses sous-dossiers. */ + async findWordsJson(basename) { + const filename = `${basename}.words.json`; + const search = (folder) => { + for (const child of folder.children) { + if (child instanceof import_obsidian13.TFile && child.name === filename) + return child; + if (child instanceof import_obsidian13.TFolder) { + const found = search(child); + if (found) + return found; + } + } + return null; + }; + const mappingRoot = this.app.vault.getAbstractFileByPath(this.settings.mappingFolder); + if (!(mappingRoot instanceof import_obsidian13.TFolder)) + return null; + const wordsFile = search(mappingRoot); + return wordsFile ? this.app.vault.read(wordsFile) : null; } async scanCurrentFile() { const file = this.app.workspace.getActiveFile(); if (!file) { - new import_obsidian12.Notice(t("notice.noActiveFile")); + new import_obsidian13.Notice(t("notice.noActiveFile")); return; } const rules = await this.scopeResolver.getRulesFor(file.path); if (rules.length === 0) { - new import_obsidian12.Notice(t("notice.noRules")); + new import_obsidian13.Notice(t("notice.noRules")); return; } const content = await this.app.vault.read(file); @@ -35367,15 +36423,21 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { wholeWordOnly: this.settings.wholeWordOnly }); if (occurrences.length === 0) { - new import_obsidian12.Notice(t("notice.noOccurrences")); + new import_obsidian13.Notice(t("notice.noOccurrences")); return; } - const countByRule = /* @__PURE__ */ new Map(); + const occsByRule = /* @__PURE__ */ new Map(); for (const occ of occurrences) { const id = occ.mappingId ?? ""; - countByRule.set(id, (countByRule.get(id) ?? 0) + 1); + if (!occsByRule.has(id)) + occsByRule.set(id, []); + occsByRule.get(id).push(occ); } - const ruleResults = rules.filter((r) => countByRule.has(r.id)).map((r) => ({ rule: r, matchCount: countByRule.get(r.id) })); + const ruleResults = rules.filter((r) => occsByRule.has(r.id)).map((r) => ({ + rule: r, + matchCount: occsByRule.get(r.id).length, + occurrences: occsByRule.get(r.id) + })); new MappingScanReviewModal(this.app, this, file, content, ruleResults).open(); } // Appelé par OccurrencesModal après application — met à jour les statuts des règles. @@ -35427,6 +36489,51 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { await this.app.vault.modify(file, applySpans(content, spans)); return spans.length; } + /** + * Annule l'application d'une règle dans le fichier actif : + * cherche le remplacement (avec ou sans marqueurs) et le réécrit avec la source. + * Appelé automatiquement à la suppression d'une règle dans EditRuleModal. + */ + async revertRuleInFile(source, replacement) { + const file = this.app.workspace.getActiveFile(); + if (!file) + return; + const s = this.settings; + const variants = []; + if (s.useMarkerInExport) { + variants.push(`${s.markerOpen}${replacement}${s.markerClose}`); + } + variants.push(replacement); + let content = await this.app.vault.read(file); + let changed = false; + for (const variant of variants) { + const fakeRule = { + id: "_revert", + source: variant, + replacement: source, + category: "custom", + scope: { type: "file", path: file.path }, + status: "validated", + priority: 0, + createdBy: "user", + createdAt: (/* @__PURE__ */ new Date()).toISOString() + }; + const spans = findSpansForRule(content, fakeRule, { + caseSensitive: false, + wholeWordOnly: false + // le remplacement peut contenir des 🀫 ou marqueurs + }); + if (spans.length > 0) { + spans.sort((a, b) => b.start - a.start); + content = applySpans(content, spans); + changed = true; + } + } + if (changed) { + await this.app.vault.modify(file, content); + void this.refresh(); + } + } // --- Utilitaires --- async ensureFolder(folderPath) { const parts = folderPath.split("/").filter(Boolean); diff --git a/manifest.json b/manifest.json index 5e24422..6f2a04c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "pseudonymizer-tool", "name": "Pseudonymizer Tool", - "version": "0.1.4", + "version": "0.1.5", "minAppVersion": "1.7.2", "description": "Pseudonymize and correct interactional transcripts (Jefferson, ICOR, SRT, CHAT/CHA). Designed for qualitative researchers in linguistics and conversation analysis.", "author": "Axelle Abbadie", diff --git a/package.json b/package.json index 5eeb2f6..37ee619 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pseudonymizer-tool", - "version": "0.1.4", + "version": "0.1.5", "description": "Obsidian plugin for pseudonymizing and correcting interactional transcripts (Jefferson, ICOR, SRT, CHAT/CHA).", "author": "Axelle Abbadie", "homepage": "https://cv.hal.science/axelle-abbadie/", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3339cd0..fd818e2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -41,6 +41,7 @@ "notice.noDictDetection": "No detection dictionary loaded.\nInstall a dictionary from the Dictionaries panel.", "notice.noDictEntities": "No entities found in the detection dictionaries.", "notice.noDictReplacements": "No replacements available for the found entities.", + "notice.ruleDeleted": "✓ Rule deleted — \"{0}\" restored in active file", "notice.ruleCreated": "✓ Rule created: \"{0}\" → \"{1}\"", "notice.rulesCreated": "✓ {0} {1} created", "notice.rulesCreated.rule": "rule", @@ -66,6 +67,14 @@ "notice.nerModelLoading": "Loading NER model (first use — ~66 MB)…", "notice.ruleNotFound": "Rule not found in mappings.", "notice.noCheckedDicts": "No dictionary checked.", + "mappingScanModal.saveExceptions": "Save exceptions", + "mappingScanModal.exceptionsSaved": "Exceptions saved to mapping.", + "panel.mappings.exceptions": "Exceptions", + "panel.mappings.exceptions.hint": "Occurrences explicitly ignored for this rule.", + "notice.notNoScribeFormat": "This file is not a noScribe transcript (pseudobs-format: vtt or html expected).", + "notice.wordsJsonMissing": "Timestamp file not found: {0}.words.json", + "notice.vttMismatch": "Warning: Markdown cue count and words.json cue count differ — exported VTT may be incomplete.", + "notice.vttExported": "VTT exported: {0}", "command.organizeCorpus": "Organize corpus", "command.addTranscription": "Add a transcription", @@ -75,6 +84,7 @@ "command.scanNer": "Scan file with NER detection", "command.scanDictionaries": "Scan file with dictionaries", "command.pseudonymizeSelection": "Pseudonymize selection", + "command.exportAsVtt": "Export as VTT", "command.openPanel": "Pseudonymization: open panel", "contextMenu.cancelPseudonymization": "Cancel pseudonymization of \"{0}\"", @@ -84,6 +94,9 @@ "ruleModal.scopeWarnTitle": "Longitudinal scope", "ruleModal.scopeOkTitle": "File scope", "ruleModal.scopeOk": "Good practice. This pseudonym will only apply to this transcription — each file remains independent.", + "contextMenu.redact": "Redact \"{0}\"", + "redaction.checkbox": "Redact (🀫)", + "redaction.checkboxDesc": "Replace with 🀫 symbols (one per syllable) — for non-essential identifying content", "contextMenu.coulmont": "Pseudonymize with Prof. Baptiste Coulmont", "contextMenu.createRule": "Create a replacement rule…", @@ -121,13 +134,13 @@ "scope.folder": "Folder", "scope.vault": "Vault", - "status.validated": "✓", - "status.ignored": "✗", - "status.partial": "◑", - "status.suggested": "?", - "status.conflict": "⚠", - "status.disabled": "–", - "status.needs_review": "👁", + "status.validated": "Active", + "status.ignored": "Ignored", + "status.partial": "Partial", + "status.suggested": "Suggested", + "status.conflict": "Conflict", + "status.disabled": "Inactive", + "status.needs_review": "Review", "panel.tab.mappings": "Mappings", "panel.tab.dictionaries": "Dictionaries", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d7759cd..9e0298f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -41,6 +41,7 @@ "notice.noDictDetection": "Aucun dictionnaire de détection chargé.\nInstallez un dictionnaire depuis le panneau Dictionnaires.", "notice.noDictEntities": "Aucune entité trouvée dans les dictionnaires de détection.", "notice.noDictReplacements": "Aucun remplacement disponible pour les entités trouvées.", + "notice.ruleDeleted": "✓ Règle supprimée — \"{0}\" rétabli dans le fichier actif", "notice.ruleCreated": "✓ Règle créée : \"{0}\" → \"{1}\"", "notice.rulesCreated": "✓ {0} règle{1} créée{1}", "notice.rulesCreated.rule": "", @@ -66,6 +67,14 @@ "notice.nerModelLoading": "Chargement du modèle NER (première utilisation — ~66 Mo)…", "notice.ruleNotFound": "Règle introuvable dans les mappings.", "notice.noCheckedDicts": "Aucun dictionnaire coché.", + "mappingScanModal.saveExceptions": "Enregistrer les exceptions", + "mappingScanModal.exceptionsSaved": "Exceptions enregistrées dans le mapping.", + "panel.mappings.exceptions": "Exceptions", + "panel.mappings.exceptions.hint": "Occurrences explicitement ignorées pour cette règle.", + "notice.notNoScribeFormat": "Ce fichier n'est pas une transcription noScribe (pseudobs-format: vtt ou html attendu).", + "notice.wordsJsonMissing": "Fichier de timestamps introuvable : {0}.words.json", + "notice.vttMismatch": "Attention : le nombre de cues du Markdown et du words.json diffère — le VTT exporté peut être incomplet.", + "notice.vttExported": "VTT exporté : {0}", "command.organizeCorpus": "Organiser le corpus", "command.addTranscription": "Ajouter une transcription", @@ -75,6 +84,7 @@ "command.scanNer": "Scanner le fichier avec détection NER", "command.scanDictionaries": "Scanner le fichier avec les dictionnaires", "command.pseudonymizeSelection": "Pseudonymiser la sélection", + "command.exportAsVtt": "Exporter en VTT", "command.openPanel": "Pseudonymisation : ouvrir le panneau", "contextMenu.cancelPseudonymization": "Annuler la pseudonymisation de \"{0}\"", @@ -84,6 +94,9 @@ "ruleModal.scopeWarnTitle": "Portée longitudinale", "ruleModal.scopeOkTitle": "Portée fichier", "ruleModal.scopeOk": "Bonne pratique. Ce pseudonyme ne s'appliquera qu'à cette transcription — chaque fichier reste indépendant.", + "contextMenu.redact": "Caviarder \"{0}\"", + "redaction.checkbox": "Caviardage (🀫)", + "redaction.checkboxDesc": "Remplace par des 🀫 (1 par syllabe) — pour les informations identifiantes non essentielles", "contextMenu.coulmont": "Pseudonymiser avec Pr Baptiste Coulmont", "contextMenu.createRule": "Créer une règle de remplacement…", @@ -121,13 +134,13 @@ "scope.folder": "Dossier", "scope.vault": "Vault", - "status.validated": "✓", - "status.ignored": "✗", - "status.partial": "◑", - "status.suggested": "?", - "status.conflict": "⚠", - "status.disabled": "–", - "status.needs_review": "👁", + "status.validated": "Actif", + "status.ignored": "Ignoré", + "status.partial": "Partiel", + "status.suggested": "Suggéré", + "status.conflict": "Conflit", + "status.disabled": "Inactif", + "status.needs_review": "À réviser", "panel.tab.mappings": "Mappings", "panel.tab.dictionaries": "Dictionnaires", @@ -147,7 +160,7 @@ "panel.mappings.col.category": "Catégorie", "panel.mappings.col.scope": "Portée", "panel.mappings.col.priority": "P.", - "panel.mappings.col.status": "Statut", + "panel.mappings.col.status": "État", "panel.dict.noneInstalled": "Aucun dictionnaire installé. Installez-en un depuis le wizard (Paramètres → Reconfigurer) ou importez un fichier local.", "panel.dict.checkbox": "Inclure dans le scan groupé", diff --git a/src/main.ts b/src/main.ts index 9163241..a0b7f82 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { Plugin, Notice, TFile, TAbstractFile, Editor, Menu, MarkdownView, requestUrl, WorkspaceLeaf } from 'obsidian'; +import { Plugin, Notice, TFile, TFolder, TAbstractFile, Editor, Menu, MarkdownView, requestUrl, WorkspaceLeaf } from 'obsidian'; import { t, setLocale } from './i18n'; import { EditorView } from '@codemirror/view'; import { PseudObsSettings, DEFAULT_SETTINGS, PseudObsSettingTab } from './settings'; @@ -11,6 +11,7 @@ import { OnboardingModal } from './ui/OnboardingModal'; import { OnnxNerScanner } from './scanner/OnnxNerScanner'; import { DictionaryLoader } from './dictionaries/DictionaryLoader'; import { DictScanReviewModal } from './ui/DictScanReviewModal'; +import { generateRedaction } from './pseudonymizer/Redaction'; import { CorpusModal, ClassSelectModal, getCorpusClasses } from './ui/CorpusModal'; import type { DictScanResultItem } from './ui/DictScanReviewModal'; import { MappingScanReviewModal } from './ui/MappingScanReviewModal'; @@ -18,7 +19,10 @@ import type { MappingRuleResult } from './ui/MappingScanReviewModal'; import { scanOccurrences } from './scanner/OccurrenceScanner'; import { SrtParser } from './parsers/SrtParser'; import { ChatParser } from './parsers/ChatParser'; -import { srtToMarkdown, chatToMarkdown } from './parsers/TranscriptConverter'; +import { VttParser } from './parsers/VttParser'; +import { NoScribeHtmlParser } from './parsers/NoScribeHtmlParser'; +import { NoScribeVttParser } from './parsers/NoScribeVttParser'; +import { srtToMarkdown, chatToMarkdown, vttToMarkdown, noScribeHtmlToMarkdown, extractWordData, markdownToVtt, type VttCueData } from './parsers/TranscriptConverter'; import { MappingStore } from './mappings/MappingStore'; import { ScopeResolver } from './mappings/ScopeResolver'; import { PseudonymizationEngine } from './pseudonymizer/PseudonymizationEngine'; @@ -26,7 +30,7 @@ import { findSpansForRule } from './pseudonymizer/ReplacementPlanner'; import { applySpans } from './pseudonymizer/SpanProtector'; import type { MappingRule, MappingStatus, Occurrence } from './types'; -const CONVERTIBLE_EXTS = ['srt', 'cha', 'chat']; +const CONVERTIBLE_EXTS = ['srt', 'cha', 'chat', 'vtt', 'html']; export default class PseudObsPlugin extends Plugin { settings!: PseudObsSettings; @@ -34,10 +38,14 @@ export default class PseudObsPlugin extends Plugin { nerScanner!: OnnxNerScanner; dictionaryLoader!: DictionaryLoader; // Cache synchrone pour le surlignage CM6 (mis à jour de façon asynchrone) - private highlightData: HighlightData = { sources: [], replacements: [], nerCandidates: [] }; + private highlightData: HighlightData = { sources: [], replacements: [], nerCandidates: [], ignoredTerms: [] }; // Candidats NER par fichier (effacés au changement de fichier ou à un nouveau scan) private nerCandidateFile: TFile | null = null; private nerCandidates: string[] = []; + // Dernière MarkdownView connue — pour mettre à jour le surlignage même + // quand le panneau latéral a le focus (getActiveViewOfType retourne null dans ce cas) + private lastMarkdownView: MarkdownView | null = null; + private _viewRefreshTimer: number | null = null; async onload(): Promise { await this.loadSettings(); @@ -57,12 +65,31 @@ export default class PseudObsPlugin extends Plugin { createPseudonymHighlighter(() => this.highlightData) ); - // Rafraîchir le cache de surlignage à chaque changement de fichier actif + // Tracker la dernière MarkdownView pour garder le surlignage actif + // même quand le panneau latéral prend le focus this.registerEvent( - this.app.workspace.on('active-leaf-change', () => { void this.refreshHighlightData(); }) + this.app.workspace.on('active-leaf-change', (leaf) => { + const v = leaf?.view; + if (v instanceof MarkdownView) this.lastMarkdownView = v; + void this.refresh(); + }) ); + + // Rafraîchir panneau + surlignage quand le FICHIER TRANSCRIPT actif est modifié. + // Les fichiers .mapping.json sont exclus — leurs changements sont déjà gérés + // par les appels explicites à refresh() dans les modales, évitant le double rendu. + this.registerEvent( + this.app.vault.on('modify', (file) => { + if (file instanceof TFile + && !file.name.endsWith('.mapping.json') + && file === this.app.workspace.getActiveFile()) { + void this.refresh(); + } + }) + ); + // Premier chargement au démarrage - void this.refreshHighlightData(); + void this.refresh(); // Onboarding au premier lancement if (!this.settings.onboardingCompleted) { @@ -126,6 +153,12 @@ export default class PseudObsPlugin extends Plugin { callback: () => void this.scanCurrentFileWithDictionaries(), }); + this.addCommand({ + id: 'export-as-vtt', + name: t('command.exportAsVtt'), + callback: () => void this.exportCurrentFileAsVtt(), + }); + this.addCommand({ id: 'pseudonymize-selection', name: t('command.pseudonymizeSelection'), @@ -164,7 +197,7 @@ export default class PseudObsPlugin extends Plugin { const location = await this.scopeResolver.findRuleByTerm(bare); if (!location) { new Notice(t('notice.ruleNotFound')); return; } editor.replaceSelection(location.rule.source); - void this.refreshHighlightData(); + void this.refresh(); }) ); } @@ -192,6 +225,13 @@ export default class PseudObsPlugin extends Plugin { .onClick(() => new QuickPseudonymizeModal(this.app, this, editor).open()) ); + menu.addItem((item) => + item + .setTitle(t('contextMenu.redact', truncate(selection))) + .setIcon('square') + .onClick(() => new QuickPseudonymizeModal(this.app, this, editor, generateRedaction(selection), [], true).open()) + ); + menu.addItem((item) => item .setTitle(t('contextMenu.coulmont')) @@ -262,9 +302,8 @@ export default class PseudObsPlugin extends Plugin { async refreshHighlightData(): Promise { const file = this.app.workspace.getActiveFile(); if (!file) { - this.highlightData = { sources: [], replacements: [], nerCandidates: [] }; + this.highlightData = { sources: [], replacements: [], nerCandidates: [], ignoredTerms: [] }; } else { - // Candidats NER : uniquement si le fichier actif est celui du dernier scan const nerCandidates = file === this.nerCandidateFile ? this.nerCandidates : []; try { @@ -287,24 +326,57 @@ export default class PseudObsPlugin extends Plugin { } else { rules = await this.scopeResolver.getRulesFor(file.path); } + // Termes ignorés : extraits des ignoredOccurrences de chaque règle + const ignoredTerms = rules.flatMap((r) => + (r.ignoredOccurrences ?? []).map((o) => o.text) + ); this.highlightData = { sources: rules.map((r) => r.source).filter(Boolean), replacements: rules.map((r) => r.replacement).filter(Boolean), nerCandidates, + ignoredTerms, }; } catch { - this.highlightData = { sources: [], replacements: [], nerCandidates }; + this.highlightData = { sources: [], replacements: [], nerCandidates, ignoredTerms: [] }; } } - // 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); + // Dispatcher le StateEffect — utiliser lastMarkdownView si le panneau a le focus + const view = this.app.workspace.getActiveViewOfType(MarkdownView) ?? this.lastMarkdownView; const cm = view?.editor && ((view.editor as unknown as { cm?: EditorView }).cm); cm?.dispatch({ effects: highlightDataChanged.of(undefined) }); } + /** + * Rafraîchit à la fois le surlignage CM6 ET le panneau latéral. + * À appeler après toute action qui crée, modifie ou supprime une règle. + */ + async refresh(): Promise { + await this.refreshHighlightData(); + this.refreshView(); + } + + /** + * Demande au panneau latéral ouvert de re-rendre son onglet actif. + * Debounce 80 ms pour coalescer les appels multiples rapides + * (vault watcher + appel explicite dans la même action). + */ + refreshView(): void { + if (this._viewRefreshTimer !== null) { + window.clearTimeout(this._viewRefreshTimer); + } + this._viewRefreshTimer = window.setTimeout(() => { + this._viewRefreshTimer = null; + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PSEUDOBS); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof PseudonymizationView) { + void view.refreshActiveTab(); + } + } + }, 80); + } + // --- Conversion automatique --- private async autoConvert(file: TFile): Promise { @@ -315,28 +387,76 @@ export default class PseudObsPlugin extends Plugin { 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 + // Ne pas écraser un .md existant if (this.app.vault.getAbstractFileByPath(mdPath) instanceof TFile) { new Notice(t('notice.conversionSkipped', basename, file.name)); return; } + // Chercher un fichier audio déjà présent dans le même dossier du vault + let audioFilename = this.findAudioInVaultFolder(folder); + + // Conversion + let mdContent: string; + let wordData: ReturnType | null = null; + + if (ext === 'srt') { + mdContent = srtToMarkdown(new SrtParser().parse(raw), file.name); + } else if (ext === 'vtt') { + const doc = NoScribeVttParser.isNoScribeVtt(raw) + ? new NoScribeVttParser().parse(raw) + : new VttParser().parse(raw); + wordData = extractWordData(doc); + // Pour les VTT noScribe, chercher aussi l'audio via NOTE media + if (!audioFilename && NoScribeVttParser.isNoScribeVtt(raw)) { + const audioSource = NoScribeVttParser.extractAudioSource(raw); + if (audioSource) audioFilename = await this.importAudioFromPath(audioSource, folder || this.settings.transcriptionsFolder); + } + const toMd = NoScribeVttParser.isNoScribeVtt(raw) ? noScribeHtmlToMarkdown : vttToMarkdown; + mdContent = toMd(doc, file.name, audioFilename ?? undefined); + } else if (ext === 'html') { + if (!NoScribeHtmlParser.isNoScribeHtml(raw)) return; + const doc = new NoScribeHtmlParser().parse(raw); + wordData = extractWordData(doc); + // Importer l'audio depuis le chemin absolu dans la meta tag si pas encore dans le vault + if (!audioFilename) { + const audioSource = NoScribeHtmlParser.extractAudioSource(raw); + if (audioSource) { + audioFilename = await this.importAudioFromPath(audioSource, folder || this.settings.transcriptionsFolder); + } + } + mdContent = noScribeHtmlToMarkdown(doc, file.name, audioFilename ?? undefined); + } else { + mdContent = chatToMarkdown(new ChatParser().parse(raw), file.name); + } + await this.app.vault.create(mdPath, mdContent); - const mappingPath = `${this.settings.mappingFolder}/${basename}.mapping.json`; + // Structure miroir pour les mappings + const transcRoot = this.settings.transcriptionsFolder; + const fileFolder = file.parent?.path ?? ''; + const relSubFolder = fileFolder.startsWith(transcRoot) + ? fileFolder.slice(transcRoot.length).replace(/^\//, '') + : ''; + const mappingDir = relSubFolder + ? `${this.settings.mappingFolder}/${relSubFolder}` + : this.settings.mappingFolder; + await this.ensureFolder(mappingDir); + + const mappingPath = `${mappingDir}/${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)); } + // Écrire les timestamps word-level dans un fichier auxiliaire + if (wordData && wordData.length > 0) { + const wordsPath = `${mappingDir}/${basename}.words.json`; + if (!this.app.vault.getAbstractFileByPath(wordsPath)) { + await this.app.vault.create(wordsPath, JSON.stringify(wordData, null, 2)); + } + } + await this.app.fileManager.trashFile(file); const mdFile = this.app.vault.getAbstractFileByPath(mdPath); @@ -350,12 +470,52 @@ export default class PseudObsPlugin extends Plugin { } } + /** Retourne le nom du premier fichier audio trouvé dans un dossier du vault. */ + private findAudioInVaultFolder(folderPath: string): string | null { + const AUDIO_EXTS = new Set(['m4a', 'mp3', 'wav', 'ogg', 'flac', 'mp4', 'aac', 'aiff']); + const folder = this.app.vault.getAbstractFileByPath(folderPath || '/'); + if (!(folder instanceof TFolder)) return null; + const audioFile = folder.children.find( + (f) => f instanceof TFile && AUDIO_EXTS.has((f as TFile).extension.toLowerCase()) + ) as TFile | undefined; + return audioFile?.name ?? null; + } + + /** + * Copie un fichier audio externe (chemin absolu sur disque) dans le vault. + * Utilise l'API Node.js fs — desktop uniquement. + * Retourne le nom du fichier importé, ou null en cas d'échec. + */ + private async importAudioFromPath(sourcePath: string, targetFolder: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeFs = require('fs') as typeof import('fs'); + if (!nodeFs.existsSync(sourcePath)) return null; + + const audioFilename = sourcePath.replace(/\\/g, '/').split('/').pop()!; + const destPath = targetFolder ? `${targetFolder}/${audioFilename}` : audioFilename; + + if (this.app.vault.getAbstractFileByPath(destPath) instanceof TFile) { + return audioFilename; // déjà présent + } + + const buffer: Buffer = await nodeFs.promises.readFile(sourcePath); + await this.ensureFolder(targetFolder); + const arrayBuf = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; + await this.app.vault.createBinary(destPath, arrayBuf); + new Notice(`Audio importé : ${audioFilename}`); + return audioFilename; + } catch { + return null; + } + } + // --- Commande "Ajouter une transcription" --- private openFilePicker(): void { const input = activeDocument.createElement('input'); input.type = 'file'; - input.accept = '.srt,.cha,.chat,.txt,.md'; + input.accept = '.srt,.vtt,.cha,.chat,.html,.txt,.md'; input.multiple = true; // Pas de display:none — bloque le change event dans certaines versions d'Electron input.classList.add('pseudobs-hidden-input'); @@ -376,6 +536,7 @@ export default class PseudObsPlugin extends Plugin { private async copyToVault(browserFile: File): Promise { const raw = await browserFile.text(); + const ext = browserFile.name.split('.').pop()?.toLowerCase() ?? ''; // Sélection de classe si le corpus est organisé en sous-dossiers const classes = getCorpusClasses(this.app, this.settings.transcriptionsFolder); @@ -397,6 +558,39 @@ export default class PseudObsPlugin extends Plugin { } await this.app.vault.create(destPath, raw); + + // Pour VTT : chercher un fichier audio dans le dossier source (Electron expose le chemin complet) + if (ext === 'vtt') { + const sourcePath = (browserFile as unknown as { path?: string }).path; + if (sourcePath) { + const sourceDir = sourcePath.replace(/\\/g, '/').replace(/\/[^/]+$/, ''); + const audioPath = await this.findAudioInSourceFolder(sourceDir); + if (audioPath) await this.importAudioFromPath(audioPath, targetFolder); + } + } + // Pour HTML : l'audio sera importé par autoConvert via la meta tag audio_source + } + + /** + * Cherche un fichier audio dans un dossier sur le disque (hors vault). + * Retourne le chemin absolu du seul fichier audio trouvé, ou null si 0 ou >1. + */ + private async findAudioInSourceFolder(folderPath: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeFs = require('fs') as typeof import('fs'); + const AUDIO_EXTS = new Set(['m4a', 'mp3', 'wav', 'ogg', 'flac', 'mp4', 'aac', 'aiff']); + const entries = await nodeFs.promises.readdir(folderPath); + const audioFiles = entries.filter((f) => { + const ext = f.split('.').pop()?.toLowerCase() ?? ''; + return AUDIO_EXTS.has(ext); + }); + // Import uniquement s'il y a exactement un fichier audio (évite l'ambiguïté) + if (audioFiles.length === 1) return `${folderPath}/${audioFiles[0]}`; + return null; + } catch { + return null; + } } // --- Pseudonymisation --- @@ -490,7 +684,7 @@ export default class PseudObsPlugin extends Plugin { const unique = [...new Set(occurrences.map((o) => o.text).filter(Boolean))]; this.nerCandidateFile = file; this.nerCandidates = unique; - void this.refreshHighlightData(); + void this.refresh(); new Notice(t('notice.nerEntitiesFound', String(unique.length), unique.length > 1 ? t('notice.nerEntitiesFound.entities') : t('notice.nerEntitiesFound.entity')), 6000); } catch (e) { @@ -576,7 +770,7 @@ export default class PseudObsPlugin extends Plugin { // Surlignage bleu préventif (aperçu pendant que la modale est ouverte) this.nerCandidateFile = file; this.nerCandidates = results.map((r) => r.term); - void this.refreshHighlightData(); + void this.refresh(); new DictScanReviewModal(this.app, this, file, results, existingReplacements).open(); } @@ -585,9 +779,10 @@ export default class PseudObsPlugin extends Plugin { clearNerCandidates(): void { this.nerCandidates = []; this.nerCandidateFile = null; - void this.refreshHighlightData(); + void this.refresh(); } + async exportMappingForFile(file: TFile): Promise { const mappingPath = `${this.settings.mappingFolder}/${file.basename}.mapping.json`; const mappingFile = this.app.vault.getAbstractFileByPath(mappingPath); @@ -611,6 +806,77 @@ export default class PseudObsPlugin extends Plugin { new Notice(t('notice.mappingExported', destPath)); } + /** + * Exporte le fichier Markdown noScribe actif en WebVTT pseudonymisé. + * Lit le .words.json correspondant pour les timestamps précis. + */ + async exportCurrentFileAsVtt(): Promise { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== 'md') { + new Notice(t('notice.noActiveFile')); + return; + } + + const content = await this.app.vault.read(file); + + // Vérifier que c'est bien un fichier noScribe converti + const formatMatch = /^pseudobs-format:\s*(\w+)/m.exec(content); + const format = formatMatch?.[1]; + if (format !== 'vtt' && format !== 'html') { + new Notice(t('notice.notNoScribeFormat')); + return; + } + + // Trouver le .words.json : même basename, dans le dossier mappings + // Le basename peut être "juste-leblanc" ou "juste-leblanc.pseudonymized" + const rawBasename = file.basename.replace(/\.pseudonymized$/, ''); + const wordsJson = await this.findWordsJson(rawBasename); + if (!wordsJson) { + new Notice(t('notice.wordsJsonMissing', rawBasename)); + return; + } + + const wordData = JSON.parse(wordsJson) as VttCueData[]; + const { vtt, mismatch } = markdownToVtt(content, wordData); + + if (mismatch) { + new Notice(t('notice.vttMismatch')); + } + + await this.ensureFolder(this.settings.exportsFolder); + const outputPath = `${this.settings.exportsFolder}/${rawBasename}.pseudonymized.vtt`; + const existing = this.app.vault.getAbstractFileByPath(outputPath); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, vtt); + } else { + await this.app.vault.create(outputPath, vtt); + } + + new Notice(t('notice.vttExported', outputPath)); + } + + /** Cherche .words.json dans le dossier mappings et ses sous-dossiers. */ + private async findWordsJson(basename: string): Promise { + const filename = `${basename}.words.json`; + // Chercher dans l'ensemble du dossier mappings + const search = (folder: TFolder): TFile | null => { + for (const child of folder.children) { + if (child instanceof TFile && child.name === filename) return child; + if (child instanceof TFolder) { + const found = search(child); + if (found) return found; + } + } + return null; + }; + + const mappingRoot = this.app.vault.getAbstractFileByPath(this.settings.mappingFolder); + if (!(mappingRoot instanceof TFolder)) return null; + + const wordsFile = search(mappingRoot); + return wordsFile ? this.app.vault.read(wordsFile) : null; + } + private async scanCurrentFile(): Promise { const file = this.app.workspace.getActiveFile(); if (!file) { new Notice(t('notice.noActiveFile')); return; } @@ -632,14 +898,19 @@ export default class PseudObsPlugin extends Plugin { return; } - const countByRule = new Map(); + const occsByRule = new Map(); for (const occ of occurrences) { const id = occ.mappingId ?? ''; - countByRule.set(id, (countByRule.get(id) ?? 0) + 1); + if (!occsByRule.has(id)) occsByRule.set(id, []); + occsByRule.get(id)!.push(occ); } const ruleResults: MappingRuleResult[] = rules - .filter((r) => countByRule.has(r.id)) - .map((r) => ({ rule: r, matchCount: countByRule.get(r.id)! })); + .filter((r) => occsByRule.has(r.id)) + .map((r) => ({ + rule: r, + matchCount: occsByRule.get(r.id)!.length, + occurrences: occsByRule.get(r.id)!, + })); new MappingScanReviewModal(this.app, this, file, content, ruleResults).open(); } @@ -694,6 +965,50 @@ export default class PseudObsPlugin extends Plugin { return spans.length; } + /** + * Annule l'application d'une règle dans le fichier actif : + * cherche le remplacement (avec ou sans marqueurs) et le réécrit avec la source. + * Appelé automatiquement à la suppression d'une règle dans EditRuleModal. + */ + async revertRuleInFile(source: string, replacement: string): Promise { + const file = this.app.workspace.getActiveFile(); + if (!file) return; + + const s = this.settings; + // Chercher la version avec marqueurs EN PREMIER — sinon on trouve le texte + // à l'intérieur des marqueurs et on obtient {{source}} au lieu de source. + const variants: string[] = []; + if (s.useMarkerInExport) { + variants.push(`${s.markerOpen}${replacement}${s.markerClose}`); + } + variants.push(replacement); // version sans marqueurs en dernier + + let content = await this.app.vault.read(file); + let changed = false; + + for (const variant of variants) { + const fakeRule: MappingRule = { + id: '_revert', source: variant, replacement: source, category: 'custom', + scope: { type: 'file', path: file.path }, status: 'validated', + priority: 0, createdBy: 'user', createdAt: new Date().toISOString(), + }; + const spans = findSpansForRule(content, fakeRule, { + caseSensitive: false, + wholeWordOnly: false, // le remplacement peut contenir des 🀫 ou marqueurs + }); + if (spans.length > 0) { + spans.sort((a, b) => b.start - a.start); + content = applySpans(content, spans); + changed = true; + } + } + + if (changed) { + await this.app.vault.modify(file, content); + void this.refresh(); + } + } + // --- Utilitaires --- async ensureFolder(folderPath: string): Promise { diff --git a/src/mappings/MappingStore.ts b/src/mappings/MappingStore.ts index 235fc98..70e72a7 100644 --- a/src/mappings/MappingStore.ts +++ b/src/mappings/MappingStore.ts @@ -64,8 +64,13 @@ export class MappingStore { // Règles validées applicables à un fichier donné (cascade file → folder → vault) getValidatedFor(filePath: string): MappingRule[] { + // 'validated' et 'partial' sont des règles actives. + // 'ignored' = l'utilisateur a ignoré toutes les occurrences au dernier scan + // → la règle reste définie mais désactivée. + // 'suggested' = non encore confirmée → inactive. + const ACTIVE: Set = new Set(['validated', 'partial']); return this.getAll().filter((r) => { - if (r.status !== 'validated') return false; + if (!ACTIVE.has(r.status)) 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; diff --git a/src/mappings/ScopeResolver.ts b/src/mappings/ScopeResolver.ts index 3cc17cd..1ff60b6 100644 --- a/src/mappings/ScopeResolver.ts +++ b/src/mappings/ScopeResolver.ts @@ -17,16 +17,26 @@ export class ScopeResolver { private mappingFolder: string ) {} + /** Collecte récursivement tous les fichiers .mapping.json dans un dossier. */ + private collectMappingFiles(folder: TFolder): TFile[] { + const files: TFile[] = []; + for (const child of folder.children) { + if (child instanceof TFile && child.name.endsWith('.mapping.json')) { + files.push(child); + } else if (child instanceof TFolder) { + files.push(...this.collectMappingFiles(child)); + } + } + return files; + } + async getRulesFor(filePath: string): Promise { 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; - + for (const child of this.collectMappingFiles(folder)) { try { const raw = await this.vault.read(child); const data = JSON.parse(raw) as MappingFile; @@ -56,8 +66,7 @@ export class ScopeResolver { const needle = term.toLowerCase(); - for (const child of folder.children) { - if (!(child instanceof TFile) || !child.name.endsWith('.mapping.json')) continue; + for (const child of this.collectMappingFiles(folder)) { try { const data = JSON.parse(await this.vault.read(child)) as MappingFile; const store = MappingStore.fromJSON(data); @@ -82,8 +91,7 @@ export class ScopeResolver { const result: RuleLocation[] = []; - for (const child of folder.children) { - if (!(child instanceof TFile) || !child.name.endsWith('.mapping.json')) continue; + for (const child of this.collectMappingFiles(folder)) { try { const data = JSON.parse(await this.vault.read(child)) as MappingFile; const store = MappingStore.fromJSON(data); @@ -111,8 +119,7 @@ export class ScopeResolver { const result: RuleLocation[] = []; - for (const child of folder.children) { - if (!(child instanceof TFile) || !child.name.endsWith('.mapping.json')) continue; + for (const child of this.collectMappingFiles(folder)) { try { const data = JSON.parse(await this.vault.read(child)) as MappingFile; const store = MappingStore.fromJSON(data); diff --git a/src/parsers/NoScribeHtmlParser.ts b/src/parsers/NoScribeHtmlParser.ts new file mode 100644 index 0000000..264703f --- /dev/null +++ b/src/parsers/NoScribeHtmlParser.ts @@ -0,0 +1,150 @@ +/** + * Parser HTML noScribe — produit un VttDocument compatible avec VttParser/TranscriptConverter. + * + * Format noScribe HTML (Qt Rich Text) : + * - : chemin vers le fichier audio source + * - Chaque

= un tour de parole ou une pause + * - Chaque = un segment avec timestamps + * START et END sont en millisecondes depuis le début de l'audio + * - Le nom du locuteur précède " :" en début de tour + * - Les timestamps d'affichage [HH:MM:SS] (couleur #78909c) sont à ignorer + */ + +import type { VttDocument, VttCue, VttWord } from './VttParser'; + +// ---- Regex ------------------------------------------------------------------ + +const PARA_RE = /]*)?>(?.*?)<\/p>/gs; +const ANCHOR_RE = /]*>(.*?)<\/a>/gs; +const TS_NAME_RE = /^ts_(\d+)_(\d+)_(\w*)$/; +// Timestamps d'affichage [HH:MM:SS] générés par noScribe pour l'interface — ignorer +const DISPLAY_TS_RE = /^\[\d{2}:\d{2}:\d{2}\]$/; +// Locuteur suivi de " :" en début de texte +const SPEAKER_RE = /^(.+?)\s*:\s*/; +const ALL_TAGS_RE = /<[^>]+>/g; + +const HTML_ENTITIES: Record = { + '&': '&', '<': '<', '>': '>', '"': '"', + ''': "'", ''': "'", ' ': ' ', +}; + +// ---- Helpers ---------------------------------------------------------------- + +function decodeEntities(text: string): string { + return text.replace(/&[^;]+;/g, (m) => HTML_ENTITIES[m] ?? m); +} + +function stripTags(html: string): string { + return decodeEntities(html.replace(ALL_TAGS_RE, '')); +} + +function pad2(n: number): string { return String(n).padStart(2, '0'); } +function pad3(n: number): string { return String(n).padStart(3, '0'); } + +/** Convertit des millisecondes en timestamp HH:MM:SS.mmm. */ +function msToTimestamp(ms: number): string { + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + const mss = ms % 1000; + return `${pad2(h)}:${pad2(m)}:${pad2(s)}.${pad3(mss)}`; +} + +// ---- Parser ----------------------------------------------------------------- + +export class NoScribeHtmlParser { + /** Vérifie qu'un contenu HTML est une sortie noScribe. */ + static isNoScribeHtml(content: string): boolean { + return content.includes('qrichtext') && content.includes('(); + const groupOrder: string[] = []; + + ANCHOR_RE.lastIndex = 0; + let anchorMatch: RegExpExecArray | null; + + while ((anchorMatch = ANCHOR_RE.exec(paraHtml)) !== null) { + const tsName = anchorMatch[1]; + const innerHtml = anchorMatch[2]; + const tsMatch = TS_NAME_RE.exec(tsName); + if (!tsMatch) continue; + + const text = stripTags(innerHtml); + if (DISPLAY_TS_RE.test(text.trim())) continue; + + if (!grouped.has(tsName)) { + grouped.set(tsName, { + startMs: parseInt(tsMatch[1], 10), + endMs: parseInt(tsMatch[2], 10), + speakerId: tsMatch[3], + texts: [], + }); + groupOrder.push(tsName); + } + if (text) grouped.get(tsName)!.texts.push(text); + } + + if (groupOrder.length === 0) continue; + + // Construire les VttWords à partir des groupes + const rawWords: VttWord[] = []; + for (const tsName of groupOrder) { + const g = grouped.get(tsName)!; + const text = g.texts.join(''); + if (text.trim()) rawWords.push({ text, time: msToTimestamp(g.startMs) }); + } + if (rawWords.length === 0) continue; + + // Extraire le locuteur depuis le début du premier mot + let speaker: string | undefined; + const firstText = rawWords[0].text; + const speakerMatch = SPEAKER_RE.exec(firstText); + if (speakerMatch) { + const candidate = speakerMatch[1].trim(); + // Pas de locuteur si c'est juste une marque de pause + if (candidate && !/^\(\.+\)$/.test(candidate)) { + speaker = candidate; + rawWords[0] = { ...rawWords[0], text: firstText.slice(speakerMatch[0].length) }; + } + } + + const words = rawWords.filter((w) => w.text.trim().length > 0); + if (words.length === 0) continue; + + const startMs = grouped.get(groupOrder[0])!.startMs; + const endMs = grouped.get(groupOrder[groupOrder.length - 1])!.endMs; + + cues.push({ + startTime: msToTimestamp(startMs), + endTime: msToTimestamp(endMs), + speaker, + text: words.map((w) => w.text).join(''), + words, + rawLines: [], + }); + } + + return { cues, trailingNewline: false }; + } +} diff --git a/src/parsers/NoScribeVttParser.ts b/src/parsers/NoScribeVttParser.ts new file mode 100644 index 0000000..3e824c1 --- /dev/null +++ b/src/parsers/NoScribeVttParser.ts @@ -0,0 +1,209 @@ +/** + * Parser VTT noScribe — format produit par noScribe ≥ 0.7. + * + * Le VTT noScribe n'est PAS du WebVTT standard : chaque tour de parole est + * décomposé en plusieurs cues successives ayant souvent le même intervalle : + * - cue "label" : "SXX: " (détection du locuteur) + * - cue "display" : "[HH:MM:SS]" (timestamp lisible — ignorer) + * - cue "texte" : "contenu transcrit" (texte réel) + * + * Parfois le label et le texte sont fusionnés : + * "S01: [00:00:09] Vous avez fini ces rapports ?" + * + * Contraintes : + * - Le tag n'est pas fiable pour l'attribution des locuteurs. + * - Le timestamp des cues "label" est erroné ; utiliser celui de la cue texte. + * - Pas de word-level timestamps (contrairement au VTT Whisper). + * + * Produit un VttDocument compatible avec vttToMarkdown / extractWordData. + */ + +import type { VttDocument, VttCue } from './VttParser'; + +// ---- Regex ------------------------------------------------------------------ + +// Ligne de timestamp VTT +const TIMESTAMP_LINE_RE = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})/; + +// Tag noScribe +const SPEAKER_V_TAG_RE = /^/; + +// Label locuteur en début de texte : "S00: " ou "S00 : " +const SPEAKER_LABEL_RE = /^(S\d+)\s*:\s*/; + +// Timestamp d'affichage : [HH:MM:SS] +const DISPLAY_TS_RE = /^\[\d{2}:\d{2}:\d{2}\]\s*/; + +// Entités HTML basiques +const HTML_ENTITIES: Record = { + ''': "'", ''': "'", '&': '&', + '<': '<', '>': '>', '"': '"', ' ': ' ', +}; + +function decodeEntities(text: string): string { + return text.replace(/&#?x?[0-9a-zA-Z]+;/g, (m) => HTML_ENTITIES[m] ?? m); +} + +function stripSpeakerVTag(line: string): string { + return SPEAKER_V_TAG_RE.test(line) ? line.replace(SPEAKER_V_TAG_RE, '') : line; +} + +// ---- Types intermédiaires --------------------------------------------------- + +interface RawCue { + startTime: string; + endTime: string; + text: string; // texte nettoyé (sans ), entités décodées +} + +interface Fragment { + startTime: string; + endTime: string; + speaker: string | undefined; + text: string; +} + +// ---- Parser ----------------------------------------------------------------- + +export class NoScribeVttParser { + /** Détecte un VTT produit par noScribe (présence de NOTE noScribe). */ + static isNoScribeVtt(content: string): boolean { + return content.startsWith('WEBVTT') && content.includes('noScribe'); + } + + /** Extrait le chemin audio depuis la ligne NOTE media. */ + static extractAudioSource(content: string): string | null { + const m = /^NOTE\s+media:\s*(.+)$/m.exec(content); + return m ? m[1].trim() : null; + } + + parse(content: string): VttDocument { + const rawCues = this.parseRawCues(content); + const fragments = this.buildFragments(rawCues); + const cues = this.mergeIntoCues(fragments); + return { cues, trailingNewline: content.endsWith('\n') }; + } + + // --- Étape 1 : parser les cues brutes ------------------------------------ + + private parseRawCues(content: string): RawCue[] { + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split('\n'); + const cues: RawCue[] = []; + + let i = 0; + // Sauter jusqu'à la première ligne de timestamp (ignore header + NOTE) + while (i < lines.length) { + const tsMatch = TIMESTAMP_LINE_RE.exec(lines[i]); + if (tsMatch) { + const startTime = tsMatch[1]; + const endTime = tsMatch[2]; + i++; + + // Collecter les lignes de texte jusqu'à la ligne vide + const textLines: string[] = []; + while (i < lines.length && lines[i].trim() !== '') { + const cleaned = decodeEntities(stripSpeakerVTag(lines[i])); + textLines.push(cleaned); + i++; + } + + const text = textLines.join(' ').trim(); + cues.push({ startTime, endTime, text }); + } else { + i++; + } + } + return cues; + } + + // --- Étape 2 : convertir en fragments (speaker + text + timestamp) -------- + + private buildFragments(cues: RawCue[]): Fragment[] { + const fragments: Fragment[] = []; + let currentSpeaker: string | undefined; + + for (const cue of cues) { + let text = cue.text; + + // Extraire le label locuteur s'il est présent + const labelMatch = SPEAKER_LABEL_RE.exec(text); + if (labelMatch) { + currentSpeaker = labelMatch[1]; // "S00", "S01", etc. + text = text.slice(labelMatch[0].length); + } + + // Supprimer le timestamp d'affichage [HH:MM:SS] + text = text.replace(DISPLAY_TS_RE, '').trim(); + + // Ignorer les cues vides, les cues label seuls, et les timestamps seuls + if (!text || /^\[[\d:]+\]$/.test(text)) continue; + + fragments.push({ + startTime: cue.startTime, + endTime: cue.endTime, + speaker: currentSpeaker, + text, + }); + } + + return fragments; + } + + // --- Étape 3 : fusionner les fragments en VttCues ------------------------- + // + // Un "tour" commence à chaque nouveau label de locuteur dans l'étape 2. + // Les fragments sans changement de locuteur sont rattachés au tour courant. + // Heuristique de fusion : on regroupe les fragments temporellement proches + // (gap < 2 s) appartenant au même locuteur. + + private mergeIntoCues(fragments: Fragment[]): VttCue[] { + if (fragments.length === 0) return []; + + const cues: VttCue[] = []; + let batch: Fragment[] = [fragments[0]]; + + const flushBatch = () => { + if (batch.length === 0) return; + const text = batch.map((f) => f.text).join(' ').replace(/\s+/g, ' ').trim(); + if (text) { + cues.push({ + startTime: batch[0].startTime, + endTime: batch[batch.length - 1].endTime, + speaker: batch[0].speaker, + text, + words: [{ text, time: batch[0].startTime }], + rawLines: [], + }); + } + batch = []; + }; + + for (let i = 1; i < fragments.length; i++) { + const prev = batch[batch.length - 1]; + const curr = fragments[i]; + + // Nouveau tour si le locuteur change + const speakerChanged = curr.speaker !== prev.speaker; + // Ou si le gap temporel est significatif (> 2 s) entre deux fragments du même locuteur + const prevEndSec = timeToSeconds(prev.endTime); + const currStartSec = timeToSeconds(curr.startTime); + const bigGap = !speakerChanged && (currStartSec - prevEndSec) > 2; + + if (speakerChanged || bigGap) { + flushBatch(); + batch = [curr]; + } else { + batch.push(curr); + } + } + + flushBatch(); + return cues; + } +} + +function timeToSeconds(ts: string): number { + const [h, m, s] = ts.split(':'); + return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); +} diff --git a/src/parsers/TranscriptConverter.ts b/src/parsers/TranscriptConverter.ts index 6e8641f..c5b18f2 100644 --- a/src/parsers/TranscriptConverter.ts +++ b/src/parsers/TranscriptConverter.ts @@ -1,5 +1,6 @@ import type { SrtDocument } from './SrtParser'; import type { ChatDocument, ChatLine } from './ChatParser'; +import type { VttDocument, VttWord } from './VttParser'; // Converts a SrtDocument to a structured, Obsidian-readable Markdown string. // Timestamps are preserved as italic headers; text lines are the editable content. @@ -19,7 +20,6 @@ export function srtToMarkdown(doc: SrtDocument, sourceName: string): string { lines.push(''); } - // Retirer la dernière ligne vide superflue, puis ajouter \n final while (lines[lines.length - 1] === '') lines.pop(); lines.push(''); @@ -43,7 +43,6 @@ export function chatToMarkdown(doc: ChatDocument, sourceName: string): string { 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(''); } @@ -57,13 +56,11 @@ export function chatToMarkdown(doc: ChatDocument, sourceName: string): string { 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; } @@ -76,6 +73,136 @@ export function chatToMarkdown(doc: ChatDocument, sourceName: string): string { return lines.join('\n'); } +// ---- VTT / noScribe HTML --------------------------------------------------- + +/** Données word-level d'une cue — stockées dans .words.json. */ +export interface VttCueData { + index: number; + startTime: string; + endTime: string; + speaker?: string; + words: VttWord[]; +} + +/** + * Extrait les données word-level d'un VttDocument pour le fichier .words.json. + * Seules les cues avec au moins un word timestamp sont incluses. + */ +export function extractWordData(doc: VttDocument): VttCueData[] { + return doc.cues + .map((cue, index) => ({ + index, + startTime: cue.startTime, + endTime: cue.endTime, + speaker: cue.speaker, + words: cue.words, + })) + .filter((c) => c.words.some((w) => w.time !== '')); +} + +// ---- Re-export VTT ---------------------------------------------------------- + +/** + * Regex pour une ligne de corps noScribe dans le Markdown. + * Capture optionnellement le locuteur (groupe 1) et le texte (groupe 2). + * **S00** [00:01:28] : texte → speaker=S00, text=texte + * [00:00:00] (..) → speaker=undefined, text=(..) + */ +const MD_CUE_RE = /^(?:\*\*([^*]+)\*\*\s+)?\[[\d:]+\]\s*(?::\s*)?(.*)$/; + +/** + * Reconstruit un fichier WebVTT pseudonymisé depuis : + * - `mdContent` : le Markdown noScribe (pseudonymisé) + * - `wordData` : le contenu de .words.json (timestamps précis) + * + * Alignement par index : ligne de corps N = cue N du words.json. + * Le texte vient du Markdown ; les timestamps et le speaker de secours + * viennent du words.json. + * + * Retourne null si le nombre de lignes et de cues ne correspondent pas + * (le caller affiche un avertissement dans ce cas). + */ +export function markdownToVtt( + mdContent: string, + wordData: VttCueData[], +): { vtt: string; mismatch: boolean } { + // Extraire le corps (après le frontmatter --- ... ---) + const bodyMatch = /^---\n[\s\S]*?\n---\n+([\s\S]*)$/.exec(mdContent); + const body = bodyMatch ? bodyMatch[1] : mdContent; + + const cueLines = body.split('\n').filter((l) => MD_CUE_RE.test(l.trim())); + const mismatch = cueLines.length !== wordData.length; + + const parts = ['WEBVTT', '']; + const count = Math.min(cueLines.length, wordData.length); + + for (let i = 0; i < count; i++) { + const m = MD_CUE_RE.exec(cueLines[i].trim())!; + // Locuteur : priorité au Markdown, fallback words.json + const speaker = m[1]?.trim() || wordData[i].speaker; + const text = m[2]?.trim() ?? ''; + + if (!text) continue; + + parts.push(String(i + 1)); + parts.push(`${wordData[i].startTime} --> ${wordData[i].endTime}`); + parts.push(speaker ? `${text}` : text); + parts.push(''); + } + + return { vtt: parts.join('\n'), mismatch }; +} + +/** Convertit HH:MM:SS.mmm en [HH:MM:SS] — format d'affichage noScribe. */ +function displayTime(ts: string): string { + return `[${ts.slice(0, 8)}]`; +} + +/** + * Convertit un VttDocument en Markdown structuré au format noScribe. + * + * Format par cue : + * **Locuteur** [HH:MM:SS] : texte + * [HH:MM:SS] texte (sans locuteur) + * + * Les timestamps précis (avec ms) et les word timestamps sont dans .words.json, + * pas dans le corps Markdown. Le frontmatter peut inclure pseudobs-audio si + * un fichier audio a été importé avec la transcription. + */ +function vttDocToMarkdown( + doc: VttDocument, + sourceName: string, + format: 'vtt' | 'html', + audioFilename?: string, +): string { + const lines: string[] = ['---', `pseudobs-format: ${format}`, `pseudobs-source: "${sourceName}"`]; + if (audioFilename) lines.push(`pseudobs-audio: "${audioFilename}"`); + lines.push('---', ''); + + for (const cue of doc.cues) { + const ts = displayTime(cue.startTime); + if (cue.speaker) { + lines.push(`**${cue.speaker}** ${ts} : ${cue.text}`); + } else { + lines.push(`${ts} ${cue.text}`); + } + lines.push(''); + } + + while (lines[lines.length - 1] === '') lines.pop(); + lines.push(''); + + return lines.join('\n'); +} + +export function vttToMarkdown(doc: VttDocument, sourceName: string, audioFilename?: string): string { + return vttDocToMarkdown(doc, sourceName, 'vtt', audioFilename); +} + +export function noScribeHtmlToMarkdown(doc: VttDocument, sourceName: string, audioFilename?: string): string { + return vttDocToMarkdown(doc, sourceName, 'html', audioFilename); +} + 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'; diff --git a/src/parsers/VttParser.ts b/src/parsers/VttParser.ts new file mode 100644 index 0000000..fc1882c --- /dev/null +++ b/src/parsers/VttParser.ts @@ -0,0 +1,250 @@ +/** + * Parser VTT (WebVTT) — optimisé pour les sorties noScribe (Whisper + pyannote). + * + * Formats pris en charge : + * - VTT standard (blocs sans word timestamps) + * - Whisper word timestamps : mot + * - Speaker tags : ou [SPEAKER_NAME] + * + * Round-trip garanti : les word timestamps sont stockés séparément du texte + * et réinsérés à la reconstruction autour du texte (éventuellement pseudonymisé). + */ + +import type { BaseTranscriptParser } from './BaseTranscriptParser'; + +/** Un mot avec sa position temporelle dans l'audio. */ +export interface VttWord { + text: string; // texte du mot (tel qu'il apparaît dans le fichier, espaces inclus) + time: string; // timestamp Whisper "HH:MM:SS.mmm" — vide si absent +} + +export interface VttCue { + id?: string; // identifiant optionnel de la cue (nombre ou chaîne) + startTime: string; // "HH:MM:SS.mmm" + endTime: string; // "HH:MM:SS.mmm" + speaker?: string; // locuteur extrait du tag ou [...] + text: string; // texte nettoyé (pseudonymisable) + words: VttWord[]; // mots avec timestamps — vide si pas de word timestamps + // Ligne(s) de texte brut original (pour le round-trip si pas de word timestamps) + rawLines: string[]; +} + +export interface VttDocument { + cues: VttCue[]; + trailingNewline: boolean; +} + +// ---- Regex ------------------------------------------------------------------ + +// Timestamp VTT : HH:MM:SS.mmm ou MM:SS.mmm +const TIME_RE = /(\d{1,2}:\d{2}:\d{2}\.\d{3}|\d{2}:\d{2}\.\d{3})/; +const TIMESTAMP_LINE_RE = new RegExp( + `^${TIME_RE.source}\\s+-->\\s+${TIME_RE.source}\\s*(.*)$` +); + +// Word timestamp Whisper : +const WORD_TIME_RE = /<(\d{1,2}:\d{2}:\d{2}\.\d{3}|\d{2}:\d{2}\.\d{3})>/g; + +// Tag de classe Whisper : ... +const CLASS_TAG_RE = /<\/?c>/g; + +// Speaker tag : ou +const SPEAKER_V_RE = /^]+)?\s+([^>]+)>/; + +// Speaker bracket : [Nom] +const SPEAKER_BRACKET_RE = /^\[([^\]]+)\]/; + +// Tout tag HTML/VTT générique +const ALL_TAGS_RE = /<[^>]+>/g; + +// ---- Helpers ---------------------------------------------------------------- + +/** Normalise un timestamp "MM:SS.mmm" en "HH:MM:SS.mmm". */ +function normalizeTime(t: string): string { + return t.includes(':') && t.split(':').length === 2 ? `00:${t}` : t; +} + +/** Retire tous les tags VTT du texte pour obtenir le texte brut lisible. */ +function stripTags(text: string): string { + return text.replace(ALL_TAGS_RE, '').trim(); +} + +/** + * Extrait les mots avec leurs timestamps Whisper depuis une ligne de texte cue. + * Si aucun timestamp n'est présent, retourne un tableau avec le texte complet. + */ +function extractWords(rawText: string): VttWord[] { + // Vérifier si des word timestamps sont présents + const hasWordTimes = WORD_TIME_RE.test(rawText); + WORD_TIME_RE.lastIndex = 0; // reset après test + + if (!hasWordTimes) { + const clean = stripTags(rawText); + return clean ? [{ text: clean, time: '' }] : []; + } + + // Découper en segments : [timestamp, texte, timestamp, texte, ...] + const words: VttWord[] = []; + const remaining = rawText; + let currentTime = ''; + + // Traiter segment par segment + const parts = remaining.split(WORD_TIME_RE); + // parts alterne : [texte_avant_premier_ts, ts1, texte1, ts2, texte2, ...] + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (TIME_RE.test(part) && part.match(/^\d/)) { + // C'est un timestamp + currentTime = normalizeTime(part); + } else { + // C'est du texte — nettoyer les tags de classe + const clean = part.replace(CLASS_TAG_RE, '').replace(ALL_TAGS_RE, ''); + if (clean.trim()) { + words.push({ text: clean, time: currentTime }); + currentTime = ''; + } + } + } + + return words; +} + +/** + * Extrait le locuteur et le texte brut (sans speaker tag) d'une ligne cue. + */ +function extractSpeakerAndText(line: string): { speaker: string | undefined; text: string } { + const vMatch = SPEAKER_V_RE.exec(line); + if (vMatch) { + return { speaker: vMatch[1].trim(), text: line.slice(vMatch[0].length) }; + } + const bMatch = SPEAKER_BRACKET_RE.exec(line); + if (bMatch) { + return { speaker: bMatch[1].trim(), text: line.slice(bMatch[0].length).trim() }; + } + return { speaker: undefined, text: line }; +} + +// ---- Parser ----------------------------------------------------------------- + +export class VttParser implements BaseTranscriptParser { + + parse(content: string): VttDocument { + const trailingNewline = content.endsWith('\n'); + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd(); + + const lines = normalized.split('\n'); + let i = 0; + + // Sauter le header WEBVTT (et les métadonnées éventuelles) + while (i < lines.length && !lines[i].startsWith('WEBVTT')) i++; + i++; // sauter la ligne WEBVTT elle-même + + const cues: VttCue[] = []; + + while (i < lines.length) { + // Sauter les lignes vides + while (i < lines.length && lines[i].trim() === '') i++; + if (i >= lines.length) break; + + // ID optionnel (ligne non-timestamp, non-vide avant le timestamp) + let cueId: string | undefined; + if (i < lines.length && !TIMESTAMP_LINE_RE.test(lines[i])) { + cueId = lines[i].trim(); + i++; + } + + // Ligne timestamp + if (i >= lines.length) break; + const tsMatch = TIMESTAMP_LINE_RE.exec(lines[i]); + if (!tsMatch) { i++; continue; } // ligne inattendue + + const startTime = normalizeTime(tsMatch[1]); + const endTime = normalizeTime(tsMatch[2]); + i++; + + // Lignes de texte de la cue + const rawLines: string[] = []; + while (i < lines.length && lines[i].trim() !== '') { + rawLines.push(lines[i]); + i++; + } + + if (rawLines.length === 0) continue; + + // Extraire locuteur depuis la première ligne + const firstLineResult = extractSpeakerAndText(rawLines[0]); + const speaker = firstLineResult.speaker; + + // Reconstruire le texte complet de la cue (sans speaker tag sur la 1ère ligne) + const textLines = [firstLineResult.text, ...rawLines.slice(1)]; + const fullRaw = textLines.join('\n'); + + // Extraire les word timestamps + const words = extractWords(fullRaw); + + // Texte nettoyé pour pseudonymisation + const text = words.map((w) => w.text).join(''); + + cues.push({ id: cueId, startTime, endTime, speaker, text, words, rawLines }); + } + + return { cues, trailingNewline }; + } + + reconstruct(doc: VttDocument): string { + const parts: string[] = ['WEBVTT', '']; + + for (const cue of doc.cues) { + if (cue.id !== undefined) parts.push(cue.id); + + parts.push(`${cue.startTime} --> ${cue.endTime}`); + + if (cue.words.length > 0 && cue.words.some((w) => w.time !== '')) { + // Reconstruire avec word timestamps depuis le tableau words + const line = this.reconstructWithWordTimestamps(cue); + parts.push(line); + } else { + // Pas de word timestamps — reconstruire depuis le texte pseudonymisé + // en préservant le speaker tag si présent + const speaker = cue.speaker; + const textLine = speaker ? `${cue.text}` : cue.text; + parts.push(textLine); + } + + parts.push(''); + } + + const body = parts.join('\n'); + return doc.trailingNewline ? body : body.trimEnd(); + } + + private reconstructWithWordTimestamps(cue: VttCue): string { + let line = cue.speaker ? `` : ''; + for (const word of cue.words) { + if (word.time) line += `<${word.time}>`; + line += `${word.text}`; + } + return line; + } + + /** + * Met à jour le texte d'une cue après pseudonymisation. + * Propage le remplacement dans le tableau words pour maintenir le round-trip. + */ + static applyTextToWords(cue: VttCue, newText: string): void { + cue.text = newText; + // Si pas de word timestamps, on met tout dans le premier mot + if (cue.words.length === 0 || cue.words.every((w) => w.time === '')) { + cue.words = [{ text: newText, time: '' }]; + return; + } + // Avec word timestamps : le remplacement affecte potentiellement plusieurs mots. + // Stratégie simple : on remplace le texte du premier mot et on vide les suivants. + // Une future version pourrait aligner mot à mot via diff. + cue.words[0].text = newText; + for (let i = 1; i < cue.words.length; i++) { + cue.words[i].text = ''; + } + } +} diff --git a/src/pseudonymizer/Redaction.ts b/src/pseudonymizer/Redaction.ts new file mode 100644 index 0000000..4ad24e0 --- /dev/null +++ b/src/pseudonymizer/Redaction.ts @@ -0,0 +1,31 @@ +export const REDACTION_CHAR = '🀫'; + +/** + * Compte les syllabes d'un texte par heuristique de groupes de voyelles. + * Fonctionne pour le français et les principales langues romanes/germaniques. + * Minimum 1 syllabe retourné. + */ +export function countSyllables(text: string): number { + const groups = text.match(/[aeiouyàâäéèêëîïôùûüœæAEIOUYÀÂÄÉÈÊËÎÏÔÙÛÜŒÆ]+/g); + return Math.max(1, groups?.length ?? 1); +} + +/** + * Génère un remplacement caviardé : 1 🀫 par syllabe de chaque mot. + * Les espaces entre mots sont préservés. + * Ex : "Marie Dupont" → "🀫🀫 🀫🀫🀫" · "Saint-Jean" → "🀫🀫🀫" + */ +export function generateRedaction(text: string): string { + return text + .split(/( +)/) // séparer sur les espaces en les conservant + .map((part) => { + if (/^ +$/.test(part)) return part; // préserver les espaces tels quels + return REDACTION_CHAR.repeat(countSyllables(part)); + }) + .join(''); +} + +/** Retourne true si un remplacement est un caviardage (commence par 🀫). */ +export function isRedaction(replacement: string): boolean { + return replacement.startsWith(REDACTION_CHAR); +} diff --git a/src/scanner/OnnxNerScanner.ts b/src/scanner/OnnxNerScanner.ts index a2fb6d8..6c41a53 100644 --- a/src/scanner/OnnxNerScanner.ts +++ b/src/scanner/OnnxNerScanner.ts @@ -18,6 +18,39 @@ const TAG_TO_CATEGORY: Record = { const MIN_ENTITY_LENGTH = 2; +// Préambules Markdown à stripper avant envoi au modèle NER. +// Chaque regex matche le début d'une ligne (format produit par les parsers du plugin). +// Le résultat : { cleanText, preambleLength } pour recaler les positions. +const PREAMBLE_PATTERNS: RegExp[] = [ + // VTT noScribe : *HH:MM:SS.mmm → HH:MM:SS.mmm* **SPEAKER** + /^\*[\d:.]+\s*→\s*[\d:.]+\*(?:\s*\*\*[^*]+\*\*)?\s*/, + // SRT bloc de texte : **[N]** *HH:MM:SS,mmm → HH:MM:SS,mmm* (ligne à ignorer entièrement) + /^\*\*\[\d+\]\*\*\s*\*[^*]+\*/, + // CHAT tour de parole : **SPEAKER** : + /^\*\*[A-Z0-9_]+\*\*\s*:\s*/, +]; + +// Lignes à ignorer entièrement (pas de texte à analyser) +const SKIP_PATTERNS: RegExp[] = [ + /^---/, // frontmatter YAML + /^pseudobs-/, // clés frontmatter du plugin + /^> /, // métadonnées CHAT (@, %) + /^\*\*\[\d+\]\*\*/, // en-tête de bloc SRT (index + timestamp sur la même ligne) +]; + +function stripMarkdownPreamble(line: string): { cleanText: string; preambleLength: number } { + // Lignes à ignorer complètement + for (const skip of SKIP_PATTERNS) { + if (skip.test(line)) return { cleanText: '', preambleLength: line.length }; + } + // Stripper le préambule + for (const pat of PREAMBLE_PATTERNS) { + const m = pat.exec(line); + if (m) return { cleanText: line.slice(m[0].length), preambleLength: m[0].length }; + } + return { cleanText: line, preambleLength: 0 }; +} + type NerResult = { entity_group: string; score: number; @@ -103,6 +136,9 @@ export class OnnxNerScanner { // numThreads=1 : SharedArrayBuffer non disponible dans Electron renderer // sans COOP/COEP → pas de WASM threadé. env.backends.onnx.wasm.numThreads = 1; + // proxy=true : délègue la compilation WASM à un worker thread Node.js + // → libère le thread renderer pendant l'initialisation du modèle. + env.backends.onnx.wasm.proxy = true; } // Autoriser le téléchargement depuis HuggingFace Hub @@ -147,18 +183,20 @@ export class OnnxNerScanner { let offset = 0; for (const line of lines) { - if (line.trim().length > 2) { + const { cleanText, preambleLength } = stripMarkdownPreamble(line); + + if (cleanText.trim().length > 2) { try { - const entities: NerResult[] = await _pipeline(line, { aggregation_strategy: 'simple' }); + const entities: NerResult[] = await _pipeline(cleanText, { aggregation_strategy: 'simple' }); for (const ent of entities) { if (ent.score < minScore) continue; - const word = ent.word.trim().replace(/^#+/, ''); // supprimer les ## de sous-mots - // Filtrer les artefacts de tokenisation BERT (mots fonctionnels, tokens trop courts) + const word = ent.word.trim().replace(/^#+/, ''); if (word.length < MIN_ENTITY_LENGTH) continue; if (functionWords.has(word.toLowerCase())) continue; const category = TAG_TO_CATEGORY[ent.entity_group] ?? 'custom'; - const start = offset + ent.start; - const end = offset + ent.end; + // Positions recalées : offset de ligne + préambule strippé + position dans cleanText + const start = offset + preambleLength + ent.start; + const end = offset + preambleLength + ent.end; const ctxLen = 45; results.push({ id: `ner_${Date.now()}_${++_counter}`, diff --git a/src/types.ts b/src/types.ts index 57143c7..f4daa6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,13 @@ export interface OccurrenceRef { status: MappingStatus; } +/** Occurrence d'une règle explicitement ignorée par l'utilisateur. */ +export interface IgnoredOccurrence { + text: string; // texte exact trouvé dans le document (sensible à la casse) + contextBefore: string; + contextAfter: string; +} + export interface MappingRule { id: string; source: string; @@ -50,6 +57,7 @@ export interface MappingRule { updatedAt?: string; notes?: string; occurrences?: OccurrenceRef[]; + ignoredOccurrences?: IgnoredOccurrence[]; } export interface Occurrence { diff --git a/src/ui/DictScanReviewModal.ts b/src/ui/DictScanReviewModal.ts index be85881..78b11c1 100644 --- a/src/ui/DictScanReviewModal.ts +++ b/src/ui/DictScanReviewModal.ts @@ -248,7 +248,7 @@ export class DictScanReviewModal extends Modal { const n = toCreate.length; new Notice(t('notice.rulesCreated', String(n), n > 1 ? t('notice.rulesCreated.rules') : t('notice.rulesCreated.rule'))); - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } diff --git a/src/ui/EditRuleModal.ts b/src/ui/EditRuleModal.ts index 18df78f..acaa4b6 100644 --- a/src/ui/EditRuleModal.ts +++ b/src/ui/EditRuleModal.ts @@ -128,7 +128,7 @@ export class EditRuleModal extends Modal { await this.plugin.scopeResolver.saveStore(store, filePath); new Notice(t('notice.ruleCreated', rule.source, this.replacement.trim())); - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } @@ -136,8 +136,9 @@ export class EditRuleModal extends Modal { const { store, filePath, rule } = this.location; store.remove(rule.id); await this.plugin.scopeResolver.saveStore(store, filePath); - new Notice(`✓ "${rule.source}"`); - void this.plugin.refreshHighlightData(); + // Rétablir le texte original dans le fichier actif + await this.plugin.revertRuleInFile(rule.source, rule.replacement); + new Notice(t('notice.ruleDeleted', rule.source)); this.close(); } diff --git a/src/ui/MappingScanReviewModal.ts b/src/ui/MappingScanReviewModal.ts index 4132067..0350d82 100644 --- a/src/ui/MappingScanReviewModal.ts +++ b/src/ui/MappingScanReviewModal.ts @@ -1,13 +1,16 @@ import { App, Modal, Notice, TFile } from 'obsidian'; import { t } from '../i18n'; import type PseudObsPlugin from '../main'; -import type { MappingRule } from '../types'; -import { findSpansForRule } from '../pseudonymizer/ReplacementPlanner'; +import type { MappingRule, Occurrence } from '../types'; import { resolveSpans, applySpans } from '../pseudonymizer/SpanProtector'; +import type { ReplacementSpan } from '../types'; +import { OccurrencesContextModal, type OccurrenceDecision } from './OccurrencesContextModal'; +import type { IgnoredOccurrence } from '../types'; export interface MappingRuleResult { rule: MappingRule; matchCount: number; + occurrences: Occurrence[]; } export class MappingScanReviewModal extends Modal { @@ -16,7 +19,11 @@ export class MappingScanReviewModal extends Modal { private content: string; private ruleResults: MappingRuleResult[]; private checked: boolean[]; + // Décisions par règle : ruleId → occId → decision + private decisionsMap = new Map>(); private applyBtn!: HTMLButtonElement; + // Cellules de comptage — pour mise à jour live + private countCells: HTMLElement[] = []; constructor( app: App, @@ -31,6 +38,13 @@ export class MappingScanReviewModal extends Modal { this.content = content; this.ruleResults = ruleResults; this.checked = ruleResults.map(() => true); + + // Décisions initiales : toutes les occurrences validées + for (const { rule, occurrences } of ruleResults) { + const map = new Map(); + for (const occ of occurrences) map.set(occ.id, 'validated'); + this.decisionsMap.set(rule.id, map); + } } onOpen(): void { @@ -41,7 +55,9 @@ export class MappingScanReviewModal extends Modal { contentEl.createEl('h2', { text: t('mappingScanModal.title') }); const nr = this.ruleResults.length; contentEl.createEl('p', { - text: t('mappingScanModal.summary', String(nr), nr > 1 ? t('mappingScanModal.summary.rules') : t('mappingScanModal.summary.rule'), this.file.name), + text: t('mappingScanModal.summary', String(nr), + nr > 1 ? t('mappingScanModal.summary.rules') : t('mappingScanModal.summary.rule'), + this.file.name), cls: 'pseudobs-scan-summary', }); contentEl.createEl('p', { text: t('mappingScanModal.hint'), cls: 'pseudobs-view-hint' }); @@ -56,7 +72,7 @@ export class MappingScanReviewModal extends Modal { ); const tbody = table.createEl('tbody'); - this.ruleResults.forEach(({ rule, matchCount }, i) => { + this.ruleResults.forEach(({ rule, occurrences }, i) => { const tr = tbody.createEl('tr', { cls: 'pseudobs-dict-review-row' }); // Checkbox @@ -72,11 +88,9 @@ export class MappingScanReviewModal extends Modal { // Source tr.createEl('td', { text: rule.source, cls: 'pseudobs-dict-review-term' }); - - // Flèche tr.createEl('td', { text: '→', cls: 'pseudobs-dict-review-arrow' }); - // Remplacement (lecture seule — déjà défini dans la règle) + // Remplacement const repCell = tr.createEl('td'); const s = this.plugin.settings; const displayRep = s.useMarkerInExport @@ -84,68 +98,156 @@ export class MappingScanReviewModal extends Modal { : rule.replacement; repCell.createEl('span', { text: displayRep, cls: 'pseudobs-dict-review-rep-static' }); - // Occurrences - tr.createEl('td', { text: String(matchCount), cls: 'pseudobs-dict-review-count' }); + // Occurrences — bouton cliquable qui ouvre la modale de contexte + const countCell = tr.createEl('td', { cls: 'pseudobs-dict-review-count' }); + this.countCells.push(countCell); + this.renderCountCell(countCell, rule, occurrences, i); }); const footer = contentEl.createDiv('pseudobs-dict-review-footer'); footer.createEl('button', { text: t('mappingScanModal.cancel') }) .addEventListener('click', () => this.close()); + footer.createEl('button', { text: t('mappingScanModal.saveExceptions'), cls: 'pseudobs-save-exceptions-btn' }) + .addEventListener('click', () => void this.saveExceptions()); + this.applyBtn = footer.createEl('button', { cls: 'mod-cta' }); this.applyBtn.addEventListener('click', () => void this.apply()); this.updateApplyLabel(); } + private renderCountCell( + cell: HTMLElement, + rule: MappingRule, + occurrences: Occurrence[], + ruleIndex: number + ): void { + cell.empty(); + const decisions = this.decisionsMap.get(rule.id)!; + const validated = occurrences.filter((o) => decisions.get(o.id) === 'validated').length; + const total = occurrences.length; + + if (total === 0) { + cell.createSpan({ text: '0' }); + return; + } + + const btn = cell.createEl('button', { cls: 'pseudobs-count-btn' }); + // Afficher "N / total" si des occurrences ont été ignorées, "N" sinon + btn.setText(validated < total ? `${validated} / ${total}` : String(total)); + btn.title = 'Voir et sélectionner les candidats'; + + btn.addEventListener('click', () => { + new OccurrencesContextModal( + this.app, + rule, + occurrences, + decisions, + (newDecisions) => { + this.decisionsMap.set(rule.id, newDecisions); + this.renderCountCell(cell, rule, occurrences, ruleIndex); + this.updateApplyLabel(); + } + ).open(); + }); + } + + private countValidated(): number { + return this.ruleResults.reduce((sum, { rule, occurrences }, i) => { + if (!this.checked[i]) return sum; + const decisions = this.decisionsMap.get(rule.id)!; + return sum + occurrences.filter((o) => decisions.get(o.id) === 'validated').length; + }, 0); + } + private updateApplyLabel(): void { - const n = this.checked.filter(Boolean).length; - const total = this.ruleResults - .filter((_, i) => this.checked[i]) - .reduce((sum, r) => sum + r.matchCount, 0); - this.applyBtn.textContent = n === 0 + const rules = this.checked.filter(Boolean).length; + const total = this.countValidated(); + this.applyBtn.textContent = rules === 0 ? t('mappingScanModal.noRules') : t('mappingScanModal.apply', - String(n), n > 1 ? t('mappingScanModal.apply.rules') : t('mappingScanModal.apply.rule'), + String(rules), rules > 1 ? t('mappingScanModal.apply.rules') : t('mappingScanModal.apply.rule'), String(total), total > 1 ? t('mappingScanModal.apply.occurrences') : t('mappingScanModal.apply.occurrence')); - this.applyBtn.toggleClass('pseudobs-dict-review-btn-empty', n === 0); + this.applyBtn.toggleClass('pseudobs-dict-review-btn-empty', rules === 0); + } + + /** Enregistre les exceptions dans le mapping sans appliquer de remplacements. */ + private async saveExceptions(): Promise { + await this.persistIgnoredOccurrences(); + new Notice(t('mappingScanModal.exceptionsSaved')); + this.close(); + } + + /** + * Persiste les occurrences ignorées (✗ et ⚠) dans le mapping.json de chaque règle. + * S'appuie sur findRuleByTerm pour localiser le bon mapping file. + * Les nouvelles exceptions sont fusionnées avec les existantes (déduplication par texte). + */ + private async persistIgnoredOccurrences(): Promise { + for (let i = 0; i < this.ruleResults.length; i++) { + const { rule, occurrences } = this.ruleResults[i]; + const decisions = this.decisionsMap.get(rule.id)!; + + const newIgnored: IgnoredOccurrence[] = occurrences + .filter((occ) => { + const d = decisions.get(occ.id) ?? 'validated'; + return d === 'ignored' || d === 'false_positive'; + }) + .map((occ) => ({ text: occ.text, contextBefore: occ.contextBefore, contextAfter: occ.contextAfter })); + + if (newIgnored.length === 0) continue; + + const location = await this.plugin.scopeResolver.findRuleByTerm(rule.source); + if (!location) continue; + + const existing = location.rule.ignoredOccurrences ?? []; + const existingTexts = new Set(existing.map((o) => o.text)); + const merged = [...existing, ...newIgnored.filter((o) => !existingTexts.has(o.text))]; + + location.store.update(rule.id, { ignoredOccurrences: merged }); + await this.plugin.scopeResolver.saveStore(location.store, location.filePath); + } + void this.plugin.refresh(); } private async apply(): Promise { - const checkedRules = this.ruleResults - .filter((_, i) => this.checked[i]) - .map((r) => r.rule); - if (checkedRules.length === 0) { this.close(); return; } - - this.applyBtn.setAttr('disabled', 'true'); - const s = this.plugin.settings; - const marker = s.useMarkerInExport - ? { open: s.markerOpen, close: s.markerClose } - : undefined; + const wrap = (r: string) => s.useMarkerInExport + ? `${s.markerOpen}${r}${s.markerClose}` : r; - // Collecter tous les spans (toutes règles cochées), résoudre les chevauchements, - // appliquer de droite à gauche comme le moteur principal - const allSpans = checkedRules.flatMap((rule) => - findSpansForRule(this.content, rule, { - caseSensitive: s.caseSensitive, - wholeWordOnly: s.wholeWordOnly, - }).map((span) => - marker - ? { ...span, replacement: `${marker.open}${span.replacement}${marker.close}` } - : span - ) - ); + const spans: ReplacementSpan[] = []; + for (let i = 0; i < this.ruleResults.length; i++) { + if (!this.checked[i]) continue; + const { rule, occurrences } = this.ruleResults[i]; + const decisions = this.decisionsMap.get(rule.id)!; - const resolved = resolveSpans(allSpans); - if (resolved.length === 0) { + for (const occ of occurrences) { + if ((decisions.get(occ.id) ?? 'validated') !== 'validated') continue; + spans.push({ + start: occ.start, + end: occ.end, + source: occ.text, + replacement: wrap(rule.replacement), + mappingId: rule.id, + priority: rule.priority, + }); + } + } + + if (spans.length === 0) { new Notice(t('notice.noOccurrences')); this.close(); return; } + this.applyBtn.setAttr('disabled', 'true'); + + const resolved = resolveSpans(spans); const modified = applySpans(this.content, resolved); await this.app.vault.modify(this.file, modified); - void this.plugin.refreshHighlightData(); + + // Persister les exceptions ignorées dans le mapping + await this.persistIgnoredOccurrences(); const total = resolved.length; new Notice(t('notice.occurrencesPseudonymized', diff --git a/src/ui/OccurrencesContextModal.ts b/src/ui/OccurrencesContextModal.ts new file mode 100644 index 0000000..6568354 --- /dev/null +++ b/src/ui/OccurrencesContextModal.ts @@ -0,0 +1,163 @@ +/** + * Modale de contexte légère — affiche les candidats (occurrences) d'une règle + * et retourne les décisions sans appliquer immédiatement. + * + * Utilisée depuis MappingScanReviewModal pour permettre une sélection fine + * occurrence par occurrence (ex : valider "Juste" nom propre, ignorer "juste" adjectif). + */ + +import { App, Modal, Setting } from 'obsidian'; +import type { MappingRule, Occurrence } from '../types'; + +export type OccurrenceDecision = 'validated' | 'ignored' | 'false_positive'; + +interface CardRef { + card: HTMLElement; + buttons: Map; + arrow: HTMLElement; + resLine: HTMLElement; + statusLabel: HTMLElement; +} + +export class OccurrencesContextModal extends Modal { + private rule: MappingRule; + private occurrences: Occurrence[]; + private decisions: Map; + private onConfirm: (decisions: Map) => void; + private cardRefs = new Map(); + + constructor( + app: App, + rule: MappingRule, + occurrences: Occurrence[], + existingDecisions: Map, + onConfirm: (decisions: Map) => void + ) { + super(app); + this.rule = rule; + this.occurrences = occurrences; + this.decisions = new Map(existingDecisions); + this.onConfirm = onConfirm; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.addClass('pseudobs-ctx-modal'); + + contentEl.createEl('h3', { + text: `${this.rule.source} → ${this.rule.replacement}`, + cls: 'pseudobs-ctx-modal-title', + }); + contentEl.createEl('p', { + text: `${this.occurrences.length} occurrence${this.occurrences.length > 1 ? 's' : ''} — sélectionnez celles à remplacer.`, + cls: 'pseudobs-view-hint', + }); + + // 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(); + }) + ); + + // Cartes + const scroll = contentEl.createDiv('pseudobs-ctx-modal-scroll'); + for (const occ of this.occurrences) { + this.buildCard(scroll, occ); + } + + contentEl.createEl('hr'); + + new Setting(contentEl) + .addButton((b) => + b.setButtonText('Annuler').onClick(() => this.close()) + ) + .addButton((b) => + b.setButtonText('Confirmer la sélection').setCta().onClick(() => { + this.onConfirm(new Map(this.decisions)); + this.close(); + }) + ); + } + + private buildCard(container: HTMLElement, occ: Occurrence): void { + const card = container.createDiv('pseudobs-occ-card'); + + // Ligne source + const srcLine = card.createDiv('pseudobs-occ-line'); + srcLine.createSpan({ text: occ.contextBefore, cls: 'pseudobs-ctx-side' }); + srcLine.createSpan({ text: occ.text, cls: 'pseudobs-occ-term' }); + srcLine.createSpan({ text: occ.contextAfter, cls: 'pseudobs-ctx-side' }); + + // Flèche + ligne résultat + const arrow = card.createDiv('pseudobs-occ-arrow'); + arrow.setText('↓'); + + const resLine = card.createDiv('pseudobs-occ-line pseudobs-occ-result-line'); + resLine.createSpan({ text: occ.contextBefore, cls: 'pseudobs-ctx-side' }); + resLine.createSpan({ text: this.rule.replacement, cls: 'pseudobs-occ-replacement' }); + resLine.createSpan({ text: occ.contextAfter, cls: 'pseudobs-ctx-side' }); + + const statusLabel = card.createDiv('pseudobs-occ-status-label'); + card.createEl('small', { text: `ligne ${occ.line}`, cls: 'pseudobs-occ-meta' }); + + // Boutons décision + const actions = card.createDiv('pseudobs-occ-actions'); + const btnRefs = new Map(); + + for (const [label, value, title] of [ + ['✓', 'validated', 'Valider'], + ['✗', 'ignored', 'Ignorer'], + ['⚠', 'false_positive', 'Faux positif'], + ] as [string, OccurrenceDecision, string][]) { + const btn = actions.createEl('button', { text: label }); + btn.title = title; + btn.addClass('pseudobs-occ-btn'); + 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 }); + this.updateCard(occ.id); + } + + private updateCard(occId: string): void { + const ref = this.cardRefs.get(occId); + if (!ref) return; + const decision = this.decisions.get(occId) ?? 'validated'; + + ref.card.removeClass('pseudobs-occ-validated', 'pseudobs-occ-ignored', 'pseudobs-occ-false_positive'); + ref.card.addClass(`pseudobs-occ-${decision}`); + + for (const [value, btn] of ref.buttons) { + btn.toggleClass('pseudobs-occ-btn-active', value === decision); + } + + const show = decision === 'validated'; + ref.arrow.toggle(show); + ref.resLine.toggle(show); + ref.statusLabel.toggle(!show); + ref.statusLabel.setText( + decision === 'ignored' ? 'Conservé tel quel' : decision === 'false_positive' ? 'Faux positif — exclu' : '' + ); + } + + private updateAllCards(): void { + for (const occId of this.cardRefs.keys()) this.updateCard(occId); + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/ui/OccurrencesModal.ts b/src/ui/OccurrencesModal.ts index 44d63c0..6b7a67a 100644 --- a/src/ui/OccurrencesModal.ts +++ b/src/ui/OccurrencesModal.ts @@ -238,7 +238,7 @@ export class OccurrencesModal extends Modal { 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' : ''}` : '')); - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } diff --git a/src/ui/PseudonymHighlighter.ts b/src/ui/PseudonymHighlighter.ts index bfdad7a..10af079 100644 --- a/src/ui/PseudonymHighlighter.ts +++ b/src/ui/PseudonymHighlighter.ts @@ -6,9 +6,10 @@ import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@ export const highlightDataChanged = StateEffect.define(); export interface HighlightData { - sources: string[]; // termes originaux encore présents → orange (à pseudonymiser) - replacements: string[]; // pseudonymes déjà appliqués → vert + souligné - nerCandidates: string[]; // entités détectées par NER → bleu (candidats à pseudonymiser) + sources: string[]; // termes originaux encore présents → orange + replacements: string[]; // pseudonymes déjà appliqués → vert + souligné + nerCandidates: string[]; // entités NER → bleu + ignoredTerms: string[]; // textes exacts des occurrences ignorées → rouge (sensible à la casse) } // Extension CodeMirror 6 qui surligne dans l'éditeur : @@ -34,17 +35,20 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens } private build(view: EditorView): DecorationSet { - const { sources, replacements, nerCandidates } = getData(); - if (sources.length === 0 && replacements.length === 0 && nerCandidates.length === 0) + const { sources, replacements, nerCandidates, ignoredTerms } = getData(); + if (sources.length === 0 && replacements.length === 0 + && nerCandidates.length === 0 && ignoredTerms.length === 0) return Decoration.none; const text = view.state.doc.toString(); const lower = text.toLowerCase(); - type Span = { from: number; to: number; cls: string }; + // prio : 0 = plus haute priorité (gagne en cas de chevauchement) + type Span = { from: number; to: number; cls: string; prio: number }; const spans: Span[] = []; - const collect = (terms: string[], cls: string) => { + // Matching insensible à la casse (sources, remplacements, NER) + const collect = (terms: string[], cls: string, prio: number) => { for (const term of terms) { if (!term) continue; const needle = term.toLowerCase(); @@ -52,7 +56,21 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens while (pos < lower.length) { const idx = lower.indexOf(needle, pos); if (idx === -1) break; - spans.push({ from: idx, to: idx + term.length, cls }); + spans.push({ from: idx, to: idx + term.length, cls, prio }); + pos = idx + term.length; + } + } + }; + + // Matching SENSIBLE à la casse (exceptions — "juste" ≠ "Juste") + const collectExact = (terms: string[], cls: string, prio: number) => { + for (const term of terms) { + if (!term) continue; + let pos = 0; + while (pos < text.length) { + const idx = text.indexOf(term, pos); + if (idx === -1) break; + spans.push({ from: idx, to: idx + term.length, cls, prio }); pos = idx + term.length; } } @@ -73,12 +91,14 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens return !sourcesLower.some((src) => src !== cl && src.includes(cl)); }); - collect(freshCandidates, 'pseudobs-ner-candidate'); - collect(sources, 'pseudobs-source'); - collect(replacements, 'pseudobs-replaced'); + // Priorités explicites : 0 = gagne en cas de chevauchement + collectExact(ignoredTerms, 'pseudobs-exception', 0); + collect(replacements, 'pseudobs-replaced', 1); + collect(sources, 'pseudobs-source', 2); + collect(freshCandidates, 'pseudobs-ner-candidate', 3); - // Trier par position (RangeSetBuilder l'exige) et éliminer les chevauchements - spans.sort((a, b) => a.from - b.from || a.to - b.to); + // Trier par position, puis par priorité (0 = gagne) en cas d'égalité de position + spans.sort((a, b) => a.from - b.from || a.prio - b.prio || a.to - b.to); const builder = new RangeSetBuilder(); let lastTo = -1; diff --git a/src/ui/PseudonymizationView.ts b/src/ui/PseudonymizationView.ts index 23eb0e2..5c720c3 100644 --- a/src/ui/PseudonymizationView.ts +++ b/src/ui/PseudonymizationView.ts @@ -94,6 +94,11 @@ export class PseudonymizationView extends ItemView { await this.renderTab(tab); } + /** Appelé par le plugin pour forcer un re-rendu de l'onglet actif. */ + async refreshActiveTab(): Promise { + if (!this._renderingTab) await this.renderTab(this.activeTab); + } + private async renderTab(tab: Tab): Promise { const pane = this.panes[tab]; pane.empty(); @@ -153,14 +158,19 @@ export class PseudonymizationView extends ItemView { caseSensitive: this.plugin.settings.caseSensitive, wholeWordOnly: this.plugin.settings.wholeWordOnly, }); - const countByRule = new Map(); + const occsByRule = new Map(); for (const occ of occs) { const id = occ.mappingId ?? ''; - countByRule.set(id, (countByRule.get(id) ?? 0) + 1); + if (!occsByRule.has(id)) occsByRule.set(id, []); + occsByRule.get(id)!.push(occ); } const ruleResults: MappingRuleResult[] = rules - .filter((r) => countByRule.has(r.id)) - .map((r) => ({ rule: r, matchCount: countByRule.get(r.id)! })); + .filter((r) => occsByRule.has(r.id)) + .map((r) => ({ + rule: r, + matchCount: occsByRule.get(r.id)!.length, + occurrences: occsByRule.get(r.id)!, + })); if (ruleResults.length === 0) { new Notice(t('notice.noOccurrences')); return; } new MappingScanReviewModal(this.app, this.plugin, file, content, ruleResults).open(); } finally { @@ -240,6 +250,46 @@ export class PseudonymizationView extends ItemView { editBtn.addEventListener('click', () => new EditRuleModal(this.app, this.plugin, loc).open()); } } + + // ---- Section Exceptions ---- + const allIgnored = locations.flatMap(({ rule, store, filePath }) => + (rule.ignoredOccurrences ?? []).map((occ) => ({ occ, rule, store, filePath })) + ); + + if (allIgnored.length > 0) { + el.createEl('h3', { text: t('panel.mappings.exceptions'), cls: 'pseudobs-mappings-scope-heading' }); + el.createEl('p', { text: t('panel.mappings.exceptions.hint'), cls: 'pseudobs-view-hint' }); + + const exceptionsGrid = el.createDiv('pseudobs-exceptions-grid'); + + for (const { occ, rule, store, filePath } of allIgnored) { + const card = exceptionsGrid.createDiv('pseudobs-exception-card'); + + // En-tête : règle concernée + card.createEl('div', { + text: `${rule.source} → ${rule.replacement}`, + cls: 'pseudobs-exception-card-rule', + }); + + // Contexte + const ctx = card.createDiv('pseudobs-exception-card-ctx'); + ctx.createSpan({ text: occ.contextBefore, cls: 'pseudobs-ctx-side' }); + ctx.createSpan({ text: occ.text, cls: 'pseudobs-exception-card-term' }); + ctx.createSpan({ text: occ.contextAfter, cls: 'pseudobs-ctx-side' }); + + // Bouton supprimer + const delBtn = card.createEl('button', { cls: 'pseudobs-exception-card-del' }); + setIcon(delBtn, 'x'); + delBtn.title = 'Supprimer cette exception'; + delBtn.addEventListener('click', async () => { + const updated = (rule.ignoredOccurrences ?? []).filter((o) => o.text !== occ.text); + store.update(rule.id, { ignoredOccurrences: updated }); + await this.plugin.scopeResolver.saveStore(store, filePath); + void this.plugin.refresh(); + void this.renderTab('mappings'); + }); + } + } } // ---- Onglet Dictionnaires -------------------------------------- @@ -386,10 +436,22 @@ export class PseudonymizationView extends ItemView { const s = this.plugin.settings; const nerScanBtn = el.createEl('button', { cls: 'pseudobs-view-action-btn mod-cta' }); - setIcon(nerScanBtn, 'scan-search'); - nerScanBtn.createSpan({ text: t('panel.ner.scanBtn') }); + const nerScanIcon = nerScanBtn.createSpan(); + setIcon(nerScanIcon, 'scan-search'); + nerScanBtn.createSpan({ text: ` ${t('panel.ner.scanBtn')}` }); nerScanBtn.title = t('panel.ner.scanBtn'); - nerScanBtn.addEventListener('click', () => void this.plugin.scanCurrentFileNer()); + nerScanBtn.addEventListener('click', () => { void (async () => { + nerScanBtn.setAttr('disabled', 'true'); + setIcon(nerScanIcon, 'loader-circle'); + nerScanIcon.addClass('pseudobs-spin'); + try { + await this.plugin.scanCurrentFileNer(); + } finally { + nerScanBtn.removeAttribute('disabled'); + setIcon(nerScanIcon, 'scan-search'); + nerScanIcon.removeClass('pseudobs-spin'); + } + })(); }); el.createEl('hr'); diff --git a/src/ui/QuickPseudonymizeModal.ts b/src/ui/QuickPseudonymizeModal.ts index e043d17..e5f039d 100644 --- a/src/ui/QuickPseudonymizeModal.ts +++ b/src/ui/QuickPseudonymizeModal.ts @@ -3,6 +3,7 @@ import type PseudObsPlugin from '../main'; import { MappingStore } from '../mappings/MappingStore'; import type { EntityCategory, MappingFile } from '../types'; import { t } from '../i18n'; +import { generateRedaction } from '../pseudonymizer/Redaction'; type ApplyScope = 'occurrence' | 'file'; @@ -18,19 +19,23 @@ export class QuickPseudonymizeModal extends Modal { private applyScope: ApplyScope = 'file'; private suggestions: string[]; + private isRedactionMode: boolean; + constructor( app: App, plugin: PseudObsPlugin, editor: Editor, prefillReplacement = '', - suggestions: string[] = [] + suggestions: string[] = [], + isRedactionMode = false, ) { super(app); this.plugin = plugin; this.editor = editor; this.source = editor.getSelection(); - this.replacement = prefillReplacement; + this.replacement = prefillReplacement || (isRedactionMode ? generateRedaction(this.source) : ''); this.suggestions = suggestions; + this.isRedactionMode = isRedactionMode; if (suggestions.length > 0) this.category = 'first_name'; this.from = editor.getCursor('from'); this.to = editor.getCursor('to'); @@ -48,7 +53,7 @@ export class QuickPseudonymizeModal extends Modal { tx.inputEl.addClass('pseudobs-disabled-input'); }); - let replacementInput: HTMLInputElement; + let replacementInput: HTMLInputElement | undefined; if (this.suggestions.length > 0) { const suggBox = contentEl.createDiv('pseudobs-suggestions-box'); @@ -98,6 +103,30 @@ export class QuickPseudonymizeModal extends Modal { d.onChange((v) => (this.applyScope = v as ApplyScope)); }); + // Checkbox caviardage + const redactRow = contentEl.createDiv('pseudobs-redact-row'); + const redactCb = redactRow.createEl('input'); + redactCb.type = 'checkbox'; + redactCb.checked = this.isRedactionMode; + redactCb.addClass('pseudobs-dict-review-cb'); + const redactLabel = redactRow.createSpan({ text: ` ${t('redaction.checkbox')}` }); + redactLabel.title = t('redaction.checkboxDesc'); + redactCb.addEventListener('change', () => { + this.isRedactionMode = redactCb.checked; + if (this.isRedactionMode) { + this.replacement = generateRedaction(this.source); + if (replacementInput) replacementInput.value = this.replacement; + replacementInput?.setAttr('disabled', 'true'); + } else { + this.replacement = ''; + if (replacementInput) replacementInput.value = ''; + replacementInput?.removeAttribute('disabled'); + } + }); + if (this.isRedactionMode && replacementInput) { + replacementInput.setAttr('disabled', 'true'); + } + new Setting(contentEl).addButton((btn) => btn.setButtonText(t('quickModal.submit')).setCta().onClick(() => void this.apply()) ); @@ -133,7 +162,7 @@ export class QuickPseudonymizeModal extends Modal { new Notice(t('notice.appliedFile', this.source, marked, String(count), count > 1 ? 's' : '')); } - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } diff --git a/src/ui/RuleModal.ts b/src/ui/RuleModal.ts index d5f5d69..cc9b2ee 100644 --- a/src/ui/RuleModal.ts +++ b/src/ui/RuleModal.ts @@ -1,5 +1,6 @@ import { App, Modal, Setting, TFile, Notice } from 'obsidian'; import { t } from '../i18n'; +import { generateRedaction, REDACTION_CHAR } from '../pseudonymizer/Redaction'; import type PseudObsPlugin from '../main'; import { MappingStore } from '../mappings/MappingStore'; import type { EntityCategory, MappingFile, ScopeType } from '../types'; @@ -211,6 +212,23 @@ export class RuleModal extends Modal { tx.setValue('0').onChange((v) => { this.priority = parseInt(v, 10) || 0; }) ); + // Checkbox caviardage + const redactRow = contentEl.createDiv('pseudobs-redact-row'); + const redactCb = redactRow.createEl('input'); + redactCb.type = 'checkbox'; + redactCb.addClass('pseudobs-dict-review-cb'); + redactRow.createSpan({ text: ` ${t('redaction.checkbox')}` }).title = t('redaction.checkboxDesc'); + redactCb.addEventListener('change', () => { + if (redactCb.checked) { + this.replacement = generateRedaction(this.source || REDACTION_CHAR); + this.useClass = false; + if (replacementInput) { replacementInput.value = this.replacement; replacementInput.setAttr('disabled', 'true'); } + } else { + this.replacement = ''; + if (replacementInput) { replacementInput.value = ''; replacementInput.removeAttribute('disabled'); } + } + }); + new Setting(contentEl).addButton((btn) => btn.setButtonText(t('ruleModal.submit')).setCta().onClick(() => void this.createRule()) ); @@ -283,7 +301,7 @@ export class RuleModal extends Modal { } new Notice(t('notice.ruleCreated', this.source.trim(), this.replacement.trim())); - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); this.close(); } diff --git a/styles.css b/styles.css index 39b82be..a2a9a9f 100644 --- a/styles.css +++ b/styles.css @@ -7,6 +7,14 @@ outline: 1px solid rgba(80, 160, 255, 0.45); } +/* Occurrences explicitement ignorées — exceptions (sensibles à la casse) */ +.pseudobs-exception { + background-color: rgba(220, 50, 50, 0.15); + border-radius: 2px; + outline: 1px solid rgba(220, 50, 50, 0.4); + border-bottom: 2px solid rgba(220, 50, 50, 0.7); +} + /* Termes sources encore présents dans le texte (à pseudonymiser) */ .pseudobs-source { background-color: rgba(255, 160, 0, 0.22); @@ -955,6 +963,7 @@ } .pseudobs-onboarding-icon-btn-loading { cursor: wait; } .pseudobs-onboarding-icon-btn-loading svg { animation: pseudobs-spin 0.9s linear infinite; } +.pseudobs-spin svg { animation: pseudobs-spin 0.9s linear infinite; } /* ---- CorpusModal ---- */ @@ -1048,3 +1057,71 @@ font-size: 0.95em; line-height: 1.55; } + +/* ---- Section Exceptions (onglet Mappings) ---- */ +.pseudobs-exceptions-grid { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.pseudobs-exception-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid rgba(220, 50, 50, 0.35); + background: rgba(220, 50, 50, 0.06); + position: relative; +} + +.pseudobs-exception-card-rule { + font-size: 0.8em; + color: var(--text-muted); + font-style: italic; +} + +.pseudobs-exception-card-ctx { + font-size: 0.9em; +} + +.pseudobs-exception-card-term { + background: rgba(220, 50, 50, 0.2); + border-radius: 2px; + outline: 1px solid rgba(220, 50, 50, 0.45); + padding: 0 2px; + font-weight: 500; +} + +.pseudobs-exception-card-del { + position: absolute; + top: 6px; + right: 6px; + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 2px; + border-radius: 3px; + line-height: 1; +} +.pseudobs-exception-card-del:hover { + color: var(--text-error); + background: var(--background-modifier-error-hover); +} + +.pseudobs-save-exceptions-btn { + margin-right: auto; +} + +/* Ligne checkbox caviardage (QuickPseudonymizeModal, RuleModal) */ +.pseudobs-redact-row { + display: flex; + align-items: center; + gap: 6px; + margin: 6px 0 10px; + font-size: 0.88em; + color: var(--text-muted); +} diff --git a/tests/fixtures/fight_club.vtt b/tests/fixtures/fight_club.vtt new file mode 100644 index 0000000..d3b8d47 --- /dev/null +++ b/tests/fixtures/fight_club.vtt @@ -0,0 +1,298 @@ +WEBVTT fight_club + +NOTE +Transcrit avec noScribe vers. 0.7 +Fichier audio : /Users/axelleabbadie/SynologyDrive/Scientifique/Général/Tools/Pseudobsidianization/tests/fixtures/fight_club.mp3 +(Langue : French (fr) | Détection des locuteurs : 3 | Chevauchements de parole 1 | Horodatage : 1 | Mots de remplissage : 1 | Marquer les pauses : 1) + + +NOTE media: /Users/axelleabbadie/SynologyDrive/Scientifique/Général/Tools/Pseudobsidianization/tests/fixtures/fight_club.mp3 + +1 +00:00:00.530 --> 00:00:04.160 +S00: + +2 +00:00:00.530 --> 00:00:04.160 +[00:00:00] + +3 +00:00:00.530 --> 00:00:04.160 +Après un combat, tout le reste de la vie prenait beaucoup moins d'importance. + +4 +00:00:04.160 --> 00:00:07.584 + + +5 +00:00:00.530 --> 00:00:04.160 +S01: + +6 +00:00:00.530 --> 00:00:04.160 +[00:00:04] + +7 +00:00:00.530 --> 00:00:04.160 + + +8 +00:00:04.160 --> 00:00:07.584 +Qu'est-ce vous avez encore fichu hier soir ? + +9 +00:00:07.584 --> 00:00:07.990 + + +10 +00:00:07.584 --> 00:00:07.990 +S00: + +11 +00:00:00.530 --> 00:00:04.160 +[00:00:07] + +12 +00:00:00.530 --> 00:00:04.160 + + +13 +00:00:07.584 --> 00:00:07.990 +Quoi ? + +14 +00:00:07.584 --> 00:00:07.990 +S02: + +15 +00:00:00.530 --> 00:00:04.160 +[00:00:04] + +16 +00:00:00.530 --> 00:00:04.160 + + +17 +00:00:07.990 --> 00:00:09.312 +On pouvait endurer n'importe quoi. + +18 +00:00:09.312 --> 00:00:16.416 + + +19 +00:00:09.312 --> 00:00:16.416 +S01: [00:00:09] Vous avez fini ces rapports ? + +20 +00:00:16.416 --> 00:00:18.080 +S02: + +21 +00:00:16.416 --> 00:00:18.080 +[00:00:16] + +22 +00:00:16.416 --> 00:00:18.080 +Tu pouvais choisir ton adversaire, qui tu prendrais ? + +23 +00:00:18.080 --> 00:00:19.808 +(..) + +24 +00:00:19.808 --> 00:00:20.960 + + +25 +00:00:19.808 --> 00:00:20.960 +S00: Je prendrais sans doute mon patron. + +26 +00:00:21.824 --> 00:00:22.208 + + +27 +00:00:21.824 --> 00:00:22.208 +S02: Sérieux ? + +28 +00:00:22.208 --> 00:00:23.936 +(..) + +29 +00:00:23.936 --> 00:00:24.992 +S00: + +30 +00:00:23.936 --> 00:00:24.992 +[00:00:23] + +31 +00:00:23.936 --> 00:00:24.992 +Bah oui, pourquoi tu prendrais qui toi ? + +32 +00:00:25.696 --> 00:00:26.336 + + +33 +00:00:25.696 --> 00:00:26.336 +S02: Je prendrais mon père. + +34 +00:00:26.336 --> 00:00:27.616 +(.) + +35 +00:00:27.616 --> 00:00:28.224 + + +36 +00:00:27.616 --> 00:00:28.224 +S00: Je connais pas le mien. + +37 +00:00:28.224 --> 00:00:29.408 +(.) + +38 +00:00:29.408 --> 00:00:32.832 +Si, je le connais, mais il est parti quand je devais avoir 6 ans. + +39 +00:00:33.408 --> 00:00:34.816 +Il s'est remarié, il a eu d'autres gamins. + +40 +00:00:34.816 --> 00:00:36.160 +(.) + +41 +00:00:36.160 --> 00:00:36.960 +Il fait ça tous les 6 ans. + +42 +00:00:36.960 --> 00:00:38.016 +(.) + +43 +00:00:38.016 --> 00:00:39.740 +Il va dans une autre ville et il fonde une nouvelle famille. + +44 +00:00:39.960 --> 00:00:41.340 +S01: + +45 +00:00:39.960 --> 00:00:41.340 +[00:00:39] + +46 +00:00:39.960 --> 00:00:41.340 +Ce salaud, il monte des franchises. + +47 +00:00:41.340 --> 00:00:44.288 +(...) + +48 +00:00:44.288 --> 00:00:45.520 +Mon père, il est jamais allé en fac. + +49 +00:00:45.900 --> 00:00:47.552 +Alors pour lui, c'était important que j'y aille. + +50 +00:00:48.320 --> 00:00:48.990 + + +51 +00:00:48.320 --> 00:00:48.990 +S00: J'connais ce genre d'histoire. + +52 +00:00:48.990 --> 00:00:51.880 + + +53 +00:00:48.990 --> 00:00:51.880 +S01: Alors je passe ma licence, je l'appelle à l'autre bout du pays. + +54 +00:00:52.000 --> 00:00:52.992 +Je lui dis, papa, qu'est-ce que je fais ? + +55 +00:00:53.824 --> 00:00:54.650 +Il me dit, trouve-toi un boulot. + +56 +00:00:54.950 --> 00:00:55.750 + + +57 +00:00:54.950 --> 00:00:55.750 +S00: J'ai connu ça aussi. + +58 +00:00:55.990 --> 00:00:58.130 + + +59 +00:00:55.990 --> 00:00:58.130 +S01: Ensuite, j'ai 25 ans, je lui passe mon coup de fil annuel. + +60 +00:00:58.310 --> 00:01:00.670 +Je lui dis, papa, qu'est-ce que je fais ? + +61 +00:01:00.670 --> 00:01:02.770 +Il dit, je sais pas, tiens, marie-toi. + +62 +00:01:03.290 --> 00:01:04.192 +S00: + +63 +00:01:03.290 --> 00:01:04.192 +[00:01:03] + +64 +00:01:03.290 --> 00:01:04.192 +Ouais, pareil pour moi. + +65 +00:01:04.192 --> 00:01:05.440 +(.) + +66 +00:01:05.440 --> 00:01:08.928 +On peut pas se marier, je suis un gamin de 30 ans. + +67 +00:01:08.928 --> 00:01:10.176 +(.) + +68 +00:01:10.176 --> 00:01:12.032 +S01: + +69 +00:01:10.176 --> 00:01:12.032 +[00:01:10] + +70 +00:01:10.176 --> 00:01:12.032 +On a une génération d'hommes élevés par des femmes. + +71 +00:01:12.032 --> 00:01:13.376 +(.) + +72 +00:01:13.376 --> 00:01:15.840 +J'suis pas sûr qu'une autre femme soit la solution à nos problèmes. + diff --git a/tests/fixtures/juste-leblanc.html b/tests/fixtures/juste-leblanc.html new file mode 100644 index 0000000..8b1a1cd --- /dev/null +++ b/tests/fixtures/juste-leblanc.html @@ -0,0 +1,13 @@ + + +

\ No newline at end of file diff --git a/tests/fixtures/juste-leblanc.mp3 b/tests/fixtures/juste-leblanc.mp3 new file mode 100644 index 0000000..f42a0ee Binary files /dev/null and b/tests/fixtures/juste-leblanc.mp3 differ diff --git a/tests/unit/NoScribeHtmlParser.test.ts b/tests/unit/NoScribeHtmlParser.test.ts new file mode 100644 index 0000000..678b54c --- /dev/null +++ b/tests/unit/NoScribeHtmlParser.test.ts @@ -0,0 +1,104 @@ +import { NoScribeHtmlParser } from '../../src/parsers/NoScribeHtmlParser'; + +const parser = new NoScribeHtmlParser(); + +// ---- Fixture ---------------------------------------------------------------- +// Structure calquée sur une vraie sortie noScribe (Qt Rich Text HTML). +// Les timestamps sont en millisecondes depuis le début de l'audio. + +const NOSCRIBE_HTML = ` + + + + + +`; + +// ---- Tests ------------------------------------------------------------------ + +describe('NoScribeHtmlParser — isNoScribeHtml', () => { + test('reconnaît un HTML noScribe', () => { + expect(NoScribeHtmlParser.isNoScribeHtml(NOSCRIBE_HTML)).toBe(true); + }); + + test('rejette un HTML ordinaire', () => { + expect(NoScribeHtmlParser.isNoScribeHtml('

Bonjour

')).toBe(false); + }); +}); + +describe('NoScribeHtmlParser — parse', () => { + test('ignore les paragraphes sans ancre ts_', () => { + const doc = parser.parse(NOSCRIBE_HTML); + // Les 2 paragraphes d'en-tête (titre + métadonnées) ne doivent pas produire de cue + // Les cues sont : pause, tour S00, tour S01, tour S00 long = 4 cues + expect(doc.cues.length).toBe(4); + }); + + test('extrait la pause sans locuteur', () => { + const doc = parser.parse(NOSCRIBE_HTML); + const pause = doc.cues[0]; + expect(pause.speaker).toBeUndefined(); + expect(pause.text).toContain('..'); + expect(pause.startTime).toBe('00:00:00.000'); + expect(pause.endTime).toBe('00:00:02.130'); + }); + + test('extrait le locuteur depuis "Nom : texte" multi-ancres', () => { + const doc = parser.parse(NOSCRIBE_HTML); + const turn = doc.cues[1]; + expect(turn.speaker).toBe('Expérimentateur'); + expect(turn.text).not.toContain('Expérimentateur'); + expect(turn.text).toContain('Ok. D\'abord'); + }); + + test('extrait le locuteur depuis "Nom : texte" mono-ancre', () => { + const doc = parser.parse(NOSCRIBE_HTML); + const turn = doc.cues[2]; + expect(turn.speaker).toBe('SC01'); + expect(turn.text).toContain('Si je me présente'); + expect(turn.text).not.toContain('SC01'); + }); + + test('convertit les timestamps en HH:MM:SS.mmm', () => { + const doc = parser.parse(NOSCRIBE_HTML); + const turn = doc.cues[1]; + expect(turn.startTime).toBe('00:00:02.130'); + expect(turn.endTime).toBe('00:00:12.230'); + }); + + test('timestamp long (> 27 min) correctement converti', () => { + const doc = parser.parse(NOSCRIBE_HTML); + const turn = doc.cues[3]; + // 1668980ms = 27min 48.980s + expect(turn.startTime).toBe('00:27:48.980'); + expect(turn.endTime).toBe('00:27:53.180'); + }); + + test('word timestamps produits pour chaque ancre distincte', () => { + const doc = parser.parse(NOSCRIBE_HTML); + const turn = doc.cues[1]; // Expérimentateur avec 3 ancres ts_ distinctes + expect(turn.words.length).toBeGreaterThanOrEqual(2); + expect(turn.words[0].time).toBe('00:00:02.130'); + }); + + test('ignore les timestamps d\'affichage [HH:MM:SS]', () => { + const doc = parser.parse(NOSCRIBE_HTML); + const turn = doc.cues[1]; + expect(turn.text).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + }); +}); diff --git a/tests/unit/NoScribeVttParser.test.ts b/tests/unit/NoScribeVttParser.test.ts new file mode 100644 index 0000000..edabd2b --- /dev/null +++ b/tests/unit/NoScribeVttParser.test.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { NoScribeVttParser } from '../../src/parsers/NoScribeVttParser'; +import { NoScribeHtmlParser } from '../../src/parsers/NoScribeHtmlParser'; +import { noScribeHtmlToMarkdown } from '../../src/parsers/TranscriptConverter'; + +const VTT_FIXTURE = path.join(__dirname, '../fixtures/fight_club.vtt'); +const HTML_FIXTURE = path.join(__dirname, '../fixtures/juste-leblanc.html'); + +// ---- Détection -------------------------------------------------------------- + +describe('NoScribeVttParser — détection', () => { + test('reconnaît un VTT noScribe', () => { + const content = fs.readFileSync(VTT_FIXTURE, 'utf-8'); + expect(NoScribeVttParser.isNoScribeVtt(content)).toBe(true); + }); + + test('rejette un VTT standard', () => { + expect(NoScribeVttParser.isNoScribeVtt('WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nBonjour\n')).toBe(false); + }); + + test('extrait le chemin audio depuis NOTE media', () => { + const content = fs.readFileSync(VTT_FIXTURE, 'utf-8'); + const src = NoScribeVttParser.extractAudioSource(content); + expect(src).toMatch(/fight_club\.mp3$/); + }); +}); + +// ---- Parsing VTT fight_club ------------------------------------------------- + +describe('NoScribeVttParser — fight_club.vtt', () => { + let doc: ReturnType; + + beforeAll(() => { + const content = fs.readFileSync(VTT_FIXTURE, 'utf-8'); + doc = new NoScribeVttParser().parse(content); + }); + + test('produit au moins 5 cues', () => { + expect(doc.cues.length).toBeGreaterThanOrEqual(5); + }); + + test('détecte les locuteurs S00, S01, S02', () => { + const speakers = new Set(doc.cues.map((c) => c.speaker).filter(Boolean)); + expect(speakers.has('S00')).toBe(true); + expect(speakers.has('S01')).toBe(true); + expect(speakers.has('S02')).toBe(true); + }); + + test('premier cue : S00, timestamp correct, texte non vide', () => { + const first = doc.cues[0]; + expect(first.speaker).toBe('S00'); + expect(first.startTime).toBe('00:00:00.530'); + expect(first.text).toContain('combat'); + }); + + test('les timestamps d\'affichage [HH:MM:SS] sont absents du texte', () => { + for (const cue of doc.cues) { + expect(cue.text).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + } + }); + + test('les entités HTML sont décodées', () => { + const allText = doc.cues.map((c) => c.text).join(' '); + expect(allText).not.toContain('''); + expect(allText).toContain("'"); // apostrophes décodées + }); + + test('les labels SXX: sont absents du texte', () => { + for (const cue of doc.cues) { + expect(cue.text).not.toMatch(/^S\d+\s*:/); + } + }); + + test('chaque cue a un mot pour le timestamps word-level', () => { + for (const cue of doc.cues) { + expect(cue.words.length).toBeGreaterThan(0); + expect(cue.words[0].time).toBe(cue.startTime); + } + }); +}); + +// ---- Parsing HTML juste-leblanc -------------------------------------------- + +describe('NoScribeHtmlParser — juste-leblanc.html', () => { + let doc: ReturnType; + + beforeAll(() => { + const content = fs.readFileSync(HTML_FIXTURE, 'utf-8'); + doc = new NoScribeHtmlParser().parse(content); + }); + + test('produit au moins 8 cues', () => { + expect(doc.cues.length).toBeGreaterThanOrEqual(8); + }); + + test('détecte S00 et S01', () => { + const speakers = new Set(doc.cues.map((c) => c.speaker).filter(Boolean)); + expect(speakers.has('S00')).toBe(true); + expect(speakers.has('S01')).toBe(true); + }); + + test('premier cue : S00, contient "Juste Leblanc"', () => { + const first = doc.cues.find((c) => c.speaker === 'S00'); + expect(first?.text).toContain('Juste Leblanc'); + }); + + test('les labels SXX: sont absents du texte', () => { + for (const cue of doc.cues) { + expect(cue.text).not.toMatch(/^S\d+\s*:/); + } + }); + + test('les timestamps [HH:MM:SS] sont absents du texte', () => { + for (const cue of doc.cues) { + expect(cue.text).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + } + }); + + test('audio source extrait correctement', () => { + const content = fs.readFileSync(HTML_FIXTURE, 'utf-8'); + expect(NoScribeHtmlParser.extractAudioSource(content)).toMatch(/juste-leblanc\.mp3$/); + }); +}); + +// ---- Markdown produit ------------------------------------------------------- + +describe('noScribeHtmlToMarkdown — juste-leblanc', () => { + test('format clean : pas de commentaires HTML', () => { + const content = fs.readFileSync(HTML_FIXTURE, 'utf-8'); + const doc = new NoScribeHtmlParser().parse(content); + const md = noScribeHtmlToMarkdown(doc, 'juste-leblanc.html'); + expect(md).toContain('pseudobs-format: html'); + expect(md).not.toContain(' 00:00:05.800 +Bonjour, je m'appelle Marie. + +2 +00:00:06.100 --> 00:00:09.300 +Si je me présente ? + +3 +00:00:09.800 --> 00:00:11.200 +(..) +`; + +const NOSCRIBE_HTML_FIXTURE = ` + +`; + +describe('vttToMarkdown — format noScribe', () => { + let md: string; + beforeAll(() => { md = vttToMarkdown(new VttParser().parse(VTT_FIXTURE), 'entretien.vtt'); }); + + it('frontmatter pseudobs-format: vtt', () => { + expect(md).toContain('pseudobs-format: vtt'); + }); + + it('locuteur en gras suivi du timestamp [HH:MM:SS]', () => { + expect(md).toContain('**Expérimentateur** [00:00:01]'); + expect(md).toContain('**SC01** [00:00:06]'); + }); + + it('texte précédé de " : " quand il y a un locuteur', () => { + expect(md).toContain('**Expérimentateur** [00:00:01] : Bonjour'); + }); + + it('cue sans locuteur : timestamp seul', () => { + expect(md).toContain('[00:00:09] (..)'); + expect(md).not.toMatch(/\*\*\*\* \[/); + }); + + it('aucun commentaire HTML dans le corps', () => { + const body = md.split('---').slice(2).join('---'); + expect(body).not.toContain(' 00:00:05.800 +<00:00:01.240> Bonjour,<00:00:02.100> Marie. + +2 +00:00:06.100 --> 00:00:09.300 +Si je me présente ? +`; + + it('extrait les cues avec word timestamps', () => { + const doc = new VttParser().parse(VTT_WITH_WORDS); + const data = extractWordData(doc); + expect(data).toHaveLength(1); // cue 2 sans word timestamps exclue + expect(data[0].index).toBe(0); + expect(data[0].speaker).toBe('Expérimentateur'); + expect(data[0].startTime).toBe('00:00:01.240'); + expect(data[0].endTime).toBe('00:00:05.800'); + expect(data[0].words.some((w) => w.time !== '')).toBe(true); + }); + + it('exclut les cues sans word timestamps', () => { + const doc = new VttParser().parse(VTT_WITH_WORDS); + const data = extractWordData(doc); + expect(data.every((c) => c.words.some((w) => w.time !== ''))).toBe(true); + }); + + it('produit un JSON sérialisable', () => { + const doc = new VttParser().parse(VTT_WITH_WORDS); + const data = extractWordData(doc); + expect(() => JSON.stringify(data)).not.toThrow(); + }); +}); + +// ---- markdownToVtt ---------------------------------------------------------- + +describe('markdownToVtt — re-export VTT', () => { + const WORDS_DATA = [ + { index: 0, startTime: '00:01:28.080', endTime: '00:01:33.536', speaker: 'S00', + words: [{ text: 'original', time: '00:01:28.080' }] }, + { index: 1, startTime: '00:01:32.672', endTime: '00:01:34.816', speaker: 'S01', + words: [{ text: 'original', time: '00:01:32.672' }] }, + { index: 2, startTime: '00:01:34.816', endTime: '00:01:45.920', speaker: 'S00', + words: [{ text: 'original', time: '00:01:34.816' }] }, + ]; + + const MD_PSEUDONYMIZED = `--- +pseudobs-format: html +pseudobs-source: "juste-leblanc.html" +--- + +**S00** [00:01:28] : Je vais le faire moi-même. Il s'appelle Pierre Martin. + +**S01** [00:01:32] : Ah bon, il n'a pas de prénom ? + +**S00** [00:01:34] : Je viens de vous le dire, Pierre Martin. +`; + + it('produit un WebVTT valide', () => { + const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA); + expect(vtt).toMatch(/^WEBVTT/); + expect(vtt).toContain('00:01:28.080 --> 00:01:33.536'); + expect(vtt).toContain('00:01:32.672 --> 00:01:34.816'); + }); + + it('intègre le texte pseudonymisé', () => { + const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA); + expect(vtt).toContain('Pierre Martin'); + expect(vtt).not.toContain('Juste Leblanc'); + }); + + it('préserve les locuteurs', () => { + const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA); + expect(vtt).toContain(''); + expect(vtt).toContain(''); + }); + + it('utilise les timestamps du words.json, pas du Markdown', () => { + const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA); + // Les ms précises viennent du words.json + expect(vtt).toContain('00:01:28.080'); + expect(vtt).toContain('00:01:33.536'); + }); + + it('signale une incohérence si le nombre de cues diffère', () => { + const { mismatch } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA.slice(0, 2)); + expect(mismatch).toBe(true); + }); + + it('pas de mismatch si les comptes sont égaux', () => { + const { mismatch } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA); + expect(mismatch).toBe(false); + }); +}); diff --git a/tests/unit/VttParser.test.ts b/tests/unit/VttParser.test.ts new file mode 100644 index 0000000..3ca6125 --- /dev/null +++ b/tests/unit/VttParser.test.ts @@ -0,0 +1,124 @@ +import { VttParser } from '../../src/parsers/VttParser'; + +const parser = new VttParser(); + +// ---- Helpers ---------------------------------------------------------------- + +function roundTrip(input: string): string { + return parser.reconstruct(parser.parse(input)); +} + +// ---- Fixtures --------------------------------------------------------------- + +const VTT_SIMPLE = `WEBVTT + +1 +00:00:01.240 --> 00:00:05.800 +Bonjour, je m'appelle Marie Dupont. + +2 +00:00:06.100 --> 00:00:09.300 +Et tu habites à Lyon ? +`; + +const VTT_SPEAKER = `WEBVTT + +1 +00:00:01.240 --> 00:00:05.800 +Bonjour, je m'appelle Marie Dupont. + +2 +00:00:06.100 --> 00:00:09.300 +Et tu habites à Lyon ? +`; + +const VTT_WORD_TIMESTAMPS = `WEBVTT + +1 +00:00:01.240 --> 00:00:05.800 +<00:00:01.240> Bonjour,<00:00:02.100> je<00:00:02.300> m'appelle<00:00:03.400> Marie<00:00:04.200> Dupont. + +2 +00:00:06.100 --> 00:00:09.300 +<00:00:06.100> Et<00:00:06.500> tu<00:00:06.800> habites<00:00:07.200> à<00:00:07.800> Lyon<00:00:08.200> ? +`; + +// ---- Tests ------------------------------------------------------------------ + +describe('VttParser — parse', () => { + test('VTT simple : 2 cues sans speaker', () => { + const doc = parser.parse(VTT_SIMPLE); + expect(doc.cues).toHaveLength(2); + expect(doc.cues[0].startTime).toBe('00:00:01.240'); + expect(doc.cues[0].endTime).toBe('00:00:05.800'); + expect(doc.cues[0].text).toContain('Marie Dupont'); + expect(doc.cues[0].speaker).toBeUndefined(); + }); + + test('VTT avec speakers : extraction correcte', () => { + const doc = parser.parse(VTT_SPEAKER); + expect(doc.cues[0].speaker).toBe('SPEAKER_1'); + expect(doc.cues[1].speaker).toBe('SPEAKER_2'); + expect(doc.cues[0].text).toContain('Marie Dupont'); + expect(doc.cues[0].text).not.toContain(' { + const doc = parser.parse(VTT_WORD_TIMESTAMPS); + const cue = doc.cues[0]; + expect(cue.speaker).toBe('SPEAKER_1'); + expect(cue.words.length).toBeGreaterThan(1); + expect(cue.words.some(w => w.time === '00:00:03.400')).toBe(true); + // Le mot "Marie" doit être associé au timestamp 00:00:03.400 + const marie = cue.words.find(w => w.time === '00:00:03.400'); + expect(marie?.text).toContain('Marie'); + }); + + test('Header WEBVTT reconnu', () => { + const doc = parser.parse(VTT_SIMPLE); + expect(doc.cues.length).toBeGreaterThan(0); + }); + + test('Timestamps normalisés en HH:MM:SS.mmm', () => { + const vtt = `WEBVTT\n\n01:23.456 --> 01:27.800\nTest\n`; + const doc = parser.parse(vtt); + expect(doc.cues[0].startTime).toBe('00:01:23.456'); + expect(doc.cues[0].endTime).toBe('00:01:27.800'); + }); +}); + +describe('VttParser — round-trip', () => { + test('VTT simple : round-trip exact', () => { + const doc = parser.parse(VTT_SIMPLE); + const out = parser.reconstruct(doc); + expect(out).toContain('WEBVTT'); + expect(out).toContain('00:00:01.240 --> 00:00:05.800'); + expect(out).toContain('Marie Dupont'); + }); + + test('VTT avec speakers : speaker préservé', () => { + const doc = parser.parse(VTT_SPEAKER); + const out = parser.reconstruct(doc); + expect(out).toContain(''); + expect(out).toContain(''); + }); + + test('VTT word timestamps : reconstruction avec timestamps', () => { + const doc = parser.parse(VTT_WORD_TIMESTAMPS); + const out = parser.reconstruct(doc); + expect(out).toContain(''); + expect(out).toContain('00:00:03.400'); + expect(out).toContain('Marie'); + }); +}); + +describe('VttParser — pseudonymisation', () => { + test('Remplacement dans le texte reflété dans la reconstruction', () => { + const doc = parser.parse(VTT_SPEAKER); + doc.cues[0].text = doc.cues[0].text.replace('Marie Dupont', 'Sophie Martin'); + VttParser.applyTextToWords(doc.cues[0], doc.cues[0].text); + const out = parser.reconstruct(doc); + expect(out).toContain('Sophie Martin'); + expect(out).not.toContain('Marie Dupont'); + }); +}); diff --git a/versions.json b/versions.json index 159775f..9802bc5 100644 --- a/versions.json +++ b/versions.json @@ -9,5 +9,6 @@ "0.1.1": "1.7.2", "0.1.2": "1.7.2", "0.1.3": "1.7.2", - "0.1.4": "1.7.2" + "0.1.4": "1.7.2", + "0.1.5": "1.7.2" } \ No newline at end of file