diff --git a/ROADMAP.md b/ROADMAP.md index d0d8edd..9a0901d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -233,7 +233,7 @@ Goal: consolidate all existing features and add the EMCA-specific functions that - [ ] **`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 +- [ ] **Audio redaction export** — once terms are pseudonymized and timestamps are validated, generate an audio export where each pseudonymized occurrence is replaced by the chosen redaction signal; export as WAV via Web Audio API. Three modes configurable in settings: **bleep** (sine tone, 1000 Hz), **silence**, or **white noise** - [ ] **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 **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. diff --git a/main.js b/main.js index fb392eb..a9ec73f 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", @@ -127,6 +128,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", @@ -384,6 +388,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": "", @@ -425,6 +430,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", @@ -32453,6 +32461,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)}`; @@ -32707,6 +32729,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 +32808,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 +32852,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 +32860,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 +32914,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 +32964,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) { @@ -33098,15 +33166,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() { @@ -33339,7 +33407,7 @@ var MappingScanReviewModal = class extends import_obsidian6.Modal { } const modified = applySpans(this.content, resolved); await this.app.vault.modify(this.file, modified); - void this.plugin.refreshHighlightData(); + void this.plugin.refresh(); const total = resolved.length; new import_obsidian6.Notice(t( "notice.occurrencesPseudonymized", @@ -33429,6 +33497,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(); @@ -33701,10 +33774,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_obsidian7.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_obsidian7.setIcon)(nerScanIcon, "loader-circle"); + nerScanIcon.addClass("pseudobs-spin"); + try { + await this.plugin.scanCurrentFileNer(); + } finally { + nerScanBtn.removeAttribute("disabled"); + (0, import_obsidian7.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"); @@ -33787,6 +33874,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; @@ -33837,6 +33954,7 @@ 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; @@ -33866,9 +33984,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 +33997,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}`, @@ -34415,7 +34534,7 @@ var DictScanReviewModal = class extends import_obsidian9.Modal { } 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(); + void this.plugin.refresh(); this.close(); } onClose() { @@ -34659,6 +34778,150 @@ 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 = []; + let 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/TranscriptConverter.ts function srtToMarkdown(doc, sourceName) { const lines = [ @@ -34716,6 +34979,27 @@ function chatToMarkdown(doc, sourceName) { lines.push(""); return lines.join("\n"); } +function vttToMarkdown(doc, sourceName) { + const lines = [ + "---", + `pseudobs-format: vtt`, + `pseudobs-source: "${sourceName}"`, + "---", + "" + ]; + for (const cue of doc.cues) { + const ts = `*${cue.startTime} \u2192 ${cue.endTime}*`; + const speaker = cue.speaker ? ` **${cue.speaker}**` : ""; + const hasWordTs = cue.words.length > 0 && cue.words.some((w) => w.time !== ""); + const wordTsComment = hasWordTs ? `` : ""; + lines.push(`${ts}${speaker} ${cue.text}${wordTsComment ? " " + wordTsComment : ""}`); + lines.push(""); + } + while (lines[lines.length - 1] === "") + lines.pop(); + lines.push(""); + return lines.join("\n"); +} function lineGroup(line) { if (line.type === "meta" || line.type === "dependent") return "structural"; @@ -34731,16 +35015,24 @@ var ScopeResolver = class { 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_obsidian11.TFile && child.name.endsWith(".mapping.json")) { + files.push(child); + } else if (child instanceof import_obsidian11.TFolder) { + files.push(...this.collectMappingFiles(child)); + } + } + return files; + } async getRulesFor(filePath) { const folder = this.vault.getAbstractFileByPath(this.mappingFolder); if (!(folder instanceof import_obsidian11.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); @@ -34765,9 +35057,7 @@ var ScopeResolver = class { if (!(folder instanceof import_obsidian11.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); @@ -34788,9 +35078,7 @@ var ScopeResolver = class { if (!(folder instanceof import_obsidian11.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); @@ -34816,9 +35104,7 @@ var ScopeResolver = class { if (!(folder instanceof import_obsidian11.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); @@ -34871,7 +35157,7 @@ var PseudonymizationEngine = class { }; // src/main.ts -var CONVERTIBLE_EXTS = ["srt", "cha", "chat"]; +var CONVERTIBLE_EXTS = ["srt", "cha", "chat", "vtt"]; var PseudObsPlugin = class extends import_obsidian12.Plugin { constructor() { super(...arguments); @@ -34880,6 +35166,10 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { // 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 +35187,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_obsidian12.MarkdownView) + this.lastMarkdownView = v; + void this.refresh(); }) ); - void this.refreshHighlightData(); + this.registerEvent( + this.app.vault.on("modify", (file) => { + if (file instanceof import_obsidian12.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(); @@ -34988,7 +35288,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { return; } editor.replaceSelection(location.rule.source); - void this.refreshHighlightData(); + void this.refresh(); }) ); } @@ -35007,6 +35307,9 @@ 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); @@ -35088,10 +35391,38 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { this.highlightData = { sources: [], replacements: [], nerCandidates }; } } - const view = this.app.workspace.getActiveViewOfType(import_obsidian12.MarkdownView); + const view = this.app.workspace.getActiveViewOfType(import_obsidian12.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 { @@ -35103,6 +35434,8 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { let mdContent; if (ext === "srt") { mdContent = srtToMarkdown(new SrtParser().parse(raw), file.name); + } else if (ext === "vtt") { + mdContent = vttToMarkdown(new VttParser().parse(raw), file.name); } else { mdContent = chatToMarkdown(new ChatParser().parse(raw), file.name); } @@ -35111,9 +35444,13 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { 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; + const mappingPath = `${mappingDir}/${basename}.mapping.json`; if (!this.app.vault.getAbstractFileByPath(mappingPath)) { - await this.ensureFolder(this.settings.mappingFolder); + await this.ensureFolder(mappingDir); const store = new MappingStore({ type: "file", path: mdPath }); await this.app.vault.create(mappingPath, JSON.stringify(store.toJSON(), null, 2)); } @@ -35131,7 +35468,7 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { openFilePicker() { const input = activeDocument.createElement("input"); input.type = "file"; - input.accept = ".srt,.cha,.chat,.txt,.md"; + input.accept = ".srt,.vtt,.cha,.chat,.txt,.md"; input.multiple = true; input.classList.add("pseudobs-hidden-input"); activeDocument.body.appendChild(input); @@ -35248,7 +35585,7 @@ var PseudObsPlugin = class extends import_obsidian12.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 import_obsidian12.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}`); @@ -35323,14 +35660,14 @@ var PseudObsPlugin = class extends import_obsidian12.Plugin { } 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`; @@ -35427,6 +35764,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/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..e51f6a0 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,51 @@ 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); + await this.app.vault.createBinary(destPath, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)); + 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 +535,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 +557,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 +683,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 +769,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 +778,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 +805,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 +897,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 +964,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..ccd4fff --- /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[] = []; + let 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..2790501 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, REDACTION_CHAR } 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'); + }); +});