328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
(function (root, factory) {
|
|
"use strict";
|
|
|
|
const api = Object.freeze(factory());
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.MbnThemeWorkflow = api;
|
|
})(typeof globalThis === "object" ? globalThis : this, function () {
|
|
"use strict";
|
|
|
|
const DEFAULT_FADE_DURATION = 6;
|
|
const SCHEMA_VERSION = 1;
|
|
const MAXIMUM_PAGE_COUNT = 20;
|
|
const MAXIMUM_PREVIEW_ITEMS = 240;
|
|
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
|
// ThemeA always generated new codes as D8, but the production SB_LIST and
|
|
// historical PLAY_LIST still contain original three- and four-digit codes.
|
|
const themeCodePattern = /^[0-9]{3,8}$/;
|
|
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
|
|
|
const sourceContract = Object.freeze({
|
|
originalControl: "Control/UC4.cs",
|
|
krxResolverGroup: "PAGED_THEME",
|
|
nxtResolverGroup: "PAGED_NXT_THEME",
|
|
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
|
maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS
|
|
});
|
|
|
|
const themeSorts = Object.freeze([
|
|
Object.freeze({ id: "INPUT_ORDER", label: "입력순" }),
|
|
Object.freeze({ id: "GAIN_DESC", label: "상승률순" }),
|
|
Object.freeze({ id: "GAIN_ASC", label: "하락률순" })
|
|
]);
|
|
const themeSortsById = new Map(themeSorts.map(value => [value.id, value]));
|
|
|
|
const nxtSessions = Object.freeze([
|
|
Object.freeze({ id: "PRE_MARKET", label: "프리마켓" }),
|
|
Object.freeze({ id: "AFTER_MARKET", label: "애프터마켓" })
|
|
]);
|
|
|
|
function action(id, label, builderKey, code, pageSize, valueKind) {
|
|
return Object.freeze({
|
|
id,
|
|
label,
|
|
builderKey,
|
|
code,
|
|
aliases: Object.freeze([code]),
|
|
pageSize,
|
|
valueKind
|
|
});
|
|
}
|
|
|
|
const actions = Object.freeze([
|
|
action("five-row-current", "5단 표그래프 · 현재가", "s5074", "5074", 5, "CURRENT"),
|
|
action("five-row-expected", "5단 표그래프 · 예상체결가", "s5074", "5074", 5, "EXPECTED"),
|
|
action("six-row-current", "6종목 현재가", "s5077", "5077", 6, "CURRENT"),
|
|
action("twelve-row-current", "12종목 현재가", "s5088", "5088", 12, "CURRENT")
|
|
]);
|
|
const actionsById = new Map(actions.map(value => [value.id, value]));
|
|
|
|
function cleanText(value, maximumLength = 128) {
|
|
if (typeof value !== "string") return null;
|
|
const text = value.trim();
|
|
return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null;
|
|
}
|
|
|
|
function normalizeMarket(value) {
|
|
const text = String(value || "").trim().toLocaleLowerCase("en-US");
|
|
return text === "krx" || text === "nxt" ? text : null;
|
|
}
|
|
|
|
function normalizeNxtSession(value) {
|
|
const text = String(value || "")
|
|
.trim()
|
|
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
.replace(/-/g, "_")
|
|
.toLocaleUpperCase("en-US");
|
|
return text === "PRE_MARKET" || text === "AFTER_MARKET" ? text : null;
|
|
}
|
|
|
|
function isKrxEmptySession(value) {
|
|
if (value === undefined || value === null || value === "") return true;
|
|
const text = String(value)
|
|
.trim()
|
|
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
.replace(/-/g, "_")
|
|
.toLocaleUpperCase("en-US");
|
|
return text === "NOT_APPLICABLE";
|
|
}
|
|
|
|
function themeValidationReason(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid-theme";
|
|
const market = normalizeMarket(value.market ?? value.Market);
|
|
if (!market) return "invalid-theme-market";
|
|
const rawThemeCode = value.themeCode ?? value.ThemeCode ?? value.code;
|
|
const themeCode = cleanText(rawThemeCode, 8);
|
|
if (!themeCode || themeCode !== rawThemeCode || !themeCodePattern.test(themeCode)) {
|
|
return "invalid-theme-code";
|
|
}
|
|
const title = cleanText(value.title ?? value.themeTitle ?? value.ThemeTitle);
|
|
if (!title || title !== String(value.title ?? value.themeTitle ?? value.ThemeTitle) ||
|
|
title.includes("(NXT)")) return "invalid-theme-title";
|
|
const sessionValue = value.session ?? value.Session;
|
|
if (market === "nxt" && !normalizeNxtSession(sessionValue)) return "nxt-session-required";
|
|
if (market === "krx" && !isKrxEmptySession(sessionValue)) return "krx-session-must-be-empty";
|
|
const itemCountValue = value.itemCount ?? value.previewItemCount ??
|
|
(Array.isArray(value.items) ? value.items.length : null);
|
|
if (itemCountValue !== null && itemCountValue !== undefined &&
|
|
(!Number.isInteger(itemCountValue) || itemCountValue < 0 || itemCountValue > MAXIMUM_PREVIEW_ITEMS)) {
|
|
return "invalid-theme-item-count";
|
|
}
|
|
const canonicalDisplay = market === "nxt" ? `${title}(NXT)` : title;
|
|
if (value.displayTitle !== undefined && value.displayTitle !== canonicalDisplay) {
|
|
return "invalid-theme-display-title";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeTheme(value) {
|
|
if (themeValidationReason(value)) return null;
|
|
const market = normalizeMarket(value.market ?? value.Market);
|
|
const themeCode = value.themeCode ?? value.ThemeCode ?? value.code;
|
|
const title = value.title ?? value.themeTitle ?? value.ThemeTitle;
|
|
const itemCountValue = value.itemCount ?? value.previewItemCount ??
|
|
(Array.isArray(value.items) ? value.items.length : null);
|
|
const session = market === "nxt" ? normalizeNxtSession(value.session ?? value.Session) : null;
|
|
return Object.freeze({
|
|
market,
|
|
themeCode,
|
|
title,
|
|
displayTitle: market === "nxt" ? `${title}(NXT)` : title,
|
|
session,
|
|
itemCount: itemCountValue === undefined ? null : itemCountValue
|
|
});
|
|
}
|
|
|
|
function normalizeThemeSort(value) {
|
|
if (value && typeof value === "object" && !Array.isArray(value)) value = value.id;
|
|
const id = String(value || "").trim().toLocaleUpperCase("en-US");
|
|
return themeSortsById.get(id) ?? null;
|
|
}
|
|
|
|
function unsupported(actionId, reason) {
|
|
return Object.freeze({ actionId, supported: false, reason });
|
|
}
|
|
|
|
function getCompatibility(actionId, themeValue, sortValue) {
|
|
const selectedAction = actionsById.get(actionId);
|
|
if (!selectedAction) return unsupported(actionId, "unknown-action");
|
|
const reason = themeValidationReason(themeValue);
|
|
if (reason) return unsupported(actionId, reason);
|
|
const theme = normalizeTheme(themeValue);
|
|
if (!normalizeThemeSort(sortValue)) return unsupported(actionId, "invalid-theme-sort");
|
|
if (theme.itemCount === 0) return unsupported(actionId, "theme-preview-is-empty");
|
|
if (selectedAction.valueKind === "EXPECTED" && theme.market !== "krx") {
|
|
return unsupported(actionId, "expected-theme-is-krx-only");
|
|
}
|
|
return Object.freeze({ actionId, supported: true, reason: null });
|
|
}
|
|
|
|
function isActionAllowed(actionId, themeValue, sortValue) {
|
|
return getCompatibility(actionId, themeValue, sortValue).supported;
|
|
}
|
|
|
|
function calculatePagePreview(actionId, themeValue) {
|
|
const selectedAction = actionsById.get(actionId);
|
|
const theme = normalizeTheme(themeValue);
|
|
if (!selectedAction || !theme) return null;
|
|
const maximumItems = selectedAction.pageSize * MAXIMUM_PAGE_COUNT;
|
|
if (theme.itemCount === null) {
|
|
return Object.freeze({
|
|
itemCount: null,
|
|
accessibleItemCount: null,
|
|
pageSize: selectedAction.pageSize,
|
|
pageCount: null,
|
|
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
|
isTruncated: false
|
|
});
|
|
}
|
|
const accessibleItemCount = Math.min(theme.itemCount, maximumItems);
|
|
return Object.freeze({
|
|
itemCount: theme.itemCount,
|
|
accessibleItemCount,
|
|
pageSize: selectedAction.pageSize,
|
|
pageCount: Math.ceil(accessibleItemCount / selectedAction.pageSize),
|
|
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
|
isTruncated: theme.itemCount > maximumItems
|
|
});
|
|
}
|
|
|
|
function buildThemeSelection(actionId, themeValue, sortValue) {
|
|
const selectedAction = actionsById.get(actionId);
|
|
const theme = normalizeTheme(themeValue);
|
|
const sort = normalizeThemeSort(sortValue);
|
|
const compatibility = getCompatibility(actionId, themeValue, sortValue);
|
|
if (!selectedAction || !theme || !sort || !compatibility.supported) {
|
|
throw new RangeError(`The theme action is unavailable: ${compatibility.reason || "invalid-selection"}.`);
|
|
}
|
|
const selection = theme.market === "krx"
|
|
? Object.freeze({
|
|
groupCode: "PAGED_THEME",
|
|
subject: theme.title,
|
|
graphicType: sort.id,
|
|
subtype: selectedAction.valueKind,
|
|
dataCode: theme.themeCode
|
|
})
|
|
: Object.freeze({
|
|
groupCode: "PAGED_NXT_THEME",
|
|
subject: theme.displayTitle,
|
|
graphicType: theme.session,
|
|
subtype: sort.id,
|
|
dataCode: theme.themeCode
|
|
});
|
|
return Object.freeze({
|
|
builderKey: selectedAction.builderKey,
|
|
code: selectedAction.code,
|
|
aliases: selectedAction.aliases,
|
|
selection,
|
|
pageSize: selectedAction.pageSize,
|
|
pagePreview: calculatePagePreview(actionId, theme)
|
|
});
|
|
}
|
|
|
|
function requireOptions(options) {
|
|
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
throw new TypeError("Theme playlist options are required.");
|
|
}
|
|
const id = cleanText(options.id);
|
|
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe theme playlist id is required.");
|
|
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration);
|
|
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
|
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
|
}
|
|
return { id, fadeDuration };
|
|
}
|
|
|
|
function createThemePlaylistEntry(actionId, themeValue, sortValue, options) {
|
|
const selectedAction = actionsById.get(actionId);
|
|
if (!selectedAction) throw new RangeError("The theme action is unknown.");
|
|
const theme = normalizeTheme(themeValue);
|
|
if (!theme) throw new RangeError(`The theme selection is unavailable: ${themeValidationReason(themeValue)}.`);
|
|
const sort = normalizeThemeSort(sortValue);
|
|
if (!sort) throw new RangeError("The theme selection is unavailable: invalid-theme-sort.");
|
|
const normalized = requireOptions(options);
|
|
const mapping = buildThemeSelection(actionId, theme, sort);
|
|
return {
|
|
id: normalized.id,
|
|
builderKey: mapping.builderKey,
|
|
code: mapping.code,
|
|
aliases: mapping.aliases,
|
|
title: theme.displayTitle,
|
|
detail: `${selectedAction.label} · ${sort.label}`,
|
|
category: "plate",
|
|
market: "theme",
|
|
selection: mapping.selection,
|
|
enabled: true,
|
|
fadeDuration: normalized.fadeDuration,
|
|
graphicType: mapping.selection.graphicType,
|
|
subtype: mapping.selection.subtype,
|
|
pageSize: mapping.pageSize,
|
|
pagePreview: mapping.pagePreview,
|
|
operator: Object.freeze({
|
|
source: "legacy-theme-workflow",
|
|
schemaVersion: SCHEMA_VERSION,
|
|
actionId,
|
|
theme,
|
|
sort: sort.id,
|
|
resolverGroup: mapping.selection.groupCode
|
|
})
|
|
};
|
|
}
|
|
|
|
function sameSelection(left, right) {
|
|
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
|
|
return ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
|
.every(key => typeof left[key] === "string" && left[key] === right[key]);
|
|
}
|
|
|
|
function samePagePreview(left, right) {
|
|
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
|
|
return ["itemCount", "accessibleItemCount", "pageSize", "pageCount", "maximumPageCount", "isTruncated"]
|
|
.every(key => left[key] === right[key]);
|
|
}
|
|
|
|
function restoreThemePlaylistEntry(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null;
|
|
const operator = value.operator;
|
|
if (!operator || typeof operator !== "object" || operator.source !== "legacy-theme-workflow" ||
|
|
operator.schemaVersion !== SCHEMA_VERSION || !actionsById.has(operator.actionId)) return null;
|
|
let recreated;
|
|
try {
|
|
recreated = createThemePlaylistEntry(operator.actionId, operator.theme, operator.sort, {
|
|
id: value.id,
|
|
fadeDuration: value.fadeDuration
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
const scalars = [
|
|
"id", "builderKey", "code", "title", "detail", "category", "market",
|
|
"fadeDuration", "graphicType", "subtype", "pageSize"
|
|
];
|
|
if (!scalars.every(key => value[key] === recreated[key]) ||
|
|
!Array.isArray(value.aliases) || value.aliases.length !== recreated.aliases.length ||
|
|
!value.aliases.every((alias, index) => alias === recreated.aliases[index]) ||
|
|
!sameSelection(value.selection, recreated.selection) ||
|
|
!samePagePreview(value.pagePreview, recreated.pagePreview) ||
|
|
operator.resolverGroup !== recreated.operator.resolverGroup) return null;
|
|
return { ...recreated, enabled: value.enabled };
|
|
}
|
|
|
|
return {
|
|
sourceContract,
|
|
themeSorts,
|
|
nxtSessions,
|
|
actions,
|
|
themeActions: actions,
|
|
normalizeTheme,
|
|
normalizeThemeSort,
|
|
getCompatibility,
|
|
isActionAllowed,
|
|
calculatePagePreview,
|
|
buildThemeSelection,
|
|
createThemePlaylistEntry,
|
|
restoreThemePlaylistEntry,
|
|
refreshThemePlaylistEntry: restoreThemePlaylistEntry
|
|
};
|
|
});
|