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

361 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Part 10 of 15 of the operator console app script.
//
// Memo presets, decision export, backup, cleared reference, LLM draft.
//
// 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.
// --- Decision memo presets ---------------------------------------------
function renderMemoPresets() {
const target = document.getElementById("memo-presets");
if (!target) return;
const chips = decisionMemoPresets
.map(
(preset) =>
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${escapeHtml(preset.label)}</button>`,
)
.join("");
target.innerHTML = `${chips}<button type="button" class="memo-preset-chip memo-preset-add" id="memo-preset-save">+ 현재 메모 저장</button>`;
}
function applyMemoPreset(presetId) {
const preset = decisionMemoPresets.find((item) => item.id === presetId);
const memo = document.getElementById("decision-memo");
if (!preset || !memo) return;
memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body;
memo.focus();
}
async function saveCurrentMemoAsPreset() {
const memo = document.getElementById("decision-memo");
const error = document.getElementById("memo-error");
const body = memo?.value.trim() || "";
if (!body) {
if (error) error.textContent = "저장할 메모 내용이 없습니다.";
return;
}
const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20));
if (!label) return;
try {
const presets = await apiJson("/api/decision-memo-presets", {
method: "POST",
body: JSON.stringify({ label, body }),
});
replaceCollection(decisionMemoPresets, presets);
renderMemoPresets();
if (error) error.textContent = "";
} catch (errorValue) {
if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`;
}
}
// --- Decision export ----------------------------------------------------
async function exportDecisions() {
const status = document.getElementById("bulk-decision-status");
const audience = window.confirm(
"내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.",
)
? "internal"
: "applicant";
try {
const payload = await apiJson(
`/api/decisions/export?audience=${encodeURIComponent(audience)}`,
);
if (!payload.rows.length) {
if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다.";
return;
}
downloadTextFile(
`copyrighter-decisions-${audience}.csv`,
payload.csv,
"text/csv;charset=utf-8",
);
if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`;
} catch (errorValue) {
if (status) status.textContent = `내보내기 실패: ${errorValue.message}`;
}
}
function downloadTextFile(filename, text, mimeType) {
// BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage.
const blob = new Blob([`${text}`], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// --- Backup -------------------------------------------------------------
async function createBackupSnapshot() {
const status = document.getElementById("bulk-decision-status");
const button = document.getElementById("create-backup");
if (button) button.disabled = true;
if (status) status.textContent = "백업 스냅샷을 만드는 중…";
try {
const result = await apiJson("/api/admin/backup", { method: "POST" });
const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1);
if (status) {
status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`;
}
await refreshFromApi();
} catch (errorValue) {
if (status) status.textContent = `백업 실패: ${errorValue.message}`;
} finally {
if (button) button.disabled = false;
}
}
// --- Cleared reference (positive knowledge) -----------------------------
async function registerClearedReference() {
const submission = getSelectedCase();
if (!submission) return;
const label = window.prompt(
"클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
submission.title || submission.id,
);
if (label === null) return;
const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
try {
const payload = await apiJson(
`/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
{ method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
);
applyBootstrap(payload);
renderAll();
} catch (errorValue) {
showApiError(errorValue.message);
}
}
// --- LLM draft (persist + show + pull into memo) ------------------------
function llmSummaryFor(submission) {
const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
// The freshest LLM summary that actually carries body text.
return (
evidence
.filter((item) => item.source === "llm" && String(item.summary || "").trim())
.slice(-1)[0] || null
);
}
let currentLlmDraftText = "";
function renderLlmDraft(submission) {
const box = document.getElementById("llm-draft");
if (!box) return;
const summary = llmSummaryFor(submission);
if (!summary) {
box.hidden = true;
box.innerHTML = "";
currentLlmDraftText = "";
return;
}
currentLlmDraftText = String(summary.summary || "");
const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
box.hidden = false;
box.innerHTML = `
<div class="llm-draft-head">
<span class="llm-draft-tag">내부 요약(초안)</span>
<span class="muted small">${escapeHtml(sourceNote)} · 최종 판정은 운영자</span>
</div>
<p class="llm-draft-body">${escapeHtml(currentLlmDraftText)}</p>
<button type="button" class="llm-draft-use" id="llm-draft-use">메모 초안으로 넣기</button>
`;
}
function pullLlmDraftIntoMemo() {
const memo = document.getElementById("decision-memo");
if (!memo || !currentLlmDraftText) return;
memo.value = memo.value.trim()
? `${memo.value.trim()}\n${currentLlmDraftText}`
: currentLlmDraftText;
memo.focus();
}