단일 파일이 프로젝트 상한(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건.
361 lines
6.9 KiB
JavaScript
361 lines
6.9 KiB
JavaScript
// 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();
|
||
|
||
}
|
||
|
||
|