1315 lines
60 KiB
PHP
1315 lines
60 KiB
PHP
@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"> </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"> </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 }}"> </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ử lý...</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
|