AI-markdown/laravel-app/resources/views/email_convert.blade.php

1315 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', 'Email Convert — AI Markdown Demo')
@section('head')
<style>
/* ── 3-column layout helpers ──────────────────────────────────── */
#ctxPanel {
height: calc(100vh - 110px);
display: flex;
flex-direction: column;
overflow: hidden;
}
#ctxPanelBody {
flex: 1 1 0;
overflow-y: auto;
padding: 1rem;
}
#ctxConvertWrap {
margin-top: auto;
padding: .75rem 1rem;
border-top: 1px solid #dee2e6;
background: #fff;
}
/* ── 3 selector labels ───────────────────────────────────────── */
.sel-label { font-size: .68rem; font-weight: 700; letter-spacing: .07em; margin-bottom: 2px; display: flex; align-items: center; gap: 4px; }
/* ── Tree entries (no action buttons, role-aware) ─────────────── */
.file-entry {
display: flex; align-items: center; gap: 4px;
padding: 3px 6px; border-radius: 4px;
cursor: pointer; user-select: none;
transition: background .1s;
}
.file-entry:hover { background: #f8f9fa; }
.file-entry.input-sel { background: #cfe2ff; }
.file-entry.output-sel { background: #d1e7dd; }
.file-entry.preview-sel { background: #e2e3e5; }
.file-entry.indent-1 { padding-left: 18px; }
.file-entry.indent-2 { padding-left: 32px; }
.file-entry.indent-3 { padding-left: 46px; }
.file-entry.indent-4 { padding-left: 60px; }
.path-badge {
font-size: .65rem; color: #6c757d; font-family: monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px;
}
.ResultPre { white-space: pre-wrap; word-break: break-word; font-size: .75rem; max-height: 55vh; overflow-y: auto; }
/* github-markdown-css handles preview styling */
.PreviewPane { max-height: 55vh; overflow-y: auto; padding: .75rem; font-size: .82rem; }
#resultsCol { display: flex; flex-direction: column; gap: 1rem; height: calc(100vh - 110px); overflow-y: auto; }
#ctxEmpty, #ctxDir, #ctxFile { display: none; }
</style>
@endsection
@section('content')
<div class="row g-3">
{{-- ── Col 1: Tree browser (col-3, full height) ─────────────── --}}
<div class="col-lg-3">
<div class="card shadow-sm d-flex flex-column" style="height:calc(100vh - 110px)">
{{-- Header --}}
<div class="card-header py-2 d-flex align-items-center gap-2 flex-shrink-0">
<i class="bi bi-hdd-fill text-secondary"></i>
<span class="fw-semibold small flex-grow-1">Server</span>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" id="btnRefresh" title="Refresh">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
{{-- 3 select boxes --}}
<div class="px-2 pt-2 pb-2 border-bottom flex-shrink-0 d-flex flex-column gap-2">
{{-- INPUT --}}
<div>
<div class="sel-label text-primary"><i class="bi bi-folder-check"></i> INPUT</div>
<select class="form-select form-select-sm" id="selRowInput" onchange="onSelectChange('input',this.value)">
<option value=""> Chưa chọn </option>
<option value="/workspace/{{ $workingDir }}/input">{{ $workingDir }}/input/</option>
<option value="/workspace/{{ $workingDir }}">{{ $workingDir }}/</option>
<option value="/workspace">/ workspace</option>
</select>
</div>
{{-- OUTPUT --}}
<div>
<div class="sel-label text-success"><i class="bi bi-folder-symlink"></i> OUTPUT</div>
<select class="form-select form-select-sm" id="selRowOutput" onchange="onSelectChange('output',this.value)">
<option value="/workspace/{{ $workingDir }}/output" selected>/{{ $workingDir }}/output/</option>
<option value="/workspace/{{ $workingDir }}">/{{ $workingDir }}/</option>
<option value="/workspace">/ workspace</option>
<option value=""> Không lưu </option>
</select>
</div>
{{-- PREVIEW --}}
<div>
<div class="sel-label text-secondary"><i class="bi bi-eye"></i> PREVIEW</div>
<select class="form-select form-select-sm" id="selRowPreview" onchange="onSelectChange('preview',this.value)">
<option value=""> Chưa chọn </option>
</select>
</div>
</div>{{-- /3 select boxes --}}
{{-- Browse tree base path --}}
<div class="px-2 pt-1 pb-1 border-bottom flex-shrink-0">
<select class="form-select form-select-sm" id="basePath">
<option value="/workspace/{{ $workingDir }}/input">browse: {{ $workingDir }}/input/</option>
<option value="/workspace/{{ $workingDir }}">browse: {{ $workingDir }}/</option>
<option value="/workspace/{{ $workingDir }}/output">browse: {{ $workingDir }}/output/</option>
<option value="/workspace">browse: / workspace</option>
</select>
</div>
{{-- Tree --}}
<div class="flex-grow-1 overflow-auto p-1" id="fileBrowser">
<div class="text-center text-muted py-4 small" id="browserLoading">
<div class="spinner-border spinner-border-sm mb-2"></div><br>Đang tải...
</div>
</div>
</div>
</div>
{{-- ── Col 2: Context panel (col-3, full height) ─────────────── --}}
<div class="col-lg-3">
<div class="card shadow-sm" id="ctxPanel">
{{-- State: nothing selected --}}
<div id="ctxEmpty" style="display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;color:#adb5bd;padding:2rem;text-align:center">
<i class="bi bi-folder2 fs-1 mb-3"></i>
<div class="small">Chọn file hoặc thư mục<br>từ cây bên trái</div>
</div>
{{-- State: folder selected --}}
<div id="ctxDir">
<div class="px-3 pt-3 pb-2 border-bottom flex-shrink-0">
<div class="d-flex align-items-center gap-2 mb-1">
<i class="bi bi-folder2-open fs-5 text-warning flex-shrink-0" id="selIcon"></i>
<div class="overflow-hidden flex-grow-1">
<div class="fw-semibold small text-truncate" id="selName"></div>
<div class="path-badge" id="selPath"></div>
</div>
<span class="badge bg-secondary-subtle text-secondary flex-shrink-0" id="dirBadge" style="display:none"></span>
</div>
</div>
<div id="ctxPanelBody">
{{-- Service checkboxes (stacked) --}}
<div class="mb-3">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="useMd" checked>
<label class="form-check-label small fw-medium text-primary" for="useMd">
<span class="badge rounded-circle p-1 bg-primary me-1">&nbsp;</span>MarkItDown
</label>
</div>
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="useDl" checked>
<label class="form-check-label small fw-medium text-success" for="useDl">
<span class="badge rounded-circle p-1 bg-success me-1">&nbsp;</span>Docling
</label>
</div>
</div>
{{-- Docling format --}}
<div id="dlFormatWrap" class="mb-3">
<label class="form-label small mb-1">Format (Docling)</label>
<select class="form-select form-select-sm" id="dlFormat">
<option value="markdown">Markdown</option>
<option value="json">JSON</option>
<option value="html">HTML</option>
<option value="text">Plain Text</option>
</select>
</div>
{{-- LLM toggle --}}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="llmToggle" checked>
<label class="form-check-label small fw-medium" for="llmToggle">LLM</label>
</div>
<hr class="my-2">
{{-- Output folder --}}
<div class="mb-2">
<label class="form-label small mb-1 fw-semibold">
<i class="bi bi-folder-symlink text-secondary me-1"></i>Output folder
</label>
<input type="text" class="form-control form-control-sm font-monospace mb-2"
id="outputPath" placeholder="/workspace/{{ $workingDir }}/output"
value="/workspace/{{ $workingDir }}/output" style="font-size:.72rem">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="saveToServer" checked>
<label class="form-check-label small" for="saveToServer">Lưu lên server</label>
</div>
</div>
</div>{{-- /ctxPanelBody --}}
<div id="ctxConvertWrap">
<button class="btn btn-primary w-100" id="btnConvert" disabled>
<i class="bi bi-play-fill me-1"></i>Chuyển đổi
</button>
</div>
</div>{{-- /ctxDir --}}
{{-- State: single file selected --}}
<div id="ctxFile">
<div class="px-3 pt-3 pb-2 border-bottom flex-shrink-0">
<div class="d-flex align-items-center gap-2 mb-1">
<i class="bi bi-file-earmark-text fs-5 text-secondary flex-shrink-0"></i>
<div class="overflow-hidden flex-grow-1">
<div class="fw-semibold small text-truncate" id="selNameFile"></div>
<div class="path-badge" id="selPathFile"></div>
</div>
</div>
</div>
<div class="p-3 flex-grow-1">
<div class="mb-3">
<div class="small text-muted mb-2 fw-semibold">Dịch vụ chuyển đổi</div>
<div class="d-flex gap-3 flex-wrap">
<span class="small text-primary"><i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>MarkItDown</span>
<span class="small text-success"><i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>Docling</span>
</div>
<div class="small text-muted mt-1" style="font-size:.72rem">(dùng cài đặt từ cột bên)</div>
</div>
</div>
<div style="margin-top:auto;padding:.75rem 1rem;border-top:1px solid #dee2e6;background:#fff;">
<button class="btn btn-primary w-100" id="btnConvertFile" disabled>
<i class="bi bi-play-fill me-1"></i>Chuyển đổi
</button>
</div>
</div>{{-- /ctxFile --}}
</div>
</div>
{{-- ── Col 3: Results area (col-6) ──────────────────────────── --}}
<div class="col-lg-6">
<div id="resultsCol">
{{-- Empty placeholder --}}
<div id="resultsEmpty" class="card shadow-sm text-muted" style="display:flex;align-items:center;justify-content:center;height:200px">
<div class="text-center">
<i class="bi bi-layout-text-sidebar fs-1 mb-2 d-block"></i>
<div class="small">Kết quả sẽ hiển thị đây</div>
</div>
</div>
{{-- Restore panel injected here by JS (before #batchResult) --}}
{{-- ── Tree inline preview (.md / .txt) ───────────────────── --}}
<div id="treePreview" style="display:none">
<div class="card shadow-sm">
<div class="card-header py-2 d-flex align-items-center gap-2">
<i class="bi bi-markdown text-secondary"></i>
<span class="small fw-semibold flex-grow-1" id="treePreviewTitle"></span>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary active" id="treePreviewModeRaw">Raw</button>
<button class="btn btn-outline-secondary" id="treePreviewModeRender">Preview</button>
</div>
<button class="btn btn-sm btn-close" id="treePreviewClose"></button>
</div>
<div id="treePreviewBody" style="max-height:calc(100vh - 260px);overflow-y:auto"></div>
</div>
</div>
{{-- ── Single file result ─────────────────────────────────── --}}
<div id="singleResult" style="display:none">
<div class="row g-3">
@foreach([['md','MarkItDown','bg-primary','text-primary'],['dl','Docling','bg-success','text-success']] as [$k,$label,$bg,$tc])
<div class="col-md-6 pane-col-{{ $k }}">
<div class="card shadow-sm h-100">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold small d-flex align-items-center gap-2">
<span class="badge rounded-circle p-1 {{ $bg }}">&nbsp;</span>{{ $label }}
<span class="badge bg-primary-subtle text-primary llm-badge-{{ $k }}" style="display:none;font-size:.65rem">🤖 LLM</span>
</span>
<div class="d-flex align-items-center gap-1">
<small class="text-muted status-text-{{ $k }}"></small>
<button class="btn btn-outline-secondary btn-sm py-0 px-2 btn-dl-{{ $k }}" style="display:none">
<i class="bi bi-download"></i>
</button>
@include('partials.pane-tabs', ['k' => $k])
</div>
</div>
<div class="card-body p-0" style="min-height:200px">
@include('partials.pane-body', ['k' => $k, 'tc' => $tc, 'idle' => 'Chờ convert...'])
</div>
</div>
</div>
@endforeach
</div>
</div>
{{-- ── Batch dir result ───────────────────────────────────── --}}
<div id="batchResult" style="display:none">
<div class="card shadow-sm mb-3" id="batchProgress">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="fw-semibold" id="batchLabel">Đang xử ...</small>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-warning py-0 px-2" id="btnContinueJob" style="display:none;font-size:.72rem">
<i class="bi bi-play-fill me-1"></i>Tiếp tục
</button>
<small class="text-muted" id="batchCounter">0 / 0</small>
</div>
</div>
<div class="progress" style="height:6px">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary" id="batchBar" style="width:0%"></div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span class="small fw-semibold"><i class="bi bi-list-check me-1"></i>Kết quả</span>
<button class="btn btn-outline-secondary btn-sm py-0 px-2" id="btnDownloadAll" style="display:none">
<i class="bi bi-download me-1"></i>Tải tất cả (.zip)
</button>
</div>
<div class="table-responsive" style="max-height:calc(100vh - 320px);overflow-y:auto">
<table class="table table-sm table-hover mb-0" id="batchTable">
<thead class="table-light sticky-top">
<tr>
<th class="ps-3">File</th>
<th style="width:80px">MarkItDown</th>
<th style="width:80px">Docling</th>
<th style="width:80px">Server</th>
<th style="width:90px">Thời gian</th>
<th style="width:90px">Tải về</th>
</tr>
</thead>
<tbody id="batchBody"></tbody>
</table>
</div>
<div id="previewPanel" style="display:none;border-top:1px solid #dee2e6">
{{-- Header: title + service tabs + raw/render + close --}}
<div class="d-flex align-items-center gap-2 px-3 py-2 bg-light border-bottom flex-wrap">
<span class="small fw-semibold text-truncate" id="previewTitle" style="max-width:200px"></span>
{{-- Service tabs --}}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-primary active" id="previewTabMd">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>MarkItDown
<span class="badge bg-white text-primary ms-1" id="previewMdMeta" style="font-size:.6rem"></span>
</button>
<button class="btn btn-outline-success" id="previewTabDl">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>Docling
<span class="badge bg-success-subtle text-success ms-1" id="previewDlMeta" style="font-size:.6rem"></span>
</button>
</div>
{{-- Raw / Render --}}
<div class="btn-group btn-group-sm ms-auto" role="group">
<button class="btn btn-outline-secondary active" id="previewModeRaw">Raw</button>
<button class="btn btn-outline-secondary" id="previewModeRender">Preview</button>
</div>
<button class="btn btn-sm btn-close" id="previewClose"></button>
</div>
{{-- Content area one tab at a time --}}
<div style="max-height:45vh;overflow-y:auto">
<div id="previewPaneMd">
<pre class="m-0 p-3 small preview-raw-pane" id="previewRawMd" style="white-space:pre-wrap;word-break:break-word"></pre>
<div class="PreviewPane markdown-body p-3 preview-render-pane" id="previewRenderMd" style="display:none;max-height:none"></div>
</div>
<div id="previewPaneDl" style="display:none">
<pre class="m-0 p-3 small preview-raw-pane" id="previewRawDl" style="white-space:pre-wrap;word-break:break-word"></pre>
<div class="PreviewPane markdown-body p-3 preview-render-pane" id="previewRenderDl" style="display:none;max-height:none"></div>
</div>
</div>
</div>
</div>
</div>{{-- /batchResult --}}
</div>{{-- /resultsCol --}}
</div>
</div>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script>
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } })
$(function () {
// ── State ─────────────────────────────────────────────────────
let expanded = {}
let rootEntries = []
let sel = { path: null, name: '', type: null, files: [] }
const singleResults = { md: '', dl: '' }
let batchResults = {}
let batchRunning = false
let currentJobId = null
let activeSelector = null // 'input' | 'output' | 'preview'
const WORKING_DIR = '/workspace/{{ $workingDir }}'
let outputPath = WORKING_DIR + '/output'
let previewSelPath = null
// ── Batch job DB helpers ──────────────────────────────────────
const csrf = $('meta[name="csrf-token"]').attr('content')
async function jobCreate(dirPath, dirName, outputBase, settings, files) {
const r = await fetch('/batch-jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
body: JSON.stringify({ dir_path: dirPath, dir_name: dirName, output_base: outputBase, settings, files }),
})
const d = await r.json()
return d.id
}
async function jobUpdate(id, path, entry, finished = false) {
await fetch(`/batch-jobs/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
body: JSON.stringify({ path, entry, finished }),
})
}
async function jobList() {
const r = await fetch('/batch-jobs')
return r.json()
}
async function jobGet(id) {
const r = await fetch(`/batch-jobs/${id}`)
return r.json()
}
async function jobDelete(id) {
await fetch(`/batch-jobs/${id}`, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': csrf } })
}
// ── Restore panel ─────────────────────────────────────────────
async function loadRestorePanel() {
const jobs = await jobList()
if (!jobs.length) return
const unfinished = jobs.filter(j => !j.finished)
const recent = jobs.slice(0, 5)
let html = '<div id="restorePanel" class="alert alert-info alert-dismissible py-2 px-3 mb-0 small d-flex align-items-center gap-2 flex-wrap">'
html += '<i class="bi bi-clock-history"></i>'
if (unfinished.length) {
const j = unfinished[0]
const pct = j.total ? Math.round(j.done_count / j.total * 100) : 0
html += `<span class="fw-semibold">Job chưa xong:</span>
<span class="text-truncate">${j.dir_name}</span>
<span class="badge bg-warning-subtle text-warning">${j.done_count}/${j.total} (${pct}%)</span>
<button class="btn btn-sm btn-primary py-0 px-2" onclick="restoreJob(${j.id})">Khôi phục</button>`
} else {
const j = recent[0]
html += `<span>Job gần nhất: <b>${j.dir_name}</b> — ${j.done_count}/${j.total} files</span>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="restoreJob(${j.id})">Xem lại</button>`
}
html += `<button type="button" class="btn-close btn-sm ms-auto" data-bs-dismiss="alert"></button></div>`
$('#batchResult').before(html)
}
window.restoreJob = async function(id) {
const job = await jobGet(id)
if (!job || !job.files) return
// Auto-remap stale paths if job was saved with a different working dir
const oldPrefix = job.dir_path
const newPrefix = WORKING_DIR + '/input'
const needRemap = oldPrefix && !oldPrefix.startsWith(WORKING_DIR) && oldPrefix.includes('/workspace/')
const fixPath = p => needRemap ? p.replace(oldPrefix, newPrefix) : p
if (needRemap) {
job.dir_path = newPrefix
job.dir_name = 'input'
job.output_base = job.output_base ? job.output_base.replace(/\/workspace\/[^\/]+\//, WORKING_DIR + '/') : job.output_base
job.files = job.files.map(f => ({ ...f, path: fixPath(f.path) }))
const fixedResults = {}
Object.entries(job.results || {}).forEach(([k, v]) => { fixedResults[fixPath(k)] = v })
job.results = fixedResults
}
$('#restorePanel').remove()
currentJobId = id
batchResults = {}
const files = job.files
const results = job.results || {}
const s = job.settings || {}
if (s.useMd !== undefined) $('#useMd').prop('checked', s.useMd)
if (s.useDl !== undefined) $('#useDl').prop('checked', s.useDl)
if (s.dlFmt) $('#dlFormat').val(s.dlFmt)
if (s.useLlm !== undefined) $('#llmToggle').prop('checked', s.useLlm)
// restore output path
if (job.output_base) {
outputPath = job.output_base
$('#outputPath').val(job.output_base)
addSelectOption('selRowOutput', job.output_base, job.output_base.split('/').pop() || job.output_base)
$('#selRowOutput').val(job.output_base)
}
// restore input info in context panel
sel = { path: job.dir_path, name: job.dir_name, type: 'dir', files: files.map(f => ({ path: f.path, name: f.name })) }
$('#selName').text(job.dir_name)
$('#selPath').text(job.dir_path)
addSelectOption('selRowInput', job.dir_path, job.dir_name)
$('#selRowInput').val(job.dir_path)
$('#dirBadge').text(files.length + ' files').show()
showCtxState('dir')
syncBtn()
$('#resultsEmpty').hide()
$('#treePreview').hide()
$('#singleResult').hide()
$('#batchResult').show()
$('#previewPanel').hide()
$('#batchBar').css('width', job.total ? (job.done_count / job.total * 100) + '%' : '0%')
.removeClass('progress-bar-animated').toggleClass('bg-success', !!job.finished)
$('#batchCounter').text(`${job.done_count} / ${job.total}`)
const pendingCount = files.length - Object.keys(results).length
if (job.finished) {
$('#batchLabel').text('✅ Hoàn thành (restored)')
$('#btnContinueJob').hide()
} else {
$('#batchLabel').text(`⏸ Bị gián đoạn — ${job.done_count}/${job.total} xong`)
$('#btnContinueJob').show().off('click').on('click', () => continueJob(files, results, job))
}
const tbody = $('#batchBody').empty()
files.forEach(f => {
const rid = rowId(f.path)
const r = results[f.path]
const mdOk = r && r.md != null
const dlOk = r && r.dl != null
const pending = !r
tbody.append(`<tr id="row-${rid}" class="batch-row" data-path="${f.path}" style="cursor:pointer">
<td class="ps-3 small text-truncate" style="max-width:180px" title="${f.path}">
<i class="bi bi-chevron-right me-1 small row-chevron-${rid}" style="transition:.15s"></i>${f.name}
</td>
<td class="text-center"><span class="badge row-md-${rid} ${pending ? 'bg-secondary-subtle text-secondary' : (mdOk ? 'bg-primary-subtle text-primary' : 'bg-danger-subtle text-danger')}">${pending ? '⏸' : (mdOk ? '✓' : '✗')}</span></td>
<td class="text-center"><span class="badge row-dl-${rid} ${pending ? 'bg-secondary-subtle text-secondary' : (dlOk ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger')}">${pending ? '⏸' : (dlOk ? '✓' : '✗')}</span></td>
<td class="text-center row-save-${rid} text-muted small">—</td>
<td class="text-muted small row-ms-${rid}">—</td>
<td class="row-actions-${rid}"></td>
</tr>`)
if (r) {
batchResults[f.path] = r
const actions = $(`.row-actions-${rid}`)
if (r.md) actions.append(`<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1 btn-dl-batch" data-path="${f.path}" data-svc="md"><i class="bi bi-download"></i> md</button>`)
if (r.dl) actions.append(`<button class="btn btn-outline-success btn-sm py-0 px-1 btn-dl-batch" data-path="${f.path}" data-svc="dl"><i class="bi bi-download"></i> dl</button>`)
}
})
if (Object.keys(batchResults).length) $('#btnDownloadAll').show()
}
// ── Select box change handlers ────────────────────────────────
window.onSelectChange = function(role, path) {
if (role === 'input') {
if (!path) {
sel = { path: null, name: '', type: null, files: [] }
$('.file-entry').removeClass('input-sel')
showCtxState('empty'); syncBtn()
return
}
// Treat selected path as dir (most input options are dirs)
const name = path.split('/').pop() || path
sel = { path, name, type: 'dir', files: [] }
// Add this path as an option if not already there
addSelectOption('selRowInput', path, name)
$('.file-entry').removeClass('input-sel')
$(`.file-entry[data-path="${path}"]`).addClass('input-sel')
loadDir(path, false)
showCtxState('dir'); syncBtn()
}
if (role === 'output') {
outputPath = path || ''
$('#outputPath').val(outputPath)
if (path) addSelectOption('selRowOutput', path, path.split('/').pop() || path)
}
if (role === 'preview') {
if (!path) { previewSelPath = null; $('#treePreview').hide(); $('#resultsEmpty').show(); return }
previewSelPath = path
previewFileInline(path, path.split('/').pop())
}
}
// Add option to a select if not already present, then select it
function addSelectOption(selectId, value, label) {
const $sel = $('#' + selectId)
if (!$sel.find(`option[value="${value}"]`).length) {
$sel.append(new Option(label, value))
}
$sel.val(value)
}
// Tree click still updates the active select
window.activateSelector = function(role) { activeSelector = role }
window.clearSelector = function(role) {
if (role === 'input') { $('#selRowInput').val(''); onSelectChange('input', '') }
if (role === 'output') { $('#selRowOutput').val(''); onSelectChange('output', '') }
if (role === 'preview') { $('#selRowPreview').val(''); onSelectChange('preview', '') }
}
// ── Init ──────────────────────────────────────────────────────
loadDir($('#basePath').val(), true)
loadRestorePanel()
showCtxState('empty')
$('#basePath').on('change', function () { reset(); loadDir(this.value, true) })
$('#btnRefresh').on('click', function () { reset(); loadDir($('#basePath').val(), true) })
$('#useDl').on('change', function () { $('#dlFormatWrap').toggle(this.checked); syncBtn() })
$('#useMd').on('change', syncBtn)
$('#btnConvertFile').on('click', function () {
if (!sel.path || batchRunning) return
convertFile()
})
function syncBtn() {
const anyService = $('#useMd').is(':checked') || $('#useDl').is(':checked')
const disabled = !sel.path || !anyService || batchRunning
$('#btnConvert').prop('disabled', disabled)
$('#btnConvertFile').prop('disabled', disabled)
}
function showCtxState(state) {
$('#ctxEmpty').hide()
$('#ctxDir').hide()
$('#ctxFile').hide()
if (state === 'empty') {
$('#ctxEmpty').css('display', 'flex')
} else if (state === 'dir') {
$('#ctxDir').css({ display: 'flex', 'flex-direction': 'column', flex: '1' })
} else if (state === 'file') {
$('#ctxFile').css({ display: 'flex', 'flex-direction': 'column', flex: '1' })
}
}
// ── Browser ───────────────────────────────────────────────────
function loadDir(path, isRoot) {
if (!isRoot && expanded[path]) { delete expanded[path]; renderTree(); return }
if (isRoot) { $('#fileBrowser').html('<div class="text-center text-muted py-4 small"><div class="spinner-border spinner-border-sm mb-2"></div><br>Đang tải...</div>') }
$.getJSON('/api/markitdown/browse?path=' + encodeURIComponent(path))
.done(function (d) {
if (d.type === 'dir') {
if (isRoot) rootEntries = d.entries
else expanded[path] = d.entries
}
renderTree()
})
.fail(function () { $('#fileBrowser').html('<div class="text-danger text-center py-3 small">Lỗi tải thư mục</div>') })
}
function renderTree() {
const rows = []
function walk(entries, depth) {
entries.forEach(e => {
const ind = Math.min(depth, 4)
const isDir = e.type === 'dir'
const icon = isDir
? (expanded[e.path] ? 'bi-folder2-open text-warning' : 'bi-folder2 text-warning')
: fileIcon(e.ext)
const isInputSel = sel.path === e.path
const isOutputSel = outputPath === e.path
const isPrevSel = previewSelPath === e.path
const cls = isInputSel ? ' input-sel' : (isOutputSel ? ' output-sel' : (isPrevSel ? ' preview-sel' : ''))
// Role hint on the entry
let roleTag = ''
if (isInputSel) roleTag = `<span style="font-size:.6rem;background:#cfe2ff;color:#084298;border-radius:3px;padding:0 4px;flex-shrink:0">IN</span>`
if (isOutputSel) roleTag = `<span style="font-size:.6rem;background:#d1e7dd;color:#0a3622;border-radius:3px;padding:0 4px;flex-shrink:0">OUT</span>`
if (isPrevSel) roleTag = `<span style="font-size:.6rem;background:#e2e3e5;color:#383d41;border-radius:3px;padding:0 4px;flex-shrink:0">PRV</span>`
rows.push(`<div class="file-entry ${e.type}${cls} indent-${ind}"
data-path="${e.path}" data-name="${e.name}" data-type="${e.type}" data-ext="${e.ext||''}">
<i class="bi ${icon} flex-shrink-0" style="font-size:.85rem"></i>
<span class="text-truncate small flex-grow-1">${e.name}</span>
${roleTag}
</div>`)
if (isDir && expanded[e.path]) walk(expanded[e.path], depth + 1)
})
}
walk(rootEntries, 0)
$('#fileBrowser').html(rows.join('') || '<div class="text-muted text-center py-3 small">Trống</div>')
}
function previewDir(path, name) {
$('#singleResult, #batchResult').hide()
$('#resultsEmpty').hide()
$('#treePreview').show()
$('#treePreviewTitle').text(name + '/')
$('#treePreviewBody').html('<div class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm"></div></div>')
$.getJSON('/api/markitdown/browse?path=' + encodeURIComponent(path)).done(function (d) {
const entries = d.entries || []
if (!entries.length) { $('#treePreviewBody').html('<div class="text-muted text-center py-4 small">Thư mục trống</div>'); return }
const byType = { dir: 0, file: 0 }
entries.forEach(e => byType[e.type] = (byType[e.type] || 0) + 1)
const extCount = {}
entries.filter(e => e.type === 'file').forEach(e => { const x = e.ext || '(no ext)'; extCount[x] = (extCount[x]||0)+1 })
let html = `<div class="px-3 py-2 border-bottom small text-muted d-flex gap-3 flex-wrap">
<span><i class="bi bi-folder2 text-warning me-1"></i>${byType.dir||0} folder</span>
<span><i class="bi bi-file-earmark me-1"></i>${byType.file||0} files</span>
${Object.entries(extCount).sort((a,b)=>b[1]-a[1]).slice(0,5).map(([x,c])=>`<span class="badge bg-secondary-subtle text-secondary">${x}×${c}</span>`).join('')}
</div><div class="p-2">`
entries.forEach(e => {
const ic = e.type === 'dir' ? 'bi-folder2 text-warning' : fileIcon(e.ext)
html += `<div class="d-flex align-items-center gap-2 py-1 px-2 rounded small">
<i class="bi ${ic}" style="font-size:.85rem;flex-shrink:0"></i>
<span class="text-truncate">${e.name}</span>
</div>`
})
html += '</div>'
$('#treePreviewBody').html(html)
}).fail(() => $('#treePreviewBody').html('<div class="alert alert-danger m-3 small">Không tải được thư mục</div>'))
}
function setOutputPath(path, name) {
outputPath = path
$('#outputPath').val(path)
addSelectOption('selRowOutput', path, name || path.split('/').pop() || path)
$('#selRowOutput').val(path)
$('.file-entry').removeClass('output-sel')
$(`.file-entry[data-path="${path}"]`).addClass('output-sel')
}
function fileIcon(ext) {
const m = {
'':'bi-envelope-at text-warning',
'.eml':'bi-envelope-at text-warning',
'.pdf':'bi-file-earmark-pdf text-danger',
'.docx':'bi-file-earmark-word text-primary',
'.xlsx':'bi-file-earmark-excel text-success',
'.html':'bi-file-earmark-code text-info', '.htm':'bi-file-earmark-code text-info',
'.md':'bi-markdown text-secondary', '.txt':'bi-file-earmark-text text-secondary',
'.png':'bi-file-earmark-image text-info', '.jpg':'bi-file-earmark-image',
'.jpeg':'bi-file-earmark-image', '.csv':'bi-file-earmark-spreadsheet text-success',
}
return m[ext] || 'bi-file-earmark text-secondary'
}
// ── Entry click — role-aware ──────────────────────────────────
$(document).on('click', '.file-entry', function () {
const type = $(this).data('type')
const path = $(this).data('path')
const name = $(this).data('name')
const ext = ($(this).data('ext') || '').toLowerCase()
if (!activeSelector) {
// No role selected: only expand/collapse dirs, no other action
if (type === 'dir') loadDir(path)
return
}
if (activeSelector === 'input') {
if (type === 'dir') {
loadDir(path)
selectEntry(path, name, 'dir')
} else {
selectEntry(path, name, 'file')
}
addSelectOption('selRowInput', path, name)
}
else if (activeSelector === 'output') {
let p = path, n = name
if (type === 'file') { p = path.substring(0, path.lastIndexOf('/')); n = p.split('/').pop() }
setOutputPath(p, n)
addSelectOption('selRowOutput', p, n)
$('#selRowOutput').val(p)
}
else if (activeSelector === 'preview') {
if (type === 'dir') {
loadDir(path)
previewDir(path, name)
} else {
previewSelPath = path
addSelectOption('selRowPreview', path, name)
$('#selRowPreview').val(path)
renderTree()
previewFileInline(path, name)
}
}
})
// ── Inline tree preview (.md / .txt) ─────────────────────────
let treePreviewMode = 'render'
window._treeContent = ''
function previewFileInline(path, name) {
$('#singleResult, #batchResult').hide()
$('#resultsEmpty').hide()
$('#treePreview').show()
$('#treePreviewTitle').text(name)
$('#treePreviewBody').html('<div class="text-center text-muted py-5"><div class="spinner-border spinner-border-sm"></div></div>')
$('#treePreviewModeRaw').toggleClass('active', treePreviewMode === 'raw')
$('#treePreviewModeRender').toggleClass('active', treePreviewMode === 'render')
$.getJSON('/workspace-file?path=' + encodeURIComponent(path))
.done(function (d) {
window._treeContent = d.content || ''
renderTreePreview(window._treeContent)
})
.fail(function (e) {
$('#treePreviewBody').html(`<div class="alert alert-danger m-3 small">Lỗi đọc file: ${e?.responseJSON?.detail || 'unknown'}</div>`)
})
}
function renderTreePreview(content) {
if (treePreviewMode === 'raw') {
$('#treePreviewBody').html(`<pre class="m-0 p-3 small" style="white-space:pre-wrap;word-break:break-word">${$('<div>').text(content).html()}</pre>`)
} else {
$('#treePreviewBody').html(`<div class="PreviewPane markdown-body p-3" style="max-height:none">${mdRender(content)}</div>`)
}
}
$('#treePreviewClose').on('click', function () { $('#treePreview').hide(); $('#resultsEmpty').show() })
$(document).on('click', '#treePreviewModeRaw, #treePreviewModeRender', function () {
treePreviewMode = $(this).is('#treePreviewModeRaw') ? 'raw' : 'render'
$('#treePreviewModeRaw').toggleClass('active', treePreviewMode === 'raw')
$('#treePreviewModeRender').toggleClass('active', treePreviewMode === 'render')
if (window._treeContent) renderTreePreview(window._treeContent)
})
function selectEntry(path, name, type) {
sel = { path, name, type, files: [] }
if (type === 'dir') {
$('#selName').text(name).css('color', '')
$('#selPath').text(path)
addSelectOption('selRowInput', path, name)
$('.file-entry').removeClass('input-sel')
$(`.file-entry[data-path="${path}"]`).addClass('input-sel')
const now = new Date()
const ts = now.getFullYear().toString()
+ String(now.getMonth() + 1).padStart(2, '0')
+ String(now.getDate()).padStart(2, '0')
+ '_'
+ String(now.getHours()).padStart(2, '0')
+ String(now.getMinutes()).padStart(2, '0')
+ String(now.getSeconds()).padStart(2, '0')
const suggested = WORKING_DIR + '/output/' + name + '_' + ts
$('#outputPath').val(suggested)
showCtxState('dir')
$.getJSON('/api/markitdown/browse?path=' + encodeURIComponent(path)).done(function (d) {
sel.files = (d.entries || []).filter(e => e.type === 'file')
$('#dirBadge').text(sel.files.length + ' files').show()
syncBtn()
})
$('#singleResult').hide()
$('#batchResult').hide()
} else {
sel.files = []
$('#selNameFile').text(name)
$('#selPathFile').text(path)
$('#selName').text(name)
$('#selPath').text(path)
$('#dirBadge').hide()
addSelectOption('selRowInput', path, name)
$('.file-entry').removeClass('input-sel')
$(`.file-entry[data-path="${path}"]`).addClass('input-sel')
showCtxState('file')
$('#singleResult').hide()
$('#batchResult').hide()
syncBtn()
}
}
// ── Convert button ────────────────────────────────────────────
$('#btnConvert').on('click', function () {
if (!sel.path || batchRunning) return
if (sel.type === 'dir') convertDir()
else convertFile()
})
// ── Single file convert ───────────────────────────────────────
function convertFile() {
const useMd = $('#useMd').is(':checked')
const useDl = $('#useDl').is(':checked')
const useLlm = $('#llmToggle').is(':checked')
const dlFmt = $('#dlFormat').val()
const body = { path: sel.path, use_llm: useLlm }
$('.pane-col-md').toggle(useMd)
$('.pane-col-dl').toggle(useDl)
$('#resultsEmpty').hide()
$('#singleResult').show()
$('#batchResult').hide()
const keys = []
if (useMd) keys.push('md')
if (useDl) keys.push('dl')
keys.forEach(k => startPane(k))
setConvertBtn(true)
const reqs = {}
if (useMd) reqs.md = $.ajax({ url: '/api/markitdown/convert-path', method: 'POST', contentType: 'application/json', data: JSON.stringify(body) })
if (useDl) reqs.dl = $.ajax({ url: '/api/docling/convert-path', method: 'POST', contentType: 'application/json', data: JSON.stringify({ ...body, output_format: dlFmt }) })
const settled = (k, req) => new Promise(resolve => {
const t0 = performance.now()
req.done(d => resolve({ ok: true, data: d, ms: Math.round(performance.now() - t0) }))
.fail(e => resolve({ ok: false, err: e?.responseJSON?.detail || 'Lỗi không xác định' }))
})
Promise.all(Object.entries(reqs).map(([k, r]) => settled(k, r).then(res => ({ k, ...res }))))
.then(all => {
all.forEach(({ k, ok, data, ms, err }) => ok ? finishPane(k, data, ms) : errorPane(k, err))
setConvertBtn(false)
})
}
// ── Continue restored job (only pending files) ────────────────
async function continueJob(allFiles, doneResults, job) {
// Re-fetch latest job state so a second resume sees fresh results
const latestJob = await jobGet(job.id)
if (latestJob?.results) Object.assign(doneResults, latestJob.results)
const pendingFiles = allFiles.filter(f => !doneResults[f.path])
if (!pendingFiles.length) { alert('Tất cả file đã xong rồi!'); return }
const useMd = $('#useMd').is(':checked')
const useDl = $('#useDl').is(':checked')
const useLlm = $('#llmToggle').is(':checked')
const dlFmt = $('#dlFormat').val()
const outputBase = $('#outputPath').val().trim()
batchRunning = true
setConvertBtn(true)
$('#btnContinueJob').prop('disabled', true)
const initialDone = Object.keys(doneResults).length // snapshot before loop
for (let i = 0; i < pendingFiles.length; i++) {
const fi = pendingFiles[i]
const rid = rowId(fi.path)
updateProgress(initialDone + i, allFiles.length, fi.name)
const body = { path: fi.path, use_llm: useLlm }
const t0 = performance.now()
const fileRes = {}
const reqs = []
if (useMd) reqs.push(fetchSettled('/api/markitdown/convert-path', body).then(r => { fileRes.md = r }))
if (useDl) reqs.push(fetchSettled('/api/docling/convert-path', { ...body, output_format: dlFmt }).then(r => { fileRes.dl = r }))
await Promise.all(reqs)
const ms = Math.round(performance.now() - t0)
const mdR = fileRes.md, dlR = fileRes.dl
if (useMd && mdR) $(`.row-md-${rid}`).attr('class', mdR.ok ? 'badge bg-primary-subtle text-primary' : 'badge bg-danger-subtle text-danger').text(mdR.ok ? '✓' : '✗')
if (useDl && dlR) $(`.row-dl-${rid}`).attr('class', dlR.ok ? 'badge bg-success-subtle text-success' : 'badge bg-danger-subtle text-danger').text(dlR.ok ? '✓' : '✗')
$(`.row-ms-${rid}`).text(`${ms}ms`)
const eid = fi.name.split(',')[0] || fi.name
const entry = { name: fi.name, eid }
if (mdR?.ok && mdR.data?.markdown) entry.md = mdR.data.markdown
if (dlR?.ok && dlR.data?.markdown) entry.dl = dlR.data.markdown
batchResults[fi.path] = entry
doneResults[fi.path] = entry
if (outputBase) {
const saves = []
if (entry.md) saves.push(fetchSettled('/api/markitdown/write-file', { path: `${outputBase}/${eid}/markitdown/body.md`, content: entry.md }))
if (entry.dl) saves.push(fetchSettled('/api/markitdown/write-file', { path: `${outputBase}/${eid}/docling/body.md`, content: entry.dl }))
const saveRes = await Promise.all(saves)
$(`.row-save-${rid}`).html(saveRes.every(r => r.ok) ? '<span class="badge bg-success-subtle text-success">✓</span>' : '<span class="badge bg-danger-subtle text-danger">✗</span>')
}
if (currentJobId) jobUpdate(currentJobId, fi.path, { name: entry.name, eid: entry.eid, ...(entry.md != null ? {md: entry.md} : {}), ...(entry.dl != null ? {dl: entry.dl} : {}) })
}
if (currentJobId) jobUpdate(currentJobId, null, null, true)
updateProgress(allFiles.length, allFiles.length, 'Hoàn thành')
$('#batchBar').removeClass('progress-bar-animated').addClass('bg-success')
$('#batchLabel').text('✅ Hoàn thành')
batchRunning = false
setConvertBtn(false)
$('#btnContinueJob').hide()
if (Object.keys(batchResults).length) $('#btnDownloadAll').show()
}
// ── Directory batch convert ───────────────────────────────────
async function convertDir() {
const useMd = $('#useMd').is(':checked')
const useDl = $('#useDl').is(':checked')
const useLlm = $('#llmToggle').is(':checked')
const dlFmt = $('#dlFormat').val()
const saveServer = $('#saveToServer').is(':checked')
const outputBase = $('#outputPath').val().trim()
// Always fetch file list from backend — sel.files may be empty when dir was chosen via dropdown
let files = sel.files
if (!files.length) {
try {
const d = await $.getJSON('/api/markitdown/browse?path=' + encodeURIComponent(sel.path))
files = (d.entries || []).filter(e => e.type === 'file')
sel.files = files
$('#dirBadge').text(files.length + ' files').show()
syncBtn()
} catch (e) {
alert('Không thể đọc thư mục từ server.'); return
}
}
if (!files.length) { alert('Thư mục không có file nào.'); return }
batchRunning = true
batchResults = {}
setConvertBtn(true)
$('#resultsEmpty').hide()
$('#singleResult').hide()
$('#batchResult').show()
$('#btnDownloadAll').hide()
$('#previewPanel').hide()
$('#restorePanel').remove()
currentJobId = await jobCreate(
sel.path, sel.name, outputBase,
{ useMd, useDl, dlFmt, useLlm },
files.map(f => ({ path: f.path, name: f.name }))
)
const tbody = $('#batchBody').empty()
files.forEach(f => {
tbody.append(`<tr id="row-${rowId(f.path)}" class="batch-row" data-path="${f.path}" style="cursor:pointer">
<td class="ps-3 small text-truncate" style="max-width:180px" title="${f.path}">
<i class="bi bi-chevron-right me-1 small row-chevron-${rowId(f.path)}" style="transition:.15s"></i>${f.name}
</td>
<td class="text-center"><span class="badge bg-secondary-subtle text-secondary row-md-${rowId(f.path)}">…</span></td>
<td class="text-center"><span class="badge bg-secondary-subtle text-secondary row-dl-${rowId(f.path)}">…</span></td>
<td class="text-center row-save-${rowId(f.path)} text-muted small">—</td>
<td class="text-muted small row-ms-${rowId(f.path)}">—</td>
<td class="row-actions-${rowId(f.path)}"></td>
</tr>`)
})
for (let i = 0; i < files.length; i++) {
const f = files[i]
const rid = rowId(f.path)
updateProgress(i, files.length, f.name)
const body = { path: f.path, use_llm: useLlm }
const t0 = performance.now()
const fileRes = {}
const reqs = []
if (useMd) reqs.push(fetchSettled('/api/markitdown/convert-path', body).then(r => { fileRes.md = r }))
if (useDl) reqs.push(fetchSettled('/api/docling/convert-path', { ...body, output_format: dlFmt }).then(r => { fileRes.dl = r }))
await Promise.all(reqs)
const ms = Math.round(performance.now() - t0)
const mdR = fileRes.md; const dlR = fileRes.dl
if (useMd && mdR) {
$(`.row-md-${rid}`).attr('class', mdR.ok ? 'badge bg-primary-subtle text-primary' : 'badge bg-danger-subtle text-danger').text(mdR.ok ? '✓' : '✗')
} else if (!useMd) { $(`.row-md-${rid}`).text('—') }
if (useDl && dlR) {
$(`.row-dl-${rid}`).attr('class', dlR.ok ? 'badge bg-success-subtle text-success' : 'badge bg-danger-subtle text-danger').text(dlR.ok ? '✓' : '✗')
} else if (!useDl) { $(`.row-dl-${rid}`).text('—') }
$(`.row-ms-${rid}`).text(ms + ' ms')
const eid = f.name.includes(',') ? f.name.split(',')[0] : (f.name.replace(/\.[^.]+$/, '') || f.name)
const entry = { name: f.name, eid }
if (mdR?.ok) entry.md = mdR.data.markdown || mdR.data.content || ''
if (dlR?.ok) entry.dl = dlR.data.content || dlR.data.markdown || ''
batchResults[f.path] = entry
const actions = $(`.row-actions-${rid}`)
if (entry.md) actions.append(`<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1 btn-dl-batch" data-path="${f.path}" data-svc="md"><i class="bi bi-download"></i> md</button>`)
if (entry.dl) actions.append(`<button class="btn btn-outline-success btn-sm py-0 px-1 btn-dl-batch" data-path="${f.path}" data-svc="dl"><i class="bi bi-download"></i> dl</button>`)
if (saveServer && outputBase) {
const saves = []
if (entry.md) saves.push(fetchSettled('/api/markitdown/write-file', { path: `${outputBase}/${eid}/markitdown/body.md`, content: entry.md }))
if (entry.dl) saves.push(fetchSettled('/api/markitdown/write-file', { path: `${outputBase}/${eid}/docling/body.md`, content: entry.dl }))
const saveRes = await Promise.all(saves)
const allSaved = saveRes.every(r => r.ok)
$(`.row-save-${rid}`).html(allSaved
? '<span class="badge bg-success-subtle text-success">✓</span>'
: '<span class="badge bg-danger-subtle text-danger" title="Lưu thất bại">✗</span>')
}
if (currentJobId) {
const dbEntry = { name: entry.name, eid: entry.eid }
if (entry.md != null) dbEntry.md = entry.md
if (entry.dl != null) dbEntry.dl = entry.dl
jobUpdate(currentJobId, f.path, dbEntry)
}
}
if (currentJobId) jobUpdate(currentJobId, null, null, true)
updateProgress(files.length, files.length, 'Hoàn thành')
$('#batchBar').removeClass('progress-bar-animated').addClass('bg-success')
batchRunning = false
setConvertBtn(false)
if (Object.keys(batchResults).length) $('#btnDownloadAll').show()
}
function rowId(path) { return path.replace(/[^a-zA-Z0-9]/g, '_') }
function updateProgress(done, total, name) {
const pct = total ? Math.round(done / total * 100) : 0
$('#batchBar').css('width', pct + '%')
$('#batchCounter').text(`${done} / ${total}`)
$('#batchLabel').text(done >= total ? '✅ Xong' : `Đang xử lý: ${name}`)
}
async function fetchSettled(url, body) {
return new Promise(resolve => {
$.ajax({ url, method: 'POST', contentType: 'application/json', data: JSON.stringify(body) })
.done(data => resolve({ ok: true, data }))
.fail(e => resolve({ ok: false, err: e?.responseJSON?.detail || 'error' }))
})
}
// ── Batch row preview ─────────────────────────────────────────
let previewPath = null
let previewMode = 'raw'
let previewTab = 'md' // 'md' | 'dl'
function renderPreviewTab() {
const r = previewPath ? batchResults[previewPath] : null
if (!r) return
// Switch visible pane
$('#previewPaneMd').toggle(previewTab === 'md')
$('#previewPaneDl').toggle(previewTab === 'dl')
// Tab button styles
const hasMd = r.md != null
const hasDl = r.dl != null
$('#previewTabMd')
.toggleClass('btn-primary', previewTab === 'md')
.toggleClass('btn-outline-primary', previewTab !== 'md')
.prop('disabled', !hasMd)
$('#previewTabDl')
.toggleClass('btn-success', previewTab === 'dl')
.toggleClass('btn-outline-success', previewTab !== 'dl')
.prop('disabled', !hasDl)
// Render content in active pane
const id = previewTab === 'md' ? 'Md' : 'Dl'
const content = previewTab === 'md' ? r.md : r.dl
const raw = $(`#previewRaw${id}`)
const render = $(`#previewRender${id}`)
if (content == null) {
raw.text('(không có kết quả)').show(); render.hide()
} else if (previewMode === 'raw') {
raw.text(content).show(); render.hide()
} else {
render.html(mdRender(content)).show(); raw.hide()
}
}
function showPreview(path) {
const r = batchResults[path]
if (!r) return
const prevPath = previewPath
previewPath = path
$('#previewTitle').text(r.name)
$('#previewModeRaw').toggleClass('active', previewMode === 'raw')
$('#previewModeRender').toggleClass('active', previewMode === 'render')
$('#previewMdMeta').text(r.md != null ? r.md.length.toLocaleString() + ' ký tự' : 'không có')
$('#previewDlMeta').text(r.dl != null ? r.dl.length.toLocaleString() + ' ký tự' : 'không có')
// Auto-select first available tab
if (previewTab === 'md' && r.md == null && r.dl != null) previewTab = 'dl'
if (previewTab === 'dl' && r.dl == null && r.md != null) previewTab = 'md'
renderPreviewTab()
$('.batch-row').removeClass('table-active')
$(`#row-${rowId(path)}`).addClass('table-active')
if (prevPath) $(`.row-chevron-${rowId(prevPath)}`).css('transform', '')
$(`.row-chevron-${rowId(path)}`).css('transform', 'rotate(90deg)')
$('#previewPanel').show()
$('#previewPanel')[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
// Service tab clicks
$('#previewTabMd').on('click', function () { previewTab = 'md'; renderPreviewTab() })
$('#previewTabDl').on('click', function () { previewTab = 'dl'; renderPreviewTab() })
$(document).on('click', '.batch-row', function (e) {
if ($(e.target).closest('button').length) return
const path = $(this).data('path')
if (!batchResults[path]) return
if (previewPath === path && $('#previewPanel').is(':visible')) {
$('#previewPanel').hide()
$(`.row-chevron-${rowId(path)}`).css('transform', '')
$('.batch-row').removeClass('table-active')
previewPath = null
} else {
showPreview(path)
}
})
$('#previewClose').on('click', function () {
$('#previewPanel').hide()
if (previewPath) $(`.row-chevron-${rowId(previewPath)}`).css('transform', '')
$('.batch-row').removeClass('table-active')
previewPath = null
})
$('#previewModeRaw').on('click', function () { previewMode = 'raw'; $('#previewModeRaw').addClass('active'); $('#previewModeRender').removeClass('active'); renderPreviewTab() })
$('#previewModeRender').on('click', function () { previewMode = 'render'; $('#previewModeRender').addClass('active'); $('#previewModeRaw').removeClass('active'); renderPreviewTab() })
// Keep setPreviewPane alias for restoreJob compatibility
function setPreviewPane(id, content, mode) {
const raw = $(`#previewRaw${id}`), render = $(`#previewRender${id}`)
if (content == null) { raw.text('(không có kết quả)').show(); render.hide(); return }
if (mode === 'raw') { raw.text(content).show(); render.hide() }
else { render.html(mdRender(content)).show(); raw.hide() }
}
// ── Batch download individual ─────────────────────────────────
$(document).on('click', '.btn-dl-batch', function () {
const path = $(this).data('path')
const svc = $(this).data('svc')
const r = batchResults[path]
if (!r || !r[svc]) return
const eid = r.eid || (r.name.includes(',') ? r.name.split(',')[0] : r.name.replace(/\.[^.]+$/, '') || r.name)
dlBlob(r[svc], `${eid}_${svc === 'md' ? 'markitdown' : 'docling'}_body.md`)
})
// ── Download all as ZIP ───────────────────────────────────────
$('#btnDownloadAll').on('click', async function () {
const zip = new JSZip()
Object.values(batchResults).forEach(r => {
const eid = r.eid || (r.name.includes(',') ? r.name.split(',')[0] : r.name.replace(/\.[^.]+$/, '') || r.name)
if (r.md) zip.file(`${eid}/markitdown/body.md`, r.md)
if (r.dl) zip.file(`${eid}/docling/body.md`, r.dl)
})
const blob = await zip.generateAsync({ type: 'blob' })
dlBlob(blob, `${sel.name}_output.zip`)
})
// ── Single pane helpers ───────────────────────────────────────
function startPane(k) {
$(`.pane-idle-${k}, .pane-error-${k}, .pane-raw-${k}, .pane-preview-${k}`).hide()
$(`.pane-loading-${k}`).show()
$(`.status-text-${k}`).text('Đang xử lý...')
}
function finishPane(k, data, ms) {
const content = data.markdown || data.content || ''
singleResults[k] = content
$(`.pane-loading-${k}`).hide()
$(`.pane-raw-${k}`).text(content).show()
$(`.pane-preview-${k}`).html(mdRender(content)).hide()
$(`.tab-raw-${k}`).addClass('active'); $(`.tab-preview-${k}`).removeClass('active')
$(`.status-text-${k}`).text(`✅ ${ms} ms`)
if (data.llm_enabled) $(`.llm-badge-${k}`).show()
if (content) $(`.btn-dl-${k}`).show()
}
function errorPane(k, msg) {
$(`.pane-loading-${k}`).hide()
$(`.pane-error-${k}`).text(msg).show()
$(`.status-text-${k}`).text('❌ ' + msg.slice(0, 60))
}
// ── Tab switching ─────────────────────────────────────────────
$(document).on('click', '[data-tab]', function (e) {
e.preventDefault()
const k = $(this).data('pane'), t = $(this).data('tab')
$(`.tab-raw-${k}, .tab-preview-${k}`).removeClass('active')
$(`.tab-${t}-${k}`).addClass('active')
$(`.pane-raw-${k}, .pane-preview-${k}`).hide()
$(`.pane-${t}-${k}`).show()
})
// ── Single download ───────────────────────────────────────────
$(document).on('click', '[class*="btn-dl-"]:not(.btn-dl-batch)', function () {
const k = $(this).attr('class').match(/btn-dl-(\w+)/)?.[1]
if (!k || !singleResults[k]) return
dlBlob(singleResults[k], sel.name.replace(/\.[^.]+$/, '') + `_${k}.md`)
})
// ── Helpers ───────────────────────────────────────────────────
function dlBlob(content, filename) {
const blob = content instanceof Blob ? content : new Blob([content], { type: 'text/markdown' })
const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: filename })
a.click(); URL.revokeObjectURL(a.href)
}
function setConvertBtn(loading) {
if (loading) {
$('#btnConvert').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Đang xử lý...')
$('#btnConvertFile').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Đang xử lý...')
} else {
$('#btnConvert').html('<i class="bi bi-play-fill me-1"></i>Chuyển đổi')
$('#btnConvertFile').html('<i class="bi bi-play-fill me-1"></i>Chuyển đổi')
syncBtn()
}
}
function reset() {
sel = { path: null, name: '', type: null, files: [] }
expanded = {}; rootEntries = []
activeSelector = null
previewSelPath = null
$('#singleResult, #batchResult, #treePreview').hide()
$('#resultsEmpty').show()
showCtxState('empty')
syncBtn()
$('#activeRoleHint').text('↑ Chọn ô rồi click vào cây').css('color','#adb5bd')
$('#selRowInput').val('')
$('#selRowPreview').val('')
}
})
</script>
@endsection