ntphuc149 commited on
Commit
2124d6b
·
verified ·
1 Parent(s): 87b1d2a

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +484 -0
app.js ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DATASET_KEY = "humanlabeling_dataset";
2
+ const AUTOSAVE_DELAY = 1500;
3
+
4
+ let _dataset = [];
5
+ let _activeRowId = null;
6
+ let _autosaveTimer = null;
7
+ let _currentReferences = [];
8
+ let _currentVideoUrl = null;
9
+ let _currentTitle = "";
10
+ let _currentPage = 1;
11
+ const PAGE_SIZE = 10;
12
+
13
+ // ── Init ──────────────────────────────────────────────────────────────────────
14
+ window.addEventListener("DOMContentLoaded", () => {
15
+ _loadDataset();
16
+
17
+ document.getElementById("transcriptArea").addEventListener("input", () => {
18
+ updateCharCount();
19
+ _scheduleAutosave();
20
+ });
21
+ document.getElementById("refInput").addEventListener("keydown", (e) => {
22
+ if (e.key === "Enter") addReference();
23
+ });
24
+ });
25
+
26
+ // ── References ────────────────────────────────────────────────────────────────
27
+ function addReference() {
28
+ const input = document.getElementById("refInput");
29
+ const val = input.value.trim();
30
+ if (!val) return;
31
+ if (!_currentReferences.includes(val)) {
32
+ _currentReferences.push(val);
33
+ _renderRefPills();
34
+ _scheduleAutosave();
35
+ }
36
+ input.value = "";
37
+ input.focus();
38
+ }
39
+
40
+ function removeReference(idx) {
41
+ _currentReferences.splice(idx, 1);
42
+ _renderRefPills();
43
+ _scheduleAutosave();
44
+ }
45
+
46
+ function _renderRefPills() {
47
+ const container = document.getElementById("refPills");
48
+ if (_currentReferences.length === 0) {
49
+ container.innerHTML = "";
50
+ return;
51
+ }
52
+ container.innerHTML = _currentReferences
53
+ .map(
54
+ (ref, i) => `
55
+ <span class="ref-pill">
56
+ ${_esc(ref)}
57
+ <button onclick="removeReference(${i})" title="Xoá">×</button>
58
+ </span>
59
+ `,
60
+ )
61
+ .join("");
62
+ }
63
+
64
+ // ── Video preview ─────────────────────────────────────────────────────────────
65
+ function loadVideoPreview(url) {
66
+ const container = document.getElementById("videoContainer");
67
+ const ytMatch = url.match(/(?:v=|youtu\.be\/|shorts\/)([A-Za-z0-9_-]{11})/);
68
+ if (!ytMatch) {
69
+ container.innerHTML = `<div class="video-placeholder"><p style="color:#475569;font-size:12px;">Không phải YouTube URL</p></div>`;
70
+ return;
71
+ }
72
+ container.innerHTML = "";
73
+ const iframe = document.createElement("iframe");
74
+ iframe.width = "100%";
75
+ iframe.height = "100%";
76
+ iframe.style.border = "none";
77
+ iframe.allow =
78
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
79
+ iframe.allowFullscreen = true;
80
+ iframe.src = `https://www.youtube.com/embed/${ytMatch[1]}`;
81
+ container.appendChild(iframe);
82
+ }
83
+
84
+ // ── Transcript ────────────────────────────────────────────────────────────────
85
+ function setTranscript(text) {
86
+ document.getElementById("transcriptArea").value = text || "";
87
+ updateCharCount();
88
+ }
89
+
90
+ function updateCharCount() {
91
+ const text = document.getElementById("transcriptArea").value;
92
+ const el = document.getElementById("charCount");
93
+ if (text.length > 0) {
94
+ const words = text.trim().split(/\s+/).length;
95
+ el.textContent = `${words} từ · ${text.length} ký tự`;
96
+ } else {
97
+ el.textContent = "";
98
+ }
99
+ }
100
+
101
+ async function copyText() {
102
+ const text = document.getElementById("transcriptArea").value;
103
+ if (!text) return;
104
+ try {
105
+ await navigator.clipboard.writeText(text);
106
+ const btn = document.getElementById("copyBtn");
107
+ btn.innerHTML = '<i class="ri-check-line"></i> Đã copy!';
108
+ setTimeout(() => {
109
+ btn.innerHTML = '<i class="ri-file-copy-line"></i> Copy';
110
+ }, 1500);
111
+ } catch (_) {}
112
+ }
113
+
114
+ // ── Status bar ────────────────────────────────────────────────────────────────
115
+ function setStatus(type, text) {
116
+ const bar = document.getElementById("statusBar");
117
+ bar.className = `status-bar ${type}`;
118
+ document.getElementById("statusText").textContent = text;
119
+ }
120
+
121
+ // ── Dataset ───────────────────────────────────────────────────────────────────
122
+ function _loadDataset() {
123
+ try {
124
+ _dataset = JSON.parse(localStorage.getItem(DATASET_KEY) || "[]");
125
+ } catch (_) {
126
+ _dataset = [];
127
+ }
128
+ _renderDataset();
129
+ }
130
+
131
+ function _saveDataset() {
132
+ localStorage.setItem(DATASET_KEY, JSON.stringify(_dataset));
133
+ _showAutosaved();
134
+ }
135
+
136
+ function _scheduleAutosave() {
137
+ if (!_activeRowId) return;
138
+ clearTimeout(_autosaveTimer);
139
+ document.getElementById("autosaveIndicator").textContent = "lưu...";
140
+ document.getElementById("autosaveIndicator").className = "autosave-indicator";
141
+ _autosaveTimer = setTimeout(() => {
142
+ const entry = _dataset.find((e) => e.id === _activeRowId);
143
+ if (entry) {
144
+ entry.script = document.getElementById("transcriptArea").value;
145
+ entry.references = [..._currentReferences];
146
+ _saveDataset();
147
+ _renderDataset();
148
+ }
149
+ }, AUTOSAVE_DELAY);
150
+ }
151
+
152
+ function _showAutosaved() {
153
+ const el = document.getElementById("autosaveIndicator");
154
+ el.textContent = "đã lưu";
155
+ el.className = "autosave-indicator saved";
156
+ setTimeout(() => {
157
+ el.textContent = "";
158
+ }, 2000);
159
+ }
160
+
161
+ function saveCurrentToDataset() {
162
+ if (!_activeRowId) return;
163
+ const script = document.getElementById("transcriptArea").value.trim();
164
+ const entry = _dataset.find((e) => e.id === _activeRowId);
165
+ if (!entry) return;
166
+
167
+ entry.script = script;
168
+ entry.references = [..._currentReferences];
169
+ entry.status = "verified";
170
+ _saveDataset();
171
+ _renderDataset();
172
+ setStatus("done", `Đã verify: ${_esc(entry.title)}`);
173
+
174
+ // Auto-advance to next unverified entry
175
+ const currentIdx = _dataset.findIndex((e) => e.id === _activeRowId);
176
+ const next = _dataset.slice(currentIdx + 1).find((e) => (e.status || "none") !== "verified");
177
+ if (next) selectRow(next.id);
178
+ }
179
+
180
+ function selectRow(id) {
181
+ const entry = _dataset.find((e) => e.id === id);
182
+ if (!entry) return;
183
+ _activeRowId = id;
184
+ _currentVideoUrl = entry.url;
185
+ _currentTitle = entry.title;
186
+ _currentReferences = [...(entry.references || [])];
187
+ setTranscript(entry.script);
188
+ _renderRefPills();
189
+ loadVideoPreview(entry.url);
190
+ document.getElementById("videoMeta").textContent = entry.title || entry.url;
191
+ _renderDataset();
192
+ }
193
+
194
+ function deleteRow(id) {
195
+ _dataset = _dataset.filter((e) => e.id !== id);
196
+ if (_activeRowId === id) {
197
+ _activeRowId = null;
198
+ setTranscript("");
199
+ _currentReferences = [];
200
+ _renderRefPills();
201
+ document.getElementById("videoMeta").textContent = "";
202
+ document.getElementById("videoContainer").innerHTML = `
203
+ <div class="video-placeholder">
204
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
205
+ <rect x="2" y="3" width="20" height="14" rx="2"/>
206
+ <path d="M8 21h8M12 17v4"/>
207
+ </svg>
208
+ <p>Chọn một video từ dataset để xem</p>
209
+ </div>`;
210
+ }
211
+ _saveDataset();
212
+ _renderDataset();
213
+ }
214
+
215
+ function clearDataset() {
216
+ if (!_dataset.length) return;
217
+ if (!confirm(`Xoá tất cả ${_dataset.length} video khỏi dataset?`)) return;
218
+ _dataset = [];
219
+ _activeRowId = null;
220
+ _saveDataset();
221
+ _renderDataset();
222
+ }
223
+
224
+ function _renderDataset() {
225
+ const tbody = document.getElementById("datasetBody");
226
+ const count = document.getElementById("datasetCount");
227
+
228
+ const verified = _dataset.filter((e) => (e.status || "none") === "verified").length;
229
+ count.textContent = _dataset.length
230
+ ? `${_dataset.length} video · ${verified} verified`
231
+ : "";
232
+
233
+ if (_dataset.length === 0) {
234
+ tbody.innerHTML =
235
+ '<tr><td colspan="7" class="empty-state">Import file JSON hoặc CSV để bắt đầu labeling.</td></tr>';
236
+ _renderPagination(0);
237
+ return;
238
+ }
239
+
240
+ const totalPages = Math.ceil(_dataset.length / PAGE_SIZE);
241
+ if (_currentPage > totalPages) _currentPage = totalPages;
242
+
243
+ const start = (_currentPage - 1) * PAGE_SIZE;
244
+ const pageItems = _dataset.slice(start, start + PAGE_SIZE);
245
+
246
+ tbody.innerHTML = pageItems
247
+ .map((e, i) => {
248
+ const refs = e.references || [];
249
+ const refText =
250
+ refs.length === 0
251
+ ? "—"
252
+ : refs.length === 1
253
+ ? refs[0]
254
+ : `${refs[0]} +${refs.length - 1}`;
255
+ const refTitle = refs.join(" | ");
256
+ const st = e.status || "none";
257
+ const statusBadge = `<span class="status-badge status-${st}">${st}</span>`;
258
+ return `
259
+ <tr data-id="${e.id}" class="${e.id === _activeRowId ? "active" : ""}" onclick="selectRow('${e.id}')">
260
+ <td class="td-num">${start + i + 1}</td>
261
+ <td class="td-title" title="${_esc(e.title)}">${_esc(e.title)}</td>
262
+ <td class="td-script" title="${_esc(e.script)}">${_esc(e.script)}</td>
263
+ <td class="td-refs" title="${_esc(refTitle)}">${_esc(refText)}</td>
264
+ <td style="max-width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
265
+ <a href="${_esc(e.url)}" target="_blank" style="color:#3b82f6;font-size:11px;" onclick="event.stopPropagation()">link</a>
266
+ </td>
267
+ <td>${statusBadge}</td>
268
+ <td class="td-actions">
269
+ <button onclick="event.stopPropagation();deleteRow('${e.id}')" title="Xoá">✕</button>
270
+ </td>
271
+ </tr>`;
272
+ })
273
+ .join("");
274
+
275
+ _renderPagination(totalPages);
276
+ }
277
+
278
+ function _renderPagination(totalPages) {
279
+ const bar = document.getElementById("paginationBar");
280
+ if (!bar) return;
281
+ if (totalPages <= 1) {
282
+ bar.innerHTML = "";
283
+ return;
284
+ }
285
+
286
+ const prev = _currentPage > 1;
287
+ const next = _currentPage < totalPages;
288
+
289
+ const pages = [];
290
+ const delta = 3;
291
+ for (let p = 1; p <= totalPages; p++) {
292
+ if (
293
+ p === 1 ||
294
+ p === totalPages ||
295
+ (p >= _currentPage - delta && p <= _currentPage + delta)
296
+ ) {
297
+ pages.push(p);
298
+ }
299
+ }
300
+
301
+ let html = `<button class="pg-btn" ${prev ? "" : "disabled"} onclick="gotoPage(${_currentPage - 1})"><i class="ri-arrow-left-s-line"></i></button>`;
302
+ let last = 0;
303
+ for (const p of pages) {
304
+ if (last && p - last > 1) html += `<span class="pg-ellipsis">…</span>`;
305
+ html += `<button class="pg-btn ${p === _currentPage ? "pg-active" : ""}" onclick="gotoPage(${p})">${p}</button>`;
306
+ last = p;
307
+ }
308
+ html += `<button class="pg-btn" ${next ? "" : "disabled"} onclick="gotoPage(${_currentPage + 1})"><i class="ri-arrow-right-s-line"></i></button>`;
309
+ bar.innerHTML = html;
310
+ }
311
+
312
+ function gotoPage(p) {
313
+ _currentPage = p;
314
+ _renderDataset();
315
+ }
316
+
317
+ // ── Import / Export ───────────────────────────────────────────────────────────
318
+ function exportJSON() {
319
+ if (!_dataset.length) return;
320
+ const blob = new Blob([JSON.stringify(_dataset, null, 2)], {
321
+ type: "application/json;charset=utf-8",
322
+ });
323
+ const a = document.createElement("a");
324
+ a.href = URL.createObjectURL(blob);
325
+ a.download = `labeled_${new Date().toISOString().slice(0, 10)}.json`;
326
+ a.click();
327
+ URL.revokeObjectURL(a.href);
328
+ }
329
+
330
+ function importJSON(event) {
331
+ const file = event.target.files[0];
332
+ if (!file) return;
333
+ const reader = new FileReader();
334
+ reader.onload = (e) => {
335
+ try {
336
+ const imported = JSON.parse(e.target.result);
337
+ if (!Array.isArray(imported))
338
+ throw new Error("Không phải mảng JSON hợp lệ");
339
+
340
+ let added = 0;
341
+ for (const entry of imported) {
342
+ if (!entry.url) continue;
343
+ if (_dataset.find((d) => d.url === entry.url)) continue;
344
+ _dataset.push({
345
+ id: entry.id || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)),
346
+ title: entry.title || entry.url,
347
+ url: entry.url,
348
+ script: entry.script || "",
349
+ references: Array.isArray(entry.references) ? entry.references : [],
350
+ status: ["none", "extracted", "verified"].includes(entry.status)
351
+ ? entry.status
352
+ : entry.script
353
+ ? "extracted"
354
+ : "none",
355
+ });
356
+ added++;
357
+ }
358
+ _currentPage = 1;
359
+ _saveDataset();
360
+ _renderDataset();
361
+ alert(`Import thành công: +${added} video mới (bỏ qua ${imported.length - added} trùng URL).`);
362
+ } catch (err) {
363
+ alert(`Import thất bại: ${err.message}`);
364
+ }
365
+ event.target.value = "";
366
+ };
367
+ reader.readAsText(file, "utf-8");
368
+ }
369
+
370
+ function importCSV(event) {
371
+ const file = event.target.files[0];
372
+ if (!file) return;
373
+ const reader = new FileReader();
374
+ reader.onload = (e) => {
375
+ try {
376
+ const rows = _parseCSV(e.target.result);
377
+ if (rows.length < 2) throw new Error("File CSV không có dữ liệu");
378
+
379
+ const header = rows[0].map((h) => h.trim().toLowerCase());
380
+ const col = (name) => header.indexOf(name);
381
+ const iTitle = col("title"),
382
+ iUrl = col("url"),
383
+ iScript = col("script"),
384
+ iRefs = col("references"),
385
+ iStatus = col("status");
386
+
387
+ if (iUrl === -1) throw new Error('Không tìm thấy cột "url" trong CSV');
388
+
389
+ let added = 0;
390
+ for (let i = 1; i < rows.length; i++) {
391
+ const row = rows[i];
392
+ const url = (row[iUrl] || "").trim();
393
+ if (!url) continue;
394
+ if (_dataset.find((d) => d.url === url)) continue;
395
+ const refsRaw = iRefs !== -1 ? row[iRefs] || "" : "";
396
+ const references = refsRaw
397
+ ? refsRaw.split(";").map((r) => r.trim()).filter(Boolean)
398
+ : [];
399
+ const scriptVal = iScript !== -1 ? row[iScript] || "" : "";
400
+ const statusVal =
401
+ iStatus !== -1 && ["none", "extracted", "verified"].includes(row[iStatus])
402
+ ? row[iStatus]
403
+ : scriptVal
404
+ ? "extracted"
405
+ : "none";
406
+ _dataset.push({
407
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + i,
408
+ title: iTitle !== -1 ? row[iTitle] || url : url,
409
+ url,
410
+ script: scriptVal,
411
+ references,
412
+ status: statusVal,
413
+ });
414
+ added++;
415
+ }
416
+ _currentPage = 1;
417
+ _saveDataset();
418
+ _renderDataset();
419
+ alert(`Import CSV thành công: +${added} video mới (bỏ qua ${rows.length - 1 - added} trùng URL).`);
420
+ } catch (err) {
421
+ alert(`Import CSV thất bại: ${err.message}`);
422
+ }
423
+ event.target.value = "";
424
+ };
425
+ reader.readAsText(file, "utf-8");
426
+ }
427
+
428
+ function exportCSV() {
429
+ if (!_dataset.length) return;
430
+ const header = ["title", "url", "script", "references", "status"];
431
+ const rows = _dataset.map((e) =>
432
+ [e.title, e.url, e.script, (e.references || []).join("; "), e.status || "none"]
433
+ .map(_csvCell)
434
+ .join(","),
435
+ );
436
+ const csv = [header.join(","), ...rows].join("\n");
437
+ const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
438
+ const a = document.createElement("a");
439
+ a.href = URL.createObjectURL(blob);
440
+ a.download = `labeled_${new Date().toISOString().slice(0, 10)}.csv`;
441
+ a.click();
442
+ URL.revokeObjectURL(a.href);
443
+ }
444
+
445
+ function _parseCSV(text) {
446
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
447
+ const rows = [];
448
+ let row = [], field = "", inQuotes = false, i = 0;
449
+ while (i < text.length) {
450
+ const ch = text[i];
451
+ if (inQuotes) {
452
+ if (ch === '"' && text[i + 1] === '"') { field += '"'; i += 2; continue; }
453
+ if (ch === '"') { inQuotes = false; i++; continue; }
454
+ field += ch;
455
+ } else {
456
+ if (ch === '"') { inQuotes = true; i++; continue; }
457
+ if (ch === ",") { row.push(field); field = ""; i++; continue; }
458
+ if (ch === "\n" || (ch === "\r" && text[i + 1] === "\n")) {
459
+ row.push(field); field = "";
460
+ if (row.some((c) => c !== "")) rows.push(row);
461
+ row = [];
462
+ i += ch === "\r" ? 2 : 1;
463
+ continue;
464
+ }
465
+ field += ch;
466
+ }
467
+ i++;
468
+ }
469
+ if (field || row.length) { row.push(field); if (row.some((c) => c !== "")) rows.push(row); }
470
+ return rows;
471
+ }
472
+
473
+ function _csvCell(val) {
474
+ const s = String(val ?? "").replace(/"/g, '""');
475
+ return `"${s}"`;
476
+ }
477
+
478
+ function _esc(s) {
479
+ return String(s ?? "")
480
+ .replace(/&/g, "&amp;")
481
+ .replace(/</g, "&lt;")
482
+ .replace(/>/g, "&gt;")
483
+ .replace(/"/g, "&quot;");
484
+ }