단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며 어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본 app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B). 클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧 계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록 2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라 순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정: - test_index_loads_every_app_part_exactly_once_in_declared_order 각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가 전부 배선됐는지 + 구 app.js 가 남아있지 않은지 - test_app_parts_stay_within_the_file_size_budget (800줄 상한) 정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는 '어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드 순서대로 이어붙여 읽는 _read_app() 으로 바꿨다. 검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함). 운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0건.
323 lines
6 KiB
JavaScript
323 lines
6 KiB
JavaScript
// Part 13 of 15 of the operator console app script.
|
|
//
|
|
// Collection candidate search, dismissal, selection, and promotion.
|
|
//
|
|
// Load order is significant: these are classic (non-module) scripts sharing one
|
|
// global scope, and index.html must keep them in the order listed above/below.
|
|
// Splitting is a pure line-range extraction of the former single app.js.
|
|
|
|
function clearCollectionCandidatesForSearch() {
|
|
|
|
collectionCandidates.splice(0, collectionCandidates.length);
|
|
|
|
renderCollectionCandidates();
|
|
|
|
}
|
|
|
|
|
|
|
|
function clearCollectionCandidatesAfterAction() {
|
|
|
|
collectionCandidates.splice(0, collectionCandidates.length);
|
|
|
|
state.currentCollectionQuery = "";
|
|
|
|
state.currentCollectionProvider = "";
|
|
|
|
renderCollectionCandidates();
|
|
|
|
}
|
|
|
|
|
|
|
|
async function dismissCollectionCandidate(candidateId) {
|
|
|
|
const status = document.getElementById("collection-status");
|
|
|
|
if (status) status.textContent = "후보를 무시하는 중입니다.";
|
|
|
|
try {
|
|
|
|
const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/dismiss`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
if (status) status.textContent = "후보를 무시했습니다.";
|
|
|
|
} catch (errorValue) {
|
|
|
|
if (status) status.textContent = errorValue.message;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function runCandidateCollection(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
const provider = document.getElementById("collection-provider").value;
|
|
|
|
const queryInput = document.getElementById("collection-query");
|
|
|
|
const status = document.getElementById("collection-status");
|
|
|
|
const submitButton = event.target.querySelector('button[type="submit"]');
|
|
|
|
const query = queryInput.value.trim();
|
|
|
|
|
|
|
|
if (!query) {
|
|
|
|
state.currentCollectionQuery = "";
|
|
|
|
state.currentCollectionProvider = "";
|
|
|
|
renderCollectionCandidates();
|
|
|
|
status.textContent = "수집할 키워드를 입력하세요.";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const providerConfig = providers.find((item) => item.id === provider);
|
|
|
|
if (!providerConfig || !providerConfig.enabled) {
|
|
|
|
status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
submitButton.disabled = true;
|
|
|
|
status.textContent = "키워드 후보를 수집하는 중입니다.";
|
|
|
|
try {
|
|
|
|
const payload = await apiJson("/api/collections/keyword", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({ provider, query }),
|
|
|
|
});
|
|
|
|
// Commit the new query/provider and swap in the new candidates only after the
|
|
// request succeeds, so a failed collection doesn't wipe the previous view.
|
|
state.currentCollectionQuery = query;
|
|
|
|
state.currentCollectionProvider = provider;
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
status.textContent = `${payload.collected || 0}개후보를 수집했습니다.`;
|
|
|
|
} catch (errorValue) {
|
|
|
|
status.textContent = errorValue.message;
|
|
|
|
} finally {
|
|
|
|
submitButton.disabled = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectableCollectionCandidateInputs() {
|
|
|
|
return Array.from(document.querySelectorAll("input[data-collection-candidate-id]:not(:disabled)"));
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectedCollectionCandidateIds() {
|
|
|
|
return selectableCollectionCandidateInputs()
|
|
|
|
.filter((input) => input.checked)
|
|
|
|
.map((input) => input.dataset.collectionCandidateId);
|
|
|
|
}
|
|
|
|
|
|
|
|
function setAllCollectionCandidateSelection(selected) {
|
|
|
|
const inputs = selectableCollectionCandidateInputs();
|
|
|
|
const status = document.getElementById("collection-status");
|
|
|
|
|
|
|
|
inputs.forEach((input) => {
|
|
|
|
input.checked = selected;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!inputs.length) {
|
|
|
|
status.textContent = "선택된 후보가 없습니다.";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
status.textContent = selected ? `${inputs.length}개 후보를 선택했습니다.` : "후보 선택을 모두 해제했습니다.";
|
|
|
|
}
|
|
|
|
|
|
|
|
function commaSeparatedValues(elementId) {
|
|
|
|
return document
|
|
|
|
.getElementById(elementId)
|
|
|
|
.value.split(",")
|
|
|
|
.map((item) => item.trim())
|
|
|
|
.filter(Boolean);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function promoteSelectedCollectionCandidates(event) {
|
|
|
|
event.preventDefault();
|
|
|
|
const candidateIds = selectedCollectionCandidateIds();
|
|
|
|
const status = document.getElementById("collection-status");
|
|
|
|
const submitButton = document.getElementById("promote-selected-candidates");
|
|
|
|
|
|
|
|
if (!candidateIds.length) {
|
|
|
|
status.textContent = "반영할 후보를 선택하세요.";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
submitButton.disabled = true;
|
|
|
|
status.textContent = "선택한 후보를 기준 DB에 반영하는 중입니다.";
|
|
|
|
try {
|
|
|
|
const payload = await apiJson("/api/collections/candidates/promote-batch", {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({
|
|
|
|
candidate_ids: candidateIds,
|
|
|
|
name: document.getElementById("collection-promotion-name").value.trim(),
|
|
|
|
type: document.getElementById("collection-promotion-type").value,
|
|
|
|
aliases: commaSeparatedValues("collection-promotion-aliases"),
|
|
|
|
keywords: commaSeparatedValues("collection-promotion-keywords"),
|
|
|
|
memo: document.getElementById("collection-promotion-memo").value.trim(),
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
clearCollectionCandidatesAfterAction();
|
|
|
|
event.target.reset();
|
|
|
|
renderAll();
|
|
|
|
status.textContent = `${candidateIds.length}개후보를 기준 DB에 반영했습니다.`;
|
|
|
|
} catch (errorValue) {
|
|
|
|
status.textContent = errorValue.message;
|
|
|
|
} finally {
|
|
|
|
submitButton.disabled = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function promoteCollectionCandidate(candidateId) {
|
|
|
|
const status = document.getElementById("collection-status");
|
|
|
|
status.textContent = "후보를 기준 DB에 반영하는 중입니다.";
|
|
|
|
try {
|
|
|
|
const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/promote`, {
|
|
|
|
method: "POST",
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
});
|
|
|
|
applyBootstrap(payload);
|
|
|
|
clearCollectionCandidatesAfterAction();
|
|
|
|
renderAll();
|
|
|
|
status.textContent = "후보를 기준 DB에 반영했습니다.";
|
|
|
|
} catch (errorValue) {
|
|
|
|
status.textContent = errorValue.message;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|