POSA_Copyrighter/web/operator-gui/app-collections.js
changukyu fc4e04f65c refactor: app.js 5,428줄을 관심사별 app-*.js 15개로 분할
단일 파일이 프로젝트 상한(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건.
2026-08-08 09:48:52 +09:00

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;
}
}