feat: i18n ru/es, export HTML noScribe word-level, fix auto-convert + export paths

- i18n: add Russian (ru) and Spanish (es) locales (~240 keys each)
- feat: markdownToNoScribeHtml — reconstruct pseudonymized noScribe HTML
  with word-level ts_ anchors via proportional index mapping on words.json;
  add export-as-html command, panel button, notice.htmlExported
- fix: autoConvert now skips files created in exports/mappings folders
  (prevented re-import of freshly written VTT/SRT/HTML exports)
- fix: pseudonymizeActiveFile respects exportMirrorClasses — intermediate
  .pseudonymized.md is now written into the correct class subfolder
- fix: extract getClassSub() helper shared by resolveExportPath and pseudonymizeActiveFile
- fix: "Nouvelle classe" button in corpus panel now opens CorpusModal
  instead of window.prompt (blocked in Electron)
- refactor: remove next-to-source export destination option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Axelle Abbadie 2026-05-20 00:07:39 +02:00
parent a3204f1f76
commit 928ff8d42b
11 changed files with 1906 additions and 91 deletions

948
main.js

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,11 @@
import en from './locales/en.json';
import es from './locales/es.json';
import fr from './locales/fr.json';
import ru from './locales/ru.json';
type Strings = Record<string, string>;
const LOCALES: Record<string, Strings> = { en, fr };
const LOCALES: Record<string, Strings> = { en, es, fr, ru };
const FALLBACK = 'en';
let _locale = FALLBACK;
@ -36,5 +38,7 @@ export function t(key: string, ...args: (string | number)[]): string {
/** Langues disponibles avec leur nom natif. */
export const AVAILABLE_LANGUAGES: Record<string, string> = {
en: 'English',
es: 'Español',
fr: 'Français',
ru: 'Русский',
};

View file

@ -201,7 +201,6 @@
"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",
@ -220,8 +219,12 @@
"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.",
"panel.exports.exportHtml": "Re-export as HTML",
"panel.exports.exportHtml.desc": "Rebuilds the pseudonymized noScribe HTML file from the Markdown and word timestamps (.words.json).",
"notice.htmlExported": "HTML exported: {0}",
"command.exportAsSrt": "Export as SRT",
"command.exportAsCha": "Export as CHAT",
"command.exportAsHtml": "Export as HTML",
"panel.ner.scanBtn": "Identify candidates",
"panel.ner.hint": "Settings for the automatic named entity detection scanner.",

378
src/i18n/locales/es.json Normal file
View file

@ -0,0 +1,378 @@
{
"corpus.modal.title": "Organizar corpus",
"corpus.modal.hint": "Cada clase crea carpetas espejo para las transcripciones, las tablas de correspondencia y los exportes.",
"corpus.modal.noClasses": "No hay clases definidas. Añada una para organizar sus documentos por carpeta.",
"corpus.modal.addClass": "Añadir una clase",
"corpus.modal.classNamePlaceholder": "Nombre de la clase (p. ej. Sesión_A, Informante_1…)",
"corpus.modal.classNameEmpty": "El nombre de la clase no puede estar vacío.",
"corpus.modal.classExists": "Ya existe una clase con este nombre.",
"corpus.modal.deleteConfirm": "¿Eliminar la clase «{0}»? Las carpetas serán retiradas del vault (los archivos dentro irán a la papelera).",
"corpus.modal.files": "{0} archivo(s)",
"corpus.modal.close": "Cerrar",
"corpus.select.title": "¿En qué clase añadir?",
"corpus.select.hint": "Elija la clase (carpeta) para esta transcripción.",
"corpus.select.none": "Sin clase (raíz)",
"corpus.select.confirm": "Añadir",
"quickModal.title": "Pseudonimizar",
"quickModal.source": "Expresión seleccionada",
"quickModal.sourceDesc": "Término a reemplazar — no modificable",
"quickModal.replaceBy": "Reemplazar por",
"quickModal.replacementPlaceholder": "Pseudónimo o categoría analítica",
"quickModal.scope": "Ámbito del reemplazo",
"quickModal.scopeFile": "Todas las ocurrencias en este archivo",
"quickModal.scopeOccurrence": "Solo esta ocurrencia",
"quickModal.submit": "Pseudonimizar",
"notice.appliedOccurrence": "✓ «{0}» → «{1}» (esta ocurrencia)",
"notice.appliedFile": "✓ «{0}» → «{1}» ({2} ocurrencia{3})",
"onboarding.dict.importOk": "{0} diccionario importado",
"onboarding.dict.importOkMany": "{0} diccionarios importados",
"onboarding.dict.importErr": "{0} archivo rechazado",
"onboarding.dict.importErrMany": "{0} archivos rechazados",
"onboarding.dict.importError": "Error al importar {0} — verifique el formato .dict.json",
"notice.noActiveFile": "No hay archivo activo.",
"notice.formatUnsupported": "Formato no compatible: .{0}",
"notice.noRules": "No hay reglas para este archivo.\nCree reglas con Ctrl+P → «Crear una regla».",
"notice.noOccurrences": "No se encontraron ocurrencias para las reglas activas.",
"notice.noNerEntities": "El NER no detectó ninguna entidad.",
"notice.nerEntitiesFound": "✓ {0} {1} detectada(s) — resaltada(s) en azul.\nClic derecho para crear una regla.",
"notice.nerEntitiesFound.entity": "entidad",
"notice.nerEntitiesFound.entities": "entidades",
"notice.nerNotEnabled": "La detección NER de transformers.js no está activada.\nActívela en Configuración → Pseudonymizer Tool.",
"notice.noDictDetection": "No hay diccionario de detección cargado.\nInstale uno desde el panel Diccionarios.",
"notice.noDictEntities": "No se encontraron entidades en los diccionarios de detección.",
"notice.noDictReplacements": "No hay reemplazos disponibles para las entidades encontradas.",
"notice.ruleDeleted": "✓ Regla eliminada — «{0}» restaurado en el archivo activo",
"notice.ruleCreated": "✓ Regla creada: «{0}» → «{1}»",
"notice.rulesCreated": "✓ {0} {1} creada(s)",
"notice.rulesCreated.rule": "regla",
"notice.rulesCreated.rules": "reglas",
"notice.occurrencesPseudonymized": "✓ {0} {1} pseudonimizada(s) en «{2}»",
"notice.occurrencesPseudonymized.occurrence": "ocurrencia",
"notice.occurrencesPseudonymized.occurrences": "ocurrencias",
"notice.converted": "✓ {0} → {1}",
"notice.conversionError": "Error de conversión de {0}: {1}",
"notice.fileExists": "El archivo ya existe en el vault: {0}",
"notice.conversionSkipped": "⚠ {0}.md ya existe — conversión omitida para {1}",
"notice.exportDone": "✓ {0} regla(s) aplicada(s)\n→ {1}",
"notice.mappingExported": "✓ Tabla de correspondencia exportada → {0}",
"notice.noMapping": "No se encontró tabla de correspondencia para {0}",
"notice.invalidFormat": "Formato no válido: {0}",
"notice.dictImported": "✓ {0} {1} importado(s)",
"notice.dictImported.one": "diccionario",
"notice.dictImported.many": "diccionarios",
"notice.dictRejected": "{0} {1} rechazado(s)",
"notice.dictRejected.one": "archivo",
"notice.dictRejected.many": "archivos",
"notice.nerModelLoaded": "✓ Modelo NER cargado",
"notice.nerModelLoading": "Cargando modelo NER (primer uso — ~66 MB)…",
"notice.ruleNotFound": "Regla no encontrada en las tablas de correspondencia.",
"notice.noCheckedDicts": "Ningún diccionario marcado.",
"notice.audioImported": "Audio importado: {0}",
"occurrencesCtx.hint": "{0} ocurrencia{1} — seleccione las que desea reemplazar.",
"occurrencesCtx.hint.s": "s",
"occurrencesCtx.hint.empty": "",
"occurrencesCtx.validateAll": "Validar todas",
"occurrencesCtx.ignoreAll": "Ignorar todas",
"occurrencesCtx.cancel": "Cancelar",
"occurrencesCtx.confirm": "Confirmar selección",
"occurrencesCtx.btn.validate": "Validar",
"occurrencesCtx.btn.ignore": "Ignorar",
"occurrencesCtx.btn.fp": "Falso positivo",
"occurrencesCtx.status.ignored": "Conservado tal cual",
"occurrencesCtx.status.fp": "Falso positivo — excluido",
"notice.notNoScribeFormat": "Este archivo no es una transcripción de noScribe (se espera pseudobs-format: vtt o html).",
"notice.wordsJsonMissing": "Archivo de marcas de tiempo no encontrado: {0}.words.json",
"notice.vttMismatch": "Aviso: el número de cues del Markdown y del words.json difiere — el VTT exportado puede estar incompleto.",
"notice.vttExported": "VTT exportado: {0}",
"command.organizeCorpus": "Organizar corpus",
"command.addTranscription": "Añadir una transcripción",
"command.pseudonymizeFile": "Pseudonimizar el archivo actual",
"command.createRule": "Crear una regla de reemplazo",
"command.scanFile": "Escanear el archivo actual",
"command.scanNer": "Escanear el archivo con detección NER",
"command.scanDictionaries": "Escanear el archivo con diccionarios",
"command.pseudonymizeSelection": "Pseudonimizar la selección",
"command.exportAsVtt": "Exportar como VTT",
"command.exportAsSrt": "Exportar como SRT",
"command.exportAsCha": "Exportar como CHAT",
"command.exportAsHtml": "Exportar como HTML",
"command.openPanel": "Pseudonimización: abrir panel",
"contextMenu.declareException": "Declarar «{0}» como excepción aquí",
"contextMenu.cancelPseudonymization": "Cancelar pseudonimización de «{0}»",
"contextMenu.editRule": "Editar regla para «{0}»",
"contextMenu.pseudonymize": "Pseudonimizar «{0}»",
"contextMenu.redact": "Redactar «{0}»",
"contextMenu.coulmont": "Pseudonimizar con el prof. Baptiste Coulmont",
"contextMenu.createRule": "Crear una regla de reemplazo…",
"redaction.checkbox": "Redactar (🀫)",
"redaction.checkboxDesc": "Reemplazar con símbolos 🀫 (uno por sílaba) — para contenido identificador no esencial",
"ruleModal.delete": "Eliminar regla",
"ruleModal.scopeWarnTitle": "Ámbito longitudinal",
"ruleModal.scopeOkTitle": "Ámbito de archivo",
"ruleModal.scopeOk": "Buena práctica. Este pseudónimo solo se aplicará a esta transcripción — cada archivo permanece independiente.",
"ruleModal.title": "Crear una regla de reemplazo",
"ruleModal.source": "Fuente",
"ruleModal.sourceDesc": "Término original a reemplazar",
"ruleModal.replacement": "Reemplazo",
"ruleModal.replacementDesc": "Pseudónimo o categoría analítica",
"ruleModal.replacementDescClass": "El índice exacto se calculará al crearlo según las reglas existentes en el ámbito.",
"ruleModal.category": "Categoría",
"ruleModal.scope": "Ámbito",
"ruleModal.scopeFile": "Solo este archivo",
"ruleModal.scopeFolder": "Esta carpeta",
"ruleModal.scopeVault": "Todo el vault",
"ruleModal.priority": "Prioridad",
"ruleModal.priorityDesc": "Entero libre, como un z-index CSS — por defecto 0, mayor = aplicado primero",
"ruleModal.submit": "Crear regla",
"ruleModal.errorMissing": "La fuente y el reemplazo son obligatorios.",
"ruleModal.errorNoFile": "No hay archivo activo.",
"ruleModal.coulomontLabel": "Sugerencias de nombres equivalentes — elija:",
"ruleModal.dictLabel": "Diccionario: «{0}» → clase {1}",
"ruleModal.dictUseClass": "Usar «{0}» (ámbito: {1})",
"category.first_name": "Nombre",
"category.last_name": "Apellido",
"category.full_name": "Nombre completo",
"category.place": "Lugar",
"category.institution": "Institución",
"category.date": "Fecha",
"category.age": "Edad",
"category.profession": "Profesión",
"category.custom": "Otro",
"scope.file": "Archivo",
"scope.folder": "Carpeta",
"scope.vault": "Vault",
"status.validated": "Activo",
"status.ignored": "Ignorado",
"status.partial": "Parcial",
"status.suggested": "Sugerido",
"status.conflict": "Conflicto",
"status.disabled": "Inactivo",
"status.needs_review": "Revisión",
"panel.tab.mappings": "Correspondencias",
"panel.tab.dictionaries": "Diccionarios",
"panel.tab.exports": "Exportes",
"panel.tab.ner": "NER",
"panel.tab.corpus": "Corpus",
"panel.mappings.filterActive": "Solo archivo activo",
"panel.mappings.warnBroadName": "⚠ Ampliar el ámbito de un nombre o apellido más allá del archivo solo debe hacerse como elección analítica deliberada — por ejemplo, para seguir de forma coherente al mismo participante en un estudio longitudinal. Incluso cuando el mismo individuo aparece en varios archivos, la pseudonimización a nivel de archivo suele ser preferible: mantiene cada transcripción independiente y reduce el riesgo de identificación cruzada.",
"panel.mappings.addRule": "Añadir una regla",
"panel.mappings.scanFile": "Escanear archivo",
"panel.mappings.scanning": "Escaneando…",
"panel.mappings.noFile": "No hay archivo activo.",
"panel.mappings.noRules": "No hay reglas para {0}.",
"panel.mappings.noRulesHint": "No hay reglas activas para este archivo. Cree reglas desde el menú contextual o el comando «Crear una regla».",
"panel.mappings.col.source": "Fuente",
"panel.mappings.col.replacement": "Reemplazo",
"panel.mappings.col.category": "Categoría",
"panel.mappings.col.scope": "Ámbito",
"panel.mappings.col.priority": "P.",
"panel.mappings.col.status": "Estado",
"panel.mappings.exceptions": "Excepciones",
"panel.mappings.exceptions.hint": "Ocurrencias explícitamente ignoradas para esta regla.",
"panel.mappings.exceptions.delete": "Eliminar esta excepción",
"panel.dict.noneInstalled": "No hay diccionarios instalados. Instale uno desde el asistente (Configuración → Reconfigurar) o importe un archivo local.",
"panel.dict.checkbox": "Incluir en el escaneo agrupado",
"panel.dict.scanAll": "Escanear diccionarios marcados",
"panel.dict.importLocal": "Importar un archivo local (.dict.json)",
"panel.dict.scanWith": "Escanear el archivo activo con «{0}»",
"panel.dict.remove": "Eliminar este diccionario",
"panel.corpus.noFolder": "Carpeta de transcripciones no encontrada. Configúrela en los ajustes.",
"panel.corpus.empty": "No se encontraron transcripciones en esta carpeta.",
"panel.corpus.rules": "{0} regla(s)",
"panel.corpus.hasPseudo": "Versión pseudonimizada creada",
"panel.corpus.noPseudo": "Aún no pseudonimizado",
"panel.corpus.hasFinal": "Exporte final {0} presente",
"panel.corpus.noFinal": "Sin exporte final",
"panel.corpus.addClass": "Nueva clase",
"panel.corpus.noClass": "Sin clase",
"panel.corpus.moveTo": "Mover a…",
"panel.corpus.moveToRoot": "Sin clase (raíz)",
"panel.corpus.deleteClass": "Eliminar clase",
"panel.corpus.deleteClassConfirm": "¿Eliminar la clase «{0}»? Los archivos dentro se moverán a la raíz.",
"panel.corpus.exportSettings": "Destino del exporte final",
"panel.corpus.exportDest.vault": "En el vault",
"panel.corpus.exportDest.external": "Carpeta externa",
"panel.corpus.exportFolder": "Carpeta de exporte (vault)",
"panel.corpus.exportExternalPath": "Ruta absoluta",
"panel.corpus.exportExternalPathPlaceholder": "/Volumes/Archives/Exports",
"panel.corpus.exportMirrorClasses": "Replicar estructura de clases",
"panel.filenameWarning.msg": "El nombre de archivo «{0}» contiene un término pseudonimizable.",
"panel.filenameWarning.edit": "Renombrar…",
"panel.filenameWarning.editTitle": "Introduzca un nombre manualmente",
"panel.filenameWarning.editPrompt": "Nuevo nombre para «{0}»",
"panel.filenameWarning.wandTitle": "Renombrar a «{0}»",
"panel.exports.pseudonymize": "Crear versión pseudonimizada",
"panel.exports.exportMapping": "Exportar tabla de correspondencia",
"panel.exports.exportVtt": "Reexportar como VTT",
"panel.exports.exportVtt.desc": "Reconstruye un archivo WebVTT pseudonimizado desde el Markdown y las marcas de tiempo (.words.json).",
"panel.exports.exportSrt": "Reexportar como SRT",
"panel.exports.exportSrt.desc": "Reconstruye el archivo SRT pseudonimizado desde el Markdown.",
"panel.exports.exportCha": "Reexportar como CHAT (.cha)",
"panel.exports.exportCha.desc": "Reconstruye el archivo CHAT pseudonimizado desde el Markdown.",
"panel.exports.exportHtml": "Reexportar como HTML",
"panel.exports.exportHtml.desc": "Reconstruye el archivo HTML noScribe pseudonimizado desde el Markdown y las marcas de tiempo (.words.json).",
"notice.htmlExported": "HTML exportado: {0}",
"panel.ner.scanBtn": "Identificar candidatos",
"panel.ner.hint": "Ajustes del escáner de detección automática de entidades nombradas.",
"panel.ner.threshold": "Umbral de confianza",
"panel.ner.thresholdDesc": "Las entidades con una puntuación inferior a este valor se ignoran. Mayor = menos falsos positivos pero menos detecciones.",
"panel.ner.functionWords": "Palabras funcionales excluidas",
"panel.ner.functionWordsDesc": "Una palabra por línea. Estos tokens nunca se conservarán como entidades nombradas, aunque el modelo los detecte.",
"panel.ner.save": "Guardar",
"panel.ner.saved": "Guardado",
"panel.ner.reset": "Restablecer valores predeterminados",
"dictScanModal.title": "Revisión del escaneo — diccionarios",
"dictScanModal.summary": "{0} {1} detectada(s) · {2}",
"dictScanModal.summary.entity": "entidad",
"dictScanModal.summary.entities": "entidades",
"dictScanModal.hint": "Desmarque los falsos positivos. El prefijo es editable; el índice se calcula automáticamente.",
"dictScanModal.apply": "Crear {0} {1}",
"dictScanModal.apply.rule": "regla",
"dictScanModal.apply.rules": "reglas",
"dictScanModal.noRules": "No hay reglas que aplicar",
"dictScanModal.cancel": "Cancelar",
"mappingScanModal.title": "Revisión del escaneo — correspondencias",
"mappingScanModal.summary": "{0} {1} con ocurrencias en «{2}»",
"mappingScanModal.summary.rule": "regla activa",
"mappingScanModal.summary.rules": "reglas activas",
"mappingScanModal.hint": "Desmarque las reglas que no desea aplicar. Los reemplazos se escribirán en el archivo fuente.",
"mappingScanModal.col.source": "",
"mappingScanModal.col.replacement": "Reemplazo",
"mappingScanModal.col.occurrences": "Ocur.",
"mappingScanModal.apply": "Pseudonimizar ({0} {1}, {2} {3})",
"mappingScanModal.apply.rule": "regla",
"mappingScanModal.apply.rules": "reglas",
"mappingScanModal.apply.occurrence": "ocurrencia",
"mappingScanModal.apply.occurrences": "ocurrencias",
"mappingScanModal.noRules": "No hay reglas que aplicar",
"mappingScanModal.cancel": "Cancelar",
"mappingScanModal.saveExceptions": "Guardar excepciones",
"mappingScanModal.exceptionsSaved": "Excepciones guardadas en la tabla de correspondencia.",
"mappingScanModal.countTooltip": "Ver y seleccionar candidatos",
"onboarding.step.storage": "Almacenamiento",
"onboarding.step.language": "Idioma",
"onboarding.step.welcome": "Bienvenida",
"onboarding.step.ner": "Detección NER",
"onboarding.step.dictionaries": "Diccionarios",
"onboarding.step.summary": "Resumen",
"onboarding.welcome.title": "Pseudonymizer Tool",
"onboarding.welcome.desc": "Este plugin le ayuda a corregir y pseudonimizar transcripciones de entrevistas y corpus interaccionales (convenciones Jefferson, ICOR, formatos .srt, .cha).",
"onboarding.welcome.steps": "El asistente le guiará en unos pocos pasos rápidos:",
"onboarding.welcome.step1": "Elija el idioma de la interfaz.",
"onboarding.welcome.step2": "Elija un motor de detección automática de entidades nombradas (opcional).",
"onboarding.welcome.step3": "Instale diccionarios de candidatos de reemplazo si los necesita.",
"onboarding.welcome.hint": "Puede reconfigurar estas opciones en cualquier momento desde los ajustes del plugin.",
"onboarding.storage.title": "Almacenamiento",
"onboarding.storage.vaultPerCorpus": "Un vault por corpus",
"onboarding.storage.vaultPerCorpusDesc": "Recomendamos usar un vault de Obsidian dedicado por corpus. Esto garantiza que los archivos de correspondencia, los diccionarios y los exportes permanezcan junto a las transcripciones, facilitando compartir o archivar un corpus.",
"onboarding.storage.transcriptionsFolder": "Transcripciones",
"onboarding.storage.mappingFolder": "Tablas de correspondencia",
"onboarding.storage.dictionariesFolder": "Diccionarios",
"onboarding.storage.exportsFolder": "Exportes",
"onboarding.storage.hint": "Estas rutas son relativas a la raíz del vault. Puede modificarlas más tarde en Configuración.",
"onboarding.language.title": "Idioma de la interfaz",
"onboarding.language.hint": "El idioma de la interfaz no afecta al análisis de las transcripciones. Las convenciones (Jefferson, ICOR) son independientes del idioma.",
"onboarding.ner.desc": "Un modelo NER (reconocimiento de entidades nombradas) puede detectar automáticamente nombres propios, apellidos, lugares e instituciones en las transcripciones — sin una lista exhaustiva, ejecutándose localmente en Obsidian.",
"onboarding.ner.tfjsTitle": "transformers.js — modelo ONNX integrado",
"onboarding.ner.tfjsBadge": "Python no requerido",
"onboarding.ner.tfjsDesc": "Modelo NER multilingüe ejecutado localmente en el vault. Los archivos WASM (~19 MB) se descargan una vez. El modelo (~66 MB) se descarga en el primer escaneo.",
"onboarding.ner.wasmReady": "Archivos WASM ya instalados",
"onboarding.ner.installBtn": "Instalar y usar transformers.js",
"onboarding.ner.selectedBtn": "Seleccionado",
"onboarding.ner.noneActive": "Desactivado (solo reglas manuales)",
"onboarding.ner.noneSkip": "Omitir — trabajaré manualmente",
"onboarding.dict.title": "Diccionarios de candidatos de reemplazo",
"onboarding.dict.desc": "Los diccionarios proporcionan candidatos de reemplazo (ciudades, nombres…) y alimentan la detección automática. Están alojados en un repositorio dedicado y se descargan al vault — ningún dato de transcripción sale de Obsidian.",
"onboarding.dict.catalogueLoading": "Cargando catálogo…",
"onboarding.dict.catalogueError": "No se puede acceder al catálogo en línea. Compruebe su conexión o importe un archivo local a continuación.",
"onboarding.dict.recommended": "Recomendado",
"onboarding.dict.col.dict": "Diccionario",
"onboarding.dict.col.lang": "Idioma",
"onboarding.dict.col.roles": "Roles",
"onboarding.dict.col.size": "Tamaño",
"onboarding.dict.role.detection": "detección",
"onboarding.dict.role.replacement": "reemplazo",
"onboarding.dict.role.classes": "clases",
"onboarding.dict.install": "Instalar",
"onboarding.dict.reinstall": "Reinstalar",
"onboarding.dict.installed": "Instalado",
"onboarding.dict.manualHint": "¿Ya tiene un archivo .dict.json? Impórtelo manualmente:",
"onboarding.dict.importBtn": "Importar un archivo local (.dict.json)",
"onboarding.dict.listTitle": "Diccionarios instalados en este vault ({0}):",
"onboarding.dict.remove": "Eliminar este diccionario",
"onboarding.summary.ner.none": "Desactivado — solo reglas manuales",
"onboarding.summary.ner.tfjs": "transformers.js — modelo ONNX (descarga en el primer escaneo)",
"onboarding.nav.cancel": "Cancelar",
"onboarding.nav.back": "Atrás",
"onboarding.nav.skip": "Omitir este paso",
"onboarding.nav.next": "Siguiente",
"onboarding.nav.start": "Comenzar",
"onboarding.nav.finish": "Empezar a trabajar",
"onboarding.summary.title": "Configuración completa",
"onboarding.summary.intro": "Esto es lo que se ha configurado:",
"onboarding.summary.ner": "Detección NER",
"onboarding.summary.dicts": "Diccionarios",
"onboarding.summary.dicts.count": "{0} archivo(s) en el vault",
"onboarding.summary.hint": "Estos ajustes pueden cambiarse en cualquier momento desde la configuración del plugin: Reconfigurar.",
"settings.heading.general": "General",
"settings.heading.textDetection": "Detección de texto",
"settings.heading.pseudonymization": "Pseudonimización",
"settings.heading.ner": "Detección automática (NER)",
"settings.heading.storage": "Almacenamiento",
"settings.heading.security": "Seguridad",
"settings.language": "Idioma de la interfaz",
"settings.languageDesc": "No afecta al análisis de transcripciones. Las convenciones (Jefferson, ICOR) son independientes del idioma.",
"settings.vaultPerCorpus": "Un vault por corpus",
"settings.vaultPerCorpusDesc": "Recomendamos usar un vault de Obsidian dedicado por corpus. Esto garantiza que los archivos de correspondencia, los diccionarios y los exportes permanezcan junto a las transcripciones.",
"settings.transcriptionsFolder": "Transcripciones importadas",
"settings.transcriptionsFolderDesc": "Carpeta de destino para las transcripciones importadas.",
"settings.mappingFolder": "Tablas de correspondencia",
"settings.mappingFolderDesc": "Ruta relativa en el vault.",
"settings.dictionariesFolder": "Diccionarios",
"settings.dictionariesFolderDesc": "Ruta relativa en el vault.",
"settings.exportsFolder": "Exportes",
"settings.accentSensitive": "Sensible a los acentos",
"settings.preserveCase": "Preservar mayúsculas",
"settings.preserveCaseDesc": "Adaptar las mayúsculas del reemplazo para que coincidan con la fuente (p. ej. JUAN → PEDRO, juan → pedro, Juan → Pedro).",
"settings.preserveAnalyticNotation": "Preservar notación analítica",
"settings.preserveAnalyticNotationDesc": "No reemplazar nunca los símbolos de convención analítica de tipo Jefferson o ICOR.",
"settings.useMarkerInExport": "Añadir marcador alrededor de los pseudónimos en el exporte",
"settings.useMarkerInExportDesc": "Permite identificar visualmente los términos pseudonimizados en el archivo exportado.",
"settings.markerOpen": "Marcador de apertura",
"settings.markerOpenDesc": "Ejemplo: {{ ⟦ [ «",
"settings.markerClose": "Marcador de cierre",
"settings.markerCloseDesc": "Ejemplo: }} ⟧ ] »",
"settings.nerBackend": "Motor de detección",
"settings.nerBackendDesc": "Backend usado para la detección automática de entidades nombradas identificadoras.",
"settings.nerBackend.none": "Desactivado — solo reglas manuales",
"settings.nerBackend.tfjs": "transformers.js (modelo ONNX integrado)",
"settings.reconfigure": "Asistente de configuración",
"settings.reconfigureDesc2": "Relanzar el asistente para reconfigurar la detección NER y los diccionarios.",
"settings.reconfigureBtn2": "Reconfigurar…",
"settings.warnIfSyncedFolder": "Avisar si la carpeta está sincronizada",
"settings.warnIfSyncedFolderDesc": "Alertar si las tablas de correspondencia están en una carpeta Git, iCloud o Synology Drive.",
"settings.caseSensitive": "Sensible a mayúsculas",
"settings.caseSensitiveDesc": "Si está activado, «Juan» y «juan» se tratan como distintos.",
"settings.wholeWordOnly": "Solo palabras completas",
"settings.wholeWordOnlyDesc": "Si está activado, solo se reemplazan las ocurrencias de palabras completas."
}

View file

@ -201,7 +201,6 @@
"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",
@ -220,8 +219,12 @@
"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.",
"panel.exports.exportHtml": "Re-exporter en HTML",
"panel.exports.exportHtml.desc": "Reconstruit le fichier HTML noScribe pseudonymisé à partir du Markdown et des timestamps (.words.json).",
"notice.htmlExported": "HTML exporté : {0}",
"command.exportAsSrt": "Exporter en SRT",
"command.exportAsCha": "Exporter en CHAT",
"command.exportAsHtml": "Exporter en HTML",
"panel.ner.scanBtn": "Identifier des candidats",
"panel.ner.hint": "Paramètres du scanner de détection automatique des entités nommées.",

378
src/i18n/locales/ru.json Normal file
View file

@ -0,0 +1,378 @@
{
"corpus.modal.title": "Организовать корпус",
"corpus.modal.hint": "Каждый класс создаёт зеркальные папки для транскрипций, таблиц соответствия и экспортов.",
"corpus.modal.noClasses": "Классы не определены. Добавьте один, чтобы организовать документы по папкам.",
"corpus.modal.addClass": "Добавить класс",
"corpus.modal.classNamePlaceholder": "Название класса (например, Сессия_А, Информант_1…)",
"corpus.modal.classNameEmpty": "Название класса не может быть пустым.",
"corpus.modal.classExists": "Класс с таким названием уже существует.",
"corpus.modal.deleteConfirm": "Удалить класс «{0}»? Папки будут удалены из хранилища (файлы внутри будут перемещены в корзину).",
"corpus.modal.files": "{0} файл(ов)",
"corpus.modal.close": "Закрыть",
"corpus.select.title": "В какой класс добавить?",
"corpus.select.hint": "Выберите класс (папку) для этой транскрипции.",
"corpus.select.none": "Без класса (корень)",
"corpus.select.confirm": "Добавить",
"quickModal.title": "Псевдонимизировать",
"quickModal.source": "Выбранное выражение",
"quickModal.sourceDesc": "Заменяемый термин — нельзя изменить",
"quickModal.replaceBy": "Заменить на",
"quickModal.replacementPlaceholder": "Псевдоним или аналитическая категория",
"quickModal.scope": "Область замены",
"quickModal.scopeFile": "Все вхождения в файле",
"quickModal.scopeOccurrence": "Только это вхождение",
"quickModal.submit": "Псевдонимизировать",
"notice.appliedOccurrence": "✓ «{0}» → «{1}» (это вхождение)",
"notice.appliedFile": "✓ «{0}» → «{1}» ({2} вхождений)",
"onboarding.dict.importOk": "{0} словарь импортирован",
"onboarding.dict.importOkMany": "{0} словарей импортировано",
"onboarding.dict.importErr": "{0} файл отклонён",
"onboarding.dict.importErrMany": "{0} файлов отклонено",
"onboarding.dict.importError": "Ошибка импорта {0} — проверьте формат .dict.json",
"notice.noActiveFile": "Нет активного файла.",
"notice.formatUnsupported": "Неподдерживаемый формат: .{0}",
"notice.noRules": "Нет правил для этого файла.\nСоздайте правила через Ctrl+P → «Создать правило».",
"notice.noOccurrences": "Вхождений для активных правил не найдено.",
"notice.noNerEntities": "NER не обнаружил сущностей.",
"notice.nerEntitiesFound": "✓ Обнаружено {0} {1} — выделено синим.\nПравой кнопкой мыши — создать правило.",
"notice.nerEntitiesFound.entity": "сущность",
"notice.nerEntitiesFound.entities": "сущностей",
"notice.nerNotEnabled": "Детектирование через transformers.js не включено.\nВключите в Настройках → Pseudonymizer Tool.",
"notice.noDictDetection": "Словарь обнаружения не загружен.\nУстановите словарь в панели «Словари».",
"notice.noDictEntities": "Сущностей в словарях обнаружения не найдено.",
"notice.noDictReplacements": "Нет доступных замен для найденных сущностей.",
"notice.ruleDeleted": "✓ Правило удалено — «{0}» восстановлено в активном файле",
"notice.ruleCreated": "✓ Правило создано: «{0}» → «{1}»",
"notice.rulesCreated": "✓ {0} {1} создано",
"notice.rulesCreated.rule": "правило",
"notice.rulesCreated.rules": "правил",
"notice.occurrencesPseudonymized": "✓ {0} {1} псевдонимизировано в «{2}»",
"notice.occurrencesPseudonymized.occurrence": "вхождение",
"notice.occurrencesPseudonymized.occurrences": "вхождений",
"notice.converted": "✓ {0} → {1}",
"notice.conversionError": "Ошибка конвертации {0}: {1}",
"notice.fileExists": "Файл уже существует в хранилище: {0}",
"notice.conversionSkipped": "⚠ {0}.md уже существует — конвертация пропущена для {1}",
"notice.exportDone": "✓ {0} правил(о) применено\n→ {1}",
"notice.mappingExported": "✓ Таблица соответствия экспортирована → {0}",
"notice.noMapping": "Таблица соответствия для {0} не найдена",
"notice.invalidFormat": "Неверный формат: {0}",
"notice.dictImported": "✓ {0} {1} импортировано",
"notice.dictImported.one": "словарь",
"notice.dictImported.many": "словарей",
"notice.dictRejected": "{0} {1} отклонено",
"notice.dictRejected.one": "файл",
"notice.dictRejected.many": "файлов",
"notice.nerModelLoaded": "✓ Модель NER загружена",
"notice.nerModelLoading": "Загрузка модели NER (первый запуск — ~66 МБ)…",
"notice.ruleNotFound": "Правило не найдено в таблицах соответствия.",
"notice.noCheckedDicts": "Ни один словарь не выбран.",
"notice.audioImported": "Аудио импортировано: {0}",
"occurrencesCtx.hint": "{0} вхождени{1} — выберите нужные для замены.",
"occurrencesCtx.hint.s": "й",
"occurrencesCtx.hint.empty": "е",
"occurrencesCtx.validateAll": "Выбрать все",
"occurrencesCtx.ignoreAll": "Пропустить все",
"occurrencesCtx.cancel": "Отмена",
"occurrencesCtx.confirm": "Подтвердить выбор",
"occurrencesCtx.btn.validate": "Выбрать",
"occurrencesCtx.btn.ignore": "Пропустить",
"occurrencesCtx.btn.fp": "Ложное срабатывание",
"occurrencesCtx.status.ignored": "Оставлено как есть",
"occurrencesCtx.status.fp": "Ложное срабатывание — исключено",
"notice.notNoScribeFormat": "Этот файл не является транскрипцией noScribe (ожидается pseudobs-format: vtt или html).",
"notice.wordsJsonMissing": "Файл временных меток не найден: {0}.words.json",
"notice.vttMismatch": "Предупреждение: количество реплик в Markdown и words.json различается — экспортированный VTT может быть неполным.",
"notice.vttExported": "VTT экспортирован: {0}",
"command.organizeCorpus": "Организовать корпус",
"command.addTranscription": "Добавить транскрипцию",
"command.pseudonymizeFile": "Псевдонимизировать текущий файл",
"command.createRule": "Создать правило замены",
"command.scanFile": "Сканировать текущий файл",
"command.scanNer": "Сканировать файл с NER-детектированием",
"command.scanDictionaries": "Сканировать файл по словарям",
"command.pseudonymizeSelection": "Псевдонимизировать выделение",
"command.exportAsVtt": "Экспортировать как VTT",
"command.exportAsSrt": "Экспортировать как SRT",
"command.exportAsCha": "Экспортировать как CHAT",
"command.exportAsHtml": "Экспортировать как HTML",
"command.openPanel": "Псевдонимизация: открыть панель",
"contextMenu.declareException": "Объявить «{0}» исключением здесь",
"contextMenu.cancelPseudonymization": "Отменить псевдонимизацию «{0}»",
"contextMenu.editRule": "Изменить правило для «{0}»",
"contextMenu.pseudonymize": "Псевдонимизировать «{0}»",
"contextMenu.redact": "Скрыть «{0}»",
"contextMenu.coulmont": "Псевдонимизировать с проф. Батистом Кульмоном",
"contextMenu.createRule": "Создать правило замены…",
"redaction.checkbox": "Скрыть (🀫)",
"redaction.checkboxDesc": "Заменить символами 🀫 (по одному на слог) — для несущественного идентифицирующего содержания",
"ruleModal.delete": "Удалить правило",
"ruleModal.scopeWarnTitle": "Лонгитюдная область",
"ruleModal.scopeOkTitle": "Область файла",
"ruleModal.scopeOk": "Хорошая практика. Этот псевдоним будет применяться только к этой транскрипции — каждый файл остаётся независимым.",
"ruleModal.title": "Создать правило замены",
"ruleModal.source": "Источник",
"ruleModal.sourceDesc": "Исходный термин для замены",
"ruleModal.replacement": "Замена",
"ruleModal.replacementDesc": "Псевдоним или аналитическая категория",
"ruleModal.replacementDescClass": "Точный индекс будет рассчитан при создании на основе существующих правил в области.",
"ruleModal.category": "Категория",
"ruleModal.scope": "Область",
"ruleModal.scopeFile": "Только этот файл",
"ruleModal.scopeFolder": "Эта папка",
"ruleModal.scopeVault": "Всё хранилище",
"ruleModal.priority": "Приоритет",
"ruleModal.priorityDesc": "Целое число, как CSS z-index — по умолчанию 0, чем выше — тем раньше применяется",
"ruleModal.submit": "Создать правило",
"ruleModal.errorMissing": "Источник и замена обязательны.",
"ruleModal.errorNoFile": "Нет активного файла.",
"ruleModal.coulomontLabel": "Предложения эквивалентных имён — выберите:",
"ruleModal.dictLabel": "Словарь: «{0}» → класс {1}",
"ruleModal.dictUseClass": "Использовать «{0}» (область: {1})",
"category.first_name": "Имя",
"category.last_name": "Фамилия",
"category.full_name": "Полное имя",
"category.place": "Место",
"category.institution": "Учреждение",
"category.date": "Дата",
"category.age": "Возраст",
"category.profession": "Профессия",
"category.custom": "Другое",
"scope.file": "Файл",
"scope.folder": "Папка",
"scope.vault": "Хранилище",
"status.validated": "Активно",
"status.ignored": "Игнорируется",
"status.partial": "Частично",
"status.suggested": "Предложено",
"status.conflict": "Конфликт",
"status.disabled": "Неактивно",
"status.needs_review": "На проверке",
"panel.tab.mappings": "Соответствия",
"panel.tab.dictionaries": "Словари",
"panel.tab.exports": "Экспорты",
"panel.tab.ner": "NER",
"panel.tab.corpus": "Корпус",
"panel.mappings.filterActive": "Только активный файл",
"panel.mappings.warnBroadName": "⚠ Распространение имени или фамилии за пределы файла следует делать только как обдуманный аналитический выбор — например, для последовательного отслеживания одного участника в лонгитюдном исследовании. Даже если один и тот же человек фигурирует в нескольких файлах, псевдонимизация на уровне файла предпочтительнее: она сохраняет независимость каждой транскрипции и снижает риск идентификации через перекрёстный анализ.",
"panel.mappings.addRule": "Добавить правило",
"panel.mappings.scanFile": "Сканировать файл",
"panel.mappings.scanning": "Сканирование…",
"panel.mappings.noFile": "Нет активного файла.",
"panel.mappings.noRules": "Нет правил для {0}.",
"panel.mappings.noRulesHint": "Нет активных правил для этого файла. Создайте правила через контекстное меню или команду «Создать правило».",
"panel.mappings.col.source": "Источник",
"panel.mappings.col.replacement": "Замена",
"panel.mappings.col.category": "Категория",
"panel.mappings.col.scope": "Область",
"panel.mappings.col.priority": "П.",
"panel.mappings.col.status": "Статус",
"panel.mappings.exceptions": "Исключения",
"panel.mappings.exceptions.hint": "Вхождения, явно проигнорированные для этого правила.",
"panel.mappings.exceptions.delete": "Удалить это исключение",
"panel.dict.noneInstalled": "Словари не установлены. Установите через мастер (Настройки → Перенастроить) или импортируйте локальный файл.",
"panel.dict.checkbox": "Включить в групповое сканирование",
"panel.dict.scanAll": "Сканировать отмеченные словари",
"panel.dict.importLocal": "Импортировать локальный файл (.dict.json)",
"panel.dict.scanWith": "Сканировать активный файл по «{0}»",
"panel.dict.remove": "Удалить этот словарь",
"panel.corpus.noFolder": "Папка транскрипций не найдена. Настройте её в параметрах.",
"panel.corpus.empty": "В этой папке транскрипций не найдено.",
"panel.corpus.rules": "{0} правил(о)",
"panel.corpus.hasPseudo": "Псевдонимизированная версия создана",
"panel.corpus.noPseudo": "Ещё не псевдонимизировано",
"panel.corpus.hasFinal": "Финальный экспорт {0} присутствует",
"panel.corpus.noFinal": "Нет финального экспорта",
"panel.corpus.addClass": "Новый класс",
"panel.corpus.noClass": "Без класса",
"panel.corpus.moveTo": "Переместить в…",
"panel.corpus.moveToRoot": "Без класса (корень)",
"panel.corpus.deleteClass": "Удалить класс",
"panel.corpus.deleteClassConfirm": "Удалить класс «{0}»? Файлы внутри будут перемещены в корень.",
"panel.corpus.exportSettings": "Назначение финального экспорта",
"panel.corpus.exportDest.vault": "В хранилище",
"panel.corpus.exportDest.external": "Внешняя папка",
"panel.corpus.exportFolder": "Папка экспорта (хранилище)",
"panel.corpus.exportExternalPath": "Абсолютный путь",
"panel.corpus.exportExternalPathPlaceholder": "/Volumes/Archives/Exports",
"panel.corpus.exportMirrorClasses": "Зеркалировать структуру классов",
"panel.filenameWarning.msg": "Имя файла «{0}» содержит псевдонимизируемый термин.",
"panel.filenameWarning.edit": "Переименовать…",
"panel.filenameWarning.editTitle": "Введите имя вручную",
"panel.filenameWarning.editPrompt": "Новое имя для «{0}»",
"panel.filenameWarning.wandTitle": "Переименовать в «{0}»",
"panel.exports.pseudonymize": "Создать псевдонимизированную версию",
"panel.exports.exportMapping": "Экспортировать таблицу соответствия",
"panel.exports.exportVtt": "Повторно экспортировать как VTT",
"panel.exports.exportVtt.desc": "Восстанавливает псевдонимизированный WebVTT из Markdown и временных меток (.words.json).",
"panel.exports.exportSrt": "Повторно экспортировать как SRT",
"panel.exports.exportSrt.desc": "Восстанавливает псевдонимизированный SRT из Markdown.",
"panel.exports.exportCha": "Повторно экспортировать как CHAT (.cha)",
"panel.exports.exportCha.desc": "Восстанавливает псевдонимизированный CHAT из Markdown.",
"panel.exports.exportHtml": "Повторно экспортировать как HTML",
"panel.exports.exportHtml.desc": "Восстанавливает псевдонимизированный HTML noScribe из Markdown и временных меток (.words.json).",
"notice.htmlExported": "HTML экспортирован: {0}",
"panel.ner.scanBtn": "Определить кандидатов",
"panel.ner.hint": "Настройки автоматического сканера распознавания именованных сущностей.",
"panel.ner.threshold": "Порог уверенности",
"panel.ner.thresholdDesc": "Сущности с оценкой ниже этого значения игнорируются. Выше = меньше ложных срабатываний, но меньше обнаружений.",
"panel.ner.functionWords": "Исключённые служебные слова",
"panel.ner.functionWordsDesc": "По одному слову на строку. Эти токены никогда не будут сохранены как именованные сущности, даже если обнаружены моделью.",
"panel.ner.save": "Сохранить",
"panel.ner.saved": "Сохранено",
"panel.ner.reset": "Сбросить до значений по умолчанию",
"dictScanModal.title": "Проверка сканирования по словарю",
"dictScanModal.summary": "{0} {1} обнаружено · {2}",
"dictScanModal.summary.entity": "сущность",
"dictScanModal.summary.entities": "сущностей",
"dictScanModal.hint": "Снимите галочки с ложных срабатываний. Префикс редактируется; индекс рассчитывается автоматически.",
"dictScanModal.apply": "Создать {0} {1}",
"dictScanModal.apply.rule": "правило",
"dictScanModal.apply.rules": "правил",
"dictScanModal.noRules": "Нет правил для применения",
"dictScanModal.cancel": "Отмена",
"mappingScanModal.title": "Проверка сканирования соответствий",
"mappingScanModal.summary": "{0} {1} с вхождениями в «{2}»",
"mappingScanModal.summary.rule": "активное правило",
"mappingScanModal.summary.rules": "активных правил",
"mappingScanModal.hint": "Снимите галочки с правил, которые не нужно применять. Замены будут записаны в исходный файл.",
"mappingScanModal.col.source": "",
"mappingScanModal.col.replacement": "Замена",
"mappingScanModal.col.occurrences": "Вхожд.",
"mappingScanModal.apply": "Псевдонимизировать ({0} {1}, {2} {3})",
"mappingScanModal.apply.rule": "правило",
"mappingScanModal.apply.rules": "правил",
"mappingScanModal.apply.occurrence": "вхождение",
"mappingScanModal.apply.occurrences": "вхождений",
"mappingScanModal.noRules": "Нет правил для применения",
"mappingScanModal.cancel": "Отмена",
"mappingScanModal.saveExceptions": "Сохранить исключения",
"mappingScanModal.exceptionsSaved": "Исключения сохранены в таблицу соответствия.",
"mappingScanModal.countTooltip": "Просмотр и выбор кандидатов",
"onboarding.step.storage": "Хранилище",
"onboarding.step.language": "Язык",
"onboarding.step.welcome": "Добро пожаловать",
"onboarding.step.ner": "Детектирование NER",
"onboarding.step.dictionaries": "Словари",
"onboarding.step.summary": "Итоги",
"onboarding.welcome.title": "Pseudonymizer Tool",
"onboarding.welcome.desc": "Этот плагин помогает корректировать и псевдонимизировать транскрипции интервью и интеракциональных корпусов (конвенции Jefferson, ICOR, форматы .srt, .cha).",
"onboarding.welcome.steps": "Мастер проведёт вас через несколько быстрых шагов:",
"onboarding.welcome.step1": "Выберите язык интерфейса.",
"onboarding.welcome.step2": "Выберите движок автоматического распознавания именованных сущностей (необязательно).",
"onboarding.welcome.step3": "Установите словари кандидатов для замены, если необходимо.",
"onboarding.welcome.hint": "Эти параметры можно изменить в любое время в настройках плагина.",
"onboarding.storage.title": "Хранилище",
"onboarding.storage.vaultPerCorpus": "Одно хранилище на корпус",
"onboarding.storage.vaultPerCorpusDesc": "Рекомендуем использовать отдельное хранилище Obsidian для каждого корпуса. Это позволяет хранить таблицы соответствия, словари и экспорты вместе с транскрипциями, упрощая обмен и архивирование корпуса.",
"onboarding.storage.transcriptionsFolder": "Папка транскрипций",
"onboarding.storage.mappingFolder": "Таблицы соответствия",
"onboarding.storage.dictionariesFolder": "Словари",
"onboarding.storage.exportsFolder": "Экспорты",
"onboarding.storage.hint": "Эти пути указаны относительно корня хранилища. Их можно изменить позже в Настройках.",
"onboarding.language.title": "Язык интерфейса",
"onboarding.language.hint": "Язык интерфейса не влияет на анализ транскрипций. Конвенции (Jefferson, ICOR) не зависят от языка.",
"onboarding.ner.desc": "Модель NER (распознавание именованных сущностей) может автоматически обнаруживать имена, фамилии, места и учреждения в транскрипциях — без исчерпывающего списка, локально в Obsidian.",
"onboarding.ner.tfjsTitle": "transformers.js — встроенная модель ONNX",
"onboarding.ner.tfjsBadge": "Python не требуется",
"onboarding.ner.tfjsDesc": "Многоязычная модель NER, работающая локально в хранилище. WASM-файлы (~19 МБ) загружаются один раз. Модель (~66 МБ) загружается при первом сканировании.",
"onboarding.ner.wasmReady": "WASM-файлы уже установлены",
"onboarding.ner.installBtn": "Установить и использовать transformers.js",
"onboarding.ner.selectedBtn": "Выбрано",
"onboarding.ner.noneActive": "Отключено (только ручные правила)",
"onboarding.ner.noneSkip": "Пропустить — буду работать вручную",
"onboarding.dict.title": "Словари кандидатов для замены",
"onboarding.dict.desc": "Словари предоставляют кандидатов для замены (города, имена…) и поддерживают автоматическое обнаружение. Они размещены в специальном репозитории и загружаются в хранилище — данные транскрипций не покидают Obsidian.",
"onboarding.dict.catalogueLoading": "Загрузка каталога…",
"onboarding.dict.catalogueError": "Не удалось подключиться к онлайн-каталогу. Проверьте соединение или импортируйте локальный файл ниже.",
"onboarding.dict.recommended": "Рекомендуется",
"onboarding.dict.col.dict": "Словарь",
"onboarding.dict.col.lang": "Язык",
"onboarding.dict.col.roles": "Роли",
"onboarding.dict.col.size": "Размер",
"onboarding.dict.role.detection": "обнаружение",
"onboarding.dict.role.replacement": "замена",
"onboarding.dict.role.classes": "классы",
"onboarding.dict.install": "Установить",
"onboarding.dict.reinstall": "Переустановить",
"onboarding.dict.installed": "Установлено",
"onboarding.dict.manualHint": "Уже есть файл .dict.json? Импортируйте вручную:",
"onboarding.dict.importBtn": "Импортировать локальный файл (.dict.json)",
"onboarding.dict.listTitle": "Словари, установленные в этом хранилище ({0}):",
"onboarding.dict.remove": "Удалить этот словарь",
"onboarding.summary.ner.none": "Отключено — только ручные правила",
"onboarding.summary.ner.tfjs": "transformers.js — модель ONNX (загрузка при первом сканировании)",
"onboarding.nav.cancel": "Отмена",
"onboarding.nav.back": "Назад",
"onboarding.nav.skip": "Пропустить этот шаг",
"onboarding.nav.next": "Далее",
"onboarding.nav.start": "Начать",
"onboarding.nav.finish": "Приступить к работе",
"onboarding.summary.title": "Настройка завершена",
"onboarding.summary.intro": "Вот что было настроено:",
"onboarding.summary.ner": "Детектирование NER",
"onboarding.summary.dicts": "Словари",
"onboarding.summary.dicts.count": "{0} файл(ов) в хранилище",
"onboarding.summary.hint": "Эти настройки можно изменить в любое время в параметрах плагина: Перенастроить.",
"settings.heading.general": "Общие",
"settings.heading.textDetection": "Обнаружение текста",
"settings.heading.pseudonymization": "Псевдонимизация",
"settings.heading.ner": "Автоматическое обнаружение (NER)",
"settings.heading.storage": "Хранилище",
"settings.heading.security": "Безопасность",
"settings.language": "Язык интерфейса",
"settings.languageDesc": "Не влияет на анализ транскрипций. Конвенции (Jefferson, ICOR) не зависят от языка.",
"settings.vaultPerCorpus": "Одно хранилище на корпус",
"settings.vaultPerCorpusDesc": "Рекомендуем использовать отдельное хранилище Obsidian для каждого корпуса. Это гарантирует, что таблицы соответствия, словари и экспорты хранятся вместе с транскрипциями.",
"settings.transcriptionsFolder": "Импортированные транскрипции",
"settings.transcriptionsFolderDesc": "Папка назначения для импортированных транскрипций.",
"settings.mappingFolder": "Таблицы соответствия",
"settings.mappingFolderDesc": "Относительный путь в хранилище.",
"settings.dictionariesFolder": "Словари",
"settings.dictionariesFolderDesc": "Относительный путь в хранилище.",
"settings.exportsFolder": "Экспорты",
"settings.accentSensitive": "Учитывать акценты",
"settings.preserveCase": "Сохранять регистр",
"settings.preserveCaseDesc": "Адаптировать регистр замены под исходный (например, ИВАН → ПЁТР, иван → пётр, Иван → Пётр).",
"settings.preserveAnalyticNotation": "Сохранять аналитическую нотацию",
"settings.preserveAnalyticNotationDesc": "Никогда не заменять символы аналитических конвенций Jefferson или ICOR.",
"settings.useMarkerInExport": "Добавлять маркер вокруг псевдонимов в экспорте",
"settings.useMarkerInExportDesc": "Позволяет визуально идентифицировать псевдонимизированные термины в экспортированном файле.",
"settings.markerOpen": "Открывающий маркер",
"settings.markerOpenDesc": "Пример: {{ ⟦ [ «",
"settings.markerClose": "Закрывающий маркер",
"settings.markerCloseDesc": "Пример: }} ⟧ ] »",
"settings.nerBackend": "Движок обнаружения",
"settings.nerBackendDesc": "Бэкенд для автоматического обнаружения идентифицирующих именованных сущностей.",
"settings.nerBackend.none": "Отключено — только ручные правила",
"settings.nerBackend.tfjs": "transformers.js (встроенная модель ONNX)",
"settings.reconfigure": "Мастер настройки",
"settings.reconfigureDesc2": "Перезапустить мастер для перенастройки NER-детектирования и словарей.",
"settings.reconfigureBtn2": "Перенастроить…",
"settings.warnIfSyncedFolder": "Предупреждать о синхронизированной папке",
"settings.warnIfSyncedFolderDesc": "Предупреждать, если таблицы соответствия находятся в папке Git, iCloud или Synology Drive.",
"settings.caseSensitive": "Учитывать регистр",
"settings.caseSensitiveDesc": "Если включено, «Иван» и «иван» считаются разными.",
"settings.wholeWordOnly": "Только целые слова",
"settings.wholeWordOnlyDesc": "Если включено, заменяются только вхождения полных слов."
}

View file

@ -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, markdownToSrt, markdownToCha, type VttCueData } from './parsers/TranscriptConverter';
import { srtToMarkdown, chatToMarkdown, vttToMarkdown, noScribeHtmlToMarkdown, extractWordData, markdownToVtt, markdownToSrt, markdownToCha, markdownToNoScribeHtml, type VttCueData } from './parsers/TranscriptConverter';
import { MappingStore } from './mappings/MappingStore';
import { ScopeResolver } from './mappings/ScopeResolver';
import { PseudonymizationEngine } from './pseudonymizer/PseudonymizationEngine';
@ -183,6 +183,12 @@ export default class PseudObsPlugin extends Plugin {
callback: () => void this.exportCurrentFileAsFormat('cha'),
});
this.addCommand({
id: 'export-as-html',
name: t('command.exportAsHtml'),
callback: () => void this.exportCurrentFileAsHtml(),
});
this.addCommand({
id: 'pseudonymize-selection',
name: t('command.pseudonymizeSelection'),
@ -389,6 +395,26 @@ export default class PseudObsPlugin extends Plugin {
* Calcule le chemin de l'export final selon les paramètres de destination.
* Retourne { vaultPath } si dans le vault, { externalPath } si hors vault.
*/
/** Retourne le sous-dossier de classe (vide si pas de mirroring ou pas de classe). */
private getClassSub(file: TFile): string {
const s = this.settings;
if (!s.exportMirrorClasses) return '';
const transcRoot = s.transcriptionsFolder;
const fileFolder = file.parent?.path ?? '';
const rawBase = file.basename.replace(/\.pseudonymized$/, '');
if (fileFolder.startsWith(transcRoot)) {
return fileFolder.slice(transcRoot.length).replace(/^\//, '');
}
const mappingFile = this.findInMappings(`${rawBase}.mapping.json`);
if (mappingFile) {
const mFolder = mappingFile.parent?.path ?? '';
if (mFolder.startsWith(s.mappingFolder)) {
return mFolder.slice(s.mappingFolder.length).replace(/^\//, '');
}
}
return '';
}
resolveExportPath(
file: TFile,
ext: string,
@ -396,41 +422,13 @@ export default class PseudObsPlugin extends Plugin {
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 classSub = this.getClassSub(file);
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 };
}
@ -879,6 +877,12 @@ export default class PseudObsPlugin extends Plugin {
// --- Conversion automatique ---
private async autoConvert(file: TFile): Promise<void> {
// Ne pas convertir les fichiers produits par le plugin lui-même
const s = this.settings;
const fileFolder = file.parent?.path ?? '';
const outputFolders = [s.exportsFolder, s.mappingFolder, s.exportFinalFolder].filter(Boolean);
if (outputFolders.some((f) => fileFolder === f || fileFolder.startsWith(f + '/'))) return;
try {
const raw = await this.app.vault.read(file);
const ext = file.extension.toLowerCase();
@ -1141,8 +1145,12 @@ export default class PseudObsPlugin extends Plugin {
pseudonymized = engine.pseudonymize(content, rules, marker);
}
await this.ensureFolder(this.settings.exportsFolder);
const outputPath = `${this.settings.exportsFolder}/${file.basename}.pseudonymized.${ext}`;
const classSub = this.getClassSub(file);
const exportBase = classSub
? `${this.settings.exportsFolder}/${classSub}`
: this.settings.exportsFolder;
await this.ensureFolder(exportBase);
const outputPath = `${exportBase}/${file.basename}.pseudonymized.${ext}`;
const existing = this.app.vault.getAbstractFileByPath(outputPath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, pseudonymized);
@ -1345,6 +1353,45 @@ export default class PseudObsPlugin extends Plugin {
await this.writeExport(file, 'vtt', vtt);
}
/**
* Re-exporte le Markdown noScribe actif au format HTML noScribe pseudonymisé.
* Lit le .words.json correspondant pour les timestamps précis.
*/
async exportCurrentFileAsHtml(): 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);
if (formatMatch?.[1] !== 'html') {
new Notice(t('notice.notNoScribeFormat'));
return;
}
const rawBasename = file.basename.replace(/\.pseudonymized$/, '');
const wordsJson = await this.findWordsJson(rawBasename);
if (!wordsJson) {
new Notice(t('notice.wordsJsonMissing', rawBasename));
return;
}
const audioMatch = /^pseudobs-audio:\s*"([^"]+)"/m.exec(content);
const audioSource = audioMatch?.[1];
const wordData = JSON.parse(wordsJson) as VttCueData[];
const { html, mismatch } = markdownToNoScribeHtml(content, wordData, audioSource);
if (mismatch) {
new Notice(t('notice.vttMismatch'));
}
await this.writeExport(file, 'html', html, 'notice.htmlExported');
}
/**
* Re-exporte le Markdown pseudonymisé courant au format d'origine (srt ou cha).
*/
@ -1374,7 +1421,7 @@ export default class PseudObsPlugin extends Plugin {
* É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> {
private async writeExport(file: TFile, ext: string, content: string, noticeKey = 'notice.vttExported'): Promise<void> {
const dest = this.resolveExportPath(file, ext);
if (dest.externalPath) {
@ -1386,7 +1433,7 @@ export default class PseudObsPlugin extends Plugin {
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));
new Notice(t(noticeKey, dest.externalPath));
} catch (e) {
new Notice(`Export error: ${(e as Error).message}`);
}
@ -1404,7 +1451,7 @@ export default class PseudObsPlugin extends Plugin {
} else {
await this.app.vault.create(outputPath, content);
}
new Notice(t('notice.vttExported', outputPath));
new Notice(t(noticeKey, outputPath));
}
/** Cherche <basename>.words.json dans le dossier mappings et ses sous-dossiers. */

View file

@ -286,6 +286,134 @@ export function noScribeHtmlToMarkdown(doc: VttDocument, sourceName: string, aud
return vttDocToMarkdown(doc, sourceName, 'html', audioFilename);
}
// ---- Re-export HTML noScribe ------------------------------------------------
/** Convertit HH:MM:SS.mmm en millisecondes. */
function tsToMs(ts: string): number {
const [h, m, s] = ts.split(':');
return Math.round((parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s)) * 1000);
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Conserve uniquement les caractères word ASCII pour les noms d'ancre ts_. */
function sanitizeSpeakerId(speaker: string): string {
return speaker.replace(/\W/g, '');
}
/**
* Reconstruit un fichier HTML noScribe pseudonymisé depuis :
* - `mdContent` : le Markdown noScribe (pseudobs-format: html)
* - `wordData` : le contenu de <basename>.words.json (timestamps précis)
* - `audioSource` : chemin vers le fichier audio (optionnel, pour la meta tag)
*
* Chaque réplique devient un <p> avec deux ancres ts_ : le timestamp d'affichage
* et le texte. Les timestamps word-level ne sont pas reconstitués (le texte
* pseudonymisé ne s'aligne plus mot à mot sur l'original).
*/
export function markdownToNoScribeHtml(
mdContent: string,
wordData: VttCueData[],
audioSource?: string,
): { html: string; mismatch: boolean } {
const bodyMatch = /^---\n[\s\S]*?\n---\n+([\s\S]*)$/.exec(mdContent);
const body = bodyMatch ? bodyMatch[1] : mdContent;
const cueLines = body.split('\n').filter((l) => MD_CUE_RE.test(l.trim()));
const mismatch = cueLines.length !== wordData.length;
const count = Math.min(cueLines.length, wordData.length);
const P_STYLE = ' margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;';
const paragraphs: string[] = [];
for (let i = 0; i < count; i++) {
const m = MD_CUE_RE.exec(cueLines[i].trim())!;
const speaker = m[1]?.trim() || wordData[i].speaker || '';
const text = m[2]?.trim() ?? '';
if (!text) continue;
const cueData = wordData[i];
const cueStartMs = tsToMs(cueData.startTime);
const cueEndMs = tsToMs(cueData.endTime);
const speakerId = speaker ? sanitizeSpeakerId(speaker) : '';
const cueAnchor = `ts_${cueStartMs}_${cueEndMs}_${speakerId}`;
const displayTs = `[${cueData.startTime.slice(0, 8)}]`;
// Ancres word-level : mapper les tokens pseudonymisés sur les timestamps d'origine
// par index proportionnel (floor). Un pseudonyme hérite du timestamp du nom original.
const origWords = cueData.words.filter(w => w.time !== '');
const pseudoTokens = text.split(/\s+/).filter(Boolean);
let contentHtml: string;
if (origWords.length === 0 || pseudoTokens.length === 0) {
// Pas de timestamps word-level : ancre unique pour toute la réplique
const prefix = speaker ? `${escapeHtml(speaker)} : ` : '';
contentHtml = `<a name="${cueAnchor}">${prefix}${escapeHtml(text)}</a>`;
} else {
const wordStartMs = origWords.map(w => tsToMs(w.time));
const wordEndMs = origWords.map((_, j) =>
j + 1 < origWords.length ? wordStartMs[j + 1] : cueEndMs
);
// Mapping floor : token pseudo j → orig word floor(j * M / N)
const mapToOrig = (j: number): number =>
Math.min(Math.floor(j * origWords.length / pseudoTokens.length), origWords.length - 1);
// Grouper les tokens consécutifs qui partagent le même index orig
interface Group { origIdx: number; tokens: string[] }
const groups: Group[] = [];
for (let j = 0; j < pseudoTokens.length; j++) {
const origIdx = mapToOrig(j);
if (groups.length === 0 || groups[groups.length - 1].origIdx !== origIdx) {
groups.push({ origIdx, tokens: [] });
}
groups[groups.length - 1].tokens.push(pseudoTokens[j]);
}
const parts: string[] = [];
for (let g = 0; g < groups.length; g++) {
const { origIdx, tokens } = groups[g];
const anchorName = `ts_${wordStartMs[origIdx]}_${wordEndMs[origIdx]}_${speakerId}`;
let groupText = tokens.join(' ');
// Espace séparateur entre ancres (sauf la dernière)
if (g < groups.length - 1) groupText += ' ';
// Préfixe locuteur sur le premier groupe
const content = (g === 0 && speaker)
? `${escapeHtml(speaker)} : ${escapeHtml(groupText)}`
: escapeHtml(groupText);
parts.push(`<a name="${anchorName}">${content}</a>`);
}
contentHtml = parts.join('');
}
paragraphs.push(
`<p style="${P_STYLE}">` +
`<a name="${cueAnchor}" href="${cueAnchor}"><span style=" color:#78909c;">${displayTs}</span></a>` +
contentHtml +
`</p>`
);
}
const audioMeta = audioSource
? `<meta name="audio_source" content="${escapeHtml(audioSource)}" />\n`
: '';
const html = [
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">',
`<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" />\n${audioMeta}<style type="text/css">`,
'p, li { white-space: pre-wrap; }',
`</style></head><body style=" font-family:'Arial'; font-size:12pt; font-weight:400; font-style:normal;">`,
...paragraphs,
'</body></html>',
'',
].join('\n');
return { html, mismatch };
}
function lineGroup(line: ChatLine): 'structural' | 'turn' | null {
if (line.type === 'meta' || line.type === 'dependent') return 'structural';
if (line.type === 'turn' || line.type === 'continuation') return 'turn';

View file

@ -5,7 +5,7 @@ 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 type ExportDestinationType = 'vault' | 'external';
export interface PseudObsSettings {
transcriptionsFolder: string;

View file

@ -21,10 +21,12 @@ export function getCorpusClasses(app: App, transcriptionsFolder: string): string
*/
export class CorpusModal extends Modal {
private plugin: PseudObsPlugin;
private onCloseCb?: () => void;
constructor(app: App, plugin: PseudObsPlugin) {
constructor(app: App, plugin: PseudObsPlugin, onCloseCb?: () => void) {
super(app);
this.plugin = plugin;
this.onCloseCb = onCloseCb;
}
onOpen(): void {
@ -145,6 +147,7 @@ export class CorpusModal extends Modal {
onClose(): void {
this.contentEl.empty();
this.onCloseCb?.();
}
}

View file

@ -3,7 +3,7 @@ import { t } from '../i18n';
import type { DictionaryFile } from '../types';
import type PseudObsPlugin from '../main';
import { scanOccurrences } from '../scanner/OccurrenceScanner';
import { getCorpusClasses } from './CorpusModal';
import { getCorpusClasses, CorpusModal } from './CorpusModal';
import { FolderSuggest } from './FolderSuggest';
import { EditRuleModal } from './EditRuleModal';
import { RuleModal } from './RuleModal';
@ -211,9 +211,8 @@ export class PseudonymizationView extends ItemView {
// 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'],
['vault', 'panel.corpus.exportDest.vault'],
['external', 'panel.corpus.exportDest.external'],
] as [string, string][]) {
const lbl = typeRow.createEl('label', { cls: 'pseudobs-corpus-export-type-label' });
const radio = lbl.createEl('input');
@ -270,12 +269,8 @@ export class PseudonymizationView extends ItemView {
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');
addClassBtn.addEventListener('click', () => {
new CorpusModal(this.app, this.plugin, () => void this.renderTab('corpus')).open();
});
// ---- Liste des fichiers par classe ----------------------------
@ -723,6 +718,16 @@ export class PseudonymizationView extends ItemView {
void this.plugin.exportCurrentFileAsVtt();
})
);
if (format === 'html') {
new Setting(el)
.setName(t('panel.exports.exportHtml'))
.setDesc(t('panel.exports.exportHtml.desc'))
.addButton((btn) =>
btn.setButtonText(t('command.exportAsHtml')).onClick(() => {
void this.plugin.exportCurrentFileAsHtml();
})
);
}
} else if (format === 'srt') {
new Setting(el)
.setName(t('panel.exports.exportSrt'))