(() => { "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(); })();