(function (root, factory) { "use strict"; const themes = typeof module === "object" && module.exports ? require("./theme-workflow.js") : root.MbnThemeWorkflow; const api = Object.freeze(factory(themes)); if (typeof module === "object" && module.exports) module.exports = api; if (root) root.MbnNamedKrxThemeRestoreWorkflow = api; })(typeof globalThis === "object" ? globalThis : this, function (themes) { "use strict"; if (!themes) throw new Error("Theme workflow is unavailable."); const SCHEMA_VERSION = 1; const MAXIMUM_PREVIEW_ITEMS = 240; const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; const themeCodePattern = /^[0-9]{3,8}$/; const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/; const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; const pendingValues = new WeakSet(); const resolvedValues = new WeakSet(); const materializedValues = new WeakSet(); const failureCodes = new Set([ "INVALID_ROW", "UNSUPPORTED_ACTION", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS", "PREVIEW_EMPTY", "DATABASE_DATA_INVALID", "RESTORE_FAILED" ]); const profiles = Object.freeze({ "five-row-current": Object.freeze({ builderKey: "s5074", cutCode: "5074", pageSize: 5 }), "five-row-expected": Object.freeze({ builderKey: "s5074", cutCode: "5074", pageSize: 5 }), "six-row-current": Object.freeze({ builderKey: "s5077", cutCode: "5077", pageSize: 6 }), "twelve-row-current": Object.freeze({ builderKey: "s5088", cutCode: "5088", pageSize: 12 }) }); const subtypes = Object.freeze({ "테마-현재가(입력순)": Object.freeze({ valueKind: "CURRENT", sort: "INPUT_ORDER" }), "테마-현재가(상승률순)": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_DESC" }), "테마-현재가(하락률순)": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_ASC" }), "테마-현재가": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_ASC" }), "테마-예상체결가(입력순)": Object.freeze({ valueKind: "EXPECTED", sort: "INPUT_ORDER" }), "테마-예상체결가(상승률순)": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_DESC" }), "테마-예상체결가(하락률순)": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_ASC" }), "테마-예상체결가": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_ASC" }) }); const actionIds = Object.freeze({ "5단 표그래프|CURRENT": "five-row-current", "5단 표그래프|EXPECTED": "five-row-expected", "6종목 현재가|CURRENT": "six-row-current", "12종목 현재가|CURRENT": "twelve-row-current" }); const bridgeContract = Object.freeze({ resultType: "named-krx-theme-restore-results", errorType: "named-krx-theme-restore-error" }); function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function hasExactKeys(value, keys) { if (!isPlainObject(value)) return false; const actual = Object.keys(value).sort(); const expected = [...keys].sort(); return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } function safeText(value, maximumLength, allowEmpty = false) { return typeof value === "string" && value.length <= maximumLength && (allowEmpty || value.length > 0) && value === value.trim() && !value.includes("^") && !unsafeTextPattern.test(value); } function safeRaw(raw) { return isPlainObject(raw) && typeof raw.enabled === "boolean" && Number.isInteger(raw.itemIndex) && raw.itemIndex >= 0 && raw.itemIndex < 1000 && safeText(raw.groupCode, 256) && safeText(raw.subject, 256, true) && safeText(raw.graphicType, 256, true) && safeText(raw.subtype, 256, true) && safeText(raw.pageText, 9, true) && safeText(raw.dataCode, 256, true); } function expectedResolvedProfile(rawSelection) { const subtype = subtypes[rawSelection?.subtype]; const actionId = subtype ? actionIds[`${rawSelection.graphicType}|${subtype.valueKind}`] : null; return actionId ? Object.freeze({ actionId, sort: subtype.sort }) : null; } // Route every exact group row. Malformed and unsupported rows remain a // correlated native request so a stale code can never escape via fallback. function classify(raw, options = {}) { if (!safeRaw(raw) || raw.groupCode !== "테마" || !requestIdPattern.test(options.id) || !Number.isInteger(options.fadeDuration) || options.fadeDuration < 0 || options.fadeDuration > 60) return null; const pending = Object.freeze({ itemIndex: raw.itemIndex, enabled: raw.enabled, fadeDuration: options.fadeDuration, entry: Object.freeze({ id: options.id }), rawSelection: Object.freeze({ groupCode: raw.groupCode, subject: raw.subject, graphicType: raw.graphicType, subtype: raw.subtype, dataCode: raw.dataCode }) }); pendingValues.add(pending); return pending; } function normalizeOutcome(value, pending) { if (!pendingValues.has(pending) || !isPlainObject(value) || value.itemIndex !== pending.itemIndex) return null; if (value.status === "failed") { if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) || !failureCodes.has(value.failure)) return null; return Object.freeze({ ...value }); } const keys = [ "itemIndex", "status", "actionId", "builderKey", "cutCode", "pageSize", "sort", "themeTitle", "storedThemeCode", "currentThemeCode", "previewItemCount", "previewIsTruncated", "codeWasRemapped" ]; if (value.status !== "resolved" || !hasExactKeys(value, keys)) return null; const profile = profiles[value.actionId]; const expected = expectedResolvedProfile(pending.rawSelection); if (!profile || !expected || value.actionId !== expected.actionId || value.sort !== expected.sort || value.builderKey !== profile.builderKey || value.cutCode !== profile.cutCode || value.pageSize !== profile.pageSize || !safeText(value.themeTitle, 128) || value.themeTitle.includes("(NXT)") || pending.rawSelection.subject !== value.themeTitle || !themeCodePattern.test(value.storedThemeCode) || value.storedThemeCode !== pending.rawSelection.dataCode || !themeCodePattern.test(value.currentThemeCode) || value.codeWasRemapped !== (value.storedThemeCode !== value.currentThemeCode) || !Number.isInteger(value.previewItemCount) || value.previewItemCount < 1 || value.previewItemCount > MAXIMUM_PREVIEW_ITEMS || typeof value.previewIsTruncated !== "boolean" || value.previewIsTruncated && value.previewItemCount !== MAXIMUM_PREVIEW_ITEMS) return null; const normalized = Object.freeze({ ...value }); resolvedValues.add(normalized); return normalized; } function normalizeResponse(value, expectedRequestId, pendingRows) { if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) || pendingRows.length < 1 || pendingRows.length > 1000 || pendingRows.some(pending => !pendingValues.has(pending)) || !hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) || value.requestId !== expectedRequestId || typeof value.retrievedAt !== "string" || !isoTimestampPattern.test(value.retrievedAt) || !Number.isFinite(Date.parse(value.retrievedAt)) || value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) || value.rows.length !== pendingRows.length) return null; const rows = []; for (let index = 0; index < pendingRows.length; index += 1) { const outcome = normalizeOutcome(value.rows[index], pendingRows[index]); if (!outcome) return null; rows.push(outcome); } return Object.freeze({ requestId: value.requestId, retrievedAt: value.retrievedAt, rows: Object.freeze(rows) }); } function normalizeError(value, expectedRequestId) { return requestIdPattern.test(expectedRequestId) && hasExactKeys(value, ["requestId", "code", "message", "retryable"]) && value.requestId === expectedRequestId && safeText(value.code, 64) && safeText(value.message, 1024) && value.retryable === false ? Object.freeze({ ...value }) : null; } function materialize(pending, outcome) { if (!pendingValues.has(pending) || !resolvedValues.has(outcome) || outcome?.status !== "resolved" || outcome.itemIndex !== pending.itemIndex) return null; try { const created = themes.createThemePlaylistEntry( outcome.actionId, { market: "krx", themeCode: outcome.currentThemeCode, title: outcome.themeTitle, session: null, itemCount: outcome.previewItemCount }, outcome.sort, { id: pending.entry.id, fadeDuration: pending.fadeDuration }); const entry = themes.restoreThemePlaylistEntry({ ...created, enabled: pending.enabled, operator: Object.freeze({ ...created.operator, namedKrxRestore: Object.freeze({ schemaVersion: SCHEMA_VERSION, rawSelection: pending.rawSelection, currentThemeCode: outcome.currentThemeCode, previewItemCount: outcome.previewItemCount, previewIsTruncated: outcome.previewIsTruncated, codeWasRemapped: outcome.codeWasRemapped }) }) }); if (!entry) return null; // The generic theme restore validates and recreates the closed entry. // Add the correlated native proof only after that round-trip succeeds. const materialized = Object.freeze({ ...entry, operator: Object.freeze({ ...entry.operator, namedKrxRestore: Object.freeze({ schemaVersion: SCHEMA_VERSION, rawSelection: pending.rawSelection, currentThemeCode: outcome.currentThemeCode, previewItemCount: outcome.previewItemCount, previewIsTruncated: outcome.previewIsTruncated, codeWasRemapped: outcome.codeWasRemapped }) }) }); materializedValues.add(materialized); return materialized; } catch { return null; } } function matchesRaw(entry, raw) { if (!materializedValues.has(entry) || !safeRaw(raw)) return false; const saved = entry.operator?.namedKrxRestore?.rawSelection; return saved && raw.enabled === entry.enabled && ["groupCode", "subject", "graphicType", "subtype", "dataCode"] .every(key => saved[key] === raw[key]); } function legacySelectionForEntry(entry) { if (!materializedValues.has(entry)) return null; return entry.operator.namedKrxRestore.rawSelection; } function withEnabled(entry, enabled) { if (!materializedValues.has(entry) || typeof enabled !== "boolean") return null; if (entry.enabled === enabled) return entry; const replacement = Object.freeze({ ...entry, enabled }); materializedValues.add(replacement); return replacement; } function isPending(value) { return pendingValues.has(value); } function isMaterializedEntry(value) { return materializedValues.has(value); } return { bridgeContract, classify, normalizeResponse, normalizeError, materialize, withEnabled, matchesRaw, legacySelectionForEntry, isPending, isMaterializedEntry }; });