Files
Estacion-de-Documentos/estacion documentos.html
T
2026-05-17 11:18:12 -06:00

1612 lines
61 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Estación de Documentos Premium</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<!-- Icons -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css" rel="stylesheet">
<!-- Libraries -->
<script src="https://unpkg.com/turndown/dist/turndown.js"></script>
<script src="https://unpkg.com/turndown-plugin-gfm/dist/turndown-plugin-gfm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/mammoth.browser.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js"></script>
<style>
:root {
/* Light Theme */
--bg-body: #f1f5f9;
--bg-card: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--accent-primary: #3b82f6;
--accent-hover: #2563eb;
--accent-secondary: #6366f1;
--border-color: #e2e8f0;
--toolbar-bg: #f8fafc;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-body: #0f172a;
--bg-card: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent-primary: #60a5fa;
--accent-hover: #3b82f6;
--border-color: #334155;
--toolbar-bg: #1e293b;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
background-color: var(--bg-body);
color: var(--text-primary);
line-height: 1.6;
padding: 2rem;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
header {
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn-main {
background: var(--bg-card);
border: 1px solid var(--border-color);
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-main:hover {
box-shadow: var(--shadow-sm);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.btn-main.primary {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.btn-main.primary:hover {
background: var(--accent-hover);
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
flex: 1;
min-height: 0;
height: calc(100vh - 150px);
}
/* CARD STYLES */
.card {
background: var(--bg-card);
border-radius: 16px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
/* For drop overlay */
}
.card-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--toolbar-bg);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card-subtitle {
font-size: 0.8rem;
color: var(--success);
background: rgba(16, 185, 129, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 4px;
opacity: 1;
/* Always visible for status */
transition: color 0.3s;
}
/* EDITOR SIDE */
.editor-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.toolbar {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
overflow-x: auto;
}
.btn-tool {
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
}
.btn-tool:hover {
background: rgba(59, 130, 246, 0.1);
color: var(--accent-primary);
}
textarea {
flex: 1;
width: 100%;
padding: 1.5rem;
border: none;
background: var(--bg-card);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 1rem;
resize: none;
outline: none;
line-height: 1.6;
}
/* DROP ZONE OVERLAY */
.drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(59, 130, 246, 0.05);
/* Very light blue */
border: 2px dashed var(--accent-primary);
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10;
opacity: 0;
pointer-events: none;
/* Make it pass-through when not active */
transition: opacity 0.2s;
}
.drop-active .drop-overlay {
opacity: 1;
pointer-events: all;
}
.drop-message {
background: var(--bg-card);
padding: 2rem;
border-radius: 12px;
box-shadow: var(--shadow-lg);
text-align: center;
color: var(--accent-primary);
font-weight: 600;
}
/* PREVIEW SIDE */
.preview-container {
flex: 1;
padding: 2rem;
/* Reduced padding */
overflow-y: auto;
background: var(--bg-card);
}
/* Make preview editable for bi-directional sync */
.preview-content {
outline: none;
min-height: 100%;
}
/* MARKDOWN STYLES */
.markdown-body h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.markdown-body h2 {
font-size: 1.4rem;
margin-top: 2rem;
margin-bottom: 1rem;
color: var(--text-primary);
border-left: 4px solid var(--accent-primary);
padding-left: 1rem;
}
.markdown-body h3 {
font-size: 1.15rem;
margin-top: 1.5rem;
color: var(--text-secondary);
font-weight: 600;
}
.markdown-body p,
.markdown-body li {
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.markdown-body ul,
.markdown-body ol {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.markdown-body strong {
color: var(--text-primary);
font-weight: 600;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
margin: 1.5rem 0;
border-radius: 8px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.markdown-body th {
background-color: var(--bg-body);
text-align: left;
padding: 1rem;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--border-color);
white-space: nowrap;
}
.markdown-body td {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-secondary);
}
.markdown-body tr:last-child td {
border-bottom: none;
}
.markdown-body tr:hover {
background-color: rgba(59, 130, 246, 0.05);
}
/* Code */
.markdown-body pre {
background: #1e293b;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
color: #f8fafc;
margin: 1.5rem 0;
}
.markdown-body code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
}
.markdown-body p code {
background: rgba(99, 102, 241, 0.1);
color: var(--accent-secondary);
padding: 0.2em 0.4em;
border-radius: 4px;
}
/* Progress Bar */
.progress-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: transparent;
z-index: 20;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
width: 0%;
transition: width 0.3s ease;
}
.status-pill {
position: absolute;
bottom: 2rem;
right: 2rem;
background: var(--text-primary);
color: var(--bg-body);
padding: 0.75rem 1.25rem;
border-radius: 50px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: var(--shadow-lg);
display: none;
z-index: 1000;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* MODAL */
dialog {
margin: auto;
padding: 0;
border: none;
border-radius: 16px;
box-shadow: var(--shadow-lg);
background: var(--bg-card);
color: var(--text-primary);
max-width: 500px;
width: 90%;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-body {
padding: 2rem;
}
.modal-header {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
}
.form-row {
margin-bottom: 1rem;
}
.form-row label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.form-radio {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
input[type="text"] {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
background: var(--bg-body);
color: var(--text-primary);
border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
}
/* TABS STYLES */
.tabs-container {
display: flex;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
padding: 0 1rem;
gap: 0.5rem;
min-height: 40px;
align-items: flex-end;
}
.tab {
padding: 0.5rem 1rem;
background: rgba(0,0,0,0.05);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: 8px 8px 0 0;
font-size: 0.85rem;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s;
max-width: 200px;
}
.tab span {
overflow: hidden;
text-overflow: ellipsis;
}
.tab:hover {
background: var(--bg-card);
color: var(--accent-primary);
}
.tab.active {
background: var(--bg-card);
color: var(--accent-primary);
border-bottom: 2px solid var(--accent-primary);
font-weight: 600;
}
.tab-close {
font-size: 1.1rem;
opacity: 0.5;
cursor: pointer;
}
.tab-close:hover {
opacity: 1;
color: var(--danger);
}
/* FIND & REPLACE STYLES */
.find-replace-bar {
background: var(--toolbar-bg);
border-bottom: 1px solid var(--border-color);
padding: 0.5rem 1rem;
display: none;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
animation: slideDown 0.2s ease;
}
@keyframes slideDown {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.find-replace-bar input {
padding: 0.4rem 0.6rem;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.85rem;
background: var(--bg-card);
color: var(--text-primary);
width: 150px;
}
/* BREATHING DOT STYLES */
.sync-indicator {
position: absolute;
left: 5px;
width: 8px;
height: 8px;
background: var(--accent-primary);
border-radius: 50%;
pointer-events: none;
opacity: 0.4;
animation: breathe 2s infinite ease-in-out;
z-index: 5;
display: none;
}
@keyframes breathe {
0%, 100% { transform: scale(0.8); opacity: 0.2; }
50% { transform: scale(1.2); opacity: 0.6; }
}
</style>
<style media="print">
body * { visibility: hidden; }
#preview, #preview * { visibility: visible; }
#preview {
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 0;
background: white !important;
color: black !important;
}
.card, header, .toolbar, .status-pill, .progress-container { display: none !important; }
.main-grid { display: block !important; }
</style>
</head>
<body>
<header>
<div class="header-left">
<h1>
<i class="bi bi-file-earmark-richtext"></i> Estación de Documentos
</h1>
<div
style="font-size: 0.9rem; color: var(--text-secondary); border-left: 2px solid var(--border-color); padding-left: 1rem;">
Arrastra PDF, DOCX, TXT, Audio, Video
</div>
</div>
<div class="header-actions">
<button class="btn-main" id="configBtn" title="Configuración">
<i class="bi bi-gear"></i>
</button>
<div style="position: relative; display: inline-block;">
<button class="btn-main primary" id="exportBtn">
<i class="bi bi-box-arrow-up"></i> Exportar <i class="bi bi-chevron-down"></i>
</button>
<div id="exportMenu" style="display:none; position:absolute; right:0; top:100%; background:var(--bg-card); border:1px solid var(--border-color); border-radius:8px; box-shadow:var(--shadow-lg); z-index:1000; min-width:150px; margin-top:5px;">
<button class="btn-tool" id="downloadMd" style="width:100%; text-align:left; padding:10px;"><i class="bi bi-filetype-md"></i> Markdown (.md)</button>
<button class="btn-tool" id="downloadHtml" style="width:100%; text-align:left; padding:10px;"><i class="bi bi-filetype-html"></i> HTML (.html)</button>
<button class="btn-tool" id="downloadPdf" style="width:100%; text-align:left; padding:10px;"><i class="bi bi-filetype-pdf"></i> PDF (Imprimir)</button>
</div>
</div>
</div>
</header>
<div class="main-grid">
<!-- LEFT: EDITOR -->
<div class="card" id="dropZone">
<div class="card-header">
<span class="card-title">Documentos (Entrada)</span>
<span class="card-subtitle" id="inputStatus">Listo</span>
</div>
<div class="tabs-container" id="tabsContainer">
<!-- Tabs will be injected here -->
</div>
<div class="editor-container">
<div class="toolbar" id="customToolbar">
<button class="btn-tool" onclick="document.getElementById('fileInput').click()" title="Importar">
<i class="bi bi-upload"></i> Importar
</button>
<div style="width: 1px; background: var(--border-color); margin: 0 0.5rem;"></div>
<button class="btn-tool" data-format="bold" title="Negrita"><i class="bi bi-type-bold"></i></button>
<button class="btn-tool" data-format="italic" title="Cursiva"><i class="bi bi-type-italic"></i></button>
<button class="btn-tool" data-format="h1" title="Título 1"><i class="bi bi-type-h1"></i></button>
<button class="btn-tool" data-format="h2" title="Título 2"><i class="bi bi-type-h2"></i></button>
<button class="btn-tool" data-format="bullet" title="Lista"><i class="bi bi-list-ul"></i></button>
<button class="btn-tool" data-format="table" title="Tabla"><i class="bi bi-table"></i></button>
<div style="flex:1"></div>
<div style="display: flex; align-items: center; background: var(--toolbar-bg); border-radius: 6px; border: 1px solid var(--border-color);">
<button class="btn-tool" id="lineWrapBtn" title="Ajuste de líneas (quitar saltos sin punto final)" style="border:none; padding-right: 5px;"><i class="bi bi-text-paragraph"></i> Ajuste</button>
<button class="btn-tool" id="undoLineWrapBtn" title="Revertir ajuste" style="border:none; border-left: 1px solid var(--border-color); padding-left: 5px; padding-right: 5px; display:none;"><i class="bi bi-arrow-counterclockwise"></i></button>
</div>
<button class="btn-tool" id="llmFixBtn" title="Interpretar formato LLM (arreglar \n)"><i
class="bi bi-robot"></i> LLM</button>
<div style="width: 1px; background: var(--border-color); margin: 0 0.5rem;"></div>
<button class="btn-tool" id="findReplaceToggle" title="Buscar y Reemplazar"><i class="bi bi-search"></i> Buscar</button>
<button class="btn-tool" id="clearBtn" title="Limpiar"><i class="bi bi-trash"></i></button>
</div>
<div class="find-replace-bar" id="findReplaceBar">
<input type="text" id="findInput" placeholder="Buscar...">
<input type="text" id="replaceInput" placeholder="Reemplazar con...">
<button class="btn-tool" id="replaceBtn">Reemplazar</button>
<button class="btn-tool" id="replaceAllBtn">Todo</button>
<button class="btn-tool" onclick="document.getElementById('findReplaceBar').style.display='none'"><i class="bi bi-x"></i></button>
</div>
<div style="position: relative; flex: 1; display: flex; flex-direction: column;">
<div id="inputIndicator" class="sync-indicator"></div>
<textarea id="markdownInput" placeholder="Escribe markdown aquí o arrastra archivos..."></textarea>
</div>
<!-- Hidden components -->
<input type="file" id="fileInput" accept=".docx,.txt,.md,.pdf,audio/*,video/*,.mp3,.wav,.m4a,.mp4,.webm,.ogg,.flac,.mov,.mkv,.avi" multiple style="display: none;">
<div class="drop-overlay">
<div class="drop-message">
<i class="bi bi-cloud-upload" style="font-size: 3rem; margin-bottom: 1rem; display: block;"></i>
Suelta para Importar<br>
<span style="font-size: 0.9rem; font-weight: 400; opacity: 0.8">PDF, DOCX, TXT, Audio, Video</span>
</div>
</div>
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
</div>
</div>
</div>
<!-- RIGHT: PREVIEW -->
<div class="card">
<div class="card-header">
<span class="card-title">Vista Previa (Rich Text)</span>
<span class="card-subtitle" id="previewStatus">Sincronizado</span>
</div>
<div class="preview-container" style="position: relative;">
<div id="previewIndicator" class="sync-indicator"></div>
<div id="preview" class="markdown-body preview-content" contenteditable="true"></div>
</div>
</div>
</div>
<!-- Toast/Status -->
<div id="statusPill" class="status-pill"></div>
<!-- OCR MODAL -->
<dialog id="ocrModal">
<div class="modal-body">
<div class="modal-header">
<i class="bi bi-eye"></i> Procesamiento de PDF
</div>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem; font-size: 0.95rem;" id="ocrModalMsg">
Este PDF parece contener imágenes o ser un escaneo. ¿Cómo deseas procesarlo?
</p>
<div class="form-row">
<label>Páginas a procesar:</label>
<div class="form-radio">
<input type="radio" name="pageSelection" id="allPages" value="all" checked>
<label for="allPages" style="margin:0; cursor:pointer">Todas (<span
id="totalPages">0</span>)</label>
</div>
<div class="form-radio">
<input type="radio" name="pageSelection" id="customPages" value="custom">
<label for="customPages" style="margin:0; cursor:pointer">Páginas específicas</label>
</div>
</div>
<div id="customRangeInput" style="display:none; margin-top: 1rem;">
<input type="text" id="pageRanges" placeholder="Ej: 1-5, 8">
<small style="display:block; margin-top:0.5rem; color: var(--text-secondary)">Separa rangos con
comas.</small>
</div>
<div class="modal-actions">
<button class="btn-tool" onclick="document.getElementById('ocrModal').close()">Cancelar</button>
<button class="btn-main" id="startStandardBtn">Procesamiento Estándar</button>
<button class="btn-main primary" id="startOCRBtn">Mistral OCR (IA)</button>
</div>
</div>
</dialog>
<!-- CONFIG MODAL -->
<dialog id="configModal">
<div class="modal-body">
<div class="modal-header">
<i class="bi bi-gear"></i> Configuración
</div>
<div class="form-row">
<label for="apiKeyInput">Mistral API Key (para OCR):</label>
<input type="text" id="apiKeyInput" placeholder="Ingresa tu API Key aquí...">
<small style="display:block; margin-top:0.5rem; color: var(--text-secondary)">
La llave se guarda localmente en tu navegador.
</small>
</div>
<div class="form-row">
<label for="deepgramApiKeyInput">Deepgram API Key (para transcribir audio):</label>
<input type="text" id="deepgramApiKeyInput" placeholder="Ingresa tu API Key de Deepgram aquí...">
<small style="display:block; margin-top:0.5rem; color: var(--text-secondary)">
La llave se guarda localmente en tu navegador.
</small>
</div>
<div class="modal-actions">
<button class="btn-tool" onclick="document.getElementById('configModal').close()">Cancelar</button>
<button class="btn-main primary" id="saveConfigBtn">Guardar</button>
</div>
</div>
</dialog>
<script>
// --- CONFIGURATION ---
let MISTRAL_API_KEY = localStorage.getItem('mistral_api_key') || '';
let DEEPGRAM_API_KEY = localStorage.getItem('deepgram_api_key') || '';
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.js';
}
// --- DOM Elements ---
const markdownInput = document.getElementById('markdownInput');
const preview = document.getElementById('preview');
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const progressBar = document.getElementById('progressBar');
const statusPill = document.getElementById('statusPill');
const inputStatus = document.getElementById('inputStatus');
const previewStatus = document.getElementById('previewStatus');
const tabsContainer = document.getElementById('tabsContainer');
const inputIndicator = document.getElementById('inputIndicator');
const previewIndicator = document.getElementById('previewIndicator');
let currentPDFDoc = null;
let currentPDFFile = null;
let lastMarkdownState = null;
// --- TABS STATE ---
let documents = []; // { id, name, content }
let activeDocId = null;
// --- 1. INITIALIZATION & SYNC ---
marked.use({ gfm: true, breaks: true });
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced'
});
if (typeof turndownPluginGfm !== 'undefined') {
turndownService.use(turndownPluginGfm.gfm);
}
// Listener para el input de Markdown
markdownInput.addEventListener('input', () => {
if (activeDocId !== null) {
const doc = documents.find(d => d.id === activeDocId);
if (doc) doc.content = markdownInput.value;
}
updatePreview();
});
function updatePreview() {
const raw = markdownInput.value;
let html = marked.parse(raw);
html = DOMPurify.sanitize(html);
html = html.replace(/<table/g, '<div class="table-wrapper"><table');
html = html.replace(/<\/table>/g, '</table></div>');
preview.innerHTML = html;
// Auto-guardado
localStorage.setItem('estacion_docs_content', raw);
}
function updateMarkdownFromPreview() {
const md = turndownService.turndown(preview.innerHTML);
markdownInput.value = md;
if (activeDocId !== null) {
const doc = documents.find(d => d.id === activeDocId);
if (doc) doc.content = md;
}
localStorage.setItem('estacion_docs_content', md);
}
preview.addEventListener('input', updateMarkdownFromPreview);
// --- 2. FILE HANDLING ---
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
window.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); });
});
dropZone.addEventListener('dragenter', () => dropZone.classList.add('drop-active'));
dropZone.addEventListener('dragover', () => dropZone.classList.add('drop-active'));
dropZone.addEventListener('dragleave', (e) => {
if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove('drop-active');
});
dropZone.addEventListener('drop', (e) => {
dropZone.classList.remove('drop-active');
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) handleFiles(e.target.files);
});
async function handleFiles(files) {
setStatus(`&#x1f4e5; Procesando ${files.length} archivo(s)...`, false);
for (let i = 0; i < files.length; i++) {
await handleFile(files[i]);
}
renderTabs();
}
async function handleFile(file) {
const ext = file.name.split('.').pop().toLowerCase();
const fileNameNoExt = file.name.replace(/\.[^/.]+$/, "");
setProgress(15);
try {
let content = "";
if (ext === 'txt' || ext === 'md') {
content = await file.text();
}
else if (ext === 'docx') {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
content = turndownService.turndown(result.value);
}
else if (ext === 'pdf') {
content = await processPDF(file);
}
else if (isAudioFile(file, ext)) {
content = await processAudio(file);
}
else {
setStatus('&#x26a0;&#xfe0f; Formato no reconocido: ' + file.name);
return;
}
if (content !== undefined) {
const newDoc = {
id: Date.now() + Math.random(),
name: fileNameNoExt,
content: content
};
documents.push(newDoc);
setActiveTab(newDoc.id);
}
} catch (err) {
console.error(err);
setStatus('&#x274c; Error en ' + file.name + ': ' + err.message);
}
setProgress(100);
}
function isAudioFile(file, ext) {
return file.type.startsWith('audio/') || ['mp3', 'wav', 'm4a', 'mp4', 'webm', 'ogg', 'flac'].includes(ext);
}
async function processAudio(file) {
if (!DEEPGRAM_API_KEY) {
configModal.showModal();
throw new Error('API Key de Deepgram requerida. Guárdala en configuración.');
}
setStatus('&#x1f399;&#xfe0f; Transcribiendo audio con Deepgram...', false);
setProgress(20);
const contentType = file.type || getAudioContentType(file.name);
const response = await fetch('https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=es', {
method: 'POST',
headers: {
'Authorization': `Token ${DEEPGRAM_API_KEY}`,
'Content-Type': contentType
},
body: file
});
setProgress(80);
if (response.status === 401) {
configModal.showModal();
throw new Error('API Key de Deepgram inválida. Revísala en configuración.');
}
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.err_msg || errData.error || `Error ${response.status}`);
}
const data = await response.json();
const alternative = data.results?.channels?.[0]?.alternatives?.[0];
const transcript = alternative?.paragraphs?.transcript?.trim() || alternative?.transcript?.trim();
if (!transcript) throw new Error('Deepgram no devolvió transcripción.');
const confidence = typeof alternative.confidence === 'number'
? `\n\n_Confianza estimada: ${(alternative.confidence * 100).toFixed(1)}%_`
: '';
setStatus('&#x2705; Audio transcrito');
return `# Transcripción: ${file.name}\n\n${transcript}${confidence}\n`;
}
function getAudioContentType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const types = {
mp3: 'audio/mpeg',
wav: 'audio/wav',
m4a: 'audio/mp4',
mp4: 'audio/mp4',
webm: 'audio/webm',
ogg: 'audio/ogg',
flac: 'audio/flac'
};
return types[ext] || 'application/octet-stream';
}
function renderTabs() {
tabsContainer.innerHTML = '';
documents.forEach(doc => {
const tab = document.createElement('div');
tab.className = `tab ${doc.id === activeDocId ? 'active' : ''}`;
tab.onclick = () => setActiveTab(doc.id);
const name = document.createElement('span');
name.textContent = doc.name;
const close = document.createElement('i');
close.className = 'bi bi-x tab-close';
close.onclick = (e) => {
e.stopPropagation();
closeTab(doc.id);
};
tab.appendChild(name);
tab.appendChild(close);
tabsContainer.appendChild(tab);
});
}
function setActiveTab(id) {
activeDocId = id;
const doc = documents.find(d => d.id === id);
if (doc) {
markdownInput.value = doc.content;
updatePreview();
}
renderTabs();
}
function closeTab(id) {
const index = documents.findIndex(d => d.id === id);
if (index !== -1) {
documents.splice(index, 1);
if (activeDocId === id) {
activeDocId = documents.length > 0 ? documents[documents.length - 1].id : null;
}
if (activeDocId !== null) {
setActiveTab(activeDocId);
} else {
markdownInput.value = '';
updatePreview();
renderTabs();
}
}
}
// --- 3. PDF PROCESSING ---
async function processPDF(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
currentPDFDoc = pdf;
currentPDFFile = file;
// Check for images or low text
let hasImages = false;
let totalTextLength = 0;
for (let i = 1; i <= Math.min(pdf.numPages, 3); i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
totalTextLength += content.items.reduce((acc, item) => acc + item.str.trim().length, 0);
const ops = await page.getOperatorList();
for (let j = 0; j < ops.fnArray.length; j++) {
if (ops.fnArray[j] === pdfjsLib.OPS.paintImageXObject ||
ops.fnArray[j] === pdfjsLib.OPS.paintInlineImageXObject) {
hasImages = true;
break;
}
}
if (hasImages) break;
}
const isScanned = totalTextLength < 50;
if (isScanned || hasImages) {
document.getElementById('totalPages').textContent = pdf.numPages;
document.getElementById('ocrModalMsg').textContent = isScanned
? "Este PDF parece ser un escaneo. ¿Deseas usar IA para extraer el texto?"
: "Este PDF contiene imágenes. ¿Deseas usar Mistral OCR para una mejor extracción de tablas y gráficos?";
// We wrap the modal in a promise to allow the sequential handleFiles to wait
return new Promise((resolve) => {
const modal = document.getElementById('ocrModal');
const onStandard = async () => {
cleanup();
const md = await runStandardPDFProcessing(pdf);
resolve(md);
};
const onOCR = () => {
cleanup();
// OCR logic will create its own tab and handle everything
// We resolve with undefined so handleFile doesn't create a duplicate tab
startOCRProcess();
resolve(undefined);
};
const onCancel = () => {
cleanup();
resolve(undefined);
};
const cleanup = () => {
document.getElementById('startStandardBtn').removeEventListener('click', onStandard);
document.getElementById('startOCRBtn').removeEventListener('click', onOCR);
modal.removeEventListener('close', onCancel);
modal.close();
};
document.getElementById('startStandardBtn').addEventListener('click', onStandard);
document.getElementById('startOCRBtn').addEventListener('click', onOCR);
modal.addEventListener('close', onCancel);
modal.showModal();
setStatus('&#x1f441;&#xfe0f; PDF complejo detectado', false);
});
} else {
return await runStandardPDFProcessing(pdf);
}
}
async function runStandardPDFProcessing(pdf) {
let fullScrapedText = "";
for (let i = 1; i <= pdf.numPages; i++) {
setStatus(`&#x1f4c4; Analizando pág ${i}/${pdf.numPages}...`, false);
setProgress((i / pdf.numPages) * 100);
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const items = textContent.items.sort((a, b) => {
if (Math.abs(a.transform[5] - b.transform[5]) > 5) return b.transform[5] - a.transform[5];
return a.transform[4] - b.transform[4];
});
let pageText = "";
let lastY = null;
items.forEach(item => {
const currentY = item.transform[5];
if (lastY !== null && Math.abs(lastY - currentY) > 5) pageText += '\n';
pageText += item.str + (item.str.trim() ? " " : "");
lastY = currentY;
});
fullScrapedText += pageText + "\n\n";
}
const structure = analyzeTextStructure(fullScrapedText);
const markdown = convertStructureToMarkdown(structure);
setStatus('&#x2705; PDF procesado');
return markdown;
}
// --- TABLE DETECTION LOGIC ---
function analyzeTextStructure(textContent) {
const lines = textContent.split('\n').filter(line => line.trim());
const analysis = { tables: [], paragraphs: [] };
let lineIndex = 0;
while (lineIndex < lines.length) {
const line = lines[lineIndex].trim();
// Detect Table
if (detectTableRow(line) && lineIndex < lines.length - 1) {
const tableRows = [line];
let nextIdx = lineIndex + 1;
while (nextIdx < lines.length && detectTableRow(lines[nextIdx])) {
tableRows.push(lines[nextIdx]);
nextIdx++;
}
if (tableRows.length >= 2) {
analysis.tables.push({ rows: tableRows, startLine: lineIndex });
lineIndex = nextIdx;
continue;
}
}
analysis.paragraphs.push({ text: line, lineIndex });
lineIndex++;
}
return analysis;
}
function detectTableRow(line) {
// Split by 2+ spaces or tabs, ensure 2+ cols
const parts = line.trim().split(/\s{2,}|\t/);
return parts.length >= 2 && parts.every(p => p.trim().length > 0);
}
function convertStructureToMarkdown(structure) {
let all = [];
structure.tables.forEach(t => all.push({ ...t, type: 'table' }));
structure.paragraphs.forEach(p => all.push({ ...p, type: 'paragraph' }));
all.sort((a, b) => (a.lineIndex || a.startLine) - (b.lineIndex || b.startLine));
let md = "";
all.forEach(el => {
if (el.type === 'table') {
const rowsData = el.rows.map(r => r.trim().split(/\s{2,}|\t/).map(c => c.trim()));
if (rowsData.length > 0) {
const header = rowsData[0];
md += `\n| ${header.join(' | ')} |\n`;
md += `| ${header.map(() => '---').join(' | ')} |\n`;
for (let i = 1; i < rowsData.length; i++) md += `| ${rowsData[i].join(' | ')} |\n`;
md += "\n";
}
} else {
md += `${el.text}\n\n`;
}
});
return md;
}
// --- OCR LOGIC ---
const customPagesRadio = document.getElementById('customPages');
const allPagesRadio = document.getElementById('allPages');
const customRangeInput = document.getElementById('customRangeInput');
customPagesRadio.addEventListener('change', () => customRangeInput.style.display = 'block');
allPagesRadio.addEventListener('change', () => customRangeInput.style.display = 'none');
async function startOCRProcess() {
setStatus('&#x1f916; Iniciando OCR...', false);
const fileNameNoExt = currentPDFFile.name.replace(/\.[^/.]+$/, "");
const newDoc = { id: Date.now() + Math.random(), name: fileNameNoExt + " (OCR)", content: "⌛ Procesando OCR..." };
documents.push(newDoc);
setActiveTab(newDoc.id);
renderTabs();
let pages = [];
if (allPagesRadio.checked) {
pages = Array.from({ length: currentPDFDoc.numPages }, (_, i) => i + 1);
} else {
const val = document.getElementById('pageRanges').value;
val.split(',').forEach(p => {
if (p.includes('-')) {
const [s, e] = p.split('-').map(Number);
for (let i = s; i <= e; i++) pages.push(i);
} else pages.push(Number(p));
});
}
let finalText = "";
try {
for (let i = 0; i < pages.length; i++) {
const pageNum = pages[i];
if (!documents.find(d => d.id === newDoc.id)) break; // Stop if tab closed
setStatus(`&#x1f441;&#xfe0f; OCR pág ${pageNum}...`, false);
setProgress(((i + 1) / pages.length) * 100);
const img = await renderPageAsImage(currentPDFDoc, pageNum);
const text = await mistralOCR(img);
finalText += (finalText ? "\n\n" : "") + `## Página ${pageNum}\n\n${text}`;
const doc = documents.find(d => d.id === newDoc.id);
if (doc) {
doc.content = finalText;
if (activeDocId === newDoc.id) {
markdownInput.value = finalText;
updatePreview();
}
}
}
setStatus('&#x2705; OCR Terminado');
} catch (e) {
console.error(e);
const doc = documents.find(d => d.id === newDoc.id);
if (doc) {
doc.content += "\n\n❌ Error OCR: " + e.message;
if (activeDocId === newDoc.id) {
markdownInput.value = doc.content;
updatePreview();
}
}
setStatus('&#x274c; Error en OCR');
}
}
async function renderPageAsImage(pdf, num) {
const page = await pdf.getPage(num);
const viewport = page.getViewport({ scale: 2.0 }); // Aumentamos escala para mejor OCR
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport }).promise;
// Mistral acepta base64 puro o URL. Usaremos el formato data:image/jpeg;base64,...
return canvas.toDataURL('image/jpeg', 0.7);
}
async function mistralOCR(dataUrl) {
try {
const res = await fetch('https://api.mistral.ai/v1/ocr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MISTRAL_API_KEY}`
},
body: JSON.stringify({
model: 'mistral-ocr-latest',
document: {
type: 'image_url',
image_url: dataUrl // dataUrl ya incluye 'data:image/jpeg;base64,...'
},
include_image_base64: false
})
});
if (res.status === 401) {
configModal.showModal();
throw new Error("API Key inválida. Por favor revísala en configuración.");
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error?.message || `Error ${res.status}`);
}
const data = await res.json();
const pages = data.pages || [];
const markdown = pages.map(page => page.markdown || '').filter(Boolean).join('\n\n');
if (!markdown && !data.markdown) throw new Error("Respuesta de API vacía");
return markdown || data.markdown;
} catch (error) {
console.error("Mistral API Error:", error);
throw error;
}
}
// --- TOOLS & UTILS ---
document.getElementById('llmFixBtn').addEventListener('click', () => {
const current = markdownInput.value;
const fixed = current.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
markdownInput.value = fixed;
updatePreview();
setStatus('&#x1f916; Formato LLM corregido');
});
document.getElementById('lineWrapBtn').addEventListener('click', () => {
const current = markdownInput.value;
lastMarkdownState = current; // Guardar para revertir
document.getElementById('undoLineWrapBtn').style.display = 'block';
// Explicación de la lógica:
// Buscamos saltos de línea (\n) que NO estén precedidos por un punto (.)
// Los reemplazamos por un espacio.
// Usamos un regex que mira atrás (lookbehind) si el motor lo soporta,
// o simplemente una búsqueda que capture el carácter previo si no es un punto.
// Lógica: Dividir por líneas y unir si la línea anterior no termina en punto.
const lines = current.split('\n');
let result = "";
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
let nextLine = lines[i+1];
result += line;
if (nextLine !== undefined) {
const trimmedLine = line.trim();
if (trimmedLine.length > 0 && !trimmedLine.endsWith('.')) {
result += " "; // Reemplazamos salto de línea por espacio
} else {
result += "\n"; // Mantenemos el salto de línea
}
}
}
markdownInput.value = result;
updatePreview();
setStatus('&#x2705; Ajuste de líneas completado');
});
document.getElementById('undoLineWrapBtn').addEventListener('click', () => {
if (lastMarkdownState !== null) {
markdownInput.value = lastMarkdownState;
updatePreview();
lastMarkdownState = null;
document.getElementById('undoLineWrapBtn').style.display = 'none';
setStatus('&#x21aa; Ajuste revertido');
}
});
document.getElementById('clearBtn').addEventListener('click', () => {
markdownInput.value = '';
updatePreview();
});
document.querySelectorAll('button[data-format]').forEach(btn => {
btn.addEventListener('click', () => {
const fmt = btn.dataset.format;
const start = markdownInput.selectionStart;
const end = markdownInput.selectionEnd;
const text = markdownInput.value;
const sel = text.substring(start, end);
let insert = '';
switch (fmt) {
case 'bold': insert = `**${sel}**`; break;
case 'italic': insert = `*${sel}*`; break;
case 'h1': insert = `# ${sel}`; break;
case 'h2': insert = `## ${sel}`; break;
case 'bullet': insert = `- ${sel}`; break;
case 'table': insert = `\n| H1 | H2 |\n|---|---|\n| C1 | C2 |\n`; break;
}
markdownInput.setRangeText(insert, start, end, 'select');
updatePreview();
markdownInput.focus();
});
});
// --- FIND & REPLACE ---
const findReplaceBar = document.getElementById('findReplaceBar');
const findInput = document.getElementById('findInput');
const replaceInput = document.getElementById('replaceInput');
const replaceBtn = document.getElementById('replaceBtn');
const replaceAllBtn = document.getElementById('replaceAllBtn');
document.getElementById('findReplaceToggle').addEventListener('click', () => {
findReplaceBar.style.display = findReplaceBar.style.display === 'none' ? 'flex' : 'none';
if (findReplaceBar.style.display === 'flex') findInput.focus();
});
replaceBtn.addEventListener('click', () => {
const findText = findInput.value;
const replaceText = replaceInput.value;
if (!findText) return;
const content = markdownInput.value;
const start = markdownInput.selectionStart;
const index = content.indexOf(findText, start);
if (index !== -1) {
markdownInput.setRangeText(replaceText, index, index + findText.length, 'select');
markdownInput.dispatchEvent(new Event('input'));
} else {
const firstIndex = content.indexOf(findText);
if (firstIndex !== -1) {
markdownInput.setRangeText(replaceText, firstIndex, firstIndex + findText.length, 'select');
markdownInput.dispatchEvent(new Event('input'));
}
}
});
replaceAllBtn.addEventListener('click', () => {
const findText = findInput.value;
const replaceText = replaceInput.value;
if (!findText) return;
const content = markdownInput.value;
const newContent = content.split(findText).join(replaceText);
markdownInput.value = newContent;
markdownInput.dispatchEvent(new Event('input'));
setStatus('&#x2705; Reemplazado todo');
});
// --- BREATHING SYNC INDICATOR ---
function updateIndicators() {
const text = markdownInput.value;
const pos = markdownInput.selectionStart;
const lines = text.substring(0, pos).split('\n');
const currentLineNum = lines.length;
const lineHeight = 25.6;
const paddingTop = 24;
const y = (currentLineNum - 1) * lineHeight + paddingTop;
inputIndicator.style.display = 'block';
inputIndicator.style.top = `${y + 8}px`;
const totalLines = text.split('\n').length;
const percentage = currentLineNum / totalLines;
const previewScrollHeight = preview.scrollHeight;
const previewY = percentage * previewScrollHeight;
previewIndicator.style.display = 'block';
previewIndicator.style.top = `${previewY}px`;
}
markdownInput.addEventListener('click', updateIndicators);
markdownInput.addEventListener('keyup', updateIndicators);
markdownInput.addEventListener('input', updateIndicators);
markdownInput.addEventListener('blur', () => {
setTimeout(() => {
inputIndicator.style.display = 'none';
previewIndicator.style.display = 'none';
}, 500);
});
// Export logic
const exportBtn = document.getElementById('exportBtn');
const exportMenu = document.getElementById('exportMenu');
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.style.display = exportMenu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', () => exportMenu.style.display = 'none');
document.getElementById('downloadMd').addEventListener('click', () => {
const blob = new Blob([markdownInput.value], { type: 'text/markdown' });
const filename = (documents.find(d => d.id === activeDocId)?.name || 'documento') + '.md';
downloadFile(blob, filename);
});
document.getElementById('downloadHtml').addEventListener('click', () => {
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Documento Exportado</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap">
<style>
body { font-family: 'Outfit', sans-serif; line-height: 1.6; color: #1e293b; max-width: 800px; margin: 40px auto; padding: 20px; }
h1, h2, h3 { color: #3b82f6; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #e2e8f0; padding: 12px; text-align: left; }
th { background: #f8fafc; }
code { background: #f1f5f9; padding: 2px 4px; border-radius: 4px; font-family: monospace; }
</style>
</head>
<body>${preview.innerHTML}</body>
</html>
`;
const blob = new Blob([fullHtml], { type: 'text/html' });
const filename = (documents.find(d => d.id === activeDocId)?.name || 'documento') + '.html';
downloadFile(blob, filename);
});
document.getElementById('downloadPdf').addEventListener('click', () => {
window.print();
});
function downloadFile(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// Config logic
const configModal = document.getElementById('configModal');
const apiKeyInput = document.getElementById('apiKeyInput');
const deepgramApiKeyInput = document.getElementById('deepgramApiKeyInput');
document.getElementById('configBtn').addEventListener('click', () => {
apiKeyInput.value = MISTRAL_API_KEY;
deepgramApiKeyInput.value = DEEPGRAM_API_KEY;
configModal.showModal();
});
document.getElementById('saveConfigBtn').addEventListener('click', () => {
MISTRAL_API_KEY = apiKeyInput.value.trim();
DEEPGRAM_API_KEY = deepgramApiKeyInput.value.trim();
localStorage.setItem('mistral_api_key', MISTRAL_API_KEY);
localStorage.setItem('deepgram_api_key', DEEPGRAM_API_KEY);
configModal.close();
setStatus('&#x2705; Configuración guardada');
});
function setStatus(msg, autoHide = true) {
statusPill.innerHTML = msg;
statusPill.style.display = 'block';
// Si hay un error (contiene la cruz o error), no lo ocultamos tan rápido
const delay = msg.includes('❌') || msg.includes('Error') ? 10000 : 4000;
if (autoHide) setTimeout(() => { statusPill.style.display = 'none'; }, delay);
}
function setProgress(p) {
progressBar.style.width = `${p}%`;
if (p >= 100) setTimeout(() => progressBar.style.width = '0%', 500);
}
// Init
const saved = localStorage.getItem('estacion_docs_content');
if (saved) {
markdownInput.value = saved;
// Create a default doc for saved content
const defaultDoc = { id: Date.now(), name: 'Documento Guardado', content: saved };
documents.push(defaultDoc);
activeDocId = defaultDoc.id;
} else {
markdownInput.value = "# Listo\n\nArrastra un archivo o escribe aquí.";
}
renderTabs();
updatePreview();
</script>
</body>
</html>