Files
MBN_STOCK_WEBVIEW/Web/app.js

8389 lines
375 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
const playoutSafety = globalThis.MbnPlayoutSafety;
if (!playoutSafety) throw new Error("Playout safety module is unavailable.");
const marketNavReorder = globalThis.MbnMarketNavReorder;
if (!marketNavReorder) throw new Error("Market-nav reorder module is unavailable.");
const operatorWorkflow = globalThis.MbnOperatorWorkflow;
if (!operatorWorkflow) throw new Error("Operator workflow module is unavailable.");
const legacyBatchSelectionWorkflow = globalThis.MbnLegacyBatchSelectionWorkflow;
if (!legacyBatchSelectionWorkflow) throw new Error("Legacy batch-selection workflow module is unavailable.");
const fixedWorkflow = globalThis.MbnLegacyFixedWorkflow;
if (!fixedWorkflow) throw new Error("Legacy fixed workflow module is unavailable.");
const industryWorkflow = globalThis.MbnIndustryWorkflow;
if (!industryWorkflow) throw new Error("Legacy industry workflow module is unavailable.");
const comparisonWorkflow = globalThis.MbnComparisonWorkflow;
if (!comparisonWorkflow) throw new Error("Legacy comparison workflow module is unavailable.");
const comparisonImportWorkflow = globalThis.MbnComparisonImportWorkflow;
if (!comparisonImportWorkflow) throw new Error("Legacy comparison import workflow module is unavailable.");
const themeWorkflow = globalThis.MbnThemeWorkflow;
if (!themeWorkflow) throw new Error("Legacy theme workflow module is unavailable.");
const overseasWorkflow = globalThis.MbnOverseasWorkflow;
if (!overseasWorkflow) throw new Error("Legacy overseas workflow module is unavailable.");
const expertWorkflow = globalThis.MbnExpertWorkflow;
if (!expertWorkflow) throw new Error("Legacy expert workflow module is unavailable.");
const tradingHaltWorkflow = globalThis.MbnTradingHaltWorkflow;
if (!tradingHaltWorkflow) throw new Error("Legacy trading-halt workflow module is unavailable.");
const candleOptionsWorkflow = globalThis.MbnCandleOptionsWorkflow;
if (!candleOptionsWorkflow) throw new Error("Global candle-options workflow module is unavailable.");
const manualListsWorkflow = globalThis.MbnManualListsWorkflow;
if (!manualListsWorkflow) throw new Error("Manual lists workflow module is unavailable.");
const manualFinancialWorkflow = globalThis.MbnManualFinancialWorkflow;
if (!manualFinancialWorkflow) throw new Error("Manual financial workflow module is unavailable.");
const namedManualRestoreWorkflow = globalThis.MbnNamedManualRestoreWorkflow;
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;
if (!legacyNamedRowWorkflow) throw new Error("Legacy named-row workflow module is unavailable.");
const operatorCatalogWorkflow = globalThis.MbnOperatorCatalogWorkflow;
if (!operatorCatalogWorkflow) throw new Error("Operator catalog workflow module is unavailable.");
const manualListsUiModule = globalThis.MbnManualListsUi;
if (!manualListsUiModule) throw new Error("Manual lists UI module is unavailable.");
const manualFinancialUiModule = globalThis.MbnManualFinancialUi;
if (!manualFinancialUiModule) throw new Error("Manual financial UI module is unavailable.");
const operatorCatalogUiModule = globalThis.MbnOperatorCatalogUi;
if (!operatorCatalogUiModule) throw new Error("Operator catalog UI module is unavailable.");
const namedPlaylistWorkflow = globalThis.MbnNamedPlaylistWorkflow;
if (!namedPlaylistWorkflow) throw new Error("Named playlist workflow module is unavailable.");
const DEFAULT_FADE_DURATION = 6;
const namedPagedBuilderKeys = new Set(["s5074", "s5077", "s5088"]);
const namedWriteOperations = new Set(["create", "replace", "delete"]);
const namedInvalidBuilderKey = "s0";
const catalog = [
{ builderKey: "s5001", code: "5001", aliases: ["5001", "N5001"], title: "1열판 · 기본", detail: "국내·NXT·해외 현재가", category: "plate", market: "kospi" },
{ builderKey: "s5006", code: "5006", title: "1열판 · 시가 상세", detail: "현재·시가·고가·저가", category: "plate", market: "kosdaq" },
{ builderKey: "s5011", code: "5011", title: "1열판 · 상세", detail: "52주·시총·순위", category: "plate", market: "kospi" },
{ builderKey: "s5016", code: "5016", title: "3열판", detail: "주요 지수 3열 구성", category: "plate", market: "index" },
{ builderKey: "s50160", code: "50160", title: "2열판 · 보조", detail: "원면·금·국채 2열", category: "plate", market: "overseas" },
{ builderKey: "s5023", code: "5023", title: "매매동향 · 표", detail: "지수별 투자자 동향", category: "market", market: "index" },
{ builderKey: "s5024", code: "5024", title: "매매동향 · 막대", detail: "주체별 매매 동향", category: "chart", market: "kospi" },
{ builderKey: "s5025", code: "5025", title: "순매도 상위", detail: "수동 상위 종목 표", category: "market", market: "kospi" },
{ builderKey: "s5026", code: "5026", title: "비교분석 · 캔들", detail: "두 종목 주간 비교", category: "chart", market: "compare" },
{ builderKey: "s5029", code: "5029", title: "수익률 비교", detail: "두 종목 수익률", category: "chart", market: "compare" },
{ builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"], title: "2열판 · 선물", detail: "지수·선물 2열", category: "plate", market: "index" },
{ builderKey: "s5037", code: "5037", title: "거래원", detail: "매수·매도 거래원", category: "market", market: "kospi" },
{ builderKey: "s5074", code: "5074", title: "5단 표", detail: "5개 단위 · 최대 20페이지", category: "plate", market: "kosdaq" },
{ builderKey: "s5076", code: "5076", title: "주요매출 구성", detail: "매출 비중 원그래프", category: "chart", market: "kospi" },
{ builderKey: "s5077", code: "5077", title: "6종목 현재가", detail: "6개 단위 · 최대 20페이지", category: "plate", market: "kospi" },
{ builderKey: "s5078", code: "5078", title: "섹터 지수", detail: "미국·국내 섹터 막대", category: "chart", market: "overseas" },
{ builderKey: "s5079", code: "5079", title: "성장성 지표", detail: "복수 성장 지표 선그래프", category: "chart", market: "kospi" },
{ builderKey: "s5080", code: "5080", title: "매출액 지표", detail: "분기별 매출 막대", category: "chart", market: "kospi" },
{ builderKey: "s5081", code: "5081", title: "영업이익 지표", detail: "분기별 영업이익", category: "chart", market: "kospi" },
{ builderKey: "s5082", code: "5082", title: "주체별 매매동향", detail: "코스피·코스닥·200", category: "market", market: "index" },
{ builderKey: "s5083", code: "5083", title: "주체별 선그래프", detail: "개인·외국인·기관", category: "chart", market: "kospi" },
{ builderKey: "s5084", code: "5084", title: "매매동향 라인", detail: "코스피·코스닥", category: "chart", market: "index" },
{ builderKey: "s5085", code: "5085", title: "프로그램 매매", detail: "구분별 프로그램 금액", category: "market", market: "index" },
{ builderKey: "s5086", code: "5086", title: "수익률 그래프", detail: "지수·종목 기간 수익률", category: "chart", market: "compare" },
{ builderKey: "s50860", code: "50860", title: "라인 그래프", detail: "지수·종목 기간 추이", category: "chart", market: "compare" },
{ builderKey: "s5087", code: "5087", title: "비교분석 · 라인", detail: "두 종목 라인 비교", category: "chart", market: "compare" },
{ builderKey: "s5088", code: "5088", title: "12종목 현재가", detail: "12개 단위 · 최대 20페이지", category: "plate", market: "theme" },
{ builderKey: "s6001", code: "6001", title: "해외증시 이미지", detail: "해외 지수·원자재", category: "market", market: "overseas" },
{ builderKey: "s6067", code: "6067", title: "기관 순매수", detail: "기관 순매수 현황", category: "market", market: "kospi" },
{ builderKey: "s8001", code: "8001", aliases: ["8001", "8002"], title: "네모 그래프", detail: "업종별 등락 큐브", category: "chart", market: "index" },
{ builderKey: "s8003", code: "8003", title: "호가창", detail: "매수·매도 10단 호가", category: "market", market: "kospi" },
{ builderKey: "s8010", code: "8035", aliases: ["8035", "8061", "8040", "8046", "8051", "8056"], title: "캔들 그래프", detail: "8035·8061·8040·8046·8051·8056", category: "chart", market: "index" },
{ builderKey: "s8018", code: "8018", aliases: ["8018", "8032", "5032"], title: "2열판 · 시장", detail: "국내·NXT·해외 2열", category: "plate", market: "index" },
{ builderKey: "s8067", code: "8067", aliases: ["8067", "5068", "5070", "5072"], title: "글로벌 증시 지도", detail: "8067·5068·5070·5072", category: "market", market: "overseas" },
{ builderKey: "s8086", code: "8086", title: "유가·금값", detail: "원본 MainForm 미도달 장면", category: "market", market: "exchange", reachable: false }
];
if (catalog.length !== 35) throw new Error("Legacy scene catalog must contain exactly 35 builders.");
const sceneAliases = item => Array.isArray(item?.aliases) ? item.aliases : [String(item?.code || "")];
const reachableAliases = new Set(catalog
.filter(item => item.reachable !== false)
.flatMap(sceneAliases));
if (reachableAliases.size !== 45) throw new Error("Legacy scene catalog must expose all 45 active aliases.");
const operatorInputBuilderKeys = new Set([
"s5001", "s5006", "s5011", "s5025", "s5026", "s5029", "s5037", "s5074",
"s5076", "s5077", "s5079", "s5080", "s5081", "s5086", "s5087",
"s5088", "s8003", "s8010"
]);
const sceneSelectionDefaults = Object.freeze({
s5001: ["KOSPI", "삼성전자", "", "CURRENT", ""],
s5006: ["KOSPI_STOCK", "삼성전자", "", "CURRENT", "005930"],
s5011: ["KOSPI_STOCK", "삼성전자", "", "Valuation", "005930"],
s5016: ["ALL", "INDEX", "", "UnitedStatesIndices", ""],
s50160: ["ALL", "INDEX", "", "Cotton", ""],
s5023: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", ""],
s5024: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", ""],
s5025: ["INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", ""],
s5026: ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
s5029: ["STOCK", "삼성전자,SK하이닉스", "", "5일", ""],
s5032: ["INDEX", "FUTURES,Kospi", "", "CURRENT", ""],
s5037: ["KOSPI", "삼성전자", "TRADER", "TABLE_GRAPH", ""],
s5074: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
s5076: ["KOSPI", "삼성전자", "", "", ""],
s5077: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
s5078: ["FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", "", ""],
s5079: ["KOSPI", "삼성전자", "GROWTH_METRICS", "", ""],
s5080: ["KOSPI", "삼성전자", "SALES", "", ""],
s5081: ["KOSPI", "삼성전자", "", "", ""],
s5082: ["ALL", "INDEX", "", "", ""],
s5083: ["ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", ""],
s5084: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", ""],
s5085: ["ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", ""],
s5086: ["EXCHANGE_RATE", "원달러", "", "20일", ""],
s50860: ["EXCHANGE_RATE", "원달러", "", "20일", ""],
s5087: ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
s5088: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
s6001: ["FOREIGN_INDEX", "DOW", "", "DowJones", ""],
s6067: ["ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", ""],
s8001: ["ALL", "KOSPI", "", "", ""],
s8003: ["KOSPI", "삼성전자", "ORDER_BOOK", "TABLE_GRAPH", "005930"],
s8010: ["KOSPI_INDEX", "INDEX", "", "PRICE", ""],
s8018: ["INDEX", "Kospi,Kosdaq", "", "CURRENT", ""],
s8067: ["ALL", "INDEX", "", "", ""]
});
if (Object.keys(sceneSelectionDefaults).length !== 34) {
throw new Error("Every reachable legacy builder must have a Web selection preset.");
}
function localDateText() {
const now = new Date();
const year = String(now.getFullYear()).padStart(4, "0");
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function defaultSceneSelection(builderKey) {
const values = sceneSelectionDefaults[builderKey];
if (!values) return undefined;
return {
groupCode: values[0],
subject: values[1],
graphicType: values[2],
subtype: values[3],
dataCode: values[4] === "$TODAY" ? localDateText() : values[4]
};
}
const marketTitles = {
dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽",
index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
compare: "종목 비교", theme: "테마 관리", "foreign-stock": "해외종목 그래픽",
expert: "전문가 추천", halted: "거래 정지"
};
const liveDataViews = new Set(["index", "overseas"]);
const databaseBackedViews = new Set([
"kospi", "kosdaq", "index", "overseas", "theme", "foreign-stock", "expert", "halted"
]);
const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" };
const storageKey = "mbn-stock-webview.playlist.v1";
const namedPlaylistBackupStorageKey = "mbn-stock-webview.playlist.before-db-load.v1";
const comparisonStorageKey = "mbn-stock-webview.comparison-pairs.v1";
// Read only while migrating the former two-key layout. All new commits use
// the single comparisonStorageKey envelope so pair data and import state
// cannot be split by a renderer/process crash.
const comparisonImportMarkerStorageKey = "mbn-stock-webview.comparison-pairs.legacy-import.v1";
const state = {
activeMarket: "overseas",
activeCategory: "all",
stockWorkflowCollapsed: false,
stockCutSelection: legacyBatchSelectionWorkflow.createSelection(operatorWorkflow.stockCuts.length),
fixedWorkflowCollapsed: false,
industryWorkflowCollapsed: false,
fixedSearch: "",
search: "",
playlist: [],
selectedRows: new Set(),
selectionAnchorIndex: -1,
currentIndex: -1,
dragIndex: -1,
candleOptions: {
ma5: true,
ma20: true,
invalidIds: []
},
manualFinancialRestore: {
entries: new Map()
},
namedPlaylist: {
open: false,
status: "idle",
pending: null,
pagePlanPending: null,
pagePlanError: null,
restoration: null,
definitions: [],
selectedProgramCode: "",
suggestedProgramCode: "",
canMutate: false,
writeQuarantined: false,
error: "",
loadedDefinition: null,
guardByEntryId: new Map(),
manualRestoreFinalized: true,
comparisonRestorePending: null,
foreignStockRestorePending: null,
foreignIndexCandleRestorePending: null,
krxThemeRestorePending: null,
nxtThemeRestorePending: null,
selectAfterRefresh: ""
},
playout: {
mode: "unknown",
engineState: "IDLE",
connectionState: "unavailable",
processDetected: false,
connected: false,
lastKtapConnectState: "not-attempted",
ktapConnectAttempted: false,
ktapConnectAccepted: false,
ktapHelloObserved: null,
networkMonitoringRecordExpected: false,
networkMonitoringCheckRequired: false,
commandAvailable: false,
liveTakeInAllowed: false,
nativeOutcomeUnknown: false,
browserCorrelationQuarantined: false,
sessionQuarantined: false,
operationTimeoutMs: 5000,
responseTimeoutMs: 10000,
message: "송출 어댑터 상태를 확인하고 있습니다.",
preparedCode: null,
onAirCode: null,
preparedCue: null,
currentCueIndex: -1,
currentEntryId: null,
builderKey: null,
pageIndex: 0,
pageCount: 0,
pageSize: 0,
itemCount: 0,
currentPageItemCount: 0,
isLastPage: true,
nextKind: "none",
previewFields: [],
refreshActive: false,
refreshNextAt: null,
refreshLastSuccessAt: null,
refreshFaulted: false,
refreshFaultCode: null,
refreshFaultMessage: null,
changedAt: null,
changedAtMs: 0,
latestStatusRequestId: null,
pending: null,
takeInAfterPrepare: false,
error: null,
lastSignature: ""
},
background: {
requestId: null,
enabled: false,
kind: "none",
fileName: "",
canChange: false,
pending: false,
message: "원본 F2/F3 배경 상태를 확인하고 있습니다.",
error: ""
},
database: { sources: [], loading: true, lastSignature: "", latestSequence: 0 },
liveData: {
requestId: null,
view: null,
status: "idle",
tables: [],
retrievedAt: null,
error: "",
timeoutId: null
},
stockSearch: {
requestId: null,
query: "",
status: "idle",
results: [],
selected: null,
totalRowCount: 0,
truncated: false,
error: "",
timeoutId: null
},
industry: {
requestId: null,
market: null,
status: "idle",
results: [],
totalRowCount: 0,
highlighted: null,
pairFirst: null,
pairSecond: null,
search: "",
error: "",
timeoutId: null
},
comparison: {
source: "domestic",
draftFirst: null,
draftSecond: null,
pairList: comparisonWorkflow.deleteAllPairs(),
selectedPairId: null,
legacyImport: {
requestId: null,
status: "idle",
marker: null,
recoveryBlocked: false,
error: "",
timeoutId: null
},
domestic: {
requestId: null,
query: "",
status: "idle",
results: [],
totalRowCount: 0,
error: "",
timeoutId: null
},
world: {
requestId: null,
query: "",
status: "idle",
results: [],
totalRowCount: 0,
error: "",
timeoutId: null
}
},
theme: {
nxtSession: "preMarket",
sort: "INPUT_ORDER",
search: {
requestId: null,
query: "",
status: "idle",
results: [],
totalRowCount: 0,
truncated: false,
error: "",
timeoutId: null
},
selected: null,
preview: {
requestId: null,
status: "idle",
items: [],
totalRowCount: 0,
truncated: false,
error: "",
timeoutId: null,
catalogContext: null
}
},
overseas: {
source: "industry",
industry: {
request: null, status: "idle", results: [], selected: null,
totalRowCount: 0, truncated: false, error: "", timeoutId: null
},
stock: {
request: null, status: "idle", results: [], selected: null,
totalRowCount: 0, truncated: false, error: "", timeoutId: null
}
},
expert: {
search: {
request: null, status: "idle", results: [], totalRowCount: 0,
truncated: false, error: "", timeoutId: null
},
selected: null,
selectable: null,
preview: {
request: null, response: null, status: "idle", items: [],
totalRowCount: 0, truncated: false, error: "", timeoutId: null,
catalogContext: null
}
},
tradingHalt: {
request: null,
status: "idle",
results: [],
selected: null,
sortDirection: null,
totalRowCount: 0,
truncated: false,
error: "",
timeoutId: null
}
};
const elements = {
bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"),
databaseStateDot: document.querySelector("#databaseStateDot"),
databaseSummary: document.querySelector("#databaseSummary"),
databaseSummaryIcon: document.querySelector("#databaseSummaryIcon"),
databaseSummaryDetail: document.querySelector("#databaseSummaryDetail"),
databaseSummaryBadge: document.querySelector("#databaseSummaryBadge"),
runtimeLabel: document.querySelector("#runtimeLabel"), packageLabel: document.querySelector("#packageLabel"),
workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"),
catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"),
catalogList: document.querySelector("#catalogList"), catalogModeBadge: document.querySelector("#catalogModeBadge"),
stockWorkflow: document.querySelector("#stockWorkflow"),
stockSearchForm: document.querySelector("#stockSearchForm"),
stockSearchInput: document.querySelector("#stockSearchInput"),
stockSearchButton: document.querySelector("#stockSearchButton"),
stockSearchBadge: document.querySelector("#stockSearchBadge"),
stockWorkflowToggle: document.querySelector("#stockWorkflowToggle"),
stockSearchState: document.querySelector("#stockSearchState"),
stockSearchResults: document.querySelector("#stockSearchResults"),
selectedStock: document.querySelector("#selectedStock"),
selectedStockName: document.querySelector("#selectedStockName"),
selectedStockMeta: document.querySelector("#selectedStockMeta"),
stockMa5: document.querySelector("#stockMa5"), stockMa20: document.querySelector("#stockMa20"),
stockCutList: document.querySelector("#stockCutList"),
stockCutSelectionCount: document.querySelector("#stockCutSelectionCount"),
stockCutBatchAdd: document.querySelector("#stockCutBatchAdd"),
stockCutSelectionClear: document.querySelector("#stockCutSelectionClear"),
manualStockActions: document.querySelector("#manualStockActions"),
legacyFixedWorkflow: document.querySelector("#legacyFixedWorkflow"),
legacyFixedWorkflowTitle: document.querySelector("#legacyFixedWorkflowTitle"),
legacyFixedCount: document.querySelector("#legacyFixedCount"),
legacyFixedToggle: document.querySelector("#legacyFixedToggle"),
legacyFixedSearch: document.querySelector("#legacyFixedSearch"),
legacyFixedState: document.querySelector("#legacyFixedState"),
legacyFixedSections: document.querySelector("#legacyFixedSections"),
industryWorkflow: document.querySelector("#industryWorkflow"),
industryWorkflowTitle: document.querySelector("#industryWorkflowTitle"),
industryCount: document.querySelector("#industryCount"),
industryRetry: document.querySelector("#industryRetry"),
industryWorkflowToggle: document.querySelector("#industryWorkflowToggle"),
industrySearch: document.querySelector("#industrySearch"),
industryState: document.querySelector("#industryState"),
industryResults: document.querySelector("#industryResults"),
industryPrimary: document.querySelector("#industryPrimary"),
industrySecondary: document.querySelector("#industrySecondary"),
industrySwap: document.querySelector("#industrySwap"),
industryClear: document.querySelector("#industryClear"),
industryCutList: document.querySelector("#industryCutList"),
comparisonWorkflow: document.querySelector("#comparisonWorkflow"),
comparisonImportLegacy: document.querySelector("#comparisonImportLegacy"),
comparisonPairCount: document.querySelector("#comparisonPairCount"),
comparisonSourceTabs: document.querySelector("#comparisonSourceTabs"),
comparisonDomesticPanel: document.querySelector("#comparisonDomesticPanel"),
comparisonDomesticSearchForm: document.querySelector("#comparisonDomesticSearchForm"),
comparisonDomesticSearch: document.querySelector("#comparisonDomesticSearch"),
comparisonDomesticSearchButton: document.querySelector("#comparisonDomesticSearchButton"),
comparisonDomesticState: document.querySelector("#comparisonDomesticState"),
comparisonDomesticResults: document.querySelector("#comparisonDomesticResults"),
comparisonFixedPanel: document.querySelector("#comparisonFixedPanel"),
comparisonFixedTargets: document.querySelector("#comparisonFixedTargets"),
comparisonWorldPanel: document.querySelector("#comparisonWorldPanel"),
comparisonWorldSearchForm: document.querySelector("#comparisonWorldSearchForm"),
comparisonWorldSearch: document.querySelector("#comparisonWorldSearch"),
comparisonWorldSearchButton: document.querySelector("#comparisonWorldSearchButton"),
comparisonWorldState: document.querySelector("#comparisonWorldState"),
comparisonWorldResults: document.querySelector("#comparisonWorldResults"),
comparisonFirst: document.querySelector("#comparisonFirst"),
comparisonSecond: document.querySelector("#comparisonSecond"),
comparisonSwap: document.querySelector("#comparisonSwap"),
comparisonClear: document.querySelector("#comparisonClear"),
comparisonSavePair: document.querySelector("#comparisonSavePair"),
comparisonMoveUp: document.querySelector("#comparisonMoveUp"),
comparisonMoveDown: document.querySelector("#comparisonMoveDown"),
comparisonDeletePair: document.querySelector("#comparisonDeletePair"),
comparisonDeleteAll: document.querySelector("#comparisonDeleteAll"),
comparisonPairList: document.querySelector("#comparisonPairList"),
comparisonYieldBatch: document.querySelector("#comparisonYieldBatch"),
comparisonActionList: document.querySelector("#comparisonActionList"),
themeWorkflow: document.querySelector("#themeWorkflow"),
themeCount: document.querySelector("#themeCount"),
themeSearchForm: document.querySelector("#themeSearchForm"),
themeSearch: document.querySelector("#themeSearch"),
themeNxtSession: document.querySelector("#themeNxtSession"),
themeSearchButton: document.querySelector("#themeSearchButton"),
themeSearchState: document.querySelector("#themeSearchState"),
themeResults: document.querySelector("#themeResults"),
selectedTheme: document.querySelector("#selectedTheme"),
selectedThemeTitle: document.querySelector("#selectedThemeTitle"),
selectedThemeMeta: document.querySelector("#selectedThemeMeta"),
themeSort: document.querySelector("#themeSort"),
themePreviewCount: document.querySelector("#themePreviewCount"),
themePreviewState: document.querySelector("#themePreviewState"),
themePreviewItems: document.querySelector("#themePreviewItems"),
themeActionList: document.querySelector("#themeActionList"),
themeCatalogManageButton: document.querySelector("#themeCatalogManageButton"),
overseasWorkflow: document.querySelector("#overseasWorkflow"),
overseasCount: document.querySelector("#overseasCount"),
overseasSourceTabs: document.querySelector("#overseasSourceTabs"),
overseasSearchForm: document.querySelector("#overseasSearchForm"),
overseasSearch: document.querySelector("#overseasSearch"),
overseasSearchButton: document.querySelector("#overseasSearchButton"),
overseasSearchState: document.querySelector("#overseasSearchState"),
overseasResults: document.querySelector("#overseasResults"),
selectedOverseas: document.querySelector("#selectedOverseas"),
selectedOverseasName: document.querySelector("#selectedOverseasName"),
selectedOverseasMeta: document.querySelector("#selectedOverseasMeta"),
overseasMa5: document.querySelector("#overseasMa5"),
overseasMa20: document.querySelector("#overseasMa20"),
overseasActionList: document.querySelector("#overseasActionList"),
expertWorkflow: document.querySelector("#expertWorkflow"),
expertCount: document.querySelector("#expertCount"),
expertSearchForm: document.querySelector("#expertSearchForm"),
expertSearch: document.querySelector("#expertSearch"),
expertSearchButton: document.querySelector("#expertSearchButton"),
expertSearchState: document.querySelector("#expertSearchState"),
expertResults: document.querySelector("#expertResults"),
selectedExpert: document.querySelector("#selectedExpert"),
selectedExpertName: document.querySelector("#selectedExpertName"),
selectedExpertMeta: document.querySelector("#selectedExpertMeta"),
expertPreviewCount: document.querySelector("#expertPreviewCount"),
expertPreviewState: document.querySelector("#expertPreviewState"),
expertPreviewItems: document.querySelector("#expertPreviewItems"),
expertActionList: document.querySelector("#expertActionList"),
expertCatalogManageButton: document.querySelector("#expertCatalogManageButton"),
tradingHaltWorkflow: document.querySelector("#tradingHaltWorkflow"),
tradingHaltCount: document.querySelector("#tradingHaltCount"),
tradingHaltSearchForm: document.querySelector("#tradingHaltSearchForm"),
tradingHaltSearch: document.querySelector("#tradingHaltSearch"),
tradingHaltSearchButton: document.querySelector("#tradingHaltSearchButton"),
tradingHaltSearchState: document.querySelector("#tradingHaltSearchState"),
tradingHaltNameSort: document.querySelector("#tradingHaltNameSort"),
tradingHaltResults: document.querySelector("#tradingHaltResults"),
selectedTradingHalt: document.querySelector("#selectedTradingHalt"),
selectedTradingHaltName: document.querySelector("#selectedTradingHaltName"),
selectedTradingHaltMeta: document.querySelector("#selectedTradingHaltMeta"),
tradingHaltMa5: document.querySelector("#tradingHaltMa5"),
tradingHaltMa20: document.querySelector("#tradingHaltMa20"),
tradingHaltActionList: document.querySelector("#tradingHaltActionList"),
liveDataPanel: document.querySelector("#liveDataPanel"), liveDataTitle: document.querySelector("#liveDataTitle"),
liveDataState: document.querySelector("#liveDataState"), liveDataTables: document.querySelector("#liveDataTables"),
databaseSources: document.querySelector("#databaseSources"),
playlistBody: document.querySelector("#playlistBody"),
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
toggleAllEnabled: document.querySelector("#toggleAllEnabledButton"),
selectAll: document.querySelector("#selectAllButton"),
moveUpButton: document.querySelector("#moveUpButton"),
moveDownButton: document.querySelector("#moveDownButton"),
deleteButton: document.querySelector("#deleteButton"),
deleteAllButton: document.querySelector("#deleteAllButton"),
globalMa5: document.querySelector("#globalMa5"),
globalMa20: document.querySelector("#globalMa20"),
loadButton: document.querySelector("#loadButton"),
saveButton: document.querySelector("#saveButton"),
namedPlaylistOpenButton: document.querySelector("#namedPlaylistOpenButton"),
namedPlaylistPanel: document.querySelector("#namedPlaylistPanel"),
namedPlaylistStateBadge: document.querySelector("#namedPlaylistStateBadge"),
namedPlaylistRefreshButton: document.querySelector("#namedPlaylistRefreshButton"),
namedPlaylistCloseButton: document.querySelector("#namedPlaylistCloseButton"),
namedPlaylistStatus: document.querySelector("#namedPlaylistStatus"),
namedPlaylistDefinitions: document.querySelector("#namedPlaylistDefinitions"),
namedPlaylistSelectionSummary: document.querySelector("#namedPlaylistSelectionSummary"),
namedPlaylistLoadButton: document.querySelector("#namedPlaylistLoadButton"),
namedPlaylistPageRetryButton: document.querySelector("#namedPlaylistPageRetryButton"),
namedPlaylistRestoreBackupButton: document.querySelector("#namedPlaylistRestoreBackupButton"),
namedPlaylistProgramCode: document.querySelector("#namedPlaylistProgramCode"),
namedPlaylistProgramTitle: document.querySelector("#namedPlaylistProgramTitle"),
namedPlaylistCreateButton: document.querySelector("#namedPlaylistCreateButton"),
namedPlaylistReplaceButton: document.querySelector("#namedPlaylistReplaceButton"),
namedPlaylistDeleteButton: document.querySelector("#namedPlaylistDeleteButton"),
namedPlaylistGuardSummary: document.querySelector("#namedPlaylistGuardSummary"),
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
playoutState: document.querySelector("#playoutState"),
playoutSummary: document.querySelector("#playoutSummary"),
playoutSummaryIcon: document.querySelector("#playoutSummaryIcon"),
playoutSummaryDetail: document.querySelector("#playoutSummaryDetail"),
playoutSummaryBadge: document.querySelector("#playoutSummaryBadge"),
tornadoProcessDot: document.querySelector("#tornadoProcessDot"),
tornadoProcessState: document.querySelector("#tornadoProcessState"),
playoutConnectionDot: document.querySelector("#playoutConnectionDot"),
playoutConnectionState: document.querySelector("#playoutConnectionState"),
playoutSafetyMode: document.querySelector("#playoutSafetyMode"),
backgroundFileName: document.querySelector("#backgroundFileName"),
backgroundStateText: document.querySelector("#backgroundStateText"),
chooseBackgroundButton: document.querySelector("#chooseBackgroundButton"),
toggleBackgroundButton: document.querySelector("#toggleBackgroundButton"),
playoutStatusMessage: document.querySelector("#playoutStatusMessage"),
playoutSceneCode: document.querySelector("#playoutSceneCode"),
playoutEntryId: document.querySelector("#playoutEntryId"),
playoutBuilderKey: document.querySelector("#playoutBuilderKey"),
playoutPageNumber: document.querySelector("#playoutPageNumber"),
playoutPageSize: document.querySelector("#playoutPageSize"),
playoutItemCount: document.querySelector("#playoutItemCount"),
playoutCurrentRows: document.querySelector("#playoutCurrentRows"),
playoutLastPage: document.querySelector("#playoutLastPage"),
playoutNextKind: document.querySelector("#playoutNextKind"),
playoutRefreshState: document.querySelector("#playoutRefreshState"),
sceneDataPreview: document.querySelector("#sceneDataPreview"),
playoutError: document.querySelector("#playoutError"),
playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
sceneSelectionForm: document.querySelector("#sceneSelectionForm"),
sceneGroupCode: document.querySelector("#sceneGroupCode"),
sceneSubject: document.querySelector("#sceneSubject"),
sceneGraphicType: document.querySelector("#sceneGraphicType"),
sceneSubtype: document.querySelector("#sceneSubtype"),
sceneDataCode: document.querySelector("#sceneDataCode"),
sceneCutAlias: document.querySelector("#sceneCutAlias"),
sceneFadeDuration: document.querySelector("#sceneFadeDuration"),
applySceneSelectionButton: document.querySelector("#applySceneSelectionButton"),
prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
eventLog: document.querySelector("#eventLog"),
toast: document.querySelector("#toast")
};
const nativeBridge = window.chrome?.webview;
let manualListsUi = null;
let manualFinancialUi = null;
let operatorCatalogUi = null;
function createId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function operatorUiLocked() {
return isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
state.manualFinancialRestore.entries.size > 0;
}
function selectedStockForManualFinancial() {
const selected = state.stockSearch.selected;
if (!selected) return null;
return Object.freeze({
market: selected.isNxt ? `nxt-${selected.market}` : selected.market,
source: selected.isNxt ? "mariaDb" : "oracle",
name: selected.stockName,
code: selected.stockCode
});
}
function appendTrustedOperatorEntry(entry) {
if (operatorUiLocked()) throw new Error("The operator playlist is locked.");
if (!entry || typeof entry !== "object" || Array.isArray(entry) ||
typeof entry.id !== "string" || state.playlist.some(item => item.id === entry.id)) {
throw new TypeError("A unique trusted operator playlist entry is required.");
}
const source = entry.operator?.source;
if (source === "legacy-manual-lists-workflow") {
if (!manualListsWorkflow.restorePlaylistEntry(entry) ||
!manualListsWorkflow.isPlaylistEntryPlayable(entry)) {
throw new TypeError("The manual-list entry is not fresh-read verified.");
}
} else if (source === "manual-financial-workflow") {
if (!manualFinancialWorkflow.isPlaylistEntryPlayable(entry)) {
throw new TypeError("The manual-financial entry is not native-confirmed.");
}
} else {
throw new TypeError("The operator UI attempted to append an unsupported entry source.");
}
const definition = catalog.find(value => value.builderKey === entry.builderKey &&
sceneAliases(value).includes(String(entry.code || "")));
if (!definition) throw new TypeError("The operator entry does not resolve to a registered scene.");
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
return true;
}
function isPlaylistSnapshotLocked() {
return playoutSafety.playlistSnapshotLocked({
pending: Boolean(state.playout.pending),
outcomeUnknown: state.playout.sessionQuarantined ||
state.playout.nativeOutcomeUnknown ||
state.playout.error?.outcomeUnknown === true,
engineState: state.playout.engineState,
preparedCode: state.playout.preparedCode,
onAirCode: state.playout.onAirCode
});
}
function requirePlaylistEditable(allowFailedManualRestore = false) {
const restoreRecords = [...state.manualFinancialRestore.entries.values()];
const failedRestoreOnly = restoreRecords.length > 0 &&
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 재검증이 끝날 때까지 순서를 변경할 수 없습니다.");
return false;
}
if (!isPlaylistSnapshotLocked()) return true;
showToast("PREPARE 이후에는 TAKE OUT까지 플레이리스트가 잠깁니다.");
return false;
}
function updateClock() {
elements.clock.textContent = new Intl.DateTimeFormat("ko-KR", {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
}).format(new Date());
}
function filteredCatalog() {
const term = state.search.trim().toLocaleLowerCase("ko-KR");
return catalog.filter(item => {
const marketMatches = state.activeMarket === "dashboard" || item.market === state.activeMarket;
const categoryMatches = state.activeCategory === "all" || item.category === state.activeCategory;
const searchMatches = !term || `${item.code} ${item.title} ${item.detail}`.toLocaleLowerCase("ko-KR").includes(term);
return marketMatches && categoryMatches && searchMatches;
});
}
function renderCatalog() {
elements.catalogList.replaceChildren();
const items = filteredCatalog();
if (!items.length) {
const empty = document.createElement("div");
empty.className = "catalog-empty";
empty.textContent = "조건에 맞는 그래픽 항목이 없습니다.";
elements.catalogList.append(empty);
return;
}
for (const item of items) {
const row = document.createElement("article");
row.className = "catalog-item";
const code = document.createElement("span");
code.className = "cut-code";
code.textContent = item.code;
const copy = document.createElement("div");
const title = document.createElement("strong");
const detail = document.createElement("small");
title.textContent = item.title;
detail.textContent = item.detail;
copy.append(title, detail);
const add = document.createElement("button");
add.className = "add-cut";
add.type = "button";
add.disabled = item.reachable === false || isPlaylistSnapshotLocked();
add.title = item.reachable === false ? "원본 MainForm에서 도달하지 않는 장면" : "플레이리스트에 추가";
add.textContent = "+";
add.addEventListener("click", () => addToPlaylist(item));
row.append(code, copy, add);
elements.catalogList.append(row);
}
}
function fixedMarketTitle(market) {
return { overseas: "해외", exchange: "환율", index: "지수" }[market] || "원본";
}
function openFixedManualAction(action) {
if (!action || action.available !== false || operatorUiLocked()) return false;
if (action.builderKey === "s5025") {
manualListsUi?.openNetSell(action.selection?.groupCode);
return true;
}
if (action.builderKey === "s5074" && action.selection?.groupCode === "PAGED_VI") {
manualListsUi?.openVi();
return true;
}
showToast(action.prerequisite || "지원하는 수동 입력 화면을 찾지 못했습니다.");
return false;
}
function addFixedAction(action) {
if (action?.available === false) {
openFixedManualAction(action);
return;
}
if (!action?.available || !requirePlaylistEditable()) {
return;
}
try {
const created = fixedWorkflow.createFixedPlaylistEntry(action.id, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION,
ma5: state.candleOptions.ma5,
ma20: state.candleOptions.ma20
});
const definition = catalog.find(item =>
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
if (!definition) throw new Error("The fixed action does not resolve to a registered scene alias.");
const entry = { ...definition, ...created };
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${entry.code} ${action.section} · ${action.label} 추가`);
showToast(`${action.label} 항목을 플레이리스트에 추가했습니다.`);
} catch {
showToast("원본 운영 항목 매핑을 확인할 수 없어 추가하지 않았습니다.");
}
}
function addFixedActions(sectionActions) {
if (!Array.isArray(sectionActions) || !sectionActions.length) {
showToast("일괄 추가할 원본 운영 항목이 없습니다.");
return;
}
if (sectionActions.some(action => action?.available === false)) {
showToast("이 섹션에는 수동 입력이 필요한 항목이 있어 아무것도 추가하지 않았습니다. 수동 항목은 개별 입력 버튼으로 먼저 확정하세요.");
return;
}
if (sectionActions.some(action => !action?.available) || !requirePlaylistEditable()) {
return;
}
try {
const entries = legacyBatchSelectionWorkflow.materializeAtomicBatch(
sectionActions,
action => {
const created = fixedWorkflow.createFixedPlaylistEntry(action.id, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION,
ma5: state.candleOptions.ma5,
ma20: state.candleOptions.ma20
});
const definition = catalog.find(item =>
item.builderKey === created.builderKey &&
sceneAliases(item).includes(created.code));
if (!definition) {
throw new Error("A fixed batch item does not resolve to a registered scene alias.");
}
return { ...definition, ...created, enabled: true };
});
state.playlist.push(...entries);
state.selectedRows.clear();
entries.forEach(entry => state.selectedRows.add(entry.id));
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${sectionActions[0].section} 섹션 전체 추가 · ${entries.length}개 · 원본 자식 순서`);
showToast(`${sectionActions[0].section} 항목 ${entries.length}개를 순서대로 추가했습니다.`);
} catch {
showToast("섹션의 모든 원본 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
}
}
function renderFixedWorkflow() {
const visible = fixedWorkflow.fixedMarkets.includes(state.activeMarket);
elements.legacyFixedWorkflow.hidden = !visible;
if (!visible) return;
const allActions = fixedWorkflow.actionsForMarket(state.activeMarket);
const search = state.fixedSearch.trim().toLocaleLowerCase("ko-KR");
const actions = allActions.filter(action => !search ||
`${action.section} ${action.label} ${action.code} ${action.builderKey}`
.toLocaleLowerCase("ko-KR").includes(search));
const available = allActions.filter(action => action.available).length;
const manual = allActions.length - available;
elements.legacyFixedWorkflow.classList.toggle("collapsed", state.fixedWorkflowCollapsed);
elements.legacyFixedToggle.textContent = state.fixedWorkflowCollapsed ? "펼치기" : "접기";
elements.legacyFixedToggle.setAttribute("aria-expanded", String(!state.fixedWorkflowCollapsed));
elements.legacyFixedWorkflowTitle.textContent = `${fixedMarketTitle(state.activeMarket)} 원본 운영 항목`;
elements.legacyFixedCount.textContent = `${allActions.length} ACTIONS`;
elements.legacyFixedState.textContent = search
? `${actions.length}개 검색 결과 · 원본 실행본 SHA 고정`
: `${available}개 즉시 사용${manual ? ` · 수동 전제 ${manual}` : ""}`;
elements.legacyFixedSections.replaceChildren();
if (!actions.length) {
const empty = document.createElement("div");
empty.className = "catalog-empty";
empty.textContent = "조건에 맞는 원본 운영 항목이 없습니다.";
elements.legacyFixedSections.append(empty);
return;
}
const sections = new Map();
for (const action of actions) {
if (!sections.has(action.section)) sections.set(action.section, []);
sections.get(action.section).push(action);
}
for (const [sectionName, sectionActions] of sections) {
const section = document.createElement("section");
section.className = "legacy-fixed-section";
const header = document.createElement("header");
const title = document.createElement("strong");
const count = document.createElement("span");
const tools = document.createElement("div");
const addAll = document.createElement("button");
title.textContent = sectionName;
count.textContent = `${sectionActions.length}`;
tools.className = "legacy-fixed-section-tools";
addAll.className = "legacy-fixed-batch-add";
addAll.type = "button";
addAll.textContent = "전체 추가";
addAll.disabled = operatorUiLocked();
addAll.title = sectionActions.some(action => action.available === false)
? "수동 입력 항목이 포함되어 있으며 전체 배치는 부분 추가 없이 차단됩니다."
: "현재 표시된 섹션 자식을 원본 순서대로 추가합니다.";
addAll.addEventListener("click", event => {
event.stopPropagation();
addFixedActions(sectionActions);
});
header.addEventListener("dblclick", event => {
if (event.target.closest("button")) return;
addFixedActions(sectionActions);
});
tools.append(count, addAll);
header.append(title, tools);
section.append(header);
for (const action of sectionActions) {
const row = document.createElement("article");
row.className = `legacy-fixed-row${action.available ? "" : " unavailable"}`;
const number = document.createElement("span");
number.className = "legacy-fixed-number";
number.textContent = action.id.slice(-3);
const copy = document.createElement("div");
copy.className = "legacy-fixed-copy";
const label = document.createElement("strong");
const meta = document.createElement("small");
label.textContent = action.label;
meta.textContent = action.available
? `${action.code} · ${action.builderKey}`
: `수동 입력 필요 · ${action.builderKey}`;
copy.append(label, meta);
const add = document.createElement("button");
add.className = "legacy-fixed-add";
add.type = "button";
add.textContent = action.available ? "+" : "입력";
add.disabled = operatorUiLocked();
add.title = action.available ? "플레이리스트에 추가" : "원본 수동 입력 화면 열기";
add.addEventListener("click", () => addFixedAction(action));
row.addEventListener("dblclick", event => {
if (event.target.closest("button")) return;
addFixedAction(action);
});
row.append(number, copy, add);
section.append(row);
}
elements.legacyFixedSections.append(section);
}
}
function synchronizeFixedEntries(showFeedback) {
let changed = false;
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-fixed-workflow") return item;
const refreshed = fixedWorkflow.refreshFixedPlaylistEntry(item);
if (!refreshed) {
invalid = true;
return item;
}
const definition = catalog.find(value =>
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
if (!definition) {
invalid = true;
return item;
}
if (refreshed.selection.dataCode !== item.selection?.dataCode ||
refreshed.selection.graphicType !== item.selection?.graphicType) changed = true;
return { ...definition, ...refreshed };
});
if (invalid) {
showToast("저장된 원본 운영 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
return false;
}
if (changed) {
renderPlaylist();
updatePreview();
if (showFeedback) showToast("날짜와 NXT 세션을 현재 운영 시점으로 갱신했습니다.");
}
return true;
}
function clearIndustryTimeout() {
if (state.industry.timeoutId !== null) clearTimeout(state.industry.timeoutId);
state.industry.timeoutId = null;
}
function industryIdentity(industry) {
if (!industry) return "";
return [industry.market, industry.code, industry.name].join("|");
}
function reconcileIndustrySelection(value, results) {
const identity = industryIdentity(value);
return identity ? results.find(item => industryIdentity(item) === identity) || null : null;
}
function selectIndustry(industry) {
state.industry.highlighted = industry;
renderIndustryWorkflow();
addLog(`업종 선택 · ${industry.name} · ${industry.code}`);
}
function shiftIndustryPair(industry) {
state.industry.pairSecond = state.industry.pairFirst;
state.industry.pairFirst = industry;
renderIndustryWorkflow();
addLog(`업종 비교 선택 · ${industry.name}`);
}
function addIndustryCut(cutId) {
if (!requirePlaylistEditable()) return;
const industry = state.industry;
if (!industryWorkflow.isCutAllowed(
state.activeMarket,
cutId,
industry.highlighted,
industry.pairFirst,
industry.pairSecond)) {
showToast(cutId === "two-column"
? "업종 목록에서 두 업종을 차례로 더블클릭하세요."
: "업종 목록에서 한 업종을 선택하세요.");
return;
}
try {
const created = industryWorkflow.createIndustryPlaylistEntry(state.activeMarket, cutId, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION,
selected: industry.highlighted,
pair: [industry.pairFirst, industry.pairSecond],
ma5: state.candleOptions.ma5,
ma20: state.candleOptions.ma20
});
const definition = catalog.find(item =>
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
if (!definition) throw new Error("The industry action does not resolve to a registered scene alias.");
const entry = { ...definition, ...created };
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
showToast(`${entry.detail} 항목을 플레이리스트에 추가했습니다.`);
} catch {
showToast("선택한 업종과 컷을 안전한 장면으로 구성하지 못했습니다.");
}
}
function renderIndustryWorkflow() {
const visible = industryWorkflow.markets.includes(state.activeMarket);
elements.industryWorkflow.hidden = !visible;
if (!visible) return;
const industry = state.industry;
const marketLabel = state.activeMarket === "kospi" ? "코스피" : "코스닥";
const playlistLocked = isPlaylistSnapshotLocked();
elements.industryWorkflow.classList.toggle("collapsed", state.industryWorkflowCollapsed);
elements.industryWorkflowToggle.textContent = state.industryWorkflowCollapsed ? "펼치기" : "접기";
elements.industryWorkflowToggle.setAttribute("aria-expanded", String(!state.industryWorkflowCollapsed));
elements.industryWorkflowTitle.textContent = `${marketLabel} 업종 선택`;
elements.industryCount.textContent = industry.status === "loading"
? "LOADING"
: `${industry.totalRowCount.toLocaleString("ko-KR")} DB`;
elements.industryRetry.disabled = industry.status === "loading";
elements.industryState.className = `industry-state ${industry.status}`;
if (industry.status === "loading") {
elements.industryState.textContent = `${marketLabel} 업종 목록을 Oracle에서 조회하고 있습니다.`;
} else if (industry.status === "error") {
elements.industryState.textContent = industry.error || "업종 목록을 불러오지 못했습니다.";
} else if (industry.status === "ready") {
elements.industryState.textContent = `${industry.totalRowCount.toLocaleString("ko-KR")}개 업종 · 클릭은 단일 선택, 더블클릭은 비교 선택`;
} else {
elements.industryState.textContent = "업종 목록을 조회할 준비가 되었습니다.";
}
const term = industry.search.trim().toLocaleLowerCase("ko-KR");
const results = industry.results.filter(item => !term ||
`${item.name} ${item.code}`.toLocaleLowerCase("ko-KR").includes(term));
elements.industryResults.replaceChildren();
if (!results.length) {
const empty = document.createElement("div");
empty.className = "industry-results-empty";
empty.textContent = industry.status === "ready" ? "일치하는 업종이 없습니다." : "업종 목록이 여기에 표시됩니다.";
elements.industryResults.append(empty);
} else {
const selectedIdentity = industryIdentity(industry.highlighted);
for (const item of results) {
const row = document.createElement("button");
row.type = "button";
row.className = "industry-result";
row.setAttribute("role", "option");
const selected = industryIdentity(item) === selectedIdentity;
row.classList.toggle("selected", selected);
row.setAttribute("aria-selected", selected ? "true" : "false");
const name = document.createElement("strong");
const code = document.createElement("small");
name.textContent = item.name;
code.textContent = item.code;
row.append(name, code);
row.addEventListener("click", () => selectIndustry(item));
row.addEventListener("dblclick", () => shiftIndustryPair(item));
elements.industryResults.append(row);
}
}
elements.industryPrimary.textContent = industry.pairFirst?.name || "선택 없음";
elements.industrySecondary.textContent = industry.pairSecond?.name || "선택 없음";
elements.industrySwap.disabled = playlistLocked || !industry.pairFirst || !industry.pairSecond;
elements.industryClear.disabled = playlistLocked ||
(!industry.highlighted && !industry.pairFirst && !industry.pairSecond);
elements.industryCutList.replaceChildren();
let previousSection = null;
industryWorkflow.cuts.forEach((cut, index) => {
if (cut.section !== previousSection) {
const section = document.createElement("div");
section.className = "industry-cut-section";
section.textContent = cut.section;
elements.industryCutList.append(section);
previousSection = cut.section;
}
const allowed = industryWorkflow.isCutAllowed(
state.activeMarket, cut.id, industry.highlighted, industry.pairFirst, industry.pairSecond);
const row = document.createElement("div");
row.className = "industry-cut-row";
row.classList.toggle("disabled", !allowed);
const number = document.createElement("span");
number.className = "industry-cut-number";
number.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
copy.className = "industry-cut-copy";
const label = document.createElement("strong");
const detail = document.createElement("small");
label.textContent = cut.label;
detail.textContent = `${cut.builderKey} · ${cut.kind === "fixed" ? "시장 전체" : (cut.kind === "pair" ? "두 업종" : "선택 업종")}`;
copy.append(label, detail);
const add = document.createElement("button");
add.type = "button";
add.className = "industry-cut-add";
add.textContent = "+";
add.disabled = !allowed || playlistLocked;
add.title = allowed ? "플레이리스트에 추가" : (cut.kind === "pair" ? "두 업종을 더블클릭해 선택하세요." : "업종을 선택하세요.");
add.addEventListener("click", () => addIndustryCut(cut.id));
row.addEventListener("dblclick", event => {
if (!event.target.closest("button") && allowed) addIndustryCut(cut.id);
});
row.append(number, copy, add);
elements.industryCutList.append(row);
});
}
function requestIndustries(market = state.activeMarket) {
clearIndustryTimeout();
if (!industryWorkflow.markets.includes(market)) return;
const changingMarket = state.industry.market !== market;
if (changingMarket) {
state.industry.highlighted = null;
state.industry.pairFirst = null;
state.industry.pairSecond = null;
state.industry.search = "";
elements.industrySearch.value = "";
}
const requestId = createId();
state.industry.requestId = requestId;
state.industry.market = market;
state.industry.status = "loading";
state.industry.results = [];
state.industry.totalRowCount = 0;
state.industry.error = "";
renderIndustryWorkflow();
if (!nativeBridge) {
state.industry.status = "error";
state.industry.error = "브라우저 미리보기에서는 실제 업종 DB 조회를 사용할 수 없습니다.";
renderIndustryWorkflow();
return;
}
postNative("request-industries", { requestId, market });
state.industry.timeoutId = setTimeout(() => {
if (state.industry.requestId !== requestId || state.industry.status !== "loading") return;
state.industry.status = "error";
state.industry.error = "업종 조회 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 조회하세요.";
renderIndustryWorkflow();
addLog(`${marketLabelForIndustry(market)} 업종 조회 시간 초과`);
}, 30000);
}
function marketLabelForIndustry(market) {
return market === "kospi" ? "코스피" : "코스닥";
}
function failIndustryResponse(message) {
clearIndustryTimeout();
state.industry.status = "error";
state.industry.results = [];
state.industry.totalRowCount = 0;
state.industry.error = message;
renderIndustryWorkflow();
}
function handleIndustryResults(payload) {
if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return;
const rawResults = Array.isArray(payload.results) ? payload.results : null;
if (!rawResults || rawResults.length > 500 || !Number.isInteger(payload.totalRowCount) ||
payload.totalRowCount !== rawResults.length) {
failIndustryResponse("업종 목록 응답 형식이 올바르지 않습니다.");
return;
}
const results = [];
const names = new Set();
const codes = new Set();
for (const value of rawResults) {
const normalized = industryWorkflow.normalizeIndustry(value);
if (!normalized || normalized.market !== state.industry.market ||
names.has(normalized.name) || codes.has(normalized.code)) {
failIndustryResponse("업종 목록에 올바르지 않거나 중복된 항목이 있습니다.");
return;
}
names.add(normalized.name);
codes.add(normalized.code);
results.push(normalized);
}
clearIndustryTimeout();
state.industry.results = results;
state.industry.totalRowCount = results.length;
state.industry.highlighted = reconcileIndustrySelection(state.industry.highlighted, results);
state.industry.pairFirst = reconcileIndustrySelection(state.industry.pairFirst, results);
state.industry.pairSecond = reconcileIndustrySelection(state.industry.pairSecond, results);
state.industry.status = "ready";
state.industry.error = "";
renderIndustryWorkflow();
addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 완료 · ${results.length}`);
}
function handleIndustryError(payload) {
if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return;
failIndustryResponse(typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "업종 목록을 불러오지 못했습니다.");
addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 실패`);
}
function synchronizeIndustryEntries(showFeedback) {
let changed = false;
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-industry-workflow") return item;
const refreshed = industryWorkflow.refreshIndustryPlaylistEntry(item);
if (!refreshed) {
invalid = true;
return item;
}
const definition = catalog.find(value =>
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
if (!definition) {
invalid = true;
return item;
}
if (JSON.stringify(refreshed.selection) !== JSON.stringify(item.selection)) changed = true;
return { ...definition, ...refreshed };
});
if (invalid) {
showToast("저장된 업종 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
return false;
}
if (changed) {
renderPlaylist();
updatePreview();
if (showFeedback) showToast("업종 페이지 날짜를 현재 운영 시점으로 갱신했습니다.");
}
return true;
}
function clearComparisonSearchTimeout(kind) {
const search = state.comparison[kind];
if (search?.timeoutId !== null) clearTimeout(search.timeoutId);
if (search) search.timeoutId = null;
}
function comparisonTargetDisplay(target) {
return target?.displayName || target?.stockName || target?.inputName || target?.target || "선택 없음";
}
function comparisonTargetMeta(target) {
if (!target) return "";
if (target.kind === "market-target") return target.target;
if (target.kind === "world-stock") return `${target.nation} · ${target.symbol}`;
return `${target.market.toLocaleUpperCase("en-US")}${target.kind === "nxt-stock" ? " NXT" : " KRX"} · ${target.stockCode}`;
}
function comparisonTargetFromStockResult(value) {
const stock = operatorWorkflow.normalizeStockResult(value);
if (!stock) return null;
return comparisonWorkflow.normalizeTarget({
kind: stock.isNxt ? "nxt-stock" : "krx-stock",
market: stock.market,
stockName: stock.stockName,
stockCode: stock.stockCode,
displayName: stock.displayName
});
}
function comparisonTargetFromWorldResult(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return comparisonWorkflow.normalizeTarget({
kind: "world-stock",
inputName: value.inputName,
displayName: value.inputName,
symbol: value.symbol,
nation: value.nationCode
});
}
function rotateComparisonDraft(target) {
if (isPlaylistSnapshotLocked()) return;
const rotated = comparisonWorkflow.rotatePair(
[state.comparison.draftFirst, state.comparison.draftSecond],
target);
if (!rotated) return;
[state.comparison.draftFirst, state.comparison.draftSecond] = rotated;
renderComparisonWorkflow();
addLog(`비교 대상 선택 · ${comparisonTargetDisplay(target)}`);
}
function selectedComparisonRecord() {
return state.comparison.pairList.pairs.find(record =>
record.id === state.comparison.selectedPairId) || null;
}
function selectedComparisonPair() {
return selectedComparisonRecord()?.pair || null;
}
function persistComparisonPairList(nextList, successMessage) {
try {
const envelope = comparisonImportWorkflow.createStorageEnvelope(
nextList,
state.comparison.legacyImport.marker);
if (!envelope) throw new Error("The comparison storage envelope is invalid.");
localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope));
state.comparison.pairList = nextList;
if (successMessage) addLog(successMessage);
return true;
} catch {
showToast("비교쌍 로컬 저장소를 갱신하지 못했습니다.");
return false;
}
}
function loadComparisonPairList() {
const operation = state.comparison.legacyImport;
try {
const serialized = localStorage.getItem(comparisonStorageKey);
const raw = JSON.parse(serialized || "null");
let envelope = comparisonImportWorkflow.normalizeStorageEnvelope(raw);
if (!envelope && raw !== null) {
const oldMarker = comparisonImportWorkflow.normalizeMarker(JSON.parse(
localStorage.getItem(comparisonImportMarkerStorageKey) || "null"));
envelope = comparisonImportWorkflow.createStorageEnvelope(raw, oldMarker);
if (!envelope) throw new Error("A split legacy comparison import was detected.");
localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope));
localStorage.removeItem(comparisonImportMarkerStorageKey);
}
if (!envelope) {
envelope = comparisonImportWorkflow.createStorageEnvelope(
comparisonWorkflow.deleteAllPairs(),
null);
}
if (!envelope) throw new Error("The comparison storage envelope is invalid.");
state.comparison.pairList = envelope.pairList;
operation.marker = envelope.legacyImportMarker;
operation.status = operation.marker ? "imported" : "idle";
operation.recoveryBlocked = false;
operation.error = "";
state.comparison.selectedPairId = state.comparison.pairList.pairs[0]?.id || null;
} catch {
state.comparison.pairList = comparisonWorkflow.deleteAllPairs();
state.comparison.selectedPairId = null;
operation.marker = null;
operation.status = "error";
operation.recoveryBlocked = true;
operation.error = "비교쌍과 1회 가져오기 표식이 분리되어 안전하게 복구할 수 없습니다. 로컬 상태를 확인하기 전에는 가져오기를 다시 실행하지 않습니다.";
}
}
function clearComparisonImportTimeout() {
const operation = state.comparison.legacyImport;
if (operation.timeoutId !== null) clearTimeout(operation.timeoutId);
operation.timeoutId = null;
}
function persistComparisonLegacyImport(nextList, marker) {
try {
const envelope = comparisonImportWorkflow.createStorageEnvelope(nextList, marker);
if (!envelope) return false;
localStorage.setItem(comparisonStorageKey, JSON.stringify(envelope));
} catch {
return false;
}
state.comparison.pairList = nextList;
state.comparison.legacyImport.marker = marker;
state.comparison.legacyImport.status = "imported";
state.comparison.legacyImport.recoveryBlocked = false;
return true;
}
function requestLegacyComparisonImport() {
const operation = state.comparison.legacyImport;
if (isPlaylistSnapshotLocked() || operation.status === "loading" ||
operation.recoveryBlocked) return;
if (operation.marker) {
showToast("원본 비교쌍은 이미 한 번 안전하게 가져왔습니다.");
return;
}
if (!nativeBridge) {
operation.status = "error";
operation.error = "브라우저 미리보기에서는 원본 파일과 실제 DB를 검증할 수 없습니다.";
renderComparisonWorkflow();
showToast(operation.error);
return;
}
if (!window.confirm(
"원본 종목비교.dat를 한 번 읽고, 모든 종목을 현재 DB에서 재검증한 뒤 로컬 비교쌍으로 가져올까요?")) return;
clearComparisonImportTimeout();
const requestId = createId();
Object.assign(operation, {
requestId,
status: "loading",
error: ""
});
renderComparisonWorkflow();
postNative("import-legacy-comparison-pairs", { requestId });
operation.timeoutId = setTimeout(() => {
if (operation.requestId !== requestId || operation.status !== "loading") return;
operation.status = "error";
operation.error = "원본 비교쌍 검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.";
renderComparisonWorkflow();
addLog("원본 비교쌍 가져오기 시간 초과 · 자동 재시도 없음");
showToast(operation.error);
}, 60000);
}
function handleLegacyComparisonImportResults(payload) {
const operation = state.comparison.legacyImport;
if (operation.status !== "loading" || payload?.requestId !== operation.requestId) return;
clearComparisonImportTimeout();
const imported = comparisonImportWorkflow.normalizeImportResponse(
payload,
operation.requestId);
if (!imported) {
operation.status = "error";
operation.error = "Native가 반환한 원본 비교쌍 형식 또는 상관관계가 올바르지 않습니다.";
renderComparisonWorkflow();
showToast(operation.error);
return;
}
let merged;
try {
merged = comparisonImportWorkflow.mergeImport(
state.comparison.pairList,
imported);
} catch {
operation.status = "error";
operation.error = "기존 비교쌍과 중복되거나 저장 한도를 초과해 전체 가져오기를 취소했습니다.";
renderComparisonWorkflow();
showToast(operation.error);
return;
}
const previousCount = state.comparison.pairList.pairs.length;
const marker = comparisonImportWorkflow.createMarker(imported);
if (!marker || !persistComparisonLegacyImport(merged, marker)) {
operation.status = "error";
operation.error = "비교쌍 로컬 저장소를 갱신하지 못해 전체 가져오기를 취소했습니다.";
renderComparisonWorkflow();
showToast(operation.error);
return;
}
try {
state.comparison.selectedPairId = merged.pairs[previousCount]?.id ||
merged.pairs[0]?.id || null;
operation.requestId = null;
operation.error = "";
renderComparisonWorkflow();
addLog(`원본 비교쌍 가져오기 완료 · ${imported.sourceRowCount.toLocaleString("ko-KR")}쌍 · 현재 DB 재검증`);
showToast(`원본 비교쌍 ${imported.sourceRowCount.toLocaleString("ko-KR")}개를 로컬 저장소로 가져왔습니다.`);
} catch {
// Persistence already completed; rendering/logging failures must not
// roll back or reopen the one-time import gate.
}
}
function handleLegacyComparisonImportError(payload) {
const operation = state.comparison.legacyImport;
if (operation.status !== "loading" || payload?.requestId !== operation.requestId) return;
clearComparisonImportTimeout();
const message = typeof payload.message === "string" && payload.message.trim().length <= 512 &&
!/[\u0000-\u001f\u007f]/u.test(payload.message)
? payload.message.trim()
: "원본 비교쌍 가져오기를 완료하지 못했습니다.";
operation.status = "error";
operation.error = message;
renderComparisonWorkflow();
addLog(`원본 비교쌍 가져오기 실패 · ${message}`);
showToast(message);
}
function saveComparisonDraftPair() {
if (isPlaylistSnapshotLocked()) return;
const pair = comparisonWorkflow.normalizePair([
state.comparison.draftFirst,
state.comparison.draftSecond
]);
if (!pair) {
showToast("비교 대상 두 개를 더블클릭해 선택하세요.");
return;
}
const id = createId();
const record = comparisonWorkflow.createPairRecord(id, pair);
if (!record) {
showToast("비교쌍 식별자를 안전하게 구성하지 못했습니다.");
return;
}
try {
const next = comparisonWorkflow.appendPair(state.comparison.pairList, record);
if (!persistComparisonPairList(next, `비교쌍 저장 · ${comparisonWorkflow.pairLabel(pair)}`)) return;
state.comparison.selectedPairId = id;
renderComparisonWorkflow();
showToast("비교쌍을 저장하고 선택했습니다.");
} catch {
showToast("비교쌍 저장 한도 또는 형식을 확인하세요.");
}
}
function moveComparisonPair(direction) {
if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return;
const next = comparisonWorkflow.reorderPairList(
state.comparison.pairList,
state.comparison.selectedPairId,
direction);
if (next === state.comparison.pairList ||
next.pairs.map(value => value.id).join("|") === state.comparison.pairList.pairs.map(value => value.id).join("|")) return;
if (persistComparisonPairList(next, "비교쌍 순서 변경")) renderComparisonWorkflow();
}
function deleteSelectedComparisonPair() {
if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return;
const previousId = state.comparison.selectedPairId;
const index = state.comparison.pairList.pairs.findIndex(record => record.id === previousId);
const next = comparisonWorkflow.deletePair(state.comparison.pairList, previousId);
if (!persistComparisonPairList(next, "비교쌍 삭제")) return;
state.comparison.selectedPairId = next.pairs[Math.min(Math.max(index, 0), next.pairs.length - 1)]?.id || null;
renderComparisonWorkflow();
}
function deleteAllComparisonPairs() {
if (isPlaylistSnapshotLocked() || !state.comparison.pairList.pairs.length) return;
if (!window.confirm("저장된 비교쌍을 모두 삭제할까요?")) return;
const next = comparisonWorkflow.deleteAllPairs();
if (!persistComparisonPairList(next, "비교쌍 전체 삭제")) return;
state.comparison.selectedPairId = null;
renderComparisonWorkflow();
}
function appendComparisonPlaylistEntries(createdEntries) {
if (!requirePlaylistEditable()) return false;
const entries = [];
for (const created of createdEntries) {
const definition = catalog.find(item =>
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
if (!definition) {
showToast("비교 컷이 등록된 장면 alias와 일치하지 않습니다.");
return false;
}
entries.push({ ...definition, ...created });
}
const startIndex = state.playlist.length;
state.playlist.push(...entries);
state.selectedRows.clear();
entries.forEach(entry => state.selectedRows.add(entry.id));
state.currentIndex = startIndex;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`비교 컷 ${entries.length}개 추가 · ${entries[0]?.title || "비교쌍"}`);
showToast(entries.length === 1 ? "비교 컷을 추가했습니다." : `비교 수익률 컷 ${entries.length}개를 추가했습니다.`);
return true;
}
function addComparisonAction(actionId) {
const pair = selectedComparisonPair();
if (!pair) {
showToast("저장 비교쌍에서 사용할 행을 선택하세요.");
return;
}
const compatibility = comparisonWorkflow.getCompatibility(actionId, pair);
if (!compatibility.supported) {
showToast(comparisonCompatibilityText(compatibility.reason));
return;
}
try {
const entry = comparisonWorkflow.createComparisonPlaylistEntry(actionId, pair, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION
});
if (appendComparisonPlaylistEntries([entry]) && entry.warning) showToast(entry.warning);
} catch {
showToast("선택한 비교쌍과 컷을 안전한 장면으로 구성하지 못했습니다.");
}
}
function addComparisonYieldBatch() {
const pair = selectedComparisonPair();
if (!pair || !comparisonWorkflow.yieldActionIds.every(actionId =>
comparisonWorkflow.isActionAllowed(actionId, pair))) {
showToast("수익률 비교 5종은 국내 KRX 종목 두 개가 필요합니다.");
return;
}
try {
const entries = comparisonWorkflow.createYieldBatchEntries(pair, {
idPrefix: createId(),
fadeDuration: DEFAULT_FADE_DURATION
});
appendComparisonPlaylistEntries(entries);
} catch {
showToast("수익률 비교 5종을 안전하게 구성하지 못했습니다.");
}
}
function comparisonCompatibilityText(reason) {
return {
"pair-incomplete-or-invalid": "저장 비교쌍을 선택하세요.",
"comparison-graphs-require-two-krx-stocks": "비교 차트와 수익률은 국내 KRX 종목 두 개만 지원합니다.",
"two-futures-targets-are-unsupported": "선물 지수 두 개의 조합은 지원하지 않습니다.",
"futures-requires-a-market-target-counterpart": "선물은 다른 시장 지수와만 2열판으로 비교할 수 있습니다.",
"mixed-market-and-world-is-unsupported": "시장 지수와 해외 종목의 혼합 2열판은 안전한 DTO가 없어 지원하지 않습니다.",
"expected-mode-supports-only-krx-stocks-and-four-domestic-indices": "예상체결은 국내 KRX 종목과 국내 4개 지수만 지원합니다."
}[reason] || "현재 Core에서 안전하게 지원하지 않는 비교 조합입니다.";
}
function renderComparisonTargetList(container, values, emptyText) {
container.replaceChildren();
if (!values.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = emptyText;
container.append(empty);
return;
}
const locked = isPlaylistSnapshotLocked();
for (const target of values) {
const button = document.createElement("button");
button.type = "button";
button.className = "comparison-target";
button.disabled = locked;
button.title = "더블클릭하면 첫 번째 슬롯에 넣고 기존 첫 번째를 두 번째로 이동합니다.";
const name = document.createElement("strong");
const meta = document.createElement("small");
name.textContent = comparisonTargetDisplay(target);
meta.textContent = comparisonTargetMeta(target);
button.append(name, meta);
button.addEventListener("dblclick", () => rotateComparisonDraft(target));
button.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
rotateComparisonDraft(target);
}
});
container.append(button);
}
}
function renderComparisonSearchState(kind, element) {
const search = state.comparison[kind];
element.className = `comparison-search-state ${search.status}`;
if (search.status === "loading") element.textContent = `${search.query}” 검색 중입니다.`;
else if (search.status === "error") element.textContent = search.error || "검색에 실패했습니다.";
else if (search.status === "ready") {
element.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}건 · 대상을 더블클릭하세요.`;
} else {
element.textContent = kind === "domestic" ? "국내·NXT 종목명을 입력하세요." : "미국·대만 종목명을 입력하세요.";
}
}
function renderComparisonWorkflow() {
const visible = state.activeMarket === "compare";
elements.comparisonWorkflow.hidden = !visible;
if (!visible) return;
const comparison = state.comparison;
const locked = isPlaylistSnapshotLocked();
const pairs = comparison.pairList.pairs;
const selectedRecord = selectedComparisonRecord();
const selectedPair = selectedRecord?.pair || null;
elements.comparisonPairCount.textContent = `${pairs.length} PAIRS`;
const legacyImport = comparison.legacyImport;
elements.comparisonImportLegacy.classList.toggle("imported", Boolean(legacyImport.marker));
elements.comparisonImportLegacy.disabled = locked || legacyImport.status === "loading" ||
legacyImport.recoveryBlocked || Boolean(legacyImport.marker);
elements.comparisonImportLegacy.textContent = legacyImport.marker
? `원본 ${legacyImport.marker.importedPairCount}쌍 가져옴`
: (legacyImport.recoveryBlocked
? "원본 가져오기 복구 필요"
: (legacyImport.status === "loading"
? "원본 검증 중…"
: (legacyImport.status === "error" ? "원본 가져오기 재시도" : "원본 비교쌍 가져오기")));
elements.comparisonImportLegacy.title = legacyImport.marker
? "원본 파일은 다시 참조하지 않으며 현재 로컬 비교쌍만 사용합니다."
: (legacyImport.error || "고정된 원본 종목비교.dat를 한 번만 가져옵니다.");
elements.comparisonSourceTabs.querySelectorAll("button[data-comparison-source]").forEach(button => {
button.classList.toggle("active", button.dataset.comparisonSource === comparison.source);
button.disabled = locked;
});
elements.comparisonDomesticPanel.hidden = comparison.source !== "domestic";
elements.comparisonFixedPanel.hidden = comparison.source !== "fixed";
elements.comparisonWorldPanel.hidden = comparison.source !== "world";
renderComparisonSearchState("domestic", elements.comparisonDomesticState);
renderComparisonSearchState("world", elements.comparisonWorldState);
elements.comparisonDomesticSearch.disabled = locked;
elements.comparisonDomesticSearchButton.disabled = locked || comparison.domestic.status === "loading";
elements.comparisonWorldSearch.disabled = locked;
elements.comparisonWorldSearchButton.disabled = locked || comparison.world.status === "loading";
renderComparisonTargetList(
elements.comparisonDomesticResults,
comparison.domestic.results,
comparison.domestic.status === "ready" ? "일치하는 국내·NXT 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.");
renderComparisonTargetList(elements.comparisonFixedTargets, comparisonWorkflow.marketTargets, "고정 비교 대상이 없습니다.");
renderComparisonTargetList(
elements.comparisonWorldResults,
comparison.world.results,
comparison.world.status === "ready" ? "일치하는 해외 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.");
elements.comparisonFirst.textContent = comparisonTargetDisplay(comparison.draftFirst);
elements.comparisonSecond.textContent = comparisonTargetDisplay(comparison.draftSecond);
elements.comparisonSwap.disabled = locked || !comparison.draftFirst || !comparison.draftSecond;
elements.comparisonClear.disabled = locked || (!comparison.draftFirst && !comparison.draftSecond);
elements.comparisonSavePair.disabled = locked ||
!comparisonWorkflow.normalizePair([comparison.draftFirst, comparison.draftSecond]);
elements.comparisonPairList.replaceChildren();
if (!pairs.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = "저장된 비교쌍이 없습니다.";
elements.comparisonPairList.append(empty);
} else {
pairs.forEach((record, index) => {
const row = document.createElement("button");
row.type = "button";
row.className = "comparison-pair-row";
row.disabled = locked;
row.classList.toggle("selected", record.id === comparison.selectedPairId);
row.setAttribute("role", "option");
row.setAttribute("aria-selected", record.id === comparison.selectedPairId ? "true" : "false");
const number = document.createElement("span");
number.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
const title = document.createElement("strong");
const meta = document.createElement("small");
title.textContent = comparisonWorkflow.pairLabel(record.pair);
meta.textContent = record.pair.map(comparisonTargetMeta).join(" · ");
copy.append(title, meta);
const kind = document.createElement("span");
kind.textContent = record.pair.map(target => target.kind.replace("-stock", "")).join("/");
row.append(number, copy, kind);
row.addEventListener("click", () => {
if (isPlaylistSnapshotLocked()) return;
comparison.selectedPairId = record.id;
renderComparisonWorkflow();
});
elements.comparisonPairList.append(row);
});
}
const selectedIndex = pairs.findIndex(record => record.id === comparison.selectedPairId);
elements.comparisonMoveUp.disabled = locked || selectedIndex <= 0;
elements.comparisonMoveDown.disabled = locked || selectedIndex < 0 || selectedIndex >= pairs.length - 1;
elements.comparisonDeletePair.disabled = locked || selectedIndex < 0;
elements.comparisonDeleteAll.disabled = locked || !pairs.length;
elements.comparisonActionList.replaceChildren();
let previousSection = null;
comparisonWorkflow.actions.forEach((action, index) => {
const sectionName = action.kind === "two-column"
? "2열판"
: (action.kind === "comparison" ? "종목별 비교분석" : "종목별 수익률 비교");
if (sectionName !== previousSection) {
const section = document.createElement("div");
section.className = "comparison-action-section";
section.textContent = sectionName;
elements.comparisonActionList.append(section);
previousSection = sectionName;
}
const compatibility = comparisonWorkflow.getCompatibility(action.id, selectedPair);
const row = document.createElement("div");
row.className = "comparison-action-row";
row.classList.toggle("disabled", !compatibility.supported);
const number = document.createElement("span");
number.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
copy.className = "comparison-action-copy";
const label = document.createElement("strong");
const detail = document.createElement("small");
label.textContent = action.label;
detail.textContent = compatibility.supported
? `${action.kind === "two-column" ? "s8018/s5032" : (action.kind === "return" ? "s5029" : action.builderKey)} · ${compatibility.effectiveMode || "GRAPH"}`
: comparisonCompatibilityText(compatibility.reason);
copy.append(label, detail);
const add = document.createElement("button");
add.type = "button";
add.className = "comparison-action-add";
add.textContent = "+";
add.disabled = locked || !compatibility.supported;
add.title = compatibility.supported ? "플레이리스트에 추가" : comparisonCompatibilityText(compatibility.reason);
add.addEventListener("click", () => addComparisonAction(action.id));
row.addEventListener("dblclick", event => {
if (!event.target.closest("button") && compatibility.supported) addComparisonAction(action.id);
});
row.append(number, copy, add);
elements.comparisonActionList.append(row);
});
elements.comparisonYieldBatch.disabled = locked || !selectedPair ||
!comparisonWorkflow.yieldActionIds.every(actionId => comparisonWorkflow.isActionAllowed(actionId, selectedPair));
}
function requestComparisonDomesticSearch(event) {
event?.preventDefault();
const search = state.comparison.domestic;
clearComparisonSearchTimeout("domestic");
const query = String(elements.comparisonDomesticSearch.value || "").trim();
if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
search.status = "error";
search.error = "1~64자의 국내·NXT 종목명을 입력하세요.";
search.results = [];
renderComparisonWorkflow();
return;
}
if (!nativeBridge) {
search.status = "error";
search.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다.";
renderComparisonWorkflow();
return;
}
const requestId = createId();
Object.assign(search, {
requestId, query, status: "loading", results: [], totalRowCount: 0, error: ""
});
renderComparisonWorkflow();
postNative("search-stocks", { requestId, query, maximumResults: 200 });
search.timeoutId = setTimeout(() => {
if (search.requestId !== requestId || search.status !== "loading") return;
search.status = "error";
search.error = "국내·NXT 종목 검색 응답 시간이 초과되었습니다.";
renderComparisonWorkflow();
}, 30000);
}
function handleComparisonDomesticResults(payload) {
const search = state.comparison.domestic;
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false;
clearComparisonSearchTimeout("domestic");
const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
const results = raw.flatMap(value => {
const target = comparisonTargetFromStockResult(value);
return target ? [target] : [];
});
if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) {
search.status = "error";
search.results = [];
search.error = "국내·NXT 종목 검색 결과 식별자가 올바르지 않습니다.";
} else {
search.status = "ready";
search.results = results;
search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
search.error = "";
}
renderComparisonWorkflow();
return true;
}
function handleComparisonDomesticError(payload) {
const search = state.comparison.domestic;
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false;
clearComparisonSearchTimeout("domestic");
search.status = "error";
search.results = [];
search.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "국내·NXT 종목 검색에 실패했습니다.";
renderComparisonWorkflow();
return true;
}
function requestComparisonWorldSearch(event) {
event?.preventDefault();
const search = state.comparison.world;
clearComparisonSearchTimeout("world");
const query = String(elements.comparisonWorldSearch.value || "").trim();
if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
search.status = "error";
search.error = "1~64자의 미국·대만 종목명을 입력하세요.";
search.results = [];
renderComparisonWorkflow();
return;
}
if (!nativeBridge) {
search.status = "error";
search.error = "브라우저 미리보기에서는 실제 해외종목 DB 검색을 사용할 수 없습니다.";
renderComparisonWorkflow();
return;
}
const requestId = createId();
Object.assign(search, {
requestId, query, status: "loading", results: [], totalRowCount: 0, error: ""
});
renderComparisonWorkflow();
postNative("search-world-stocks", { requestId, query, maximumResults: 100 });
search.timeoutId = setTimeout(() => {
if (search.requestId !== requestId || search.status !== "loading") return;
search.status = "error";
search.error = "해외종목 검색 응답 시간이 초과되었습니다.";
renderComparisonWorkflow();
}, 30000);
}
function handleComparisonWorldResults(payload) {
const search = state.comparison.world;
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return;
clearComparisonSearchTimeout("world");
const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
const results = raw.flatMap(value => {
const target = comparisonTargetFromWorldResult(value);
return target ? [target] : [];
});
if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) {
search.status = "error";
search.results = [];
search.error = "해외종목 검색 결과 식별자가 올바르지 않습니다.";
} else {
search.status = "ready";
search.results = results;
search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
search.error = "";
}
renderComparisonWorkflow();
}
function handleComparisonWorldError(payload) {
const search = state.comparison.world;
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return;
clearComparisonSearchTimeout("world");
search.status = "error";
search.results = [];
search.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "해외종목 검색에 실패했습니다.";
renderComparisonWorkflow();
}
function synchronizeComparisonEntries() {
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-comparison-workflow") return item;
const refreshed = comparisonWorkflow.refreshComparisonPlaylistEntry(item);
if (!refreshed) {
invalid = true;
return item;
}
const definition = catalog.find(value =>
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
if (!definition) {
invalid = true;
return item;
}
return { ...definition, ...refreshed };
});
if (invalid) {
showToast("저장된 비교 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
return false;
}
return true;
}
function clearThemeTimeout(kind) {
const value = state.theme[kind];
if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
if (value) value.timeoutId = null;
}
function themeSessionWire(theme) {
if (theme?.market === "krx") return "notApplicable";
return theme?.session === "AFTER_MARKET" ? "afterMarket" : "preMarket";
}
function themeIdentity(theme) {
if (!theme) return "";
return [theme.market, themeSessionWire(theme), theme.themeCode, theme.title].join("|");
}
function selectedThemeWithItemCount(itemCount) {
const selected = state.theme.selected;
return selected ? themeWorkflow.normalizeTheme({ ...selected, itemCount }) : null;
}
function themeCompatibilityText(reason) {
return {
"invalid-theme": "테마를 선택하세요.",
"invalid-theme-sort": "테마 정렬 방식을 확인하세요.",
"theme-preview-is-empty": "종목이 없는 테마는 송출할 수 없습니다.",
"expected-theme-is-krx-only": "예상체결가는 KRX 테마 5단 표에서만 지원합니다.",
"nxt-session-required": "NXT 장전 또는 시간외 세션을 선택하세요."
}[reason] || "현재 테마와 컷 조합은 지원하지 않습니다.";
}
function addThemeAction(actionId) {
if (!requirePlaylistEditable()) return;
const theme = state.theme.selected;
const sort = state.theme.sort;
if (state.theme.preview.status !== "ready" || !theme) {
showToast("테마를 선택하고 종목 미리보기가 완료될 때까지 기다리세요.");
return;
}
const compatibility = themeWorkflow.getCompatibility(actionId, theme, sort);
if (!compatibility.supported) {
showToast(themeCompatibilityText(compatibility.reason));
return;
}
try {
const created = themeWorkflow.createThemePlaylistEntry(actionId, theme, sort, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION
});
const definition = catalog.find(item =>
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
if (!definition) throw new Error("The theme action does not resolve to a registered scene alias.");
const entry = { ...definition, ...created };
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
showToast("테마 컷을 플레이리스트에 추가했습니다.");
} catch {
showToast("테마와 컷을 안전한 페이지 장면으로 구성하지 못했습니다.");
}
}
function renderThemeWorkflow() {
const visible = state.activeMarket === "theme";
elements.themeWorkflow.hidden = !visible;
if (!visible) return;
const theme = state.theme;
const search = theme.search;
const preview = theme.preview;
const locked = isPlaylistSnapshotLocked();
elements.themeCatalogManageButton.disabled = operatorUiLocked();
elements.themeCatalogManageButton.textContent = preview.catalogContext ? "선택 테마 편집" : "테마 관리";
elements.themeCount.textContent = search.status === "loading"
? "LOADING"
: `${search.totalRowCount.toLocaleString("ko-KR")} THEMES`;
elements.themeSearch.disabled = locked;
elements.themeNxtSession.disabled = locked;
elements.themeSearchButton.disabled = locked || search.status === "loading";
elements.themeSearchState.className = `theme-search-state ${search.status}`;
if (search.status === "loading") {
elements.themeSearchState.textContent = "KRX(Oracle)와 NXT(MariaDB) 테마를 조회하고 있습니다.";
} else if (search.status === "error") {
elements.themeSearchState.textContent = search.error || "테마 검색에 실패했습니다.";
} else if (search.status === "ready") {
elements.themeSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 테마${search.truncated ? " · 결과 제한 적용" : ""}`;
} else {
elements.themeSearchState.textContent = "조회 버튼을 누르면 전체 테마를 불러옵니다.";
}
elements.themeResults.replaceChildren();
if (!search.results.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = search.status === "ready" ? "일치하는 테마가 없습니다." : "테마 검색 결과가 여기에 표시됩니다.";
elements.themeResults.append(empty);
} else {
const selectedIdentity = themeIdentity(theme.selected);
for (const item of search.results) {
const row = document.createElement("button");
row.type = "button";
row.className = "theme-result";
row.disabled = locked;
const selected = themeIdentity(item) === selectedIdentity;
row.classList.toggle("selected", selected);
row.setAttribute("role", "option");
row.setAttribute("aria-selected", selected ? "true" : "false");
const copy = document.createElement("span");
const title = document.createElement("strong");
const meta = document.createElement("small");
title.textContent = item.displayTitle;
meta.textContent = `${item.themeCode} · ${themeSessionWire(item)}`;
copy.append(title, meta);
const market = document.createElement("span");
market.className = "theme-market-badge";
market.textContent = item.market.toLocaleUpperCase("en-US");
row.append(copy, market);
row.addEventListener("click", () => selectTheme(item));
elements.themeResults.append(row);
}
}
elements.selectedTheme.hidden = !theme.selected;
elements.themeSort.disabled = locked || !theme.selected;
if (theme.selected) {
elements.selectedThemeTitle.textContent = theme.selected.displayTitle;
elements.selectedThemeMeta.textContent = `${theme.selected.themeCode} · ${theme.selected.market.toLocaleUpperCase("en-US")} · ${themeSessionWire(theme.selected)}`;
}
elements.themePreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`;
elements.themePreviewState.className = `theme-preview-state ${preview.status}`;
if (preview.status === "loading") elements.themePreviewState.textContent = "선택 테마의 종목을 조회하고 있습니다.";
else if (preview.status === "error") elements.themePreviewState.textContent = preview.error || "테마 종목을 불러오지 못했습니다.";
else if (preview.status === "ready") {
elements.themePreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 종목${preview.truncated ? " · 240개 상한 적용" : ""}`;
} else elements.themePreviewState.textContent = "테마를 선택하면 종목 구성을 확인합니다.";
elements.themePreviewItems.replaceChildren();
if (!preview.items.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = preview.status === "ready" ? "테마에 송출 가능한 종목이 없습니다." : "미리보기 대기 중";
elements.themePreviewItems.append(empty);
} else {
for (const item of preview.items) {
const row = document.createElement("div");
row.className = "theme-preview-item";
const index = document.createElement("span");
const name = document.createElement("strong");
const code = document.createElement("small");
index.textContent = String(item.inputIndex).padStart(2, "0");
name.textContent = item.itemName;
code.textContent = item.itemCode;
row.append(index, name, code);
elements.themePreviewItems.append(row);
}
}
elements.themeActionList.replaceChildren();
themeWorkflow.actions.forEach((action, index) => {
const compatibility = preview.status === "ready"
? themeWorkflow.getCompatibility(action.id, theme.selected, theme.sort)
: { supported: false, reason: "invalid-theme" };
const row = document.createElement("div");
row.className = "theme-action-row";
row.classList.toggle("disabled", !compatibility.supported);
const number = document.createElement("span");
number.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
copy.className = "theme-action-copy";
const label = document.createElement("strong");
const detail = document.createElement("small");
label.textContent = action.label;
const page = theme.selected ? themeWorkflow.calculatePagePreview(action.id, theme.selected) : null;
detail.textContent = compatibility.supported
? `${action.code} · ${action.pageSize}× ${page?.pageCount ?? "?"}페이지${page?.isTruncated ? " · 20페이지 상한" : ""}`
: themeCompatibilityText(compatibility.reason);
copy.append(label, detail);
const add = document.createElement("button");
add.type = "button";
add.className = "theme-action-add";
add.textContent = "+";
add.disabled = locked || !compatibility.supported;
add.title = compatibility.supported ? "플레이리스트에 추가" : themeCompatibilityText(compatibility.reason);
add.addEventListener("click", () => addThemeAction(action.id));
row.addEventListener("dblclick", event => {
if (!event.target.closest("button") && compatibility.supported) addThemeAction(action.id);
});
row.append(number, copy, add);
elements.themeActionList.append(row);
});
}
function requestThemeSearch(event) {
event?.preventDefault();
const search = state.theme.search;
clearThemeTimeout("search");
clearThemeTimeout("preview");
const query = String(elements.themeSearch.value || "").trim();
if (query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
search.status = "error";
search.error = "테마 검색어는 64자 이하여야 합니다.";
search.results = [];
renderThemeWorkflow();
return;
}
state.theme.selected = null;
Object.assign(state.theme.preview, {
requestId: null, status: "idle", items: [], totalRowCount: 0, truncated: false, error: ""
});
if (!nativeBridge) {
search.status = "error";
search.error = "브라우저 미리보기에서는 실제 테마 DB 조회를 사용할 수 없습니다.";
renderThemeWorkflow();
return;
}
const requestId = createId();
Object.assign(search, {
requestId, query, status: "loading", results: [], totalRowCount: 0, truncated: false, error: ""
});
renderThemeWorkflow();
postNative("search-themes", {
requestId,
query,
nxtSession: state.theme.nxtSession,
maximumResults: 200
});
search.timeoutId = setTimeout(() => {
if (search.requestId !== requestId || search.status !== "loading") return;
search.status = "error";
search.error = "테마 검색 응답 시간이 초과되었습니다.";
renderThemeWorkflow();
}, 30000);
}
function handleThemeSearchResults(payload) {
const search = state.theme.search;
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query ||
payload.nxtSession !== state.theme.nxtSession) return;
clearThemeTimeout("search");
const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
const results = raw.flatMap(value => {
const normalized = themeWorkflow.normalizeTheme({ ...value, itemCount: null });
return normalized ? [normalized] : [];
});
const identities = results.map(themeIdentity);
if (results.length !== raw.length || new Set(identities).size !== identities.length) {
search.status = "error";
search.results = [];
search.error = "테마 검색 결과 식별자가 올바르지 않습니다.";
} else {
search.status = "ready";
search.results = results;
search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
search.truncated = payload.truncated === true;
search.error = "";
}
renderThemeWorkflow();
}
function handleThemeSearchError(payload) {
const search = state.theme.search;
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query ||
payload.nxtSession !== state.theme.nxtSession) return;
clearThemeTimeout("search");
search.status = "error";
search.results = [];
search.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "테마 검색에 실패했습니다.";
renderThemeWorkflow();
}
function selectTheme(theme) {
if (isPlaylistSnapshotLocked()) return;
const normalized = themeWorkflow.normalizeTheme({ ...theme, itemCount: null });
if (!normalized) return;
state.theme.selected = normalized;
requestThemePreview(normalized);
addLog(`테마 선택 · ${normalized.displayTitle} · ${normalized.themeCode}`);
}
function requestThemePreview(theme) {
clearThemeTimeout("preview");
const preview = state.theme.preview;
const requestId = createId();
Object.assign(preview, {
requestId, status: "loading", items: [], totalRowCount: 0, truncated: false, error: "",
catalogContext: null
});
renderThemeWorkflow();
if (!nativeBridge) {
preview.status = "error";
preview.error = "브라우저 미리보기에서는 실제 테마 종목 조회를 사용할 수 없습니다.";
renderThemeWorkflow();
return;
}
postNative("request-theme-preview", {
requestId,
market: theme.market,
session: themeSessionWire(theme),
themeCode: theme.themeCode,
themeTitle: theme.title,
maximumItems: 240
});
preview.timeoutId = setTimeout(() => {
if (preview.requestId !== requestId || preview.status !== "loading") return;
preview.status = "error";
preview.error = "테마 종목 미리보기 응답 시간이 초과되었습니다.";
renderThemeWorkflow();
}, 30000);
}
function handleThemePreviewResults(payload) {
const preview = state.theme.preview;
if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return;
const rawSelection = payload.selection;
const responseTheme = themeWorkflow.normalizeTheme({
market: rawSelection?.market,
session: rawSelection?.session,
themeCode: rawSelection?.themeCode,
title: rawSelection?.title,
itemCount: Number.isInteger(payload.totalRowCount) ? payload.totalRowCount : null
});
if (!responseTheme || themeIdentity(responseTheme) !== themeIdentity(state.theme.selected)) return;
clearThemeTimeout("preview");
const rawItems = Array.isArray(payload.items) ? payload.items.slice(0, 240) : [];
const seenCodes = new Set();
const seenIndexes = new Set();
const items = [];
for (const value of rawItems) {
const inputIndex = value?.inputIndex;
const itemCode = typeof value?.itemCode === "string" ? value.itemCode.trim() : "";
const itemName = typeof value?.itemName === "string" ? value.itemName.trim() : "";
if (!Number.isInteger(inputIndex) || inputIndex < 0 || inputIndex > 9999 ||
!itemCode || itemCode.length > 32 || !itemName || itemName.length > 200 ||
/[\u0000-\u001f\u007f]/.test(`${itemCode}${itemName}`) ||
seenCodes.has(itemCode) || seenIndexes.has(inputIndex)) {
preview.status = "error";
preview.items = [];
preview.error = "테마 종목 미리보기 식별자가 올바르지 않습니다.";
renderThemeWorkflow();
return;
}
seenCodes.add(itemCode);
seenIndexes.add(inputIndex);
items.push({ inputIndex, itemCode, itemName });
}
if (!Number.isInteger(payload.totalRowCount) || payload.totalRowCount !== items.length) {
preview.status = "error";
preview.items = [];
preview.error = "테마 종목 미리보기 행 수가 올바르지 않습니다.";
renderThemeWorkflow();
return;
}
state.theme.selected = responseTheme;
preview.status = "ready";
preview.items = items;
preview.totalRowCount = items.length;
preview.truncated = payload.truncated === true;
preview.error = "";
const catalogSelection = {
market: responseTheme.market,
session: themeSessionWire(responseTheme),
themeCode: responseTheme.themeCode,
themeTitle: responseTheme.title,
source: responseTheme.market === "krx" ? "oracle" : "mariaDb"
};
const catalogPreview = {
requestId: payload.requestId,
selection: {
market: responseTheme.market,
session: themeSessionWire(responseTheme),
themeCode: responseTheme.themeCode,
title: responseTheme.title
},
retrievedAt: payload.retrievedAt,
totalRowCount: items.length,
truncated: payload.truncated === true,
items: items.map(value => ({ ...value }))
};
try {
operatorCatalogWorkflow.createThemeEditDraft(catalogSelection, catalogPreview);
preview.catalogContext = Object.freeze({
selection: Object.freeze({ ...catalogSelection }),
preview: Object.freeze({ ...catalogPreview, items: Object.freeze(catalogPreview.items) })
});
} catch {
preview.catalogContext = null;
}
renderThemeWorkflow();
addLog(`테마 종목 조회 완료 · ${responseTheme.displayTitle} · ${items.length}`);
}
function handleThemePreviewError(payload) {
const preview = state.theme.preview;
if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return;
const selection = payload.selection;
const identity = [selection?.market, selection?.session, selection?.themeCode, selection?.title].join("|");
if (identity !== themeIdentity(state.theme.selected)) return;
clearThemeTimeout("preview");
preview.status = "error";
preview.items = [];
preview.catalogContext = null;
preview.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "테마 종목을 불러오지 못했습니다.";
renderThemeWorkflow();
}
function synchronizeThemeEntries() {
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-theme-workflow") return 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;
}
const definition = catalog.find(value =>
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
if (!definition) {
invalid = true;
return item;
}
// 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 };
});
if (invalid) {
showToast("저장된 테마 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
return false;
}
return true;
}
function clearOverseasTimeout(kind) {
const value = state.overseas[kind];
if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
if (value) value.timeoutId = null;
}
function currentOverseasStore() {
return state.overseas[state.overseas.source];
}
function overseasIdentity(kind, value) {
if (!value) return "";
return kind === "industry"
? [value.koreanName, value.inputName, value.symbol, value.nationCode, value.fdtc].join("|")
: [value.inputName, value.symbol, value.nationCode, value.fdtc].join("|");
}
function selectOverseas(value) {
if (isPlaylistSnapshotLocked()) return;
const kind = state.overseas.source;
const normalized = kind === "industry"
? overseasWorkflow.normalizeIndustry(value)
: overseasWorkflow.normalizeStock(value);
if (!normalized) return;
state.overseas[kind].selected = normalized;
renderOverseasWorkflow();
addLog(`해외 ${kind === "industry" ? "업종" : "종목"} 선택 · ${kind === "industry" ? normalized.koreanName : normalized.inputName} · ${normalized.symbol}`);
}
function addOverseasAction(actionId) {
if (!requirePlaylistEditable()) return;
const kind = state.overseas.source;
const selected = state.overseas[kind].selected;
if (!selected) {
showToast("먼저 해외업종 또는 해외종목을 선택하세요.");
elements.overseasSearch.focus();
return;
}
try {
const options = {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION,
ma5: state.candleOptions.ma5,
ma20: state.candleOptions.ma20
};
const created = kind === "industry"
? overseasWorkflow.createOverseasIndustryPlaylistEntry(selected, options)
: overseasWorkflow.createOverseasStockPlaylistEntry(selected, actionId, options);
const definition = catalog.find(item =>
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
if (!definition) throw new Error("The overseas action does not resolve to a scene alias.");
const entry = { ...definition, ...created };
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
showToast("해외 대상 컷을 플레이리스트에 추가했습니다.");
} catch {
showToast("선택한 해외 대상과 컷을 안전하게 구성하지 못했습니다.");
}
}
function renderOverseasWorkflow() {
renderGlobalCandleOptions();
const visible = state.activeMarket === "foreign-stock";
elements.overseasWorkflow.hidden = !visible;
if (!visible) return;
const kind = state.overseas.source;
const store = currentOverseasStore();
const locked = isPlaylistSnapshotLocked();
elements.overseasSourceTabs.querySelectorAll("button[data-overseas-source]").forEach(button => {
button.classList.toggle("active", button.dataset.overseasSource === kind);
button.disabled = locked;
});
elements.overseasCount.textContent = store.status === "loading"
? "LOADING"
: `${store.totalRowCount.toLocaleString("ko-KR")} ${kind === "industry" ? "INDUSTRIES" : "STOCKS"}`;
elements.overseasSearch.disabled = locked;
elements.overseasSearchButton.disabled = locked || store.status === "loading";
elements.overseasSearchState.className = `overseas-search-state ${store.status}`;
if (store.status === "loading") {
elements.overseasSearchState.textContent = kind === "industry"
? "Oracle에서 미국 해외업종을 조회하고 있습니다."
: "Oracle에서 미국·대만 해외종목을 조회하고 있습니다.";
} else if (store.status === "error") {
elements.overseasSearchState.textContent = store.error || "해외 대상 조회에 실패했습니다.";
} else if (store.status === "ready") {
elements.overseasSearchState.textContent = `${store.totalRowCount.toLocaleString("ko-KR")}개 결과${store.truncated ? " · 결과 제한 적용" : ""}`;
} else {
elements.overseasSearchState.textContent = "조회 버튼을 누르면 전체 목록을 불러옵니다.";
}
elements.overseasResults.replaceChildren();
if (!store.results.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = store.status === "ready" ? "일치하는 결과가 없습니다." : "검색 결과가 여기에 표시됩니다.";
elements.overseasResults.append(empty);
} else {
const selectedIdentity = overseasIdentity(kind, store.selected);
for (const item of store.results) {
const row = document.createElement("button");
row.type = "button";
row.className = "overseas-result";
row.disabled = locked;
const selected = overseasIdentity(kind, item) === selectedIdentity;
row.classList.toggle("selected", selected);
row.setAttribute("role", "option");
row.setAttribute("aria-selected", selected ? "true" : "false");
const copy = document.createElement("span");
const name = document.createElement("strong");
const meta = document.createElement("small");
name.textContent = kind === "industry" ? item.koreanName : item.inputName;
meta.textContent = kind === "industry"
? `${item.inputName} · ${item.symbol}`
: `${item.symbol} · FDTC ${item.fdtc}`;
copy.append(name, meta);
const nation = document.createElement("span");
nation.textContent = item.nationCode;
row.append(copy, nation);
row.addEventListener("click", () => selectOverseas(item));
elements.overseasResults.append(row);
}
}
elements.selectedOverseas.hidden = !store.selected;
elements.overseasMa5.disabled = locked || kind !== "stock" || !store.selected;
elements.overseasMa20.disabled = locked || kind !== "stock" || !store.selected;
if (store.selected) {
elements.selectedOverseasName.textContent = kind === "industry"
? store.selected.koreanName
: store.selected.inputName;
elements.selectedOverseasMeta.textContent = `${store.selected.symbol} · ${store.selected.nationCode} · FDTC ${store.selected.fdtc}`;
}
const actions = kind === "industry"
? overseasWorkflow.industryActions
: overseasWorkflow.stockActions;
elements.overseasActionList.replaceChildren();
actions.forEach((action, index) => {
const allowed = overseasWorkflow.isActionAllowed(kind, action.id, store.selected);
const row = document.createElement("div");
row.className = "overseas-action-row";
row.classList.toggle("disabled", !allowed);
const number = document.createElement("span");
number.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
copy.className = "overseas-action-copy";
const label = document.createElement("strong");
const detail = document.createElement("small");
label.textContent = action.label;
detail.textContent = `${action.code}${action.periodDays ? ` · ${action.periodDays}` : " · 현재가"}`;
copy.append(label, detail);
const add = document.createElement("button");
add.type = "button";
add.className = "overseas-action-add";
add.textContent = "+";
add.disabled = locked || !allowed;
add.title = allowed ? "플레이리스트에 추가" : "해외 대상을 먼저 선택하세요.";
add.addEventListener("click", () => addOverseasAction(action.id));
row.addEventListener("dblclick", event => {
if (!event.target.closest("button") && allowed) addOverseasAction(action.id);
});
row.append(number, copy, add);
elements.overseasActionList.append(row);
});
}
function requestOverseasSearch(event) {
event?.preventDefault();
if (isPlaylistSnapshotLocked()) return;
const kind = state.overseas.source;
const store = state.overseas[kind];
clearOverseasTimeout(kind);
let request;
try {
const createRequest = kind === "industry"
? overseasWorkflow.createIndustrySearchRequest
: overseasWorkflow.createStockSearchRequest;
request = createRequest(
createId(),
String(elements.overseasSearch.value || ""),
overseasWorkflow.bridgeContracts[kind].maximumResults);
} catch {
store.status = "error";
store.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
store.results = [];
store.totalRowCount = 0;
renderOverseasWorkflow();
return;
}
store.request = request;
store.status = "loading";
store.results = [];
store.selected = null;
store.totalRowCount = 0;
store.truncated = false;
store.error = "";
renderOverseasWorkflow();
if (!nativeBridge) {
store.status = "error";
store.error = "브라우저 미리보기에서는 실제 해외 DB 조회를 사용할 수 없습니다.";
renderOverseasWorkflow();
return;
}
postNative(overseasWorkflow.bridgeContracts[kind].requestType, request);
store.timeoutId = setTimeout(() => {
if (store.request !== request || store.status !== "loading") return;
store.status = "error";
store.error = "해외 대상 조회 응답 시간이 초과되었습니다.";
renderOverseasWorkflow();
}, 30000);
}
function handleOverseasSearchResults(kind, payload) {
const store = state.overseas[kind];
const request = store.request;
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false;
clearOverseasTimeout(kind);
const response = kind === "industry"
? overseasWorkflow.normalizeIndustrySearchResponse(payload, request)
: overseasWorkflow.normalizeStockSearchResponse(payload, request);
if (!response) {
store.status = "error";
store.results = [];
store.totalRowCount = 0;
store.error = "해외 대상 응답 형식 또는 식별자가 올바르지 않습니다.";
} else {
store.status = "ready";
store.results = [...response.results];
store.totalRowCount = response.totalRowCount;
store.truncated = response.truncated;
store.error = "";
}
renderOverseasWorkflow();
return true;
}
function handleOverseasSearchError(kind, payload) {
const store = state.overseas[kind];
const request = store.request;
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false;
clearOverseasTimeout(kind);
const error = kind === "industry"
? overseasWorkflow.normalizeIndustrySearchError(payload, request)
: overseasWorkflow.normalizeStockSearchError(payload, request);
store.status = "error";
store.results = [];
store.totalRowCount = 0;
store.error = error?.message || "해외 대상 조회에 실패했습니다.";
renderOverseasWorkflow();
return true;
}
function synchronizeOverseasEntries() {
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-overseas-workflow") return item;
const refreshed = overseasWorkflow.refreshOverseasPlaylistEntry(item);
if (!refreshed) {
invalid = true;
return item;
}
const definition = catalog.find(value =>
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
if (!definition) {
invalid = true;
return item;
}
return { ...definition, ...refreshed };
});
if (invalid) {
showToast("저장된 해외 대상 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
return false;
}
return true;
}
function clearExpertTimeout(kind) {
const value = state.expert[kind];
if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
if (value) value.timeoutId = null;
}
function expertIdentity(value) {
return value ? `${value.expertCode}|${value.expertName}` : "";
}
function selectExpert(value) {
if (isPlaylistSnapshotLocked()) return;
const selected = expertWorkflow.normalizeExpert(value);
if (!selected) return;
state.expert.selected = selected;
state.expert.selectable = null;
requestExpertPreview(selected);
addLog(`전문가 선택 · ${selected.expertName} · ${selected.expertCode}`);
}
function addExpertAction() {
if (!requirePlaylistEditable()) return;
const selected = state.expert.selectable;
if (!selected || state.expert.preview.status !== "ready") {
showToast("전문가를 선택하고 추천 종목 미리보기가 완료될 때까지 기다리세요.");
return;
}
try {
const created = expertWorkflow.createExpertPlaylistEntry(selected, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION
});
const definition = catalog.find(item =>
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
if (!definition) throw new Error("The expert action does not resolve to a scene alias.");
const entry = { ...definition, ...created };
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${entry.code} ${entry.title} · ${entry.pageCount}페이지 추가`);
showToast("전문가 추천 컷을 플레이리스트에 추가했습니다.");
} catch {
showToast("전문가 추천을 안전한 페이지 장면으로 구성하지 못했습니다.");
}
}
function renderExpertWorkflow() {
const visible = state.activeMarket === "expert";
elements.expertWorkflow.hidden = !visible;
if (!visible) return;
const expert = state.expert;
const search = expert.search;
const preview = expert.preview;
const locked = isPlaylistSnapshotLocked();
elements.expertCatalogManageButton.disabled = operatorUiLocked();
elements.expertCatalogManageButton.textContent = preview.catalogContext ? "선택 전문가 편집" : "전문가 관리";
elements.expertCount.textContent = search.status === "loading"
? "LOADING"
: `${search.totalRowCount.toLocaleString("ko-KR")} EXPERTS`;
elements.expertSearch.disabled = locked;
elements.expertSearchButton.disabled = locked || search.status === "loading";
elements.expertSearchState.className = `expert-search-state ${search.status}`;
if (search.status === "loading") elements.expertSearchState.textContent = "Oracle 전문가 목록을 조회하고 있습니다.";
else if (search.status === "error") elements.expertSearchState.textContent = search.error || "전문가 검색에 실패했습니다.";
else if (search.status === "ready") elements.expertSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}${search.truncated ? " · 결과 제한 적용" : ""}`;
else elements.expertSearchState.textContent = "조회 버튼을 누르면 전체 전문가를 불러옵니다.";
elements.expertResults.replaceChildren();
if (!search.results.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = search.status === "ready" ? "일치하는 전문가가 없습니다." : "전문가 검색 결과가 여기에 표시됩니다.";
elements.expertResults.append(empty);
} else {
const selectedIdentity = expertIdentity(expert.selected);
for (const item of search.results) {
const row = document.createElement("button");
row.type = "button";
row.className = "expert-result";
row.disabled = locked;
const selected = expertIdentity(item) === selectedIdentity;
row.classList.toggle("selected", selected);
row.setAttribute("role", "option");
row.setAttribute("aria-selected", selected ? "true" : "false");
const copy = document.createElement("span");
const name = document.createElement("strong");
const code = document.createElement("small");
name.textContent = item.expertName;
code.textContent = item.expertCode;
copy.append(name, code);
const source = document.createElement("span");
source.textContent = "ORACLE";
row.append(copy, source);
row.addEventListener("click", () => selectExpert(item));
elements.expertResults.append(row);
}
}
elements.selectedExpert.hidden = !expert.selected;
if (expert.selected) {
elements.selectedExpertName.textContent = expert.selected.expertName;
elements.selectedExpertMeta.textContent = `${expert.selected.expertCode} · ORACLE`;
}
elements.expertPreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`;
elements.expertPreviewState.className = `expert-preview-state ${preview.status}`;
if (preview.status === "loading") elements.expertPreviewState.textContent = "추천 종목을 PLAYINDEX 순서로 조회하고 있습니다.";
else if (preview.status === "error") elements.expertPreviewState.textContent = preview.error || "추천 종목을 불러오지 못했습니다.";
else if (preview.status === "ready") elements.expertPreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 추천${preview.truncated ? " · 100개 상한 적용" : ""}`;
else elements.expertPreviewState.textContent = "전문가를 선택하면 추천 종목을 확인합니다.";
elements.expertPreviewItems.replaceChildren();
if (!preview.items.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = preview.status === "ready" ? "추천 종목이 없습니다." : "미리보기 대기 중";
elements.expertPreviewItems.append(empty);
} else {
for (const item of preview.items) {
const row = document.createElement("div");
row.className = "expert-preview-item";
const index = document.createElement("span");
const name = document.createElement("strong");
const amount = document.createElement("small");
index.textContent = String(item.playIndex).padStart(2, "0");
name.textContent = `${item.stockName} · ${item.stockCode}`;
amount.textContent = `${item.buyAmount.toLocaleString("ko-KR")}`;
row.append(index, name, amount);
elements.expertPreviewItems.append(row);
}
}
elements.expertActionList.replaceChildren();
const action = expertWorkflow.actions[0];
const page = expert.selectable ? expertWorkflow.calculatePagePreview(expert.selectable) : null;
const allowed = Boolean(expert.selectable && preview.status === "ready" && preview.totalRowCount > 0);
const row = document.createElement("div");
row.className = "expert-action-row";
row.classList.toggle("disabled", !allowed);
const number = document.createElement("span");
number.textContent = "01";
const copy = document.createElement("span");
copy.className = "expert-action-copy";
const label = document.createElement("strong");
const detail = document.createElement("small");
label.textContent = action.label;
detail.textContent = allowed
? `${action.code} · 5개 × ${page?.pageCount ?? 0}페이지${page?.isTruncated ? " · 상한" : ""}`
: "전문가와 추천 종목을 먼저 확인하세요.";
copy.append(label, detail);
const add = document.createElement("button");
add.type = "button";
add.className = "expert-action-add";
add.textContent = "+";
add.disabled = locked || !allowed;
add.addEventListener("click", addExpertAction);
row.addEventListener("dblclick", event => {
if (!event.target.closest("button") && allowed) addExpertAction();
});
row.append(number, copy, add);
elements.expertActionList.append(row);
}
function requestExpertSearch(event) {
event?.preventDefault();
if (isPlaylistSnapshotLocked()) return;
const search = state.expert.search;
clearExpertTimeout("search");
clearExpertTimeout("preview");
let request;
try {
request = expertWorkflow.createExpertSearchRequest(
createId(),
String(elements.expertSearch.value || ""),
expertWorkflow.bridgeContract.maximumResults);
} catch {
search.status = "error";
search.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
search.results = [];
search.totalRowCount = 0;
renderExpertWorkflow();
return;
}
state.expert.selected = null;
state.expert.selectable = null;
Object.assign(state.expert.preview, {
request: null, response: null, status: "idle", items: [], totalRowCount: 0,
truncated: false, error: ""
});
Object.assign(search, {
request, status: "loading", results: [], totalRowCount: 0, truncated: false, error: ""
});
renderExpertWorkflow();
if (!nativeBridge) {
search.status = "error";
search.error = "브라우저 미리보기에서는 실제 전문가 DB 조회를 사용할 수 없습니다.";
renderExpertWorkflow();
return;
}
postNative(expertWorkflow.bridgeContract.searchRequestType, request);
search.timeoutId = setTimeout(() => {
if (search.request !== request || search.status !== "loading") return;
search.status = "error";
search.error = "전문가 검색 응답 시간이 초과되었습니다.";
renderExpertWorkflow();
}, 30000);
}
function handleExpertSearchResults(payload) {
const search = state.expert.search;
const request = search.request;
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
clearExpertTimeout("search");
const response = expertWorkflow.normalizeExpertSearchResponse(payload, request);
if (!response) {
search.status = "error";
search.results = [];
search.totalRowCount = 0;
search.error = "전문가 응답 형식 또는 식별자가 올바르지 않습니다.";
} else {
search.status = "ready";
search.results = [...response.results];
search.totalRowCount = response.totalRowCount;
search.truncated = response.truncated;
search.error = "";
}
renderExpertWorkflow();
}
function handleExpertSearchError(payload) {
const search = state.expert.search;
const request = search.request;
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
clearExpertTimeout("search");
const error = expertWorkflow.normalizeExpertSearchError(payload, request);
search.status = "error";
search.results = [];
search.totalRowCount = 0;
search.error = error?.message || "전문가 검색에 실패했습니다.";
renderExpertWorkflow();
}
function requestExpertPreview(selected) {
clearExpertTimeout("preview");
const preview = state.expert.preview;
let request;
try {
request = expertWorkflow.createExpertPreviewRequest(
createId(), selected, expertWorkflow.bridgeContract.maximumPreviewItems);
} catch {
preview.status = "error";
preview.error = "전문가 식별자가 올바르지 않습니다.";
renderExpertWorkflow();
return;
}
Object.assign(preview, {
request, response: null, status: "loading", items: [], totalRowCount: 0,
truncated: false, error: "", catalogContext: null
});
state.expert.selectable = null;
renderExpertWorkflow();
if (!nativeBridge) {
preview.status = "error";
preview.error = "브라우저 미리보기에서는 실제 추천 종목 조회를 사용할 수 없습니다.";
renderExpertWorkflow();
return;
}
postNative(expertWorkflow.bridgeContract.previewRequestType, request);
preview.timeoutId = setTimeout(() => {
if (preview.request !== request || preview.status !== "loading") return;
preview.status = "error";
preview.error = "추천 종목 미리보기 응답 시간이 초과되었습니다.";
renderExpertWorkflow();
}, 30000);
}
function handleExpertPreviewResults(payload) {
const preview = state.expert.preview;
const request = preview.request;
if (!request || payload?.requestId !== request.requestId || !state.expert.selected) return;
clearExpertTimeout("preview");
const response = expertWorkflow.normalizeExpertPreviewResponse(payload, request);
const selectable = response
? expertWorkflow.createSelectableExpert(state.expert.selected, response)
: null;
if (!response || !selectable) {
preview.status = "error";
preview.items = [];
preview.totalRowCount = 0;
preview.error = "추천 종목 응답 형식, 순서 또는 식별자가 올바르지 않습니다.";
state.expert.selectable = null;
} else {
preview.status = "ready";
preview.response = response;
preview.items = [...response.items];
preview.totalRowCount = response.totalRowCount;
preview.truncated = response.truncated;
preview.error = "";
state.expert.selectable = selectable;
try {
operatorCatalogWorkflow.createExpertEditDraft(state.expert.selected, response);
preview.catalogContext = Object.freeze({
selection: state.expert.selected,
preview: response
});
} catch {
preview.catalogContext = null;
}
}
renderExpertWorkflow();
}
function handleExpertPreviewError(payload) {
const preview = state.expert.preview;
const request = preview.request;
if (!request || payload?.requestId !== request.requestId) return;
clearExpertTimeout("preview");
const error = expertWorkflow.normalizeExpertPreviewError(payload, request);
preview.status = "error";
preview.items = [];
preview.totalRowCount = 0;
preview.catalogContext = null;
preview.error = error?.message || "추천 종목을 불러오지 못했습니다.";
state.expert.selectable = null;
renderExpertWorkflow();
}
function synchronizeExpertEntries() {
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-expert-workflow") return item;
const refreshed = expertWorkflow.refreshExpertPlaylistEntry(item);
if (!refreshed) {
invalid = true;
return item;
}
const definition = catalog.find(value =>
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
if (!definition) {
invalid = true;
return item;
}
return { ...definition, ...refreshed };
});
if (invalid) {
showToast("저장된 전문가 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
return false;
}
return true;
}
function clearTradingHaltTimeout() {
if (state.tradingHalt.timeoutId !== null) clearTimeout(state.tradingHalt.timeoutId);
state.tradingHalt.timeoutId = null;
}
function tradingHaltIdentity(value) {
return tradingHaltWorkflow.tradingHaltIdentity(value);
}
function toggleTradingHaltNameSort() {
const workflow = state.tradingHalt;
if (isPlaylistSnapshotLocked() || workflow.status !== "ready" || !workflow.results.length) return;
workflow.sortDirection = tradingHaltWorkflow.nextTradingHaltNameSort(workflow.sortDirection);
renderTradingHaltWorkflow();
}
function selectTradingHalt(value) {
if (isPlaylistSnapshotLocked()) return;
const selected = tradingHaltWorkflow.normalizeTradingHalt(value);
if (!selected) return;
state.tradingHalt.selected = selected;
renderTradingHaltWorkflow();
addLog(`거래 정지 종목 선택 · ${selected.displayName} · ${selected.stockCode}`);
}
function addTradingHaltAction(actionId) {
if (!requirePlaylistEditable()) return;
const selected = state.tradingHalt.selected;
if (!selected) {
showToast("먼저 거래 정지 종목을 선택하세요.");
elements.tradingHaltSearch.focus();
return;
}
try {
const created = tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, actionId, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION,
ma5: state.candleOptions.ma5,
ma20: state.candleOptions.ma20
});
const definition = catalog.find(item =>
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
if (!definition) throw new Error("The trading-halt action does not resolve to a scene alias.");
const entry = { ...definition, ...created };
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
showToast("거래 정지 컷을 플레이리스트에 추가했습니다.");
} catch {
showToast("선택한 거래 정지 종목과 컷을 안전하게 구성하지 못했습니다.");
}
}
function renderTradingHaltWorkflow() {
renderGlobalCandleOptions();
const visible = state.activeMarket === "halted";
elements.tradingHaltWorkflow.hidden = !visible;
if (!visible) return;
const workflow = state.tradingHalt;
const locked = isPlaylistSnapshotLocked();
elements.tradingHaltCount.textContent = workflow.status === "loading"
? "LOADING"
: `${workflow.totalRowCount.toLocaleString("ko-KR")} HALTED`;
elements.tradingHaltSearch.disabled = locked;
elements.tradingHaltSearchButton.disabled = locked || workflow.status === "loading";
const canSort = workflow.status === "ready" && workflow.results.length > 0;
elements.tradingHaltNameSort.disabled = locked || !canSort;
elements.tradingHaltNameSort.dataset.sortDirection = workflow.sortDirection || "none";
const sortLabel = workflow.sortDirection === "descending"
? "종목명 정렬: 내림차순, 누르면 오름차순"
: workflow.sortDirection === "ascending"
? "종목명 정렬: 오름차순, 누르면 내림차순"
: "종목명 정렬: 서버 기본 순서, 누르면 내림차순";
elements.tradingHaltNameSort.setAttribute("aria-label", sortLabel);
elements.tradingHaltNameSort.title = sortLabel;
elements.tradingHaltSearchState.className = `trading-halt-search-state ${workflow.status}`;
if (workflow.status === "loading") {
elements.tradingHaltSearchState.textContent = "Oracle의 KOSPI·KOSDAQ 거래 정지 종목을 조회하고 있습니다.";
} else if (workflow.status === "error") {
elements.tradingHaltSearchState.textContent = workflow.error || "거래 정지 종목 조회에 실패했습니다.";
} else if (workflow.status === "ready") {
elements.tradingHaltSearchState.textContent = `${workflow.totalRowCount.toLocaleString("ko-KR")}개 종목${workflow.truncated ? " · 결과 제한 적용" : ""}`;
} else {
elements.tradingHaltSearchState.textContent = "조회 버튼을 누르면 현재 거래 정지 종목을 불러옵니다.";
}
elements.tradingHaltResults.replaceChildren();
if (!workflow.results.length) {
const empty = document.createElement("div");
empty.className = "comparison-target-empty";
empty.textContent = workflow.status === "ready"
? "일치하는 거래 정지 종목이 없습니다."
: "거래 정지 종목 검색 결과가 여기에 표시됩니다.";
elements.tradingHaltResults.append(empty);
} else {
const selectedIdentity = tradingHaltIdentity(workflow.selected);
const displayResults = tradingHaltWorkflow.sortTradingHaltsForDisplay(
workflow.results,
workflow.sortDirection);
for (const item of displayResults) {
const row = document.createElement("button");
row.type = "button";
row.className = "trading-halt-result";
row.disabled = locked;
const selected = tradingHaltIdentity(item) === selectedIdentity;
row.classList.toggle("selected", selected);
row.setAttribute("role", "option");
row.setAttribute("aria-selected", selected ? "true" : "false");
const copy = document.createElement("span");
const name = document.createElement("strong");
const code = document.createElement("small");
name.textContent = item.displayName;
code.textContent = item.stockCode;
copy.append(name, code);
const market = document.createElement("span");
market.className = "trading-halt-market";
market.textContent = item.market.toLocaleUpperCase("en-US");
row.append(copy, market);
row.addEventListener("click", () => selectTradingHalt(item));
elements.tradingHaltResults.append(row);
}
}
elements.selectedTradingHalt.hidden = !workflow.selected;
elements.tradingHaltMa5.disabled = locked || !workflow.selected;
elements.tradingHaltMa20.disabled = locked || !workflow.selected;
if (workflow.selected) {
elements.selectedTradingHaltName.textContent = workflow.selected.displayName;
elements.selectedTradingHaltMeta.textContent = `${workflow.selected.stockCode} · ${workflow.selected.market.toLocaleUpperCase("en-US")}`;
}
elements.tradingHaltActionList.replaceChildren();
tradingHaltWorkflow.actions.forEach((action, index) => {
const row = document.createElement("div");
row.className = "trading-halt-action-row";
row.classList.toggle("disabled", !workflow.selected);
const number = document.createElement("span");
number.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
copy.className = "trading-halt-action-copy";
const label = document.createElement("strong");
const detail = document.createElement("small");
label.textContent = action.label;
detail.textContent = action.kind === "candle"
? `${action.code} · 120일 · MA5/MA20 선택`
: `${action.code} · 1열판 현재가`;
copy.append(label, detail);
const add = document.createElement("button");
add.type = "button";
add.className = "trading-halt-action-add";
add.textContent = "+";
add.disabled = locked || !workflow.selected;
add.title = workflow.selected ? "플레이리스트에 추가" : "거래 정지 종목을 먼저 선택하세요.";
add.addEventListener("click", () => addTradingHaltAction(action.id));
row.addEventListener("dblclick", event => {
if (!event.target.closest("button") && workflow.selected) addTradingHaltAction(action.id);
});
row.append(number, copy, add);
elements.tradingHaltActionList.append(row);
});
}
function requestTradingHaltSearch(event) {
event?.preventDefault();
if (isPlaylistSnapshotLocked()) return;
clearTradingHaltTimeout();
const workflow = state.tradingHalt;
let request;
try {
request = tradingHaltWorkflow.createTradingHaltSearchRequest(
createId(),
String(elements.tradingHaltSearch.value || ""),
tradingHaltWorkflow.bridgeContract.maximumResults);
} catch {
workflow.status = "error";
workflow.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
workflow.results = [];
workflow.totalRowCount = 0;
renderTradingHaltWorkflow();
return;
}
workflow.request = request;
workflow.status = "loading";
workflow.results = [];
workflow.selected = null;
workflow.sortDirection = null;
workflow.totalRowCount = 0;
workflow.truncated = false;
workflow.error = "";
renderTradingHaltWorkflow();
if (!nativeBridge) {
workflow.status = "error";
workflow.error = "브라우저 미리보기에서는 실제 거래 정지 DB 조회를 사용할 수 없습니다.";
renderTradingHaltWorkflow();
return;
}
postNative(tradingHaltWorkflow.bridgeContract.requestType, request);
workflow.timeoutId = setTimeout(() => {
if (workflow.request !== request || workflow.status !== "loading") return;
workflow.status = "error";
workflow.error = "거래 정지 종목 조회 응답 시간이 초과되었습니다.";
renderTradingHaltWorkflow();
}, 30000);
}
function handleTradingHaltResults(payload) {
const workflow = state.tradingHalt;
const request = workflow.request;
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
clearTradingHaltTimeout();
workflow.sortDirection = null;
const response = tradingHaltWorkflow.normalizeTradingHaltSearchResponse(payload, request);
if (!response) {
workflow.status = "error";
workflow.results = [];
workflow.totalRowCount = 0;
workflow.error = "거래 정지 종목 응답 형식 또는 식별자가 올바르지 않습니다.";
} else {
workflow.status = "ready";
workflow.results = [...response.results];
workflow.totalRowCount = response.totalRowCount;
workflow.truncated = response.truncated;
workflow.error = "";
}
renderTradingHaltWorkflow();
}
function handleTradingHaltError(payload) {
const workflow = state.tradingHalt;
const request = workflow.request;
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
clearTradingHaltTimeout();
const error = tradingHaltWorkflow.normalizeTradingHaltError(payload, request);
workflow.status = "error";
workflow.results = [];
workflow.totalRowCount = 0;
workflow.error = error?.message || "거래 정지 종목 조회에 실패했습니다.";
renderTradingHaltWorkflow();
}
function synchronizeTradingHaltEntries() {
let invalid = false;
state.playlist = state.playlist.map(item => {
if (item?.operator?.source !== "legacy-trading-halt-workflow") return item;
const refreshed = tradingHaltWorkflow.refreshTradingHaltPlaylistEntry(item);
if (!refreshed) {
invalid = true;
return item;
}
const definition = catalog.find(value =>
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
if (!definition) {
invalid = true;
return item;
}
return { ...definition, ...refreshed };
});
if (invalid) {
showToast("저장된 거래 정지 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
return false;
}
return true;
}
function clearStockSearchTimeout() {
if (state.stockSearch.timeoutId !== null) clearTimeout(state.stockSearch.timeoutId);
state.stockSearch.timeoutId = null;
}
function stockIdentity(stock) {
if (!stock) return "";
return [stock.market, stock.isNxt === true ? "nxt" : "krx", stock.stockCode, stock.stockName].join("|");
}
function selectStock(stock) {
state.stockSearch.selected = stock;
renderStockWorkflow();
addLog(`종목 선택 · ${stock.displayName} · ${stock.stockCode}`);
}
function selectStockCut(index, event) {
try {
state.stockCutSelection = legacyBatchSelectionWorkflow.applyPointerSelection(
state.stockCutSelection,
index,
{
ctrlKey: event?.ctrlKey === true || event?.metaKey === true,
shiftKey: event?.shiftKey === true
});
renderStockCutSelection();
} catch {
state.stockCutSelection = legacyBatchSelectionWorkflow.createSelection(
operatorWorkflow.stockCuts.length);
renderStockCutSelection();
}
}
function clearStockCutSelection() {
state.stockCutSelection = legacyBatchSelectionWorkflow.createSelection(
operatorWorkflow.stockCuts.length);
renderStockCutSelection();
}
function renderStockCutSelection() {
let selection;
try {
selection = legacyBatchSelectionWorkflow.createSelection(
operatorWorkflow.stockCuts.length,
state.stockCutSelection.selectedIndices,
state.stockCutSelection.anchorIndex);
} catch {
selection = legacyBatchSelectionWorkflow.createSelection(
operatorWorkflow.stockCuts.length);
}
state.stockCutSelection = selection;
const selected = new Set(selection.selectedIndices);
const selectedCount = selection.selectedIndices.length;
const locked = operatorUiLocked();
elements.stockCutSelectionCount.textContent = `${selectedCount}개 선택`;
elements.stockCutBatchAdd.disabled = selectedCount === 0 ||
!state.stockSearch.selected || locked;
elements.stockCutBatchAdd.title = selectedCount === 0
? "Ctrl 또는 Shift와 함께 컷을 선택하세요."
: (!state.stockSearch.selected
? "먼저 검색 결과에서 종목을 선택하세요."
: "선택 컷을 원본 목록 순서로 한 번에 추가합니다.");
elements.stockCutSelectionClear.disabled = selectedCount === 0;
for (const row of elements.stockCutList.querySelectorAll("[data-stock-cut-index]")) {
const index = Number(row.dataset.stockCutIndex);
const isSelected = selected.has(index);
row.classList.toggle("selected", isSelected);
row.setAttribute("aria-selected", isSelected ? "true" : "false");
}
}
function addSelectedStockCuts() {
const selected = state.stockCutSelection.selectedIndices;
if (!selected.length) {
showToast("추가할 종목 컷을 먼저 선택하세요.");
return;
}
addStockCuts(selected.map(index => operatorWorkflow.stockCuts[index]?.id));
}
function addStockCut(cutId) {
addStockCuts([cutId]);
}
function addStockCuts(cutIds) {
if (!requirePlaylistEditable()) return;
const stock = state.stockSearch.selected;
if (!stock) {
showToast("먼저 종목을 검색하고 결과에서 한 종목을 선택하세요.");
elements.stockSearchInput.focus();
return;
}
const cutsById = new Map(operatorWorkflow.stockCuts.map(cut => [cut.id, cut]));
const ids = Array.isArray(cutIds) ? cutIds : [];
const cuts = ids.map(id => cutsById.get(id));
if (!ids.length || cuts.some(cut => !cut) || new Set(ids).size !== ids.length) {
showToast("선택한 종목 컷 목록이 올바르지 않아 아무것도 추가하지 않았습니다.");
return;
}
if (cuts.some(cut => !operatorWorkflow.isStockCutAllowed(stock, cut.id))) {
showToast(stock.isNxt
? "선택한 컷 중 NXT가 지원하지 않는 항목이 있어 아무것도 추가하지 않았습니다. NXT는 1열판기본_현재가만 지원합니다."
: "지원하지 않는 종목 컷이 포함되어 아무것도 추가하지 않았습니다.");
return;
}
try {
const entries = legacyBatchSelectionWorkflow.materializeAtomicBatch(cuts, cut => {
const created = operatorWorkflow.createStockPlaylistEntry(stock, cut.id, {
id: createId(),
fadeDuration: DEFAULT_FADE_DURATION,
ma5: state.candleOptions.ma5,
ma20: state.candleOptions.ma20
});
const definition = catalog.find(item => item.builderKey === created.builderKey);
if (!definition || !sceneAliases(definition).includes(String(created.code || ""))) {
throw new Error("The operator cut does not resolve to a registered scene alias.");
}
return {
...definition,
...created,
enabled: true
};
});
state.playlist.push(...entries);
state.selectedRows.clear();
entries.forEach(entry => state.selectedRows.add(entry.id));
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${stock.displayName} 종목 컷 일괄 추가 · ${entries.length}개 · 원본 목록 순서`);
showToast(entries.length === 1
? `${stock.displayName} 컷을 플레이리스트에 추가했습니다.`
: `${stock.displayName}${entries.length}개를 순서대로 추가했습니다.`);
} catch {
showToast("선택한 종목 컷 전체를 안전한 장면으로 구성하지 못해 아무것도 추가하지 않았습니다.");
}
}
function renderGlobalCandleOptions() {
const { ma5, ma20 } = state.candleOptions;
for (const element of [elements.globalMa5, elements.stockMa5, elements.overseasMa5, elements.tradingHaltMa5]) {
if (element) element.checked = ma5;
}
for (const element of [elements.globalMa20, elements.stockMa20, elements.overseasMa20, elements.tradingHaltMa20]) {
if (element) element.checked = ma20;
}
const locked = isPlaylistSnapshotLocked();
elements.globalMa5.disabled = locked;
elements.globalMa20.disabled = locked;
}
function synchronizeGlobalCandleOptions(render = false) {
if (isPlaylistSnapshotLocked()) {
renderGlobalCandleOptions();
return false;
}
const { ma5, ma20 } = state.candleOptions;
const result = candleOptionsWorkflow.synchronizePlaylist(
state.playlist,
ma5,
ma20,
{
stock: operatorWorkflow,
fixed: fixedWorkflow,
industry: industryWorkflow,
overseas: overseasWorkflow,
tradingHalt: tradingHaltWorkflow
},
new Date());
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;
});
renderGlobalCandleOptions();
if (render && (result.changed || !result.valid)) {
renderPlaylist();
updatePreview();
}
if (!result.valid) {
if (render) {
addLog(`캔들 이동평균선 동기화 차단 · 신뢰 복원 실패 ${result.invalidIds.length}`);
showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다. 해당 항목을 삭제하고 다시 추가하세요.");
}
return false;
}
if (result.changed && render) {
addLog(`전체 캔들 이동평균선 적용 · 5일선 ${ma5 ? "표시" : "숨김"} · 20일선 ${ma20 ? "표시" : "숨김"}`);
}
return true;
}
function applyGlobalCandleOptions(ma5, ma20, render = true) {
if (isPlaylistSnapshotLocked()) {
renderGlobalCandleOptions();
return false;
}
try {
const normalized = candleOptionsWorkflow.normalizeOptions(Boolean(ma5), Boolean(ma20));
state.candleOptions.ma5 = normalized.ma5;
state.candleOptions.ma20 = normalized.ma20;
return synchronizeGlobalCandleOptions(render);
} catch {
renderGlobalCandleOptions();
return false;
}
}
function synchronizeStockCandleOptions(render = false) {
return applyGlobalCandleOptions(elements.stockMa5.checked, elements.stockMa20.checked, render);
}
function renderStockWorkflow() {
renderGlobalCandleOptions();
// In MainForm this was a top-level left panel, not content owned by the
// dashboard/KOSPI/KOSDAQ tabs. Keep the same search result and selected
// stock available while the operator moves between every business view.
elements.stockWorkflow.hidden = false;
elements.stockWorkflow.classList.toggle("collapsed", state.stockWorkflowCollapsed);
elements.stockWorkflowToggle.textContent = state.stockWorkflowCollapsed ? "펼치기" : "접기";
elements.stockWorkflowToggle.setAttribute("aria-expanded", String(!state.stockWorkflowCollapsed));
const search = state.stockSearch;
const playlistLocked = isPlaylistSnapshotLocked();
elements.stockMa5.disabled = playlistLocked;
elements.stockMa20.disabled = playlistLocked;
elements.stockSearchButton.disabled = search.status === "loading";
elements.stockSearchBadge.textContent = search.status === "loading" ? "SEARCHING" : "DB SEARCH";
elements.stockSearchState.className = `stock-search-state ${search.status}`;
if (search.status === "loading") {
elements.stockSearchState.textContent = `${search.query}” 종목을 Oracle/MariaDB에서 조회하고 있습니다.`;
} else if (search.status === "error") {
elements.stockSearchState.textContent = search.error || "종목 검색에 실패했습니다.";
} else if (search.status === "ready") {
const suffix = search.truncated ? " · 결과 제한 적용" : "";
elements.stockSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 종목 검색 완료${suffix}`;
} else {
elements.stockSearchState.textContent = "종목명을 입력하고 Enter를 누르세요.";
}
elements.stockSearchResults.replaceChildren();
if (!search.results.length) {
const empty = document.createElement("div");
empty.className = "stock-results-empty";
empty.textContent = search.status === "ready" ? "일치하는 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.";
elements.stockSearchResults.append(empty);
} else {
const selectedIdentity = stockIdentity(search.selected);
for (const stock of search.results) {
const row = document.createElement("button");
row.className = "stock-result";
row.type = "button";
row.setAttribute("role", "option");
const selected = stockIdentity(stock) === selectedIdentity;
row.classList.toggle("selected", selected);
row.setAttribute("aria-selected", selected ? "true" : "false");
const copy = document.createElement("span");
const name = document.createElement("strong");
const meta = document.createElement("small");
name.textContent = stock.displayName;
meta.textContent = `${stock.stockCode} · ${stock.sourceLabel || stock.source || "DB"}`;
copy.append(name, meta);
const market = document.createElement("span");
market.className = "stock-market-badge";
market.textContent = stock.marketLabel || `${stock.market}${stock.isNxt ? " NXT" : ""}`;
row.append(copy, market);
row.addEventListener("click", () => selectStock(stock));
elements.stockSearchResults.append(row);
}
}
elements.selectedStock.hidden = !search.selected;
if (search.selected) {
elements.selectedStockName.textContent = search.selected.displayName;
elements.selectedStockMeta.textContent = `${search.selected.marketLabel || search.selected.market} · ${search.selected.stockCode}`;
}
elements.stockCutList.replaceChildren();
let previousSection = null;
operatorWorkflow.stockCuts.forEach((cut, index) => {
if (cut.section !== previousSection) {
const section = document.createElement("div");
section.className = "stock-cut-section";
section.textContent = cut.section;
elements.stockCutList.append(section);
previousSection = cut.section;
}
const allowed = Boolean(search.selected) && operatorWorkflow.isStockCutAllowed(search.selected, cut.id);
const row = document.createElement("div");
row.className = "stock-cut-row";
row.dataset.stockCutIndex = String(index);
row.setAttribute("role", "option");
row.tabIndex = 0;
row.classList.toggle("disabled", !allowed);
const number = document.createElement("span");
number.className = "stock-cut-number";
number.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
copy.className = "stock-cut-copy";
const label = document.createElement("strong");
const detail = document.createElement("small");
label.textContent = cut.label;
detail.textContent = `${cut.builderKey} · ${cut.detail || "원본 종목 컷"}`;
copy.append(label, detail);
const add = document.createElement("button");
add.className = "stock-cut-add";
add.type = "button";
add.textContent = "+";
add.disabled = !allowed || isPlaylistSnapshotLocked();
add.title = !search.selected
? "먼저 종목을 선택하세요."
: (!allowed ? "이 종목 시장에서는 지원하지 않는 컷입니다." : "플레이리스트에 추가");
add.addEventListener("click", event => {
event.stopPropagation();
addStockCut(cut.id);
});
row.addEventListener("click", event => {
if (event.target.closest("button")) return;
selectStockCut(index, event);
});
row.addEventListener("dblclick", event => {
if (event.target.closest("button")) return;
if (allowed && legacyBatchSelectionWorkflow.canActivateSingle(
state.stockCutSelection,
index)) addStockCut(cut.id);
});
row.addEventListener("keydown", event => {
if (event.repeat) return;
if (event.key === " ") {
event.preventDefault();
selectStockCut(index, event);
} else if (event.key === "Enter") {
event.preventDefault();
if (!legacyBatchSelectionWorkflow.canActivateSingle(
state.stockCutSelection,
index)) selectStockCut(index, {});
if (allowed) addStockCut(cut.id);
}
});
row.append(number, copy, add);
elements.stockCutList.append(row);
});
renderStockCutSelection();
elements.manualStockActions.replaceChildren();
for (const action of operatorWorkflow.manualStockActions) {
const button = document.createElement("button");
button.type = "button";
button.textContent = action.label;
button.disabled = !search.selected || operatorUiLocked();
button.title = !search.selected
? "먼저 종목을 검색하고 선택하세요."
: `${action.prerequisiteTable} 원본 수동 입력 화면 열기`;
button.addEventListener("click", () => manualFinancialUi?.open(action));
elements.manualStockActions.append(button);
}
}
function requestStockSearch(event) {
event?.preventDefault();
clearStockSearchTimeout();
const query = String(elements.stockSearchInput.value || "").trim();
if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
state.stockSearch.status = "error";
state.stockSearch.error = "1~64자의 종목명을 입력하세요.";
state.stockSearch.results = [];
state.stockSearch.selected = null;
renderStockWorkflow();
return;
}
if (!nativeBridge) {
state.stockSearch.status = "error";
state.stockSearch.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다.";
renderStockWorkflow();
return;
}
const requestId = createId();
state.stockSearch.requestId = requestId;
state.stockSearch.query = query;
state.stockSearch.status = "loading";
state.stockSearch.results = [];
state.stockSearch.selected = null;
state.stockSearch.totalRowCount = 0;
state.stockSearch.truncated = false;
state.stockSearch.error = "";
renderStockWorkflow();
postNative("search-stocks", { requestId, query, maximumResults: 200 });
state.stockSearch.timeoutId = setTimeout(() => {
if (state.stockSearch.requestId !== requestId || state.stockSearch.status !== "loading") return;
state.stockSearch.status = "error";
state.stockSearch.error = "종목 검색 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 검색하세요.";
renderStockWorkflow();
addLog(`종목 검색 시간 초과 · ${query}`);
}, 30000);
}
function handleStockSearchResults(payload) {
if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return;
clearStockSearchTimeout();
const results = Array.isArray(payload.results) ? payload.results : [];
state.stockSearch.results = results.slice(0, 500).flatMap(value => {
try {
const normalized = operatorWorkflow.normalizeStockResult(value);
return normalized ? [normalized] : [];
} catch { return []; }
});
state.stockSearch.status = "ready";
state.stockSearch.totalRowCount = Number.isInteger(payload.totalRowCount)
? Math.max(0, payload.totalRowCount)
: state.stockSearch.results.length;
state.stockSearch.truncated = payload.truncated === true;
state.stockSearch.error = "";
renderStockWorkflow();
addLog(`종목 검색 완료 · ${state.stockSearch.query} · ${state.stockSearch.results.length}건 표시`);
}
function handleStockSearchError(payload) {
if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return;
clearStockSearchTimeout();
state.stockSearch.status = "error";
state.stockSearch.results = [];
state.stockSearch.selected = null;
state.stockSearch.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "종목 검색에 실패했습니다.";
renderStockWorkflow();
addLog(`종목 검색 실패 · ${state.stockSearch.query}`);
}
function addToPlaylist(item) {
if (item.reachable === false || !requirePlaylistEditable()) return;
if (operatorInputBuilderKeys.has(item.builderKey)) {
if (["s5026", "s5029", "s5087"].includes(item.builderKey)) {
showToast("비교 장면은 두 종목을 확정하는 전용 입력 화면에서 추가해야 합니다.");
} else if (item.builderKey === "s5025") {
showToast("수동 순매도 장면은 투자자별 수동 목록 화면에서 추가해야 합니다.");
} else if (["s5074", "s5077", "s5088"].includes(item.builderKey)) {
showToast("5·6·12행 페이지 장면은 지수·업종·테마 등 원본 전용 입력 화면에서 추가해야 합니다.");
} else if (["s5076", "s5079", "s5080", "s5081"].includes(item.builderKey)) {
showToast("이 장면은 원본 수동 재무 데이터 화면의 저장 항목에서 추가해야 합니다.");
} else {
showToast("종목 검색 결과와 종목용 컷을 선택해 추가하세요.");
elements.stockSearchInput.focus();
}
return;
}
const selection = defaultSceneSelection(item.builderKey);
const entry = {
...item,
id: createId(),
selection,
enabled: true,
fadeDuration: DEFAULT_FADE_DURATION,
graphicType: selection?.graphicType || "",
subtype: selection?.subtype || ""
};
state.playlist.push(entry);
state.selectedRows.clear();
state.selectedRows.add(entry.id);
state.currentIndex = state.playlist.length - 1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`${item.code} ${item.title} 항목 추가`);
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();
elements.playlistCount.textContent = `${state.playlist.length} CUTS`;
elements.playlistEmpty.classList.toggle("show", state.playlist.length === 0);
const allSelected = state.playlist.length > 0 &&
state.playlist.every(item => state.selectedRows.has(item.id));
const allEnabled = state.playlist.length > 0 &&
state.playlist.every(item => item.enabled !== false);
elements.selectAll.textContent = allSelected ? "작업 선택해제" : "작업 전체선택";
elements.toggleAllEnabled.textContent = allEnabled ? "전체 송출 해제" : "전체 송출";
state.playlist.forEach((item, index) => {
const row = document.createElement("tr");
const namedGuard = state.namedPlaylist.guardByEntryId.get(item.id);
const manualFinancialRestore = state.manualFinancialRestore.entries.get(item.id);
row.draggable = !isPlaylistSnapshotLocked();
row.dataset.index = String(index);
row.classList.toggle("active", index === state.currentIndex);
row.classList.toggle("selected", state.selectedRows.has(item.id));
row.classList.toggle("named-restore-invalid", namedGuard?.trusted === false);
row.classList.toggle("named-page-pending", namedGuard?.trusted === true &&
namedGuard.pageRecalculationRequired && !namedGuard.pageRecalculated);
row.classList.toggle("named-restore-invalid", manualFinancialRestore?.status === "error");
row.classList.toggle("named-page-pending", Boolean(manualFinancialRestore) &&
manualFinancialRestore.status !== "error");
row.setAttribute("aria-selected", state.selectedRows.has(item.id) ? "true" : "false");
const checkCell = document.createElement("td");
checkCell.className = "check-cell";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = item.enabled !== false;
checkbox.disabled = isPlaylistSnapshotLocked();
checkbox.setAttribute("aria-label", `${item.title} 송출 포함`);
checkbox.addEventListener("click", event => event.stopPropagation());
checkbox.addEventListener("change", event => {
event.stopPropagation();
if (!requirePlaylistEditable()) {
checkbox.checked = item.enabled !== false;
return;
}
const replacement = replacePlaylistEntryEnabledAt(index, checkbox.checked);
if (!replacement) {
checkbox.checked = item.enabled !== false;
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
return;
}
renderPlaylist();
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
});
checkCell.append(checkbox);
const number = document.createElement("td");
number.className = "number-cell";
number.textContent = String(index + 1).padStart(2, "0");
const targetCell = document.createElement("td");
targetCell.className = "playlist-target-cell";
const target = document.createElement("strong");
const targetMeta = document.createElement("small");
target.textContent = item.selection?.subject || item.title;
targetMeta.textContent = [item.selection?.groupCode, item.selection?.dataCode]
.filter(Boolean).join(" · ") || item.detail;
targetCell.append(target, targetMeta);
const cutCell = document.createElement("td");
cutCell.className = "playlist-cut-cell";
const cutTitle = document.createElement("strong");
const cutMeta = document.createElement("small");
cutTitle.textContent = item.operator?.legacyLabel || item.title;
cutMeta.textContent = `${item.code} · ${item.builderKey}`;
cutCell.append(cutTitle, cutMeta);
const detailCell = document.createElement("td");
detailCell.className = "playlist-detail-cell";
const detailTitle = document.createElement("strong");
detailTitle.textContent = [item.selection?.graphicType, item.selection?.subtype]
.filter(Boolean).join(" · ") || item.detail;
const type = document.createElement("span");
type.className = "row-type";
let rowType = item.category.toUpperCase();
if (namedGuard?.pageRecalculationRequired && !namedGuard.pageRecalculated) rowType = "PAGE 재계산";
if (namedGuard?.recalculatedPagePlan?.isTruncated === true) rowType = "20 PAGE 제한";
if (namedGuard?.pageUnavailableReason === "empty-result") rowType = "DB 결과 없음";
if (namedGuard?.trusted === false) rowType = "복원 필요";
if (manualFinancialRestore) rowType = manualFinancialRestore.status === "error"
? "재무 복원 실패"
: "재무 DB 확인";
type.textContent = rowType;
const detailMeta = document.createElement("small");
detailMeta.append(type);
detailCell.append(detailTitle, detailMeta);
const page = document.createElement("td");
page.className = "page-cell";
const freshNamedPagePlan = namedGuard?.recalculatedPagePlan;
page.textContent = item.id === state.playout.currentEntryId && state.playout.pageCount > 0
? `${state.playout.pageIndex + 1}/${state.playout.pageCount}`
: (freshNamedPagePlan?.itemCount === 0
? "0건"
: (Number.isInteger(freshNamedPagePlan?.pageCount)
? `1/${freshNamedPagePlan.pageCount}`
: (item.pageText || "1/1")));
if (namedGuard?.rawRow) {
const historical = namedPlaylistWorkflow.pageStatusLabel(namedGuard.rawRow);
const current = freshNamedPagePlan
? `현재 DB ${freshNamedPagePlan.itemCount}건 · ${freshNamedPagePlan.pageCount}페이지` +
(freshNamedPagePlan.isTruncated ? ` · ${freshNamedPagePlan.accessibleItemCount}건까지만 송출` : "")
: "현재 DB 페이지 조회 전";
page.title = `${historical} / ${current}`;
}
const drag = document.createElement("td");
drag.className = "drag-cell";
drag.textContent = "⠿";
row.append(checkCell, number, targetCell, cutCell, detailCell, page, drag);
row.addEventListener("click", event => selectPlaylistRow(index, event));
row.addEventListener("dragstart", onDragStart);
row.addEventListener("dragover", onDragOver);
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
row.addEventListener("drop", onDrop);
row.addEventListener("dragend", onDragEnd);
elements.playlistBody.append(row);
});
}
function selectPlaylistRow(index, event) {
if (isPlaylistSnapshotLocked()) return;
const item = state.playlist[index];
if (!item) return;
if (event?.shiftKey) {
const anchor = state.selectionAnchorIndex >= 0 && state.selectionAnchorIndex < state.playlist.length
? state.selectionAnchorIndex
: (state.currentIndex >= 0 ? state.currentIndex : index);
if (!(event.ctrlKey || event.metaKey)) state.selectedRows.clear();
const from = Math.min(anchor, index);
const to = Math.max(anchor, index);
for (let value = from; value <= to; value++) state.selectedRows.add(state.playlist[value].id);
} else if (event?.ctrlKey || event?.metaKey) {
if (state.selectedRows.has(item.id)) state.selectedRows.delete(item.id);
else state.selectedRows.add(item.id);
} else {
state.selectedRows.clear();
state.selectedRows.add(item.id);
}
if (!event?.shiftKey) state.selectionAnchorIndex = index;
state.currentIndex = index;
renderPlaylist();
updatePreview();
}
function updatePreview() {
const item = state.playlist[state.currentIndex];
if (!item) {
elements.previewTitle.textContent = "선택된 그래픽 없음";
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
renderSceneSelectionForm(null);
renderPlayout();
return;
}
elements.previewTitle.textContent = item.title;
const selection = item.selection?.subject
? ` · ${item.selection.subject}${item.selection.dataCode ? ` (${item.selection.dataCode})` : ""}`
: "";
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}${selection}`;
renderSceneSelectionForm(item);
renderPlayout();
}
function renderSceneSelectionForm(item) {
const selection = item?.selection || {};
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 || "");
elements.sceneSubtype.value = String(selection.subtype || "");
elements.sceneDataCode.value = String(selection.dataCode || "");
elements.sceneCutAlias.replaceChildren();
for (const alias of item ? sceneAliases(item) : []) {
const option = document.createElement("option");
option.value = alias;
option.textContent = alias;
elements.sceneCutAlias.append(option);
}
elements.sceneCutAlias.value = String(item?.code || "");
elements.sceneFadeDuration.value = String(Number.isInteger(Number(item?.fadeDuration))
? Number(item.fadeDuration)
: DEFAULT_FADE_DURATION);
for (const input of [
elements.sceneGroupCode,
elements.sceneSubject,
elements.sceneGraphicType,
elements.sceneSubtype,
elements.sceneDataCode,
elements.sceneCutAlias,
elements.sceneFadeDuration
]) input.disabled = disabled;
elements.applySceneSelectionButton.disabled = disabled;
}
function applySceneSelection(event) {
event.preventDefault();
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,
elements.sceneGraphicType.value,
elements.sceneSubtype.value,
elements.sceneDataCode.value
];
if (values.some(value => value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))) {
showToast("장면 조회값 형식이 올바르지 않습니다.");
return;
}
const fadeDuration = Number(elements.sceneFadeDuration.value);
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
showToast("FADE 값은 0~60 사이의 정수여야 합니다.");
return;
}
const cutAlias = String(elements.sceneCutAlias.value || "");
if (!sceneAliases(item).includes(cutAlias)) {
showToast("허용되지 않은 CUT alias입니다.");
return;
}
const replacement = recreatePlaylistEntryWithSceneSelection(item, {
groupCode: values[0].trim(),
subject: values[1].trim(),
graphicType: values[2].trim(),
subtype: values[3].trim(),
dataCode: values[4].trim()
}, cutAlias, fadeDuration);
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
renderSceneSelectionForm(item);
showToast("장면 조회값을 안전하게 적용할 수 없습니다.");
return;
}
renderPlaylist();
updatePreview();
addLog(`${replacement.code} 장면 조회값 적용`);
showToast("장면 조회값을 적용했습니다.");
}
function applyCutAliasPreset() {
const item = state.playlist[state.currentIndex];
if (!item) return;
const alias = String(elements.sceneCutAlias.value || "");
if (item.builderKey === "s5001") {
elements.sceneGroupCode.value = alias === "N5001" ? "NXT_KOSPI" : "KOSPI";
} else if (item.builderKey === "s8001") {
elements.sceneSubject.value = alias === "8002" ? "KOSDAQ" : "KOSPI";
}
}
function onDragStart(event) {
if (!requirePlaylistEditable()) {
event.preventDefault();
return;
}
state.dragIndex = Number(event.currentTarget.dataset.index);
event.currentTarget.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(state.dragIndex));
}
function onDragOver(event) {
if (isPlaylistSnapshotLocked()) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
event.currentTarget.classList.add("drag-over");
}
function onDrop(event) {
event.preventDefault();
if (!requirePlaylistEditable()) return;
const targetIndex = Number(event.currentTarget.dataset.index);
if (state.dragIndex < 0 || state.dragIndex === targetIndex) return;
const [moved] = state.playlist.splice(state.dragIndex, 1);
state.playlist.splice(targetIndex, 0, moved);
state.currentIndex = targetIndex;
state.selectionAnchorIndex = targetIndex;
state.dragIndex = -1;
renderPlaylist();
updatePreview();
addLog("플레이리스트 순서 변경");
}
function onDragEnd() {
state.dragIndex = -1;
document.querySelectorAll("tr.dragging, tr.drag-over").forEach(row => row.classList.remove("dragging", "drag-over"));
}
function toggleSelectAll() {
if (!requirePlaylistEditable()) return;
const allSelected = state.playlist.length > 0 && state.playlist.every(item => state.selectedRows.has(item.id));
state.selectedRows.clear();
if (!allSelected) state.playlist.forEach(item => state.selectedRows.add(item.id));
state.selectionAnchorIndex = !allSelected && state.playlist.length ? 0 : -1;
renderPlaylist();
}
function toggleAllEntriesEnabled() {
if (!requirePlaylistEditable()) return;
if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다.");
const enable = state.playlist.some(item => item.enabled === false);
const replacements = state.playlist.map(item => recreatePlaylistEntryWithEnabled(item, enable));
if (replacements.some(item => !item)) {
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
return;
}
state.playlist = replacements;
renderPlaylist();
addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`);
}
function moveSelected(direction) {
if (!requirePlaylistEditable()) return;
if (!state.selectedRows.size) return showToast("이동할 항목을 선택하세요.");
const currentId = state.playlist[state.currentIndex]?.id;
let changed = false;
if (direction < 0) {
for (let index = 1; index < state.playlist.length; index++) {
if (state.selectedRows.has(state.playlist[index].id) &&
!state.selectedRows.has(state.playlist[index - 1].id)) {
[state.playlist[index - 1], state.playlist[index]] = [state.playlist[index], state.playlist[index - 1]];
changed = true;
}
}
} else {
for (let index = state.playlist.length - 2; index >= 0; index--) {
if (state.selectedRows.has(state.playlist[index].id) &&
!state.selectedRows.has(state.playlist[index + 1].id)) {
[state.playlist[index], state.playlist[index + 1]] = [state.playlist[index + 1], state.playlist[index]];
changed = true;
}
}
}
if (!changed) return;
state.currentIndex = state.playlist.findIndex(item => item.id === currentId);
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`선택 항목 ${direction < 0 ? "위" : "아래"}로 이동`);
}
function deleteSelected() {
if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요.");
const restoreRecords = [...state.manualFinancialRestore.entries.values()];
const deletingFailedRestores = restoreRecords.length > 0 &&
restoreRecords.every(record => record.status === "error") &&
[...state.selectedRows].every(id =>
state.manualFinancialRestore.entries.get(id)?.status === "error");
if (!requirePlaylistEditable(deletingFailedRestores)) return;
const deleted = state.selectedRows.size;
for (const id of state.selectedRows) {
const restore = state.manualFinancialRestore.entries.get(id);
clearManualFinancialRestoreTimeout(restore);
state.manualFinancialRestore.entries.delete(id);
}
state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id));
state.selectedRows.clear();
state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1);
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
renderNamedPlaylist();
updatePreview();
addLog(`${deleted}개 항목 삭제`);
requestNextManualFinancialRestore();
}
function deleteAllPlaylistEntries() {
const restoreRecords = [...state.manualFinancialRestore.entries.values()];
const failedRestoreOnly = restoreRecords.length > 0 &&
restoreRecords.every(record => record.status === "error");
if (!requirePlaylistEditable(failedRestoreOnly)) return;
if (!state.playlist.length) return;
if (!window.confirm("플레이리스트의 모든 항목을 삭제할까요?")) return;
const deleted = state.playlist.length;
installManualFinancialRestores([]);
clearNamedPlaylistLoadState();
state.playlist = [];
state.selectedRows.clear();
state.currentIndex = -1;
state.selectionAnchorIndex = -1;
renderPlaylist();
renderNamedPlaylist();
updatePreview();
addLog(`플레이리스트 전체 ${deleted}개 항목 삭제`);
}
function selectPlaylistBoundary(index, extend) {
if (!requirePlaylistEditable() || !state.playlist.length) return;
const target = Math.max(0, Math.min(state.playlist.length - 1, index));
selectPlaylistRow(target, { shiftKey: extend, ctrlKey: false, metaKey: false });
elements.playlistBody.querySelector(`tr[data-index="${target}"]`)?.scrollIntoView({ block: "nearest" });
}
function toggleCurrentEntryEnabled() {
if (!requirePlaylistEditable()) return;
const item = state.playlist[state.currentIndex];
if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요.");
const replacement = replacePlaylistEntryEnabledAt(state.currentIndex, item.enabled === false);
if (!replacement) {
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
return;
}
renderPlaylist();
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
}
function savePlaylist() {
localStorage.setItem(storageKey, JSON.stringify(state.playlist));
addLog(`플레이리스트 ${state.playlist.length}개 로컬 저장`);
showToast("플레이리스트를 저장했습니다.");
}
function normalizeStoredSelection(value) {
if (!value || typeof value !== "object") return undefined;
const result = {};
for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) {
const text = typeof value[key] === "string" ? value[key] : "";
if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return undefined;
result[key] = text;
}
return result;
}
function normalizeStoredPlaylist(value, options = {}) {
if (!Array.isArray(value)) return [];
return value.slice(0, 1000).flatMap((item, itemIndex) => {
if (!item || typeof item !== "object") return [];
const restoredFixedEntry = fixedWorkflow.restoreFixedPlaylistEntry(item, { now: options.now });
if (restoredFixedEntry) {
const fixedDefinition = catalog.find(definition =>
definition.builderKey === restoredFixedEntry.builderKey &&
sceneAliases(definition).includes(restoredFixedEntry.code));
return fixedDefinition
? [{ ...fixedDefinition, ...restoredFixedEntry }]
: [];
}
const restoredIndustryEntry = industryWorkflow.restoreIndustryPlaylistEntry(item, { now: options.now });
if (restoredIndustryEntry) {
const industryDefinition = catalog.find(definition =>
definition.builderKey === restoredIndustryEntry.builderKey &&
sceneAliases(definition).includes(restoredIndustryEntry.code));
return industryDefinition
? [{ ...industryDefinition, ...restoredIndustryEntry }]
: [];
}
const restoredComparisonEntry = comparisonWorkflow.restoreComparisonPlaylistEntry(item);
if (restoredComparisonEntry) {
const comparisonDefinition = catalog.find(definition =>
definition.builderKey === restoredComparisonEntry.builderKey &&
sceneAliases(definition).includes(restoredComparisonEntry.code));
return comparisonDefinition
? [{ ...comparisonDefinition, ...restoredComparisonEntry }]
: [];
}
const restoredThemeEntry = themeWorkflow.restoreThemePlaylistEntry(item);
if (restoredThemeEntry) {
const themeDefinition = catalog.find(definition =>
definition.builderKey === restoredThemeEntry.builderKey &&
sceneAliases(definition).includes(restoredThemeEntry.code));
return themeDefinition
? [{ ...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 =>
definition.builderKey === restoredOverseasEntry.builderKey &&
sceneAliases(definition).includes(restoredOverseasEntry.code));
return overseasDefinition
? [{ ...overseasDefinition, ...restoredOverseasEntry }]
: [];
}
const restoredExpertEntry = expertWorkflow.restoreExpertPlaylistEntry(item);
if (restoredExpertEntry) {
const expertDefinition = catalog.find(definition =>
definition.builderKey === restoredExpertEntry.builderKey &&
sceneAliases(definition).includes(restoredExpertEntry.code));
return expertDefinition
? [{ ...expertDefinition, ...restoredExpertEntry }]
: [];
}
const restoredTradingHaltEntry = tradingHaltWorkflow.restoreTradingHaltPlaylistEntry(item);
if (restoredTradingHaltEntry) {
const tradingHaltDefinition = catalog.find(definition =>
definition.builderKey === restoredTradingHaltEntry.builderKey &&
sceneAliases(definition).includes(restoredTradingHaltEntry.code));
return tradingHaltDefinition
? [{ ...tradingHaltDefinition, ...restoredTradingHaltEntry }]
: [];
}
const restoredManualListEntry = manualListsWorkflow.restorePlaylistEntry(item);
if (restoredManualListEntry) {
const pendingManualListEntry = namedManualRestoreWorkflow.createStoredManualListPending(
restoredManualListEntry,
itemIndex);
if (!pendingManualListEntry) return [];
const manualListDefinition = catalog.find(definition =>
definition.builderKey === restoredManualListEntry.builderKey &&
sceneAliases(definition).includes(restoredManualListEntry.code));
if (manualListDefinition && Array.isArray(options.manualFinancialRestores)) {
options.manualFinancialRestores.push(pendingManualListEntry);
}
return manualListDefinition
? [{ ...manualListDefinition, ...restoredManualListEntry }]
: [];
}
const pendingManualFinancialEntry = manualFinancialWorkflow.restorePlaylistEntry(item);
if (pendingManualFinancialEntry) {
const manualFinancialDefinition = catalog.find(definition =>
definition.builderKey === pendingManualFinancialEntry.entry.builderKey &&
sceneAliases(definition).includes(pendingManualFinancialEntry.entry.code));
if (!manualFinancialDefinition) return [];
if (Array.isArray(options.manualFinancialRestores)) {
options.manualFinancialRestores.push(pendingManualFinancialEntry);
}
return [{ ...manualFinancialDefinition, ...pendingManualFinancialEntry.entry }];
}
const restoredOperatorEntry = operatorWorkflow.restoreStockPlaylistEntry(item);
if (restoredOperatorEntry) {
const operatorDefinition = catalog.find(definition =>
definition.builderKey === restoredOperatorEntry.builderKey &&
sceneAliases(definition).includes(restoredOperatorEntry.code));
return operatorDefinition
? [{ ...operatorDefinition, ...restoredOperatorEntry }]
: [];
}
if (operatorInputBuilderKeys.has(String(item.builderKey || "")) || item.operator !== undefined) {
return [];
}
const storedCode = String(item.code || "");
const definitionIndex = playoutSafety.resolveStoredCatalogIndex(
catalog,
item.builderKey,
storedCode);
const definition = definitionIndex >= 0 ? catalog[definitionIndex] : null;
if (!definition) return [];
if (operatorInputBuilderKeys.has(definition.builderKey)) return [];
const id = typeof item.id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(item.id)
? item.id
: createId();
const selection = normalizeStoredSelection(item.selection);
if (!selection) return [];
const fadeDuration = Number(item.fadeDuration);
return [{
...definition,
id,
code: sceneAliases(definition).includes(storedCode) ? storedCode : definition.code,
enabled: item.enabled !== false,
fadeDuration: Number.isInteger(fadeDuration) && fadeDuration >= 0 && fadeDuration <= 60
? fadeDuration
: DEFAULT_FADE_DURATION,
...(selection ? {
selection,
graphicType: selection.graphicType,
subtype: selection.subtype
} : {})
}];
});
}
function manualFinancialRestoreRecordForRequest(requestId, status) {
if (typeof requestId !== "string") return null;
for (const record of state.manualFinancialRestore.entries.values()) {
const expectedId = record.request?.requestId ?? record.request?.payload?.requestId;
if (record.status === status && expectedId === requestId) return record;
}
return null;
}
function clearManualFinancialRestoreTimeout(record) {
if (record?.timeoutId !== null && record?.timeoutId !== undefined) {
clearTimeout(record.timeoutId);
record.timeoutId = null;
}
}
function failManualFinancialRestore(record, message) {
if (!record || !state.manualFinancialRestore.entries.has(record.pending.entry.id)) return;
clearManualFinancialRestoreTimeout(record);
record.status = "error";
record.request = null;
record.error = typeof message === "string" && message.trim()
? message.trim()
: "수동 재무 항목을 현재 DB 값으로 다시 확인하지 못했습니다.";
renderPlaylist();
renderPlayout();
const restoreLabel = record.pending.identity?.stockName || record.pending.audience ||
(record.pending.kind === "vi" ? "VI" : "manual");
addLog(`수동 입력 복원 차단 · ${restoreLabel} · ${record.error}`);
requestNextManualFinancialRestore();
}
function armManualFinancialRestoreTimeout(record) {
clearManualFinancialRestoreTimeout(record);
const expectedRequestId = record.request?.requestId ?? record.request?.payload?.requestId;
record.timeoutId = setTimeout(() => {
if ((record.request?.requestId ?? record.request?.payload?.requestId) !== expectedRequestId) return;
failManualFinancialRestore(record, "수동 재무 복원 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.");
}, 30000);
}
function requestManualFinancialRestoreLoad(record) {
try {
if (["named-list", "stored-list"].includes(record.restoreKind)) {
const envelope = namedManualRestoreWorkflow.createManualListReadRequest(
`named-manual-read-${createId()}`,
record.pending);
record.status = "manual-list";
record.request = envelope;
record.error = "";
postNative(envelope.type, envelope.payload);
armManualFinancialRestoreTimeout(record);
return;
}
const request = record.restoreKind === "named-financial"
? manualFinancialWorkflow.createLoadRequest(
`named-financial-load-${createId()}`,
record.pending.screen,
record.pending.stockName)
: manualFinancialWorkflow.createRestoreLoadRequest(
`manual-restore-load-${createId()}`,
record.pending);
record.status = "load";
record.request = request;
record.error = "";
postNative(manualFinancialWorkflow.bridgeContract.loadRequestType, request);
armManualFinancialRestoreTimeout(record);
} catch {
failManualFinancialRestore(record, "저장된 수동 재무 식별자가 현재 계약과 일치하지 않습니다.");
}
}
function requestNextManualFinancialRestore() {
const records = [...state.manualFinancialRestore.entries.values()];
if (records.some(record => ["load", "stock", "selection", "manual-list"].includes(record.status))) return;
const next = records.find(record => record.status === "queued");
if (next) {
requestManualFinancialRestoreLoad(next);
return;
}
finalizeNamedManualRestores();
}
function installManualFinancialRestores(pendingValues) {
for (const current of state.manualFinancialRestore.entries.values()) {
clearManualFinancialRestoreTimeout(current);
}
state.manualFinancialRestore.entries.clear();
for (const pending of pendingValues) {
if (!pending?.entry?.id || state.manualFinancialRestore.entries.has(pending.entry.id)) continue;
state.manualFinancialRestore.entries.set(pending.entry.id, {
pending,
restoreKind: namedManualRestoreWorkflow.isPending(pending)
? (pending.kind === "financial"
? "named-financial"
: (namedManualRestoreWorkflow.isStoredManualListPending(pending)
? "stored-list"
: "named-list"))
: "stored-financial",
status: nativeBridge ? "queued" : "error",
request: null,
loadResponse: null,
verifiedStock: null,
timeoutId: null,
error: nativeBridge ? "" : "Native Bridge가 없어 현재 DB 재검증을 실행할 수 없습니다."
});
}
if (nativeBridge) requestNextManualFinancialRestore();
}
function handleManualFinancialRestoreLoadResults(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load");
if (!record) return false;
clearManualFinancialRestoreTimeout(record);
const response = manualFinancialWorkflow.normalizeLoadResponse(payload, record.request);
if (!response) {
failManualFinancialRestore(record, "Native Bridge가 올바른 수동 재무 load 응답을 보내지 않았습니다.");
return true;
}
record.loadResponse = response;
const expectedMarket = record.restoreKind === "named-financial"
? record.pending.market
: record.pending.verifiedStock?.market;
let searchQuery = response.snapshot.stockName;
if (String(expectedMarket || "").startsWith("nxt-")) {
const suffix = "(NXT)";
if (!searchQuery.endsWith(suffix) || searchQuery.length === suffix.length) {
failManualFinancialRestore(record, "NXT 종목의 원본 표시명에서 검색 identity를 복원할 수 없습니다.");
return true;
}
searchQuery = searchQuery.slice(0, -suffix.length);
if (!searchQuery || searchQuery !== searchQuery.trim() || searchQuery.includes(suffix)) {
failManualFinancialRestore(record, "NXT 종목의 원본 표시명에서 검색 identity를 복원할 수 없습니다.");
return true;
}
}
const request = {
requestId: `manual-restore-stock-${createId()}`,
query: searchQuery,
maximumResults: 200
};
record.status = "stock";
record.request = request;
postNative("search-stocks", request);
armManualFinancialRestoreTimeout(record);
return true;
}
function handleManualFinancialRestoreLoadError(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load");
if (!record) return false;
const error = manualFinancialWorkflow.normalizeLoadError(payload, record.request);
failManualFinancialRestore(record, error?.message || "수동 재무 DB load가 실패했습니다.");
return true;
}
function handleManualFinancialRestoreStockResults(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock");
if (!record) return false;
clearManualFinancialRestoreTimeout(record);
const response = manualFinancialWorkflow.normalizeStockSearchResponse(payload);
const correlated = response && response.query === record.request.query &&
response.truncated === false && response.totalRowCount <= record.request.maximumResults;
const namedMatch = record.restoreKind === "named-financial" && correlated
? response.results.filter(item =>
item.name === record.pending.stockName && item.market === record.pending.market)
: [];
const expectedMarket = record.restoreKind === "named-financial"
? record.pending.market
: record.pending.verifiedStock.market;
const expectedCode = record.restoreKind === "named-financial"
? (namedMatch.length === 1 ? namedMatch[0].code : "")
: record.pending.verifiedStock.code;
const verified = correlated && manualFinancialWorkflow.verifyStockForRecord(
record.loadResponse.snapshot.record,
response,
expectedMarket,
expectedCode);
if (!verified) {
failManualFinancialRestore(record, "저장된 종목의 시장·코드를 현재 종목 마스터에서 유일하게 확인하지 못했습니다.");
return true;
}
record.verifiedStock = verified;
try {
const request = record.restoreKind === "named-financial"
? manualFinancialWorkflow.createSelectionRequest(
`named-financial-selection-${createId()}`,
record.loadResponse.snapshot,
verified)
: manualFinancialWorkflow.createRestoreSelectionRequest(
`manual-restore-selection-${createId()}`,
record.pending,
record.loadResponse,
verified);
record.status = "selection";
record.request = request;
postNative(manualFinancialWorkflow.bridgeContract.selectionRequestType, request);
armManualFinancialRestoreTimeout(record);
} catch {
failManualFinancialRestore(record, "수동 재무 행 또는 종목 마스터 값이 저장 시점과 달라졌습니다.");
}
return true;
}
function handleManualFinancialRestoreStockError(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock");
if (!record) return false;
const message = payload?.query === record.request.query && typeof payload?.message === "string"
? payload.message
: "종목 마스터 재검증에 실패했습니다.";
failManualFinancialRestore(record, message);
return true;
}
function handleManualFinancialRestoreSelectionResults(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection");
if (!record) return false;
clearManualFinancialRestoreTimeout(record);
const selection = manualFinancialWorkflow.normalizeSelectionResponse(payload, record.request);
let entry = null;
if (selection) {
try {
entry = record.restoreKind === "named-financial"
? manualFinancialWorkflow.createPlaylistEntry(
record.loadResponse.snapshot,
record.verifiedStock,
selection,
{
id: record.pending.entry.id,
fadeDuration: record.pending.fadeDuration,
enabled: record.pending.enabled
})
: manualFinancialWorkflow.materializeRestoredPlaylistEntry(
record.pending,
record.loadResponse,
record.verifiedStock,
selection);
} catch {
entry = null;
}
}
if (!entry || !manualFinancialWorkflow.isPlaylistEntryPlayable(entry) ||
(record.restoreKind === "named-financial" &&
!namedManualRestoreWorkflow.matchesMaterializedEntry(record.pending, entry))) {
failManualFinancialRestore(record, "수동 재무 선택 결과가 저장된 행과 정확히 일치하지 않습니다.");
return true;
}
const index = state.playlist.findIndex(item => item.id === record.pending.entry.id);
if (index < 0 || isPlaylistSnapshotLocked()) {
failManualFinancialRestore(record, "복원 중 플레이리스트 상태가 바뀌어 항목을 적용하지 않았습니다.");
return true;
}
if (record.restoreKind === "named-financial" &&
!trustNamedManualRestoration(record.pending, entry)) {
failManualFinancialRestore(record, "DB raw 행과 현재 GraphE 선택을 안전하게 연결하지 못했습니다.");
return true;
}
state.playlist[index] = entry;
state.manualFinancialRestore.entries.delete(entry.id);
renderPlaylist();
updatePreview();
renderPlayout();
addLog(`수동 재무 복원 완료 · ${entry.title} · ${entry.code}`);
requestNextManualFinancialRestore();
return true;
}
function handleManualFinancialRestoreSelectionError(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection");
if (!record) return false;
const error = manualFinancialWorkflow.normalizeSelectionError(payload, record.request);
failManualFinancialRestore(record, error?.message || "수동 재무 장면 선택 재검증이 실패했습니다.");
return true;
}
function handleManualFinancialRestoreRequestError(payload) {
const record = ["load", "selection"]
.map(status => manualFinancialRestoreRecordForRequest(payload?.requestId, status))
.find(Boolean);
if (!record) return false;
const expectedOperation = record.status === "selection" ? "selection" : "load";
const error = manualFinancialWorkflow.normalizeRequestError(payload, expectedOperation);
failManualFinancialRestore(record, error?.message || "수동 재무 복원 요청 형식이 거부되었습니다.");
return true;
}
function trustNamedManualRestoration(pending, entry) {
const restoration = state.namedPlaylist.restoration;
if (!restoration || !namedManualRestoreWorkflow.matchesMaterializedEntry(pending, entry)) return false;
const row = restoration.rows[pending.itemIndex];
if (!row || row.trusted || row.rawRow.enabled !== pending.enabled ||
!sameNamedSelection(row.rawRow, pending.rawSelection)) return false;
const rows = restoration.rows.map((value, index) => index === pending.itemIndex
? Object.freeze({ ...value, entry, trusted: true })
: value);
state.namedPlaylist.restoration = Object.freeze({
...restoration,
rows: Object.freeze(rows)
});
return true;
}
function finalizeNamedManualRestores() {
if (state.namedPlaylist.manualRestoreFinalized || !state.namedPlaylist.restoration) return;
const records = [...state.manualFinancialRestore.entries.values()];
if (records.some(record => ["queued", "load", "stock", "selection", "manual-list"].includes(record.status))) {
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);
state.namedPlaylist.restoration = restoration;
state.namedPlaylist.guardByEntryId = materialized.guards;
state.namedPlaylist.manualRestoreFinalized = true;
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
requestNamedPlaylistPagePlans();
}
function handleNamedManualListResults(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list");
if (!record || !["named-list", "stored-list"].includes(record.restoreKind)) return false;
clearManualFinancialRestoreTimeout(record);
let entry = null;
try {
entry = namedManualRestoreWorkflow.materializeManualList(
record.pending,
payload,
record.request);
} catch {
entry = null;
}
const index = state.playlist.findIndex(item => item.id === record.pending.entry.id);
const current = index >= 0 ? state.playlist[index] : null;
const trusted = entry && index >= 0 && !isPlaylistSnapshotLocked() &&
(record.restoreKind === "named-list"
? trustNamedManualRestoration(record.pending, entry)
: namedManualRestoreWorkflow.matchesMaterializedEntry(record.pending, current));
if (!trusted) {
failManualFinancialRestore(record, "현재 신뢰 저장소 값이 DB raw 행과 정확히 일치하지 않습니다.");
return true;
}
state.playlist[index] = entry;
state.manualFinancialRestore.entries.delete(entry.id);
renderPlaylist();
updatePreview();
renderPlayout();
addLog(`수동 입력 복원 완료 · ${entry.title} · ${entry.code}`);
requestNextManualFinancialRestore();
return true;
}
function handleNamedManualListError(payload) {
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list");
if (!record || !["named-list", "stored-list"].includes(record.restoreKind)) return false;
const expectedId = record.request?.payload?.requestId;
const error = manualListsWorkflow.normalizeError(payload, expectedId);
failManualFinancialRestore(record, error?.message || "신뢰 수동 저장소 읽기에 실패했습니다.");
return true;
}
function manualFinancialPrepareBlocked() {
if (state.manualFinancialRestore.entries.size > 0) return true;
return state.playlist.some(item =>
(item?.operator?.source === "manual-financial-workflow" &&
!manualFinancialWorkflow.isPlaylistEntryPlayable(item)) ||
(item?.operator?.source === "legacy-manual-lists-workflow" &&
(!manualListsWorkflow.restorePlaylistEntry(item) ||
!manualListsWorkflow.isPlaylistEntryPlayable(item))));
}
function loadPlaylist() {
if (!requirePlaylistEditable()) return;
try {
const stored = JSON.parse(localStorage.getItem(storageKey) || "[]");
const pendingManualFinancial = [];
state.playlist = normalizeStoredPlaylist(stored, {
manualFinancialRestores: pendingManualFinancial
});
installManualFinancialRestores(pendingManualFinancial);
state.namedPlaylist.guardByEntryId.clear();
state.namedPlaylist.loadedDefinition = null;
synchronizeGlobalCandleOptions(false);
state.selectedRows.clear();
if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
state.currentIndex = state.playlist.length ? 0 : -1;
state.selectionAnchorIndex = state.currentIndex;
renderPlaylist();
updatePreview();
addLog(`플레이리스트 ${state.playlist.length}개 불러오기`);
showToast("저장된 플레이리스트를 불러왔습니다.");
} catch {
showToast("저장 데이터를 읽을 수 없습니다.");
}
}
function sameNamedSelection(left, right) {
return Boolean(left && right) &&
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => typeof left[key] === "string" && left[key] === right[key]);
}
function namedNowForRawRow(rawRow) {
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(rawRow?.dataCode || ""));
const now = dateMatch
? new Date(Number(dateMatch[1]), Number(dateMatch[2]) - 1, Number(dateMatch[3]), 9, 0, 0, 0)
: new Date();
if (rawRow?.graphicType === "PRE_MARKET") now.setHours(9, 0, 0, 0);
if (rawRow?.graphicType === "AFTER_MARKET") now.setHours(14, 0, 0, 0);
return now;
}
function addNamedCandidate(candidates, factory) {
try {
const candidate = factory();
if (candidate) candidates.push(candidate);
} catch {
// A raw seven-field row is untrusted until a closed workflow can recreate it.
}
}
function namedMovingAverageOptions(rawRow) {
const flags = new Set(String(rawRow.graphicType || "").split(",").filter(Boolean));
return {
ma5: flags.has("MA5"),
ma20: flags.has("MA20")
};
}
function addFixedNamedCandidates(candidates, rawRow, itemIndex, now) {
for (const action of fixedWorkflow.actions) {
if (!action.available) continue;
const canonicalMatch = ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => {
if (key === "dataCode" && action.dynamicDate) return /^\d{4}-\d{2}-\d{2}$/.test(rawRow.dataCode);
if (key === "graphicType" && action.dynamicNxtSession) {
return ["PRE_MARKET", "AFTER_MARKET"].includes(rawRow.graphicType);
}
return action.selection[key] === rawRow[key];
});
if (!canonicalMatch && !legacyNamedRowWorkflow.matchesFixedActionRaw(rawRow, action)) continue;
addNamedCandidate(candidates, () => fixedWorkflow.createFixedPlaylistEntry(action.id, {
id: `db-${itemIndex}-${action.id}`,
fadeDuration: DEFAULT_FADE_DURATION,
now
}));
}
}
function addStockNamedCandidates(candidates, rawRow, itemIndex) {
const market = operatorWorkflow.normalizeMarket(rawRow.groupCode);
if (!market || !rawRow.subject || !rawRow.dataCode) return;
const displayName = rawRow.subject;
const stockName = displayName.replace(/\s*\(NXT\)\s*/g, "").trim();
const stock = {
market: market.market,
stockName,
displayName,
stockCode: rawRow.dataCode,
isNxt: market.isNxt || displayName.includes("(NXT)")
};
const movingAverages = namedMovingAverageOptions(rawRow);
for (const cut of operatorWorkflow.stockCuts) {
addNamedCandidate(candidates, () => operatorWorkflow.createStockPlaylistEntry(stock, cut.id, {
id: `db-${itemIndex}-stock-${cut.id}`,
fadeDuration: DEFAULT_FADE_DURATION,
...movingAverages
}));
}
}
function addIndustryNamedCandidates(candidates, rawRow, itemIndex, now) {
const descriptor = legacyNamedRowWorkflow.industryFixedDescriptor(rawRow);
if (!descriptor) return;
addNamedCandidate(candidates, () => industryWorkflow.createIndustryPlaylistEntry(
descriptor.market,
descriptor.cutId,
{
id: `db-${itemIndex}-industry-${descriptor.cutId}`,
fadeDuration: DEFAULT_FADE_DURATION,
now
}));
}
function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) {
// 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);
const legacy = legacyNamedRowWorkflow.themeLegacyDescriptor(rawRow, { now });
if (legacy) {
theme = legacy.theme;
sort = legacy.sort;
actionIds = [legacy.actionId];
} else if (rawRow.groupCode === "PAGED_THEME") {
theme = {
market: "krx",
themeCode: rawRow.dataCode,
title: rawRow.subject,
session: null,
itemCount: 1
};
sort = rawRow.graphicType;
} else if (rawRow.groupCode === "PAGED_NXT_THEME" && rawRow.subject.endsWith("(NXT)")) {
theme = {
market: "nxt",
themeCode: rawRow.dataCode,
title: rawRow.subject.slice(0, -5),
session: rawRow.graphicType,
itemCount: 1
};
sort = rawRow.subtype;
} else {
return;
}
for (const actionId of actionIds) {
addNamedCandidate(candidates, () => themeWorkflow.createThemePlaylistEntry(actionId, theme, sort, {
id: `db-${itemIndex}-theme-${actionId}`,
fadeDuration: DEFAULT_FADE_DURATION
}));
}
}
function addOverseasNamedCandidates(candidates, rawRow, itemIndex) {
const movingAverages = namedMovingAverageOptions(rawRow);
if (rawRow.groupCode === "FOREIGN_INDUSTRY") {
const parts = String(rawRow.dataCode || "").split("|");
if (parts.length !== 4) return;
addNamedCandidate(candidates, () => overseasWorkflow.createOverseasIndustryPlaylistEntry({
koreanName: rawRow.subject,
inputName: parts[0],
symbol: parts[1],
nationCode: parts[2],
fdtc: parts[3]
}, {
id: `db-${itemIndex}-overseas-industry`,
fadeDuration: DEFAULT_FADE_DURATION
}));
return;
}
if (rawRow.groupCode !== "FOREIGN_STOCK") return;
const parts = String(rawRow.dataCode || "").split("|");
if (parts.length !== 3) return;
const stock = {
inputName: rawRow.subject,
symbol: parts[0],
nationCode: parts[1],
fdtc: parts[2]
};
for (const action of overseasWorkflow.stockActions) {
addNamedCandidate(candidates, () => overseasWorkflow.createOverseasStockPlaylistEntry(stock, action.id, {
id: `db-${itemIndex}-overseas-${action.id}`,
fadeDuration: DEFAULT_FADE_DURATION,
...movingAverages
}));
}
}
function addExpertNamedCandidate(candidates, rawRow, itemIndex) {
const expert = rawRow.groupCode === "PAGED_EXPERT"
? {
expertCode: rawRow.dataCode,
expertName: rawRow.subject,
source: "oracle",
itemCount: 1,
previewTruncated: false
}
: legacyNamedRowWorkflow.expertLegacyDescriptor(rawRow);
if (!expert) return;
addNamedCandidate(candidates, () => expertWorkflow.createExpertPlaylistEntry(expert, {
id: `db-${itemIndex}-expert`,
fadeDuration: DEFAULT_FADE_DURATION
}));
}
function addTradingHaltNamedCandidates(candidates, rawRow, itemIndex) {
const upperGroup = String(rawRow.groupCode || "").toLocaleUpperCase("en-US");
const market = upperGroup.includes("KOSDAQ") ? "kosdaq" : (upperGroup.includes("KOSPI") ? "kospi" : null);
if (!market || !rawRow.subject || !rawRow.dataCode) return;
const selected = { market, stockCode: rawRow.dataCode, displayName: rawRow.subject };
const movingAverages = namedMovingAverageOptions(rawRow);
for (const action of tradingHaltWorkflow.actions) {
addNamedCandidate(candidates, () => tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, action.id, {
id: `db-${itemIndex}-halt-${action.id}`,
fadeDuration: DEFAULT_FADE_DURATION,
...movingAverages
}));
}
}
function addDefaultNamedCandidates(candidates, rawRow, itemIndex) {
for (const definition of catalog) {
if (definition.reachable === false || operatorInputBuilderKeys.has(definition.builderKey)) continue;
const selection = defaultSceneSelection(definition.builderKey);
if (!sameNamedSelection(selection, rawRow)) continue;
candidates.push({
...definition,
id: `db-${itemIndex}-default-${definition.builderKey}`,
selection: { ...selection },
enabled: rawRow.enabled,
fadeDuration: DEFAULT_FADE_DURATION,
graphicType: selection.graphicType,
subtype: selection.subtype
});
}
}
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);
addStockNamedCandidates(candidates, rawRow, itemIndex);
addIndustryNamedCandidates(candidates, rawRow, itemIndex, now);
addThemeNamedCandidates(candidates, rawRow, itemIndex, now);
addOverseasNamedCandidates(candidates, rawRow, itemIndex);
addExpertNamedCandidate(candidates, rawRow, itemIndex);
addTradingHaltNamedCandidates(candidates, rawRow, itemIndex);
addDefaultNamedCandidates(candidates, rawRow, itemIndex);
const trusted = [];
for (const candidate of candidates) {
const restored = normalizeStoredPlaylist([{ ...candidate, enabled: rawRow.enabled }], { now });
const entry = restored.length === 1 ? restored[0] : null;
if (!entry || entry.enabled !== rawRow.enabled ||
!legacyNamedRowWorkflow.matchesCanonicalEntry(entry, rawRow, { now })) continue;
trusted.push(entry);
}
return legacyNamedRowWorkflow.selectUniqueCanonicalEntry(trusted, rawRow, { now });
}
function prepareNamedPagePreflight(restoration) {
return namedPlaylistWorkflow.preparePagePreflight(
restoration,
entry => namedPagedBuilderKeys.has(entry.builderKey));
}
function namedInvalidEntry(definition, rawRow) {
return {
id: `db-invalid-${definition.programCode}-${rawRow.itemIndex}`,
builderKey: namedInvalidBuilderKey,
code: "0",
aliases: ["0"],
title: rawRow.subject || `DB 행 ${rawRow.itemIndex + 1}`,
detail: "DB 7필드에서 컷을 유일하게 복원할 수 없음",
category: "invalid",
market: rawRow.groupCode || "unknown",
selection: {
groupCode: rawRow.groupCode,
subject: rawRow.subject,
graphicType: rawRow.graphicType,
subtype: rawRow.subtype,
dataCode: rawRow.dataCode
},
enabled: rawRow.enabled,
fadeDuration: DEFAULT_FADE_DURATION,
graphicType: rawRow.graphicType,
subtype: rawRow.subtype,
pageText: rawRow.pageText
};
}
function materializeNamedRestoration(restoration) {
const entries = [];
const guards = new Map();
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 })
: namedInvalidEntry(restoration.definition, row.rawRow);
entries.push(entry);
guards.set(entry.id, Object.freeze({
trusted: row.trusted,
pageRecalculationRequired: row.pageRecalculationRequired,
pageRecalculated: row.pageRecalculated,
pagePreflightCompleted: row.pagePreflightCompleted,
pageUnavailableReason: row.pageUnavailableReason,
recalculatedPagePlan: row.recalculatedPagePlan,
historicalPageOutOfBounds: row.rawRow.historicalPageOutOfBounds,
rawRow: row.rawRow
}));
}
return { entries, guards };
}
function namedPlaylistPrepareBlockers() {
const blockers = [];
state.playlist.forEach((entry, index) => {
const guard = state.namedPlaylist.guardByEntryId.get(entry.id);
if (!guard) return;
if (!guard.trusted) blockers.push({ itemIndex: index, reason: "trusted-restore-required" });
if (guard.pageRecalculationRequired && !guard.pageRecalculated) {
blockers.push({
itemIndex: index,
reason: guard.pagePreflightCompleted && guard.pageUnavailableReason === "empty-result"
? "page-result-empty"
: "page-recalculation-required"
});
}
});
return blockers;
}
function clearNamedPlaylistLoadState() {
state.namedPlaylist.guardByEntryId.clear();
state.namedPlaylist.loadedDefinition = null;
state.namedPlaylist.restoration = null;
state.namedPlaylist.pagePlanPending = null;
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;
}
function saveNamedPlaylistBackup() {
if (!state.playlist.length) return true;
try {
localStorage.setItem(namedPlaylistBackupStorageKey, JSON.stringify({
savedAt: new Date().toISOString(),
playlist: state.playlist
}));
return true;
} catch {
return false;
}
}
function hasNamedPlaylistBackup() {
try {
return Boolean(localStorage.getItem(namedPlaylistBackupStorageKey));
} catch {
return false;
}
}
function restoreNamedPlaylistBackup() {
if (!requirePlaylistEditable() || namedPlaylistBusy()) return;
try {
const stored = JSON.parse(localStorage.getItem(namedPlaylistBackupStorageKey) || "null");
if (!stored || typeof stored !== "object" || !Array.isArray(stored.playlist)) throw new Error();
const pendingManualFinancial = [];
const restored = normalizeStoredPlaylist(stored.playlist, {
manualFinancialRestores: pendingManualFinancial
});
if (restored.length !== stored.playlist.length) throw new Error();
state.playlist = restored;
installManualFinancialRestores(pendingManualFinancial);
synchronizeGlobalCandleOptions(false);
clearNamedPlaylistLoadState();
state.selectedRows.clear();
if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
state.currentIndex = state.playlist.length ? 0 : -1;
state.selectionAnchorIndex = state.currentIndex;
localStorage.removeItem(namedPlaylistBackupStorageKey);
renderPlaylist();
updatePreview();
addLog(`DB 불러오기 전 플레이리스트 ${state.playlist.length}개 복원`);
showToast("DB 불러오기 전 상태를 복원했습니다.");
} catch {
showToast("DB 불러오기 전 상태 백업을 복원할 수 없습니다.");
}
}
function namedPageTextForEntry(entry) {
if (!namedPagedBuilderKeys.has(entry.builderKey)) return "1/1";
const guard = state.namedPlaylist.guardByEntryId.get(entry.id);
const guardedCount = guard?.recalculatedPagePlan?.pageCount;
const previewCount = entry.pagePreview?.pageCount ?? entry.pageCount;
const pageCount = Number.isInteger(guardedCount) ? guardedCount : previewCount;
if (!Number.isInteger(pageCount) || pageCount < 1 ||
pageCount > namedPlaylistWorkflow.bridgeContract.maximumPageCount) {
throw new Error("5·6·12행 장면은 현재 DB 결과로 페이지를 다시 계산한 뒤 저장해야 합니다.");
}
return `1/${pageCount}`;
}
function namedLegacyFieldsForEntry(entry, itemIndex, pageText) {
const manualFields = namedManualRestoreWorkflow.legacyFieldsForEntry(entry, itemIndex);
if (manualFields) {
if (manualFields[5] !== pageText) {
throw new Error("수동 항목의 원본 페이지 서명을 현재 검증 결과와 일치시킬 수 없습니다.");
}
return manualFields;
}
const selection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
namedForeignStockRestoreWorkflow.legacySelectionForEntry(entry) ||
legacyNamedRowWorkflow.legacySelectionForEntry(entry);
if (!selection) {
throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다.");
}
const fields = namedPlaylistWorkflow.toLegacySevenFields(
{ ...entry, selection },
pageText);
const rawRow = {
itemIndex,
enabled: fields[0] === "1",
groupCode: fields[1],
subject: fields[2],
graphicType: fields[3],
subtype: fields[4],
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를 안전하게 왕복할 수 없습니다.");
}
return fields;
}
if (entry.operator?.source === "legacy-comparison-workflow") {
const restoredComparison = comparisonWorkflow.restoreComparisonPlaylistEntry(entry);
if (!restoredComparison || restoredComparison.builderKey !== entry.builderKey ||
restoredComparison.code !== entry.code ||
!legacyNamedRowWorkflow.matchesCanonicalEntry(restoredComparison, rawRow)) {
throw new Error("비교 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
}
return fields;
}
if (entry.operator?.source === "legacy-foreign-index-candle-workflow") {
const restoredForeignIndex = foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry);
if (!restoredForeignIndex || restoredForeignIndex.builderKey !== entry.builderKey ||
restoredForeignIndex.code !== entry.code ||
!legacyNamedRowWorkflow.matchesCanonicalEntry(restoredForeignIndex, rawRow)) {
throw new Error("해외지수 캔들 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
}
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필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다.");
}
return fields;
}
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 {
const legacySelection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry);
const fields = namedPlaylistWorkflow.toLegacySevenFields(
{ ...entry, selection: legacySelection },
namedPageTextForEntry(entry));
return namedNxtThemeRestoreWorkflow.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 (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)
: 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-named-foreign-stock-workflow"
]
.includes(entry.operator?.source)) {
const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry);
if (!legacySelection) return false;
let legacyFields;
try {
legacyFields = namedPlaylistWorkflow.toLegacySevenFields(
{ ...entry, selection: legacySelection },
namedPageTextForEntry(entry));
} catch {
return false;
}
const rawRow = {
itemIndex: 0,
enabled: legacyFields[0] === "1",
groupCode: legacyFields[1],
subject: legacyFields[2],
graphicType: legacyFields[3],
subtype: legacyFields[4],
pageText: legacyFields[5],
dataCode: legacyFields[6]
};
const restoredClosedEntry = entry.operator.source === "legacy-comparison-workflow"
? comparisonWorkflow.restoreComparisonPlaylistEntry(entry)
: entry.operator.source === "legacy-industry-workflow"
? industryWorkflow.restoreIndustryPlaylistEntry(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));
}
let fields;
try {
fields = namedPlaylistWorkflow.toLegacySevenFields(entry, namedPageTextForEntry(entry));
} catch {
return false;
}
const roundTrip = resolveNamedPlaylistRawRow({
itemIndex: 0,
enabled: fields[0] === "1",
groupCode: fields[1],
subject: fields[2],
graphicType: fields[3],
subtype: fields[4],
pageText: fields[5],
dataCode: fields[6]
}, 0);
return Boolean(roundTrip && roundTrip.builderKey === entry.builderKey && roundTrip.code === entry.code);
}
function selectedNamedPlaylistDefinition() {
return state.namedPlaylist.definitions.find(
value => value.programCode === state.namedPlaylist.selectedProgramCode) || null;
}
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);
}
function renderNamedPlaylist() {
const workflow = state.namedPlaylist;
const locked = isPlaylistSnapshotLocked();
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();
const blockers = namedPlaylistPrepareBlockers();
const pageRetryAvailable = workflow.pagePlanError?.retryable === true ||
blockers.some(value => value.reason === "page-result-empty");
const writeAllowed = Boolean(nativeBridge) && !locked && !busy &&
workflow.canMutate && !workflow.writeQuarantined;
elements.namedPlaylistPanel.hidden = !workflow.open;
elements.namedPlaylistOpenButton.setAttribute("aria-expanded", String(workflow.open));
elements.namedPlaylistOpenButton.textContent = workflow.open ? "DB 패널 닫기" : "DB 저장·불러오기";
elements.namedPlaylistDefinitions.replaceChildren();
for (const definition of workflow.definitions) {
const option = document.createElement("option");
option.value = definition.programCode;
option.textContent = `${definition.title} · ${definition.programCode}`;
option.selected = definition.programCode === workflow.selectedProgramCode;
elements.namedPlaylistDefinitions.append(option);
}
elements.namedPlaylistSelectionSummary.textContent = selected
? `${selected.title} · ${selected.programCode}${workflow.loadedDefinition?.programCode === selected.programCode ? " · 현재 불러온 정의" : ""}`
: "선택된 DB 정의 없음";
elements.namedPlaylistStatus.classList.remove("ready", "error", "quarantined");
elements.namedPlaylistStateBadge.classList.remove("neutral", "live", "dry-run");
let badge = "대기";
let status = "Oracle에 저장된 이름 목록을 조회하세요. 로컬 임시저장과는 별도입니다.";
if (!nativeBridge) {
badge = "미리보기";
status = "Native Bridge가 없어 Oracle DB 플레이리스트를 사용할 수 없습니다.";
} else if (workflow.writeQuarantined) {
badge = "WRITE 잠금";
status = workflow.error || "DB 쓰기 결과가 불명확합니다. 앱을 다시 시작하기 전까지 생성·덮어쓰기·삭제를 반복하지 않습니다.";
elements.namedPlaylistStatus.classList.add("quarantined");
elements.namedPlaylistStateBadge.classList.add("dry-run");
} else if (workflow.error) {
badge = "오류";
status = workflow.error;
elements.namedPlaylistStatus.classList.add("error");
elements.namedPlaylistStateBadge.classList.add("dry-run");
} else if (pending) {
badge = "처리 중";
status = pending.operation === "page-preflight"
? "현재 Oracle 조회 결과로 5·6·12행 장면의 페이지 수를 계산하고 있습니다. 이 조회는 송출 엔진을 호출하지 않습니다."
: `${pending.operation.toLocaleUpperCase("en-US")} 요청을 처리하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다.`;
} else if (freshRestorePending) {
badge = "재검증 중";
status = "DB 행의 수동·비교 장면 identity를 현재 저장소와 종목 마스터에서 재검증하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다.";
} else if (workflow.loadedDefinition) {
badge = blockers.length ? "복원 확인" : "불러옴";
status = blockers.length
? `${workflow.loadedDefinition.title}을(를) 불러왔지만 ${blockers.length}개 안전 조건이 남았습니다. 표시된 행을 현재 전용 선택 화면에서 다시 구성하세요.`
: `${workflow.loadedDefinition.title} · ${state.playlist.length}개 행이 신뢰 복원과 페이지 검증을 통과했습니다.`;
elements.namedPlaylistStatus.classList.add(blockers.length ? "quarantined" : "ready");
elements.namedPlaylistStateBadge.classList.add(blockers.length ? "dry-run" : "live");
} else if (workflow.status === "ready") {
badge = "목록 준비";
status = `${workflow.definitions.length}개 DB 정의를 조회했습니다.${workflow.canMutate ? "" : " 현재 환경은 DB 쓰기를 허용하지 않습니다."}`;
elements.namedPlaylistStatus.classList.add("ready");
elements.namedPlaylistStateBadge.classList.add("live");
} else {
elements.namedPlaylistStateBadge.classList.add("neutral");
}
if (!elements.namedPlaylistStateBadge.classList.contains("live") &&
!elements.namedPlaylistStateBadge.classList.contains("dry-run")) {
elements.namedPlaylistStateBadge.classList.add("neutral");
}
elements.namedPlaylistStateBadge.textContent = badge;
elements.namedPlaylistStatus.textContent = locked
? "PREPARED/PROGRAM 상태에서는 DB 플레이리스트 UI가 TAKE OUT까지 잠깁니다."
: status;
elements.namedPlaylistRefreshButton.disabled = !nativeBridge || locked || busy;
elements.namedPlaylistDefinitions.disabled = locked || busy || !workflow.definitions.length;
elements.namedPlaylistLoadButton.disabled = !nativeBridge || locked || busy || !selected;
elements.namedPlaylistPageRetryButton.hidden = !pageRetryAvailable;
elements.namedPlaylistPageRetryButton.disabled = !nativeBridge || locked || busy ||
!workflow.restoration;
elements.namedPlaylistRestoreBackupButton.disabled = locked || busy || !hasNamedPlaylistBackup();
elements.namedPlaylistProgramCode.disabled = !writeAllowed;
elements.namedPlaylistProgramTitle.disabled = !writeAllowed;
elements.namedPlaylistCreateButton.disabled = !writeAllowed;
elements.namedPlaylistReplaceButton.disabled = !writeAllowed || !selected;
elements.namedPlaylistDeleteButton.disabled = !writeAllowed || !selected;
for (const button of [
elements.namedPlaylistRefreshButton,
elements.namedPlaylistLoadButton,
elements.namedPlaylistPageRetryButton,
elements.namedPlaylistCreateButton,
elements.namedPlaylistReplaceButton,
elements.namedPlaylistDeleteButton
]) button.setAttribute("aria-busy", String(busy));
const unresolved = blockers.filter(value => value.reason === "trusted-restore-required").length;
const pagePending = blockers.filter(value => value.reason === "page-recalculation-required").length;
const pageEmpty = blockers.filter(value => value.reason === "page-result-empty").length;
elements.namedPlaylistGuardSummary.classList.toggle("blocked", blockers.length > 0);
elements.namedPlaylistGuardSummary.textContent = blockers.length
? `PREPARE 차단 · 컷 복원 ${unresolved}건 · 페이지 조회 ${pagePending}건 · DB 결과 없음 ${pageEmpty}건. 원본 7필드가 여러 컷과 겹치면 임의로 선택하지 않습니다.`
: "DB에서 읽은 각 행은 기존 trusted restore 체인과 현재 20페이지 상한 검증을 통과했습니다.";
}
function beginNamedPlaylistOperation(operation, request, programCode = "") {
if (namedPlaylistBusy()) return false;
state.namedPlaylist.pending = {
requestId: request.requestId,
operation,
programCode
};
state.namedPlaylist.status = "pending";
state.namedPlaylist.error = "";
postNative(namedPlaylistWorkflow.bridgeContract.requests[operation], request);
renderNamedPlaylist();
return true;
}
function requestNamedPlaylistList() {
if (!nativeBridge) return showToast("브라우저 미리보기에서는 Oracle DB 이름 목록을 조회하지 않습니다.");
if (isPlaylistSnapshotLocked()) return showToast("TAKE OUT 후 DB 플레이리스트를 조회하세요.");
try {
const request = namedPlaylistWorkflow.createListRequest(`named-list-${createId()}`, 1000);
beginNamedPlaylistOperation("list", request);
} catch {
showToast("DB 플레이리스트 목록 요청을 만들 수 없습니다.");
}
}
function requestNamedPlaylistLoad() {
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy()) return;
const definition = selectedNamedPlaylistDefinition();
if (!definition) return showToast("불러올 DB 정의를 선택하세요.");
if (state.playlist.length && !window.confirm(
`${definition.title}을(를) 불러오면 현재 화면의 플레이리스트가 바뀝니다. 불러오기 직전 상태는 별도 로컬 백업으로 보존합니다.`)) return;
try {
const request = namedPlaylistWorkflow.createLoadRequest(
`named-load-${createId()}`,
definition.programCode);
beginNamedPlaylistOperation("load", request, definition.programCode);
} catch {
showToast("선택한 DB 플레이리스트 요청 형식이 올바르지 않습니다.");
}
}
function requestNamedPlaylistCreate() {
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
try {
const request = namedPlaylistWorkflow.createDefinitionRequest(
`named-create-${createId()}`,
elements.namedPlaylistProgramCode.value,
elements.namedPlaylistProgramTitle.value);
if (!window.confirm(`${request.programCode} · ${request.title} 이름을 새로 만들까요? 현재 순서는 만든 뒤 '선택 정의에 현재 순서 저장'으로 저장합니다.`)) return;
state.namedPlaylist.selectAfterRefresh = request.programCode;
beginNamedPlaylistOperation("create", request, request.programCode);
} catch {
showToast("새 코드는 8자리 숫자, 이름은 앞뒤 공백 없는 128자 이내로 입력하세요.");
}
}
function requestNamedPlaylistReplace() {
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
const definition = selectedNamedPlaylistDefinition();
if (!definition) return showToast("덮어쓸 DB 정의를 선택하세요.");
const blockers = namedPlaylistPrepareBlockers();
if (blockers.length) {
renderNamedPlaylist();
return showToast("복원 또는 페이지 재계산이 끝나지 않은 행은 DB에 덮어쓸 수 없습니다.");
}
try {
const request = namedPlaylistWorkflow.createReplaceRequest(
`named-replace-${createId()}`,
definition.programCode,
state.playlist,
{
isTrustedEntry: isTrustedNamedPlaylistEntry,
pageTextForEntry: namedPageTextForEntry,
fieldsForEntry: namedLegacyFieldsForEntry
});
if (!window.confirm(`${definition.title}의 DB 플레이리스트를 현재 ${request.items.length}개 순서로 교체할까요? 이 작업은 한 트랜잭션으로 실행되며 자동 재시도하지 않습니다.`)) return;
beginNamedPlaylistOperation("replace", request, definition.programCode);
} catch (error) {
addLog(`DB 플레이리스트 저장 차단 · ${error?.message || "trusted restore 실패"}`);
showToast("DB 7필드로 유일하게 왕복 복원되지 않거나 현재 페이지 수가 없는 행이 있어 저장을 중단했습니다.");
}
}
function requestNamedPlaylistDelete() {
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
const definition = selectedNamedPlaylistDefinition();
if (!definition) return showToast("삭제할 DB 정의를 선택하세요.");
if (!window.confirm(`${definition.title} · ${definition.programCode} 정의와 그 하위 PLAY_LIST 행을 삭제할까요?`)) return;
try {
const request = namedPlaylistWorkflow.createDeleteRequest(
`named-delete-${createId()}`,
definition.programCode);
beginNamedPlaylistOperation("delete", request, definition.programCode);
} catch {
showToast("선택한 DB 정의를 삭제할 수 없습니다.");
}
}
function namedPendingMatches(operation, payload) {
const pending = state.namedPlaylist.pending;
return Boolean(pending && pending.operation === operation && payload?.requestId === pending.requestId);
}
function failNamedPlaylistProtocol(operation) {
const pending = state.namedPlaylist.pending;
if (!pending || pending.operation !== operation) return;
state.namedPlaylist.pending = null;
state.namedPlaylist.status = "error";
state.namedPlaylist.error = "Native Bridge가 유효한 DB 플레이리스트 응답을 보내지 않았습니다.";
if (namedWriteOperations.has(operation)) {
state.namedPlaylist.writeQuarantined = true;
state.namedPlaylist.canMutate = false;
state.namedPlaylist.error += " 쓰기 결과를 확인할 수 없어 앱 재시작 전까지 추가 쓰기를 차단합니다.";
}
renderNamedPlaylist();
}
function handleNamedPlaylistListResults(payload) {
const result = namedPlaylistWorkflow.normalizeListResponse(payload);
if (!result) return failNamedPlaylistProtocol("list");
if (!namedPendingMatches("list", result)) return;
const previousSuggestion = state.namedPlaylist.suggestedProgramCode;
state.namedPlaylist.pending = null;
state.namedPlaylist.status = "ready";
state.namedPlaylist.definitions = [...result.definitions];
state.namedPlaylist.suggestedProgramCode = result.suggestedProgramCode;
state.namedPlaylist.canMutate = result.canMutate;
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined;
const preferred = state.namedPlaylist.selectAfterRefresh || state.namedPlaylist.selectedProgramCode;
state.namedPlaylist.selectedProgramCode = result.definitions.some(value => value.programCode === preferred)
? preferred
: (result.definitions[0]?.programCode || "");
state.namedPlaylist.selectAfterRefresh = "";
if (!elements.namedPlaylistProgramCode.value ||
elements.namedPlaylistProgramCode.value === previousSuggestion) {
elements.namedPlaylistProgramCode.value = result.suggestedProgramCode;
}
renderNamedPlaylist();
addLog(`DB 플레이리스트 이름 목록 ${result.definitions.length}개 조회`);
}
function handleNamedPlaylistLoadResults(payload) {
const load = namedPlaylistWorkflow.normalizeLoadResponse(payload);
if (!load) return failNamedPlaylistProtocol("load");
if (!namedPendingMatches("load", load)) return;
state.namedPlaylist.pending = null;
state.namedPlaylist.canMutate = load.canMutate;
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || load.writeQuarantined;
if (isPlaylistSnapshotLocked()) {
state.namedPlaylist.status = "error";
state.namedPlaylist.error = "DB 응답 중 송출 상태가 잠겨 현재 플레이리스트를 교체하지 않았습니다.";
renderNamedPlaylist();
return;
}
if (!saveNamedPlaylistBackup()) {
state.namedPlaylist.status = "error";
state.namedPlaylist.error = "현재 플레이리스트를 로컬 백업하지 못해 DB 불러오기를 적용하지 않았습니다.";
renderNamedPlaylist();
return;
}
try {
let restoration = namedPlaylistWorkflow.restoreRawRows(
load,
resolveNamedPlaylistRawRow,
legacyNamedRowWorkflow.matchesCanonicalEntry);
const pendingNamedManual = restoration.rows.flatMap(row => {
if (row.trusted) return [];
const pending = namedManualRestoreWorkflow.classify(row.rawRow, {
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
fadeDuration: DEFAULT_FADE_DURATION
});
return pending ? [pending] : [];
});
const pendingNamedComparison = restoration.rows.flatMap(row => {
if (row.trusted) return [];
const pending = namedComparisonRestoreWorkflow.classify(row.rawRow, {
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
fadeDuration: DEFAULT_FADE_DURATION
});
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, {
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
fadeDuration: DEFAULT_FADE_DURATION
});
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, {
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
fadeDuration: DEFAULT_FADE_DURATION
});
return pending ? [pending] : [];
});
state.namedPlaylist.manualRestoreFinalized =
pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 &&
pendingForeignStocks.length === 0 &&
pendingForeignIndexCandles.length === 0 && pendingKrxThemes.length === 0 &&
pendingNxtThemes.length === 0;
if (state.namedPlaylist.manualRestoreFinalized) {
restoration = prepareNamedPagePreflight(restoration);
}
const materialized = materializeNamedRestoration(restoration);
state.playlist = materialized.entries;
synchronizeGlobalCandleOptions(false);
state.namedPlaylist.guardByEntryId = materialized.guards;
state.namedPlaylist.restoration = restoration;
state.namedPlaylist.comparisonRestorePending = pendingNamedComparison.length
? Object.freeze({
requestId: load.requestId,
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,
rows: Object.freeze(pendingNxtThemes)
})
: null;
state.namedPlaylist.pagePlanError = null;
state.namedPlaylist.loadedDefinition = load.definition;
state.namedPlaylist.selectedProgramCode = load.definition.programCode;
state.namedPlaylist.status = "loaded";
state.namedPlaylist.error = "";
state.selectedRows.clear();
if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
state.currentIndex = state.playlist.length ? 0 : -1;
state.selectionAnchorIndex = state.currentIndex;
installManualFinancialRestores(pendingNamedManual);
renderPlaylist();
updatePreview();
const blockers = namedPlaylistPrepareBlockers();
addLog(`DB 플레이리스트 ${load.definition.title} · ${state.playlist.length}개 불러오기 · 차단 ${blockers.length}`);
showToast(blockers.length
? "DB 목록을 불러왔습니다. 표시된 복원·페이지 항목을 확인하세요."
: "DB 플레이리스트를 안전하게 불러왔습니다.");
if (state.namedPlaylist.manualRestoreFinalized) requestNamedPlaylistPagePlans();
} catch {
state.namedPlaylist.status = "error";
state.namedPlaylist.error = "DB 원시 행의 엄격한 복원 중 오류가 발생해 현재 플레이리스트를 교체하지 않았습니다.";
renderNamedPlaylist();
}
}
function failNamedComparisonRestore(record, message) {
if (!record || state.namedPlaylist.comparisonRestorePending !== record) return;
state.namedPlaylist.comparisonRestorePending = null;
state.namedPlaylist.error = message;
state.namedPlaylist.status = "error";
finalizeNamedManualRestores();
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedComparisonRestoreResults(payload) {
const record = state.namedPlaylist.comparisonRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const response = namedComparisonRestoreWorkflow.normalizeResponse(
payload,
record.requestId,
record.rows);
if (!response) {
failNamedComparisonRestore(
record,
"Native Bridge의 비교 행 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
return;
}
const restoration = state.namedPlaylist.restoration;
if (!restoration || isPlaylistSnapshotLocked()) {
failNamedComparisonRestore(
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 = namedComparisonRestoreWorkflow.materialize(pending, outcome);
if (!storedRow || storedRow.trusted || !entry ||
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
entry.enabled !== storedRow.rawRow.enabled ||
!legacyNamedRowWorkflow.matchesCanonicalEntry(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)
});
state.namedPlaylist.comparisonRestorePending = 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 handleNamedComparisonRestoreError(payload) {
const record = state.namedPlaylist.comparisonRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const error = namedComparisonRestoreWorkflow.normalizeError(payload, record.requestId);
failNamedComparisonRestore(
record,
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;
state.namedPlaylist.error = message;
state.namedPlaylist.status = "error";
finalizeNamedManualRestores();
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedForeignIndexCandleRestoreResults(payload) {
const record = state.namedPlaylist.foreignIndexCandleRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const response = foreignIndexCandleRestoreWorkflow.normalizeResponse(
payload,
record.requestId,
record.rows);
if (!response) {
failNamedForeignIndexCandleRestore(
record,
"Native Bridge의 해외지수 캔들 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
return;
}
const restoration = state.namedPlaylist.restoration;
if (!restoration || isPlaylistSnapshotLocked()) {
failNamedForeignIndexCandleRestore(
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 = foreignIndexCandleRestoreWorkflow.materialize(pending, outcome);
if (!storedRow || storedRow.trusted || !entry ||
!foreignIndexCandleRestoreWorkflow.isMaterializedEntry(entry) ||
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
entry.enabled !== storedRow.rawRow.enabled ||
!legacyNamedRowWorkflow.matchesCanonicalEntry(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)
});
state.namedPlaylist.foreignIndexCandleRestorePending = 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 handleNamedForeignIndexCandleRestoreError(payload) {
const record = state.namedPlaylist.foreignIndexCandleRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const error = foreignIndexCandleRestoreWorkflow.normalizeError(payload, record.requestId);
failNamedForeignIndexCandleRestore(
record,
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;
state.namedPlaylist.error = message;
state.namedPlaylist.status = "error";
finalizeNamedManualRestores();
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedNxtThemeRestoreResults(payload) {
const record = state.namedPlaylist.nxtThemeRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const response = namedNxtThemeRestoreWorkflow.normalizeResponse(
payload,
record.requestId,
record.rows);
if (!response) {
failNamedNxtThemeRestore(
record,
"Native Bridge의 NXT 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
return;
}
const restoration = state.namedPlaylist.restoration;
if (!restoration || isPlaylistSnapshotLocked()) {
failNamedNxtThemeRestore(
record,
"NXT 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
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 = namedNxtThemeRestoreWorkflow.materialize(pending, outcome);
if (!storedRow || storedRow.trusted || !entry ||
!namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry) ||
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
entry.enabled !== storedRow.rawRow.enabled ||
!namedNxtThemeRestoreWorkflow.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.nxtThemeRestorePending = null;
addLog(`DB NXT 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}`);
if (failures.size) {
const summary = [...failures.entries()]
.map(([code, count]) => `${code} ${count}`)
.join(" · ");
addLog(`DB NXT 테마 차단 사유 · ${summary}`);
}
finalizeNamedManualRestores();
renderPlaylist();
updatePreview();
renderNamedPlaylist();
renderPlayout();
}
function handleNamedNxtThemeRestoreError(payload) {
const record = state.namedPlaylist.nxtThemeRestorePending;
if (!record || payload?.requestId !== record.requestId) return;
const error = namedNxtThemeRestoreWorkflow.normalizeError(payload, record.requestId);
failNamedNxtThemeRestore(
record,
error?.message || "NXT 테마의 현재 MariaDB identity 재검증을 완료하지 못했습니다.");
}
function requestNamedPlaylistPagePlans() {
const workflow = state.namedPlaylist;
if (!nativeBridge || isPlaylistSnapshotLocked() || workflow.pending ||
workflow.pagePlanPending || !workflow.restoration) return;
try {
const request = namedPlaylistWorkflow.createPagePlanRequest(
`named-page-plan-${createId()}`,
workflow.restoration);
if (!request) {
workflow.pagePlanError = null;
workflow.status = "loaded";
renderNamedPlaylist();
return;
}
workflow.pagePlanPending = {
operation: "page-preflight",
requestId: request.requestId,
request,
restoration: workflow.restoration
};
workflow.pagePlanError = null;
workflow.error = "";
workflow.status = "page-preflight";
postNative(namedPlaylistWorkflow.pagePlanBridgeContract.request, request);
renderNamedPlaylist();
renderPlayout();
} catch (error) {
workflow.status = "error";
workflow.pagePlanError = Object.freeze({
code: "INVALID_PAGE_PREFLIGHT",
message: error?.message || "페이지 조회 요청을 만들 수 없습니다.",
retryable: false
});
workflow.error = workflow.pagePlanError.message;
renderNamedPlaylist();
}
}
function failNamedPagePlanProtocol(pending) {
if (state.namedPlaylist.pagePlanPending !== pending) return;
state.namedPlaylist.pagePlanPending = null;
state.namedPlaylist.status = "error";
state.namedPlaylist.pagePlanError = Object.freeze({
code: "INVALID_RESPONSE",
message: "Native Bridge가 현재 요청과 정확히 일치하는 페이지 계산 결과를 보내지 않았습니다.",
retryable: true
});
state.namedPlaylist.error = state.namedPlaylist.pagePlanError.message;
renderNamedPlaylist();
renderPlayout();
}
function handleNamedPlaylistPagePlanResults(payload) {
const pending = state.namedPlaylist.pagePlanPending;
if (!pending || payload?.requestId !== pending.requestId) return;
const result = namedPlaylistWorkflow.normalizePagePlanResponse(payload, pending.request);
if (!result) return failNamedPagePlanProtocol(pending);
if (state.namedPlaylist.restoration !== pending.restoration) return;
try {
const restoration = namedPlaylistWorkflow.applyPagePlanResponse(
pending.restoration,
result);
const materialized = materializeNamedRestoration(restoration);
state.namedPlaylist.restoration = restoration;
state.namedPlaylist.guardByEntryId = materialized.guards;
state.namedPlaylist.pagePlanPending = null;
state.namedPlaylist.pagePlanError = null;
state.namedPlaylist.status = "loaded";
state.namedPlaylist.error = "";
renderPlaylist();
renderNamedPlaylist();
renderPlayout();
const empty = result.plans.filter(plan => plan.itemCount === 0).length;
const truncated = result.plans.filter(plan => plan.isTruncated).length;
addLog(`DB 페이지 사전조회 ${result.plans.length}개 완료 · 결과 없음 ${empty}개 · 20페이지 제한 ${truncated}`);
showToast(empty
? `페이지 조회를 완료했지만 ${empty}개 장면은 DB 결과가 없어 PREPARE할 수 없습니다.`
: "현재 DB 결과로 페이지 계산을 완료했습니다.");
} catch {
failNamedPagePlanProtocol(pending);
}
}
function handleNamedPlaylistPagePlanError(payload) {
const pending = state.namedPlaylist.pagePlanPending;
if (!pending || payload?.requestId !== pending.requestId) return;
const error = namedPlaylistWorkflow.normalizePagePlanError(payload, pending.requestId);
if (!error) return failNamedPagePlanProtocol(pending);
state.namedPlaylist.pagePlanPending = null;
state.namedPlaylist.status = "error";
state.namedPlaylist.pagePlanError = error;
state.namedPlaylist.error = `${error.message} [${error.code}] 자동 재시도하지 않습니다.`;
renderNamedPlaylist();
renderPlayout();
addLog(`DB 페이지 사전조회 오류 · ${error.code}`);
showToast(state.namedPlaylist.error);
}
function handleNamedPlaylistMutationResults(payload) {
const result = namedPlaylistWorkflow.normalizeMutationResponse(payload);
if (!result) {
const operation = state.namedPlaylist.pending?.operation;
if (namedWriteOperations.has(operation)) failNamedPlaylistProtocol(operation);
return;
}
if (!namedPendingMatches(result.operation, result)) return;
state.namedPlaylist.pending = null;
state.namedPlaylist.status = "ready";
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined;
if (result.writeQuarantined) state.namedPlaylist.canMutate = false;
if (result.operation === "delete") {
state.namedPlaylist.definitions = state.namedPlaylist.definitions.filter(
value => value.programCode !== result.programCode);
if (state.namedPlaylist.loadedDefinition?.programCode === result.programCode) {
state.namedPlaylist.loadedDefinition = null;
}
state.namedPlaylist.selectedProgramCode = "";
} else {
state.namedPlaylist.selectAfterRefresh = result.programCode;
}
addLog(`DB 플레이리스트 ${result.operation.toLocaleUpperCase("en-US")} 완료 · ${result.programCode}`);
showToast("DB 플레이리스트 작업이 완료되었습니다.");
requestNamedPlaylistList();
}
function handleNamedPlaylistError(payload) {
const error = namedPlaylistWorkflow.normalizeError(payload);
const pending = state.namedPlaylist.pending;
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;
if (state.namedPlaylist.writeQuarantined) state.namedPlaylist.canMutate = false;
state.namedPlaylist.error = `${error.message} [${error.code}]${error.outcomeUnknown ? " 결과를 확인할 수 없어 자동 재시도하지 않습니다." : ""}`;
renderNamedPlaylist();
addLog(`DB 플레이리스트 ${error.operation.toLocaleUpperCase("en-US")} 오류 · ${error.code}`);
showToast(state.namedPlaylist.error);
}
function normalizePlayoutState(value) {
const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, "");
if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED";
if (["program", "onair", "takein", "live"].includes(normalized)) return "PROGRAM";
if (["dryrun", "preview", "simulation", "simulated"].includes(normalized)) return "DRY RUN";
return "IDLE";
}
function normalizePlayoutMode(value) {
return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
}
function normalizeScenePreviewFields(value) {
if (!Array.isArray(value) || value.length > 4096) return [];
return value.flatMap(field => {
if (!field || typeof field !== "object") return [];
const kind = typeof field.kind === "string" ? field.kind : "";
const name = typeof field.name === "string" ? field.name : "";
const text = typeof field.value === "string" ? field.value : "";
if (!/^[a-z-]{1,32}$/.test(kind) || !name || name.length > 128 ||
text.length > 16384 || /[\u0000\r\n]/.test(name + text)) return [];
return [{ kind, name, value: text }];
});
}
function applyRefreshState(payload) {
state.playout.refreshActive = payload.refreshActive === true;
state.playout.refreshNextAt = payload.refreshNextAt || null;
state.playout.refreshLastSuccessAt = payload.refreshLastSuccessAt || null;
state.playout.refreshFaulted = payload.refreshFaulted === true;
state.playout.refreshFaultCode = typeof payload.refreshFaultCode === "string"
? payload.refreshFaultCode
: null;
state.playout.refreshFaultMessage = typeof payload.refreshFaultMessage === "string"
? payload.refreshFaultMessage
: null;
if (state.playout.refreshFaulted && state.playout.error?.outcomeUnknown !== true) {
state.playout.error = {
code: state.playout.refreshFaultCode || "REFRESH_FAULT",
message: state.playout.refreshFaultMessage ||
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
retryable: false,
outcomeUnknown: false,
nonClearable: true,
isRefreshFault: true
};
} else if (playoutSafety.shouldClearRefreshError(
state.playout.error,
state.playout.refreshFaulted)) {
state.playout.error = null;
}
}
function renderSceneDataPreview() {
elements.sceneDataPreview.replaceChildren();
if (state.playout.previewFields.length === 0) {
const empty = document.createElement("div");
empty.className = "scene-data-empty";
empty.textContent = "PREPARE 후 실제 DTO → mutation 값을 표시합니다.";
elements.sceneDataPreview.append(empty);
return;
}
const fragment = document.createDocumentFragment();
for (const field of state.playout.previewFields) {
const row = document.createElement("div");
row.className = "scene-data-row";
const kind = document.createElement("span");
kind.textContent = field.kind;
const name = document.createElement("strong");
name.textContent = field.name;
const value = document.createElement("code");
value.textContent = field.value;
row.append(kind, name, value);
fragment.append(row);
}
elements.sceneDataPreview.append(fragment);
}
function isDryRunMode(mode = state.playout.mode) {
return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
}
function isLiveMode(mode = state.playout.mode) {
return /live|program/.test(normalizePlayoutMode(mode));
}
function isTestMode(mode = state.playout.mode) {
return /test/.test(normalizePlayoutMode(mode));
}
function playoutDisplayState() {
if (isDryRunMode()) return "DRY RUN";
if (isTestMode() && state.playout.engineState === "PROGRAM") return "TEST ON AIR";
return state.playout.engineState;
}
function setStatusDot(element, status) {
element.classList.remove("pending", "healthy", "error", "warn", "ok", "unconfigured");
element.classList.add(status);
}
function cueFromItem(item) {
if (!item) return null;
return { code: String(item.code), title: String(item.title), detail: String(item.detail) };
}
function workflowEntryFromItem(item) {
const selection = item?.selection && typeof item.selection === "object"
? item.selection
: {
groupCode: String(item?.groupCode || item?.market || ""),
subject: String(item?.subject || item?.title || ""),
graphicType: String(item?.graphicType || item?.detail || ""),
subtype: String(item?.subtype || ""),
dataCode: String(item?.dataCode || "")
};
return {
id: String(item.id),
code: String(item.code),
enabled: item.enabled !== false,
fadeDuration: Number.isInteger(Number(item.fadeDuration))
? Number(item.fadeDuration)
: DEFAULT_FADE_DURATION,
selection: {
groupCode: String(selection.groupCode || ""),
subject: String(selection.subject || ""),
graphicType: String(selection.graphicType || ""),
subtype: String(selection.subtype || ""),
dataCode: String(selection.dataCode || "")
}
};
}
function findCueByCode(code) {
if (!code) return null;
const current = state.playlist[state.currentIndex];
if (String(current?.code) === String(code)) return cueFromItem(current);
return cueFromItem(state.playlist.find(item => String(item.code) === String(code)));
}
function renderPlayout() {
const displayState = playoutDisplayState();
const pending = state.playout.pending;
const dryRun = isDryRunMode();
const liveMode = isLiveMode();
const testMode = isTestMode();
const bridgeReady = Boolean(nativeBridge);
const outcomeUnknown = state.playout.nativeOutcomeUnknown ||
state.playout.sessionQuarantined ||
state.playout.error?.outcomeUnknown === true;
const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable;
const mutationCommandReady = commandReady && !state.playout.refreshFaulted;
const namedPrepareBlocked = namedPlaylistBusy() ||
namedPlaylistPrepareBlockers().length > 0;
const candlePrepareBlocked = state.candleOptions.invalidIds.length > 0;
const manualFinancialBlocked = manualFinancialPrepareBlocked();
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode);
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
elements.playoutState.textContent = displayState;
elements.playoutState.classList.remove("neutral", "live", "test-on-air", "dry-run", "error");
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
if (displayState === "TEST ON AIR") elements.playoutState.classList.add("test-on-air");
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
const transitionalConnection = ["connecting", "reconnecting", "disconnecting"].includes(state.playout.connectionState);
const connectionLabels = {
"disabled": "Disabled",
"dry-run-ready": "DryRunReady",
"disconnected": "Disconnected",
"connecting": "Connecting",
"connected": "Connected",
"reconnecting": "Reconnecting",
"disconnecting": "Disconnecting",
"faulted": "Faulted",
"outcome-unknown": "OutcomeUnknown",
"disposed": "Disposed",
"unavailable": "Unavailable"
};
const lastKtapLabels = {
"attempted": "마지막 KTAP 결과 미확인",
"accepted-unconfirmed": "마지막 KTAP 수락 · OnHello 미확인",
"failed": "마지막 KTAP 실패"
};
const currentConnectionLabel = connectionLabels[state.playout.connectionState] || "Unavailable";
elements.playoutConnectionState.textContent = dryRun
? "KTAP 미호출"
: (state.playout.connected && state.playout.lastKtapConnectState === "accepted-unconfirmed"
? "KTAP 수락 · OnHello 미확인"
: `${currentConnectionLabel}${lastKtapLabels[state.playout.lastKtapConnectState]
? ` · ${lastKtapLabels[state.playout.lastKtapConnectState]}`
: ""}`);
setStatusDot(elements.playoutConnectionDot, state.playout.connected
? (state.playout.ktapHelloObserved === true ? "healthy" : "warn")
: (transitionalConnection ? "warn" : (bridgeReady ? "error" : "unconfigured")));
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
safetyContainer.classList.remove("live-allowed", "live-locked");
if (dryRun) {
elements.playoutSafetyMode.textContent = "DRY RUN · PROGRAM 차단";
} else if (testMode && state.playout.liveTakeInAllowed) {
elements.playoutSafetyMode.textContent = "TEST · 전용 출력 허용";
safetyContainer.classList.add("live-allowed");
} else if (testMode) {
elements.playoutSafetyMode.textContent = "TEST TAKE IN 잠금";
safetyContainer.classList.add("live-locked");
} else if (liveMode && state.playout.liveTakeInAllowed) {
elements.playoutSafetyMode.textContent = "LIVE 허용";
safetyContainer.classList.add("live-allowed");
} else if (liveMode) {
elements.playoutSafetyMode.textContent = "LIVE TAKE IN 잠금";
safetyContainer.classList.add("live-locked");
} else {
elements.playoutSafetyMode.textContent = bridgeReady ? "모드 확인 중" : "미리보기 전용";
}
const changedLabel = state.playout.changedAtMs
? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}`
: "";
const pageLabel = state.playout.pageCount > 0
? ` · 페이지 ${state.playout.pageIndex + 1}/${state.playout.pageCount}`
: "";
elements.playoutStatusMessage.textContent = pending
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${pageLabel}${changedLabel}`;
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
elements.playoutStatusMessage.classList.toggle(
"error",
Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown || state.playout.refreshFaulted);
elements.playoutSceneCode.textContent = state.playout.onAirCode || state.playout.preparedCode || "—";
elements.playoutEntryId.textContent = state.playout.currentEntryId || "—";
elements.playoutBuilderKey.textContent = state.playout.builderKey || "—";
elements.playoutPageNumber.textContent = state.playout.pageCount > 0
? `${state.playout.pageIndex + 1}/${state.playout.pageCount}`
: "—";
elements.playoutPageSize.textContent = state.playout.pageSize > 0
? String(state.playout.pageSize)
: "—";
elements.playoutItemCount.textContent = String(state.playout.itemCount);
elements.playoutCurrentRows.textContent = String(state.playout.currentPageItemCount);
elements.playoutLastPage.textContent = state.playout.isLastPage ? "YES" : "NO";
elements.playoutNextKind.textContent = state.playout.nextKind.replaceAll("-", " ").toUpperCase();
const refreshNextMs = parseChangedAt(state.playout.refreshNextAt);
elements.playoutRefreshState.textContent = state.playout.refreshFaulted
? "FAULT"
: (state.playout.refreshActive
? `ACTIVE · ${refreshNextMs ? new Date(refreshNextMs).toLocaleTimeString("ko-KR", { hour12: false }) : "WAIT"}`
: "STOPPED");
renderSceneDataPreview();
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
if (outcomeUnknown) {
elements.playoutSummary.classList.add("error");
elements.playoutSummaryBadge.textContent = "UNKNOWN";
elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요";
} else if (state.playout.refreshFaulted) {
elements.playoutSummary.classList.add("error");
elements.playoutSummaryBadge.textContent = "REFRESH FAULT";
elements.playoutSummaryDetail.textContent = "자동 갱신 중단 · TAKE OUT 후 확인 필요";
} else if (dryRun) {
elements.playoutSummary.classList.add("dry-run");
elements.playoutSummaryBadge.textContent = "DRY RUN";
elements.playoutSummaryDetail.textContent = bridgeReady ? "KTAP 미호출 · 모니터 기록 없음(정상)" : "브라우저 미리보기 전용";
} else if (state.playout.connected) {
elements.playoutSummary.classList.add("ready");
elements.playoutSummaryBadge.textContent = testMode
? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED")
: (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED");
elements.playoutSummaryDetail.textContent = state.playout.lastKtapConnectState === "accepted-unconfirmed"
? "KTAP 수락 · OnHello/[R]/[S] 확인 필요"
: (testMode
? "테스트 전용 출력 · 안전 게이트 적용"
: (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지"));
} else {
elements.playoutSummary.classList.add("error");
elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW";
elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
}
elements.prepareButton.disabled = !mutationCommandReady || !currentCue || namedPrepareBlocked ||
candlePrepareBlocked || manualFinancialBlocked;
elements.takeInButton.disabled = !mutationCommandReady || !currentCue || !takeInSafe || onAir;
elements.nextButton.disabled = !mutationCommandReady || !onAir || state.playlist.length === 0 ||
state.playout.nextKind === "end-of-playlist";
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
const playlistLocked = isPlaylistSnapshotLocked();
const operatorLocked = operatorUiLocked();
manualListsUi?.setLocked(operatorLocked);
manualFinancialUi?.setLocked(operatorLocked);
operatorCatalogUi?.setLocked(operatorLocked);
elements.toggleAllEnabled.disabled = playlistLocked;
elements.selectAll.disabled = playlistLocked;
elements.moveUpButton.disabled = playlistLocked;
elements.moveDownButton.disabled = playlistLocked;
elements.deleteButton.disabled = playlistLocked;
elements.deleteAllButton.disabled = playlistLocked;
elements.loadButton.disabled = playlistLocked || namedPlaylistBusy();
renderGlobalCandleOptions();
renderNamedPlaylist();
renderStockWorkflow();
renderFixedWorkflow();
renderIndustryWorkflow();
renderComparisonWorkflow();
renderThemeWorkflow();
renderOverseasWorkflow();
renderExpertWorkflow();
renderTradingHaltWorkflow();
renderBackgroundControls();
renderCatalog();
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
button.setAttribute("aria-busy", String(Boolean(pending)));
}
elements.playoutError.hidden = !state.playout.error;
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown
? "닫기 · 잠금 유지"
: "닫기";
}
function clearPlayoutError(render = true) {
if (!playoutSafety.canClearCommandError(
state.playout.error,
state.playout.sessionQuarantined || state.playout.nativeOutcomeUnknown)) {
if (render) showToast("결과 미확인 잠금은 앱을 다시 시작하기 전까지 해제할 수 없습니다.");
return;
}
state.playout.error = null;
if (render) renderPlayout();
}
function showPlayoutError({ code = "PLAYOUT_ERROR", message, retryable = false, outcomeUnknown = false }) {
const details = [message || "송출 명령을 완료하지 못했습니다."];
if (code) details.push(`[${code}]`);
if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요.");
else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다.");
state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown };
if (outcomeUnknown || code === "WEB_TIMEOUT") state.playout.sessionQuarantined = true;
renderPlayout();
showToast(state.playout.error.message);
}
function resolveCommand(command) {
const current = state.playlist[state.currentIndex];
if (command === "prepare") {
return current ? { cue: cueFromItem(current), targetIndex: state.currentIndex } : null;
}
if (command === "take-in") {
const cue = state.playout.preparedCue || findCueByCode(state.playout.preparedCode);
return cue ? { cue, targetIndex: state.currentIndex } : null;
}
if (command === "next") {
const cue = findCueByCode(state.playout.onAirCode) || current;
return cue ? { cue: cueFromItem(cue), targetIndex: state.currentIndex } : null;
}
if (command === "take-out") return { cue: null, targetIndex: state.currentIndex };
return null;
}
function requestPlayoutCommand(command) {
if (!nativeBridge) {
showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다.");
return false;
}
if (state.playout.pending) return false;
const blockReason = playoutSafety.commandBlockReason({
pending: state.playout.pending,
outcomeUnknown: state.playout.nativeOutcomeUnknown ||
state.playout.sessionQuarantined ||
state.playout.error?.outcomeUnknown === true,
commandAvailable: state.playout.commandAvailable,
refreshFaulted: state.playout.refreshFaulted,
engineState: state.playout.engineState,
preparedCode: state.playout.preparedCode,
onAirCode: state.playout.onAirCode
}, command);
if (blockReason) {
const message = {
"outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
"unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.",
"refresh-fault": "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
"next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.",
"take-in-requires-prepare": "먼저 PREPARE를 완료하세요.",
"nothing-to-take-out": "종료할 송출 장면이 없습니다."
}[blockReason] || "다른 송출 명령을 처리하고 있습니다.";
showToast(message);
return false;
}
if (command === "prepare") {
if (namedPlaylistBusy()) {
showToast("DB 플레이리스트 및 현재 페이지 조회가 끝난 뒤 PREPARE를 실행하세요.");
return false;
}
const namedBlockers = namedPlaylistPrepareBlockers();
if (namedBlockers.length) {
state.namedPlaylist.open = true;
renderNamedPlaylist();
showToast("DB에서 불러온 행의 컷 복원 또는 현재 페이지 재계산이 끝나지 않아 PREPARE를 차단했습니다.");
return false;
}
if (manualFinancialPrepareBlocked()) {
showToast("수동 재무 항목의 현재 DB 행·종목·장면 선택 재검증이 끝나지 않아 PREPARE를 차단했습니다.");
return false;
}
if (!synchronizeGlobalCandleOptions(false)) {
showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다.");
return false;
}
if (!synchronizeFixedEntries(false)) return false;
if (!synchronizeIndustryEntries(false)) return false;
if (!synchronizeComparisonEntries()) return false;
if (!synchronizeThemeEntries()) return false;
if (!synchronizeOverseasEntries()) return false;
if (!synchronizeExpertEntries()) return false;
if (!synchronizeTradingHaltEntries()) return false;
}
const resolved = resolveCommand(command);
if (!resolved) {
const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요.";
showToast(message);
return false;
}
const requestId = createId();
const pending = {
requestId,
command,
cue: resolved.cue,
targetIndex: resolved.targetIndex,
timeoutId: null
};
state.playout.pending = pending;
clearPlayoutError(false);
renderPlayout();
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
const payload = { requestId, command };
if (command === "prepare") {
payload.playlist = state.playlist.map(workflowEntryFromItem);
payload.selectedIndex = state.currentIndex;
}
postNative("playout-command", payload);
pending.timeoutId = setTimeout(() => {
if (state.playout.pending?.requestId !== requestId) return;
state.playout.pending = null;
if (command === "prepare") state.playout.takeInAfterPrepare = false;
postNative("playout-timeout-quarantine", { requestId, command });
showPlayoutError({
code: "WEB_TIMEOUT",
message: "송출 엔진 응답 시간이 초과되었습니다.",
retryable: false,
outcomeUnknown: true
});
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
requestPlayoutStatus();
}, state.playout.responseTimeoutMs);
return true;
}
function requestLegacyTakeInShortcut() {
const action = playoutSafety.legacyTakeInShortcutAction({
pending: Boolean(state.playout.pending),
takeInAllowed: isDryRunMode() ||
((isTestMode() || isLiveMode()) && state.playout.liveTakeInAllowed),
engineState: state.playout.engineState,
preparedCode: state.playout.preparedCode,
onAirCode: state.playout.onAirCode
});
if (action === "blocked") return false;
if (action === "take-in-locked") {
showToast("현재 검증 회차는 TAKE IN 승인이 잠겨 있어 PREPARE를 자동 실행하지 않습니다.");
return false;
}
if (action === "next-required") {
showToast("현재 장면이 송출 중입니다. 다음 장면은 NEXT를 사용하세요.");
return false;
}
if (action === "take-in") {
state.playout.takeInAfterPrepare = false;
return requestPlayoutCommand("take-in");
}
state.playout.takeInAfterPrepare = true;
const started = requestPlayoutCommand("prepare");
if (!started) state.playout.takeInAfterPrepare = false;
else addLog("F8/TAKE IN · PREPARE 성공 후 1회 TAKE IN 예약");
return started;
}
function renderBackgroundControls() {
const background = state.background;
const locked = isPlaylistSnapshotLocked();
elements.backgroundFileName.textContent = background.enabled
? (background.fileName || "선택된 배경")
: "배경 없음";
elements.backgroundStateText.textContent = background.error || background.message ||
(background.enabled ? `${background.kind.toLocaleUpperCase("en-US")} 배경 사용` : "배경 사용 안 함");
elements.chooseBackgroundButton.disabled = !nativeBridge || locked ||
background.pending || !background.canChange;
elements.toggleBackgroundButton.disabled = !nativeBridge || locked ||
background.pending || !background.canChange;
elements.chooseBackgroundButton.setAttribute("aria-busy", String(background.pending));
elements.toggleBackgroundButton.setAttribute("aria-busy", String(background.pending));
}
function beginBackgroundRequest(type) {
if (!nativeBridge) {
showToast("브라우저 미리보기에서는 네이티브 배경 파일을 선택할 수 없습니다.");
return false;
}
if (isPlaylistSnapshotLocked() || state.background.pending || !state.background.canChange) {
showToast("배경은 IDLE 상태에서 다음 PREPARE 전에만 변경할 수 있습니다.");
return false;
}
const requestId = createId();
state.background.requestId = requestId;
state.background.pending = true;
state.background.error = "";
state.background.message = type === "choose-background"
? "배경 파일 선택 창을 기다리고 있습니다."
: "배경 사용 상태를 변경하고 있습니다.";
renderBackgroundControls();
postNative(type, { requestId });
return true;
}
function requestBackgroundStatus() {
if (!nativeBridge) {
state.background.canChange = false;
state.background.pending = false;
state.background.message = "브라우저 미리보기 · 배경 변경 없음";
renderBackgroundControls();
return;
}
const requestId = createId();
state.background.requestId = requestId;
postNative("request-background-status", { requestId });
}
function requestBackgroundSelection() {
return beginBackgroundRequest("choose-background");
}
function requestBackgroundToggle() {
return beginBackgroundRequest("toggle-background");
}
function notifyLegacyBackgroundShortcut() {
return requestBackgroundSelection();
}
function handleBackgroundStatus(payload) {
if (!payload || typeof payload !== "object" ||
(payload.requestId && payload.requestId !== state.background.requestId)) return;
const kind = payload.kind;
const fileName = payload.fileName;
const message = payload.message;
const validText = value => typeof value === "string" && value.length <= 260 &&
!/[\u0000-\u001f\u007f]/.test(value);
if (typeof payload.enabled !== "boolean" ||
!["none", "texture", "video"].includes(kind) ||
!validText(fileName) || !validText(message) ||
typeof payload.canChange !== "boolean" ||
(payload.enabled && (kind === "none" || !fileName)) ||
(!payload.enabled && kind !== "none")) {
state.background.pending = false;
state.background.error = "배경 상태 응답 형식이 올바르지 않습니다.";
renderBackgroundControls();
return;
}
Object.assign(state.background, {
enabled: payload.enabled,
kind,
fileName,
canChange: payload.canChange,
pending: false,
message,
error: ""
});
renderBackgroundControls();
}
function handleBackgroundError(payload) {
if (!payload || typeof payload !== "object" ||
payload.requestId !== state.background.requestId) return;
const message = typeof payload.message === "string" && payload.message.length <= 512 &&
!/[\u0000-\u001f\u007f]/.test(payload.message)
? payload.message
: "배경 설정을 변경하지 못했습니다.";
state.background.pending = false;
state.background.error = message;
renderBackgroundControls();
showToast(message);
}
function requestPlayoutStatus() {
if (!nativeBridge) return;
const requestId = createId();
state.playout.latestStatusRequestId = requestId;
postNative("request-playout-status", { requestId });
}
function parseChangedAt(value) {
const parsed = typeof value === "string" || typeof value === "number" ? Date.parse(value) : NaN;
return Number.isFinite(parsed) ? parsed : 0;
}
function handlePlayoutStatus(payload) {
if (!payload || typeof payload !== "object") return;
if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return;
const previousCueIndex = state.currentIndex;
const previousSnapshotLocked = isPlaylistSnapshotLocked();
const changedAtMs = parseChangedAt(payload.changedAt);
if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return;
state.playout.mode = normalizePlayoutMode(payload.mode);
state.playout.engineState = normalizePlayoutState(payload.state);
state.playout.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState);
state.playout.processDetected = payload.processDetected === true;
state.playout.connected = payload.connected === true;
state.playout.lastKtapConnectState = ["not-attempted", "attempted", "accepted-unconfirmed", "failed"]
.includes(payload.lastKtapConnectState) ? payload.lastKtapConnectState : "not-attempted";
state.playout.ktapConnectAttempted = payload.ktapConnectAttempted === true;
state.playout.ktapConnectAccepted = payload.ktapConnectAccepted === true
? true
: (payload.ktapConnectAccepted === false ? false : null);
state.playout.ktapHelloObserved = payload.ktapHelloObserved === true
? true
: (payload.ktapHelloObserved === false ? false : null);
state.playout.networkMonitoringRecordExpected = payload.networkMonitoringRecordExpected === true
? true
: (payload.networkMonitoringRecordExpected === false ? false : null);
state.playout.networkMonitoringCheckRequired = payload.networkMonitoringCheckRequired === true;
state.playout.commandAvailable = payload.commandAvailable === true;
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
state.playout.browserCorrelationQuarantined =
payload.browserCorrelationQuarantined === true;
state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true ||
state.playout.browserCorrelationQuarantined ||
state.playout.connectionState === "outcome-unknown";
state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds))
? Number(payload.operationTimeoutMilliseconds)
: state.playout.operationTimeoutMs;
state.playout.responseTimeoutMs = playoutSafety.responseTimeoutFromNative(payload.operationTimeoutMilliseconds);
state.playout.message = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "Tornado 송출 상태를 수신했습니다.";
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
state.playout.preparedCue = findCueByCode(state.playout.preparedCode);
state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex) ? payload.currentCueIndex : -1;
state.playout.currentEntryId = typeof payload.currentEntryId === "string"
? payload.currentEntryId
: null;
state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
? String(payload.builderKey)
: null;
state.playout.pageIndex = Number.isInteger(payload.pageIndex) && payload.pageIndex >= 0 ? payload.pageIndex : 0;
state.playout.pageCount = Number.isInteger(payload.pageCount) && payload.pageCount >= 0 ? payload.pageCount : 0;
state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : 0;
state.playout.itemCount = Number.isInteger(payload.itemCount) && payload.itemCount >= 0 ? payload.itemCount : 0;
state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount) &&
payload.currentPageItemCount >= 0 ? payload.currentPageItemCount : 0;
state.playout.isLastPage = payload.isLastPage !== false;
state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
.includes(payload.nextKind) ? payload.nextKind : "none";
state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
applyRefreshState(payload);
const authoritativeIndex = state.playout.currentEntryId
? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
: state.playout.currentCueIndex;
if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
state.currentIndex = authoritativeIndex;
}
state.playout.changedAt = payload.changedAt || null;
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
if (state.playout.nativeOutcomeUnknown && state.playout.error?.outcomeUnknown !== true) {
state.playout.error = {
code: "OUTCOME_UNKNOWN",
message: "송출 결과를 확인할 수 없습니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
retryable: false,
outcomeUnknown: true
};
}
if (state.currentIndex !== previousCueIndex ||
isPlaylistSnapshotLocked() !== previousSnapshotLocked) {
renderPlaylist();
updatePreview();
} else {
renderPlayout();
}
const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.lastKtapConnectState, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].join("|");
if (signature !== state.playout.lastSignature) {
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
state.playout.lastSignature = signature;
}
}
function finishPendingCommand(payload) {
const pending = state.playout.pending;
if (!playoutSafety.matchesPendingCommand(pending, payload)) return null;
clearTimeout(pending.timeoutId);
state.playout.pending = null;
return pending;
}
function handlePlayoutCommandResult(payload) {
const pending = finishPendingCommand(payload);
if (!pending) return;
const takeInAfterPrepare = pending.command === "prepare" && state.playout.takeInAfterPrepare;
if (pending.command === "prepare") state.playout.takeInAfterPrepare = false;
if (payload.succeeded !== true) {
showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true });
addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`);
requestPlayoutStatus();
return;
}
clearPlayoutError(false);
if (payload.dryRun === true) state.playout.mode = "dry-run";
state.playout.engineState = normalizePlayoutState(payload.state);
state.playout.message = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: `${pending.command.toUpperCase()} 완료`;
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex)
? payload.currentCueIndex
: state.playout.currentCueIndex;
state.playout.currentEntryId = typeof payload.currentEntryId === "string"
? payload.currentEntryId
: state.playout.currentEntryId;
state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
? String(payload.builderKey)
: state.playout.builderKey;
state.playout.pageIndex = Number.isInteger(payload.pageIndex) ? payload.pageIndex : state.playout.pageIndex;
state.playout.pageCount = Number.isInteger(payload.pageCount) ? payload.pageCount : state.playout.pageCount;
state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : state.playout.pageSize;
state.playout.itemCount = Number.isInteger(payload.itemCount) ? payload.itemCount : state.playout.itemCount;
state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount)
? payload.currentPageItemCount
: state.playout.currentPageItemCount;
state.playout.isLastPage = payload.isLastPage !== false;
state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
.includes(payload.nextKind) ? payload.nextKind : state.playout.nextKind;
state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
applyRefreshState(payload);
if (pending.command === "prepare") {
state.playout.preparedCue = pending.cue;
} else if (!state.playout.preparedCode) {
state.playout.preparedCue = null;
}
const authoritativeIndex = state.playout.currentEntryId
? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
: state.playout.currentCueIndex;
if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
state.currentIndex = authoritativeIndex;
}
renderPlaylist();
updatePreview();
renderPlayout();
const safetyLabel = payload.dryRun === true ? "DRY RUN · " : "";
addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`);
showToast(state.playout.message);
if (takeInAfterPrepare) {
addLog("PREPARE 확인 · 예약된 TAKE IN 1회 실행");
setTimeout(() => requestPlayoutCommand("take-in"), 0);
} else {
requestPlayoutStatus();
}
}
function handlePlayoutCommandError(payload) {
const pending = finishPendingCommand(payload);
if (!pending) return;
if (pending.command === "prepare") state.playout.takeInAfterPrepare = false;
if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true;
showPlayoutError({
code: payload.code,
message: payload.message,
retryable: payload.retryable === true,
outcomeUnknown: payload.outcomeUnknown === true
});
addLog(`${pending.command.toUpperCase()} 오류 · ${payload.code || "PLAYOUT_ERROR"} · ${payload.message || "명령 실패"}`);
requestPlayoutStatus();
}
function addLog(message) {
const item = document.createElement("li");
const time = document.createElement("time");
time.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
item.append(time, document.createTextNode(message));
elements.eventLog.prepend(item);
while (elements.eventLog.children.length > 30) elements.eventLog.lastElementChild?.remove();
}
let toastTimer;
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2600);
}
function postNative(type, payload = {}) {
nativeBridge?.postMessage({ type, payload });
}
function normalizeSourceName(source) {
const value = String(source || "").toLocaleLowerCase("en-US");
if (value === "oracle") return "oracle";
if (value === "mariadb" || value === "maria_db" || value === "maria-db") return "mariaDb";
return String(source || "unknown");
}
function normalizedDatabaseSources(sources) {
const received = new Map((Array.isArray(sources) ? sources : []).map(source => [normalizeSourceName(source?.source), source]));
return ["oracle", "mariaDb"].map(name => {
const source = received.get(name);
return {
source: name,
configured: Boolean(source?.configured),
healthy: Boolean(source?.healthy),
message: typeof source?.message === "string" ? source.message : "",
checkedAt: source?.checkedAt || null,
latencyMs: source?.latencyMs !== null && source?.latencyMs !== undefined && Number.isFinite(Number(source.latencyMs))
? Number(source.latencyMs)
: null
};
});
}
function sourceHealthClass(source) {
if (state.database.loading && !state.database.sources.length) return "pending";
if (!source.configured) return "unconfigured";
return source.healthy ? "healthy" : "error";
}
function sourceHealthText(source) {
if (state.database.loading && !state.database.sources.length) return "확인 중";
if (!source.configured) return "미설정";
if (!source.healthy) return "연결 오류";
return source.latencyMs === null ? "정상" : `정상 · ${Math.round(source.latencyMs)}ms`;
}
function renderDatabaseSources() {
elements.databaseSources.replaceChildren();
const sources = state.database.sources.length
? state.database.sources
: normalizedDatabaseSources([]);
for (const source of sources) {
const chip = document.createElement("div");
const healthClass = sourceHealthClass(source);
chip.className = `database-source ${healthClass}`;
if (source.message) chip.title = source.message;
const dot = document.createElement("span");
dot.className = "source-dot";
const label = document.createElement("strong");
label.textContent = sourceLabels[source.source] || source.source;
const status = document.createElement("small");
status.textContent = sourceHealthText(source);
chip.append(dot, label, status);
elements.databaseSources.append(chip);
}
}
function renderDatabaseStatus() {
const sources = state.database.sources;
const configured = sources.filter(source => source.configured);
const healthy = configured.filter(source => source.healthy);
let stateClass = "pending";
let stateText = "확인 중";
let detail = "연결 상태 확인 중";
let badge = "CHECKING";
if (!state.database.loading || sources.length) {
if (!configured.length) {
stateClass = "unconfigured";
stateText = "미설정";
detail = "Oracle 미설정 · MariaDB 미설정";
badge = "SETUP";
} else if (healthy.length === sources.length && sources.length === 2) {
stateClass = "healthy";
stateText = "정상 (2/2)";
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "CONNECTED";
} else if (configured.some(source => !source.healthy)) {
stateClass = "error";
stateText = `오류 (${healthy.length}/${configured.length})`;
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "ERROR";
} else {
stateClass = "partial";
stateText = `일부 연결 (${healthy.length}/2)`;
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "PARTIAL";
}
}
elements.databaseState.textContent = stateText;
elements.databaseStateDot.className = `status-dot ${stateClass}`;
elements.databaseSummary.className = `database-summary ${stateClass}`;
elements.databaseSummaryIcon.className = `metric-icon database-icon ${stateClass}`;
elements.databaseSummaryDetail.textContent = detail;
elements.databaseSummaryBadge.textContent = badge;
renderDatabaseSources();
}
function requestDatabaseStatus() {
if (!nativeBridge) return;
state.database.loading = true;
renderDatabaseStatus();
postNative("request-database-status", { requestId: createId() });
}
function handleDatabaseStatus(payload) {
const sequence = payload?.sequence;
if (!playoutSafety.isNewerDatabaseStatusSequence(
state.database.latestSequence,
sequence)) return;
state.database.latestSequence = sequence;
state.database.sources = normalizedDatabaseSources(payload?.sources);
state.database.loading = false;
renderDatabaseStatus();
const signature = state.database.sources.map(source => `${source.source}:${source.configured}:${source.healthy}`).join("|");
if (signature !== state.database.lastSignature) {
const message = state.database.sources
.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`)
.join(" · ");
addLog(`DB 상태 · ${message}`);
state.database.lastSignature = signature;
}
}
function clearLiveDataTimeout() {
if (state.liveData.timeoutId !== null) clearTimeout(state.liveData.timeoutId);
state.liveData.timeoutId = null;
}
function formatDateTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat("ko-KR", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
}).format(date);
}
function normalizeColumns(table) {
let columns = Array.isArray(table?.columns) ? table.columns : [];
const firstRow = Array.isArray(table?.rows) ? table.rows[0] : null;
if (!columns.length && firstRow && !Array.isArray(firstRow) && typeof firstRow === "object") {
columns = Object.keys(firstRow);
}
if (!columns.length && Array.isArray(firstRow)) {
columns = firstRow.map((_, index) => `${index + 1}`);
}
return columns.map((column, index) => {
if (typeof column === "string") return { key: column, label: column };
const key = column?.key ?? column?.name ?? column?.field ?? column?.dataPropertyName ?? `column-${index}`;
const label = column?.label ?? column?.displayName ?? column?.title ?? column?.name ?? key;
return { key: String(key), label: String(label) };
});
}
function cellValue(row, column, index) {
if (Array.isArray(row)) return row[index];
if (row && typeof row === "object") return row[column.key];
return index === 0 ? row : null;
}
function formatCellValue(value) {
if (value === null || value === undefined || value === "") return "—";
if (typeof value === "number") return value.toLocaleString("ko-KR");
if (typeof value === "boolean") return value ? "예" : "아니요";
if (typeof value === "object") {
try { return JSON.stringify(value); } catch { return String(value); }
}
return String(value);
}
function liveRowValues(row, columns) {
const values = new Map();
columns.forEach((column, index) => {
values.set(String(column.key).toLocaleUpperCase("en-US"), cellValue(row, column, index));
});
return values;
}
function firstLiveValue(values, keys) {
for (const key of keys) {
const value = values.get(key);
if (value !== null && value !== undefined && String(value).trim()) return String(value).trim();
}
return "";
}
function liveSelectionGroup(table, item) {
const name = String(table?.name || "").toLocaleUpperCase("ko-KR");
let group;
if (name.includes("NXT") && state.activeMarket === "kospi") group = "NXT_KOSPI";
else if (name.includes("NXT") && state.activeMarket === "kosdaq") group = "NXT_KOSDAQ";
else if (state.activeMarket === "kospi") group = "KOSPI_STOCK";
else if (state.activeMarket === "kosdaq") group = "KOSDAQ_STOCK";
else if (state.activeMarket === "overseas") group = name.includes("종목") ? "FOREIGN_STOCK" : "FOREIGN_INDEX";
else if (name.includes("코스닥")) group = "KOSDAQ_INDEX";
else if (name.includes("200")) group = "KOSPI200_INDEX";
else if (name.includes("KRX100")) group = "KRX100_INDEX";
else group = "KOSPI_INDEX";
if (["s5037", "s5079", "s5080", "s8003"].includes(item?.builderKey)) {
if (group === "KOSPI_STOCK") return "KOSPI";
if (group === "KOSDAQ_STOCK") return "KOSDAQ";
}
return group;
}
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, [
"F_STOCK_WANNAME", "F_STOCK_NAME", "STOCK_NAME", "F_KNAM",
"F_INPUT_NAME", "NAME", "WANNAME"
]);
const dataCode = firstLiveValue(values, [
"F_STOCK_CODE", "STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE"
]);
if (!subject && !dataCode) {
showToast("이 행에는 장면 조회에 사용할 종목명이나 코드가 없습니다.");
return;
}
const previousSelection = 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
});
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
showToast("선택한 DB 행을 현재 장면에 안전하게 적용할 수 없습니다.");
return;
}
renderPlaylist();
updatePreview();
addLog(`${replacement.code} DB 선택 적용 · ${replacement.selection.subject}`);
showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
}
function renderLiveTables() {
elements.liveDataTables.replaceChildren();
for (const table of state.liveData.tables) {
const section = document.createElement("section");
section.className = "live-table-card";
const header = document.createElement("header");
const heading = document.createElement("div");
const name = document.createElement("h4");
name.textContent = table?.name || "시장 데이터";
const meta = document.createElement("small");
const rows = Array.isArray(table?.rows) ? table.rows : [];
const total = Number.isFinite(Number(table?.totalRowCount)) ? Number(table.totalRowCount) : rows.length;
meta.textContent = `전체 ${total.toLocaleString("ko-KR")}건 · 표시 ${rows.length.toLocaleString("ko-KR")}`;
heading.append(name, meta);
const source = document.createElement("span");
source.className = "live-source-badge";
const sourceName = normalizeSourceName(table?.source);
source.textContent = sourceLabels[sourceName] || table?.source || "DB";
header.append(heading, source);
section.append(header);
if (!rows.length) {
const empty = document.createElement("p");
empty.className = "live-table-empty";
empty.textContent = "조회된 행이 없습니다.";
section.append(empty);
elements.liveDataTables.append(section);
continue;
}
const columns = normalizeColumns(table);
const scroll = document.createElement("div");
scroll.className = "live-table-scroll";
const grid = document.createElement("table");
grid.className = "live-table";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const column of columns) {
const th = document.createElement("th");
th.textContent = column.label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
tr.title = "시장 현황 조회 전용입니다. 종목 컷은 위 종목 검색에서 선택하세요.";
columns.forEach((column, index) => {
const td = document.createElement("td");
td.textContent = formatCellValue(cellValue(row, column, index));
tr.append(td);
});
body.append(tr);
}
grid.append(head, body);
scroll.append(grid);
section.append(scroll);
elements.liveDataTables.append(section);
}
}
function renderLiveData() {
const visible = liveDataViews.has(state.activeMarket);
elements.liveDataPanel.hidden = !visible;
elements.catalogModeBadge.textContent = visible ? "DB LIVE" : "Web UI";
elements.catalogModeBadge.classList.toggle("live", visible);
if (!visible) return;
elements.liveDataTitle.textContent = `${marketTitles[state.activeMarket]} · DB 조회`;
elements.liveDataState.replaceChildren();
elements.liveDataState.className = `live-data-state ${state.liveData.status}`;
if (state.liveData.status === "loading") {
const spinner = document.createElement("span");
spinner.className = "live-spinner";
const copy = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = "데이터베이스에서 조회 중입니다.";
small.textContent = "연결 상태와 시장 데이터를 확인하고 있습니다.";
copy.append(strong, small);
elements.liveDataState.append(spinner, copy);
} else if (state.liveData.status === "error") {
const icon = document.createElement("span");
icon.className = "live-error-icon";
icon.textContent = "!";
const copy = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = "시장 데이터를 조회하지 못했습니다.";
small.textContent = state.liveData.error || "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
copy.append(strong, small);
elements.liveDataState.append(icon, copy);
} else if (state.liveData.status === "ready") {
const totalRows = state.liveData.tables.reduce((sum, table) => {
const count = Number(table?.totalRowCount);
return sum + (Number.isFinite(count) ? count : (Array.isArray(table?.rows) ? table.rows.length : 0));
}, 0);
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = state.liveData.tables.length
? `${state.liveData.tables.length}개 데이터셋 · 전체 ${totalRows.toLocaleString("ko-KR")}`
: "조회 결과가 없습니다.";
small.textContent = state.liveData.retrievedAt ? `조회 시각 ${formatDateTime(state.liveData.retrievedAt)}` : "조회 완료";
elements.liveDataState.append(strong, small);
}
renderDatabaseSources();
renderLiveTables();
}
function requestMarketData(view = state.activeMarket) {
clearLiveDataTimeout();
if (!liveDataViews.has(view)) {
state.liveData.requestId = null;
state.liveData.view = null;
state.liveData.status = "idle";
state.liveData.tables = [];
renderLiveData();
return;
}
const requestId = createId();
state.liveData.requestId = requestId;
state.liveData.view = view;
state.liveData.status = "loading";
state.liveData.tables = [];
state.liveData.retrievedAt = null;
state.liveData.error = "";
renderLiveData();
if (!nativeBridge) {
state.liveData.status = "error";
state.liveData.error = "브라우저 미리보기에서는 실제 DB 데이터를 조회할 수 없습니다.";
renderLiveData();
return;
}
postNative("request-market-data", { requestId, view, maximumRowsPerTable: 250 });
state.liveData.timeoutId = setTimeout(() => {
if (state.liveData.requestId !== requestId || state.liveData.status !== "loading") return;
state.liveData.status = "error";
state.liveData.error = "DB 응답 시간이 초과되었습니다. 연결 상태를 확인하고 다시 조회하세요.";
renderLiveData();
addLog(`${marketTitles[view]} DB 조회 시간 초과`);
}, 30000);
}
function handleMarketData(payload) {
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
clearLiveDataTimeout();
state.liveData.status = "ready";
state.liveData.tables = Array.isArray(payload.tables) ? payload.tables : [];
state.liveData.retrievedAt = payload.retrievedAt || new Date().toISOString();
state.liveData.error = "";
renderLiveData();
const shownRows = state.liveData.tables.reduce((sum, table) => sum + (Array.isArray(table?.rows) ? table.rows.length : 0), 0);
addLog(`${marketTitles[payload.view]} DB 조회 완료 · ${shownRows.toLocaleString("ko-KR")}행 표시`);
}
function handleMarketDataError(payload) {
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
clearLiveDataTimeout();
state.liveData.status = "error";
state.liveData.tables = [];
state.liveData.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
renderLiveData();
addLog(`${marketTitles[payload.view]} DB 조회 실패 · ${state.liveData.error}`);
}
function handleBridgeError(payload) {
const rawMessage = typeof payload?.message === "string" ? payload.message.trim() : "";
const message = rawMessage && rawMessage.length <= 512 &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(rawMessage)
? rawMessage
: "Native Bridge가 올바르지 않은 요청을 거부했습니다.";
addLog(`Native Bridge 요청 오류 · ${message}`);
showToast(message);
}
function handleNativeMessage(event) {
let message = event.data;
if (typeof message === "string") {
try { message = JSON.parse(message); } catch { return; }
}
if (!message || typeof message.type !== "string") return;
if (manualFinancialUi?.handleMessage(message.type, message.payload)) return;
if (manualListsUi?.handleMessage(message.type, message.payload)) return;
if (operatorCatalogUi?.handleMessage(message.type, message.payload)) return;
switch (message.type) {
case "bridge-error":
handleBridgeError(message.payload);
break;
case "app-info": {
const info = message.payload || {};
elements.bridgeState.textContent = "연결됨";
elements.runtimeLabel.textContent = `${info.framework || ".NET 8"} · ${info.architecture || "x64"}`;
elements.packageLabel.textContent = `${info.package || "MSIX"}${info.version ? ` · v${info.version}` : ""}`;
addLog(`Native Bridge 연결${info.windowsAppSdk ? ` · Windows App SDK ${info.windowsAppSdk}` : ""}`);
break;
}
case "database-status":
handleDatabaseStatus(message.payload);
break;
case "market-data":
handleMarketData(message.payload);
break;
case "market-data-error":
handleMarketDataError(message.payload);
break;
case "stock-search-results":
if (!handleManualFinancialRestoreStockResults(message.payload) &&
!handleComparisonDomesticResults(message.payload)) handleStockSearchResults(message.payload);
break;
case "stock-search-error":
if (!handleManualFinancialRestoreStockError(message.payload) &&
!handleComparisonDomesticError(message.payload)) handleStockSearchError(message.payload);
break;
case "manual-financial-load-results":
handleManualFinancialRestoreLoadResults(message.payload);
break;
case "manual-financial-load-error":
handleManualFinancialRestoreLoadError(message.payload);
break;
case "manual-financial-selection-results":
handleManualFinancialRestoreSelectionResults(message.payload);
break;
case "manual-financial-selection-error":
handleManualFinancialRestoreSelectionError(message.payload);
break;
case "manual-financial-request-error":
handleManualFinancialRestoreRequestError(message.payload);
break;
case "manual-net-sell-data":
case "vi-manual-list-data":
handleNamedManualListResults(message.payload);
break;
case "manual-operator-data-error":
handleNamedManualListError(message.payload);
break;
case "world-stock-search-results":
if (!handleOverseasSearchResults("stock", message.payload)) {
handleComparisonWorldResults(message.payload);
}
break;
case "world-stock-search-error":
if (!handleOverseasSearchError("stock", message.payload)) {
handleComparisonWorldError(message.payload);
}
break;
case "legacy-comparison-import-results":
handleLegacyComparisonImportResults(message.payload);
break;
case "legacy-comparison-import-error":
handleLegacyComparisonImportError(message.payload);
break;
case "overseas-industry-results":
handleOverseasSearchResults("industry", message.payload);
break;
case "overseas-industry-error":
handleOverseasSearchError("industry", message.payload);
break;
case "expert-search-results":
handleExpertSearchResults(message.payload);
break;
case "expert-search-error":
handleExpertSearchError(message.payload);
break;
case "expert-preview-results":
handleExpertPreviewResults(message.payload);
break;
case "expert-preview-error":
handleExpertPreviewError(message.payload);
break;
case "theme-search-results":
handleThemeSearchResults(message.payload);
break;
case "theme-search-error":
handleThemeSearchError(message.payload);
break;
case "theme-preview-results":
handleThemePreviewResults(message.payload);
break;
case "theme-preview-error":
handleThemePreviewError(message.payload);
break;
case "trading-halt-results":
handleTradingHaltResults(message.payload);
break;
case "trading-halt-error":
handleTradingHaltError(message.payload);
break;
case "background-status":
handleBackgroundStatus(message.payload);
break;
case "background-error":
handleBackgroundError(message.payload);
break;
case "industry-results":
handleIndustryResults(message.payload);
break;
case "industry-error":
handleIndustryError(message.payload);
break;
case "named-playlist-list-results":
handleNamedPlaylistListResults(message.payload);
break;
case "named-playlist-load-results":
handleNamedPlaylistLoadResults(message.payload);
break;
case "named-comparison-restore-results":
handleNamedComparisonRestoreResults(message.payload);
break;
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;
case "named-nxt-theme-restore-error":
handleNamedNxtThemeRestoreError(message.payload);
break;
case "named-playlist-mutation-results":
handleNamedPlaylistMutationResults(message.payload);
break;
case "named-playlist-error":
handleNamedPlaylistError(message.payload);
break;
case "playlist-page-plans-results":
handleNamedPlaylistPagePlanResults(message.payload);
break;
case "playlist-page-plans-error":
handleNamedPlaylistPagePlanError(message.payload);
break;
case "playout-status":
handlePlayoutStatus(message.payload);
break;
case "playout-command-result":
handlePlayoutCommandResult(message.payload);
break;
case "playout-command-error":
handlePlayoutCommandError(message.payload);
break;
}
}
function bindEvents() {
const marketNav = document.querySelector("#marketNav");
const marketNavReorderController = marketNavReorder.createController({
nav: marketNav,
isBlocked: () => hasOpenOperatorModal() || isPlaylistSnapshotLocked()
});
if (!marketNavReorderController.mount()) {
throw new Error("Market-nav identity contract is invalid.");
}
marketNav.addEventListener("click", event => {
const button = event.target.closest("button[data-market]");
if (!button) return;
const previousMarket = state.activeMarket;
document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button));
state.activeMarket = button.dataset.market;
if (previousMarket !== state.activeMarket && industryWorkflow.markets.includes(state.activeMarket)) {
state.industryWorkflowCollapsed = false;
}
elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
renderStockWorkflow();
renderFixedWorkflow();
renderIndustryWorkflow();
renderComparisonWorkflow();
renderThemeWorkflow();
renderOverseasWorkflow();
renderExpertWorkflow();
renderTradingHaltWorkflow();
renderCatalog();
requestMarketData(state.activeMarket);
if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket);
if (state.activeMarket === "theme" && state.theme.search.status === "idle") requestThemeSearch();
if (state.activeMarket === "foreign-stock" && currentOverseasStore().status === "idle") requestOverseasSearch();
if (state.activeMarket === "expert" && state.expert.search.status === "idle") requestExpertSearch();
if (state.activeMarket === "halted" && state.tradingHalt.status === "idle") requestTradingHaltSearch();
if (databaseBackedViews.has(state.activeMarket)) requestDatabaseStatus();
});
elements.catalogTabs.addEventListener("click", event => {
const button = event.target.closest("button[data-category]");
if (!button) return;
elements.catalogTabs.querySelectorAll("button").forEach(item => item.classList.toggle("active", item === button));
state.activeCategory = button.dataset.category;
renderCatalog();
});
elements.stockSearchForm.addEventListener("submit", requestStockSearch);
elements.stockCutBatchAdd.addEventListener("click", addSelectedStockCuts);
elements.stockCutSelectionClear.addEventListener("click", clearStockCutSelection);
elements.stockWorkflowToggle.addEventListener("click", () => {
state.stockWorkflowCollapsed = !state.stockWorkflowCollapsed;
renderStockWorkflow();
});
elements.legacyFixedToggle.addEventListener("click", () => {
state.fixedWorkflowCollapsed = !state.fixedWorkflowCollapsed;
renderFixedWorkflow();
});
elements.legacyFixedSearch.addEventListener("input", event => {
state.fixedSearch = event.target.value;
renderFixedWorkflow();
});
elements.industryRetry.addEventListener("click", () => requestIndustries(state.activeMarket));
elements.industryWorkflowToggle.addEventListener("click", () => {
state.industryWorkflowCollapsed = !state.industryWorkflowCollapsed;
renderIndustryWorkflow();
});
elements.industrySearch.addEventListener("input", event => {
state.industry.search = event.target.value;
renderIndustryWorkflow();
});
elements.industrySwap.addEventListener("click", () => {
if (isPlaylistSnapshotLocked() || !state.industry.pairFirst || !state.industry.pairSecond) return;
[state.industry.pairFirst, state.industry.pairSecond] =
[state.industry.pairSecond, state.industry.pairFirst];
renderIndustryWorkflow();
addLog("업종 비교 순서 교환");
});
elements.industryClear.addEventListener("click", () => {
if (isPlaylistSnapshotLocked()) return;
state.industry.highlighted = null;
state.industry.pairFirst = null;
state.industry.pairSecond = null;
renderIndustryWorkflow();
});
elements.comparisonSourceTabs.addEventListener("click", event => {
const button = event.target.closest("button[data-comparison-source]");
if (!button || isPlaylistSnapshotLocked()) return;
state.comparison.source = button.dataset.comparisonSource;
renderComparisonWorkflow();
});
elements.comparisonDomesticSearchForm.addEventListener("submit", requestComparisonDomesticSearch);
elements.comparisonWorldSearchForm.addEventListener("submit", requestComparisonWorldSearch);
elements.comparisonImportLegacy.addEventListener("click", requestLegacyComparisonImport);
elements.comparisonSwap.addEventListener("click", () => {
if (isPlaylistSnapshotLocked()) return;
const swapped = comparisonWorkflow.swapPair([
state.comparison.draftFirst,
state.comparison.draftSecond
]);
if (!swapped) return;
[state.comparison.draftFirst, state.comparison.draftSecond] = swapped;
renderComparisonWorkflow();
});
elements.comparisonClear.addEventListener("click", () => {
if (isPlaylistSnapshotLocked()) return;
[state.comparison.draftFirst, state.comparison.draftSecond] = comparisonWorkflow.clearPair();
renderComparisonWorkflow();
});
elements.comparisonSavePair.addEventListener("click", saveComparisonDraftPair);
elements.comparisonMoveUp.addEventListener("click", () => moveComparisonPair("up"));
elements.comparisonMoveDown.addEventListener("click", () => moveComparisonPair("down"));
elements.comparisonDeletePair.addEventListener("click", deleteSelectedComparisonPair);
elements.comparisonDeleteAll.addEventListener("click", deleteAllComparisonPairs);
elements.comparisonYieldBatch.addEventListener("click", addComparisonYieldBatch);
elements.themeSearchForm.addEventListener("submit", requestThemeSearch);
elements.themeNxtSession.addEventListener("change", () => {
if (isPlaylistSnapshotLocked()) return;
state.theme.nxtSession = elements.themeNxtSession.value;
requestThemeSearch();
});
elements.themeSort.addEventListener("change", () => {
const sort = themeWorkflow.normalizeThemeSort(elements.themeSort.value);
if (!sort || isPlaylistSnapshotLocked()) return;
state.theme.sort = sort.id;
renderThemeWorkflow();
});
elements.themeCatalogManageButton.addEventListener("click", () => {
const context = state.theme.preview.catalogContext;
operatorCatalogUi?.openTheme(context || undefined);
});
elements.overseasSourceTabs.addEventListener("click", event => {
const button = event.target.closest("button[data-overseas-source]");
if (!button || isPlaylistSnapshotLocked()) return;
state.overseas.source = button.dataset.overseasSource;
elements.overseasSearch.value = currentOverseasStore().request?.query || "";
renderOverseasWorkflow();
if (currentOverseasStore().status === "idle") requestOverseasSearch();
});
elements.overseasSearchForm.addEventListener("submit", requestOverseasSearch);
elements.expertSearchForm.addEventListener("submit", requestExpertSearch);
elements.expertCatalogManageButton.addEventListener("click", () => {
const context = state.expert.preview.catalogContext;
operatorCatalogUi?.openExpert(context || undefined);
});
elements.tradingHaltSearchForm.addEventListener("submit", requestTradingHaltSearch);
elements.tradingHaltNameSort.addEventListener("click", toggleTradingHaltNameSort);
elements.globalMa5.addEventListener("change", () =>
applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true));
elements.globalMa20.addEventListener("change", () =>
applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true));
elements.stockMa5.addEventListener("change", () => synchronizeStockCandleOptions(true));
elements.stockMa20.addEventListener("change", () => synchronizeStockCandleOptions(true));
elements.overseasMa5.addEventListener("change", () =>
applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true));
elements.overseasMa20.addEventListener("change", () =>
applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true));
elements.tradingHaltMa5.addEventListener("change", () =>
applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true));
elements.tradingHaltMa20.addEventListener("change", () =>
applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true));
elements.catalogSearch.addEventListener("input", event => { state.search = event.target.value; renderCatalog(); });
elements.toggleAllEnabled.addEventListener("click", toggleAllEntriesEnabled);
document.querySelector("#selectAllButton").addEventListener("click", toggleSelectAll);
document.querySelector("#moveUpButton").addEventListener("click", () => moveSelected(-1));
document.querySelector("#moveDownButton").addEventListener("click", () => moveSelected(1));
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
elements.deleteAllButton.addEventListener("click", deleteAllPlaylistEntries);
elements.saveButton.addEventListener("click", savePlaylist);
elements.loadButton.addEventListener("click", loadPlaylist);
elements.namedPlaylistOpenButton.addEventListener("click", () => {
state.namedPlaylist.open = !state.namedPlaylist.open;
renderNamedPlaylist();
if (state.namedPlaylist.open && !namedPlaylistBusy() && !state.namedPlaylist.definitions.length) {
requestNamedPlaylistList();
}
});
elements.namedPlaylistCloseButton.addEventListener("click", () => {
state.namedPlaylist.open = false;
renderNamedPlaylist();
});
elements.namedPlaylistRefreshButton.addEventListener("click", requestNamedPlaylistList);
elements.namedPlaylistDefinitions.addEventListener("change", () => {
state.namedPlaylist.selectedProgramCode = elements.namedPlaylistDefinitions.value;
renderNamedPlaylist();
});
elements.namedPlaylistLoadButton.addEventListener("click", requestNamedPlaylistLoad);
elements.namedPlaylistPageRetryButton.addEventListener("click", requestNamedPlaylistPagePlans);
elements.namedPlaylistRestoreBackupButton.addEventListener("click", restoreNamedPlaylistBackup);
elements.namedPlaylistCreateButton.addEventListener("click", requestNamedPlaylistCreate);
elements.namedPlaylistReplaceButton.addEventListener("click", requestNamedPlaylistReplace);
elements.namedPlaylistDeleteButton.addEventListener("click", requestNamedPlaylistDelete);
elements.sceneSelectionForm.addEventListener("submit", applySceneSelection);
elements.sceneCutAlias.addEventListener("change", applyCutAliasPreset);
elements.chooseBackgroundButton.addEventListener("click", requestBackgroundSelection);
elements.toggleBackgroundButton.addEventListener("click", requestBackgroundToggle);
elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare"));
elements.takeInButton.addEventListener("click", requestLegacyTakeInShortcut);
elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next"));
elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out"));
document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError());
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
document.querySelector("#requestInfoButton").addEventListener("click", () => {
postNative("request-app-info");
requestDatabaseStatus();
requestPlayoutStatus();
requestBackgroundStatus();
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket);
if (state.activeMarket === "foreign-stock") requestOverseasSearch();
if (state.activeMarket === "expert") requestExpertSearch();
if (state.activeMarket === "halted") requestTradingHaltSearch();
});
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
requestDatabaseStatus();
requestMarketData(state.activeMarket);
});
function hasOpenOperatorModal() {
return [...document.querySelectorAll(
'dialog[open], [role="dialog"][aria-modal="true"]')]
.some(element => !element.hidden && !element.closest("[hidden]"));
}
function isolateOperatorModalShortcut(event) {
if (!hasOpenOperatorModal()) return false;
const key = String(event.key || "");
const commandShortcut = ["F2", "F3", "F8", "Escape"].includes(key);
const playlistShortcut = ["Home", "End", "Delete", " "].includes(key);
const focusShortcut = event.ctrlKey && key.toLowerCase() === "k";
if (commandShortcut || focusShortcut) {
event.preventDefault();
}
if (commandShortcut || playlistShortcut || focusShortcut) {
event.stopPropagation();
if (!event.repeat && ["F8", "Escape"].includes(key)) {
showToast("편집 창을 먼저 닫은 뒤 송출 명령을 실행하세요.");
}
}
// Keep typing and native input editing inside the modal, but never let a key continue
// into the workspace-level playout, playlist, or focus shortcuts below.
return true;
}
document.addEventListener("keydown", event => {
if (isolateOperatorModalShortcut(event)) return;
if (["F2", "F3", "F8", "Escape"].includes(event.key)) {
event.preventDefault();
if (event.repeat) return;
if (event.key === "F2") notifyLegacyBackgroundShortcut();
else if (event.key === "F3") requestBackgroundToggle();
else if (event.key === "F8") requestLegacyTakeInShortcut();
else requestPlayoutCommand("take-out");
return;
}
if (event.ctrlKey && event.key.toLowerCase() === "k") {
event.preventDefault();
const comparisonTarget = state.comparison.source === "world"
? elements.comparisonWorldSearch
: (state.comparison.source === "domestic"
? elements.comparisonDomesticSearch
: elements.comparisonFixedTargets.querySelector("button"));
const target = !elements.tradingHaltWorkflow.hidden
? elements.tradingHaltSearch
: (!elements.expertWorkflow.hidden
? elements.expertSearch
: (!elements.overseasWorkflow.hidden
? elements.overseasSearch
: (!elements.themeWorkflow.hidden
? elements.themeSearch
: (!elements.comparisonWorkflow.hidden
? (comparisonTarget || elements.catalogSearch)
: (!elements.industryWorkflow.hidden && !state.industryWorkflowCollapsed
? elements.industrySearch
: (!elements.stockWorkflow.hidden && !state.stockWorkflowCollapsed
? elements.stockSearchInput
: (!elements.legacyFixedWorkflow.hidden ? elements.legacyFixedSearch : elements.catalogSearch)))))));
target.focus();
return;
}
if (event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement) return;
if (event.key === "Home") {
event.preventDefault();
selectPlaylistBoundary(0, event.shiftKey);
return;
}
if (event.key === "End") {
event.preventDefault();
selectPlaylistBoundary(state.playlist.length - 1, event.shiftKey);
return;
}
if (event.key === "Delete") { event.preventDefault(); deleteSelected(); }
if (event.key === " ") { event.preventDefault(); toggleCurrentEntryEnabled(); }
});
window.addEventListener("pagehide", () => operatorCatalogUi?.loseCorrelation());
}
function initializeOperatorUiControllers() {
const shared = {
postNative: (type, payload) => postNative(type, payload),
appendPlaylistEntry: appendTrustedOperatorEntry,
isLocked: operatorUiLocked,
showToast,
addLog,
createId
};
manualListsUi = manualListsUiModule.createController(shared);
manualFinancialUi = manualFinancialUiModule.createController({
...shared,
getSelectedStock: selectedStockForManualFinancial
});
operatorCatalogUi = operatorCatalogUiModule.createController({
postNative: shared.postNative,
isLocked: operatorUiLocked,
showToast,
addLog,
createId
});
manualListsUi.mount(document.body);
manualFinancialUi.mount(document.body);
operatorCatalogUi.mount(document.body);
}
function initialize() {
initializeOperatorUiControllers();
bindEvents();
loadComparisonPairList();
renderCatalog();
renderStockWorkflow();
renderFixedWorkflow();
renderIndustryWorkflow();
renderComparisonWorkflow();
renderThemeWorkflow();
renderOverseasWorkflow();
renderExpertWorkflow();
renderTradingHaltWorkflow();
renderBackgroundControls();
renderPlaylist();
renderNamedPlaylist();
updatePreview();
renderDatabaseStatus();
renderLiveData();
updateClock();
setInterval(updateClock, 1000);
addLog("WebView 운영 화면 시작");
if (nativeBridge) {
nativeBridge.addEventListener("message", handleNativeMessage);
postNative("ready");
postNative("request-app-info");
requestDatabaseStatus();
requestPlayoutStatus();
requestBackgroundStatus();
} else {
elements.bridgeState.textContent = "브라우저 미리보기";
state.database.loading = false;
state.playout.mode = "preview-dry-run";
state.playout.engineState = "IDLE";
state.playout.message = "Native Bridge 없음 · 미리보기 전용 DRY RUN (PROGRAM 출력 차단)";
renderPlayout();
renderDatabaseStatus();
addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행");
}
requestMarketData(state.activeMarket);
}
initialize();
})();