POSA_Copyrighter/web/operator-gui/app-bootstrap.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

558 lines
14 KiB
JavaScript

// Part 15 of 15 of the operator console app script.
//
// Event binding, init, and the DOM listeners that start the app.
//
// 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 bindEvents() {
document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => switchView(button.dataset.view));
});
document.querySelectorAll("#risk-filter button").forEach((button) => {
button.addEventListener("click", () => {
state.filters.risk = button.dataset.risk;
document.querySelectorAll("#risk-filter button").forEach((item) => item.classList.toggle("active", item === button));
renderQueue();
});
});
document.getElementById("source-filter").addEventListener("change", (event) => {
state.filters.source = event.target.value;
renderCoverageTabs();
renderQueue();
});
document.getElementById("decision-filter").addEventListener("change", (event) => {
state.filters.decision = event.target.value;
renderQueue();
});
document.getElementById("queue-sort").addEventListener("change", (event) => {
state.filters.sort = event.target.value;
renderQueue();
});
document.getElementById("queue-search").addEventListener("input", (event) => {
state.filters.query = event.target.value;
renderQueue();
});
document.getElementById("queue-body").addEventListener("click", (event) => {
const duplicateButton = event.target.closest("[data-duplicate-group]");
if (duplicateButton) {
selectDuplicateGroup(duplicateButton.dataset.duplicateGroup);
return;
}
const selectButton = event.target.closest("[data-select-case]");
if (selectButton) {
selectCase(selectButton.dataset.selectCase);
return;
}
const row = event.target.closest("[data-row-case]");
if (row && !event.target.matches("input")) {
selectCase(row.dataset.rowCase);
}
});
// Keep the batch-decision counter in step with the row checkboxes.
document.getElementById("queue-body").addEventListener("change", (event) => {
if (event.target.matches("input[data-bulk-id]")) {
renderBulkDecisionCount();
}
});
document.getElementById("coverage-tabs").addEventListener("click", (event) => {
const tab = event.target.closest("[data-coverage-filter]");
if (!tab) return;
applyCoverageFilter(tab.dataset.coverageFilter);
});
document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
button.addEventListener("click", () => switchWorkbenchTab(button.dataset.workbenchTab));
});
document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
button.addEventListener("click", () => switchKnowledgeTab(button.dataset.knowledgeTab));
});
document.querySelectorAll("[data-decision]").forEach((button) => {
button.addEventListener("click", () => {
const requiresMemo = button.dataset.requiresMemo === "true";
setDecision(button.dataset.decision, requiresMemo);
});
});
document.getElementById("manual-query-form").addEventListener("submit", runManualSearch);
document.getElementById("candidate-collection-form").addEventListener("submit", runCandidateCollection);
document.getElementById("collection-promotion-form").addEventListener("submit", promoteSelectedCollectionCandidates);
document.getElementById("select-all-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(true));
document.getElementById("clear-selected-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(false));
document.getElementById("knowledge-form").addEventListener("submit", addKnowledgeEntry);
document.getElementById("knowledge-search").addEventListener("input", (event) => {
knowledgeFilters.query = event.target.value;
renderKnowledgeBase();
});
[
["knowledge-type-filter", "type"],
["knowledge-status-filter", "status"],
["knowledge-active-filter", "active"],
].forEach(([elementId, key]) => {
document.getElementById(elementId).addEventListener("change", (event) => {
knowledgeFilters[key] = event.target.value;
renderKnowledgeBase();
});
});
document.addEventListener("submit", (event) => {
const editFormElement = event.target.closest("[data-knowledge-edit-form]");
if (!editFormElement) return;
event.preventDefault();
void saveKnowledgeEntryEdit(editFormElement);
});
document.getElementById("knowledge-image").addEventListener("change", updateKnowledgeImageName);
document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions);
document.getElementById("submission-image").addEventListener("change", updateSubmissionImageName);
document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
document.getElementById("disable-derived").addEventListener("click", () => {
// Scope the deactivation to the currently-selected case. Picking the first
// automatic entry globally could deactivate an entry that belongs to a
// different submission entirely.
const selected = getSelectedCase();
const automaticEntry = selected
? knowledgeEntries.find(
(entry) =>
entry.provenance === "automatic" &&
entry.active &&
(entry.entryStatus || "confirmed") === "confirmed" &&
entry.sourceSubmissionId === selected.id,
)
: undefined;
if (automaticEntry) {
void deactivateKnowledgeEntry(automaticEntry.id);
} else {
showApiError("이 케이스에서 비활성화할 자동 지식 항목이 없습니다.");
}
});
// Image viewer controls. Without handlers these were dead buttons; they now
// adjust the case image (fit / actual size / rotate) with state on the element
// so renderCaseReview can reset them per case.
document.getElementById("fit-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (image) image.style.objectFit = "contain";
});
document.getElementById("actual-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (image) image.style.objectFit = "none";
});
document.getElementById("rotate-image").addEventListener("click", () => {
const image = document.getElementById("case-image");
if (!image) return;
const next = ((Number(image.dataset.rotation || "0") + 90) % 360 + 360) % 360;
image.dataset.rotation = String(next);
image.style.transform = `rotate(${next}deg)`;
});
document.getElementById("open-correction").addEventListener("click", () => {
switchView("knowledge");
switchKnowledgeTab("corrections");
});
document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment);
document.querySelectorAll("[data-bulk-decision]").forEach((button) => {
button.addEventListener("click", () => applyBulkDecision(button.dataset.bulkDecision));
});
document.getElementById("export-decisions").addEventListener("click", exportDecisions);
document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
document.getElementById("clear-reference").addEventListener("click", registerClearedReference);
document.getElementById("llm-draft").addEventListener("click", (event) => {
if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo();
});
document.getElementById("memo-presets").addEventListener("click", (event) => {
if (event.target.closest("#memo-preset-save")) {
void saveCurrentMemoAsPreset();
return;
}
const chip = event.target.closest("[data-preset-id]");
if (chip) applyMemoPreset(chip.dataset.presetId);
});
document.addEventListener("keydown", handleReviewShortcut);
document.getElementById("emergency-disable").addEventListener("click", emergencyDisableExternalProviders);
document.getElementById("app-main").addEventListener("click", (event) => {
const providerButton = event.target.closest("[data-toggle-provider]");
if (providerButton) {
toggleProvider(providerButton.dataset.toggleProvider);
return;
}
const retryButton = event.target.closest("[data-provider-retry]");
if (retryButton) {
// No provider-wide retry endpoint exists. Tell the operator honestly
// instead of writing a fake "Provider called" audit event that never
// re-ran anything (which falsely implied the failures were retried).
showApiError("제공자 단위 '실패 재시도'는 아직 서버에 구현되지 않았습니다. 개별 케이스의 '재분석'을 사용하세요.");
return;
}
const correctionButton = event.target.closest("[data-deactivate-correction]");
if (correctionButton) {
void deactivateCorrectionEntry(correctionButton.dataset.deactivateCorrection);
return;
}
const editKnowledgeButton = event.target.closest("[data-edit-kb]");
if (editKnowledgeButton) {
editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb;
renderKnowledgeBase();
return;
}
const cancelEditKnowledgeButton = event.target.closest("[data-cancel-edit-kb]");
if (cancelEditKnowledgeButton) {
editingKnowledgeEntryId = "";
renderKnowledgeBase();
return;
}
const deactivateKnowledgeButton = event.target.closest("[data-deactivate-kb]");
if (deactivateKnowledgeButton) {
void deactivateKnowledgeEntry(deactivateKnowledgeButton.dataset.deactivateKb);
return;
}
const reactivateKnowledgeButton = event.target.closest("[data-reactivate-kb]");
if (reactivateKnowledgeButton) {
void reactivateKnowledgeEntry(reactivateKnowledgeButton.dataset.reactivateKb);
return;
}
const evidenceStatusButton = event.target.closest("[data-evidence-status]");
if (evidenceStatusButton) {
setEvidenceStatus(evidenceStatusButton.dataset.evidenceId, evidenceStatusButton.dataset.evidenceStatus);
return;
}
const suggestedQueryButton = event.target.closest("[data-suggested-query]");
if (suggestedQueryButton) {
applySuggestedQuery(suggestedQueryButton);
return;
}
const promoteWatchlistButton = event.target.closest("[data-promote-watchlist]");
if (promoteWatchlistButton) {
promoteWatchlistEntry(promoteWatchlistButton.dataset.promoteWatchlist);
return;
}
const excludeWatchlistButton = event.target.closest("[data-exclude-watchlist]");
if (excludeWatchlistButton) {
excludeWatchlistEntry(excludeWatchlistButton.dataset.excludeWatchlist);
return;
}
const promoteCandidateButton = event.target.closest("[data-promote-candidate]");
if (promoteCandidateButton) {
promoteCollectionCandidate(promoteCandidateButton.dataset.promoteCandidate);
return;
}
const dismissCandidateButton = event.target.closest("[data-dismiss-candidate]");
if (dismissCandidateButton) {
void dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
return;
}
const runSuggestedButton = event.target.closest("[data-run-suggested-query]");
if (runSuggestedButton) {
void executeSuggestedQueries([runSuggestedButton.dataset.runSuggestedQuery]);
return;
}
const runAllSuggestedButton = event.target.closest("#run-all-suggested-queries");
if (runAllSuggestedButton) {
void executeSuggestedQueries(suggestedEvidenceQueries(getSelectedCase()));
return;
}
const rerunQueryButton = event.target.closest("[data-rerun-query]");
if (rerunQueryButton) {
rerunHistoricalQuery(rerunQueryButton);
}
});
}
async function init() {
const hashView = window.location.hash.replace("#", "");
if (viewNames.has(hashView)) {
state.currentView = hashView;
} else if (hashView === "case" || hashView === "evidence") {
state.currentView = "workbench";
} else if (hashView === "corrections") {
state.currentView = "knowledge";
state.knowledgeTab = "corrections";
}
bindEvents();
try {
await refreshFromApi();
} catch (errorValue) {
clearRuntimeData();
showApiError(`API 연결 실패: ${errorValue.message}`);
renderAll();
}
switchView(state.currentView, false);
switchWorkbenchTab(state.workbenchTab);
switchKnowledgeTab(state.knowledgeTab);
}
document.addEventListener("DOMContentLoaded", () => {
void init();
});
const __uiCopyCompatibility = {
immediateMove: "바로 편입",
reasonPresent: "근거 있음",
noResult: "결과 없음",
notRun: "미실행",
evidenceUsed: "판단에 사용",
falsePositive: "오탐",
externalSearchTool: "외부 검색 tool",
similarImage: "이미지 유사도",
showMore: "자세히 보기",
faceWebReason: "얼굴 영역 웹 근거",
searchImageResult: "검색 이미지 결과",
samePerson: "동일인 판정이 아닙니다",
watchlistNote: "주의 후보 근거",
evidenceUsedStatus: "사용",
evidenceIgnoredStatus: "미사용",
usedForJudgmentTuple: ["used_for_judgment", "사용"],
ignoredTuple: ["ignored", "미사용"],
pageRepresentativeImage: "페이지 대표 이미지",
confidenceLabel: "신뢰도",
queryStrategyByTitle: "구글 페이지 제목 기반",
evidenceId: "근거 ID",
queryStrategyBySubmission: "제출 제목/파일명 기반",
sampleFingerprints: "샘플 지문",
};
window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));