core-hn_pseudobsidian-ization/src/ui/QuickPseudonymizeModal.ts
Axelle Abbadie eb62c50373 feat: noScribe import, scan modal candidats, exceptions surlignées
Intégration noScribe (Phase 10)
- VttParser : WebVTT standard avec word timestamps (Whisper)
- NoScribeHtmlParser : HTML Qt Rich Text noScribe, extraction locuteurs S00/S01, audio source
- NoScribeVttParser : VTT noScribe v0.7 (cues splitées, labels SXX:, entités HTML)
- TranscriptConverter : format Markdown noScribe (**S00** [HH:MM:SS] : texte), extractWordData → .words.json
- Import audio automatique depuis meta tag HTML ou dossier source VTT
- Re-export VTT pseudonymisé depuis .md + .words.json (commande "Exporter en VTT")
- Redaction.ts : caviardage par syllabes (🀫)

Scan modal avec candidats
- MappingScanReviewModal : colonne occurrences cliquable → OccurrencesContextModal
- OccurrencesContextModal : cartes ✓/✗/⚠ par occurrence, callback sans application immédiate
- Bouton "Enregistrer les exceptions" séparé d'Appliquer

Exceptions
- IgnoredOccurrence dans types.ts + ignoredOccurrences sur MappingRule
- Persistance dans mapping.json, chargement au démarrage
- Surlignage rouge (pseudobs-exception, sensible à la casse via collectExact)
- Section "Exceptions" dans l'onglet Mappings avec cartes contextuelles et suppression
- Priorité 0 dans le highlighter (gagne sur orange/bleu/vert)

Corrections
- getValidatedFor() : inclut 'partial' comme état actif
- Labels de statut clarifiés : "Actif", "Ignoré", "Partiel", "Suggéré"
- Scan button corrigé dans PseudonymizationView (occurrences manquantes dans MappingRuleResult)
- PseudonymHighlighter : collectExact définie, exceptionRanges orphelin supprimé

Tests : 149 tests, 10 suites
2026-05-17 01:25:20 +02:00

203 lines
6.9 KiB
TypeScript

import { App, Editor, EditorPosition, Modal, Notice, Setting, TFile } from 'obsidian';
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';
export class QuickPseudonymizeModal extends Modal {
private plugin: PseudObsPlugin;
private editor: Editor;
private source: string;
private from: EditorPosition;
private to: EditorPosition;
private replacement = '';
private category: EntityCategory = 'custom';
private applyScope: ApplyScope = 'file';
private suggestions: string[];
private isRedactionMode: boolean;
constructor(
app: App,
plugin: PseudObsPlugin,
editor: Editor,
prefillReplacement = '',
suggestions: string[] = [],
isRedactionMode = false,
) {
super(app);
this.plugin = plugin;
this.editor = editor;
this.source = editor.getSelection();
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');
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('h2', { text: t('quickModal.title') });
new Setting(contentEl)
.setName(t('quickModal.source'))
.setDesc(t('quickModal.sourceDesc'))
.addText((tx) => {
tx.setValue(this.source).setDisabled(true);
tx.inputEl.addClass('pseudobs-disabled-input');
});
let replacementInput: HTMLInputElement | undefined;
if (this.suggestions.length > 0) {
const suggBox = contentEl.createDiv('pseudobs-suggestions-box');
suggBox.createEl('small', { text: t('ruleModal.coulomontLabel') })
.addClass('pseudobs-suggestions-label');
const tags = suggBox.createDiv('pseudobs-suggestions-tags');
const btnEls: HTMLElement[] = [];
for (const name of this.suggestions) {
const btn = tags.createEl('button', { text: name, cls: 'pseudobs-suggestion-btn' });
btn.addEventListener('click', () => {
this.replacement = name;
if (replacementInput) {
replacementInput.value = name;
replacementInput.dispatchEvent(new Event('input'));
}
btnEls.forEach((b) => b.removeClass('pseudobs-suggestion-btn-selected'));
btn.addClass('pseudobs-suggestion-btn-selected');
});
btnEls.push(btn);
}
}
new Setting(contentEl)
.setName(t('quickModal.replaceBy'))
.addText((tx) => {
tx.setPlaceholder(t('quickModal.replacementPlaceholder'));
tx.setValue(this.replacement);
tx.onChange((v) => (this.replacement = v));
replacementInput = tx.inputEl;
});
new Setting(contentEl)
.setName(t('ruleModal.category'))
.addDropdown((d) => {
const cats: EntityCategory[] = ['first_name','last_name','full_name','place','institution','date','age','profession','custom'];
for (const cat of cats) d.addOption(cat, t(`category.${cat}`));
d.setValue('custom');
d.onChange((v) => (this.category = v as EntityCategory));
});
new Setting(contentEl)
.setName(t('quickModal.scope'))
.addDropdown((d) => {
d.addOption('file', t('quickModal.scopeFile'));
d.addOption('occurrence', t('quickModal.scopeOccurrence'));
d.setValue('file');
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())
);
window.setTimeout(() => replacementInput?.focus(), 50);
}
private async apply(): Promise<void> {
const replacement = this.replacement.trim();
if (!replacement) {
new Notice(t('ruleModal.errorMissing'));
return;
}
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice(t('notice.noActiveFile'));
return;
}
await this.saveRule(activeFile, replacement);
const s = this.plugin.settings;
const marked = s.useMarkerInExport
? `${s.markerOpen}${replacement}${s.markerClose}`
: replacement;
if (this.applyScope === 'occurrence') {
this.editor.replaceRange(marked, this.from, this.to);
new Notice(t('notice.appliedOccurrence', this.source, marked));
} else {
const count = await this.plugin.applyRuleToFile(activeFile, this.source, marked);
new Notice(t('notice.appliedFile', this.source, marked, String(count), count > 1 ? 's' : ''));
}
void this.plugin.refresh();
this.close();
}
private async saveRule(activeFile: TFile, replacement: string): Promise<void> {
const mappingPath = `${this.plugin.settings.mappingFolder}/${activeFile.basename}.mapping.json`;
let store: MappingStore;
const mappingTFile = this.app.vault.getAbstractFileByPath(mappingPath);
if (mappingTFile instanceof TFile) {
const data = JSON.parse(await this.app.vault.read(mappingTFile)) as MappingFile;
store = MappingStore.fromJSON(data);
} else {
await this.plugin.ensureFolder(this.plugin.settings.mappingFolder);
store = new MappingStore({ type: 'file', path: activeFile.path });
}
store.add({
source: this.source,
replacement,
category: this.category,
scope: { type: 'file', path: activeFile.path },
status: 'validated',
priority: 0,
createdBy: 'user',
});
const json = JSON.stringify(store.toJSON(), null, 2);
if (mappingTFile instanceof TFile) {
await this.app.vault.modify(mappingTFile, json);
} else {
await this.app.vault.create(mappingPath, json);
}
}
onClose(): void {
this.contentEl.empty();
}
}