merge: corpus UI, exceptions, rename cascade, export destination

This commit is contained in:
Axelle Abbadie 2026-05-17 12:42:55 +02:00
commit 364f392512
15 changed files with 2749 additions and 523 deletions

View file

@ -3,11 +3,30 @@ import process from "process";
import { builtinModules as builtins } from "module";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { copyFileSync, existsSync } from "fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const prod = process.argv[2] === "production";
const PLUGIN_DIR = "test_vault/.obsidian/plugins/pseudonymizer-tool";
// En mode dev : copie styles.css dans le vault de test après chaque rebuild.
const copyStylesPlugin = {
name: "copy-styles",
setup(build) {
build.onEnd(() => {
if (existsSync("styles.css")) {
try {
copyFileSync("styles.css", `${PLUGIN_DIR}/styles.css`);
} catch {
// vault de test absent — pas bloquant
}
}
});
},
};
const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
@ -55,9 +74,8 @@ const context = await esbuild.context({
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: prod
? "main.js"
: "test_vault/.obsidian/plugins/pseudonymizer-tool/main.js",
outfile: prod ? "main.js" : `${PLUGIN_DIR}/main.js`,
plugins: prod ? [] : [copyStylesPlugin],
});
if (prod) {

1763
main.js

File diff suppressed because it is too large Load diff

View file

@ -67,10 +67,25 @@
"notice.nerModelLoading": "Loading NER model (first use — ~66 MB)…",
"notice.ruleNotFound": "Rule not found in mappings.",
"notice.noCheckedDicts": "No dictionary checked.",
"notice.audioImported": "Audio imported: {0}",
"mappingScanModal.saveExceptions": "Save exceptions",
"mappingScanModal.exceptionsSaved": "Exceptions saved to mapping.",
"mappingScanModal.countTooltip": "View and select candidates",
"panel.mappings.exceptions": "Exceptions",
"panel.mappings.exceptions.hint": "Occurrences explicitly ignored for this rule.",
"panel.mappings.exceptions.delete": "Remove this exception",
"occurrencesCtx.hint": "{0} occurrence{1} — select the ones to replace.",
"occurrencesCtx.hint.s": "s",
"occurrencesCtx.hint.empty": "",
"occurrencesCtx.validateAll": "Validate all",
"occurrencesCtx.ignoreAll": "Ignore all",
"occurrencesCtx.cancel": "Cancel",
"occurrencesCtx.confirm": "Confirm selection",
"occurrencesCtx.btn.validate": "Validate",
"occurrencesCtx.btn.ignore": "Ignore",
"occurrencesCtx.btn.fp": "False positive",
"occurrencesCtx.status.ignored": "Kept as-is",
"occurrencesCtx.status.fp": "False positive — excluded",
"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.",
@ -87,6 +102,7 @@
"command.exportAsVtt": "Export as VTT",
"command.openPanel": "Pseudonymization: open panel",
"contextMenu.declareException": "Declare \"{0}\" as exception here",
"contextMenu.cancelPseudonymization": "Cancel pseudonymization of \"{0}\"",
"contextMenu.editRule": "Edit rule for \"{0}\"",
"contextMenu.pseudonymize": "Pseudonymize \"{0}\"",
@ -169,8 +185,43 @@
"panel.dict.scanWith": "Scan active file with \"{0}\"",
"panel.dict.remove": "Remove this dictionary",
"panel.exports.pseudonymize": "Pseudonymize and export",
"panel.tab.corpus": "Corpus",
"panel.corpus.noFolder": "Transcriptions folder not found. Configure it in settings.",
"panel.corpus.empty": "No transcriptions found in this folder.",
"panel.corpus.rules": "{0} rule(s)",
"panel.corpus.hasPseudo": "Pseudonymized version created",
"panel.corpus.noPseudo": "Not yet pseudonymized",
"panel.corpus.hasFinal": "Final {0} export present",
"panel.corpus.noFinal": "No final export",
"panel.corpus.addClass": "New class",
"panel.corpus.noClass": "No class",
"panel.corpus.moveTo": "Move to…",
"panel.corpus.moveToRoot": "No class (root)",
"panel.corpus.deleteClass": "Delete class",
"panel.corpus.deleteClassConfirm": "Delete class \"{0}\"? Files inside will be moved to root.",
"panel.corpus.exportSettings": "Final export destination",
"panel.corpus.exportDest.vault": "In the vault",
"panel.corpus.exportDest.nextToSource": "Next to source file",
"panel.corpus.exportDest.external": "External folder",
"panel.corpus.exportFolder": "Export folder (vault)",
"panel.corpus.exportExternalPath": "Absolute path",
"panel.corpus.exportExternalPathPlaceholder": "/Volumes/Archives/Exports",
"panel.corpus.exportMirrorClasses": "Mirror class structure",
"panel.filenameWarning.msg": "The filename \"{0}\" contains a pseudonymizable term.",
"panel.filenameWarning.edit": "Rename…",
"panel.filenameWarning.editTitle": "Enter a name manually",
"panel.filenameWarning.editPrompt": "New name for \"{0}\"",
"panel.filenameWarning.wandTitle": "Rename to \"{0}\"",
"panel.exports.pseudonymize": "Create pseudonymized version",
"panel.exports.exportMapping": "Export correspondence table",
"panel.exports.exportVtt": "Re-export as VTT",
"panel.exports.exportVtt.desc": "Rebuilds a pseudonymized WebVTT file from the Markdown and word timestamps (.words.json).",
"panel.exports.exportSrt": "Re-export as SRT",
"panel.exports.exportSrt.desc": "Rebuilds the pseudonymized SRT file from the Markdown.",
"panel.exports.exportCha": "Re-export as CHAT (.cha)",
"panel.exports.exportCha.desc": "Rebuilds the pseudonymized CHAT file from the Markdown.",
"command.exportAsSrt": "Export as SRT",
"command.exportAsCha": "Export as CHAT",
"panel.ner.scanBtn": "Identify candidates",
"panel.ner.hint": "Settings for the automatic named entity detection scanner.",

View file

@ -67,10 +67,25 @@
"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é.",
"notice.audioImported": "Audio importé : {0}",
"mappingScanModal.saveExceptions": "Enregistrer les exceptions",
"mappingScanModal.exceptionsSaved": "Exceptions enregistrées dans le mapping.",
"mappingScanModal.countTooltip": "Voir et sélectionner les candidats",
"panel.mappings.exceptions": "Exceptions",
"panel.mappings.exceptions.hint": "Occurrences explicitement ignorées pour cette règle.",
"panel.mappings.exceptions.delete": "Supprimer cette exception",
"occurrencesCtx.hint": "{0} occurrence{1} — sélectionnez celles à remplacer.",
"occurrencesCtx.hint.s": "s",
"occurrencesCtx.hint.empty": "",
"occurrencesCtx.validateAll": "Tout valider",
"occurrencesCtx.ignoreAll": "Tout ignorer",
"occurrencesCtx.cancel": "Annuler",
"occurrencesCtx.confirm": "Confirmer la sélection",
"occurrencesCtx.btn.validate": "Valider",
"occurrencesCtx.btn.ignore": "Ignorer",
"occurrencesCtx.btn.fp": "Faux positif",
"occurrencesCtx.status.ignored": "Conservé tel quel",
"occurrencesCtx.status.fp": "Faux positif — exclu",
"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.",
@ -87,6 +102,7 @@
"command.exportAsVtt": "Exporter en VTT",
"command.openPanel": "Pseudonymisation : ouvrir le panneau",
"contextMenu.declareException": "Déclarer \"{0}\" comme exception ici",
"contextMenu.cancelPseudonymization": "Annuler la pseudonymisation de \"{0}\"",
"contextMenu.editRule": "Modifier la règle pour \"{0}\"",
"contextMenu.pseudonymize": "Pseudonymiser \"{0}\"",
@ -169,8 +185,43 @@
"panel.dict.scanWith": "Scanner le fichier actif avec \"{0}\"",
"panel.dict.remove": "Supprimer ce dictionnaire",
"panel.exports.pseudonymize": "Pseudonymiser et exporter",
"panel.tab.corpus": "Corpus",
"panel.corpus.noFolder": "Dossier de transcriptions introuvable. Configurez-le dans les paramètres.",
"panel.corpus.empty": "Aucune transcription trouvée dans ce dossier.",
"panel.corpus.rules": "{0} règle(s)",
"panel.corpus.hasPseudo": "Version pseudonymisée créée",
"panel.corpus.noPseudo": "Pas encore pseudonymisé",
"panel.corpus.hasFinal": "Export final {0} présent",
"panel.corpus.noFinal": "Pas d'export final",
"panel.corpus.addClass": "Nouvelle classe",
"panel.corpus.noClass": "Sans classe",
"panel.corpus.moveTo": "Déplacer vers…",
"panel.corpus.moveToRoot": "Sans classe (racine)",
"panel.corpus.deleteClass": "Supprimer la classe",
"panel.corpus.deleteClassConfirm": "Supprimer la classe « {0} » ? Les fichiers à l'intérieur seront déplacés à la racine.",
"panel.corpus.exportSettings": "Destination des exports finaux",
"panel.corpus.exportDest.vault": "Dans le vault",
"panel.corpus.exportDest.nextToSource": "À côté du fichier source",
"panel.corpus.exportDest.external": "Dossier externe",
"panel.corpus.exportFolder": "Dossier d'export (vault)",
"panel.corpus.exportExternalPath": "Chemin absolu",
"panel.corpus.exportExternalPathPlaceholder": "/Volumes/Archives/Exports",
"panel.corpus.exportMirrorClasses": "Répercuter la structure de classes",
"panel.filenameWarning.msg": "Le nom de fichier « {0} » contient un terme pseudonymisable.",
"panel.filenameWarning.edit": "Renommer…",
"panel.filenameWarning.editTitle": "Saisir un nom manuellement",
"panel.filenameWarning.editPrompt": "Nouveau nom pour « {0} »",
"panel.filenameWarning.wandTitle": "Renommer en « {0} »",
"panel.exports.pseudonymize": "Créer version pseudonymisée",
"panel.exports.exportMapping": "Exporter la table de correspondance",
"panel.exports.exportVtt": "Re-exporter en VTT",
"panel.exports.exportVtt.desc": "Reconstruit un fichier WebVTT pseudonymisé à partir du Markdown et des timestamps (.words.json).",
"panel.exports.exportSrt": "Re-exporter en SRT",
"panel.exports.exportSrt.desc": "Reconstruit le fichier SRT pseudonymisé à partir du Markdown.",
"panel.exports.exportCha": "Re-exporter en CHAT (.cha)",
"panel.exports.exportCha.desc": "Reconstruit le fichier CHAT pseudonymisé à partir du Markdown.",
"command.exportAsSrt": "Exporter en SRT",
"command.exportAsCha": "Exporter en CHAT",
"panel.ner.scanBtn": "Identifier des candidats",
"panel.ner.hint": "Paramètres du scanner de détection automatique des entités nommées.",

View file

@ -4,7 +4,7 @@ import { EditorView } from '@codemirror/view';
import { PseudObsSettings, DEFAULT_SETTINGS, PseudObsSettingTab } from './settings';
import { RuleModal } from './ui/RuleModal';
import { QuickPseudonymizeModal } from './ui/QuickPseudonymizeModal';
import { createPseudonymHighlighter, highlightDataChanged, type HighlightData } from './ui/PseudonymHighlighter';
import { createPseudonymHighlighter, highlightDataChanged, type HighlightData, type ExceptionRange } from './ui/PseudonymHighlighter';
import { EditRuleModal } from './ui/EditRuleModal';
import { PseudonymizationView, VIEW_TYPE_PSEUDOBS } from './ui/PseudonymizationView';
import { OnboardingModal } from './ui/OnboardingModal';
@ -22,7 +22,7 @@ import { ChatParser } from './parsers/ChatParser';
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 { srtToMarkdown, chatToMarkdown, vttToMarkdown, noScribeHtmlToMarkdown, extractWordData, markdownToVtt, markdownToSrt, markdownToCha, type VttCueData } from './parsers/TranscriptConverter';
import { MappingStore } from './mappings/MappingStore';
import { ScopeResolver } from './mappings/ScopeResolver';
import { PseudonymizationEngine } from './pseudonymizer/PseudonymizationEngine';
@ -32,19 +32,22 @@ import type { MappingRule, MappingStatus, Occurrence } from './types';
const CONVERTIBLE_EXTS = ['srt', 'cha', 'chat', 'vtt', 'html'];
export default class PseudObsPlugin extends Plugin {
settings!: PseudObsSettings;
scopeResolver!: ScopeResolver;
nerScanner!: OnnxNerScanner;
dictionaryLoader!: DictionaryLoader;
// Cache synchrone pour le surlignage CM6 (mis à jour de façon asynchrone)
private highlightData: HighlightData = { sources: [], replacements: [], nerCandidates: [], ignoredTerms: [] };
private highlightData: HighlightData = { sources: [], replacements: [], nerCandidates: [], exceptionRanges: [] };
// 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;
// Guard contre les boucles quand le plugin renomme lui-même des fichiers liés
private _renamingRelated = false;
private _viewRefreshTimer: number | null = null;
async onload(): Promise<void> {
@ -109,6 +112,15 @@ export default class PseudObsPlugin extends Plugin {
})
);
// Watcher : cascader le renommage d'une transcription à tous ses fichiers liés
this.registerEvent(
this.app.vault.on('rename', (file: TAbstractFile, oldPath: string) => {
if (this._renamingRelated) return;
if (!(file instanceof TFile)) return;
void this.onTranscriptionRenamed(file, oldPath);
})
);
this.addCommand({
id: 'organize-corpus',
name: t('command.organizeCorpus'),
@ -159,6 +171,18 @@ export default class PseudObsPlugin extends Plugin {
callback: () => void this.exportCurrentFileAsVtt(),
});
this.addCommand({
id: 'export-as-srt',
name: t('command.exportAsSrt'),
callback: () => void this.exportCurrentFileAsFormat('srt'),
});
this.addCommand({
id: 'export-as-cha',
name: t('command.exportAsCha'),
callback: () => void this.exportCurrentFileAsFormat('cha'),
});
this.addCommand({
id: 'pseudonymize-selection',
name: t('command.pseudonymizeSelection'),
@ -186,6 +210,13 @@ export default class PseudObsPlugin extends Plugin {
const isReplacement = this.highlightData.replacements.some((r) => r.toLowerCase() === bareLower);
const isKnown = isSource || isReplacement;
// Vérifier si cette occurrence précise est déjà une exception
const editorContent = editor.getValue();
const selStart = editor.posToOffset(editor.getCursor('from'));
const isException = this.highlightData.exceptionRanges.some(
(r) => r.from === selStart && r.to === selStart + bare.length
);
const truncate = (s: string) => s.slice(0, 25) + (s.length > 25 ? '…' : '');
if (isReplacement) {
@ -202,6 +233,22 @@ export default class PseudObsPlugin extends Plugin {
);
}
// "Déclarer une exception" : visible sur les sources orange non encore exceptions
if (isSource && !isException) {
menu.addItem((item) =>
item
.setTitle(t('contextMenu.declareException', truncate(bare)))
.setIcon('ban')
.onClick(() => {
const offset = editor.posToOffset(editor.getCursor('from'));
const ctxLen = 30;
const ctxBefore = editorContent.slice(Math.max(0, offset - ctxLen), offset);
const ctxAfter = editorContent.slice(offset + bare.length, offset + bare.length + ctxLen);
void this.declareException(bare, ctxBefore, ctxAfter);
})
);
}
if (isKnown) {
menu.addItem((item) =>
item
@ -269,6 +316,413 @@ export default class PseudObsPlugin extends Plugin {
void workspace.revealLeaf(leaf);
}
/**
* Retourne le fichier actif, ou le dernier fichier Markdown ouvert si le panneau
* latéral a le focus (getActiveFile() retourne null dans ce cas).
*/
private getActiveOrLastFile(): TFile | null {
return this.app.workspace.getActiveFile() ?? this.lastMarkdownView?.file ?? null;
}
/**
* Calcule les positions des occurrences ignorées dans le texte par correspondance
* de contexte (contextBefore + text + contextAfter).
* Une seule occurrence par (text, contexte) évite les faux positifs.
*/
private findExceptionRanges(content: string, rules: MappingRule[]): ExceptionRange[] {
const ranges: ExceptionRange[] = [];
for (const rule of rules) {
for (const ignored of rule.ignoredOccurrences ?? []) {
const range = this.findByContext(content, ignored.text, ignored.contextBefore, ignored.contextAfter);
if (range) ranges.push(range);
}
}
return ranges;
}
private findByContext(
content: string,
term: string,
ctxBefore: string,
ctxAfter: string,
): ExceptionRange | null {
let pos = 0;
while (pos < content.length) {
const idx = content.indexOf(term, pos);
if (idx === -1) break;
const before = content.slice(Math.max(0, idx - ctxBefore.length), idx);
const after = content.slice(idx + term.length, idx + term.length + ctxAfter.length);
if (before.endsWith(ctxBefore) && after.startsWith(ctxAfter)) {
return { from: idx, to: idx + term.length };
}
pos = idx + 1;
}
return null;
}
/**
* Déclare une occurrence comme exception : l'ajoute à rule.ignoredOccurrences
* et sauvegarde dans le mapping.json.
*/
async declareException(term: string, contextBefore: string, contextAfter: string): Promise<void> {
const location = await this.scopeResolver.findRuleByTerm(term);
if (!location) { new Notice(t('notice.ruleNotFound')); return; }
const existing = location.rule.ignoredOccurrences ?? [];
// Ne pas dupliquer si même texte + contexte
const alreadyExists = existing.some(
(o) => o.text === term && o.contextBefore === contextBefore && o.contextAfter === contextAfter
);
if (alreadyExists) return;
location.store.update(location.rule.id, {
ignoredOccurrences: [...existing, { text: term, contextBefore, contextAfter }],
});
await this.scopeResolver.saveStore(location.store, location.filePath);
void this.refresh();
void this.refreshView();
}
/**
* Calcule le chemin de l'export final selon les paramètres de destination.
* Retourne { vaultPath } si dans le vault, { externalPath } si hors vault.
*/
resolveExportPath(
file: TFile,
ext: string,
): { vaultPath: string; externalPath: null } | { vaultPath: null; externalPath: string } {
const s = this.settings;
const rawBase = file.basename.replace(/\.pseudonymized$/, '');
const outputName = `${rawBase}.pseudonymized.${ext}`;
const fileFolder = file.parent?.path ?? '';
// Sous-dossier de classe pour le mirroring.
// Si le fichier est dans transcriptionsFolder, on le lit directement.
// Sinon (fichier pseudonymisé dans exports/), on remonte via l'emplacement
// du .mapping.json qui reflète fidèlement la structure de classes.
let classSub = '';
if (s.exportMirrorClasses) {
const transcRoot = s.transcriptionsFolder;
const fileFolder = file.parent?.path ?? '';
if (fileFolder.startsWith(transcRoot)) {
classSub = fileFolder.slice(transcRoot.length).replace(/^\//, '');
} else {
// Retrouver la classe depuis l'emplacement du mapping
const mappingFile = this.findInMappings(`${rawBase}.mapping.json`);
if (mappingFile) {
const mFolder = mappingFile.parent?.path ?? '';
const mRoot = s.mappingFolder;
if (mFolder.startsWith(mRoot)) {
classSub = mFolder.slice(mRoot.length).replace(/^\//, '');
}
}
}
}
const join = (base: string) =>
classSub ? `${base}/${classSub}/${outputName}` : `${base}/${outputName}`;
if (s.exportDestinationType === 'next-to-source') {
return { vaultPath: `${fileFolder}/${outputName}`, externalPath: null };
}
if (s.exportDestinationType === 'external' && s.exportExternalPath) {
return { vaultPath: null, externalPath: join(s.exportExternalPath) };
}
// 'vault' (défaut)
return { vaultPath: join(s.exportFinalFolder || s.exportsFolder), externalPath: null };
}
/**
* Déplace un fichier de transcription vers une nouvelle classe (sous-dossier).
* Déplace aussi .mapping.json et .words.json en miroir.
* `targetClass` = '' pour déplacer à la racine du dossier de transcriptions.
*/
async moveFileToClass(file: TFile, targetClass: string): Promise<void> {
const s = this.settings;
const transcRoot = s.transcriptionsFolder;
const mappingRoot = s.mappingFolder;
const base = file.basename;
// Nouveau chemin de la transcription
const targetFolder = targetClass ? `${transcRoot}/${targetClass}` : transcRoot;
const newMdPath = `${targetFolder}/${file.name}`;
if (newMdPath === file.path) return; // déjà en place
// Capturer le dossier source AVANT le rename (file.parent change après)
const sourceFolder = file.parent?.path ?? '';
// Déplacer le fichier .md
await this.ensureFolder(targetFolder);
await this.app.vault.rename(file, newMdPath);
// Déplacer .mapping.json en miroir
const mappingTargetDir = targetClass
? `${mappingRoot}/${targetClass}`
: mappingRoot;
await this.ensureFolder(mappingTargetDir);
for (const suffix of ['.mapping.json', '.words.json']) {
// Chercher dans tout le dossier mappings (la source peut être dans n'importe quel sous-dossier)
const found = this.findInMappings(`${base}${suffix}`);
if (found) {
const newPath = `${mappingTargetDir}/${base}${suffix}`;
if (found.path !== newPath) {
// Mettre à jour le scope.path des règles fichier après déplacement
if (suffix === '.mapping.json') {
try {
const raw = await this.app.vault.read(found);
const data = JSON.parse(raw);
let changed = false;
for (const rule of data.mappings ?? []) {
if (rule.scope?.type === 'file' && rule.scope.path === file.path) {
rule.scope.path = newMdPath;
changed = true;
}
}
if (changed) await this.app.vault.modify(found, JSON.stringify(data, null, 2));
} catch { /* ignore */ }
}
await this.app.vault.rename(found, newPath);
}
}
}
// Déplacer les fichiers audio liés à la transcription.
// Double stratégie :
// 1. Même basename que le .md (entretien_01.m4a, entretien_01.mp3…)
// 2. Nom référencé dans pseudobs-audio (peut différer du basename pour les fichiers noScribe)
{
const AUDIO_EXTS = new Set(['m4a', 'mp3', 'wav', 'ogg', 'flac', 'mp4', 'aac', 'aiff']);
const audioToMove = new Set<string>(); // chemins vault à déplacer
// Stratégie 1 : même basename
for (const ext of AUDIO_EXTS) {
const candidate = sourceFolder ? `${sourceFolder}/${base}.${ext}` : `${base}.${ext}`;
if (this.app.vault.getAbstractFileByPath(candidate) instanceof TFile) {
audioToMove.add(candidate);
}
}
// Stratégie 2 : pseudobs-audio dans le frontmatter du .md déplacé
try {
const mdFile = this.app.vault.getAbstractFileByPath(newMdPath);
if (mdFile instanceof TFile) {
const mdContent = await this.app.vault.read(mdFile);
const audioMatch = /^pseudobs-audio:\s*"([^"]+)"/m.exec(mdContent);
if (audioMatch) {
const refPath = sourceFolder ? `${sourceFolder}/${audioMatch[1]}` : audioMatch[1];
if (this.app.vault.getAbstractFileByPath(refPath) instanceof TFile) {
audioToMove.add(refPath);
}
}
}
} catch { /* ignore */ }
// Déplacer chaque fichier audio trouvé
for (const audioPath of audioToMove) {
const audioFile = this.app.vault.getAbstractFileByPath(audioPath);
if (!(audioFile instanceof TFile)) continue;
const newAudioPath = targetFolder
? `${targetFolder}/${audioFile.name}`
: audioFile.name;
if (audioPath !== newAudioPath) {
await this.app.vault.rename(audioFile, newAudioPath);
}
}
}
void this.refresh();
}
/**
* Vérifie si le basename d'un fichier contient des termes sources de règles actives.
* Retourne un nom neutre suggéré (transcript_N) si le nom est problématique, null sinon.
*/
async suggestCorrectedFilename(file: TFile): Promise<string | null> {
const rules = await this.scopeResolver.getRulesFor(file.path);
if (rules.length === 0) return null;
const basenameLower = file.basename.toLowerCase();
const hasProblem = rules.some((r) => basenameLower.includes(r.source.toLowerCase()));
if (!hasProblem) return null;
// Proposer un nom neutre transcript_N, N = prochain entier libre dans le même dossier
const folder = file.parent;
const existing = new Set(
(folder?.children ?? [])
.filter((c): c is TFile => c instanceof TFile)
.map((c) => c.basename.toLowerCase())
);
let n = 1;
while (existing.has(`transcript_${n}`)) n++;
return `transcript_${n}`;
}
/**
* Renomme un fichier de transcription ET tous ses fichiers liés.
* Déclenché depuis la bannière de warning du panneau.
*/
async renameFileAndRelated(file: TFile, newBasename: string): Promise<void> {
const oldBase = file.basename;
const folder = file.parent?.path ?? '';
const newPath = folder ? `${folder}/${newBasename}.${file.extension}` : `${newBasename}.${file.extension}`;
this._renamingRelated = true;
try {
await this.app.vault.rename(file, newPath);
const newFile = this.app.vault.getAbstractFileByPath(newPath);
if (newFile instanceof TFile) {
await this.cascadeRelatedRename(oldBase, newBasename, newFile, file.path);
}
} finally {
this._renamingRelated = false;
}
void this.refresh();
}
/**
* Écouteur vault.rename déclenché par tout renommage utilisateur dans Obsidian.
* Cascade vers les fichiers liés si c'est une transcription.
*/
private async onTranscriptionRenamed(file: TFile, oldPath: string): Promise<void> {
const s = this.settings;
const fileFolder = file.parent?.path ?? '';
const transcRoot = s.transcriptionsFolder;
// Uniquement les fichiers de transcription (dans transcriptionsFolder)
if (fileFolder !== transcRoot && !fileFolder.startsWith(`${transcRoot}/`)) return;
if (!['md', 'srt', 'cha', 'chat', 'txt', 'vtt'].includes(file.extension.toLowerCase())) return;
const oldFilename = oldPath.split('/').pop() ?? '';
const oldBase = oldFilename.replace(/\.[^.]+$/, '');
const newBase = file.basename;
if (oldBase === newBase) return; // simple déplacement de dossier, pas un renommage
this._renamingRelated = true;
try {
await this.cascadeRelatedRename(oldBase, newBase, file, oldPath);
} finally {
this._renamingRelated = false;
}
void this.refresh();
}
/**
* Cascade un renommage de basename sur tous les fichiers liés :
* frontmatter, .mapping.json, .words.json, exports, audio, source originale.
*/
private async cascadeRelatedRename(
oldBase: string,
newBase: string,
file: TFile,
oldFilePath: string,
): Promise<void> {
const s = this.settings;
const fileFolder = file.parent?.path ?? '';
// 1. Mettre à jour le frontmatter pseudobs-source dans le fichier renommé
if (file.extension === 'md') {
try {
const content = await this.app.vault.read(file);
const updated = content.replace(
/^pseudobs-source:\s*"[^"]*"/m,
`pseudobs-source: "${newBase}.${file.extension}"`
);
if (updated !== content) await this.app.vault.modify(file, updated);
} catch { /* ignore */ }
}
// 2. Mapping et words.json
for (const suffix of ['.mapping.json', '.words.json']) {
const found = this.findInMappings(`${oldBase}${suffix}`);
if (!found) continue;
const newMappingPath = found.path.replace(`${oldBase}${suffix}`, `${newBase}${suffix}`);
if (suffix === '.mapping.json') {
try {
const raw = await this.app.vault.read(found);
const data = JSON.parse(raw);
for (const rule of data.mappings ?? []) {
if (rule.scope?.type === 'file' && rule.scope.path === oldFilePath) {
rule.scope.path = file.path;
}
}
await this.app.vault.modify(found, JSON.stringify(data, null, 2));
} catch { /* ignore */ }
}
if (found.path !== newMappingPath) await this.app.vault.rename(found, newMappingPath);
}
// 3. Exports pseudonymisés
const EXPORT_EXTS = ['md', 'vtt', 'srt', 'cha'];
for (const root of [s.exportsFolder, s.exportFinalFolder].filter(Boolean)) {
for (const ext of EXPORT_EXTS) {
const exportFile = this.app.vault.getAbstractFileByPath(`${root}/${oldBase}.pseudonymized.${ext}`);
if (!(exportFile instanceof TFile)) continue;
const newExportPath = `${root}/${newBase}.pseudonymized.${ext}`;
await this.app.vault.rename(exportFile, newExportPath);
if (ext === 'md') {
const ef = this.app.vault.getAbstractFileByPath(newExportPath);
if (ef instanceof TFile) {
try {
const c = await this.app.vault.read(ef);
const u = c.replace(/^pseudobs-source:\s*"[^"]*"/m, `pseudobs-source: "${newBase}.${file.extension}"`);
if (u !== c) await this.app.vault.modify(ef, u);
} catch { /* ignore */ }
}
}
}
}
// 4. Fichiers audio avec le même basename dans le même dossier
const AUDIO_EXTS = ['m4a', 'mp3', 'wav', 'ogg', 'flac', 'mp4', 'aac', 'aiff'];
for (const ext of AUDIO_EXTS) {
const audioPath = fileFolder ? `${fileFolder}/${oldBase}.${ext}` : `${oldBase}.${ext}`;
const audioFile = this.app.vault.getAbstractFileByPath(audioPath);
if (!(audioFile instanceof TFile)) continue;
const newAudioName = `${newBase}.${ext}`;
const newAudioPath = fileFolder ? `${fileFolder}/${newAudioName}` : newAudioName;
await this.app.vault.rename(audioFile, newAudioPath);
// Mettre à jour pseudobs-audio dans le frontmatter du .md
if (file.extension === 'md') {
try {
const c = await this.app.vault.read(file);
const u = c.replace(/^pseudobs-audio:\s*"[^"]*"/m, `pseudobs-audio: "${newAudioName}"`);
if (u !== c) await this.app.vault.modify(file, u);
} catch { /* ignore */ }
}
}
// 5. Fichier source originale (même basename, extension différente — .srt, .vtt, .cha, .html, .yml…)
const SOURCE_EXTS = ['srt', 'vtt', 'cha', 'chat', 'html', 'txt', 'yml', 'yaml'];
for (const ext of SOURCE_EXTS) {
if (ext === file.extension) continue; // déjà le fichier renommé
const srcPath = fileFolder ? `${fileFolder}/${oldBase}.${ext}` : `${oldBase}.${ext}`;
const srcFile = this.app.vault.getAbstractFileByPath(srcPath);
if (!(srcFile instanceof TFile)) continue;
const newSrcPath = fileFolder ? `${fileFolder}/${newBase}.${ext}` : `${newBase}.${ext}`;
await this.app.vault.rename(srcFile, newSrcPath);
}
}
/** Cherche un fichier par nom dans le dossier mappings et ses sous-dossiers. */
private findInMappings(filename: string): TFile | null {
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 root = this.app.vault.getAbstractFileByPath(this.settings.mappingFolder);
return root instanceof TFolder ? search(root) : null;
}
// --- Coulmont ---
// Interroge l'outil de Baptiste Coulmont pour suggérer un prénom équivalent.
@ -302,7 +756,7 @@ export default class PseudObsPlugin extends Plugin {
async refreshHighlightData(): Promise<void> {
const file = this.app.workspace.getActiveFile();
if (!file) {
this.highlightData = { sources: [], replacements: [], nerCandidates: [], ignoredTerms: [] };
this.highlightData = { sources: [], replacements: [], nerCandidates: [], exceptionRanges: [] };
} else {
const nerCandidates = file === this.nerCandidateFile ? this.nerCandidates : [];
@ -313,7 +767,6 @@ export default class PseudObsPlugin extends Plugin {
let rules: MappingRule[];
if (file.basename.endsWith('.pseudonymized')) {
const originalBasename = file.basename.slice(0, -'.pseudonymized'.length);
// Vault/dossier + règles fichier de la source (sans filtre de scope path)
const vaultFolderRules = await this.scopeResolver.getRulesFor(file.path);
const fileRules = await this.scopeResolver.getRulesFromMappingFile(
`${originalBasename}.mapping.json`
@ -326,18 +779,19 @@ 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)
);
// Positions exactes des occurrences ignorées — calculées par correspondance de contexte
const content = await this.app.vault.read(file);
const exceptionRanges = this.findExceptionRanges(content, rules);
this.highlightData = {
sources: rules.map((r) => r.source).filter(Boolean),
replacements: rules.map((r) => r.replacement).filter(Boolean),
nerCandidates,
ignoredTerms,
exceptionRanges,
};
} catch {
this.highlightData = { sources: [], replacements: [], nerCandidates, ignoredTerms: [] };
this.highlightData = { sources: [], replacements: [], nerCandidates, exceptionRanges: [] };
}
}
@ -503,7 +957,7 @@ export default class PseudObsPlugin extends Plugin {
await this.ensureFolder(targetFolder);
const arrayBuf = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
await this.app.vault.createBinary(destPath, arrayBuf);
new Notice(`Audio importé : ${audioFilename}`);
new Notice(t('notice.audioImported', audioFilename));
return audioFilename;
} catch {
return null;
@ -596,7 +1050,7 @@ export default class PseudObsPlugin extends Plugin {
// --- Pseudonymisation ---
async pseudonymizeActiveFile(): Promise<void> {
const file = this.app.workspace.getActiveFile();
const file = this.getActiveOrLastFile();
if (!file) { new Notice(t('notice.noActiveFile')); return; }
const ext = file.extension.toLowerCase();
@ -660,7 +1114,7 @@ export default class PseudObsPlugin extends Plugin {
return;
}
const file = this.app.workspace.getActiveFile();
const file = this.getActiveOrLastFile();
if (!file) { new Notice(t('notice.noActiveFile')); return; }
const ext = file.extension.toLowerCase();
@ -698,7 +1152,7 @@ export default class PseudObsPlugin extends Plugin {
return;
}
const file = this.app.workspace.getActiveFile();
const file = this.getActiveOrLastFile();
if (!file) { new Notice(t('notice.noActiveFile')); return; }
const ext = file.extension.toLowerCase();
@ -811,7 +1265,7 @@ export default class PseudObsPlugin extends Plugin {
* Lit le .words.json correspondant pour les timestamps précis.
*/
async exportCurrentFileAsVtt(): Promise<void> {
const file = this.app.workspace.getActiveFile();
const file = this.getActiveOrLastFile();
if (!file || file.extension !== 'md') {
new Notice(t('notice.noActiveFile'));
return;
@ -843,15 +1297,68 @@ export default class PseudObsPlugin extends Plugin {
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);
await this.writeExport(file, 'vtt', vtt);
}
/**
* Re-exporte le Markdown pseudonymisé courant au format d'origine (srt ou cha).
*/
async exportCurrentFileAsFormat(targetFormat: 'srt' | 'cha'): Promise<void> {
const file = this.getActiveOrLastFile();
if (!file || file.extension !== 'md') {
new Notice(t('notice.noActiveFile'));
return;
}
const content = await this.app.vault.read(file);
const formatMatch = /^pseudobs-format:\s*(\w+)/m.exec(content);
const format = formatMatch?.[1];
const expectedFormat = targetFormat === 'cha' ? 'chat' : 'srt';
if (format !== expectedFormat) {
new Notice(t('notice.notNoScribeFormat'));
return;
}
const ext = targetFormat === 'cha' ? 'cha' : 'srt';
const outputContent = targetFormat === 'srt' ? markdownToSrt(content) : markdownToCha(content);
await this.writeExport(file, ext, outputContent);
}
/**
* Écrit le contenu d'un export final selon les paramètres de destination.
* Gère vault, next-to-source et external.
*/
private async writeExport(file: TFile, ext: string, content: string): Promise<void> {
const dest = this.resolveExportPath(file, ext);
if (dest.externalPath) {
// Export hors vault via Node.js fs
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodeFs = require('fs') as typeof import('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodePath = require('path') as typeof import('path');
await nodeFs.promises.mkdir(nodePath.dirname(dest.externalPath), { recursive: true });
await nodeFs.promises.writeFile(dest.externalPath, content, 'utf-8');
new Notice(t('notice.vttExported', dest.externalPath));
} catch (e) {
new Notice(`Export error: ${(e as Error).message}`);
}
return;
}
// Export dans le vault
const outputPath = dest.vaultPath!;
const dir = outputPath.includes('/') ? outputPath.slice(0, outputPath.lastIndexOf('/')) : '';
if (dir) await this.ensureFolder(dir);
const existing = this.app.vault.getAbstractFileByPath(outputPath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
} else {
await this.app.vault.create(outputPath, content);
}
new Notice(t('notice.vttExported', outputPath));
}
@ -878,7 +1385,7 @@ export default class PseudObsPlugin extends Plugin {
}
private async scanCurrentFile(): Promise<void> {
const file = this.app.workspace.getActiveFile();
const file = this.getActiveOrLastFile();
if (!file) { new Notice(t('notice.noActiveFile')); return; }
const rules = await this.scopeResolver.getRulesFor(file.path);
@ -971,7 +1478,7 @@ export default class PseudObsPlugin extends Plugin {
* Appelé automatiquement à la suppression d'une règle dans EditRuleModal.
*/
async revertRuleInFile(source: string, replacement: string): Promise<void> {
const file = this.app.workspace.getActiveFile();
const file = this.getActiveOrLastFile();
if (!file) return;
const s = this.settings;

View file

@ -75,6 +75,89 @@ export function chatToMarkdown(doc: ChatDocument, sourceName: string): string {
// ---- VTT / noScribe HTML ---------------------------------------------------
// ---- Re-exports format d'origine -------------------------------------------
/** Extrait le corps d'un Markdown (après le frontmatter ---...---). */
function extractBody(mdContent: string): string {
const m = /^---\n[\s\S]*?\n---\n+([\s\S]*)$/.exec(mdContent);
return m ? m[1] : mdContent;
}
/**
* Reconstruit un fichier SRT pseudonymisé depuis le Markdown (pseudobs-format: srt).
* Format attendu : **[N]** *HH:MM:SS,mmm HH:MM:SS,mmm* suivi des lignes de texte.
*/
export function markdownToSrt(mdContent: string): string {
const body = extractBody(mdContent);
const lines = body.split('\n');
// Regex d'en-tête de bloc SRT dans le Markdown
const HEADER_RE = /^\*\*\[(\d+)\]\*\* \*(.+?) → (.+?)\*$/;
const parts: string[] = [];
let currentBlock: { index: string; start: string; end: string; lines: string[] } | null = null;
const flush = () => {
if (!currentBlock) return;
parts.push(currentBlock.index);
parts.push(`${currentBlock.start} --> ${currentBlock.end}`);
parts.push(...currentBlock.lines);
parts.push('');
};
for (const line of lines) {
const m = HEADER_RE.exec(line);
if (m) {
flush();
currentBlock = { index: m[1], start: m[2], end: m[3], lines: [] };
} else if (currentBlock) {
currentBlock.lines.push(line);
}
}
flush();
// Retirer les lignes vides de fin de chaque bloc sauf la dernière
return parts.join('\n');
}
/**
* Reconstruit un fichier CHAT (.cha) pseudonymisé depuis le Markdown (pseudobs-format: chat).
* Format attendu :
* > @ligne-meta @ligne-meta
* **SPEAKER** : texte *SPEAKER:\ttexte
*/
export function markdownToCha(mdContent: string): string {
const body = extractBody(mdContent);
const lines = body.split('\n');
const BLOCKQUOTE_RE = /^> (.*)$/;
const TURN_RE = /^\*\*([^*]+)\*\* : (.*)$/;
const out: string[] = [];
for (const line of lines) {
const bq = BLOCKQUOTE_RE.exec(line);
if (bq) {
out.push(bq[1]);
continue;
}
const turn = TURN_RE.exec(line);
if (turn) {
out.push(`*${turn[1]}:\t${turn[2]}`);
continue;
}
// Lignes vides : préserver pour séparer les blocs
if (line.trim() === '') continue;
// Texte de continuation (rare)
out.push(line);
}
// Assurer @End en dernière ligne si présent
while (out.length > 0 && out[out.length - 1] === '') out.pop();
out.push('');
return out.join('\n');
}
/** Données word-level d'une cue — stockées dans <basename>.words.json. */
export interface VttCueData {
index: number;

View file

@ -45,16 +45,22 @@ export function findSpansForRule(
const haystack = settings.caseSensitive ? text : text.toLowerCase();
const sourceLen = needle.length;
// Textes exacts des occurrences explicitement ignorées (sensibles à la casse)
const ignoredTexts = new Set((rule.ignoredOccurrences ?? []).map((o) => o.text));
let pos = 0;
while (pos <= haystack.length - sourceLen) {
const idx = haystack.indexOf(needle, pos);
if (idx === -1) break;
if (!settings.wholeWordOnly || isWholeWord(text, idx, idx + sourceLen)) {
const actualText = text.slice(idx, idx + sourceLen);
if ((!settings.wholeWordOnly || isWholeWord(text, idx, idx + sourceLen))
&& !ignoredTexts.has(actualText)) {
spans.push({
start: idx,
end: idx + sourceLen,
source: text.slice(idx, idx + sourceLen),
source: actualText,
replacement: rule.replacement,
mappingId: rule.id,
priority: rule.priority,

View file

@ -1,9 +1,11 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import { FolderSuggest } from './ui/FolderSuggest';
import type PseudObsPlugin from './main';
import { OnboardingModal } from './ui/OnboardingModal';
import { t, setLocale, AVAILABLE_LANGUAGES } from './i18n';
export type NerBackend = 'none' | 'spacy' | 'transformers-js';
export type ExportDestinationType = 'vault' | 'next-to-source' | 'external';
export interface PseudObsSettings {
transcriptionsFolder: string;
@ -20,6 +22,11 @@ export interface PseudObsSettings {
useMarkerInExport: boolean;
markerOpen: string;
markerClose: string;
// Destination des exports finaux (VTT, SRT, CHA)
exportDestinationType: ExportDestinationType;
exportFinalFolder: string; // vault path (si type === 'vault')
exportExternalPath: string; // chemin absolu hors vault (si type === 'external')
exportMirrorClasses: boolean; // répercuter la structure de classes dans le dossier d'export
// Langue de l'interface
language: string;
// Onboarding
@ -46,6 +53,10 @@ export const DEFAULT_SETTINGS: PseudObsSettings = {
useMarkerInExport: true,
markerOpen: '{{',
markerClose: '}}',
exportDestinationType: 'vault',
exportFinalFolder: '_pseudonymisation/exports',
exportExternalPath: '',
exportMirrorClasses: false,
language: 'en',
onboardingCompleted: false,
nerBackend: 'none',
@ -209,41 +220,45 @@ export class PseudObsSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName(t('settings.transcriptionsFolder'))
.setDesc(t('settings.transcriptionsFolderDesc'))
.addText((text) =>
text.setValue(this.plugin.settings.transcriptionsFolder).onChange(async (value) => {
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setValue(this.plugin.settings.transcriptionsFolder).onChange(async (value) => {
this.plugin.settings.transcriptionsFolder = value;
await this.plugin.saveSettings();
})
);
});
});
new Setting(containerEl)
.setName(t('settings.mappingFolder'))
.setDesc(t('settings.mappingFolderDesc'))
.addText((text) =>
text.setValue(this.plugin.settings.mappingFolder).onChange(async (value) => {
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setValue(this.plugin.settings.mappingFolder).onChange(async (value) => {
this.plugin.settings.mappingFolder = value;
await this.plugin.saveSettings();
})
);
});
});
new Setting(containerEl)
.setName(t('settings.dictionariesFolder'))
.setDesc(t('settings.dictionariesFolderDesc'))
.addText((text) =>
text.setValue(this.plugin.settings.dictionariesFolder).onChange(async (value) => {
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setValue(this.plugin.settings.dictionariesFolder).onChange(async (value) => {
this.plugin.settings.dictionariesFolder = value;
await this.plugin.saveSettings();
})
);
});
});
new Setting(containerEl)
.setName(t('settings.exportsFolder'))
.addText((text) =>
text.setValue(this.plugin.settings.exportsFolder).onChange(async (value) => {
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setValue(this.plugin.settings.exportsFolder).onChange(async (value) => {
this.plugin.settings.exportsFolder = value;
await this.plugin.saveSettings();
})
);
});
});
// ---- Sécurité -------------------------------------------------------
new Setting(containerEl).setName(t('settings.heading.security')).setHeading();

42
src/ui/FolderSuggest.ts Normal file
View file

@ -0,0 +1,42 @@
import { AbstractInputSuggest, App, TFolder } from 'obsidian';
/**
* Autocomplete sur les dossiers du vault.
* Usage :
* new FolderSuggest(app, inputEl).onFolderSelect(folder => { ... });
*/
export class FolderSuggest extends AbstractInputSuggest<TFolder> {
private folderSelectCallback: ((folder: TFolder) => void) | null = null;
constructor(app: App, inputEl: HTMLInputElement) {
super(app, inputEl);
}
onFolderSelect(cb: (folder: TFolder) => void): this {
this.folderSelectCallback = cb;
return this;
}
getSuggestions(query: string): TFolder[] {
const lower = query.toLowerCase();
const results: TFolder[] = [];
const traverse = (folder: TFolder) => {
if (folder.path.toLowerCase().contains(lower)) results.push(folder);
for (const child of folder.children) {
if (child instanceof TFolder) traverse(child);
}
};
traverse(this.app.vault.getRoot());
return results.slice(0, 20);
}
renderSuggestion(folder: TFolder, el: HTMLElement): void {
el.setText(folder.isRoot() ? '/' : folder.path);
}
selectSuggestion(folder: TFolder): void {
this.setValue(folder.isRoot() ? '/' : folder.path);
this.folderSelectCallback?.(folder);
this.close();
}
}

View file

@ -39,10 +39,14 @@ export class MappingScanReviewModal extends Modal {
this.ruleResults = ruleResults;
this.checked = ruleResults.map(() => true);
// Décisions initiales : toutes les occurrences validées
// Décisions initiales : pré-marquer comme ignorées les occurrences
// dont le texte exact est déjà dans rule.ignoredOccurrences (persistées)
for (const { rule, occurrences } of ruleResults) {
const alreadyIgnored = new Set((rule.ignoredOccurrences ?? []).map((o) => o.text));
const map = new Map<string, OccurrenceDecision>();
for (const occ of occurrences) map.set(occ.id, 'validated');
for (const occ of occurrences) {
map.set(occ.id, alreadyIgnored.has(occ.text) ? 'ignored' : 'validated');
}
this.decisionsMap.set(rule.id, map);
}
}
@ -135,7 +139,7 @@ export class MappingScanReviewModal extends Modal {
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.title = t('mappingScanModal.countTooltip');
btn.addEventListener('click', () => {
new OccurrencesContextModal(

View file

@ -7,6 +7,7 @@
*/
import { App, Modal, Setting } from 'obsidian';
import { t } from '../i18n';
import type { MappingRule, Occurrence } from '../types';
export type OccurrenceDecision = 'validated' | 'ignored' | 'false_positive';
@ -48,21 +49,22 @@ export class OccurrencesContextModal extends Modal {
text: `${this.rule.source}${this.rule.replacement}`,
cls: 'pseudobs-ctx-modal-title',
});
const n = this.occurrences.length;
contentEl.createEl('p', {
text: `${this.occurrences.length} occurrence${this.occurrences.length > 1 ? 's' : ''} — sélectionnez celles à remplacer.`,
text: t('occurrencesCtx.hint', String(n), n > 1 ? t('occurrencesCtx.hint.s') : t('occurrencesCtx.hint.empty')),
cls: 'pseudobs-view-hint',
});
// Boutons globaux
new Setting(contentEl)
.addButton((b) =>
b.setButtonText('Tout valider').onClick(() => {
b.setButtonText(t('occurrencesCtx.validateAll')).onClick(() => {
for (const occ of this.occurrences) this.decisions.set(occ.id, 'validated');
this.updateAllCards();
})
)
.addButton((b) =>
b.setButtonText('Tout ignorer').onClick(() => {
b.setButtonText(t('occurrencesCtx.ignoreAll')).onClick(() => {
for (const occ of this.occurrences) this.decisions.set(occ.id, 'ignored');
this.updateAllCards();
})
@ -78,10 +80,10 @@ export class OccurrencesContextModal extends Modal {
new Setting(contentEl)
.addButton((b) =>
b.setButtonText('Annuler').onClick(() => this.close())
b.setButtonText(t('occurrencesCtx.cancel')).onClick(() => this.close())
)
.addButton((b) =>
b.setButtonText('Confirmer la sélection').setCta().onClick(() => {
b.setButtonText(t('occurrencesCtx.confirm')).setCta().onClick(() => {
this.onConfirm(new Map(this.decisions));
this.close();
})
@ -114,9 +116,9 @@ export class OccurrencesContextModal extends Modal {
const btnRefs = new Map<OccurrenceDecision, HTMLElement>();
for (const [label, value, title] of [
['✓', 'validated', 'Valider'],
['✗', 'ignored', 'Ignorer'],
['⚠', 'false_positive', 'Faux positif'],
['✓', 'validated', t('occurrencesCtx.btn.validate')],
['✗', 'ignored', t('occurrencesCtx.btn.ignore')],
['⚠', 'false_positive', t('occurrencesCtx.btn.fp')],
] as [string, OccurrenceDecision, string][]) {
const btn = actions.createEl('button', { text: label });
btn.title = title;
@ -149,7 +151,7 @@ export class OccurrencesContextModal extends Modal {
ref.resLine.toggle(show);
ref.statusLabel.toggle(!show);
ref.statusLabel.setText(
decision === 'ignored' ? 'Conservé tel quel' : decision === 'false_positive' ? 'Faux positif — exclu' : ''
decision === 'ignored' ? t('occurrencesCtx.status.ignored') : decision === 'false_positive' ? t('occurrencesCtx.status.fp') : ''
);
}

View file

@ -1,4 +1,5 @@
import { App, Modal, Notice, TFile, requestUrl, setIcon } from 'obsidian';
import { FolderSuggest } from './FolderSuggest';
import type PseudObsPlugin from '../main';
import type { NerBackend } from '../settings';
import type { DictionaryFile, DictionaryManifest, DictionaryManifestEntry } from '../types';
@ -162,6 +163,11 @@ export class OnboardingModal extends Modal {
const input = row.createEl('input');
input.type = 'text';
input.value = String(this.plugin.settings[key]);
new FolderSuggest(this.app, input).onFolderSelect(async (folder) => {
(this.plugin.settings as unknown as Record<string, unknown>)[key] = folder.path;
input.value = folder.path;
await this.plugin.saveSettings();
});
input.addEventListener('change', () => { void (async () => {
(this.plugin.settings as unknown as Record<string, unknown>)[key] = input.value.trim() || String(this.plugin.settings[key]);
await this.plugin.saveSettings();

View file

@ -5,11 +5,13 @@ import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@
// À dispatcher sur l'EditorView après chaque refreshHighlightData().
export const highlightDataChanged = StateEffect.define<void>();
export interface ExceptionRange { from: number; to: number; }
export interface HighlightData {
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)
sources: string[]; // termes originaux encore présents → orange
replacements: string[]; // pseudonymes déjà appliqués → vert + souligné
nerCandidates: string[]; // entités NER → bleu
exceptionRanges: ExceptionRange[]; // positions exactes des occurrences ignorées → rouge
}
// Extension CodeMirror 6 qui surligne dans l'éditeur :
@ -35,9 +37,9 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens
}
private build(view: EditorView): DecorationSet {
const { sources, replacements, nerCandidates, ignoredTerms } = getData();
const { sources, replacements, nerCandidates, exceptionRanges } = getData();
if (sources.length === 0 && replacements.length === 0
&& nerCandidates.length === 0 && ignoredTerms.length === 0)
&& nerCandidates.length === 0 && exceptionRanges.length === 0)
return Decoration.none;
const text = view.state.doc.toString();
@ -62,19 +64,12 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens
}
};
// 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;
}
// Exceptions par position exacte calculée depuis le contexte
for (const { from, to } of exceptionRanges) {
if (from >= 0 && to <= text.length) {
spans.push({ from, to, cls: 'pseudobs-exception', prio: 0 });
}
};
}
// Les candidats NER ne sont affichés que s'ils n'ont pas déjà une règle active
// et ne sont pas des sous-chaînes d'une source composée connue
@ -92,8 +87,8 @@ export function createPseudonymHighlighter(getData: () => HighlightData): Extens
});
// Priorités explicites : 0 = gagne en cas de chevauchement
collectExact(ignoredTerms, 'pseudobs-exception', 0);
collect(replacements, 'pseudobs-replaced', 1);
// (exceptionRanges déjà ajoutés par position ci-dessus, prio 0)
collect(replacements, 'pseudobs-replaced', 1);
collect(sources, 'pseudobs-source', 2);
collect(freshCandidates, 'pseudobs-ner-candidate', 3);

View file

@ -1,8 +1,10 @@
import { ItemView, Notice, Setting, TFile, WorkspaceLeaf, setIcon } from 'obsidian';
import { ItemView, Notice, Setting, TFile, TFolder, WorkspaceLeaf, setIcon } from 'obsidian';
import { t } from '../i18n';
import type { DictionaryFile } from '../types';
import type PseudObsPlugin from '../main';
import { scanOccurrences } from '../scanner/OccurrenceScanner';
import { getCorpusClasses } from './CorpusModal';
import { FolderSuggest } from './FolderSuggest';
import { EditRuleModal } from './EditRuleModal';
import { RuleModal } from './RuleModal';
import { MappingScanReviewModal } from './MappingScanReviewModal';
@ -10,7 +12,7 @@ import type { MappingRuleResult } from './MappingScanReviewModal';
export const VIEW_TYPE_PSEUDOBS = 'pseudonymization-view';
type Tab = 'mappings' | 'dictionaries' | 'exports' | 'ner';
type Tab = 'mappings' | 'dictionaries' | 'exports' | 'ner' | 'corpus';
function categoryLabel(cat: string): string { return t(`category.${cat}`) || cat; }
function scopeLabel(s: string): string { return t(`scope.${s}`) || s; }
@ -28,8 +30,8 @@ export class PseudonymizationView extends ItemView {
private checkedDicts = new Set<string>();
// Onglet Mappings : filtrer sur le fichier actif (coché par défaut)
private mappingsFilterActive = true;
// Garde contre la réentrance de onFileChange (le panneau lui-même peut devenir feuille active)
private _renderingTab = false;
private filenameWarningEl: HTMLElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: PseudObsPlugin) {
super(leaf);
@ -45,17 +47,24 @@ export class PseudonymizationView extends ItemView {
root.empty();
root.addClass('pseudobs-view');
// Bannière d'avertissement nom de fichier — au-dessus des onglets
this.filenameWarningEl = root.createDiv('pseudobs-filename-warning');
this.filenameWarningEl.style.display = 'none';
const tabBar = root.createDiv('pseudobs-view-tabs');
const content = root.createDiv('pseudobs-view-content');
const tabs: [Tab, string][] = [
['mappings', t('panel.tab.mappings')],
['dictionaries', t('panel.tab.dictionaries')],
['exports', t('panel.tab.exports')],
['mappings', t('panel.tab.mappings')],
['dictionaries', t('panel.tab.dictionaries')],
];
if (this.plugin.settings.nerBackend !== 'none') {
tabs.push(['ner', t('panel.tab.ner')]);
}
tabs.push(
['corpus', t('panel.tab.corpus')],
['exports', t('panel.tab.exports')],
);
this.panes = {} as Record<Tab, HTMLElement>;
this.tabBtns = {} as Record<Tab, HTMLElement>;
@ -77,6 +86,7 @@ export class PseudonymizationView extends ItemView {
if (f) this.lastFile = f;
await this.switchTab('mappings');
void this.refreshFilenameWarning();
}
private async switchTab(tab: Tab): Promise<void> {
@ -105,7 +115,8 @@ export class PseudonymizationView extends ItemView {
if (tab === 'mappings') await this.renderMappingsTab(pane);
else if (tab === 'dictionaries') await this.renderDictionariesTab(pane);
else if (tab === 'ner') await this.renderNerTab(pane);
else this.renderExportsTab(pane);
else if (tab === 'corpus') await this.renderCorpusTab(pane);
else await this.renderExportsTab(pane);
}
private async onFileChange(): Promise<void> {
@ -117,6 +128,8 @@ export class PseudonymizationView extends ItemView {
const f = this.app.workspace.getActiveFile();
if (f) this.lastFile = f;
void this.refreshFilenameWarning();
this._renderingTab = true;
try {
await this.renderTab(this.activeTab);
@ -125,10 +138,285 @@ export class PseudonymizationView extends ItemView {
}
}
private async refreshFilenameWarning(): Promise<void> {
const el = this.filenameWarningEl;
if (!el) return;
el.empty();
const file = this.getFile();
if (!file || file.extension !== 'md') { el.style.display = 'none'; return; }
const suggested = await this.plugin.suggestCorrectedFilename(file);
if (!suggested) { el.style.display = 'none'; return; }
el.style.display = '';
// Ligne 1 : icône + message
const top = el.createDiv('pseudobs-fw-top');
setIcon(top.createSpan('pseudobs-fw-icon'), 'triangle-alert');
top.createSpan({ cls: 'pseudobs-fw-msg', text: t('panel.filenameWarning.msg', file.basename) });
// Ligne 2 : bouton édition manuelle + bouton suggestion automatique
const row = el.createDiv('pseudobs-fw-row');
// Bouton ✏ — saisie libre
const editBtn = row.createEl('button', { cls: 'pseudobs-fw-action-btn' });
setIcon(editBtn.createSpan(), 'pen-line');
editBtn.createSpan({ text: ` ${t('panel.filenameWarning.edit')}` });
editBtn.title = t('panel.filenameWarning.editTitle');
editBtn.addEventListener('click', async () => {
const newName = this.promptText(t('panel.filenameWarning.editPrompt', file.basename)) ?? '';
if (!newName.trim() || newName.trim() === file.basename) return;
await this.plugin.renameFileAndRelated(file, newName.trim());
void this.refreshFilenameWarning();
});
// Bouton ✨ — appliquer la suggestion
const wandBtn = row.createEl('button', { cls: 'pseudobs-fw-action-btn pseudobs-fw-wand-btn' });
setIcon(wandBtn.createSpan(), 'wand-sparkles');
wandBtn.createSpan({ text: ` ${suggested}.${file.extension}` });
wandBtn.title = t('panel.filenameWarning.wandTitle', `${suggested}.${file.extension}`);
wandBtn.addEventListener('click', async () => {
await this.plugin.renameFileAndRelated(file, suggested);
void this.refreshFilenameWarning();
});
}
/** Affiche un prompt natif et retourne la valeur saisie, ou null si annulé. */
private promptText(placeholder: string): string | null {
return window.prompt(placeholder) ?? null;
}
private getFile(): TFile | null {
return this.app.workspace.getActiveFile() ?? this.lastFile;
}
// ---- Onglet Corpus ---------------------------------------------
private async renderCorpusTab(el: HTMLElement): Promise<void> {
const s = this.plugin.settings;
const transcRoot = s.transcriptionsFolder;
const FINAL_EXTS = ['vtt', 'srt', 'cha', 'chat'];
const transcFolder = this.app.vault.getAbstractFileByPath(transcRoot);
if (!(transcFolder instanceof TFolder)) {
el.createEl('p', { text: t('panel.corpus.noFolder'), cls: 'pseudobs-view-hint' });
return;
}
// ---- Section destination des exports finaux --------------------
const exportSection = el.createDiv('pseudobs-corpus-export-section');
exportSection.createEl('div', { text: t('panel.corpus.exportSettings'), cls: 'pseudobs-corpus-export-heading' });
// Sélecteur de type
const typeRow = exportSection.createDiv('pseudobs-corpus-export-type-row');
for (const [val, labelKey] of [
['vault', 'panel.corpus.exportDest.vault'],
['next-to-source', 'panel.corpus.exportDest.nextToSource'],
['external', 'panel.corpus.exportDest.external'],
] as [string, string][]) {
const lbl = typeRow.createEl('label', { cls: 'pseudobs-corpus-export-type-label' });
const radio = lbl.createEl('input');
radio.type = 'radio';
radio.name = 'exportDest';
radio.value = val;
radio.checked = s.exportDestinationType === val;
radio.addEventListener('change', async () => {
s.exportDestinationType = val as typeof s.exportDestinationType;
await this.plugin.saveSettings();
void this.renderTab('corpus');
});
lbl.createSpan({ text: ` ${t(labelKey)}` });
}
// Champ dossier vault
if (s.exportDestinationType === 'vault') {
new Setting(exportSection)
.setName(t('panel.corpus.exportFolder'))
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setValue(s.exportFinalFolder).onChange(async (v) => {
s.exportFinalFolder = v;
await this.plugin.saveSettings();
});
});
}
// Champ chemin externe
if (s.exportDestinationType === 'external') {
new Setting(exportSection)
.setName(t('panel.corpus.exportExternalPath'))
.addText((txt) => {
txt.setPlaceholder(t('panel.corpus.exportExternalPathPlaceholder'));
txt.setValue(s.exportExternalPath).onChange(async (v) => {
s.exportExternalPath = v;
await this.plugin.saveSettings();
});
});
}
// Toggle miroir de classes
new Setting(exportSection)
.setName(t('panel.corpus.exportMirrorClasses'))
.addToggle((tog) =>
tog.setValue(s.exportMirrorClasses).onChange(async (v) => {
s.exportMirrorClasses = v;
await this.plugin.saveSettings();
})
);
// ---- Bouton "Nouvelle classe" -----------------------------------
const addClassBtn = exportSection.createEl('button', {
text: `+ ${t('panel.corpus.addClass')}`,
cls: 'pseudobs-corpus-add-class-btn',
});
addClassBtn.addEventListener('click', async () => {
const name = this.promptText(t('corpus.modal.classNamePlaceholder'));
if (!name) return;
await this.plugin.ensureFolder(`${transcRoot}/${name}`);
await this.plugin.ensureFolder(`${s.mappingFolder}/${name}`);
void this.renderTab('corpus');
});
// ---- Liste des fichiers par classe ----------------------------
type FileEntry = { file: TFile; ruleCount: number; hasPseudo: boolean; finalExt: string | null };
const classes = getCorpusClasses(this.app, transcRoot);
const allClasses = [null, ...classes]; // null = racine
const detectFinalExport = (base: string): string | null => {
for (const ext of FINAL_EXTS) {
// Chercher dans toutes les destinations possibles
const candidates = [
`${s.exportFinalFolder}/${base}.pseudonymized.${ext}`,
`${s.exportsFolder}/${base}.pseudonymized.${ext}`,
];
for (const c of candidates) {
if (this.app.vault.getAbstractFileByPath(c) instanceof TFile) return ext;
}
}
return null;
};
const collectFiles = async (folder: TFolder): Promise<FileEntry[]> => {
const entries: FileEntry[] = [];
for (const child of folder.children) {
if (!(child instanceof TFile)) continue;
if (!['md', 'srt', 'cha', 'chat', 'txt'].includes(child.extension.toLowerCase())) continue;
if (child.basename.endsWith('.pseudonymized')) continue;
const base = child.basename;
let ruleCount = 0;
const mappingFile = this.plugin['findInMappings']?.(`${base}.mapping.json`)
?? this.app.vault.getAbstractFileByPath(`${s.mappingFolder}/${base}.mapping.json`);
if (mappingFile instanceof TFile) {
try {
const data = JSON.parse(await this.app.vault.read(mappingFile as TFile));
ruleCount = (data.mappings ?? []).length;
} catch { /* ignore */ }
}
const pseudoMd = this.app.vault.getAbstractFileByPath(`${s.exportsFolder}/${base}.pseudonymized.md`);
entries.push({ file: child, ruleCount, hasPseudo: pseudoMd instanceof TFile, finalExt: detectFinalExport(base) });
}
return entries;
};
for (const cls of allClasses) {
const folderPath = cls ? `${transcRoot}/${cls}` : transcRoot;
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) continue;
// N'afficher les fichiers de la racine que s'ils existent
const entries = await collectFiles(folder);
// En-tête de classe
const header = el.createDiv('pseudobs-corpus-class-header');
const heading = header.createEl('span', {
text: cls ?? t('panel.corpus.noClass'),
cls: 'pseudobs-corpus-class-heading',
});
heading.style.flex = '1';
if (cls) {
// Bouton supprimer la classe
const delBtn = header.createEl('button', { cls: 'pseudobs-corpus-class-del' });
setIcon(delBtn, 'trash-2');
delBtn.title = t('panel.corpus.deleteClass');
delBtn.addEventListener('click', async () => {
if (!confirm(t('panel.corpus.deleteClassConfirm', cls))) return;
// Déplacer les fichiers à la racine
for (const { file } of entries) {
await this.plugin.moveFileToClass(file, '');
}
// Supprimer les dossiers de classe si vides
for (const root of [transcRoot, s.mappingFolder]) {
const clsFolder = this.app.vault.getAbstractFileByPath(`${root}/${cls}`);
if (clsFolder instanceof TFolder && clsFolder.children.length === 0) {
await this.app.fileManager.trashFile(clsFolder);
}
}
void this.renderTab('corpus');
});
}
if (entries.length === 0) continue;
const list = el.createDiv('pseudobs-corpus-file-list');
for (const { file, ruleCount, hasPseudo, finalExt } of entries) {
const row = list.createDiv('pseudobs-corpus-file-row');
// Vérifier si le nom contient un terme pseudonymisable (badge dans le corpus)
const suggestedName = await this.plugin.suggestCorrectedFilename(file);
// Nom — cliquable
const nameEl = row.createEl('span', {
text: file.name,
cls: `pseudobs-corpus-file-name${suggestedName ? ' pseudobs-corpus-filename-warn' : ''}`,
});
if (suggestedName) nameEl.title = `⚠ → ${suggestedName}.${file.extension}`;
nameEl.addEventListener('click', () => void this.app.workspace.getLeaf().openFile(file));
const badges = row.createDiv('pseudobs-corpus-badges');
// Règles
const rb = badges.createEl('span', { text: `${ruleCount}R`, cls: 'pseudobs-corpus-badge pseudobs-corpus-badge-rules' });
rb.title = t('panel.corpus.rules', String(ruleCount));
// Version pseudo
const pb = badges.createEl('span', { cls: `pseudobs-corpus-badge ${hasPseudo ? 'pseudobs-corpus-badge-pseudo' : 'pseudobs-corpus-badge-none'}` });
setIcon(pb, hasPseudo ? 'file-check' : 'file-x');
pb.title = hasPseudo ? t('panel.corpus.hasPseudo') : t('panel.corpus.noPseudo');
// Export final
const fb = badges.createEl('span', {
cls: `pseudobs-corpus-badge ${finalExt ? 'pseudobs-corpus-badge-final' : 'pseudobs-corpus-badge-none'}`,
text: finalExt ? finalExt.toUpperCase() : '—',
});
fb.title = finalExt ? t('panel.corpus.hasFinal', finalExt.toUpperCase()) : t('panel.corpus.noFinal');
// Bouton "Déplacer vers…"
const moveBtn = row.createEl('select', { cls: 'pseudobs-corpus-move-select' });
const defaultOpt = moveBtn.createEl('option', { text: t('panel.corpus.moveTo'), value: '__none__' });
defaultOpt.selected = true;
defaultOpt.disabled = true;
// Option racine
if (cls !== null) moveBtn.createEl('option', { text: t('panel.corpus.moveToRoot'), value: '' });
// Options classes
for (const target of classes) {
if (target !== cls) moveBtn.createEl('option', { text: target, value: target });
}
moveBtn.addEventListener('change', async () => {
const target = moveBtn.value;
if (target === '__none__') return;
await this.plugin.moveFileToClass(file, target);
void this.renderTab('corpus');
});
}
}
}
// ---- Onglet Mappings -------------------------------------------
private async renderMappingsTab(el: HTMLElement): Promise<void> {
@ -280,7 +568,7 @@ export class PseudonymizationView extends ItemView {
// Bouton supprimer
const delBtn = card.createEl('button', { cls: 'pseudobs-exception-card-del' });
setIcon(delBtn, 'x');
delBtn.title = 'Supprimer cette exception';
delBtn.title = t('panel.mappings.exceptions.delete');
delBtn.addEventListener('click', async () => {
const updated = (rule.ignoredOccurrences ?? []).filter((o) => o.text !== occ.text);
store.update(rule.id, { ignoredOccurrences: updated });
@ -403,7 +691,7 @@ export class PseudonymizationView extends ItemView {
// ---- Onglet Exports --------------------------------------------
private renderExportsTab(el: HTMLElement): void {
private async renderExportsTab(el: HTMLElement): Promise<void> {
const file = this.getFile();
if (!file) {
@ -413,13 +701,61 @@ export class PseudonymizationView extends ItemView {
el.createEl('p', { text: `${file.name}`, cls: 'pseudobs-view-filename' });
new Setting(el)
.setName(t('panel.exports.pseudonymize'))
.addButton((btn) =>
btn.setButtonText(t('panel.exports.pseudonymize')).setCta().onClick(() => {
void this.plugin.pseudonymizeActiveFile();
})
);
// Détecter si le fichier est dans le dossier exports (déjà pseudonymisé — quel que soit le format)
const exportsFolder = this.plugin.settings.exportsFolder;
const isInExports = file.path.startsWith(exportsFolder + '/')
|| file.parent?.path === exportsFolder;
if (isInExports) {
// Fichier déjà pseudonymisé.
// Pour les .md noScribe (vtt/html), proposer le re-export VTT en CTA.
if (file.extension === 'md') {
try {
const content = await this.app.vault.read(file);
const formatMatch = /^pseudobs-format:\s*(\w+)/m.exec(content);
const format = formatMatch?.[1];
if (format === 'vtt' || format === 'html') {
new Setting(el)
.setName(t('panel.exports.exportVtt'))
.setDesc(t('panel.exports.exportVtt.desc'))
.addButton((btn) =>
btn.setButtonText(t('command.exportAsVtt')).setCta().onClick(() => {
void this.plugin.exportCurrentFileAsVtt();
})
);
} else if (format === 'srt') {
new Setting(el)
.setName(t('panel.exports.exportSrt'))
.setDesc(t('panel.exports.exportSrt.desc'))
.addButton((btn) =>
btn.setButtonText(t('command.exportAsSrt')).setCta().onClick(() => {
void this.plugin.exportCurrentFileAsFormat('srt');
})
);
} else if (format === 'chat') {
new Setting(el)
.setName(t('panel.exports.exportCha'))
.setDesc(t('panel.exports.exportCha.desc'))
.addButton((btn) =>
btn.setButtonText(t('command.exportAsCha')).setCta().onClick(() => {
void this.plugin.exportCurrentFileAsFormat('cha');
})
);
}
} catch { /* lecture impossible */ }
}
// Pour les autres formats (.srt, .cha, etc.) déjà dans le dossier exports :
// le fichier est déjà au format natif — aucun bouton de pseudonymisation.
} else {
// Fichier source — proposer la pseudonymisation
new Setting(el)
.setName(t('panel.exports.pseudonymize'))
.addButton((btn) =>
btn.setButtonText(t('panel.exports.pseudonymize')).setCta().onClick(() => {
void this.plugin.pseudonymizeActiveFile();
})
);
}
new Setting(el)
.setName(t('panel.exports.exportMapping'))

View file

@ -1116,6 +1116,207 @@
margin-right: auto;
}
/* ---- Bannière avertissement nom de fichier ---- */
.pseudobs-filename-warning {
display: flex;
flex-direction: column;
gap: 5px;
padding: 7px 10px;
background: rgba(255, 160, 0, 0.1);
border-bottom: 1px solid rgba(255, 160, 0, 0.35);
font-size: 0.82em;
}
.pseudobs-fw-top { display: flex; align-items: center; gap: 6px; }
.pseudobs-fw-icon { color: var(--text-warning); flex-shrink: 0; }
.pseudobs-fw-icon svg { width: 13px; height: 13px; }
.pseudobs-fw-msg { color: var(--text-warning); line-height: 1.4; }
.pseudobs-fw-row { display: flex; align-items: center; gap: 6px; }
.pseudobs-fw-action-btn {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid rgba(255, 160, 0, 0.4);
border-radius: 4px;
padding: 2px 7px;
font-size: 0.82em;
cursor: pointer;
color: var(--text-warning);
white-space: nowrap;
}
.pseudobs-fw-action-btn svg { width: 12px; height: 12px; }
.pseudobs-fw-action-btn:hover { background: rgba(255, 160, 0, 0.15); }
.pseudobs-fw-wand-btn { font-style: italic; }
/* ---- Onglet Corpus ---- */
.pseudobs-corpus-add-class-btn {
margin-top: 6px;
font-size: 0.82em;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
cursor: pointer;
background: var(--background-secondary);
}
.pseudobs-corpus-add-class-btn:hover { background: var(--background-modifier-hover); }
.pseudobs-corpus-export-section {
background: var(--background-secondary);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 10px;
}
.pseudobs-corpus-export-heading {
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 6px;
}
.pseudobs-corpus-export-type-row {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
}
.pseudobs-corpus-export-type-label {
font-size: 0.85em;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.pseudobs-corpus-export-section .setting-item {
border: none;
padding: 4px 0;
}
.pseudobs-corpus-class-header {
display: flex;
align-items: center;
gap: 4px;
margin: 10px 0 3px;
}
.pseudobs-corpus-class-del {
background: none;
border: none;
cursor: pointer;
color: var(--text-faint);
padding: 2px;
border-radius: 3px;
opacity: 0;
transition: opacity 0.15s;
}
.pseudobs-corpus-class-header:hover .pseudobs-corpus-class-del { opacity: 1; }
.pseudobs-corpus-class-del:hover { color: var(--text-error); }
.pseudobs-corpus-move-select {
font-size: 0.75em;
padding: 1px 4px;
border-radius: 3px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
}
.pseudobs-corpus-class-heading {
font-size: 0.82em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 12px 0 4px;
padding: 0 2px;
}
.pseudobs-corpus-file-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
.pseudobs-corpus-file-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
transition: background 0.1s;
}
.pseudobs-corpus-file-row:hover {
background: var(--background-secondary);
}
.pseudobs-corpus-file-name {
flex: 1;
font-size: 0.88em;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pseudobs-corpus-file-name:hover {
text-decoration: underline;
color: var(--text-accent);
}
.pseudobs-corpus-filename-warn {
color: var(--text-warning);
}
.pseudobs-corpus-fix-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-warning);
padding: 2px 3px;
border-radius: 3px;
flex-shrink: 0;
opacity: 0.7;
}
.pseudobs-corpus-fix-btn:hover { opacity: 1; background: var(--background-modifier-error-hover); }
.pseudobs-corpus-badges {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.pseudobs-corpus-badge {
font-size: 0.72em;
padding: 1px 5px;
border-radius: 3px;
font-weight: 500;
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.pseudobs-corpus-badge svg { width: 11px; height: 11px; }
.pseudobs-corpus-badge-rules {
background: var(--background-modifier-border);
color: var(--text-muted);
}
.pseudobs-corpus-badge-pseudo {
background: rgba(60, 200, 100, 0.15);
color: rgb(40, 160, 80);
outline: 1px solid rgba(60, 200, 100, 0.35);
}
.pseudobs-corpus-badge-final {
background: rgba(80, 160, 255, 0.15);
color: rgb(60, 130, 230);
outline: 1px solid rgba(80, 160, 255, 0.35);
}
.pseudobs-corpus-badge-none {
color: var(--text-faint);
background: transparent;
}
/* Ligne checkbox caviardage (QuickPseudonymizeModal, RuleModal) */
.pseudobs-redact-row {
display: flex;