feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

@@ -35,8 +35,12 @@
if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable.");
const namedComparisonRestoreWorkflow = globalThis.MbnNamedComparisonRestoreWorkflow;
if (!namedComparisonRestoreWorkflow) throw new Error("Named comparison restore workflow module is unavailable.");
const namedForeignStockRestoreWorkflow = globalThis.MbnNamedForeignStockRestoreWorkflow;
if (!namedForeignStockRestoreWorkflow) throw new Error("Named foreign-stock restore workflow module is unavailable.");
const foreignIndexCandleRestoreWorkflow = globalThis.MbnLegacyForeignIndexCandleWorkflow;
if (!foreignIndexCandleRestoreWorkflow) throw new Error("Legacy foreign-index candle restore workflow module is unavailable.");
const namedKrxThemeRestoreWorkflow = globalThis.MbnNamedKrxThemeRestoreWorkflow;
if (!namedKrxThemeRestoreWorkflow) throw new Error("Named KRX-theme restore workflow module is unavailable.");
const namedNxtThemeRestoreWorkflow = globalThis.MbnNamedNxtThemeRestoreWorkflow;
if (!namedNxtThemeRestoreWorkflow) throw new Error("Named NXT-theme restore workflow module is unavailable.");
const legacyNamedRowWorkflow = globalThis.MbnLegacyNamedRowWorkflow;
@@ -223,7 +227,9 @@
guardByEntryId: new Map(),
manualRestoreFinalized: true,
comparisonRestorePending: null,
foreignStockRestorePending: null,
foreignIndexCandleRestorePending: null,
krxThemeRestorePending: null,
nxtThemeRestorePending: null,
selectAfterRefresh: ""
},
@@ -703,7 +709,9 @@
restoreRecords.every(record => record.status === "error");
if (state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
state.namedPlaylist.comparisonRestorePending ||
state.namedPlaylist.foreignStockRestorePending ||
state.namedPlaylist.foreignIndexCandleRestorePending ||
state.namedPlaylist.krxThemeRestorePending ||
state.namedPlaylist.nxtThemeRestorePending ||
(restoreRecords.length > 0 && !(allowFailedManualRestore && failedRestoreOnly))) {
showToast("DB 플레이리스트 복원과 현재 identity 재검증이 끝날 때까지 순서를 변경할 수 없습니다.");
@@ -2423,9 +2431,12 @@
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-theme-workflow") return item;
const refreshed = namedNxtThemeRestoreWorkflow.isMaterializedEntry(item)
? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date())
: themeWorkflow.refreshThemePlaylistEntry(item);
const namedKrx = namedKrxThemeRestoreWorkflow.isMaterializedEntry(item);
const refreshed = namedKrx
? item
: namedNxtThemeRestoreWorkflow.isMaterializedEntry(item)
? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date())
: themeWorkflow.refreshThemePlaylistEntry(item);
if (!refreshed) {
invalid = true;
return item;
@@ -2436,10 +2447,10 @@
invalid = true;
return item;
}
// A native-verified named NXT entry already has the complete theme
// operator shape. Do not clone it here: its in-memory verification mark
// must survive until PREPARE/save validation.
return namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed)
// Native-verified named theme entries already have the complete operator
// shape. Do not clone them here: the in-memory verification mark must
// survive until PREPARE/save validation.
return namedKrx || namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed)
? refreshed
: { ...definition, ...refreshed };
});
@@ -3532,6 +3543,10 @@
state.candleOptions.invalidIds = [...result.invalidIds];
state.playlist = [...result.playlist].map(item => {
if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item;
// The named foreign-stock entry carries a strict native identity proof
// and original seven-field signature. Keep that exact branded object;
// catalog decoration would make its save round-trip unverifiable.
if (item.operator?.source === namedForeignStockRestoreWorkflow.source) return item;
const definition = catalog.find(value =>
value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || "")));
return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item;
@@ -3832,6 +3847,92 @@
showToast("플레이리스트에 추가했습니다.");
}
function recreatePlaylistEntryWithEnabled(item, enabled) {
if (!item || typeof item !== "object" || typeof item.enabled !== "boolean" ||
typeof enabled !== "boolean") return null;
if (item.enabled === enabled) return item;
const operator = item.operator;
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(item) ||
operator?.namedKrxRestore !== undefined) {
return namedKrxThemeRestoreWorkflow.withEnabled(item, enabled);
}
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) ||
operator?.namedNxtRestore !== undefined) {
return namedNxtThemeRestoreWorkflow.withEnabled(item, enabled);
}
if (operator?.source === namedForeignStockRestoreWorkflow.source) {
return namedForeignStockRestoreWorkflow.withEnabled(item, enabled);
}
if (operator?.source === "legacy-foreign-index-candle-workflow") {
return foreignIndexCandleRestoreWorkflow.withEnabled(item, enabled);
}
if (operator?.source === "manual-financial-workflow") {
return manualFinancialWorkflow.withEnabled(item, enabled);
}
if (operator?.source === "legacy-manual-lists-workflow") {
return manualListsWorkflow.withEnabled(item, enabled);
}
return { ...item, enabled };
}
function playlistEntryManualEditBlockReason(item) {
if (!item || typeof item !== "object") return "missing-entry";
if (item.operator?.source) return "trusted-workflow-entry";
if (state.namedPlaylist.guardByEntryId.has(item.id)) return "named-playlist-entry";
if (state.manualFinancialRestore.entries.has(item.id)) return "manual-restore-entry";
return "";
}
function normalizeManualSceneSelection(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
if (Object.keys(value).length !== keys.length || keys.some(key => !(key in value))) return null;
const selection = {};
for (const key of keys) {
if (typeof value[key] !== "string") return null;
const text = value[key].trim();
if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return null;
selection[key] = text;
}
return Object.freeze(selection);
}
function recreatePlaylistEntryWithSceneSelection(item, selection, code, fadeDuration) {
if (playlistEntryManualEditBlockReason(item)) return null;
const normalizedSelection = normalizeManualSceneSelection(selection);
if (!normalizedSelection || typeof code !== "string" || !sceneAliases(item).includes(code) ||
!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) return null;
return { ...item, selection: normalizedSelection, code, fadeDuration };
}
function recreatePlaylistEntryWithLiveSelection(item, selection) {
if (playlistEntryManualEditBlockReason(item)) return null;
const normalizedSelection = normalizeManualSceneSelection(selection);
if (!normalizedSelection || (!normalizedSelection.subject && !normalizedSelection.dataCode)) return null;
return {
...item,
selection: normalizedSelection,
subject: normalizedSelection.subject,
dataCode: normalizedSelection.dataCode
};
}
function replacePlaylistEntryAt(index, replacement) {
if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length ||
!replacement || replacement.id !== state.playlist[index]?.id) return false;
const playlist = [...state.playlist];
playlist[index] = replacement;
state.playlist = playlist;
return true;
}
function replacePlaylistEntryEnabledAt(index, enabled) {
if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length) return null;
const replacement = recreatePlaylistEntryWithEnabled(state.playlist[index], enabled);
return replacement && replacePlaylistEntryAt(index, replacement) ? replacement : null;
}
function renderPlaylist() {
renderGlobalCandleOptions();
elements.playlistBody.replaceChildren();
@@ -3874,8 +3975,14 @@
checkbox.checked = item.enabled !== false;
return;
}
item.enabled = checkbox.checked;
addLog(`${item.code} ${checkbox.checked ? "송출 포함" : "송출 제외"}`);
const replacement = replacePlaylistEntryEnabledAt(index, checkbox.checked);
if (!replacement) {
checkbox.checked = item.enabled !== false;
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
return;
}
renderPlaylist();
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
});
checkCell.append(checkbox);
@@ -3999,7 +4106,8 @@
function renderSceneSelectionForm(item) {
const selection = item?.selection || {};
const disabled = !item || isPlaylistSnapshotLocked() || Boolean(item.operator?.source);
const disabled = !item || isPlaylistSnapshotLocked() ||
Boolean(playlistEntryManualEditBlockReason(item));
elements.sceneGroupCode.value = String(selection.groupCode || "");
elements.sceneSubject.value = String(selection.subject || "");
elements.sceneGraphicType.value = String(selection.graphicType || "");
@@ -4033,6 +4141,11 @@
if (!requirePlaylistEditable()) return;
const item = state.playlist[state.currentIndex];
if (!item) return;
if (playlistEntryManualEditBlockReason(item)) {
renderSceneSelectionForm(item);
showToast("검증된 항목은 조회값을 직접 편집할 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
return;
}
const values = [
elements.sceneGroupCode.value,
elements.sceneSubject.value,
@@ -4054,18 +4167,21 @@
showToast("허용되지 않은 CUT alias입니다.");
return;
}
item.selection = {
const replacement = recreatePlaylistEntryWithSceneSelection(item, {
groupCode: values[0].trim(),
subject: values[1].trim(),
graphicType: values[2].trim(),
subtype: values[3].trim(),
dataCode: values[4].trim()
};
item.code = cutAlias;
item.fadeDuration = fadeDuration;
}, cutAlias, fadeDuration);
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
renderSceneSelectionForm(item);
showToast("장면 조회값을 안전하게 적용할 수 없습니다.");
return;
}
renderPlaylist();
updatePreview();
addLog(`${item.code} 장면 조회값 적용`);
addLog(`${replacement.code} 장면 조회값 적용`);
showToast("장면 조회값을 적용했습니다.");
}
@@ -4131,7 +4247,12 @@
if (!requirePlaylistEditable()) return;
if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다.");
const enable = state.playlist.some(item => item.enabled === false);
state.playlist.forEach(item => { item.enabled = enable; });
const replacements = state.playlist.map(item => recreatePlaylistEntryWithEnabled(item, enable));
if (replacements.some(item => !item)) {
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
return;
}
state.playlist = replacements;
renderPlaylist();
addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`);
}
@@ -4222,9 +4343,13 @@
if (!requirePlaylistEditable()) return;
const item = state.playlist[state.currentIndex];
if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요.");
item.enabled = item.enabled === false;
const replacement = replacePlaylistEntryEnabledAt(state.currentIndex, item.enabled === false);
if (!replacement) {
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
return;
}
renderPlaylist();
addLog(`${item.code} ${item.enabled ? "송출 포함" : "송출 제외"}`);
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
}
function savePlaylist() {
@@ -4284,6 +4409,16 @@
? [{ ...themeDefinition, ...restoredThemeEntry }]
: [];
}
const restoredNamedForeignStockEntry =
namedForeignStockRestoreWorkflow.restorePlaylistEntry(item);
if (restoredNamedForeignStockEntry) {
const overseasDefinition = catalog.find(definition =>
definition.builderKey === restoredNamedForeignStockEntry.builderKey &&
sceneAliases(definition).includes(restoredNamedForeignStockEntry.code));
return overseasDefinition
? [{ ...overseasDefinition, ...restoredNamedForeignStockEntry }]
: [];
}
const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item);
if (restoredOverseasEntry) {
const overseasDefinition = catalog.find(definition =>
@@ -4690,7 +4825,9 @@
return;
}
if (state.namedPlaylist.comparisonRestorePending ||
state.namedPlaylist.foreignStockRestorePending ||
state.namedPlaylist.foreignIndexCandleRestorePending ||
state.namedPlaylist.krxThemeRestorePending ||
state.namedPlaylist.nxtThemeRestorePending) return;
const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration);
const materialized = materializeNamedRestoration(restoration);
@@ -4868,10 +5005,10 @@
}
function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) {
// Historical NXT codes are not authoritative. Keep every original
// `테마_NXT` row untrusted until the correlated native MariaDB batch maps
// its exact title to the current unique code and proves a live item.
if (rawRow.groupCode === "테마_NXT") return;
// Historical theme codes are not authoritative. Keep every original KRX
// and NXT row untrusted until its correlated native batch maps the exact
// title to one current code and validates the item preview.
if (rawRow.groupCode === "테마" || rawRow.groupCode === "테마_NXT") return;
let theme;
let sort;
let actionIds = themeWorkflow.actions.map(action => action.id);
@@ -4994,6 +5131,10 @@
}
function resolveNamedPlaylistRawRow(rawRow, itemIndex) {
// The stored theme code is historical evidence, not a playable identity.
// Route both legacy theme groups only through their correlated native
// verifiers; no stock/default candidate may accept the stale code first.
if (rawRow?.groupCode === "테마" || rawRow?.groupCode === "테마_NXT") return null;
const candidates = [];
const now = namedNowForRawRow(rawRow);
addFixedNamedCandidates(candidates, rawRow, itemIndex, now);
@@ -5053,7 +5194,9 @@
for (const row of restoration.rows) {
const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" ||
row.entry?.operator?.source === "legacy-manual-lists-workflow" ||
namedKrxThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
namedNxtThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
namedForeignStockRestoreWorkflow.isMaterializedEntry(row.entry) ||
foreignIndexCandleRestoreWorkflow.isMaterializedEntry(row.entry);
const entry = row.trusted
? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText })
@@ -5099,7 +5242,10 @@
state.namedPlaylist.pagePlanError = null;
state.namedPlaylist.manualRestoreFinalized = true;
state.namedPlaylist.comparisonRestorePending = null;
clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
state.namedPlaylist.foreignStockRestorePending = null;
state.namedPlaylist.foreignIndexCandleRestorePending = null;
state.namedPlaylist.krxThemeRestorePending = null;
state.namedPlaylist.nxtThemeRestorePending = null;
}
@@ -5173,7 +5319,9 @@
}
return manualFields;
}
const selection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
const selection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
namedForeignStockRestoreWorkflow.legacySelectionForEntry(entry) ||
legacyNamedRowWorkflow.legacySelectionForEntry(entry);
if (!selection) {
throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다.");
@@ -5191,6 +5339,12 @@
pageText: fields[5],
dataCode: fields[6]
};
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
if (!namedKrxThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
throw new Error("KRX 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
}
return fields;
}
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
if (!namedNxtThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
throw new Error("NXT 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
@@ -5215,6 +5369,15 @@
}
return fields;
}
if (entry.operator?.source === "legacy-named-foreign-stock-workflow") {
const restoredForeignStock = namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
if (!restoredForeignStock || restoredForeignStock.builderKey !== entry.builderKey ||
restoredForeignStock.code !== entry.code ||
!namedForeignStockRestoreWorkflow.matchesRaw(restoredForeignStock, rawRow)) {
throw new Error("해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
}
return fields;
}
const roundTrip = resolveNamedPlaylistRawRow(rawRow, itemIndex);
if (!roundTrip || roundTrip.builderKey !== entry.builderKey || roundTrip.code !== entry.code) {
throw new Error("원본 PList 7필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다.");
@@ -5225,6 +5388,27 @@
function isTrustedNamedPlaylistEntry(entry, itemIndex = 0) {
const guard = state.namedPlaylist.guardByEntryId.get(entry?.id);
if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false;
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
if (!guard || !guard.trusted) return false;
try {
const legacySelection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry);
const fields = namedPlaylistWorkflow.toLegacySevenFields(
{ ...entry, selection: legacySelection },
namedPageTextForEntry(entry));
return namedKrxThemeRestoreWorkflow.matchesRaw(entry, {
itemIndex,
enabled: fields[0] === "1",
groupCode: fields[1],
subject: fields[2],
graphicType: fields[3],
subtype: fields[4],
pageText: fields[5],
dataCode: fields[6]
});
} catch {
return false;
}
}
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
if (!guard || !guard.trusted) return false;
try {
@@ -5248,19 +5432,24 @@
}
if (entry?.operator?.source === "legacy-foreign-index-candle-workflow" &&
(!guard || !guard.trusted)) return false;
if (entry?.operator?.source === "legacy-named-foreign-stock-workflow" &&
(!guard || !guard.trusted)) return false;
if (namedManualRestoreWorkflow.isTrustedEntryForSave(entry, itemIndex)) return true;
const selection = entry?.selection;
if (!selection) return false;
const now = namedNowForRawRow(selection);
const restored = entry.operator?.source === "legacy-foreign-index-candle-workflow"
? [foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
: normalizeStoredPlaylist([entry], { now });
: entry.operator?.source === "legacy-named-foreign-stock-workflow"
? [namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
: normalizeStoredPlaylist([entry], { now });
if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey ||
restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false;
if ([
"legacy-comparison-workflow",
"legacy-industry-workflow",
"legacy-foreign-index-candle-workflow"
"legacy-foreign-index-candle-workflow",
"legacy-named-foreign-stock-workflow"
]
.includes(entry.operator?.source)) {
const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry);
@@ -5287,7 +5476,9 @@
? comparisonWorkflow.restoreComparisonPlaylistEntry(entry)
: entry.operator.source === "legacy-industry-workflow"
? industryWorkflow.restoreIndustryPlaylistEntry(entry)
: foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry);
: entry.operator.source === "legacy-foreign-index-candle-workflow"
? foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)
: namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
return Boolean(restoredClosedEntry && restoredClosedEntry.builderKey === entry.builderKey &&
restoredClosedEntry.code === entry.code &&
legacyNamedRowWorkflow.matchesCanonicalEntry(restoredClosedEntry, rawRow));
@@ -5319,7 +5510,9 @@
function namedPlaylistBusy() {
return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
state.namedPlaylist.comparisonRestorePending ||
state.namedPlaylist.foreignStockRestorePending ||
state.namedPlaylist.foreignIndexCandleRestorePending ||
state.namedPlaylist.krxThemeRestorePending ||
state.namedPlaylist.nxtThemeRestorePending ||
state.manualFinancialRestore.entries.size > 0);
}
@@ -5330,7 +5523,9 @@
const pending = workflow.pending || workflow.pagePlanPending;
const freshRestorePending = state.manualFinancialRestore.entries.size > 0 ||
Boolean(workflow.comparisonRestorePending) ||
Boolean(workflow.foreignStockRestorePending) ||
Boolean(workflow.foreignIndexCandleRestorePending) ||
Boolean(workflow.krxThemeRestorePending) ||
Boolean(workflow.nxtThemeRestorePending);
const busy = Boolean(pending) || freshRestorePending;
const selected = selectedNamedPlaylistDefinition();
@@ -5619,6 +5814,16 @@
});
return pending ? [pending] : [];
});
const pendingForeignStocks = restoration.rows.flatMap(row => {
if (row.trusted) return [];
const pending = namedForeignStockRestoreWorkflow.classify(row.rawRow, {
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
fadeDuration: DEFAULT_FADE_DURATION,
ma5: state.candleOptions.ma5,
ma20: state.candleOptions.ma20
});
return pending ? [pending] : [];
});
const pendingForeignIndexCandles = restoration.rows.flatMap(row => {
if (row.trusted) return [];
const pending = foreignIndexCandleRestoreWorkflow.classify(row.rawRow, {
@@ -5627,6 +5832,14 @@
});
return pending ? [pending] : [];
});
const pendingKrxThemes = restoration.rows.flatMap(row => {
if (row.trusted) return [];
const pending = namedKrxThemeRestoreWorkflow.classify(row.rawRow, {
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
fadeDuration: DEFAULT_FADE_DURATION
});
return pending ? [pending] : [];
});
const pendingNxtThemes = restoration.rows.flatMap(row => {
if (row.trusted) return [];
const pending = namedNxtThemeRestoreWorkflow.classify(row.rawRow, {
@@ -5637,7 +5850,9 @@
});
state.namedPlaylist.manualRestoreFinalized =
pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 &&
pendingForeignIndexCandles.length === 0 && pendingNxtThemes.length === 0;
pendingForeignStocks.length === 0 &&
pendingForeignIndexCandles.length === 0 && pendingKrxThemes.length === 0 &&
pendingNxtThemes.length === 0;
if (state.namedPlaylist.manualRestoreFinalized) {
restoration = prepareNamedPagePreflight(restoration);
}
@@ -5652,12 +5867,26 @@
rows: Object.freeze(pendingNamedComparison)
})
: null;
state.namedPlaylist.foreignStockRestorePending = pendingForeignStocks.length
? {
requestId: load.requestId,
rows: Object.freeze(pendingForeignStocks),
timeoutId: null
}
: null;
armNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
state.namedPlaylist.foreignIndexCandleRestorePending = pendingForeignIndexCandles.length
? Object.freeze({
requestId: load.requestId,
rows: Object.freeze(pendingForeignIndexCandles)
})
: null;
state.namedPlaylist.krxThemeRestorePending = pendingKrxThemes.length
? Object.freeze({
requestId: load.requestId,
rows: Object.freeze(pendingKrxThemes)
})
: null;
state.namedPlaylist.nxtThemeRestorePending = pendingNxtThemes.length
? Object.freeze({
requestId: load.requestId,
@@ -5776,6 +6005,115 @@
error?.message || "비교 행의 현재 종목 마스터 재검증을 완료하지 못했습니다.");
}
function clearNamedForeignStockRestoreTimeout(record) {
if (record?.timeoutId !== null && record?.timeoutId !== undefined) {
clearTimeout(record.timeoutId);
record.timeoutId = null;
}
}
function armNamedForeignStockRestoreTimeout(record) {
if (!record) return;
clearNamedForeignStockRestoreTimeout(record);
record.timeoutId = setTimeout(() => {
if (state.namedPlaylist.foreignStockRestorePending !== record) return;
failNamedForeignStockRestore(
record,
"해외종목 identity 재검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.");
}, 30000);
}
function failNamedForeignStockRestore(record, message) {
if (!record || state.namedPlaylist.foreignStockRestorePending !== record) return;
clearNamedForeignStockRestoreTimeout(record);
state.namedPlaylist.foreignStockRestorePending = null;
state.namedPlaylist.error = message;
state.namedPlaylist.status = "error";
finalizeNamedManualRestores();
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedForeignStockRestoreResults(payload) {
const record = state.namedPlaylist.foreignStockRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const response = namedForeignStockRestoreWorkflow.normalizeResponse(
payload,
record.requestId,
record.rows);
if (!response) {
failNamedForeignStockRestore(
record,
"Native Bridge의 해외종목 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
return;
}
const restoration = state.namedPlaylist.restoration;
if (!restoration || isPlaylistSnapshotLocked()) {
failNamedForeignStockRestore(
record,
"해외종목 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
return;
}
const rows = [...restoration.rows];
let restoredCount = 0;
const failures = new Map();
for (let index = 0; index < response.rows.length; index += 1) {
const pending = record.rows[index];
const outcome = response.rows[index];
if (outcome.status === "failed") {
failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
continue;
}
const storedRow = rows[pending.itemIndex];
const entry = namedForeignStockRestoreWorkflow.materialize(pending, outcome);
if (!storedRow || storedRow.trusted || !entry ||
!namedForeignStockRestoreWorkflow.isMaterializedEntry(entry) ||
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
entry.enabled !== storedRow.rawRow.enabled ||
!namedForeignStockRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
failures.set("MATERIALIZATION_MISMATCH",
(failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
continue;
}
rows[pending.itemIndex] = Object.freeze({
...storedRow,
entry,
trusted: true
});
state.playlist[pending.itemIndex] = entry;
restoredCount += 1;
}
state.namedPlaylist.restoration = Object.freeze({
...restoration,
rows: Object.freeze(rows)
});
clearNamedForeignStockRestoreTimeout(record);
state.namedPlaylist.foreignStockRestorePending = null;
addLog(`DB 해외종목 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}`);
if (failures.size) {
const summary = [...failures.entries()]
.map(([code, count]) => `${code} ${count}`)
.join(" · ");
addLog(`DB 해외종목 차단 사유 · ${summary}`);
}
finalizeNamedManualRestores();
renderPlaylist();
updatePreview();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedForeignStockRestoreError(payload) {
const record = state.namedPlaylist.foreignStockRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const error = namedForeignStockRestoreWorkflow.normalizeError(payload, record.requestId);
failNamedForeignStockRestore(
record,
error?.message || "해외종목의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
}
function failNamedForeignIndexCandleRestore(record, message) {
if (!record || state.namedPlaylist.foreignIndexCandleRestorePending !== record) return;
state.namedPlaylist.foreignIndexCandleRestorePending = null;
@@ -5865,6 +6203,97 @@
error?.message || "해외지수 캔들의 현재 master 재검증을 완료하지 못했습니다.");
}
function failNamedKrxThemeRestore(record, message) {
if (!record || state.namedPlaylist.krxThemeRestorePending !== record) return;
state.namedPlaylist.krxThemeRestorePending = null;
state.namedPlaylist.error = message;
state.namedPlaylist.status = "error";
finalizeNamedManualRestores();
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedKrxThemeRestoreResults(payload) {
const record = state.namedPlaylist.krxThemeRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const response = namedKrxThemeRestoreWorkflow.normalizeResponse(
payload,
record.requestId,
record.rows);
if (!response) {
failNamedKrxThemeRestore(
record,
"Native Bridge의 KRX 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
return;
}
const restoration = state.namedPlaylist.restoration;
if (!restoration || isPlaylistSnapshotLocked()) {
failNamedKrxThemeRestore(
record,
"KRX 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
return;
}
const rows = [...restoration.rows];
let restoredCount = 0;
let remappedCount = 0;
const failures = new Map();
for (let index = 0; index < response.rows.length; index += 1) {
const pending = record.rows[index];
const outcome = response.rows[index];
if (outcome.status === "failed") {
failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
continue;
}
const storedRow = rows[pending.itemIndex];
const entry = namedKrxThemeRestoreWorkflow.materialize(pending, outcome);
if (!storedRow || storedRow.trusted || !entry ||
!namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry) ||
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
entry.enabled !== storedRow.rawRow.enabled ||
!namedKrxThemeRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
failures.set("MATERIALIZATION_MISMATCH",
(failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
continue;
}
rows[pending.itemIndex] = Object.freeze({
...storedRow,
entry,
trusted: true
});
state.playlist[pending.itemIndex] = entry;
restoredCount += 1;
if (outcome.codeWasRemapped) remappedCount += 1;
}
state.namedPlaylist.restoration = Object.freeze({
...restoration,
rows: Object.freeze(rows)
});
state.namedPlaylist.krxThemeRestorePending = null;
addLog(`DB KRX 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}`);
if (failures.size) {
const summary = [...failures.entries()]
.map(([code, count]) => `${code} ${count}`)
.join(" · ");
addLog(`DB KRX 테마 차단 사유 · ${summary}`);
}
finalizeNamedManualRestores();
renderPlaylist();
updatePreview();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedKrxThemeRestoreError(payload) {
const record = state.namedPlaylist.krxThemeRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const error = namedKrxThemeRestoreWorkflow.normalizeError(payload, record.requestId);
failNamedKrxThemeRestore(
record,
error?.message || "KRX 테마의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
}
function failNamedNxtThemeRestore(record, message) {
if (!record || state.namedPlaylist.nxtThemeRestorePending !== record) return;
state.namedPlaylist.nxtThemeRestorePending = null;
@@ -6084,7 +6513,48 @@
function handleNamedPlaylistError(payload) {
const error = namedPlaylistWorkflow.normalizeError(payload);
const pending = state.namedPlaylist.pending;
if (!error || !pending || error.requestId !== pending.requestId || error.operation !== pending.operation) return;
if (!error) return;
if (!pending || error.requestId !== pending.requestId || error.operation !== pending.operation) {
// A load result is posted before its correlated restore batches. If the
// native read is then canceled (for example by playout priority), the
// primary load is no longer pending but one or more restore batches can
// still be waiting. Close every matching batch exactly once so PREPARE
// stays blocked by the untrusted rows instead of an orphaned busy flag.
if (error.operation !== "load") return;
const restoreRecords = [
state.namedPlaylist.comparisonRestorePending,
state.namedPlaylist.foreignStockRestorePending,
state.namedPlaylist.foreignIndexCandleRestorePending,
state.namedPlaylist.krxThemeRestorePending,
state.namedPlaylist.nxtThemeRestorePending
];
if (!restoreRecords.some(record => record?.requestId === error.requestId)) return;
if (state.namedPlaylist.comparisonRestorePending?.requestId === error.requestId) {
state.namedPlaylist.comparisonRestorePending = null;
}
if (state.namedPlaylist.foreignStockRestorePending?.requestId === error.requestId) {
clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
state.namedPlaylist.foreignStockRestorePending = null;
}
if (state.namedPlaylist.foreignIndexCandleRestorePending?.requestId === error.requestId) {
state.namedPlaylist.foreignIndexCandleRestorePending = null;
}
if (state.namedPlaylist.krxThemeRestorePending?.requestId === error.requestId) {
state.namedPlaylist.krxThemeRestorePending = null;
}
if (state.namedPlaylist.nxtThemeRestorePending?.requestId === error.requestId) {
state.namedPlaylist.nxtThemeRestorePending = null;
}
state.namedPlaylist.status = "error";
state.namedPlaylist.error = `${error.message} [${error.code}] 복원 배치는 자동 재시도하지 않습니다.`;
finalizeNamedManualRestores();
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
addLog(`DB 플레이리스트 LOAD 후속 복원 종료 · ${error.code}`);
showToast(state.namedPlaylist.error);
return;
}
state.namedPlaylist.pending = null;
state.namedPlaylist.status = "error";
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined;
@@ -7132,11 +7602,16 @@
}
function applyLiveRowToCurrentCut(table, row, columns) {
if (!requirePlaylistEditable()) return;
const item = state.playlist[state.currentIndex];
if (!item) {
showToast("먼저 적용할 플레이리스트 장면을 선택하세요.");
return;
}
if (playlistEntryManualEditBlockReason(item)) {
showToast("검증된 항목에는 DB 행을 직접 덮어쓸 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
return;
}
const values = liveRowValues(row, columns);
const subject = firstLiveValue(values, [
@@ -7152,18 +7627,20 @@
}
const previousSelection = item.selection || {};
item.selection = {
const replacement = recreatePlaylistEntryWithLiveSelection(item, {
groupCode: liveSelectionGroup(table, item),
subject: subject || dataCode,
graphicType: String(previousSelection.graphicType || item.graphicType || ""),
subtype: String(previousSelection.subtype || item.subtype || ""),
dataCode
};
item.subject = item.selection.subject;
item.dataCode = dataCode;
});
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
showToast("선택한 DB 행을 현재 장면에 안전하게 적용할 수 없습니다.");
return;
}
renderPlaylist();
updatePreview();
addLog(`${item.code} DB 선택 적용 · ${item.selection.subject}`);
addLog(`${replacement.code} DB 선택 적용 · ${replacement.selection.subject}`);
showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
}
@@ -7490,12 +7967,24 @@
case "named-comparison-restore-error":
handleNamedComparisonRestoreError(message.payload);
break;
case "named-foreign-stock-restore-results":
handleNamedForeignStockRestoreResults(message.payload);
break;
case "named-foreign-stock-restore-error":
handleNamedForeignStockRestoreError(message.payload);
break;
case "named-foreign-index-candle-restore-results":
handleNamedForeignIndexCandleRestoreResults(message.payload);
break;
case "named-foreign-index-candle-restore-error":
handleNamedForeignIndexCandleRestoreError(message.payload);
break;
case "named-krx-theme-restore-results":
handleNamedKrxThemeRestoreResults(message.payload);
break;
case "named-krx-theme-restore-error":
handleNamedKrxThemeRestoreError(message.payload);
break;
case "named-nxt-theme-restore-results":
handleNamedNxtThemeRestoreResults(message.payload);
break;