feat: restore legacy named playlist workflows
This commit is contained in:
279
Web/named-krx-theme-restore-workflow.js
Normal file
279
Web/named-krx-theme-restore-workflow.js
Normal file
@@ -0,0 +1,279 @@
|
||||
(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
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user