@clazic/urban 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,511 @@
1
+ // 전역 하단 파이프라인 strip + 바텀시트 컨트롤러
2
+ // - 9상태 칩 렌더, /api/pipeline/stats 실시간 카운트 (200ms 디바운스)
3
+ // - 칩 클릭 시 /api/pending?status= 결과를 바텀시트로 슬라이드 업
4
+
5
+ import { subscribe } from "../sse.js";
6
+
7
+ const STAGES = [
8
+ { key: "waiting", label: "대기" },
9
+ { key: "queued", label: "큐" },
10
+ { key: "downloading", label: "다운로드" },
11
+ { key: "converting", label: "변환" },
12
+ { key: "extracting", label: "추출" },
13
+ { key: "graph", label: "그래프" },
14
+ { key: "wiki", label: "위키" },
15
+ { key: "done", label: "완료" },
16
+ { key: "failed", label: "실패" },
17
+ ];
18
+
19
+ const STAGE_LABELS = Object.fromEntries(STAGES.map((s) => [s.key, s.label]));
20
+ const RELOAD_TYPES = new Set(["item_stage", "pending", "done", "failure", "collect:download", "collect:convert"]);
21
+
22
+ const KORDOC_STAGE_LABELS = { convert: "변환", render: "렌더링", probe: "속도측정", ocr: "OCR", proofread: "교정", merge: "병합" };
23
+ const PAGE_SIZE = 50;
24
+
25
+ const state = {
26
+ counts: {},
27
+ activeStatus: null,
28
+ sheetOffset: 0,
29
+ sheetTotal: 0,
30
+ sheetItems: [],
31
+ refreshTimer: null,
32
+ sheetTimer: null,
33
+ fallbackTimer: null,
34
+ prevFocus: null,
35
+ transitioning: false,
36
+ // SSE로 수신된 kordoc 최신 상태 캐시 — renderSheet 이후에도 적용할 수 있도록
37
+ // key: docid, value: { innerHTML, className, ocrModel, renderTotal }
38
+ kordocLiveState: new Map(),
39
+ };
40
+
41
+ /** kordoc 상태 우선순위: done(3) > progress(2) > loading(1) > 기타(0) */
42
+ function kordocRank(className) {
43
+ if (className.includes("is-done")) return 3;
44
+ if (className.includes("is-progress")) return 2;
45
+ if (className.includes("is-loading")) return 1;
46
+ return 0;
47
+ }
48
+
49
+ const reducedMotion = () =>
50
+ typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
51
+
52
+ export async function mountPipelineFooter() {
53
+ const strip = document.getElementById("pipeline-strip");
54
+ const sheet = document.getElementById("pipeline-sheet");
55
+ if (!strip || !sheet) return;
56
+
57
+ renderChips(strip);
58
+ strip.addEventListener("click", onChipClick);
59
+
60
+ sheet.addEventListener("click", async (e) => {
61
+ if (e.target.closest("[data-close]")) { closeSheet(); return; }
62
+ const retryBtn = e.target.closest("[data-retry]");
63
+ if (retryBtn) {
64
+ e.stopPropagation();
65
+ const docid = retryBtn.dataset.retry;
66
+ if (!docid) return;
67
+ retryBtn.disabled = true;
68
+ retryBtn.textContent = "…";
69
+ try {
70
+ await retryDocid(docid);
71
+ // 시트 즉시 재로드
72
+ if (state.activeStatus) await reloadSheet(state.activeStatus, { resetOffset: true });
73
+ await refreshStats();
74
+ } finally {
75
+ retryBtn.disabled = false;
76
+ retryBtn.textContent = "↻";
77
+ }
78
+ }
79
+ });
80
+ document.addEventListener("keydown", (e) => {
81
+ if (e.key === "Escape" && isOpen()) {
82
+ e.stopPropagation();
83
+ closeSheet();
84
+ }
85
+ });
86
+ const more = document.getElementById("pipeline-sheet-more");
87
+ if (more) more.addEventListener("click", loadMore);
88
+
89
+ // SSE: pipeline_stats 이벤트로 실시간 칩 업데이트 (폴링 대체)
90
+ subscribe("pipeline_stats", onPipelineStats);
91
+
92
+ // SSE: 단계 변경 이벤트로 바텀시트 재로드
93
+ subscribe("message", onSse);
94
+
95
+ // SSE 연결 끊김 감지 — 10초 후 fallback fetch
96
+ subscribe("connection", (s) => {
97
+ clearTimeout(state.fallbackTimer);
98
+ if (s?.connected === false) {
99
+ state.fallbackTimer = setTimeout(refreshStats, 10_000);
100
+ }
101
+ });
102
+
103
+ // 초기 로드: SSE 연결 전 즉시 표시용 (서버 pushStatsOnce와 경합하지만 무해)
104
+ await refreshStats();
105
+ }
106
+
107
+ function renderChips(root) {
108
+ const ul = root.querySelector(".pipeline-strip__chips");
109
+ if (!ul) return;
110
+ ul.innerHTML = STAGES.map((s) => `
111
+ <li>
112
+ <button type="button" class="pipeline-chip pipeline-chip--zero"
113
+ role="tab"
114
+ data-status="${s.key}"
115
+ aria-pressed="false"
116
+ aria-label="${s.label} 0건">
117
+ <span class="pipeline-chip__dot" aria-hidden="true"></span>
118
+ <span class="pipeline-chip__label">${s.label}</span>
119
+ <span class="pipeline-chip__count" data-count="${s.key}">0</span>
120
+ </button>
121
+ </li>
122
+ `).join("");
123
+ }
124
+
125
+ async function refreshStats() {
126
+ try {
127
+ const r = await fetch("/api/pipeline/stats", { cache: "no-store" });
128
+ if (!r.ok) return;
129
+ const j = await r.json();
130
+ const counts = j.byStatus || j.counts || j || {};
131
+ state.counts = counts;
132
+ for (const s of STAGES) {
133
+ const n = Number(counts[s.key] ?? 0);
134
+ const btn = document.querySelector(`.pipeline-chip[data-status="${s.key}"]`);
135
+ if (!btn) continue;
136
+ const span = btn.querySelector(".pipeline-chip__count");
137
+ if (span) span.textContent = String(n);
138
+ btn.classList.toggle("pipeline-chip--zero", n === 0);
139
+ btn.setAttribute("aria-label", `${s.label} ${n}건`);
140
+ }
141
+ } catch (err) {
142
+ // silent
143
+ }
144
+ }
145
+
146
+ /** SSE pipeline_stats 이벤트 수신 → 칩 즉시 업데이트 (폴링 불필요) */
147
+ function onPipelineStats(d) {
148
+ const counts = d?.byStatus || {};
149
+ state.counts = counts;
150
+ for (const s of STAGES) {
151
+ const n = Number(counts[s.key] ?? 0);
152
+ const btn = document.querySelector(`.pipeline-chip[data-status="${s.key}"]`);
153
+ if (!btn) continue;
154
+ const span = btn.querySelector(".pipeline-chip__count");
155
+ if (span) span.textContent = String(n);
156
+ btn.classList.toggle("pipeline-chip--zero", n === 0);
157
+ btn.setAttribute("aria-label", `${s.label} ${n}건`);
158
+ }
159
+ }
160
+
161
+ function onSse(d) {
162
+ if (!d) return;
163
+ if (d.type === "kordoc_stage" && d.docid) {
164
+ updateKordocInlineFooter(d);
165
+ return;
166
+ }
167
+ // stats는 pipeline_stats SSE 이벤트로 수신 — 여기서 refreshStats() 불필요
168
+ if (!RELOAD_TYPES.has(d.type)) return;
169
+ if (state.activeStatus) {
170
+ clearTimeout(state.sheetTimer);
171
+ state.sheetTimer = setTimeout(() => reloadSheet(state.activeStatus, { resetOffset: false }), 180);
172
+ }
173
+ }
174
+
175
+ function updateKordocInlineFooter(payload) {
176
+ const { docid, kordocStage, phase, current, total, model } = payload;
177
+ const label = KORDOC_STAGE_LABELS[kordocStage] ?? kordocStage;
178
+
179
+ // ocr·proofread는 페이지마다 stage_start가 반복 발생 — start는 캐시 업데이트만, DOM 갱신 생략
180
+ const CYCLE_STAGES = new Set(["ocr", "proofread"]);
181
+ if (phase === "start" && CYCLE_STAGES.has(kordocStage)) {
182
+ // 모델명은 캐시에 저장해두기 위해 el 없어도 처리
183
+ const el = document.querySelector(`[data-kordoc-footer="${CSS.escape(docid)}"]`);
184
+ if (kordocStage === "ocr") {
185
+ const modelName = model || payload.message?.match(/\((.+?)\)/)?.[1];
186
+ if (modelName) {
187
+ if (el && !el.dataset.ocrModel) el.dataset.ocrModel = modelName;
188
+ // 캐시에도 반영
189
+ const cached = state.kordocLiveState.get(docid);
190
+ if (cached) cached.ocrModel = cached.ocrModel || modelName;
191
+ }
192
+ }
193
+ return;
194
+ }
195
+
196
+ // DOM 요소 — 없어도 캐시는 업데이트 (나중에 renderSheet가 적용)
197
+ const el = document.querySelector(`[data-kordoc-footer="${CSS.escape(docid)}"]`);
198
+
199
+ if (phase === "start") {
200
+ const html = `<span>▶ ${escapeHtml(label)}</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div>`;
201
+ const cls = "pipeline-sheet__kordoc is-loading";
202
+ if (el) { el.innerHTML = html; el.className = cls; }
203
+ state.kordocLiveState.set(docid, { innerHTML: html, className: cls,
204
+ ocrModel: el?.dataset.ocrModel || state.kordocLiveState.get(docid)?.ocrModel || "",
205
+ renderTotal: el?.dataset.renderTotal || state.kordocLiveState.get(docid)?.renderTotal || "" });
206
+ } else if (phase === "progress") {
207
+ const hasPages = typeof current === "number" && typeof total === "number";
208
+ const pct = payload.percent ?? (hasPages && total > 0 ? Math.round((current / total) * 100) : 0);
209
+ const countLabel = hasPages ? ` ${current}/${total}` : ` ${pct}%`;
210
+ const cached = state.kordocLiveState.get(docid) ?? {};
211
+ let ocrModel = el?.dataset.ocrModel || cached.ocrModel || "";
212
+ if (kordocStage === "ocr" && !ocrModel) {
213
+ ocrModel = model || payload.message?.match(/\((.+?)\)/)?.[1] || "";
214
+ }
215
+ const renderTotal = (kordocStage === "render" && hasPages) ? String(total) : (el?.dataset.renderTotal || cached.renderTotal || "");
216
+ const modelHint = kordocStage === "ocr" && ocrModel ? ` · ${ocrModel}` : "";
217
+ const html = `<span>${escapeHtml(label)}${countLabel}${escapeHtml(modelHint)}</span><div class="kordoc-bar"><div class="kordoc-bar-fill" style="width:${pct}%"></div></div><span class="kordoc-pct">${pct}%</span>`;
218
+ const cls = "pipeline-sheet__kordoc is-progress";
219
+ if (el) {
220
+ if (ocrModel) el.dataset.ocrModel = ocrModel;
221
+ if (renderTotal) el.dataset.renderTotal = renderTotal;
222
+ el.innerHTML = html; el.className = cls;
223
+ }
224
+ state.kordocLiveState.set(docid, { innerHTML: html, className: cls, ocrModel, renderTotal });
225
+ } else if (phase === "done") {
226
+ const cached = state.kordocLiveState.get(docid) ?? {};
227
+ const ocrModel = el?.dataset.ocrModel || cached.ocrModel || "";
228
+ const renderTotal = el?.dataset.renderTotal || cached.renderTotal || "";
229
+ const ocrHint = kordocStage === "ocr" && ocrModel ? ` · ${ocrModel}` : "";
230
+ const renderHint = kordocStage === "render" && renderTotal ? ` · ${renderTotal}장` : "";
231
+ const text = `✓ ${label}${ocrHint}${renderHint}`;
232
+ const cls = "pipeline-sheet__kordoc is-done";
233
+ if (el) { el.textContent = text; el.className = cls; }
234
+ state.kordocLiveState.set(docid, { innerHTML: escapeHtml(text), className: cls, ocrModel, renderTotal });
235
+ } else if (phase === "error") {
236
+ const text = `✗ ${label}`;
237
+ const cls = "pipeline-sheet__kordoc is-error";
238
+ if (el) { el.textContent = text; el.className = cls; }
239
+ state.kordocLiveState.set(docid, { innerHTML: escapeHtml(text), className: cls,
240
+ ocrModel: el?.dataset.ocrModel || "", renderTotal: el?.dataset.renderTotal || "" });
241
+ }
242
+ }
243
+
244
+ function onChipClick(e) {
245
+ const btn = e.target.closest(".pipeline-chip");
246
+ if (!btn) return;
247
+ const status = btn.dataset.status;
248
+ if (!status) return;
249
+ if (state.activeStatus === status && isOpen()) {
250
+ closeSheet();
251
+ return;
252
+ }
253
+ for (const c of document.querySelectorAll(".pipeline-chip")) {
254
+ c.setAttribute("aria-pressed", c === btn ? "true" : "false");
255
+ }
256
+ openSheet(status);
257
+ }
258
+
259
+ export function isOpen() {
260
+ const sheet = document.getElementById("pipeline-sheet");
261
+ return !!sheet && !sheet.hasAttribute("hidden") && sheet.classList.contains("is-open");
262
+ }
263
+
264
+ export async function openSheet(status) {
265
+ const sheet = document.getElementById("pipeline-sheet");
266
+ if (!sheet) return;
267
+ state.activeStatus = status;
268
+ state.sheetOffset = 0;
269
+ state.sheetItems = [];
270
+ const titleEl = document.getElementById("pipeline-sheet-title");
271
+ if (titleEl) titleEl.textContent = STAGE_LABELS[status] || status;
272
+
273
+ state.prevFocus = document.activeElement;
274
+ sheet.hidden = false;
275
+ const panel = sheet.querySelector(".pipeline-sheet__panel");
276
+
277
+ if (reducedMotion()) {
278
+ sheet.classList.add("is-open");
279
+ } else {
280
+ requestAnimationFrame(() => {
281
+ requestAnimationFrame(() => sheet.classList.add("is-open"));
282
+ });
283
+ }
284
+ if (panel) setTimeout(() => panel.focus({ preventScroll: true }), 60);
285
+
286
+ await reloadSheet(status, { resetOffset: true });
287
+ }
288
+
289
+ export function closeSheet() {
290
+ const sheet = document.getElementById("pipeline-sheet");
291
+ if (!sheet || sheet.hidden) return;
292
+ sheet.classList.remove("is-open");
293
+ for (const c of document.querySelectorAll(".pipeline-chip")) c.setAttribute("aria-pressed", "false");
294
+ state.activeStatus = null;
295
+
296
+ const finalize = () => {
297
+ sheet.hidden = true;
298
+ if (state.prevFocus && typeof state.prevFocus.focus === "function") {
299
+ state.prevFocus.focus();
300
+ state.prevFocus = null;
301
+ }
302
+ };
303
+ if (reducedMotion()) {
304
+ finalize();
305
+ return;
306
+ }
307
+ const panel = sheet.querySelector(".pipeline-sheet__panel");
308
+ let done = false;
309
+ const handler = () => { if (done) return; done = true; panel?.removeEventListener("transitionend", handler); finalize(); };
310
+ panel?.addEventListener("transitionend", handler);
311
+ setTimeout(handler, 320); // safety
312
+ }
313
+
314
+ export async function reloadSheet(status, { resetOffset = false } = {}) {
315
+ if (!status) status = state.activeStatus;
316
+ if (!status) return;
317
+ if (resetOffset) {
318
+ state.sheetOffset = 0;
319
+ state.sheetItems = [];
320
+ }
321
+ try {
322
+ const params = new URLSearchParams({
323
+ status,
324
+ limit: String(PAGE_SIZE),
325
+ offset: String(state.sheetOffset),
326
+ });
327
+ const r = await fetch(`/api/pending?${params}`, { cache: "no-store" });
328
+ if (!r.ok) return;
329
+ const j = await r.json();
330
+ const items = j.items || [];
331
+ state.sheetTotal = Number(j.total ?? items.length);
332
+ if (resetOffset || state.sheetOffset === 0) {
333
+ state.sheetItems = items;
334
+ } else {
335
+ state.sheetItems = state.sheetItems.concat(items);
336
+ }
337
+ renderSheet();
338
+ } catch (err) {
339
+ // silent
340
+ }
341
+ }
342
+
343
+ function loadMore() {
344
+ state.sheetOffset += PAGE_SIZE;
345
+ reloadSheet(state.activeStatus, { resetOffset: false });
346
+ }
347
+
348
+ function renderSheet() {
349
+ const listEl = document.getElementById("pipeline-sheet-list");
350
+ const countEl = document.getElementById("pipeline-sheet-count");
351
+ const moreEl = document.getElementById("pipeline-sheet-more");
352
+ if (!listEl) return;
353
+
354
+ if (countEl) countEl.textContent = `${state.sheetTotal}건`;
355
+
356
+ if (!state.sheetItems.length) {
357
+ listEl.innerHTML = `<li class="pipeline-sheet__empty">해당 상태의 진행 파일이 없습니다.</li>`;
358
+ } else {
359
+ listEl.innerHTML = state.sheetItems.map((it) => {
360
+ const title = escapeHtml(it.display_title || it.title || it.raw_filename || it.docid || "(제목 없음)");
361
+ const subId = escapeHtml(it.docid || "");
362
+ const rawFile = escapeHtml(it.raw_filename || "");
363
+ const source = escapeHtml(it.source || "");
364
+ const institution = escapeHtml(it.institution || "");
365
+ const updated = formatTs(it.finished_at || it.started_at || it.found_at);
366
+ const stage = escapeHtml(STAGE_LABELS[it.status] || it.status || "");
367
+ const isFailed = it.status === "failed";
368
+ const failedStage = escapeHtml(it.failed_stage || "");
369
+ const failedReason = escapeHtml(truncate(it.failed_reason || "", 240));
370
+
371
+ const metaParts = [];
372
+ if (source) metaParts.push(source);
373
+ if (institution) metaParts.push(institution);
374
+ if (updated) metaParts.push(updated);
375
+ if (it.retry_count) metaParts.push(`재시도 ${it.retry_count}`);
376
+
377
+ // 파일명 줄: 왼쪽에 raw filename, 오른쪽에 실패 사유(실패일 때만)
378
+ const hasFileLine = rawFile || (isFailed && (failedReason || failedStage));
379
+ const fileLine = hasFileLine ? `
380
+ <div class="pipeline-sheet__item-fileline">
381
+ <span class="pipeline-sheet__item-filename" title="${rawFile}">${rawFile ? `📄 ${rawFile}` : ""}</span>
382
+ ${isFailed && (failedReason || failedStage) ? `
383
+ <span class="pipeline-sheet__item-failure" title="${failedReason || failedStage}">
384
+ <span class="pipeline-sheet__failure-label">실패${failedStage ? `·${failedStage}` : ""}</span>
385
+ ${failedReason ? `<span class="pipeline-sheet__failure-reason">${failedReason}</span>` : ""}
386
+ </span>
387
+ ` : ""}
388
+ </div>
389
+ ` : "";
390
+
391
+ // kordoc 진행 상태 (converting 항목)
392
+ let kordocHtml = "";
393
+ if (it.status === "converting" && it.kordoc_progress) {
394
+ try {
395
+ const p = JSON.parse(it.kordoc_progress);
396
+ const klabel = KORDOC_STAGE_LABELS[p.stage] ?? p.stage ?? "";
397
+ let inner = "";
398
+ let cls = "";
399
+ const hasPages = typeof p.current === "number" && typeof p.total === "number";
400
+ const hasPct = typeof p.percent === "number";
401
+ if (hasPages || hasPct) {
402
+ const pct = hasPages ? (p.total > 0 ? Math.round((p.current / p.total) * 100) : 0) : p.percent;
403
+ const modelHint = (p.stage === "ocr" && p.model) ? ` · ${escapeHtml(p.model)}` : "";
404
+ const label = hasPages ? `${escapeHtml(klabel)} ${p.current}/${p.total}${modelHint}` : `${escapeHtml(klabel)} ${pct}%${modelHint}`;
405
+ inner = `<span>${label}</span><div class="kordoc-bar"><div class="kordoc-bar-fill" style="width:${pct}%"></div></div><span class="kordoc-pct">${pct}%</span>`;
406
+ cls = "is-progress";
407
+ } else if (klabel) {
408
+ // 진행 정보 없는 단계 → 로딩 바 (is-start는 바 없어 시각적 피드백 없음)
409
+ const modelHint = (p.stage === "ocr" && p.model) ? ` · ${escapeHtml(p.model)}` : "";
410
+ inner = `<span>▶ ${escapeHtml(klabel)}${modelHint}</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div>`;
411
+ cls = "is-loading";
412
+ }
413
+ // inner가 비어 있으면 기본 로딩 바
414
+ if (!inner) { inner = `<span>▶ 변환 중</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div>`; cls = "is-loading"; }
415
+ kordocHtml = `<div class="pipeline-sheet__kordoc ${cls}" data-kordoc-footer="${subId}">${inner}</div>`;
416
+ } catch {}
417
+ }
418
+ // kordoc_progress 없음(변환 초기화 중)에도 로딩 바 노출
419
+ if (!kordocHtml && it.status === "converting") {
420
+ kordocHtml = `<div class="pipeline-sheet__kordoc is-loading" data-kordoc-footer="${subId}"><span>▶ 변환 준비 중</span><div class="kordoc-bar"><div class="kordoc-bar-fill"></div></div></div>`;
421
+ }
422
+
423
+ const showId = subId && subId !== title ? subId : "";
424
+ return `
425
+ <li class="pipeline-sheet__item ${isFailed ? "pipeline-sheet__item--failed" : ""}" data-docid="${subId}">
426
+ <div class="pipeline-sheet__item-main">
427
+ <div class="pipeline-sheet__item-title" title="${title}">${title}</div>
428
+ ${kordocHtml}
429
+ ${fileLine}
430
+ <div class="pipeline-sheet__item-meta">
431
+ ${showId ? `<span class="pipeline-sheet__item-id">${showId}</span>` : ""}
432
+ ${metaParts.map((p) => `<span>${p}</span>`).join("")}
433
+ </div>
434
+ </div>
435
+ ${isFailed ? `<button type="button" class="pipeline-sheet__retry" data-retry="${subId}" title="재시도">↻</button>` : ""}
436
+ <span class="pipeline-sheet__item-stage ${isFailed ? "pipeline-sheet__item-stage--failed" : ""}">${stage}</span>
437
+ </li>
438
+ `;
439
+ }).join("");
440
+
441
+ // kordocLiveState 적용: SSE로 캐시된 최신 상태가 DB 렌더 결과보다 앞서면 덮어쓰기
442
+ // 시트를 새로 열었을 때 이미 진행 중이던 kordoc 상태도 즉시 반영됨
443
+ for (const [docid, live] of state.kordocLiveState) {
444
+ const newEl = listEl.querySelector(`[data-kordoc-footer="${CSS.escape(docid)}"]`);
445
+ if (!newEl) continue;
446
+ if (kordocRank(live.className) >= kordocRank(newEl.className)) {
447
+ newEl.innerHTML = live.innerHTML; // 모든 케이스에서 escapeHtml된 문자열로 저장됨
448
+ newEl.className = live.className;
449
+ if (live.ocrModel) newEl.dataset.ocrModel = live.ocrModel;
450
+ if (live.renderTotal) newEl.dataset.renderTotal = live.renderTotal;
451
+ }
452
+ }
453
+ }
454
+
455
+ if (moreEl) {
456
+ const hasMore = state.sheetItems.length < state.sheetTotal;
457
+ moreEl.hidden = !hasMore;
458
+ }
459
+ }
460
+
461
+ function formatTs(ms) {
462
+ if (!ms) return "";
463
+ const n = Number(ms);
464
+ if (!Number.isFinite(n) || n <= 0) return "";
465
+ const d = new Date(n);
466
+ const pad = (x) => String(x).padStart(2, "0");
467
+ return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
468
+ }
469
+
470
+ async function retryDocid(docid, { force = false } = {}) {
471
+ try {
472
+ const r = await fetch(`/api/pending/${encodeURIComponent(docid)}/retry`, {
473
+ method: "POST",
474
+ headers: { "Content-Type": "application/json" },
475
+ body: JSON.stringify({ force }),
476
+ });
477
+ const body = await r.json().catch(() => ({}));
478
+ if (r.status === 409 && body?.reason === "blocked_by_cache") {
479
+ const msg = [
480
+ `이 항목은 과거 수집에서 차단 학습됐습니다.`,
481
+ ``,
482
+ ` 상태: ${body.status}`,
483
+ body.detail ? ` 사유: ${body.detail}` : null,
484
+ ``,
485
+ `차단 캐시 해제 후 재시도할까요?`,
486
+ ].filter(Boolean).join("\n");
487
+ if (window.confirm(msg)) return retryDocid(docid, { force: true });
488
+ return { ok: false, cancelled: true };
489
+ }
490
+ if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
491
+ return body;
492
+ } catch (e) {
493
+ console.error("[pipeline-footer] retry 실패:", e);
494
+ alert(`재시도 실패: ${e.message}`);
495
+ return { ok: false, error: e.message };
496
+ }
497
+ }
498
+
499
+ function truncate(s, n) {
500
+ const str = String(s || "");
501
+ return str.length > n ? str.slice(0, n - 1) + "…" : str;
502
+ }
503
+
504
+ function escapeHtml(s) {
505
+ return String(s)
506
+ .replace(/&/g, "&amp;")
507
+ .replace(/</g, "&lt;")
508
+ .replace(/>/g, "&gt;")
509
+ .replace(/"/g, "&quot;")
510
+ .replace(/'/g, "&#39;");
511
+ }
@@ -0,0 +1,19 @@
1
+ export function renderPipelineBar(container, pipeline = {}, failCount = 0) {
2
+ if (!container) return;
3
+ const steps = [
4
+ ["waiting", "대기"],
5
+ ["queued", "큐대기"],
6
+ ["downloading", "다운로드"],
7
+ ["converting", "MD변환"],
8
+ ["extracting", "AI추출"],
9
+ ["graph", "그래프"],
10
+ ["wiki", "위키"],
11
+ ["done", "완료"],
12
+ ["failed", "실패"],
13
+ ];
14
+ container.innerHTML = steps.map(([key, label]) => {
15
+ const value = key === "failed" ? failCount : (pipeline[key] || 0);
16
+ const active = value > 0 ? " on" : "";
17
+ return `<div class="step${active}" data-stage="${key}"><div class="n">${label}</div><div class="m">${value}</div></div>`;
18
+ }).join("");
19
+ }
@@ -0,0 +1,6 @@
1
+ import { formatNumber } from "../format.js";
2
+
3
+ export function renderStat(id, value) {
4
+ const el = document.getElementById(id);
5
+ if (el) el.textContent = formatNumber(value);
6
+ }
@@ -0,0 +1,9 @@
1
+ import { escapeHtml } from "../dom.js";
2
+
3
+ export function renderRows(container, rowsHtml) {
4
+ if (container) container.innerHTML = rowsHtml || '<div class="empty-state">항목이 없습니다.</div>';
5
+ }
6
+
7
+ export function textCell(value) {
8
+ return `<span>${escapeHtml(value)}</span>`;
9
+ }
@@ -0,0 +1,12 @@
1
+ export function pushToast(message, tone = "info") {
2
+ const root = document.getElementById("toast-root");
3
+ if (!root) return;
4
+ const el = document.createElement("div");
5
+ el.className = "card";
6
+ el.style.padding = "12px 14px";
7
+ el.style.minWidth = "240px";
8
+ el.style.color = tone === "error" ? "var(--danger)" : "var(--label-1)";
9
+ el.textContent = message;
10
+ root.appendChild(el);
11
+ setTimeout(() => el.remove(), 3200);
12
+ }
@@ -0,0 +1,14 @@
1
+ export const qs = (selector, root = document) => root.querySelector(selector);
2
+ export const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector));
3
+
4
+ export function escapeHtml(value) {
5
+ return String(value ?? "")
6
+ .replace(/&/g, "&amp;")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/"/g, "&quot;");
10
+ }
11
+
12
+ export function setHtml(el, html) {
13
+ if (el) el.innerHTML = html;
14
+ }
@@ -0,0 +1,20 @@
1
+ export function formatNumber(value) {
2
+ return Number(value || 0).toLocaleString("ko-KR");
3
+ }
4
+
5
+ export function formatDate(value) {
6
+ if (!value) return "";
7
+ return new Date(value).toLocaleDateString("ko-KR");
8
+ }
9
+
10
+ export function formatDateTime(value) {
11
+ if (!value) return "";
12
+ return new Date(value).toLocaleString("ko-KR");
13
+ }
14
+
15
+ export function formatDuration(ms) {
16
+ const value = Number(ms || 0);
17
+ if (value < 1000) return `${value}ms`;
18
+ if (value < 60000) return `${(value / 1000).toFixed(1)}초`;
19
+ return `${Math.floor(value / 60000)}분 ${Math.floor((value % 60000) / 1000)}초`;
20
+ }