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

282 lines
6.5 KiB
JavaScript

// Part 14 of 15 of the operator console app script.
//
// Knowledge/correction/provider/evidence-status admin actions.
//
// 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 addKnowledgeEntryFromCase() {
// The control is labelled "주의 후보 보기" (view watchlist candidates), so show
// the registered list filtered to watchlist — it previously opened the blank
// manual-registration form, which is a different action entirely.
switchView("knowledge");
switchKnowledgeTab("registered");
knowledgeFilters.status = "watchlist";
const statusFilter = document.getElementById("knowledge-status-filter");
if (statusFilter) statusFilter.value = "watchlist";
renderAll();
}
async function deactivateCorrectionEntry(correctionId) {
const correction = corrections.find((item) => item.id === correctionId);
if (!correction) return;
// Deactivate the derived knowledge entries through the real backend endpoint
// so the change is persisted and audited server-side. The previous version
// only mutated local state and appended a fake audit event, so the action was
// silently discarded on the next refreshFromApi().
// Only entries that are still active need deactivating. Skipping already-
// inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
// abort the whole loop on the first inactive entry.
const entryIds = (correction.derivedEntries || [])
.filter((entry) => entry && entry.id && entry.active)
.map((entry) => entry.id);
if (!entryIds.length) {
showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
return;
}
const failures = [];
try {
for (const entryId of entryIds) {
try {
await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
method: "POST",
body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
});
} catch (_perEntryError) {
// One entry failing must not strand the rest.
failures.push(entryId);
}
}
} finally {
// Always refresh so the UI reflects whatever was actually persisted, even on
// a partial failure. Guard it so a refresh error isn't an unhandled rejection.
try {
await refreshFromApi();
} catch (_refreshError) {
/* keep the per-entry failure summary below */
}
}
if (failures.length) {
showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
}
}
async function toggleProvider(providerId) {
const provider = providers.find((item) => item.id === providerId);
if (!provider) return;
try {
await apiJson(`/api/providers/${encodeURIComponent(providerId)}`, {
method: "PATCH",
body: JSON.stringify({ enabled: !provider.enabled }),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function setEvidenceStatus(evidenceId, status, note = "") {
const submission = getSelectedCase();
if (!submission || !evidenceId) return;
try {
await apiJson(`/api/evidence/${encodeURIComponent(evidenceId)}/status`, {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, status: evidenceStatusPayload(status), note }),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
function markEvidenceIrrelevant(evidenceId) {
const submission = getSelectedCase();
if (!submission) return;
const fallback = submission.evidence.find((item) => item.contributed);
const targetId = evidenceId || fallback?.id;
void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리");
}
async function saveKnowledgeEntryEdit(form) {
const entryId = form.dataset.knowledgeEditForm;
const body = {
aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
memo: form.elements.memo.value.trim(),
};
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
method: "PATCH",
body: JSON.stringify(body),
});
editingKnowledgeEntryId = "";
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function deactivateKnowledgeEntry(entryId) {
const reason = window.prompt("비활성 사유 메모(선택)");
if (reason === null) return;
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
method: "POST",
body: JSON.stringify({ reason: reason.trim() }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function reactivateKnowledgeEntry(entryId) {
const reason = window.prompt("재활성 사유 메모(필수)") || "";
if (!reason.trim()) {
showApiError("재활성에는 사유 메모가 필요합니다.");
return;
}
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/reactivate`, {
method: "POST",
body: JSON.stringify({ reason: reason.trim() }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function promoteWatchlistEntry(entryId) {
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/promote-watchlist`, {
method: "POST",
body: JSON.stringify({}),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function excludeWatchlistEntry(entryId) {
try {
const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/exclude-watchlist`, {
method: "POST",
body: JSON.stringify({ reason: "오탐 확인" }),
});
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
async function emergencyDisableExternalProviders() {
try {
await apiJson("/api/providers/emergency-disable", {
method: "POST",
body: JSON.stringify({}),
});
await refreshFromApi();
} catch (errorValue) {
showApiError(errorValue.message);
}
}