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

382 lines
7.7 KiB
JavaScript

// Part 12 of 15 of the operator console app script.
//
// Submission reload/import/upload and knowledge entry creation.
//
// 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.
async function rerunSelectedEnrichment() {
const ids = selectedBulkSubmissionIds();
const button = document.getElementById("bulk-rerun");
if (!ids.length) {
setImportStatus("재분석할 제출이 없습니다.", "bad");
return;
}
if (button) button.disabled = true;
setImportStatus(`${ids.length}건 재분석을 시작합니다.`, "warn");
try {
for (const [index, submissionId] of ids.entries()) {
setImportStatus(`${index + 1}/${ids.length} 재분석 중 · ${submissionId}`, "warn");
await apiJson(`/api/submissions/${encodeURIComponent(submissionId)}/rerun-enrichment`, {
method: "POST",
body: JSON.stringify({}),
});
}
state.selectedCaseId = ids[0];
await refreshFromApi();
setImportStatus(`${ids.length}건 재분석 완료`, "ok");
} catch (errorValue) {
// Best-effort refresh so already-rerun submissions aren't left stale in the
// UI when the loop fails partway through.
try { await refreshFromApi(); } catch (_refreshError) { /* keep original error */ }
setImportStatus(errorValue.message, "bad");
} finally {
if (button) button.disabled = false;
}
}
function resetQueueFiltersForImport() {
state.filters.risk = "all";
state.filters.source = "all";
state.filters.decision = "all";
state.filters.sort = "newest";
state.filters.query = "";
document.querySelectorAll("#risk-filter button").forEach((button) => {
button.classList.toggle("active", button.dataset.risk === "all");
});
document.getElementById("source-filter").value = "all";
document.getElementById("decision-filter").value = "all";
document.getElementById("queue-sort").value = "newest";
document.getElementById("queue-search").value = "";
}
function importedFolderStatusMessage(payload, fallbackLabel) {
return window.OperatorSubmissionImport.importedFolderStatusMessage(payload, fallbackLabel);
}
function importedSubmissionStatusMessage(payload, submissionId) {
return window.OperatorSubmissionImport.importedSubmissionStatusMessage(payload, submissionId);
}
async function reloadSubmissions() {
const button = document.getElementById("reload-submissions");
const folderInput = document.getElementById("submission-folder");
const folderPath = (folderInput?.value || "").trim();
const endpoint = folderPath ? "/api/submissions/import-folder" : "/api/submissions/reload";
const body = folderPath ? { path: folderPath } : {};
button.disabled = true;
setImportStatus("제출 폴더를 읽는 중입니다.", "warn");
try {
const payload = await apiJson(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
// Snapshot the pre-reload ids BEFORE applyBootstrap replaces `submissions`,
// otherwise the imported-diff below always compares against the new list and
// is always empty (no new case is ever auto-selected).
const existingIds = new Set(submissions.map((submission) => submission.id));
applyBootstrap(payload);
if (folderPath) {
folderInput.value = "";
const importedFolderSubmissions = payload.submissions || [];
if (importedFolderSubmissions[0]) {
state.selectedCaseId = importedFolderSubmissions[0].id;
resetQueueFiltersForImport();
setImportStatus(importedFolderStatusMessage(payload, folderPath), "ok");
} else {
setImportStatus("추가된 제출 없음", "idle");
}
} else {
const importedSubmissions = (payload.submissions || []).filter((submission) => !existingIds.has(submission.id));
if (importedSubmissions.length) {
state.selectedCaseId = importedSubmissions[0].id;
resetQueueFiltersForImport();
setImportStatus(importedSubmissionStatusMessage(payload, importedSubmissions[0].id), "ok");
} else {
setImportStatus("추가된 제출 없음", "idle");
}
}
renderAll();
} catch (errorValue) {
setImportStatus(errorValue.message, "bad");
} finally {
button.disabled = false;
}
}
function updateSubmissionImageName() {
const input = document.getElementById("submission-image");
const target = document.getElementById("submission-image-name");
if (!input || !target) return;
target.textContent = input.files[0]?.name || "선택된 파일 없음";
}
async function uploadSubmissionImage() {
const input = document.getElementById("submission-image");
const button = document.getElementById("upload-submission-image");
const file = input?.files?.[0];
if (!file) {
setImportStatus("추가할 사진을 선택하세요.", "bad");
return;
}
button.disabled = true;
setImportStatus("사진을 현재 제출 폴더에 넣는 중입니다.", "warn");
try {
const image = await readKnowledgeImage(file);
const payload = await apiJson("/api/submissions/upload-image", {
method: "POST",
body: JSON.stringify({ image }),
});
applyBootstrap(payload);
input.value = "";
updateSubmissionImageName();
if (payload.uploadedSubmissionId) {
state.selectedCaseId = payload.uploadedSubmissionId;
resetQueueFiltersForImport();
switchView("workbench");
switchWorkbenchTab("evidence");
}
setImportStatus(`${payload.uploadedSubmissionId || file.name} 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다.`, "ok");
renderAll();
} catch (errorValue) {
setImportStatus(errorValue.message, "bad");
} finally {
button.disabled = false;
}
}
async function readKnowledgeImage(file) {
return window.OperatorSubmissionImport.fileToImagePayload(file);
}
function updateKnowledgeImageName() {
const input = document.getElementById("knowledge-image");
const target = document.getElementById("knowledge-image-name");
if (!input || !target) return;
target.textContent = input.files[0]?.name || "선택된 파일 없음";
}
async function addKnowledgeEntry(event) {
event.preventDefault();
const name = document.getElementById("knowledge-name").value.trim();
const type = document.getElementById("knowledge-type").value;
const aliases = document
.getElementById("knowledge-aliases")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
const keywords = document
.getElementById("knowledge-keywords")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean);
const memo = document.getElementById("knowledge-memo").value.trim();
const imageInput = document.getElementById("knowledge-image");
const status = document.getElementById("knowledge-entry-status");
const submitButton = event.target.querySelector('button[type="submit"]');
if (!name || !memo) return;
submitButton.disabled = true;
status.textContent = "기준 DB에 저장하는 중입니다.";
try {
const image = await readKnowledgeImage(imageInput.files[0]);
const payload = await apiJson("/api/knowledge/manual", {
method: "POST",
body: JSON.stringify({ name, type, aliases, keywords, memo, image }),
});
applyBootstrap(payload);
event.target.reset();
updateKnowledgeImageName();
status.textContent = image ? "참조 이미지와 이미지 지문을 저장했습니다." : "기준 DB에 저장했습니다.";
renderAll();
} catch (errorValue) {
status.textContent = errorValue.message;
} finally {
submitButton.disabled = false;
}
}