@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.
- package/package.json +2 -2
- package/scripts/e2e-mcp.js +214 -0
- package/scripts/postinstall.js +56 -0
- package/scripts/setup-graphify.js +107 -0
- package/scripts/update_css.js +799 -0
- package/web/old/scripts/app.js +2618 -0
- package/web/old/scripts/router.js +15 -0
- package/web/old/scripts/tailwind.min.js +83 -0
- package/web/scripts/api.js +41 -0
- package/web/scripts/app.js +76 -0
- package/web/scripts/components/command-palette.js +88 -0
- package/web/scripts/components/drawer.js +9 -0
- package/web/scripts/components/file-viewer.js +120 -0
- package/web/scripts/components/pdf-viewer.js +188 -0
- package/web/scripts/components/pipeline-footer.js +511 -0
- package/web/scripts/components/pipeline-strip.js +19 -0
- package/web/scripts/components/stat-cards.js +6 -0
- package/web/scripts/components/table.js +9 -0
- package/web/scripts/components/toast.js +12 -0
- package/web/scripts/dom.js +14 -0
- package/web/scripts/format.js +20 -0
- package/web/scripts/pages/catalog.js +134 -0
- package/web/scripts/pages/failures.js +72 -0
- package/web/scripts/pages/graph.js +521 -0
- package/web/scripts/pages/home.js +231 -0
- package/web/scripts/pages/pending.js +503 -0
- package/web/scripts/pages/schedules.js +134 -0
- package/web/scripts/pages/search.js +655 -0
- package/web/scripts/pages/settings.js +196 -0
- package/web/scripts/pages/wiki.js +135 -0
- package/web/scripts/router.js +58 -0
- package/web/scripts/shell.js +81 -0
- package/web/scripts/sse.js +36 -0
- package/web/scripts/store.js +32 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { getJson, postJson } from "../api.js";
|
|
2
|
+
import { qs, qsa, escapeHtml } from "../dom.js";
|
|
3
|
+
import { openDrawer, closeDrawer } from "../components/drawer.js";
|
|
4
|
+
import { openFileViewer } from "../components/file-viewer.js";
|
|
5
|
+
import { subscribe } from "../sse.js";
|
|
6
|
+
|
|
7
|
+
let extItems = {};
|
|
8
|
+
let lastFtsItems = [];
|
|
9
|
+
|
|
10
|
+
const INTERNAL_SOURCES = ["prism", "nanet", "dbpia"];
|
|
11
|
+
const EXTERNAL_SOURCES = ["prism", "nanet", "dbpia"];
|
|
12
|
+
|
|
13
|
+
function switchTab(name) {
|
|
14
|
+
qsa("[data-search-tab]").forEach((el) => {
|
|
15
|
+
const active = el.dataset.searchTab === name;
|
|
16
|
+
el.classList.toggle("active", active);
|
|
17
|
+
el.classList.toggle("hidden", !active);
|
|
18
|
+
});
|
|
19
|
+
qsa("[data-tab]").forEach((el) => el.classList.toggle("active", el.dataset.tab === name));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function highlightKeyword(text, q) {
|
|
23
|
+
if (!q) return escapeHtml(text);
|
|
24
|
+
const escaped = escapeHtml(text);
|
|
25
|
+
const terms = q.trim().split(/\s+/).filter(Boolean).map((t) =>
|
|
26
|
+
t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
27
|
+
);
|
|
28
|
+
if (!terms.length) return escaped;
|
|
29
|
+
const re = new RegExp(`(${terms.join("|")})`, "gi");
|
|
30
|
+
return escaped.replace(re, "<mark>$1</mark>");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bindChipGroup(wrap) {
|
|
34
|
+
// mount 가 여러 번 호출돼도 중복 바인딩 방지
|
|
35
|
+
if (wrap.dataset.chipBound === "1") return;
|
|
36
|
+
wrap.dataset.chipBound = "1";
|
|
37
|
+
wrap.addEventListener("click", (e) => {
|
|
38
|
+
const btn = e.target.closest("[data-source]");
|
|
39
|
+
if (!btn) return;
|
|
40
|
+
if (btn.dataset.source === "") {
|
|
41
|
+
wrap.querySelectorAll('[data-source]:not([data-source=""])').forEach((b) => b.classList.remove("active"));
|
|
42
|
+
btn.classList.add("active");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
btn.classList.toggle("active");
|
|
46
|
+
const selected = qsa('[data-source].active:not([data-source=""])', wrap);
|
|
47
|
+
const allBtn = wrap.querySelector('[data-source=""]');
|
|
48
|
+
if (allBtn) allBtn.classList.toggle("active", selected.length === 0);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderChipGroup(selector, sources) {
|
|
53
|
+
const wrap = qs(selector);
|
|
54
|
+
if (!wrap) return;
|
|
55
|
+
wrap.innerHTML = `
|
|
56
|
+
<button class="search-filter-pill active" data-source="" type="button">모든 소스</button>
|
|
57
|
+
${sources.map((source) => `<button class="search-filter-pill" data-source="${escapeHtml(source)}" type="button">${escapeHtml(source.toUpperCase())}</button>`).join("")}
|
|
58
|
+
`;
|
|
59
|
+
bindChipGroup(wrap);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getSelectedSources(selector, fallback) {
|
|
63
|
+
const selected = qsa(`${selector} [data-source].active:not([data-source=""])`)
|
|
64
|
+
.map((chip) => chip.dataset.source)
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
return selected.length ? selected : fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderInternalResults(items, q, fts) {
|
|
70
|
+
return items.map((item, idx) => {
|
|
71
|
+
if (fts === "1" && item.slug) {
|
|
72
|
+
// snippet은 서버가 <mark> 포함 HTML로 내려주므로 escape 금지
|
|
73
|
+
// body/snippet 부재 시 title만 표시
|
|
74
|
+
const snippetHtml = item.snippet
|
|
75
|
+
? `<div class="result-snippet">${item.snippet}</div>`
|
|
76
|
+
: (item.body
|
|
77
|
+
? `<div class="result-snippet">${escapeHtml(item.body.slice(0, 160))}…</div>`
|
|
78
|
+
: "");
|
|
79
|
+
return `<div class="result result-wiki" data-wiki-idx="${idx}" data-wiki-slug="${escapeHtml(item.slug)}" role="button" tabindex="0">
|
|
80
|
+
<div class="result-body">
|
|
81
|
+
<div class="result-title"><span class="chip">WIKI</span> ${highlightKeyword(item.title || item.slug, q)}</div>
|
|
82
|
+
${snippetHtml}
|
|
83
|
+
</div>
|
|
84
|
+
</div>`;
|
|
85
|
+
}
|
|
86
|
+
let snippet = "";
|
|
87
|
+
let institution = "";
|
|
88
|
+
let pubYear = item.year || "";
|
|
89
|
+
if (item.extracted_json) {
|
|
90
|
+
try {
|
|
91
|
+
const ext = JSON.parse(item.extracted_json);
|
|
92
|
+
snippet = ext.summary_120 || "";
|
|
93
|
+
const pubByRel = ext.relations?.find((r) => r.relation === "published_by" && r.target_type === "org");
|
|
94
|
+
if (pubByRel) institution = pubByRel.target;
|
|
95
|
+
else {
|
|
96
|
+
const writerOrg = ext.entities?.find((e) => e.kind === "organization" && /작성|주관|발행|발간/.test(e.role));
|
|
97
|
+
institution = writerOrg?.name || ext.entities?.find((e) => e.kind === "organization")?.name || "";
|
|
98
|
+
}
|
|
99
|
+
if (!pubYear) {
|
|
100
|
+
const yearRel = ext.relations?.find((r) => r.relation === "published_in" && r.target_type === "year");
|
|
101
|
+
pubYear = yearRel?.target || "";
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
const filePath = item.archive_path || item.file_path || "";
|
|
106
|
+
const fileName = filePath ? filePath.split("/").pop() : "";
|
|
107
|
+
const mdPath = item.md_path || "";
|
|
108
|
+
const metaParts = [institution, pubYear].filter(Boolean);
|
|
109
|
+
const srcKey = (item.source || "").toLowerCase();
|
|
110
|
+
const srcClass = srcKey === "prism" ? "prism" : srcKey === "nanet" ? "nanet" : "";
|
|
111
|
+
const sourceBadge = item.source ? `<span class="chip sm ${srcClass}">${escapeHtml(item.source.toUpperCase())}</span>` : "";
|
|
112
|
+
const score = item.score != null ? `<span class="result-score-pct">${Math.round(item.score * 100)}% match</span>` : "";
|
|
113
|
+
return `<div class="result result-report" data-hash="${escapeHtml(item.hash || "")}" data-raw-path="${escapeHtml(filePath)}" data-md-path="${escapeHtml(mdPath)}" role="button" tabindex="0">
|
|
114
|
+
<div class="result-body">
|
|
115
|
+
<div class="result-title">${highlightKeyword(item.title || "제목 없음", q)}</div>
|
|
116
|
+
${snippet ? `<div class="result-snippet">${escapeHtml(snippet)}</div>` : ""}
|
|
117
|
+
${metaParts.length ? `<div class="result-row"><span class="result-author">${escapeHtml(metaParts.join(" · "))}</span></div>` : ""}
|
|
118
|
+
${fileName ? `<div class="result-filename">${escapeHtml(fileName)}</div>` : ""}
|
|
119
|
+
</div>
|
|
120
|
+
<div class="result-score">${sourceBadge}${score}</div>
|
|
121
|
+
</div>`;
|
|
122
|
+
}).join("") || '<div class="empty-state">결과 없음</div>';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderWikiMarkdown(markdown = "") {
|
|
126
|
+
// [[slug]] → 클릭 가능한 앵커로 치환 (drawer 내부에서 동일 drawer 재로드)
|
|
127
|
+
const replaced = markdown.replace(/\[\[([^\]]+)\]\]/g, (_, slug) => {
|
|
128
|
+
const trimmed = slug.trim();
|
|
129
|
+
return `<a href="#" class="wiki-inline-link" data-wiki-slug="${escapeHtml(trimmed)}">${escapeHtml(trimmed)}</a>`;
|
|
130
|
+
});
|
|
131
|
+
if (window.marked?.parse) return window.marked.parse(replaced);
|
|
132
|
+
// marked 미로드 fallback: pre + escape
|
|
133
|
+
return `<pre class="wiki-fallback-pre">${escapeHtml(replaced)}</pre>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function openWikiDrawer(slug, { title, body } = {}) {
|
|
137
|
+
const drawerTitle = qs("#drawer-title");
|
|
138
|
+
const drawerBody = qs("#drawer-body");
|
|
139
|
+
const drawerActions = qs("#drawer-actions");
|
|
140
|
+
if (!drawerBody) return;
|
|
141
|
+
|
|
142
|
+
// 다른 용도(file-viewer 등)에서 남긴 drawer-wide 제거
|
|
143
|
+
document.getElementById("report-drawer")?.classList.remove("drawer-wide");
|
|
144
|
+
|
|
145
|
+
if (drawerTitle) drawerTitle.textContent = title || slug;
|
|
146
|
+
drawerBody.innerHTML = '<div class="loading-state"><div class="loading" style="margin:0 auto;"></div></div>';
|
|
147
|
+
if (drawerActions) {
|
|
148
|
+
const isDeletable = /^(topics|orgs|reports)\//.test(slug);
|
|
149
|
+
drawerActions.innerHTML = `
|
|
150
|
+
<a class="btn ghost" href="/wiki#${encodeURIComponent(slug)}">위키 페이지 열기</a>
|
|
151
|
+
${isDeletable
|
|
152
|
+
? `<button class="btn danger" id="wiki-delete-btn" type="button">
|
|
153
|
+
<i class="fa-solid fa-trash"></i><span>삭제</span>
|
|
154
|
+
</button>`
|
|
155
|
+
: ""}
|
|
156
|
+
`;
|
|
157
|
+
qs("#wiki-delete-btn", drawerActions)?.addEventListener("click", async () => {
|
|
158
|
+
if (!confirm(`"${slug}" 위키 페이지를 삭제하시겠습니까?\n\nFTS 색인과 파일이 제거됩니다.\n그래프 노드는 유지되며 다음 인제스트 시 자동 복구됩니다.`)) return;
|
|
159
|
+
try {
|
|
160
|
+
const encoded = slug.split("/").map(encodeURIComponent).join("/");
|
|
161
|
+
const res = await fetch(`/api/wiki/${encoded}`, { method: "DELETE" });
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
const err = await res.json().catch(() => ({}));
|
|
164
|
+
alert(`삭제 실패: ${err?.error || res.statusText}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// 검색 결과 카드 즉시 제거
|
|
168
|
+
lastFtsItems = lastFtsItems.filter((it) => it.slug !== slug);
|
|
169
|
+
document.querySelector(`.result-wiki[data-wiki-slug="${CSS.escape(slug)}"]`)?.remove();
|
|
170
|
+
closeDrawer("report-drawer");
|
|
171
|
+
} catch (e) {
|
|
172
|
+
alert(`삭제 오류: ${e.message}`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
openDrawer("report-drawer");
|
|
177
|
+
|
|
178
|
+
// body가 이미 있으면 바로 렌더, 없으면 API에서 로드
|
|
179
|
+
let markdown = body || "";
|
|
180
|
+
if (!markdown) {
|
|
181
|
+
try {
|
|
182
|
+
const encoded = slug.split("/").map(encodeURIComponent).join("/");
|
|
183
|
+
const data = await getJson(`/api/wiki/${encoded}`);
|
|
184
|
+
const raw = data.content || "";
|
|
185
|
+
const fmMatch = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
186
|
+
markdown = fmMatch ? raw.slice(fmMatch[0].length) : raw;
|
|
187
|
+
} catch {
|
|
188
|
+
drawerBody.innerHTML = `<div class="empty-state">위키 내용을 불러올 수 없습니다: ${escapeHtml(slug)}</div>`;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
drawerBody.innerHTML = `<div class="prose viewer-prose">${renderWikiMarkdown(markdown)}</div>`;
|
|
194
|
+
|
|
195
|
+
// 드로어 내부 [[위키링크]] 클릭 → 같은 드로어 내에서 교체 로드
|
|
196
|
+
qsa(".wiki-inline-link", drawerBody).forEach((link) => {
|
|
197
|
+
link.addEventListener("click", (e) => {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
const nextSlug = link.dataset.wikiSlug;
|
|
200
|
+
if (nextSlug) openWikiDrawer(nextSlug);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function bindWikiResultClicks(wrap) {
|
|
206
|
+
qsa(".result-wiki", wrap).forEach((el) => {
|
|
207
|
+
const handler = () => {
|
|
208
|
+
const idx = Number(el.dataset.wikiIdx);
|
|
209
|
+
const item = lastFtsItems[idx];
|
|
210
|
+
if (!item) return;
|
|
211
|
+
openWikiDrawer(item.slug, { title: item.title, body: item.body });
|
|
212
|
+
};
|
|
213
|
+
el.addEventListener("click", handler);
|
|
214
|
+
el.addEventListener("keydown", (e) => {
|
|
215
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
handler();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function bindReportResultClicks(wrap) {
|
|
224
|
+
qsa(".result-report", wrap).forEach((el) => {
|
|
225
|
+
const handler = () => {
|
|
226
|
+
const hash = el.dataset.hash;
|
|
227
|
+
if (!hash) return;
|
|
228
|
+
const rawPath = el.dataset.rawPath || null;
|
|
229
|
+
const mdPath = el.dataset.mdPath || null;
|
|
230
|
+
if (!rawPath && !mdPath) return; // hash만 있고 파일 없음 → 무반응
|
|
231
|
+
openFileViewer(hash, rawPath, mdPath);
|
|
232
|
+
};
|
|
233
|
+
el.addEventListener("click", handler);
|
|
234
|
+
el.addEventListener("keydown", (e) => {
|
|
235
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
handler();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function pickSnippet(item) {
|
|
244
|
+
return (item.summary || item.outline || item.contentPreview || "").trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderExternalCard(item, q) {
|
|
248
|
+
const srcKey = (item.source || "").toLowerCase();
|
|
249
|
+
const srcClass = srcKey === "prism" ? "prism"
|
|
250
|
+
: srcKey === "nanet" ? "nanet"
|
|
251
|
+
: srcKey === "dbpia" ? "dbpia"
|
|
252
|
+
: "";
|
|
253
|
+
const sourceBadge = item.source
|
|
254
|
+
? `<span class="chip sm ${srcClass}">${escapeHtml(item.source.toUpperCase())}</span>`
|
|
255
|
+
: "";
|
|
256
|
+
|
|
257
|
+
// 스니펫: 서버에서 toPlainText로 1차 정제되어 내려오지만,
|
|
258
|
+
// slice(0,800) 중간 자르기로 태그 파편이 남을 수 있어 방어적 2차 정제.
|
|
259
|
+
const snippetRaw = pickSnippet(item)
|
|
260
|
+
.replace(/<[^>]*>/g, " ")
|
|
261
|
+
.replace(/\s+/g, " ")
|
|
262
|
+
.trim();
|
|
263
|
+
const snippet = snippetRaw
|
|
264
|
+
? `<div class="result-snippet">${escapeHtml(snippetRaw.slice(0, 160))}${snippetRaw.length > 160 ? "…" : ""}</div>`
|
|
265
|
+
: "";
|
|
266
|
+
|
|
267
|
+
const authors = Array.isArray(item.authors) && item.authors.length
|
|
268
|
+
? item.authors.slice(0, 3).join(", ") + (item.authors.length > 3 ? " 외" : "")
|
|
269
|
+
: "";
|
|
270
|
+
const metaParts = [item.institution, item.year, authors].filter(Boolean);
|
|
271
|
+
const metaRow = metaParts.length
|
|
272
|
+
? `<div class="result-row"><span class="result-author">${escapeHtml(metaParts.join(" · "))}</span></div>`
|
|
273
|
+
: "";
|
|
274
|
+
|
|
275
|
+
const fileLine = item.fileName
|
|
276
|
+
? `<div class="result-filename">${escapeHtml(item.fileName)}</div>`
|
|
277
|
+
: "";
|
|
278
|
+
|
|
279
|
+
const downloadUnsupported = item.downloadSupported === false;
|
|
280
|
+
const externalUrl = typeof item.url === "string" && item.url.startsWith("http") ? item.url : "";
|
|
281
|
+
|
|
282
|
+
// Phase K-2: pending_items 기반 진행 상태 복원
|
|
283
|
+
const IN_PROGRESS_LABEL = {
|
|
284
|
+
waiting: '대기 중', queued: '큐 대기', downloading: '다운로드 중', converting: '변환 중',
|
|
285
|
+
extracting: '추출 중', graph: '그래프 갱신', wiki: '위키 작성',
|
|
286
|
+
};
|
|
287
|
+
const STAGE_RANK_INITIAL = {
|
|
288
|
+
waiting:0, queued:1, downloading:2, converting:3, extracting:4, graph:5, wiki:6, done:7, failed:7,
|
|
289
|
+
};
|
|
290
|
+
const pendingStatus = item.pendingStatus;
|
|
291
|
+
const inProgress = pendingStatus && IN_PROGRESS_LABEL[pendingStatus];
|
|
292
|
+
|
|
293
|
+
let collectArea;
|
|
294
|
+
if (item.collected) {
|
|
295
|
+
collectArea = `<button class="btn sm collected-done" disabled type="button">
|
|
296
|
+
<i class="fa-solid fa-check"></i><span>수집됨</span>
|
|
297
|
+
</button>`;
|
|
298
|
+
} else if (pendingStatus === 'failed') {
|
|
299
|
+
collectArea = `<button class="btn sm" data-collect-docid="${escapeHtml(item.docid)}" type="button"
|
|
300
|
+
title="${escapeHtml(item.failedReason || '이전 시도 실패 — 재시도 가능')}">
|
|
301
|
+
<i class="fa-solid fa-rotate-right"></i><span>재시도</span>
|
|
302
|
+
</button>`;
|
|
303
|
+
} else if (inProgress) {
|
|
304
|
+
collectArea = `<button class="btn sm in-progress" disabled type="button" data-stage="${pendingStatus}">
|
|
305
|
+
<i class="fa-solid fa-spinner fa-spin"></i><span>${IN_PROGRESS_LABEL[pendingStatus]}</span>
|
|
306
|
+
</button>`;
|
|
307
|
+
} else if (downloadUnsupported) {
|
|
308
|
+
collectArea = externalUrl
|
|
309
|
+
? `<a class="btn sm ghost" href="${escapeHtml(externalUrl)}" target="_blank" rel="noopener noreferrer"
|
|
310
|
+
title="이 소스는 서지정보 검색 전용입니다. 원문은 외부 링크에서 확인하세요.">
|
|
311
|
+
<i class="fa-solid fa-up-right-from-square"></i><span>원문 열기</span>
|
|
312
|
+
</a>`
|
|
313
|
+
: `<button class="btn sm" disabled type="button"
|
|
314
|
+
title="이 소스는 서지정보 검색 전용입니다.">
|
|
315
|
+
<i class="fa-solid fa-circle-info"></i><span>서지정보 전용</span>
|
|
316
|
+
</button>`;
|
|
317
|
+
} else {
|
|
318
|
+
collectArea = `<button class="btn sm primary" data-collect-docid="${escapeHtml(item.docid)}" type="button">
|
|
319
|
+
<i class="fa-solid fa-cloud-arrow-down"></i><span>수집</span>
|
|
320
|
+
</button>`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const rootClass = item.collected
|
|
324
|
+
? "result result-external is-collected"
|
|
325
|
+
: (downloadUnsupported ? "result result-external is-readonly"
|
|
326
|
+
: (inProgress ? "result result-external is-in-progress"
|
|
327
|
+
: "result result-external"));
|
|
328
|
+
const checkDisabled = (item.collected || downloadUnsupported || inProgress) ? "disabled" : "";
|
|
329
|
+
const initialRank = inProgress ? STAGE_RANK_INITIAL[pendingStatus] : -1;
|
|
330
|
+
|
|
331
|
+
return `<div class="${rootClass}" data-docid="${escapeHtml(item.docid)}" data-stage-rank="${initialRank}">
|
|
332
|
+
<input type="checkbox"
|
|
333
|
+
class="ext-item-check result-check"
|
|
334
|
+
data-docid="${escapeHtml(item.docid)}"
|
|
335
|
+
${checkDisabled}
|
|
336
|
+
aria-label="선택" />
|
|
337
|
+
<div class="result-body">
|
|
338
|
+
<div class="result-title">${highlightKeyword(item.title || "제목 없음", q)}</div>
|
|
339
|
+
${snippet}
|
|
340
|
+
${metaRow}
|
|
341
|
+
${fileLine}
|
|
342
|
+
</div>
|
|
343
|
+
<div class="result-score">
|
|
344
|
+
${sourceBadge}
|
|
345
|
+
${collectArea}
|
|
346
|
+
</div>
|
|
347
|
+
</div>`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function searchReports() {
|
|
351
|
+
const q = qs("#search-q")?.value.trim();
|
|
352
|
+
if (!q) return;
|
|
353
|
+
const selected = getSelectedSources("#search-source-chips", INTERNAL_SOURCES);
|
|
354
|
+
const source = selected.join(",");
|
|
355
|
+
const fts = qs("#search-fts")?.checked ? "1" : "0";
|
|
356
|
+
const params = new URLSearchParams({ q, source, fts, limit: "50" });
|
|
357
|
+
const wrap = qs("#search-results");
|
|
358
|
+
const header = qs("#search-results-header");
|
|
359
|
+
if (header) header.hidden = true;
|
|
360
|
+
if (wrap) wrap.innerHTML = '<div class="loading-state"><div class="loading" style="margin:0 auto;"></div></div>';
|
|
361
|
+
|
|
362
|
+
const startedAt = Date.now();
|
|
363
|
+
try {
|
|
364
|
+
const data = await getJson(`/api/search?${params}`);
|
|
365
|
+
const items = data.items || [];
|
|
366
|
+
lastFtsItems = fts === "1" ? items : [];
|
|
367
|
+
if (wrap) wrap.innerHTML = renderInternalResults(items, q, fts);
|
|
368
|
+
if (header) {
|
|
369
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(2);
|
|
370
|
+
const countEl = qs("#search-results-count");
|
|
371
|
+
if (countEl) countEl.textContent = `${items.length}건 · ${elapsed}초`;
|
|
372
|
+
header.hidden = false;
|
|
373
|
+
}
|
|
374
|
+
if (wrap) {
|
|
375
|
+
if (fts === "1") bindWikiResultClicks(wrap);
|
|
376
|
+
else bindReportResultClicks(wrap);
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
if (wrap) wrap.innerHTML = '<div class="empty-state">검색 실패</div>';
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function bindExternalActions() {
|
|
384
|
+
const collectToolbar = qs("#ext-collect-toolbar");
|
|
385
|
+
const selectedCount = qs("#ext-selected-count");
|
|
386
|
+
const checks = qsa(".ext-item-check");
|
|
387
|
+
|
|
388
|
+
function syncSelectedCount() {
|
|
389
|
+
const checked = qsa(".ext-item-check:checked").length;
|
|
390
|
+
if (selectedCount) selectedCount.textContent = `${checked}개 선택됨`;
|
|
391
|
+
if (collectToolbar) collectToolbar.hidden = checks.length === 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
qsa("[data-collect-docid]").forEach((button) => {
|
|
395
|
+
button.addEventListener("click", async (e) => {
|
|
396
|
+
e.stopPropagation();
|
|
397
|
+
const docid = button.dataset.collectDocid;
|
|
398
|
+
const item = extItems[docid];
|
|
399
|
+
if (!item) {
|
|
400
|
+
console.warn("[collect] item not found for docid:", docid, "keys:", Object.keys(extItems));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
button.disabled = true;
|
|
404
|
+
const originalHTML = button.innerHTML;
|
|
405
|
+
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i><span>수집 중</span>';
|
|
406
|
+
try {
|
|
407
|
+
await postJson("/api/search/external/collect", { items: [item] });
|
|
408
|
+
// 응답은 202 즉시반환 — 실제 완료는 SSE collect:* 이벤트로 갱신
|
|
409
|
+
} catch (err) {
|
|
410
|
+
console.error("[collect] 요청 실패:", err);
|
|
411
|
+
button.disabled = false;
|
|
412
|
+
button.innerHTML = originalHTML;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
syncSelectedCount();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
checks.forEach((check) => {
|
|
420
|
+
check.addEventListener("change", syncSelectedCount);
|
|
421
|
+
check.addEventListener("click", (e) => e.stopPropagation());
|
|
422
|
+
});
|
|
423
|
+
qs("#ext-select-all")?.addEventListener("change", (event) => {
|
|
424
|
+
checks.forEach((check) => {
|
|
425
|
+
if (check.disabled) return; // 수집됨 카드 스킵
|
|
426
|
+
check.checked = event.currentTarget.checked;
|
|
427
|
+
});
|
|
428
|
+
syncSelectedCount();
|
|
429
|
+
});
|
|
430
|
+
syncSelectedCount();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* SSE 이벤트로 외부 검색 카드 상태 갱신
|
|
435
|
+
* - collect:download / collect:convert → 스피너 + 단계 라벨
|
|
436
|
+
* - session:skip (already_indexed) → 수집됨 상태
|
|
437
|
+
* - collect:error → 실패 아이콘, 재시도 가능 상태
|
|
438
|
+
*/
|
|
439
|
+
const STAGE_RANK = { download:2, convert:3, extract:4, graph:5, wiki:6, done:7, error:7 };
|
|
440
|
+
const STATE_TO_MARKUP = {
|
|
441
|
+
download: { label:'다운로드 중', icon:'fa-spinner fa-spin', disabled:true },
|
|
442
|
+
convert: { label:'변환 중', icon:'fa-spinner fa-spin', disabled:true },
|
|
443
|
+
extract: { label:'추출 중', icon:'fa-spinner fa-spin', disabled:true },
|
|
444
|
+
graph: { label:'그래프 갱신', icon:'fa-spinner fa-spin', disabled:true },
|
|
445
|
+
wiki: { label:'위키 작성', icon:'fa-spinner fa-spin', disabled:true },
|
|
446
|
+
error: { label:'실패 · 재시도', icon:'fa-triangle-exclamation', disabled:false },
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
function updateExternalCardByDocid(docid, state) {
|
|
450
|
+
const card = document.querySelector(`.result-external[data-docid="${CSS.escape(docid)}"]`);
|
|
451
|
+
if (!card) return;
|
|
452
|
+
// 단조 규칙: 과거 단계 이벤트 무시 (SSE 순서 역전 방어)
|
|
453
|
+
const prevRank = parseInt(card.dataset.stageRank || '-1', 10);
|
|
454
|
+
const nextRank = STAGE_RANK[state] ?? -1;
|
|
455
|
+
if (state !== 'done' && state !== 'error' && nextRank <= prevRank) return;
|
|
456
|
+
card.dataset.stageRank = String(nextRank);
|
|
457
|
+
|
|
458
|
+
const btn = card.querySelector("[data-collect-docid], .btn.collected-done, .btn.in-progress, .btn");
|
|
459
|
+
if (!btn) return;
|
|
460
|
+
|
|
461
|
+
if (state === "done") {
|
|
462
|
+
card.classList.add("is-collected");
|
|
463
|
+
card.classList.remove("is-in-progress");
|
|
464
|
+
btn.outerHTML = '<button class="btn sm collected-done" disabled type="button"><i class="fa-solid fa-check"></i><span>수집됨</span></button>';
|
|
465
|
+
const check = card.querySelector(".ext-item-check");
|
|
466
|
+
if (check) { check.disabled = true; check.checked = false; }
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const cfg = STATE_TO_MARKUP[state];
|
|
470
|
+
if (!cfg) return;
|
|
471
|
+
btn.disabled = cfg.disabled;
|
|
472
|
+
btn.className = `btn sm ${state === 'error' ? '' : 'in-progress'}`;
|
|
473
|
+
btn.innerHTML = `<i class="fa-solid ${cfg.icon}"></i><span>${cfg.label}</span>`;
|
|
474
|
+
if (state !== 'error') card.classList.add("is-in-progress");
|
|
475
|
+
const check = card.querySelector(".ext-item-check");
|
|
476
|
+
if (check && cfg.disabled) { check.disabled = true; check.checked = false; }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let _sseSubscribed = false;
|
|
480
|
+
function ensureCollectSseSubscribed() {
|
|
481
|
+
if (_sseSubscribed) return;
|
|
482
|
+
_sseSubscribed = true;
|
|
483
|
+
subscribe("collect:download", (d) => d?.docid && updateExternalCardByDocid(d.docid, "download"));
|
|
484
|
+
subscribe("collect:convert", (d) => d?.docid && updateExternalCardByDocid(d.docid, "convert"));
|
|
485
|
+
subscribe("collect:error", (d) => {
|
|
486
|
+
if (!d?.docid) return;
|
|
487
|
+
// reason 별 UI 분기 (Phase 0 P0-6)
|
|
488
|
+
const REASON_UI = {
|
|
489
|
+
download_unsupported: { label: "서지정보 전용", retryable: false, showExternal: true },
|
|
490
|
+
license_out_of_scope: { label: "구독 범위 밖", retryable: false, showExternal: true },
|
|
491
|
+
external_link: { label: "외부 연계자료", retryable: false, showExternal: true },
|
|
492
|
+
thesis_unsupported: { label: "학위논문 미지원", retryable: false, showExternal: true },
|
|
493
|
+
publisher_disallow: { label: "출판사 비허용", retryable: false, showExternal: true },
|
|
494
|
+
link_missing: { label: "원문 링크 없음", retryable: false, showExternal: true },
|
|
495
|
+
session_expired: { label: "재로그인 필요", retryable: true, showExternal: false },
|
|
496
|
+
unknown_error: { label: "실패 · 재시도", retryable: true, showExternal: false },
|
|
497
|
+
};
|
|
498
|
+
const ui = REASON_UI[d.reason] || REASON_UI.unknown_error;
|
|
499
|
+
|
|
500
|
+
if (!ui.retryable) {
|
|
501
|
+
const card = document.querySelector(`.result-external[data-docid="${CSS.escape(d.docid)}"]`);
|
|
502
|
+
const btn = card?.querySelector("[data-collect-docid], .btn");
|
|
503
|
+
if (btn) {
|
|
504
|
+
btn.outerHTML = ui.showExternal && d.url
|
|
505
|
+
? `<a class="btn sm ghost" href="${d.url}" target="_blank" rel="noopener noreferrer" title="${ui.label}"><i class="fa-solid fa-up-right-from-square"></i><span>${ui.label}</span></a>`
|
|
506
|
+
: `<button class="btn sm" disabled type="button" title="${ui.label}"><i class="fa-solid fa-circle-info"></i><span>${ui.label}</span></button>`;
|
|
507
|
+
}
|
|
508
|
+
// 체크박스도 비활성화
|
|
509
|
+
const check = card?.querySelector(".ext-item-check");
|
|
510
|
+
if (check) { check.disabled = true; check.checked = false; }
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
updateExternalCardByDocid(d.docid, "error");
|
|
514
|
+
});
|
|
515
|
+
subscribe("session:skip", (d) => d?.docid && updateExternalCardByDocid(d.docid, "done"));
|
|
516
|
+
// 파이프라인 최종 완료 시그널 (wiki 또는 ingest 완료)
|
|
517
|
+
subscribe("wiki:updated", (d) => d?.docid && updateExternalCardByDocid(d.docid, "done"));
|
|
518
|
+
// Phase K-2: extract/graph/wiki 단계 전환 이벤트 반영
|
|
519
|
+
subscribe("item_stage", (d) => {
|
|
520
|
+
if (!d?.docid) return;
|
|
521
|
+
const MAP = {
|
|
522
|
+
extracting: 'extract',
|
|
523
|
+
graph: 'graph',
|
|
524
|
+
wiki: 'wiki',
|
|
525
|
+
done: 'done',
|
|
526
|
+
failed: 'error',
|
|
527
|
+
};
|
|
528
|
+
const state = MAP[d.stage];
|
|
529
|
+
if (state) updateExternalCardByDocid(d.docid, state);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function searchExternal() {
|
|
534
|
+
const q = qs("#ext-search-q")?.value.trim();
|
|
535
|
+
if (!q) return;
|
|
536
|
+
const sources = getSelectedSources("#ext-source-chips", EXTERNAL_SOURCES);
|
|
537
|
+
const wrap = qs("#ext-search-results");
|
|
538
|
+
const header = qs("#ext-search-results-header");
|
|
539
|
+
const loading = qs("#search-loading");
|
|
540
|
+
const collectToolbar = qs("#ext-collect-toolbar");
|
|
541
|
+
extItems = {};
|
|
542
|
+
if (header) header.hidden = true;
|
|
543
|
+
if (collectToolbar) collectToolbar.hidden = true;
|
|
544
|
+
if (loading) loading.hidden = false;
|
|
545
|
+
if (wrap) wrap.innerHTML = '<div class="loading-state"><div class="loading" style="margin:0 auto;"></div></div>';
|
|
546
|
+
|
|
547
|
+
const startedAt = Date.now();
|
|
548
|
+
try {
|
|
549
|
+
// strict 토글: 기본 true (확인된 것만). localStorage 저장.
|
|
550
|
+
const includeUnknown = localStorage.getItem("urban.dbpia.includeUnknown") === "1";
|
|
551
|
+
const strict = includeUnknown ? "0" : "1";
|
|
552
|
+
|
|
553
|
+
const params = new URLSearchParams({ q, sources: sources.join(","), limit: "20", strict });
|
|
554
|
+
const data = await getJson(`/api/search/external?${params}`);
|
|
555
|
+
|
|
556
|
+
// warmupMode: cache row < 50 → 토글 무효화 + 배너 노출 + 강제 unknown 포함
|
|
557
|
+
const warmupMode = !!data.warmupMode;
|
|
558
|
+
const banner = qs("#ext-warmup-banner");
|
|
559
|
+
const toggle = qs("#ext-include-unknown");
|
|
560
|
+
if (banner) banner.hidden = !warmupMode;
|
|
561
|
+
if (toggle) toggle.disabled = warmupMode;
|
|
562
|
+
if (warmupMode && toggle) toggle.checked = true; // 시각적으로 "미확인 포함"
|
|
563
|
+
|
|
564
|
+
let total = 0;
|
|
565
|
+
const html = Object.values(data.sources || {}).map((sourceData) => {
|
|
566
|
+
total += sourceData.count || 0;
|
|
567
|
+
const cards = (sourceData.items || []).map((item) => {
|
|
568
|
+
extItems[item.docid] = item;
|
|
569
|
+
return renderExternalCard(item, q);
|
|
570
|
+
}).join("") || '<div class="empty-state">결과 없음</div>';
|
|
571
|
+
return `<section class="search-section-block">
|
|
572
|
+
<div class="search-section-head">
|
|
573
|
+
<div class="search-section-kicker">External</div>
|
|
574
|
+
<h2 class="search-section-title">${escapeHtml((sourceData.sourceId || "").toUpperCase())}</h2>
|
|
575
|
+
</div>
|
|
576
|
+
<div class="search-results-stack">${cards}</div>
|
|
577
|
+
</section>`;
|
|
578
|
+
}).join("");
|
|
579
|
+
|
|
580
|
+
if (wrap) wrap.innerHTML = html || '<div class="empty-state">결과 없음</div>';
|
|
581
|
+
if (header) {
|
|
582
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(2);
|
|
583
|
+
const countEl = qs("#ext-search-results-count");
|
|
584
|
+
if (countEl) countEl.textContent = `${total}건 · ${elapsed}초`;
|
|
585
|
+
header.hidden = false;
|
|
586
|
+
}
|
|
587
|
+
bindExternalActions();
|
|
588
|
+
} catch {
|
|
589
|
+
if (wrap) wrap.innerHTML = '<div class="empty-state">외부 검색 실패</div>';
|
|
590
|
+
} finally {
|
|
591
|
+
if (loading) loading.hidden = true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export async function mount(_root, params) {
|
|
596
|
+
switchTab("external");
|
|
597
|
+
renderChipGroup("#search-source-chips", INTERNAL_SOURCES);
|
|
598
|
+
renderChipGroup("#ext-source-chips", EXTERNAL_SOURCES);
|
|
599
|
+
|
|
600
|
+
// 외부 수집 상태 실시간 갱신 — SSE 구독 (첫 mount에서 1회만)
|
|
601
|
+
ensureCollectSseSubscribed();
|
|
602
|
+
|
|
603
|
+
// 전역 drawer 닫기 버튼/오버레이 — 중복 바인딩 방지
|
|
604
|
+
const closeBtn = qs("#drawer-close-btn");
|
|
605
|
+
if (closeBtn && !closeBtn.dataset.boundSearch) {
|
|
606
|
+
closeBtn.addEventListener("click", () => closeDrawer("report-drawer"));
|
|
607
|
+
closeBtn.dataset.boundSearch = "1";
|
|
608
|
+
}
|
|
609
|
+
const overlay = qs("#drawer-overlay");
|
|
610
|
+
if (overlay && !overlay.dataset.boundSearch) {
|
|
611
|
+
overlay.addEventListener("click", () => closeDrawer("report-drawer"));
|
|
612
|
+
overlay.dataset.boundSearch = "1";
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
qsa("[data-tab]").forEach((button) => button.addEventListener("click", () => switchTab(button.dataset.tab)));
|
|
616
|
+
qsa("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
|
617
|
+
qsa("[data-sort]").forEach((b) => b.classList.remove("active"));
|
|
618
|
+
button.classList.add("active");
|
|
619
|
+
}));
|
|
620
|
+
|
|
621
|
+
qs("#search-submit")?.addEventListener("click", searchReports);
|
|
622
|
+
qs("#ext-search-submit")?.addEventListener("click", searchExternal);
|
|
623
|
+
qs("#ext-collect-submit")?.addEventListener("click", async () => {
|
|
624
|
+
const selected = qsa(".ext-item-check:checked").map((el) => extItems[el.dataset.docid]).filter(Boolean);
|
|
625
|
+
if (selected.length) await postJson("/api/search/external/collect", { items: selected });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
qs("#search-q")?.addEventListener("keydown", (e) => { if (e.key === "Enter") searchReports(); });
|
|
629
|
+
qs("#ext-search-q")?.addEventListener("keydown", (e) => { if (e.key === "Enter") searchExternal(); });
|
|
630
|
+
|
|
631
|
+
// 미확인 자료 포함 토글 — 초기값 복원 + 변경 시 재검색
|
|
632
|
+
const includeUnknownToggle = qs("#ext-include-unknown");
|
|
633
|
+
if (includeUnknownToggle) {
|
|
634
|
+
includeUnknownToggle.checked = localStorage.getItem("urban.dbpia.includeUnknown") === "1";
|
|
635
|
+
includeUnknownToggle.addEventListener("change", (e) => {
|
|
636
|
+
if (e.currentTarget.disabled) return; // warmupMode 중에는 토글 무효
|
|
637
|
+
localStorage.setItem("urban.dbpia.includeUnknown", e.currentTarget.checked ? "1" : "0");
|
|
638
|
+
if (qs("#ext-search-q")?.value.trim()) searchExternal();
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const initialQ = params.get("q");
|
|
643
|
+
if (initialQ && qs("#search-q")) {
|
|
644
|
+
qs("#search-q").value = initialQ;
|
|
645
|
+
searchReports();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export async function unmount() {
|
|
650
|
+
extItems = {};
|
|
651
|
+
lastFtsItems = [];
|
|
652
|
+
closeDrawer("report-drawer");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export default { mount, unmount };
|