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

264 lines
4.7 KiB
JavaScript

// Part 2 of 15 of the operator console app script.
//
// Auth token prompt, fetch wrapper, and bootstrap/refresh of client state.
//
// 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 promptForAuthToken(message = "") {
const gate = document.getElementById("auth-gate");
const form = document.getElementById("auth-gate-form");
const input = document.getElementById("auth-gate-input");
const error = document.getElementById("auth-gate-error");
if (!gate || !form || !input) {
return Promise.resolve("");
}
// Collapse concurrent 401s onto one prompt so a burst of parallel requests
// cannot stack multiple dialogs.
if (pendingAuthPrompt) {
return pendingAuthPrompt;
}
if (error) {
error.hidden = !message;
error.textContent = message || "";
}
gate.hidden = false;
input.value = "";
input.focus();
pendingAuthPrompt = new Promise((resolve) => {
const onSubmit = (event) => {
event.preventDefault();
const value = input.value.trim();
if (!value) {
return;
}
form.removeEventListener("submit", onSubmit);
gate.hidden = true;
input.value = "";
pendingAuthPrompt = null;
resolve(value);
};
form.addEventListener("submit", onSubmit);
});
return pendingAuthPrompt;
}
function authHeaders(extra) {
const headers = { "Content-Type": "application/json", ...(extra || {}) };
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
return headers;
}
async function apiJson(path, options = {}, allowAuthRetry = true) {
const response = await fetch(path, {
...options,
headers: authHeaders(options.headers),
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = null;
}
}
if (response.status === 401 && allowAuthRetry) {
// Ask for the token, then replay this request once. A second 401 falls
// through to the normal error path instead of looping.
const token = await promptForAuthToken(
authToken ? "토큰이 올바르지 않습니다. 다시 입력하세요." : "",
);
if (token) {
authToken = token;
return apiJson(path, options, false);
}
}
if (!response.ok) {
const message = (payload && payload.error) || `API 요청 실패: ${response.status}`;
throw new Error(message);
}
return payload;
}
function replaceCollection(target, next) {
target.splice(0, target.length, ...(next || []));
}
function clearRuntimeData() {
replaceCollection(submissions, []);
replaceCollection(providers, []);
replaceCollection(knowledgeEntries, []);
replaceCollection(collectionCandidates, []);
replaceCollection(corrections, []);
replaceCollection(auditEvents, []);
Object.assign(coverageThresholds, DEFAULT_COVERAGE_THRESHOLDS);
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
state.submissionQueue = null;
state.selectedCaseId = "";
}
function applyBootstrap(payload) {
// Any successful payload proves the API is reachable, so clear a stale error
// banner here (not only in refreshFromApi) — actions that applyBootstrap
// directly, e.g. promote/dismiss, otherwise leave the old error visible.
state.apiError = "";
replaceCollection(submissions, payload.submissions);
replaceCollection(providers, payload.providers);
replaceCollection(knowledgeEntries, payload.knowledgeEntries);
state.submissionQueue = payload.submissionQueue || null;
Object.assign(
coverageThresholds,
normalizeCoverageThresholds(payload.coverageThresholds || (payload.searchCoverage && payload.searchCoverage.coverageThresholds)),
);
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
Object.assign(searchCoverage, payload.searchCoverage || {});
replaceCollection(collectionCandidates, payload.collectionCandidates);
replaceCollection(corrections, payload.corrections);
replaceCollection(auditEvents, payload.auditEvents);
replaceCollection(decisionMemoPresets, payload.decisionMemoPresets || []);
if (!submissions.some((item) => item.id === state.selectedCaseId)) {
state.selectedCaseId = submissions[0]?.id || "";
}
}
async function refreshFromApi() {
const payload = await apiJson("/api/bootstrap");
state.apiError = "";
applyBootstrap(payload);
renderAll();
}
function setImportStatus(message, tone = "idle") {
state.importStatus = { message, tone };
renderImportStatus();
}