mirror of
https://github.com/core-hn/pseudobsidian-ization.git
synced 2026-07-22 06:57:06 +00:00
merge: corpus UI, exceptions, rename cascade, export destination
This commit is contained in:
commit
364f392512
15 changed files with 2749 additions and 523 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
557
src/main.ts
557
src/main.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
42
src/ui/FolderSuggest.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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') : ''
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
201
styles.css
201
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue