Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

5218
Web/app.js

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnCandleOptionsWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const CANDLE_BUILDER_KEY = "s8010";
const supportedSources = Object.freeze([
"legacy-stock-workflow",
"legacy-fixed-workflow",
"legacy-industry-workflow",
"legacy-overseas-workflow",
"legacy-trading-halt-workflow"
]);
function normalizeOptions(ma5, ma20) {
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Global moving-average flags must be boolean values.");
}
return Object.freeze({ ma5, ma20 });
}
function expectedGraphicType(ma5, ma20) {
const options = normalizeOptions(ma5, ma20);
const flags = [];
if (options.ma5) flags.push("MA5");
if (options.ma20) flags.push("MA20");
return flags.join(",");
}
function hasAdapters(value) {
return value && typeof value === "object" && !Array.isArray(value) &&
value.stock && value.fixed && value.industry && value.overseas && value.tradingHalt;
}
function recreateCandleEntry(value, ma5, ma20, adapters, now = new Date()) {
const movingAverages = normalizeOptions(ma5, ma20);
if (!value || typeof value !== "object" || Array.isArray(value) ||
value.builderKey !== CANDLE_BUILDER_KEY || typeof value.enabled !== "boolean" ||
!hasAdapters(adapters)) return null;
const operator = value.operator;
if (!operator || typeof operator !== "object" || !supportedSources.includes(operator.source)) return null;
const options = {
id: value.id,
fadeDuration: value.fadeDuration,
ma5: movingAverages.ma5,
ma20: movingAverages.ma20
};
let recreated = null;
try {
switch (operator.source) {
case "legacy-stock-workflow": {
const cut = adapters.stock.getStockCut(operator.cutId);
if (cut?.kind !== "candle") return null;
recreated = adapters.stock.createStockPlaylistEntry(operator.stock, cut.id, options);
break;
}
case "legacy-fixed-workflow": {
const action = adapters.fixed.actions.find(item => item.id === operator.actionId);
if (action?.builderKey !== CANDLE_BUILDER_KEY || action.available !== true) return null;
recreated = adapters.fixed.createFixedPlaylistEntry(action.id, { ...options, now });
break;
}
case "legacy-industry-workflow": {
const cut = adapters.industry.cuts.find(item => item.id === operator.cutId);
if (cut?.kind !== "candle") return null;
recreated = adapters.industry.createIndustryPlaylistEntry(operator.market, cut.id, {
...options,
selected: operator.selected,
pair: operator.pair,
now
});
break;
}
case "legacy-overseas-workflow": {
const action = adapters.overseas.stockActions.find(item => item.id === operator.actionId);
if (operator.kind !== "stock" || action?.kind !== "candle") return null;
recreated = adapters.overseas.createOverseasStockPlaylistEntry(
operator.identity,
action.id,
options);
break;
}
case "legacy-trading-halt-workflow": {
const action = adapters.tradingHalt.getAction(operator.actionId);
if (action?.kind !== "candle") return null;
recreated = adapters.tradingHalt.createTradingHaltPlaylistEntry(
operator.tradingHalt,
action.id,
options);
break;
}
default:
return null;
}
} catch {
return null;
}
return recreated ? { ...recreated, enabled: value.enabled } : null;
}
function entryAlreadyMatches(value, ma5, ma20) {
const graphicType = expectedGraphicType(ma5, ma20);
return value?.selection?.graphicType === graphicType &&
value?.graphicType === graphicType &&
value?.operator?.movingAverages?.ma5 === ma5 &&
value?.operator?.movingAverages?.ma20 === ma20;
}
function synchronizePlaylist(values, ma5, ma20, adapters, now = new Date()) {
normalizeOptions(ma5, ma20);
if (!Array.isArray(values)) throw new TypeError("A playlist array is required.");
const invalidIds = [];
let changed = false;
const playlist = values.map(value => {
if (value?.builderKey !== CANDLE_BUILDER_KEY) return value;
if (entryAlreadyMatches(value, ma5, ma20)) return value;
const recreated = recreateCandleEntry(value, ma5, ma20, adapters, now);
if (!recreated) {
invalidIds.push(typeof value?.id === "string" ? value.id : "");
return value;
}
changed = true;
return recreated;
});
return Object.freeze({
playlist: Object.freeze(playlist),
changed,
valid: invalidIds.length === 0,
invalidIds: Object.freeze(invalidIds)
});
}
return {
candleBuilderKey: CANDLE_BUILDER_KEY,
supportedSources,
normalizeOptions,
expectedGraphicType,
recreateCandleEntry,
synchronizePlaylist
};
});

496
Web/comparison-workflow.js Normal file
View File

@@ -0,0 +1,496 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnComparisonWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const SCHEMA_VERSION = 1;
const PAIR_LIST_SCHEMA_VERSION = 1;
const MAX_SAVED_PAIRS = 500;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const stockCodePattern = /^[A-Za-z0-9._-]{1,32}$/;
const worldSymbolPattern = /^[A-Za-z0-9@._:-]{1,64}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const runtimeAsset = Object.freeze({
relativePath: "bin/Debug/Res/종목비교.ini",
sha256: "980F74C000EF6A34B85FDAAF6D2756269483637186C91A93548F35C5CFC1B7E4"
});
const marketTargetRows = [
["kospi", "코스피 지수", "Kospi"],
["kosdaq", "코스닥 지수", "Kosdaq"],
["kospi200", "코스피200 지수", "Kospi200"],
["futures", "선물 지수", "Futures"],
["krx100", "KRX100지수", "Krx100"],
["won-dollar", "환율-원달러", "WonDollar"],
["won-yen", "환율-원엔", "WonYen"],
["won-yuan", "환율-원위엔", "WonYuan"],
["won-euro", "환율-원유로", "WonEuro"],
["dow", "해외지수-다우", "Dow"],
["nasdaq", "해외지수-나스닥", "Nasdaq"],
["sp500", "해외지수-S&P", "Sp500"],
["germany-dax", "해외지수-독일", "GermanyDax"],
["united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"],
["france-cac", "해외지수-프랑스", "FranceCac"],
["nikkei", "해외지수-일본", "Nikkei"],
["hang-seng", "해외지수-홍콩", "HangSeng"],
["taiwan-weighted", "해외지수-대만", "TaiwanWeighted"],
["singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"],
["thailand-set", "해외지수-태국", "ThailandSet"],
["philippines-composite", "해외지수-필리핀", "PhilippinesComposite"],
["malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"],
["indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"],
["shanghai-composite", "해외지수-중국", "ShanghaiComposite"]
];
const marketTargets = Object.freeze(marketTargetRows.map(([id, displayName, target]) =>
Object.freeze({ kind: "market-target", id, displayName, target })));
if (marketTargets.length !== 24 || new Set(marketTargets.map(value => value.target)).size !== 24) {
throw new Error("The legacy comparison selector must expose exactly 24 market targets.");
}
const marketTargetsByTarget = new Map(marketTargets.map(value => [value.target, value]));
const marketTargetsByLookup = new Map();
for (const target of marketTargets) {
marketTargetsByLookup.set(target.id.toLocaleLowerCase("en-US"), target);
marketTargetsByLookup.set(target.target.toLocaleLowerCase("en-US"), target);
marketTargetsByLookup.set(target.displayName.toLocaleLowerCase("ko-KR"), target);
}
function action(id, label, kind, details = {}) {
return Object.freeze({ id, label, kind, ...details });
}
const actions = Object.freeze([
action("two-column-expected", "2열판 예상체결", "two-column", { mode: "EXPECTED" }),
action("two-column-current", "2열판", "two-column", { mode: "CURRENT" }),
action("comparison-candle", "종목별 비교분석_캔들 그래프", "comparison", {
builderKey: "s5026", code: "5026", graphicType: "COMPARISON_CANDLE", period: "FiveDays"
}),
action("comparison-line", "종목별 비교분석_라인 그래프", "comparison", {
builderKey: "s5087", code: "5087", graphicType: "COMPARISON_LINE", period: "FiveDays"
}),
action("return-5d", "5일", "return", { period: "FiveDays" }),
action("return-1m", "1개월", "return", { period: "OneMonth" }),
action("return-3m", "3개월", "return", { period: "ThreeMonths" }),
action("return-6m", "6개월", "return", { period: "SixMonths" }),
action("return-12m", "12개월", "return", { period: "TwelveMonths" })
]);
if (actions.length !== 9 || new Set(actions.map(value => value.id)).size !== 9) {
throw new Error("The legacy comparison tree must expose exactly nine actions.");
}
const actionsById = new Map(actions.map(value => [value.id, value]));
const yieldActionIds = Object.freeze(actions
.filter(value => value.kind === "return")
.map(value => value.id));
const expectedMarketTargets = new Set(["Kospi", "Kosdaq", "Kospi200", "Krx100"]);
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 getMarketTarget(value) {
if (value && typeof value === "object" && !Array.isArray(value)) value = value.target ?? value.id ?? value.displayName;
const text = cleanText(value);
return text ? marketTargetsByLookup.get(text.toLocaleLowerCase("ko-KR")) ?? null : null;
}
function normalizeMarket(value) {
const text = String(value || "").trim().toLocaleLowerCase("en-US");
return text === "kospi" || text === "kosdaq" ? text : null;
}
function normalizeTarget(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
switch (value.kind) {
case "market-target": {
const target = marketTargetsByTarget.get(value.target);
if (!target) return null;
if ((value.id !== undefined && value.id !== target.id) ||
(value.displayName !== undefined && value.displayName !== target.displayName)) return null;
return target;
}
case "krx-stock":
case "nxt-stock": {
const market = normalizeMarket(value.market);
const stockName = cleanText(value.stockName ?? value.name);
const stockCode = cleanText(value.stockCode ?? value.code, 32);
if (!market || !stockName || stockName.includes(",") || stockName.includes("(NXT)") ||
!stockCode || !stockCodePattern.test(stockCode)) return null;
const displayName = value.kind === "nxt-stock" ? `${stockName}(NXT)` : stockName;
if (value.displayName !== undefined && value.displayName !== displayName) return null;
return Object.freeze({ kind: value.kind, market, stockName, stockCode, displayName });
}
case "world-stock": {
const inputName = cleanText(value.inputName);
const displayName = cleanText(value.displayName ?? value.name);
const symbol = cleanText(value.symbol, 64);
const nation = cleanText(value.nation, 2)?.toLocaleUpperCase("en-US");
if (!inputName || inputName.includes(",") || !displayName || !symbol ||
!worldSymbolPattern.test(symbol) || (nation !== "US" && nation !== "TW")) return null;
return Object.freeze({ kind: "world-stock", inputName, displayName, symbol, nation });
}
default:
return null;
}
}
function targetKey(value) {
const target = normalizeTarget(value);
if (!target) return null;
switch (target.kind) {
case "market-target": return `market:${target.target}`;
case "krx-stock": return `krx:${target.market}:${target.stockCode}`;
case "nxt-stock": return `nxt:${target.market}:${target.stockCode}`;
case "world-stock": return `world:${target.nation}:${target.symbol}`;
default: return null;
}
}
function subjectTokenForTarget(value) {
const target = normalizeTarget(value);
if (!target) return null;
switch (target.kind) {
case "market-target": return target.target;
case "krx-stock": return target.stockName;
case "nxt-stock": return `${target.stockName}(NXT)`;
case "world-stock": return target.inputName;
default: return null;
}
}
function pairParts(value) {
if (Array.isArray(value)) return [value[0], value[1]];
if (value && typeof value === "object") return [value.first ?? value.pair?.[0], value.second ?? value.pair?.[1]];
return [null, null];
}
function normalizePair(value) {
const [firstValue, secondValue] = pairParts(value);
const first = normalizeTarget(firstValue);
const second = normalizeTarget(secondValue);
return first && second ? Object.freeze([first, second]) : null;
}
function normalizePairState(value) {
const [firstValue, secondValue] = pairParts(value);
const first = firstValue == null ? null : normalizeTarget(firstValue);
const second = secondValue == null ? null : normalizeTarget(secondValue);
return (firstValue == null || first) && (secondValue == null || second)
? Object.freeze([first, second])
: null;
}
function rotatePair(value, selectedValue) {
const pair = normalizePairState(value);
const selected = normalizeTarget(selectedValue);
return pair && selected ? Object.freeze([selected, pair[0]]) : null;
}
function swapPair(value) {
const pair = normalizePairState(value);
return pair ? Object.freeze([pair[1], pair[0]]) : null;
}
function clearPair() {
return Object.freeze([null, null]);
}
function pairLabel(value) {
const pair = normalizePair(value);
return pair ? `${pair[0].displayName}${pair[1].displayName}` : "";
}
function unsupported(actionId, reason) {
return Object.freeze({ actionId, supported: false, reason, effectiveMode: null, warning: null });
}
function supported(actionId, effectiveMode = null, warning = null) {
return Object.freeze({ actionId, supported: true, reason: null, effectiveMode, warning });
}
function getCompatibility(actionId, pairValue) {
const selectedAction = actionsById.get(actionId);
if (!selectedAction) return unsupported(actionId, "unknown-action");
const pair = normalizePair(pairValue);
if (!pair) return unsupported(actionId, "pair-incomplete-or-invalid");
const futuresCount = pair.filter(value => value.kind === "market-target" && value.target === "Futures").length;
const hasWorld = pair.some(value => value.kind === "world-stock");
if (selectedAction.kind === "comparison" || selectedAction.kind === "return") {
return pair.every(value => value.kind === "krx-stock")
? supported(actionId, "GRAPH")
: unsupported(actionId, "comparison-graphs-require-two-krx-stocks");
}
if (futuresCount > 1) return unsupported(actionId, "two-futures-targets-are-unsupported");
if (futuresCount === 1) {
const counterpart = pair.find(value => !(value.kind === "market-target" && value.target === "Futures"));
if (!counterpart || counterpart.kind !== "market-target") {
return unsupported(actionId, "futures-requires-a-market-target-counterpart");
}
return selectedAction.mode === "EXPECTED"
? supported(actionId, "CURRENT", "The current Core routes a futures expected action through the current s5032 branch.")
: supported(actionId, "CURRENT");
}
if (selectedAction.mode === "EXPECTED") {
const allowed = pair.every(value => value.kind === "krx-stock" ||
(value.kind === "market-target" && expectedMarketTargets.has(value.target)));
return allowed
? supported(actionId, "EXPECTED")
: unsupported(actionId, "expected-mode-supports-only-krx-stocks-and-four-domestic-indices");
}
const hasMarketTarget = pair.some(value => value.kind === "market-target");
if (hasMarketTarget && hasWorld) {
return unsupported(actionId, "mixed-market-and-world-is-unsupported");
}
return supported(actionId, "CURRENT");
}
function isActionAllowed(actionId, pairValue) {
return getCompatibility(actionId, pairValue).supported;
}
function buildComparisonSelection(actionId, pairValue) {
const selectedAction = actionsById.get(actionId);
const pair = normalizePair(pairValue);
const compatibility = getCompatibility(actionId, pair);
if (!selectedAction || !pair || !compatibility.supported) {
throw new RangeError(`The comparison action is unavailable: ${compatibility.reason || "invalid-selection"}.`);
}
const subject = pair.map(subjectTokenForTarget).join(",");
if (selectedAction.kind === "two-column") {
const futures = pair.some(value => value.kind === "market-target" && value.target === "Futures");
const code = futures ? "5032" : "8032";
return Object.freeze({
builderKey: futures ? "s5032" : "s8018",
code,
aliases: Object.freeze([code]),
selection: Object.freeze({
groupCode: pair.some(value => value.kind === "market-target") ? "INDEX" : "STOCK",
subject,
graphicType: "2열판",
subtype: selectedAction.mode,
dataCode: ""
}),
effectiveMode: compatibility.effectiveMode,
warning: compatibility.warning
});
}
const builderKey = selectedAction.kind === "return" ? "s5029" : selectedAction.builderKey;
const code = selectedAction.kind === "return" ? "5029" : selectedAction.code;
const graphicType = selectedAction.kind === "return" ? "RETURN_COMPARISON" : selectedAction.graphicType;
return Object.freeze({
builderKey,
code,
aliases: Object.freeze([code]),
selection: Object.freeze({
groupCode: "STOCK",
subject,
graphicType,
subtype: selectedAction.period,
dataCode: ""
}),
effectiveMode: compatibility.effectiveMode,
warning: compatibility.warning
});
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Comparison playlist options are required.");
}
const id = cleanText(options.id);
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe comparison 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 createComparisonPlaylistEntry(actionId, pairValue, options) {
const selectedAction = actionsById.get(actionId);
if (!selectedAction) throw new RangeError("The comparison action is unknown.");
const pair = normalizePair(pairValue);
if (!pair) throw new RangeError("Select two comparison targets first.");
const normalized = requireOptions(options);
const mapping = buildComparisonSelection(actionId, pair);
const entry = {
id: normalized.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: mapping.aliases,
title: pairLabel(pair),
detail: selectedAction.label,
category: selectedAction.kind === "two-column" ? "plate" : "chart",
market: "compare",
selection: mapping.selection,
enabled: true,
fadeDuration: normalized.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
effectiveMode: mapping.effectiveMode,
warning: mapping.warning,
operator: Object.freeze({
source: "legacy-comparison-workflow",
schemaVersion: SCHEMA_VERSION,
actionId,
pair,
effectiveMode: mapping.effectiveMode,
assetPath: runtimeAsset.relativePath,
assetSha256: runtimeAsset.sha256
})
};
return entry;
}
function createYieldBatchEntries(pairValue, options = {}) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Yield batch options must be an object.");
}
const idPrefix = cleanText(options.idPrefix ?? "comparison-return", 96);
if (!idPrefix || !identifierPattern.test(idPrefix)) throw new TypeError("A safe yield batch id prefix is required.");
return Object.freeze(yieldActionIds.map(actionId => createComparisonPlaylistEntry(actionId, pairValue, {
id: `${idPrefix}-${actionId.replace("return-", "")}`,
fadeDuration: options.fadeDuration
})));
}
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 restoreComparisonPlaylistEntry(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-comparison-workflow" ||
operator.schemaVersion !== SCHEMA_VERSION || operator.assetPath !== runtimeAsset.relativePath ||
operator.assetSha256 !== runtimeAsset.sha256 || !actionsById.has(operator.actionId)) return null;
let recreated;
try {
recreated = createComparisonPlaylistEntry(operator.actionId, operator.pair, {
id: value.id,
fadeDuration: value.fadeDuration
});
} catch {
return null;
}
const scalars = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"fadeDuration", "graphicType", "subtype", "effectiveMode", "warning"
];
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) ||
operator.effectiveMode !== recreated.operator.effectiveMode) return null;
return { ...recreated, enabled: value.enabled };
}
function createPairRecord(idValue, pairValue) {
const id = cleanText(idValue);
const pair = normalizePair(pairValue);
if (!id || !identifierPattern.test(id) || !pair) return null;
return Object.freeze({ id, pair });
}
function emptyPairList() {
return Object.freeze({ schemaVersion: PAIR_LIST_SCHEMA_VERSION, pairs: Object.freeze([]) });
}
function freezePairList(records) {
return Object.freeze({ schemaVersion: PAIR_LIST_SCHEMA_VERSION, pairs: Object.freeze(records) });
}
function normalizePairList(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
value.schemaVersion !== PAIR_LIST_SCHEMA_VERSION || !Array.isArray(value.pairs)) return emptyPairList();
const records = [];
const ids = new Set();
for (const raw of value.pairs) {
if (records.length >= MAX_SAVED_PAIRS) break;
const record = createPairRecord(raw?.id, raw?.pair ?? raw);
if (!record || ids.has(record.id)) continue;
ids.add(record.id);
records.push(record);
}
return freezePairList(records);
}
function appendPair(value, recordOrId, pairValue) {
const list = normalizePairList(value);
const record = typeof recordOrId === "string"
? createPairRecord(recordOrId, pairValue)
: createPairRecord(recordOrId?.id, recordOrId?.pair ?? recordOrId);
if (!record) throw new TypeError("A valid saved comparison pair is required.");
if (list.pairs.length >= MAX_SAVED_PAIRS) throw new RangeError("The saved comparison pair limit was reached.");
if (list.pairs.some(value => value.id === record.id)) throw new RangeError("The saved comparison pair id already exists.");
return freezePairList([...list.pairs, record]);
}
function reorderPairList(value, idValue, directionValue) {
const list = normalizePairList(value);
const id = cleanText(idValue);
const direction = directionValue === "up" ? -1 : directionValue === "down" ? 1 : Number(directionValue);
if (!id || (direction !== -1 && direction !== 1)) return list;
const from = list.pairs.findIndex(value => value.id === id);
const to = from + direction;
if (from < 0 || to < 0 || to >= list.pairs.length) return list;
const records = [...list.pairs];
[records[from], records[to]] = [records[to], records[from]];
return freezePairList(records);
}
function deletePair(value, idValue) {
const list = normalizePairList(value);
const id = cleanText(idValue);
return id ? freezePairList(list.pairs.filter(value => value.id !== id)) : list;
}
function deleteAllPairs() {
return emptyPairList();
}
return {
runtimeAsset,
marketTargets,
marketQuoteTargets: marketTargets,
actions,
comparisonActions: actions,
yieldActionIds,
pairListSchemaVersion: PAIR_LIST_SCHEMA_VERSION,
normalizeTarget,
getMarketTarget,
targetKey,
subjectTokenForTarget,
normalizePair,
rotatePair,
swapPair,
clearPair,
pairLabel,
getCompatibility,
isActionAllowed,
buildComparisonSelection,
createComparisonPlaylistEntry,
createYieldBatchEntries,
restoreComparisonPlaylistEntry,
refreshComparisonPlaylistEntry: restoreComparisonPlaylistEntry,
createPairRecord,
normalizePairList,
appendPair,
reorderPairList,
deletePair,
deleteAllPairs
};
});

502
Web/expert-workflow.js Normal file
View File

@@ -0,0 +1,502 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnExpertWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const DEFAULT_MAXIMUM_RESULTS = 100;
const MAXIMUM_RESULTS = 500;
const MAXIMUM_QUERY_LENGTH = 64;
const DEFAULT_MAXIMUM_PREVIEW_ITEMS = 100;
const MAXIMUM_PREVIEW_ITEMS = 100;
const MAXIMUM_PAGE_COUNT = 20;
const PAGE_SIZE = 5;
const SCHEMA_VERSION = 1;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const expertCodePattern = /^[0-9]{4}$/;
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const bridgeContract = Object.freeze({
searchRequestType: "search-experts",
searchResultType: "expert-search-results",
searchErrorType: "expert-search-error",
previewRequestType: "request-expert-preview",
previewResultType: "expert-preview-results",
previewErrorType: "expert-preview-error",
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
maximumResults: MAXIMUM_RESULTS,
maximumQueryLength: MAXIMUM_QUERY_LENGTH,
defaultMaximumPreviewItems: DEFAULT_MAXIMUM_PREVIEW_ITEMS,
maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS
});
const sourceContract = Object.freeze({
originalControl: "Control/UC6.cs",
provider: "oracle",
expertTable: "EXPERT_LIST",
recommendationTable: "RECOMMEND_LIST",
originalGroupLabel: "전문가 추천",
originalCutLabel: "5단 표그래프",
originalValueLabel: "현재가",
builderKey: "s5074",
cutCode: "5074",
resolverGroup: "PAGED_EXPERT",
pageSize: PAGE_SIZE,
maximumPageCount: MAXIMUM_PAGE_COUNT
});
const actions = Object.freeze([
Object.freeze({
id: "five-row-current",
label: "전문가 추천 · 5단 표그래프 · 현재가",
builderKey: "s5074",
code: "5074",
aliases: Object.freeze(["5074"]),
pageSize: PAGE_SIZE
})
]);
const action = actions[0];
const trustedPreviewResponses = new WeakSet();
function hasExactKeys(value, keys) {
if (!value || typeof value !== "object" || Array.isArray(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 hasOnlyKeys(value, keys) {
return value && typeof value === "object" && !Array.isArray(value) &&
Object.keys(value).every(key => keys.includes(key));
}
function isSafeCanonicalText(value, maximumLength) {
return typeof value === "string" &&
value.length > 0 && value.length <= maximumLength &&
value === value.trim() && !unsafeTextPattern.test(value);
}
function isSafeRequestId(value) {
return isSafeCanonicalText(value, 128) && identifierPattern.test(value);
}
function normalizeQuery(value) {
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
const query = value.trim();
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query)
? query
: null;
}
function normalizeExpertIdentity(value) {
if (!hasExactKeys(value, ["expertCode", "expertName"]) ||
!expertCodePattern.test(value.expertCode) ||
!isSafeCanonicalText(value.expertName, 128)) return null;
return Object.freeze({
expertCode: value.expertCode,
expertName: value.expertName
});
}
function normalizeExpert(value) {
if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) ||
value.source !== "oracle") return null;
const identity = normalizeExpertIdentity({
expertCode: value.expertCode,
expertName: value.expertName
});
return identity && Object.freeze({ ...identity, source: "oracle" });
}
function createExpertSearchRequest(requestId, query = "", maximumResults) {
if (!isSafeRequestId(requestId)) {
throw new TypeError("A safe expert-search request id is required.");
}
const normalizedQuery = normalizeQuery(query);
if (normalizedQuery === null) {
throw new TypeError(`An expert query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
}
if (maximumResults !== undefined &&
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
throw new RangeError(`Expert results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
}
const request = { requestId, query: normalizedQuery };
if (maximumResults !== undefined) request.maximumResults = maximumResults;
return Object.freeze(request);
}
function compareExperts(left, right) {
if (left.expertName < right.expertName) return -1;
if (left.expertName > right.expertName) return 1;
if (left.expertCode < right.expertCode) return -1;
if (left.expertCode > right.expertCode) return 1;
return 0;
}
function normalizeExpertSearchResponse(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
]) || !isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
!Array.isArray(value.results) || value.results.length !== value.totalRowCount) return null;
let expected = null;
if (expectedRequest !== undefined) {
try {
expected = createExpertSearchRequest(
expectedRequest?.requestId,
expectedRequest?.query,
expectedRequest?.maximumResults);
} catch {
return null;
}
const bound = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
if (value.requestId !== expected.requestId || value.query !== expected.query ||
value.results.length > bound || (value.truncated && value.results.length !== bound)) return null;
}
const results = [];
const codes = new Set();
const names = new Set();
for (const raw of value.results) {
const expert = normalizeExpert(raw);
if (!expert || codes.has(expert.expertCode) || names.has(expert.expertName) ||
(results.length && compareExperts(results[results.length - 1], expert) > 0)) return null;
codes.add(expert.expertCode);
names.add(expert.expertName);
results.push(expert);
}
return Object.freeze({
requestId: value.requestId,
query: value.query,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
results: Object.freeze(results)
});
}
function normalizeExpertSearchError(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
!isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
!isSafeCanonicalText(value.message, 512)) return null;
if (expectedRequest) {
let expected;
try {
expected = createExpertSearchRequest(
expectedRequest.requestId,
expectedRequest.query,
expectedRequest.maximumResults);
} catch {
return null;
}
if (value.requestId !== expected.requestId || value.query !== expected.query) return null;
}
return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
}
function createExpertPreviewRequest(requestId, expertValue, maximumItems) {
if (!isSafeRequestId(requestId)) {
throw new TypeError("A safe expert-preview request id is required.");
}
const expert = normalizeExpert(expertValue);
if (!expert) throw new TypeError("A typed Oracle expert is required.");
if (maximumItems !== undefined &&
(!Number.isInteger(maximumItems) || maximumItems < 1 || maximumItems > MAXIMUM_PREVIEW_ITEMS)) {
throw new RangeError(`Expert preview items must be limited from 1 through ${MAXIMUM_PREVIEW_ITEMS}.`);
}
const request = {
requestId,
expertCode: expert.expertCode,
expertName: expert.expertName
};
if (maximumItems !== undefined) request.maximumItems = maximumItems;
return Object.freeze(request);
}
function normalizeRecommendation(value) {
if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
!Number.isInteger(value.playIndex) || value.playIndex < 0 || value.playIndex > 9999 ||
typeof value.stockCode !== "string" || !stockCodePattern.test(value.stockCode) ||
!isSafeCanonicalText(value.stockName, 200) ||
!Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0) return null;
return Object.freeze({
playIndex: value.playIndex,
stockCode: value.stockCode,
stockName: value.stockName,
buyAmount: value.buyAmount
});
}
function normalizeExpertPreviewResponse(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
]) || !isSafeRequestId(value.requestId) ||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
value.totalRowCount > MAXIMUM_PREVIEW_ITEMS || typeof value.truncated !== "boolean" ||
!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
const selection = normalizeExpertIdentity(value.selection);
if (!selection) return null;
if (expectedRequest !== undefined) {
let expected;
try {
expected = createExpertPreviewRequest(
expectedRequest?.requestId,
{
expertCode: expectedRequest?.expertCode,
expertName: expectedRequest?.expertName,
source: "oracle"
},
expectedRequest?.maximumItems);
} catch {
return null;
}
const bound = expected.maximumItems ?? DEFAULT_MAXIMUM_PREVIEW_ITEMS;
if (value.requestId !== expected.requestId ||
selection.expertCode !== expected.expertCode ||
selection.expertName !== expected.expertName ||
value.items.length > bound || (value.truncated && value.items.length !== bound)) return null;
}
const items = [];
const indexes = new Set();
const codes = new Set();
const names = new Set();
for (const raw of value.items) {
const item = normalizeRecommendation(raw);
if (!item || indexes.has(item.playIndex) || codes.has(item.stockCode) || names.has(item.stockName) ||
(items.length && items[items.length - 1].playIndex >= item.playIndex)) return null;
indexes.add(item.playIndex);
codes.add(item.stockCode);
names.add(item.stockName);
items.push(item);
}
const result = Object.freeze({
requestId: value.requestId,
selection,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
items: Object.freeze(items)
});
trustedPreviewResponses.add(result);
return result;
}
function normalizeExpertPreviewError(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "selection", "message"]) ||
!isSafeRequestId(value.requestId) || !isSafeCanonicalText(value.message, 512)) return null;
const selection = normalizeExpertIdentity(value.selection);
if (!selection) return null;
if (expectedRequest) {
let expected;
try {
expected = createExpertPreviewRequest(
expectedRequest.requestId,
{
expertCode: expectedRequest.expertCode,
expertName: expectedRequest.expertName,
source: "oracle"
},
expectedRequest.maximumItems);
} catch {
return null;
}
if (value.requestId !== expected.requestId ||
selection.expertCode !== expected.expertCode ||
selection.expertName !== expected.expertName) return null;
}
return Object.freeze({ requestId: value.requestId, selection, message: value.message });
}
function createSelectableExpert(expertValue, previewValue) {
const expert = normalizeExpert(expertValue);
if (!expert || !previewValue || typeof previewValue !== "object" ||
!trustedPreviewResponses.has(previewValue) ||
!normalizeExpertIdentity(previewValue.selection) ||
previewValue.selection.expertCode !== expert.expertCode ||
previewValue.selection.expertName !== expert.expertName ||
!Number.isInteger(previewValue.totalRowCount) || previewValue.totalRowCount < 0 ||
previewValue.totalRowCount > MAXIMUM_PREVIEW_ITEMS ||
typeof previewValue.truncated !== "boolean") return null;
return Object.freeze({
...expert,
itemCount: previewValue.totalRowCount,
previewTruncated: previewValue.truncated
});
}
function normalizeSelectableExpert(value) {
if (!hasExactKeys(value, [
"expertCode", "expertName", "source", "itemCount", "previewTruncated"
]) || !Number.isInteger(value.itemCount) || value.itemCount < 0 ||
value.itemCount > MAXIMUM_PREVIEW_ITEMS || typeof value.previewTruncated !== "boolean") return null;
const expert = normalizeExpert({
expertCode: value.expertCode,
expertName: value.expertName,
source: value.source
});
return expert && Object.freeze({
...expert,
itemCount: value.itemCount,
previewTruncated: value.previewTruncated
});
}
function calculatePagePreview(expertValue) {
const expert = normalizeSelectableExpert(expertValue);
if (!expert) return null;
return Object.freeze({
itemCount: expert.itemCount,
accessibleItemCount: expert.itemCount,
pageSize: PAGE_SIZE,
pageCount: Math.ceil(expert.itemCount / PAGE_SIZE),
maximumPageCount: MAXIMUM_PAGE_COUNT,
isTruncated: expert.previewTruncated
});
}
function buildExpertSelection(expertValue) {
const expert = normalizeSelectableExpert(expertValue);
if (!expert) throw new TypeError("A previewed expert selection is required.");
return Object.freeze({
builderKey: action.builderKey,
code: action.code,
aliases: action.aliases,
selection: Object.freeze({
groupCode: "PAGED_EXPERT",
subject: expert.expertName,
graphicType: "INPUT_ORDER",
subtype: "CURRENT",
dataCode: expert.expertCode
})
});
}
function requireOptions(options) {
if (!hasOnlyKeys(options, ["id", "fadeDuration"]) || !isSafeRequestId(options.id)) {
throw new TypeError("Safe expert playlist options are required.");
}
const fadeDuration = options.fadeDuration === undefined
? DEFAULT_FADE_DURATION
: options.fadeDuration;
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
throw new RangeError("Fade duration must be an integer from 0 through 60.");
}
return Object.freeze({ id: options.id, fadeDuration });
}
function createExpertPlaylistEntry(expertValue, options) {
const expert = normalizeSelectableExpert(expertValue);
if (!expert) throw new TypeError("A previewed expert selection is required.");
if (expert.itemCount === 0) throw new RangeError("An expert with no recommendation rows cannot create a cut.");
const normalizedOptions = requireOptions(options);
const mapping = buildExpertSelection(expert);
const pagePreview = calculatePagePreview(expert);
return {
id: normalizedOptions.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: [...mapping.aliases],
title: expert.expertName,
detail: action.label,
category: "expert",
source: expert.source,
expertCode: expert.expertCode,
expertName: expert.expertName,
itemCount: expert.itemCount,
previewTruncated: expert.previewTruncated,
pageSize: pagePreview.pageSize,
pageCount: pagePreview.pageCount,
selection: mapping.selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
operator: Object.freeze({
source: "legacy-expert-workflow",
schemaVersion: SCHEMA_VERSION,
actionId: action.id,
expert,
pagePreview
})
};
}
function sameStringArray(left, right) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
left.every((value, index) => value === right[index]);
}
function sameSelection(left, right) {
return left && right && ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => typeof left[key] === "string" && left[key] === right[key]);
}
function samePagePreview(left, right) {
return left && right && [
"itemCount", "accessibleItemCount", "pageSize", "pageCount",
"maximumPageCount", "isTruncated"
].every(key => left[key] === right[key]);
}
function restoreExpertPlaylistEntry(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
typeof value.enabled !== "boolean") return null;
const operator = value.operator;
if (!hasExactKeys(operator, ["source", "schemaVersion", "actionId", "expert", "pagePreview"]) ||
operator.source !== "legacy-expert-workflow" || operator.schemaVersion !== SCHEMA_VERSION ||
operator.actionId !== action.id || !normalizeSelectableExpert(operator.expert)) return null;
let recreated;
try {
recreated = createExpertPlaylistEntry(operator.expert, {
id: value.id,
fadeDuration: value.fadeDuration
});
} catch {
return null;
}
const scalars = [
"id", "builderKey", "code", "title", "detail", "category", "source",
"expertCode", "expertName", "itemCount", "previewTruncated", "pageSize",
"pageCount", "fadeDuration", "graphicType", "subtype"
];
if (!scalars.every(key => value[key] === recreated[key]) ||
!sameStringArray(value.aliases, recreated.aliases) ||
!sameSelection(value.selection, recreated.selection) ||
!samePagePreview(operator.pagePreview, recreated.operator.pagePreview)) return null;
return { ...recreated, enabled: value.enabled };
}
return {
bridgeContract,
sourceContract,
actions,
normalizeExpertIdentity,
normalizeExpert,
createExpertSearchRequest,
normalizeExpertSearchResponse,
normalizeExpertSearchError,
createExpertPreviewRequest,
normalizeRecommendation,
normalizeExpertPreviewResponse,
normalizeExpertPreviewError,
createSelectableExpert,
calculatePagePreview,
buildExpertSelection,
createExpertPlaylistEntry,
restoreExpertPlaylistEntry,
refreshExpertPlaylistEntry: restoreExpertPlaylistEntry
};
});

View File

@@ -6,6 +6,9 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>MBN Stock WebView</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="manual-lists-ui.css">
<link rel="stylesheet" href="manual-financial-ui.css">
<link rel="stylesheet" href="operator-catalog-ui.css">
</head>
<body>
<div class="app-shell">
@@ -27,6 +30,7 @@
<button class="nav-item" data-market="kosdaq"><span>Q</span>코스닥</button>
<button class="nav-item" data-market="compare"><span></span>비교</button>
<button class="nav-item" data-market="theme"><span></span>테마</button>
<button class="nav-item" data-market="foreign-stock"><span></span>해외종목</button>
<button class="nav-item" data-market="expert"><span></span>전문가</button>
<button class="nav-item" data-market="halted"><span></span>정지</button>
</nav>
@@ -61,12 +65,278 @@
<div class="console-grid">
<section class="panel catalog-panel">
<div class="panel-header">
<div><p class="panel-kicker">CUT CATALOG</p><h2>그래픽 항목</h2></div>
<div><p class="panel-kicker">OPERATOR INPUT</p><h2>종목·그래픽 선택</h2></div>
<span id="catalogModeBadge" class="badge">Web UI</span>
</div>
<section id="stockWorkflow" class="stock-workflow" aria-labelledby="stockWorkflowTitle">
<div class="stock-workflow-header">
<div>
<p class="panel-kicker">LEGACY STOCK WORKFLOW</p>
<h3 id="stockWorkflowTitle">종목 검색</h3>
</div>
<div class="stock-workflow-tools">
<span id="stockSearchBadge" class="badge neutral">DB SEARCH</span>
<button id="stockWorkflowToggle" type="button" aria-expanded="true">접기</button>
</div>
</div>
<form id="stockSearchForm" class="stock-search-form" autocomplete="off">
<label for="stockSearchInput" class="visually-hidden">종목명</label>
<input id="stockSearchInput" type="search" maxlength="64" placeholder="예: 삼성" spellcheck="false">
<button id="stockSearchButton" type="submit">검색</button>
</form>
<div id="stockSearchState" class="stock-search-state" role="status" aria-live="polite">종목명을 입력하고 Enter를 누르세요.</div>
<div id="stockSearchResults" class="stock-search-results" role="listbox" aria-label="종목 검색 결과"></div>
<div id="selectedStock" class="selected-stock" hidden>
<span>선택 종목</span>
<strong id="selectedStockName"></strong>
<small id="selectedStockMeta"></small>
</div>
<div class="stock-cut-header">
<div><strong>종목용 컷</strong><small>더블클릭 또는 + 버튼으로 추가</small></div>
<label><input id="stockMa5" type="checkbox" checked>5일선</label>
<label><input id="stockMa20" type="checkbox" checked>20일선</label>
</div>
<div id="stockCutList" class="stock-cut-list" aria-label="종목용 컷 목록"></div>
<div id="manualStockActions" class="manual-stock-actions" aria-label="수동 재무 데이터"></div>
</section>
<section id="legacyFixedWorkflow" class="legacy-fixed-workflow" aria-labelledby="legacyFixedWorkflowTitle" hidden>
<div class="legacy-fixed-header">
<div>
<p class="panel-kicker">LEGACY RUNTIME CATALOG</p>
<h3 id="legacyFixedWorkflowTitle">원본 운영 항목</h3>
</div>
<div class="legacy-fixed-tools">
<span id="legacyFixedCount" class="badge neutral">0 ACTIONS</span>
<button id="legacyFixedToggle" type="button" aria-expanded="true">접기</button>
</div>
</div>
<label class="legacy-fixed-search">
<span></span>
<input id="legacyFixedSearch" type="search" maxlength="64" autocomplete="off" placeholder="원본 항목 검색">
</label>
<div id="legacyFixedState" class="legacy-fixed-state" role="status" aria-live="polite"></div>
<div id="legacyFixedSections" class="legacy-fixed-sections" aria-label="원본 운영 항목"></div>
</section>
<section id="industryWorkflow" class="industry-workflow" aria-labelledby="industryWorkflowTitle" hidden>
<div class="industry-workflow-header">
<div>
<p class="panel-kicker">LEGACY INDUSTRY WORKFLOW</p>
<h3 id="industryWorkflowTitle">업종 선택</h3>
</div>
<div class="industry-workflow-tools">
<span id="industryCount" class="badge neutral">DB</span>
<button id="industryRetry" type="button">다시 조회</button>
<button id="industryWorkflowToggle" type="button" aria-expanded="true">접기</button>
</div>
</div>
<label class="industry-search">
<span></span>
<input id="industrySearch" type="search" maxlength="64" autocomplete="off" placeholder="업종명 검색">
</label>
<div id="industryState" class="industry-state" role="status" aria-live="polite"></div>
<div id="industryResults" class="industry-results" role="listbox" aria-label="업종 목록"></div>
<div class="industry-pair" aria-label="업종 비교 선택">
<div><span>첫 번째</span><strong id="industryPrimary">선택 없음</strong></div>
<button id="industrySwap" type="button" title="두 업종 순서 교환"></button>
<div><span>두 번째</span><strong id="industrySecondary">선택 없음</strong></div>
<button id="industryClear" type="button">초기화</button>
</div>
<div id="industryCutList" class="industry-cut-list" aria-label="업종용 컷 목록"></div>
</section>
<section id="comparisonWorkflow" class="comparison-workflow" aria-labelledby="comparisonWorkflowTitle" hidden>
<div class="comparison-workflow-header">
<div>
<p class="panel-kicker">LEGACY COMPARISON WORKFLOW</p>
<h3 id="comparisonWorkflowTitle">종목 비교</h3>
</div>
<span id="comparisonPairCount" class="badge neutral">0 PAIRS</span>
</div>
<div id="comparisonSourceTabs" class="comparison-source-tabs" role="tablist" aria-label="비교 대상 종류">
<button type="button" class="active" data-comparison-source="domestic">국내·NXT</button>
<button type="button" data-comparison-source="fixed">지수·환율</button>
<button type="button" data-comparison-source="world">해외 종목</button>
</div>
<section id="comparisonDomesticPanel" class="comparison-source-panel">
<form id="comparisonDomesticSearchForm" class="comparison-search-form" autocomplete="off">
<input id="comparisonDomesticSearch" type="search" maxlength="64" placeholder="국내·NXT 종목명" aria-label="국내·NXT 종목명">
<button id="comparisonDomesticSearchButton" type="submit">검색</button>
</form>
<div id="comparisonDomesticState" class="comparison-search-state" role="status" aria-live="polite"></div>
<div id="comparisonDomesticResults" class="comparison-target-results" role="listbox" aria-label="국내·NXT 종목 검색 결과"></div>
</section>
<section id="comparisonFixedPanel" class="comparison-source-panel" hidden>
<div id="comparisonFixedTargets" class="comparison-target-results fixed" role="listbox" aria-label="고정 비교 대상"></div>
</section>
<section id="comparisonWorldPanel" class="comparison-source-panel" hidden>
<form id="comparisonWorldSearchForm" class="comparison-search-form" autocomplete="off">
<input id="comparisonWorldSearch" type="search" maxlength="64" placeholder="미국·대만 종목명" aria-label="해외 종목명">
<button id="comparisonWorldSearchButton" type="submit">검색</button>
</form>
<div id="comparisonWorldState" class="comparison-search-state" role="status" aria-live="polite"></div>
<div id="comparisonWorldResults" class="comparison-target-results" role="listbox" aria-label="해외 종목 검색 결과"></div>
</section>
<div class="comparison-draft" aria-label="비교쌍 편집">
<div><span>첫 번째</span><strong id="comparisonFirst">선택 없음</strong></div>
<button id="comparisonSwap" type="button" title="비교 대상 순서 교환"></button>
<div><span>두 번째</span><strong id="comparisonSecond">선택 없음</strong></div>
<button id="comparisonClear" type="button">초기화</button>
<button id="comparisonSavePair" class="primary" type="button">비교쌍 추가</button>
</div>
<div class="comparison-pair-header">
<div><strong>저장 비교쌍</strong><small>행을 선택한 뒤 컷을 추가합니다.</small></div>
<div>
<button id="comparisonMoveUp" type="button" title="위로"></button>
<button id="comparisonMoveDown" type="button" title="아래로"></button>
<button id="comparisonDeletePair" type="button">삭제</button>
<button id="comparisonDeleteAll" type="button">전체삭제</button>
</div>
</div>
<div id="comparisonPairList" class="comparison-pair-list" role="listbox" aria-label="저장 비교쌍"></div>
<div class="comparison-action-header">
<div><strong>비교용 컷</strong><small>원본 종목비교.ini 9개 액션</small></div>
<button id="comparisonYieldBatch" type="button">수익률 5종 일괄 추가</button>
</div>
<div id="comparisonActionList" class="comparison-action-list" aria-label="비교용 컷 목록"></div>
</section>
<section id="themeWorkflow" class="theme-workflow" aria-labelledby="themeWorkflowTitle" hidden>
<div class="theme-workflow-header">
<div>
<p class="panel-kicker">LEGACY THEME WORKFLOW</p>
<h3 id="themeWorkflowTitle">테마 선택</h3>
</div>
<span id="themeCount" class="badge neutral">READ ONLY</span>
</div>
<form id="themeSearchForm" class="theme-search-form" autocomplete="off">
<input id="themeSearch" type="search" maxlength="64" placeholder="테마명 · 빈 값은 전체" aria-label="테마명">
<select id="themeNxtSession" aria-label="NXT 세션">
<option value="preMarket">NXT 장전</option>
<option value="afterMarket">NXT 시간외</option>
</select>
<button id="themeSearchButton" type="submit">조회</button>
</form>
<div id="themeSearchState" class="theme-search-state" role="status" aria-live="polite"></div>
<div id="themeResults" class="theme-results" role="listbox" aria-label="테마 검색 결과"></div>
<div id="selectedTheme" class="selected-theme" hidden>
<div><span>선택 테마</span><strong id="selectedThemeTitle"></strong><small id="selectedThemeMeta"></small></div>
<label>정렬
<select id="themeSort">
<option value="INPUT_ORDER">입력순</option>
<option value="GAIN_DESC">상승률순</option>
<option value="GAIN_ASC">하락률순</option>
</select>
</label>
</div>
<div class="theme-preview-header">
<div><strong>테마 종목</strong><small>송출 전 read-only 미리보기</small></div>
<span id="themePreviewCount">0 ITEMS</span>
</div>
<div id="themePreviewState" class="theme-preview-state" role="status" aria-live="polite"></div>
<div id="themePreviewItems" class="theme-preview-items" aria-label="테마 종목 미리보기"></div>
<div class="theme-action-header"><strong>테마용 컷</strong><small>5·6·12개 단위 · 최대 20페이지</small></div>
<div id="themeActionList" class="theme-action-list" aria-label="테마용 컷 목록"></div>
<div class="theme-crud-notice">
<span>ThemeA · 트랜잭션 기반 추가·편집·삭제</span>
<button id="themeCatalogManageButton" type="button">테마 관리</button>
</div>
</section>
<section id="overseasWorkflow" class="overseas-workflow" aria-labelledby="overseasWorkflowTitle" hidden>
<div class="overseas-workflow-header">
<div>
<p class="panel-kicker">LEGACY FOREIGN INSTRUMENT WORKFLOW</p>
<h3 id="overseasWorkflowTitle">해외업종·해외종목</h3>
</div>
<span id="overseasCount" class="badge neutral">ORACLE</span>
</div>
<div id="overseasSourceTabs" class="overseas-source-tabs" role="tablist" aria-label="해외 검색 종류">
<button type="button" class="active" data-overseas-source="industry">미국 해외업종</button>
<button type="button" data-overseas-source="stock">US·TW 해외종목</button>
</div>
<form id="overseasSearchForm" class="overseas-search-form" autocomplete="off">
<input id="overseasSearch" type="search" maxlength="64" placeholder="빈 값은 전체 목록" aria-label="해외업종 또는 해외종목 검색어">
<button id="overseasSearchButton" type="submit">조회</button>
</form>
<div id="overseasSearchState" class="overseas-search-state" role="status" aria-live="polite"></div>
<div id="overseasResults" class="overseas-results" role="listbox" aria-label="해외업종·해외종목 검색 결과"></div>
<div id="selectedOverseas" class="selected-overseas" hidden>
<div>
<span>선택 대상</span>
<strong id="selectedOverseasName">-</strong>
<small id="selectedOverseasMeta">-</small>
</div>
<div class="overseas-ma-options" aria-label="해외종목 캔들 이동평균선">
<label><input id="overseasMa5" type="checkbox" checked>5일선</label>
<label><input id="overseasMa20" type="checkbox" checked>20일선</label>
</div>
</div>
<div class="overseas-action-header"><strong>해외 대상 컷</strong><small>업종 1개 · 종목 6개 닫힌 동작</small></div>
<div id="overseasActionList" class="overseas-action-list" aria-label="해외 대상 컷 목록"></div>
</section>
<section id="expertWorkflow" class="expert-workflow" aria-labelledby="expertWorkflowTitle" hidden>
<div class="expert-workflow-header">
<div>
<p class="panel-kicker">LEGACY EXPERT WORKFLOW</p>
<h3 id="expertWorkflowTitle">전문가 추천</h3>
</div>
<span id="expertCount" class="badge neutral">READ ONLY</span>
</div>
<form id="expertSearchForm" class="expert-search-form" autocomplete="off">
<input id="expertSearch" type="search" maxlength="64" placeholder="전문가명 · 빈 값은 전체" aria-label="전문가명">
<button id="expertSearchButton" type="submit">조회</button>
</form>
<div id="expertSearchState" class="expert-search-state" role="status" aria-live="polite"></div>
<div id="expertResults" class="expert-results" role="listbox" aria-label="전문가 검색 결과"></div>
<div id="selectedExpert" class="selected-expert" hidden>
<span>선택 전문가</span>
<strong id="selectedExpertName">-</strong>
<small id="selectedExpertMeta">-</small>
</div>
<div class="expert-preview-header">
<div><strong>추천 종목</strong><small>PLAYINDEX 순서 · read-only 미리보기</small></div>
<span id="expertPreviewCount">0 ITEMS</span>
</div>
<div id="expertPreviewState" class="expert-preview-state" role="status" aria-live="polite"></div>
<div id="expertPreviewItems" class="expert-preview-items" aria-label="전문가 추천 종목 미리보기"></div>
<div class="expert-action-header"><strong>전문가 추천 컷</strong><small>5개 단위 · 최대 20페이지</small></div>
<div id="expertActionList" class="expert-action-list" aria-label="전문가 추천 컷 목록"></div>
<div class="expert-crud-notice">
<span>EList · 전문가·추천종목 추가·편집·삭제</span>
<button id="expertCatalogManageButton" type="button">전문가 관리</button>
</div>
</section>
<section id="tradingHaltWorkflow" class="trading-halt-workflow" aria-labelledby="tradingHaltWorkflowTitle" hidden>
<div class="trading-halt-workflow-header">
<div>
<p class="panel-kicker">LEGACY TRADING HALT WORKFLOW</p>
<h3 id="tradingHaltWorkflowTitle">거래 정지 종목</h3>
</div>
<span id="tradingHaltCount" class="badge neutral">DB</span>
</div>
<form id="tradingHaltSearchForm" class="trading-halt-search-form" autocomplete="off">
<input id="tradingHaltSearch" type="search" maxlength="64" placeholder="종목명 · 빈 값은 전체" aria-label="거래 정지 종목명">
<button id="tradingHaltSearchButton" type="submit">조회</button>
</form>
<div id="tradingHaltSearchState" class="trading-halt-search-state" role="status" aria-live="polite"></div>
<div id="tradingHaltResults" class="trading-halt-results" role="listbox" aria-label="거래 정지 종목 검색 결과"></div>
<div id="selectedTradingHalt" class="selected-trading-halt" hidden>
<div>
<span>선택 종목</span>
<strong id="selectedTradingHaltName">-</strong>
<small id="selectedTradingHaltMeta">-</small>
</div>
<div class="trading-halt-ma-options" aria-label="캔들 이동평균선">
<label><input id="tradingHaltMa5" type="checkbox" checked>5일선</label>
<label><input id="tradingHaltMa20" type="checkbox" checked>20일선</label>
</div>
</div>
<div class="trading-halt-action-header">
<strong>거래 정지 컷</strong>
<small>원본 UC7 · 현재가와 120일 캔들</small>
</div>
<div id="tradingHaltActionList" class="trading-halt-action-list" aria-label="거래 정지 컷 목록"></div>
</section>
<label class="search-box">
<span></span>
<input id="catalogSearch" type="search" autocomplete="off" placeholder="종목·지수·그래픽 검색">
<input id="catalogSearch" type="search" autocomplete="off" placeholder="장면 코드·그래픽 검색">
<kbd>Ctrl K</kbd>
</label>
<div id="catalogTabs" class="catalog-tabs" role="tablist">
@@ -96,17 +366,64 @@
<span id="playlistCount" class="badge neutral">0 CUTS</span>
</div>
<div class="playlist-actions">
<button id="selectAllButton" type="button">전체 선택</button>
<button id="toggleAllEnabledButton" type="button">전체 송출</button>
<button id="selectAllButton" type="button">작업 전체선택</button>
<button id="moveUpButton" type="button" title="선택 항목 위로"></button>
<button id="moveDownButton" type="button" title="선택 항목 아래로"></button>
<button id="deleteButton" class="danger" type="button">삭제</button>
<span></span>
<button id="loadButton" type="button">불러오기</button>
<button id="saveButton" class="primary-soft" type="button">저장</button>
<button id="deleteAllButton" class="danger" type="button">전체삭제</button>
<div class="global-candle-options" aria-label="전체 캔들 이동평균선">
<span>캔들</span>
<label><input id="globalMa5" type="checkbox" checked>5일선</label>
<label><input id="globalMa20" type="checkbox" checked>20일선</label>
</div>
<button id="namedPlaylistOpenButton" class="database-soft" type="button" aria-expanded="false" aria-controls="namedPlaylistPanel">DB 저장·불러오기</button>
<button id="loadButton" type="button">로컬 불러오기</button>
<button id="saveButton" class="primary-soft" type="button">로컬 임시저장</button>
</div>
<section id="namedPlaylistPanel" class="named-playlist-panel" aria-labelledby="namedPlaylistTitle" hidden>
<header class="named-playlist-header">
<div>
<p class="panel-kicker">ORACLE · DC_LIST / PLAY_LIST</p>
<h3 id="namedPlaylistTitle">DB 플레이리스트</h3>
</div>
<div class="named-playlist-header-actions">
<span id="namedPlaylistStateBadge" class="badge neutral">대기</span>
<button id="namedPlaylistRefreshButton" type="button">목록 새로고침</button>
<button id="namedPlaylistCloseButton" type="button" aria-label="DB 플레이리스트 닫기">닫기</button>
</div>
</header>
<div id="namedPlaylistStatus" class="named-playlist-status" role="status" aria-live="polite">
Oracle에 저장된 이름 목록을 조회하세요. 로컬 임시저장과는 별도입니다.
</div>
<div class="named-playlist-layout">
<label class="named-playlist-definitions-label" for="namedPlaylistDefinitions">저장된 이름</label>
<select id="namedPlaylistDefinitions" class="named-playlist-definitions" size="6" aria-describedby="namedPlaylistSelectionSummary"></select>
<div id="namedPlaylistSelectionSummary" class="named-playlist-selection-summary">선택된 DB 정의 없음</div>
<div class="named-playlist-load-actions">
<button id="namedPlaylistLoadButton" class="primary-soft" type="button">선택 DB 불러오기</button>
<button id="namedPlaylistPageRetryButton" type="button" hidden>페이지 다시 조회</button>
<button id="namedPlaylistRestoreBackupButton" type="button">불러오기 전 상태 복원</button>
</div>
<div class="named-playlist-create">
<label for="namedPlaylistProgramCode">새 코드</label>
<input id="namedPlaylistProgramCode" type="text" inputmode="numeric" maxlength="8" autocomplete="off" placeholder="00000001">
<label for="namedPlaylistProgramTitle">새 이름</label>
<input id="namedPlaylistProgramTitle" type="text" maxlength="128" autocomplete="off" placeholder="예: 오전 방송">
<button id="namedPlaylistCreateButton" type="button">새 이름 만들기</button>
</div>
<div class="named-playlist-mutation-actions">
<button id="namedPlaylistReplaceButton" class="primary-soft" type="button">선택 정의에 현재 순서 저장</button>
<button id="namedPlaylistDeleteButton" class="danger" type="button">선택 정의 삭제</button>
</div>
</div>
<p id="namedPlaylistGuardSummary" class="named-playlist-guard-summary">
DB에서 읽은 각 행은 신뢰 복원과 현재 페이지 재계산을 통과해야 PREPARE할 수 있습니다.
</p>
</section>
<div class="table-wrap">
<table>
<thead><tr><th class="check-cell"></th><th class="number-cell">#</th><th>그래픽 컷</th><th>구분</th><th class="drag-cell">정렬</th></tr></thead>
<thead><tr><th class="check-cell">송출</th><th class="number-cell">#</th><th>종목·대상</th><th></th><th>세부사항</th><th class="page-cell">Page</th><th class="drag-cell">정렬</th></tr></thead>
<tbody id="playlistBody"></tbody>
</table>
<div id="playlistEmpty" class="empty-state">
@@ -137,6 +454,15 @@
<strong id="playoutSafetyMode">확인 중</strong>
</div>
</div>
<div class="background-control-bar" aria-label="장면 배경 설정">
<div>
<small>BACKGROUND</small>
<strong id="backgroundFileName">배경 없음</strong>
<span id="backgroundStateText">원본 F2/F3 배경 상태 확인 중</span>
</div>
<button id="chooseBackgroundButton" type="button">배경 파일 (F2)</button>
<button id="toggleBackgroundButton" type="button">배경 켜기/끄기 (F3)</button>
</div>
<div id="playoutStatusMessage" class="playout-status-message" role="status" aria-live="polite">송출 어댑터 상태를 확인하고 있습니다.</div>
<div class="playout-page-state" aria-label="장면 및 페이지 상태">
<span>SCENE <strong id="playoutSceneCode"></strong></span>
@@ -191,7 +517,7 @@
</datalist>
</form>
<div class="playout-controls">
<button id="prepareButton" class="prepare" type="button"><span>F2</span>PREPARE</button>
<button id="prepareButton" class="prepare" type="button"><span>PREP</span>PREPARE</button>
<button id="takeInButton" class="take-in" type="button"><span>F8</span>TAKE IN</button>
<button id="nextButton" type="button"><span></span>NEXT</button>
<button id="takeOutButton" class="take-out" type="button"><span>ESC</span>TAKE OUT</button>
@@ -207,6 +533,23 @@
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="playout-safety.js"></script>
<script src="operator-workflow.js"></script>
<script src="legacy-fixed-workflow.js"></script>
<script src="industry-workflow.js"></script>
<script src="comparison-workflow.js"></script>
<script src="theme-workflow.js"></script>
<script src="overseas-workflow.js"></script>
<script src="expert-workflow.js"></script>
<script src="trading-halt-workflow.js"></script>
<script src="candle-options-workflow.js"></script>
<script src="manual-lists-workflow.js"></script>
<script src="manual-financial-workflow.js"></script>
<script src="named-manual-restore-workflow.js"></script>
<script src="operator-catalog-workflow.js"></script>
<script src="manual-lists-ui.js"></script>
<script src="manual-financial-ui.js"></script>
<script src="operator-catalog-ui.js"></script>
<script src="named-playlist-workflow.js"></script>
<script src="app.js"></script>
</body>
</html>

326
Web/industry-workflow.js Normal file
View File

@@ -0,0 +1,326 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnIndustryWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const codePattern = /^[A-Za-z0-9]{1,32}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const markets = Object.freeze(["kospi", "kosdaq"]);
const runtimeAssets = Object.freeze({
kospi: Object.freeze({
relativePath: "bin/Debug/Res/업종_코스피.ini",
sha256: "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52"
}),
kosdaq: Object.freeze({
relativePath: "bin/Debug/Res/업종_코스닥.ini",
sha256: "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4"
})
});
const periodCuts = [
["5일", "8061", "FiveDays"], ["20일", "8040", "TwentyDays"],
["60일", "8046", "SixtyDays"], ["120일", "8051", "OneHundredTwentyDays"],
["240일", "8056", "TwoHundredFortyDays"]
];
const candleCuts = [["일봉", "8035"], ...periodCuts.map(([label, code]) => [label, code])];
function cut(id, label, section, kind, details = {}) {
return Object.freeze({ id, label, section, kind, ...details });
}
const cuts = Object.freeze([
cut("one-column", "1열판", "업종 판", "single", { builderKey: "s5001" }),
cut("two-column", "2열판", "업종 판", "pair", { builderKey: "s8018" }),
...periodCuts.map(([label, , period]) =>
cut(`yield-${label}`, `수익률그래프_${label}`, "수익률 그래프", "yield", {
builderKey: "s5086", period
})),
...candleCuts.map(([label, code]) =>
cut(`candle-${label}`, `캔들그래프_${label}`, "캔들 그래프", "candle", {
builderKey: "s8010", code, mode: "PRICE"
})),
...candleCuts.map(([label, code]) =>
cut(`candle-volume-${label}`, `캔들그래프(거래량)_${label}`, "캔들 그래프 · 거래량", "candle", {
builderKey: "s8010", code, mode: "VOLUME"
})),
cut("five-row", "5단 표그래프", "시장 전체", "fixed", { builderKey: "s5074" }),
cut("square", "네모그래프", "시장 전체", "fixed", { builderKey: "s8001" }),
cut("sector", "섹터지수", "시장 전체", "fixed", { builderKey: "s5078" })
]);
if (cuts.length !== 22 || new Set(cuts.map(value => value.id)).size !== 22) {
throw new Error("Each legacy industry tab must contain exactly 22 cut actions.");
}
const cutsById = new Map(cuts.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 markets.includes(text) ? text : null;
}
function normalizeIndustry(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const market = normalizeMarket(value.market);
const name = cleanText(value.name ?? value.industryName);
const code = cleanText(value.code ?? value.industryCode, 32);
if (!market || !name || !code || !codePattern.test(code)) return null;
return Object.freeze({ market, name, code });
}
function localDateText(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 requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Industry playlist options are required.");
}
const id = cleanText(options.id);
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe industry 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.");
}
const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date();
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
return { id, fadeDuration, now, ma5, ma20 };
}
function mappingFor(market, selectedCut, selected, primary, secondary, now, movingAverages) {
const industryGroup = market === "kospi" ? "INDUSTRY_KOSPI" : "INDUSTRY_KOSDAQ";
const candleGroup = market === "kospi" ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY";
const pagedGroup = market === "kospi" ? "PAGED_DOMESTIC_KOSPI" : "PAGED_DOMESTIC_KOSDAQ";
const marketName = market === "kospi" ? "KOSPI" : "KOSDAQ";
switch (selectedCut.kind) {
case "single":
if (!selected) throw new RangeError("Select one industry first.");
return {
code: "5001",
selection: {
groupCode: industryGroup, subject: selected.name,
graphicType: "1열판", subtype: "CURRENT", dataCode: ""
}
};
case "pair":
if (!primary || !secondary || primary.name.includes("-") || secondary.name.includes("-")) {
throw new RangeError("Select two delimiter-safe industries first.");
}
return {
code: market === "kospi" ? "8018" : "8032",
selection: {
groupCode: industryGroup, subject: `${primary.name}-${secondary.name}`,
graphicType: "2열판", subtype: "", dataCode: ""
}
};
case "yield":
if (!selected) throw new RangeError("Select one industry first.");
return {
code: "5086",
selection: {
groupCode: industryGroup, subject: selected.name,
graphicType: "YIELD_GRAPH", subtype: selectedCut.period, dataCode: ""
}
};
case "candle":
if (!selected) throw new RangeError("Select one industry first.");
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
return {
code: selectedCut.code,
selection: {
groupCode: candleGroup, subject: selected.name,
graphicType: flags.join(","), subtype: selectedCut.mode, dataCode: ""
}
};
case "fixed":
if (selectedCut.id === "five-row") {
return {
code: "5074",
selection: {
groupCode: pagedGroup, subject: "INDEX",
graphicType: "", subtype: "INDUSTRY", dataCode: localDateText(now)
}
};
}
if (selectedCut.id === "square") {
return {
code: market === "kospi" ? "8001" : "8002",
selection: {
groupCode: industryGroup, subject: marketName,
graphicType: "네모그래프", subtype: "", dataCode: ""
}
};
}
return {
code: "5078",
selection: {
groupCode: industryGroup, subject: marketName,
graphicType: "SECTOR_INDEX", subtype: "", dataCode: ""
}
};
default:
throw new Error("The selected industry cut is unsupported.");
}
}
function isCutAllowed(marketValue, cutId, selectedValue, primaryValue, secondaryValue) {
const market = normalizeMarket(marketValue);
const selectedCut = cutsById.get(cutId);
const selected = normalizeIndustry(selectedValue);
const primary = normalizeIndustry(primaryValue);
const secondary = normalizeIndustry(secondaryValue);
if (!market || !selectedCut) return false;
if (selected && selected.market !== market) return false;
if (primary && primary.market !== market) return false;
if (secondary && secondary.market !== market) return false;
if (selectedCut.kind === "fixed") return true;
if (selectedCut.kind === "pair") {
return Boolean(primary && secondary && !primary.name.includes("-") && !secondary.name.includes("-"));
}
return Boolean(selected);
}
function createIndustryPlaylistEntry(marketValue, cutId, options) {
const market = normalizeMarket(marketValue);
const selectedCut = cutsById.get(cutId);
if (!market || !selectedCut) throw new RangeError("The industry action is unknown.");
const selected = normalizeIndustry(options?.selected);
const pair = Array.isArray(options?.pair) ? options.pair : [];
const primary = normalizeIndustry(pair[0]);
const secondary = normalizeIndustry(pair[1]);
if ((selected && selected.market !== market) || (primary && primary.market !== market) ||
(secondary && secondary.market !== market)) {
throw new RangeError("The selected industry market is inconsistent.");
}
if (!isCutAllowed(market, cutId, selected, primary, secondary)) {
throw new RangeError(selectedCut.kind === "pair" ? "Select two industries first." : "Select one industry first.");
}
const normalized = requireOptions(options);
const movingAverages = Object.freeze({
ma5: selectedCut.kind === "candle" && normalized.ma5,
ma20: selectedCut.kind === "candle" && normalized.ma20
});
const mapping = mappingFor(
market,
selectedCut,
selected,
primary,
secondary,
normalized.now,
movingAverages);
const selection = Object.freeze(mapping.selection);
return {
id: normalized.id,
builderKey: selectedCut.builderKey,
code: mapping.code,
aliases: [mapping.code],
title: selectedCut.kind === "fixed"
? `${market === "kospi" ? "코스피" : "코스닥"} ${selectedCut.label}`
: (selectedCut.kind === "pair" ? `${primary.name}-${secondary.name}` : selected.name),
detail: selectedCut.label,
category: selectedCut.kind === "candle" || selectedCut.kind === "yield" ? "chart" : "plate",
market,
selection,
enabled: true,
fadeDuration: normalized.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator: Object.freeze({
source: "legacy-industry-workflow",
cutId: selectedCut.id,
market,
...(selectedCut.kind === "pair" ? { pair: Object.freeze([primary, secondary]) } : {}),
...(selectedCut.kind !== "pair" && selectedCut.kind !== "fixed" ? { selected } : {}),
assetPath: runtimeAssets[market].relativePath,
assetSha256: runtimeAssets[market].sha256,
movingAverages
})
};
}
function sameSelection(left, right, allowDynamicDate) {
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
return ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => {
if (allowDynamicDate && key === "dataCode") return typeof left[key] === "string" && datePattern.test(left[key]);
return typeof left[key] === "string" && left[key] === right[key];
});
}
function restoreIndustryPlaylistEntry(value, options = {}) {
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-industry-workflow") return null;
const market = normalizeMarket(operator.market);
const selectedCut = cutsById.get(operator.cutId);
if (!market || !selectedCut || operator.assetPath !== runtimeAssets[market].relativePath ||
operator.assetSha256 !== runtimeAssets[market].sha256) return null;
let movingAverages = operator.movingAverages;
if (movingAverages === undefined) {
if (selectedCut.kind === "candle") {
const graphicType = value?.selection?.graphicType;
if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null;
movingAverages = {
ma5: graphicType === "MA5" || graphicType === "MA5,MA20",
ma20: graphicType === "MA20" || graphicType === "MA5,MA20"
};
} else {
movingAverages = { ma5: false, ma20: false };
}
}
if (!movingAverages || typeof movingAverages !== "object" || Array.isArray(movingAverages) ||
typeof movingAverages.ma5 !== "boolean" || typeof movingAverages.ma20 !== "boolean" ||
Object.keys(movingAverages).length !== 2 ||
(selectedCut.kind !== "candle" && (movingAverages.ma5 || movingAverages.ma20))) return null;
let recreated;
try {
recreated = createIndustryPlaylistEntry(market, selectedCut.id, {
id: value.id,
fadeDuration: value.fadeDuration,
selected: operator.selected,
pair: operator.pair,
now: options.now,
ma5: movingAverages.ma5,
ma20: movingAverages.ma20
});
} catch {
return null;
}
const scalars = ["id", "builderKey", "code", "title", "detail", "category", "market", "fadeDuration"];
if (!scalars.every(key => value[key] === recreated[key]) ||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.code ||
!sameSelection(value.selection, recreated.selection, selectedCut.id === "five-row")) return null;
return { ...recreated, enabled: value.enabled };
}
return {
markets,
runtimeAssets,
cuts,
normalizeIndustry,
isCutAllowed,
localDateText,
createIndustryPlaylistEntry,
restoreIndustryPlaylistEntry,
refreshIndustryPlaylistEntry: restoreIndustryPlaylistEntry
};
});

View File

@@ -0,0 +1,527 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnLegacyFixedWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const fixedMarkets = Object.freeze(["overseas", "exchange", "index"]);
const runtimeAssets = Object.freeze({
overseas: Object.freeze({
relativePath: "bin/Debug/Res/해외.ini",
sha256: "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"
}),
exchange: Object.freeze({
relativePath: "bin/Debug/Res/환율.ini",
sha256: "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"
}),
index: Object.freeze({
relativePath: "bin/Debug/Res/지수.ini",
sha256: "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6"
})
});
const rawActions = [];
function cloneSelection(value) {
return {
groupCode: String(value.groupCode || ""),
subject: String(value.subject || ""),
graphicType: String(value.graphicType || ""),
subtype: String(value.subtype || ""),
dataCode: String(value.dataCode || "")
};
}
function addAction(market, section, label, definition) {
const ordinal = rawActions.length + 1;
rawActions.push({
id: `fixed-${String(ordinal).padStart(3, "0")}`,
market,
section,
label,
detail: definition.detail || section,
category: definition.category || "market",
builderKey: definition.builderKey,
code: definition.code,
aliases: definition.aliases || [definition.code],
available: definition.available !== false,
prerequisite: definition.prerequisite || "",
selection: cloneSelection(definition.selection || {}),
dynamicDate: definition.dynamicDate === true,
dynamicNxtSession: definition.dynamicNxtSession === true
});
}
const foreignIndices = [
["다우", "Dow"], ["나스닥", "Nasdaq"], ["S&P", "Sp500"],
["독일", "GermanyDax"], ["영국", "UnitedKingdomFtse"], ["프랑스", "FranceCac"],
["니케이", "Nikkei"], ["중국 상해", "ShanghaiComposite"], ["홍콩 항셍", "HangSeng"],
["대만 가권", "TaiwanWeighted"], ["싱가포르 지수", "SingaporeStraitsTimes"],
["태국 지수", "ThailandSet"], ["필리핀 지수", "PhilippinesComposite"],
["말레이시아 지수", "MalaysiaKlse"], ["인도네시아 지수", "IndonesiaComposite"]
];
const commodities = [
["소맥", "FeedWheat"], ["옥수수", "Corn"], ["밀", "Wheat"], ["대두", "Soybean"],
["콩", "Bean"], ["현미", "BrownRice"], ["커피", "Coffee"], ["코코아", "Cocoa"],
["설탕", "Sugar"], ["생우(소)", "LiveCattle"], ["비육우", "FeederCattle"],
["돈육(돼지)", "LeanHogs"], ["구리", "Copper"], ["철광석", "IronOre"],
["니켈", "Nickel"], ["천연가스", "NaturalGas"], ["백금", "Platinum"],
["팔라듐", "Palladium"], ["납", "Lead"], ["아연", "Zinc"], ["주석", "Tin"],
["원면", "RawCotton"], ["목화(면화)", "Cotton"],
["국제 금", "InternationalGold"], ["국제 은", "InternationalSilver"],
["국내 금", "DomesticGold"], ["국내 은", "DomesticSilver"]
];
for (const [label, subject] of foreignIndices) {
addAction("overseas", "1열판기본", label, {
builderKey: "s5001", code: "5001", category: "plate",
selection: { groupCode: "FOREIGN_INDEX", subject, graphicType: "1열판기본", subtype: "CURRENT" }
});
}
addAction("overseas", "1열판기본", "BDI 지수", {
builderKey: "s5001", code: "5001", category: "plate",
selection: { groupCode: "FOREIGN_INDEX", subject: "BALTIC_DRY_INDEX", graphicType: "1열판기본", subtype: "CURRENT" }
});
for (const [label, subject] of commodities) {
addAction("overseas", "1열판기본", label, {
builderKey: "s5001", code: "5001", category: "plate",
selection: { groupCode: "COMMODITY", subject, graphicType: "1열판기본", subtype: "CURRENT" }
});
}
[
["농산물[원면, 목화(면화)]", "Cotton"],
["원자재[국제 금, 국제 은]", "InternationalPreciousMetals"],
["원자재[국내 금, 국내 은]", "DomesticPreciousMetals"]
].forEach(([label, subtype]) => addAction("overseas", "2열판-", label, {
builderKey: "s50160", code: "50160", category: "plate",
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "2열판-", subtype }
}));
[
["미국 지수[다우, 나스닥, S&P]", "UnitedStatesIndices"],
["중화권 지수[홍콩항셍, 상해종합, 대만]", "GreaterChinaIndices"],
["유럽권 지수[영국, 프랑스, 독일]", "EuropeanIndices"],
["아시아 지수[상해, 대만, 일본]", "AsianIndices"],
["미국국채[2년, 3년, 5년]", "UnitedStatesTreasuries"],
["채권금리(CD,CP,콜금리)", "BondYields"],
["유가[두바이, 브렌트유, WTI]", "Oil"],
["광물[금, 은, 구리]", "Minerals"],
["식자재[대두, 옥수수, 밀]", "FoodMaterials"],
["농산물[소맥, 옥수수, 밀]", "AgricultureWheatCorn"],
["농산물[대두, 콩, 현미]", "AgricultureSoyRice"],
["농산물[커피, 코코아, 설탕]", "AgricultureCoffeeCocoaSugar"],
["농산물[생우(소), 비육우, 돈육(돼지)]", "AgricultureLivestock"],
["원자재[구리, 철광석, 니켈]", "RawMaterialsCopperIronNickel"],
["원자재[천연가스, 백금, 팔라듐]", "RawMaterialsGasPlatinumPalladium"],
["원자재[납, 아연, 주석]", "RawMaterialsLeadZincTin"]
].forEach(([label, subtype]) => addAction("overseas", "3열판", label, {
builderKey: "s5016", code: "5016", category: "plate",
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype }
}));
[
["글로벌 증시 지도", "8067"], ["아시아 증시 지도", "5072"],
["미국 증시 지도", "5068"], ["유럽 증시 지도", "5070"]
].forEach(([label, code]) => addAction("overseas", "글로벌 증시 지도", label, {
builderKey: "s8067", code, aliases: [code],
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "WORLD_MAP", subtype: code }
}));
[
["미국 다우 지수", "DowJones"], ["미국 나스닥 지수", "Nasdaq"],
["미국 S&P 지수", "StandardAndPoor500"], ["중국 상해 지수", "China"],
["일본 닛케이 지수", "Japan"], ["유가 두바이 지수", "DubaiCrude"],
["유가 WTI 지수", "WtiCrude"], ["유가 브렌트유 지수", "BrentCrude"],
["금 지수", "Gold"]
].forEach(([label, subtype]) => addAction("overseas", "이미지 그래프", label, {
builderKey: "s6001", code: "6001",
selection: { groupCode: "FOREIGN_INDEX", subject: label, graphicType: "IMAGE_GRAPH", subtype }
}));
[["다우", "DOW"], ["나스닥", "NASDAQ"], ["S&P", "S&P500"]]
.forEach(([label, subject]) => addAction("overseas", "미국업종", label, {
builderKey: "s5078", code: "5078", category: "chart",
selection: { groupCode: "FOREIGN_INDEX", subject, graphicType: "US_SECTOR_INDEX", subtype: "" }
}));
const exchangeTargets = [
["원달러", "WonDollar"], ["원엔", "WonYen"],
["원위엔", "WonYuan"], ["원유로", "WonEuro"]
];
for (const [label, subject] of exchangeTargets) {
addAction("exchange", "1열판기본", label, {
builderKey: "s5001", code: "5001", category: "plate",
selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "1열판기본", subtype: "CURRENT" }
});
}
[
["해외환율[엔달러, 위안화달러, 유로달러]", "OverseasExchangeRates"],
["환율[원달러, 원엔, 원유로]", "DomesticExchangeRates"]
].forEach(([label, subtype]) => addAction("exchange", "3열판", label, {
builderKey: "s5016", code: "5016", category: "plate",
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype }
}));
const yieldPeriods = [
["5일", "FiveDays"], ["20일", "TwentyDays"], ["60일", "SixtyDays"],
["120일", "OneHundredTwentyDays"], ["240일", "TwoHundredFortyDays"]
];
for (const [label, subject] of exchangeTargets) {
for (const [periodLabel, subtype] of yieldPeriods) {
addAction("exchange", "수익률 그래프", `${label}_${periodLabel}`, {
builderKey: "s5086", code: "5086", category: "chart",
selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "YIELD_GRAPH", subtype }
});
}
}
for (const [label, subject] of exchangeTargets) {
for (const [periodLabel, subtype] of yieldPeriods) {
addAction("exchange", "라인 그래프", `${label}_${periodLabel}`, {
builderKey: "s50860", code: "50860", category: "chart",
selection: { groupCode: "EXCHANGE_RATE", subject, graphicType: "LINE_GRAPH", subtype }
});
}
}
[
["3열판_주요지수[코스피, 코스닥, 코스피200]", "MajorKospiKosdaqKospi200"],
["3열판_주요지수[코스피, 코스닥, 선물]", "MajorKospiKosdaqFutures"],
["3열판_예상체결지수[코스피, 코스닥, 코스피200]", "ExpectedKospiKosdaqKospi200"],
["3열판_예상체결지수[코스피, 코스닥, 선물]", "ExpectedKospiKosdaqFutures"]
].forEach(([label, subtype]) => addAction("index", "주요지수", label, {
builderKey: "s5016", code: "5016", category: "plate",
selection: { groupCode: "ALL", subject: "INDEX", graphicType: "3열판", subtype }
}));
const candlePeriods = [
["일봉", "8035"], ["5일", "8061"], ["20일", "8040"],
["60일", "8046"], ["120일", "8051"], ["240일", "8056"]
];
function addIndexMarket(section, groupCode, candleGroup, options) {
addAction("index", section, "1열판기본_예상지수", {
builderKey: "s5001", code: "5001", category: "plate",
selection: { groupCode, subject: "INDEX", graphicType: "1열판기본", subtype: "EXPECTED_INDEX" }
});
addAction("index", section, "1열판기본_현재지수", {
builderKey: "s5001", code: "5001", category: "plate",
selection: { groupCode, subject: "INDEX", graphicType: "1열판기본", subtype: "CURRENT" }
});
if (options.yield) {
for (const [periodLabel, subtype] of yieldPeriods) {
addAction("index", section, `수익률그래프_${periodLabel}`, {
builderKey: "s5086", code: "5086", category: "chart",
selection: { groupCode, subject: "INDEX", graphicType: "YIELD_GRAPH", subtype }
});
}
}
for (const [periodLabel, code] of candlePeriods) {
addAction("index", section, `캔들그래프_${periodLabel}`, {
builderKey: "s8010", code, category: "chart",
selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "PRICE" }
});
}
if (options.volume) {
for (const [periodLabel, code] of candlePeriods) {
addAction("index", section, `캔들그래프(거래량)_${periodLabel}`, {
builderKey: "s8010", code, category: "chart",
selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "VOLUME" }
});
}
}
if (options.expected) {
for (const [periodLabel, code] of candlePeriods.slice(1)) {
addAction("index", section, `캔들그래프(예상지수)_${periodLabel}`, {
builderKey: "s8010", code, category: "chart",
selection: { groupCode: candleGroup, subject: "INDEX", graphicType: "", subtype: "EXPECTED" }
});
}
}
}
addIndexMarket("코스피", "KOSPI", "KOSPI_INDEX", { yield: true, volume: true, expected: true });
addIndexMarket("코스닥", "KOSDAQ", "KOSDAQ_INDEX", { yield: true, volume: true, expected: true });
addIndexMarket("코스피200", "KOSPI200", "KOSPI200_INDEX", { yield: true, volume: true, expected: true });
addIndexMarket("선물", "FUTURES", "FUTURES_INDEX", { yield: false, volume: false, expected: true });
addIndexMarket("KRX100", "KRX100", "KRX100_INDEX", { yield: true, volume: true, expected: false });
function trend(label, builderKey, code, groupCode, graphicType, subtype, available = true) {
addAction("index", "매매동향", label, {
builderKey, code, available, category: "market",
prerequisite: available ? "" : "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.",
selection: { groupCode, subject: "INDEX", graphicType, subtype }
});
}
trend("주체별 매매동향_표그래프", "s5082", "5082", "ALL", "TRADING_BY_PARTICIPANT", "TABLE_GRAPH");
trend("코스피 매매동향_표그래프", "s5023", "5023", "KOSPI", "KOSPI_TRADING_TREND", "TABLE_GRAPH");
trend("코스피 매매동향_라인그래프", "s5084", "5084", "KOSPI", "KOSPI_TRADING_TREND", "LINE_GRAPH");
trend("코스피 매매동향_막대그래프", "s5024", "5024", "KOSPI", "KOSPI_TRADING_TREND", "BAR_GRAPH");
trend("코스닥 매매동향_표그래프", "s5023", "5023", "KOSDAQ", "KOSDAQ_TRADING_TREND", "TABLE_GRAPH");
trend("코스닥 매매동향_라인그래프", "s5084", "5084", "KOSDAQ", "KOSDAQ_TRADING_TREND", "LINE_GRAPH");
trend("코스닥 매매동향_막대그래프", "s5024", "5024", "KOSDAQ", "KOSDAQ_TRADING_TREND", "BAR_GRAPH");
trend("개인 매매동향_라인그래프", "s5083", "5083", "ALL", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH");
trend("외국인 매매동향_라인그래프", "s5083", "5083", "ALL", "FOREIGN_TRADING_TREND", "LINE_GRAPH");
trend("기관 매매동향_라인그래프", "s5083", "5083", "ALL", "INSTITUTION_TRADING_TREND", "LINE_GRAPH");
trend("기관 순매수 현황_표그래프", "s6067", "6067", "ALL", "INSTITUTION_NET_BUY", "TABLE_GRAPH");
trend("프로그램 매매_표그래프", "s5085", "5085", "ALL", "PROGRAM_TRADING", "TABLE_GRAPH");
trend("개인 순매도 상위(수동)", "s5025", "5025", "INDIVIDUAL", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false);
trend("외국인 순매도 상위(수동)", "s5025", "5025", "FOREIGN", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false);
trend("기관 순매도 상위(수동)", "s5025", "5025", "INSTITUTION", "MANUAL_NET_SELL", "MANUAL_NET_SELL", false);
const fiveRows = [
"코스피 시가총액", "코스피 예상가 시가총액 상위", "코스피 상승률 상위", "코스피 하락률 상위",
"코스피 시간외 상승률 상위", "코스피 시간외 하락률 상위",
"코스닥 시가총액", "코스닥 예상가 시가총액 상위", "코스닥 상승률 상위", "코스닥 하락률 상위",
"코스닥 시간외 상승률 상위", "코스닥 시간외 하락률 상위", "코스피 200 종목", "ETF 종목", "VI 발동(수동)",
"코스피_NXT 시가총액", "코스피_NXT 상승률 상위", "코스피_NXT 하락률 상위",
"코스피_NXT 시간외 상승률 상위", "코스피_NXT 시간외 하락률 상위", "코스피_NXT 거래량 상위",
"코스닥_NXT 시가총액", "코스닥_NXT 상승률 상위", "코스닥_NXT 하락률 상위",
"코스닥_NXT 시간외 상승률 상위", "코스닥_NXT 시간외 하락률 상위", "코스닥_NXT 거래량 상위"
];
const sixAndTwelveRows = [
"코스피 시가총액", "코스피 예상가 시가총액 상위", "코스피 상승률 상위", "코스피 하락률 상위",
"코스피 52주 신고가", "코스피 52주 신저가", "코스피 시간외 상승률 상위", "코스피 시간외 하락률 상위",
"코스닥 시가총액", "코스닥 예상가 시가총액 상위", "코스닥 상승률 상위", "코스닥 하락률 상위",
"코스닥 52주 신고가", "코스닥 52주 신저가", "코스닥 시간외 상승률 상위", "코스닥 시간외 하락률 상위",
"코스피 200 종목", "ETF 종목", "VI 발동(수동)",
"코스피_NXT 시가총액", "코스피_NXT 상승률 상위", "코스피_NXT 하락률 상위", "코스피_NXT 거래량 상위",
"코스닥_NXT 시가총액", "코스닥_NXT 상승률 상위", "코스닥_NXT 하락률 상위", "코스닥_NXT 거래량 상위"
];
function pagedDefinition(label, builderKey, code) {
if (label === "VI 발동(수동)") {
return {
// VILIST removes the placeholder row and always creates the legacy
// five-row outcome, even when the placeholder came from the 6/12 section.
builderKey: "s5074", code: "5074", available: false, category: "plate",
prerequisite: "검증된 VI 종목 목록 입력 화면이 필요합니다.",
selection: { groupCode: "PAGED_VI", subject: "", graphicType: "INPUT_ORDER", subtype: "CURRENT" }
};
}
let groupCode;
if (label.startsWith("코스피_NXT")) groupCode = "PAGED_NXT_KOSPI";
else if (label.startsWith("코스닥_NXT")) groupCode = "PAGED_NXT_KOSDAQ";
else if (label.startsWith("코스닥")) groupCode = "PAGED_DOMESTIC_KOSDAQ";
else groupCode = "PAGED_DOMESTIC_KOSPI";
let subtype;
if (label.includes("예상가 시가총액")) subtype = "EXPECTED_MARKET_CAP";
else if (label.includes("시가총액")) subtype = "MARKET_CAP";
else if (label.includes("시간외 상승률")) subtype = "AFTER_HOURS_GAIN_RATE";
else if (label.includes("시간외 하락률")) subtype = "AFTER_HOURS_LOSS_RATE";
else if (label.includes("상승률")) subtype = "GAIN_RATE";
else if (label.includes("하락률")) subtype = "LOSS_RATE";
else if (label.includes("52주 신고가")) subtype = "52_WEEK_HIGH";
else if (label.includes("52주 신저가")) subtype = "52_WEEK_LOW";
else if (label.includes("200 종목")) subtype = "KOSPI200";
else if (label.startsWith("ETF")) subtype = "ETF";
else if (label.includes("거래량")) subtype = "VOLUME";
else throw new Error(`Unknown paged legacy action: ${label}`);
const dynamicNxtSession = groupCode.startsWith("PAGED_NXT_");
return {
builderKey, code, category: "plate",
dynamicDate: !dynamicNxtSession,
dynamicNxtSession,
selection: {
groupCode,
subject: "INDEX",
graphicType: dynamicNxtSession ? "$NXT_SESSION" : "",
subtype,
dataCode: dynamicNxtSession ? "" : "$TODAY"
}
};
}
function addPagedSection(section, labels, builderKey, code) {
for (const label of labels) addAction("index", section, label, pagedDefinition(label, builderKey, code));
}
addPagedSection("5단 표그래프", fiveRows, "s5074", "5074");
addPagedSection("6종목 현재가", sixAndTwelveRows, "s5077", "5077");
addPagedSection("12종목 현재가", sixAndTwelveRows, "s5088", "5088");
const actions = Object.freeze(rawActions.map(value => Object.freeze({
...value,
aliases: Object.freeze([...value.aliases]),
selection: Object.freeze({ ...value.selection })
})));
if (actions.length !== 328) throw new Error("The three fixed legacy tabs must contain exactly 328 actions.");
if (actions.filter(action => action.available).length !== 322) {
throw new Error("Exactly six fixed-tab actions must remain trusted manual prerequisites.");
}
const byId = new Map(actions.map(action => [action.id, action]));
function localDateText(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 currentNxtSession(now = new Date()) {
return now.getHours() <= 12 ? "PRE_MARKET" : "AFTER_MARKET";
}
function materializeSelection(action, now, movingAverages) {
const selection = cloneSelection(action.selection);
if (action.dynamicDate) selection.dataCode = localDateText(now);
if (action.dynamicNxtSession) selection.graphicType = currentNxtSession(now);
if (action.builderKey === "s8010") {
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
selection.graphicType = flags.join(",");
}
return selection;
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Fixed playlist options are required.");
}
const id = typeof options.id === "string" ? options.id.trim() : "";
if (!identifierPattern.test(id)) throw new TypeError("A safe fixed playlist entry 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.");
}
const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date();
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
return { id, fadeDuration, now, ma5, ma20 };
}
function createFixedPlaylistEntry(actionId, options) {
const action = typeof actionId === "string" ? byId.get(actionId) : null;
if (!action) throw new RangeError("The selected fixed legacy action is unknown.");
if (!action.available) throw new RangeError(action.prerequisite || "The selected action requires trusted manual data.");
const normalized = requireOptions(options);
const movingAverages = Object.freeze({
ma5: action.builderKey === "s8010" && normalized.ma5,
ma20: action.builderKey === "s8010" && normalized.ma20
});
const selection = Object.freeze(materializeSelection(action, normalized.now, movingAverages));
return {
id: normalized.id,
builderKey: action.builderKey,
code: action.code,
aliases: [...action.aliases],
title: action.label,
detail: `${action.section} · ${action.label}`,
category: action.category,
market: action.market,
selection,
enabled: true,
fadeDuration: normalized.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator: Object.freeze({
source: "legacy-fixed-workflow",
actionId: action.id,
legacyTab: action.market,
legacySection: action.section,
legacyLabel: action.label,
assetPath: runtimeAssets[action.market].relativePath,
assetSha256: runtimeAssets[action.market].sha256,
movingAverages
})
};
}
function sameStringArray(left, right) {
return Array.isArray(left) && Array.isArray(right) &&
left.length === right.length && left.every((value, index) => value === right[index]);
}
function normalizeStoredMovingAverages(value, action) {
const stored = value?.operator?.movingAverages;
if (stored !== undefined) {
if (!stored || typeof stored !== "object" || Array.isArray(stored) ||
typeof stored.ma5 !== "boolean" || typeof stored.ma20 !== "boolean" ||
Object.keys(stored).length !== 2) return null;
if (action.builderKey !== "s8010" && (stored.ma5 || stored.ma20)) return null;
return Object.freeze({ ma5: stored.ma5, ma20: stored.ma20 });
}
if (action.builderKey !== "s8010") return Object.freeze({ ma5: false, ma20: false });
const graphicType = value?.selection?.graphicType;
if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null;
return Object.freeze({
ma5: graphicType === "MA5" || graphicType === "MA5,MA20",
ma20: graphicType === "MA20" || graphicType === "MA5,MA20"
});
}
function storedSelectionMatches(value, action, movingAverages) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) {
const text = value[key];
if (typeof text !== "string" || text.length > 256 || controlCharacterPattern.test(text)) return false;
if (action.dynamicDate && key === "dataCode") {
if (!datePattern.test(text)) return false;
} else if (action.dynamicNxtSession && key === "graphicType") {
if (!(["PRE_MARKET", "AFTER_MARKET"].includes(text))) return false;
} else if (action.builderKey === "s8010" && key === "graphicType") {
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
if (text !== flags.join(",")) return false;
} else if (text !== action.selection[key]) return false;
}
return true;
}
function restoreFixedPlaylistEntry(value, options = {}) {
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-fixed-workflow") return null;
const action = byId.get(operator.actionId);
const movingAverages = action ? normalizeStoredMovingAverages(value, action) : null;
if (!action || !action.available || !movingAverages ||
!storedSelectionMatches(value.selection, action, movingAverages)) return null;
if (operator.legacyTab !== action.market || operator.legacySection !== action.section ||
operator.legacyLabel !== action.label || operator.assetPath !== runtimeAssets[action.market].relativePath ||
operator.assetSha256 !== runtimeAssets[action.market].sha256) return null;
if (value.builderKey !== action.builderKey || value.code !== action.code ||
value.title !== action.label || value.detail !== `${action.section} · ${action.label}` ||
value.category !== action.category || value.market !== action.market ||
!sameStringArray(value.aliases, action.aliases)) return null;
let recreated;
try {
recreated = createFixedPlaylistEntry(action.id, {
id: value.id,
fadeDuration: value.fadeDuration,
now: options.now,
ma5: movingAverages.ma5,
ma20: movingAverages.ma20
});
} catch {
return null;
}
return { ...recreated, enabled: value.enabled };
}
function actionsForMarket(market) {
return fixedMarkets.includes(market) ? actions.filter(action => action.market === market) : [];
}
return {
runtimeAssets,
fixedMarkets,
actions,
actionsForMarket,
localDateText,
currentNxtSession,
createFixedPlaylistEntry,
restoreFixedPlaylistEntry,
refreshFixedPlaylistEntry: restoreFixedPlaylistEntry
};
});

326
Web/manual-financial-ui.css Normal file
View File

@@ -0,0 +1,326 @@
.mfui-overlay[hidden] {
display: none !important;
}
.mfui-overlay {
position: fixed;
inset: 0;
z-index: 1400;
display: grid;
place-items: center;
padding: 24px;
background: rgba(4, 10, 18, 0.74);
backdrop-filter: blur(7px);
color: #e9f0f8;
}
.mfui-dialog {
--mfui-border: rgba(139, 162, 190, 0.28);
--mfui-panel: rgba(15, 27, 42, 0.96);
--mfui-muted: #91a3b8;
width: min(1180px, 96vw);
max-height: min(880px, 94vh);
overflow: hidden;
border: 1px solid var(--mfui-border);
border-radius: 16px;
background: #0b1624;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.52);
font-family: "Segoe UI", "Malgun Gothic", sans-serif;
}
.mfui-header,
.mfui-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-color: var(--mfui-border);
background: linear-gradient(135deg, rgba(20, 39, 59, 0.98), rgba(9, 21, 35, 0.98));
}
.mfui-header {
border-bottom: 1px solid var(--mfui-border);
}
.mfui-footer {
align-items: flex-start;
border-top: 1px solid var(--mfui-border);
}
.mfui-heading {
min-width: 0;
}
.mfui-title,
.mfui-editor-title,
.mfui-subtitle,
.mfui-status,
.mfui-quarantine,
.mfui-list-state,
.mfui-stock-state,
.mfui-empty {
margin: 0;
}
.mfui-title {
color: #f5f9ff;
font-size: 22px;
line-height: 1.25;
}
.mfui-subtitle,
.mfui-list-state,
.mfui-stock-state,
.mfui-empty {
color: var(--mfui-muted);
font-size: 12px;
}
.mfui-subtitle {
margin-top: 4px;
}
.mfui-body {
display: grid;
grid-template-columns: minmax(250px, 0.72fr) minmax(520px, 1.8fr);
min-height: 560px;
max-height: calc(94vh - 150px);
}
.mfui-list-panel,
.mfui-editor {
min-height: 0;
padding: 18px;
}
.mfui-list-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
border-right: 1px solid var(--mfui-border);
background: rgba(8, 19, 31, 0.92);
}
.mfui-editor {
overflow: auto;
background: var(--mfui-panel);
}
.mfui-editor-title {
color: #f1f6fc;
font-size: 17px;
}
.mfui-stock-state {
margin: 5px 0 15px;
}
.mfui-search {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.mfui-search-input,
.mfui-input {
min-width: 0;
border: 1px solid rgba(135, 158, 186, 0.34);
border-radius: 8px;
outline: none;
background: rgba(4, 14, 25, 0.88);
color: #f1f6fc;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.mfui-search-input {
height: 38px;
padding: 0 11px;
}
.mfui-input {
width: 100%;
height: 34px;
padding: 0 9px;
box-sizing: border-box;
}
.mfui-search-input:focus,
.mfui-input:focus {
border-color: #55a9ed;
box-shadow: 0 0 0 3px rgba(65, 151, 221, 0.16);
}
.mfui-input[readonly] {
color: #aebdcd;
background: rgba(30, 43, 58, 0.82);
}
.mfui-list {
min-height: 0;
overflow: auto;
border: 1px solid rgba(135, 158, 186, 0.18);
border-radius: 10px;
background: rgba(2, 10, 19, 0.48);
}
.mfui-list-row {
width: 100%;
padding: 10px 12px;
border: 0;
border-bottom: 1px solid rgba(135, 158, 186, 0.14);
background: transparent;
color: #dce7f3;
text-align: left;
cursor: pointer;
}
.mfui-list-row:hover,
.mfui-list-row[aria-selected="true"] {
background: rgba(42, 125, 191, 0.24);
color: #fff;
}
.mfui-empty {
padding: 18px 12px;
text-align: center;
}
.mfui-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.mfui-field {
display: grid;
gap: 5px;
min-width: 0;
}
.mfui-field-label {
color: #aebdcd;
font-size: 11px;
font-weight: 650;
}
.mfui-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
min-width: 0;
margin: 0;
padding: 10px;
border: 1px solid rgba(135, 158, 186, 0.2);
border-radius: 10px;
background: rgba(6, 17, 29, 0.52);
}
.mfui-group-title {
padding: 0 5px;
color: #73b8ee;
font-size: 11px;
font-weight: 700;
}
.mfui-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
padding-top: 15px;
border-top: 1px solid rgba(135, 158, 186, 0.18);
}
.mfui-button,
.mfui-search-button,
.mfui-close {
min-height: 36px;
padding: 0 13px;
border: 1px solid rgba(135, 158, 186, 0.36);
border-radius: 8px;
background: rgba(30, 49, 69, 0.92);
color: #e8f0f8;
font-weight: 650;
cursor: pointer;
}
.mfui-button:hover:not(:disabled),
.mfui-search-button:hover:not(:disabled),
.mfui-close:hover:not(:disabled) {
border-color: #78b9eb;
background: rgba(43, 75, 105, 0.96);
}
.mfui-primary {
border-color: rgba(65, 164, 226, 0.64);
background: #1671b5;
}
.mfui-accent {
border-color: rgba(72, 194, 147, 0.64);
background: #147b59;
}
.mfui-danger {
border-color: rgba(224, 100, 108, 0.54);
background: rgba(119, 41, 50, 0.9);
}
.mfui-button:disabled,
.mfui-search-button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.mfui-status {
color: #c6d4e2;
font-size: 12px;
}
.mfui-status[data-status="error"],
.mfui-status[data-status="quarantined"],
.mfui-quarantine {
color: #ff9da5;
}
.mfui-quarantine {
max-width: 58%;
font-size: 12px;
font-weight: 700;
text-align: right;
}
@media (max-width: 820px) {
.mfui-overlay {
padding: 8px;
}
.mfui-dialog {
width: 100%;
max-height: 98vh;
}
.mfui-body {
grid-template-columns: 1fr;
overflow: auto;
}
.mfui-list-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--mfui-border);
}
.mfui-editor {
overflow: visible;
}
.mfui-form {
grid-template-columns: 1fr;
}
.mfui-quarantine {
max-width: none;
}
}

1128
Web/manual-financial-ui.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

261
Web/manual-lists-ui.css Normal file
View File

@@ -0,0 +1,261 @@
.manual-lists-root {
position: fixed;
inset: 0;
z-index: 1200;
color: #e8edf6;
font: 13px/1.45 "Segoe UI", "Malgun Gothic", sans-serif;
}
.manual-lists-root[hidden] {
display: none;
}
.manual-lists-backdrop {
display: grid;
width: 100%;
height: 100%;
box-sizing: border-box;
place-items: center;
padding: 24px;
background: rgb(5 10 18 / 76%);
backdrop-filter: blur(3px);
}
.manual-lists-dialog {
display: flex;
width: min(1080px, 96vw);
max-height: 92vh;
box-sizing: border-box;
flex-direction: column;
gap: 14px;
overflow: hidden;
padding: 18px;
border: 1px solid #344258;
border-radius: 12px;
background: #111927;
box-shadow: 0 24px 80px rgb(0 0 0 / 48%);
}
.manual-lists-header,
.manual-lists-actions,
.manual-lists-search-controls,
.manual-lists-tabs {
display: flex;
align-items: center;
gap: 8px;
}
.manual-lists-header {
justify-content: space-between;
}
.manual-lists-title {
margin: 0;
color: #fff;
font-size: 20px;
font-weight: 700;
}
.manual-lists-close,
.manual-lists-button,
.manual-lists-tab,
.manual-lists-search-result {
min-height: 34px;
box-sizing: border-box;
border: 1px solid #42516a;
border-radius: 6px;
color: #dfe8f7;
background: #1d293a;
font: inherit;
cursor: pointer;
}
.manual-lists-close,
.manual-lists-button,
.manual-lists-tab {
padding: 6px 12px;
}
.manual-lists-close:hover:not(:disabled),
.manual-lists-button:hover:not(:disabled),
.manual-lists-tab:hover:not(:disabled),
.manual-lists-search-result:hover:not(:disabled) {
border-color: #6b83a7;
background: #28374d;
}
.manual-lists-button:disabled,
.manual-lists-tab:disabled,
.manual-lists-search-result:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.manual-lists-button.is-primary {
border-color: #2f7ac4;
background: #175a97;
}
.manual-lists-button.is-accent {
border-color: #3e9a6b;
background: #17623f;
}
.manual-lists-tab.is-active {
border-color: #f29b38;
color: #fff;
background: #8a4d12;
}
.manual-lists-banner {
padding: 9px 12px;
border: 1px solid #6d5b31;
border-radius: 6px;
color: #ffe5a8;
background: #342b18;
}
.manual-lists-banner-danger {
border-color: #8b3d47;
color: #ffd0d5;
background: #3d1c23;
}
.manual-lists-table-wrap,
.manual-lists-search-results {
overflow: auto;
}
.manual-lists-table-wrap {
min-height: 160px;
max-height: 45vh;
border: 1px solid #334158;
border-radius: 7px;
}
.manual-lists-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.manual-lists-table th,
.manual-lists-table td {
padding: 7px;
border: 1px solid #334158;
text-align: left;
vertical-align: middle;
}
.manual-lists-table thead th {
position: sticky;
top: 0;
z-index: 1;
color: #f4f7fc;
background: #202c3e;
}
.manual-lists-row-number {
width: 42px;
color: #8fa4c3;
text-align: center !important;
}
.manual-lists-net-table th:first-child {
width: 42px;
}
.manual-lists-input {
width: 100%;
min-height: 34px;
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid #42516a;
border-radius: 5px;
outline: none;
color: #f4f7fc;
background: #0b1320;
font: inherit;
}
.manual-lists-input:focus {
border-color: #5da6e8;
box-shadow: 0 0 0 2px rgb(66 145 217 / 20%);
}
.manual-lists-input:disabled {
color: #7f8ba0;
background: #141b27;
}
.manual-lists-search {
display: grid;
gap: 8px;
}
.manual-lists-search-input {
flex: 1;
}
.manual-lists-search-state,
.manual-lists-summary {
margin: 0;
color: #9eafc8;
}
.manual-lists-search-results {
display: grid;
max-height: 150px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 6px;
}
.manual-lists-search-result {
padding: 7px 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.manual-lists-code {
width: 160px;
color: #9cc9ff;
font-family: Consolas, monospace;
}
.manual-lists-name {
overflow-wrap: anywhere;
}
.manual-lists-row-actions {
display: flex;
width: 190px;
gap: 5px;
}
.manual-lists-row-actions .manual-lists-button {
min-width: 42px;
padding-inline: 8px;
}
.manual-lists-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 760px) {
.manual-lists-backdrop {
padding: 8px;
}
.manual-lists-dialog {
width: 100%;
max-height: 98vh;
padding: 12px;
}
.manual-lists-net-table {
min-width: 760px;
}
}

919
Web/manual-lists-ui.js Normal file
View File

@@ -0,0 +1,919 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory(root));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnManualListsUi = api;
})(typeof globalThis === "object" ? globalThis : this, function (root) {
"use strict";
const NET_KEYS = Object.freeze(["leftName", "leftAmount", "rightName", "rightAmount"]);
const STOCK_MARKETS = Object.freeze(["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"]);
const STOCK_SOURCES = Object.freeze(["oracle", "mariaDb"]);
const MAXIMUM_STOCK_RESULTS = 200;
const DEFAULT_FADE_DURATION = 6;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const stockCodePattern = /^[A-Za-z0-9._-]{1,63}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
function exactKeys(value, keys) {
if (!value || typeof value !== "object" || Array.isArray(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 = true) {
return typeof value === "string" && value.length <= maximumLength &&
(allowEmpty || value.length > 0) && !value.includes("^") && !unsafeTextPattern.test(value);
}
function copyRows(rows) {
return rows.map(row => Object.freeze({
leftName: row.leftName,
leftAmount: row.leftAmount,
rightName: row.rightName,
rightAmount: row.rightAmount
}));
}
function copyItems(items) {
return items.map(item => Object.freeze({ code: item.code, name: item.name }));
}
function rowsEqual(left, right) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
left.every((row, index) => NET_KEYS.every(key => row[key] === right[index][key]));
}
function itemsEqual(left, right) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
left.every((item, index) => item.code === right[index].code && item.name === right[index].name);
}
function emptyRows() {
return Array.from({ length: 5 }, () => ({
leftName: "",
leftAmount: "",
rightName: "",
rightAmount: ""
}));
}
function resolveWorkflow() {
if (root && root.MbnManualListsWorkflow) return root.MbnManualListsWorkflow;
if (typeof require === "function") return require("./manual-lists-workflow.js");
throw new Error("MbnManualListsWorkflow must be loaded before the manual-list UI.");
}
function normalizeStockResult(value) {
if (!exactKeys(value, ["market", "source", "name", "code"]) ||
!STOCK_MARKETS.includes(value.market) || !STOCK_SOURCES.includes(value.source) ||
!safeText(value.name, 200, false) || value.name !== value.name.trim() || value.name.includes(",") ||
typeof value.code !== "string" || !stockCodePattern.test(value.code)) return null;
return Object.freeze({
market: value.market,
source: value.source,
name: value.name,
code: value.code
});
}
function normalizeStockSearchResponse(value, expected) {
if (!exactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
]) || !expected || value.requestId !== expected.requestId || value.query !== expected.query ||
!identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
typeof value.retrievedAt !== "string" || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
value.results.length > expected.maximumResults || value.totalRowCount < value.results.length) return null;
const results = value.results.map(normalizeStockResult);
if (!results.every(Boolean)) return null;
const identities = new Set(results.map(item => `${item.market}\u0000${item.code}\u0000${item.name}`));
if (identities.size !== results.length) return null;
return Object.freeze({
requestId: value.requestId,
query: value.query,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
results: Object.freeze(results)
});
}
function normalizeStockSearchError(value, expected) {
if (!exactKeys(value, ["requestId", "query", "message"]) || !expected ||
value.requestId !== expected.requestId || value.query !== expected.query ||
!identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
!safeText(value.message, 512, false)) return null;
return Object.freeze({ ...value });
}
function createController(dependencies) {
const workflow = resolveWorkflow();
if (!exactKeys(dependencies, [
"postNative", "appendPlaylistEntry", "isLocked", "showToast", "addLog", "createId"
]) || !Object.values(dependencies).every(value => typeof value === "function")) {
throw new TypeError("Closed manual-list UI dependencies are required.");
}
const {
postNative,
appendPlaylistEntry,
isLocked,
showToast,
addLog,
createId
} = dependencies;
const state = {
host: null,
root: null,
open: false,
mode: null,
manualLocked: false,
available: null,
quarantined: false,
quarantineMessage: "",
audience: "INDIVIDUAL",
netRows: emptyRows(),
netRevision: 0,
netVerified: null,
viItems: [],
viBaseVersion: null,
viVersion: null,
viRevision: 0,
viVerified: null,
searchQuery: "",
searchResults: [],
searchMessage: "",
pending: {
status: null,
netRead: null,
netSave: null,
viRead: null,
viSave: null,
stockSearch: null
}
};
function locked() {
try {
return state.manualLocked || Boolean(isLocked());
} catch {
return true;
}
}
function canMutate() {
return state.available === true && !state.pending.status && !locked() && !state.quarantined;
}
function notify(message, kind = "info") {
try { showToast(message, kind); } catch { /* host notification is best effort */ }
}
function log(message) {
try { addLog(message); } catch { /* host logging is best effort */ }
}
function nextId() {
const value = createId();
if (typeof value !== "string" || !identifierPattern.test(value)) {
throw new TypeError("createId returned an unsafe identifier.");
}
return value;
}
function send(request) {
postNative(request.type, request.payload);
return request.payload.requestId;
}
function requestStatus() {
const requestId = nextId();
state.pending.status = { requestId };
send(workflow.createStatusRequest(requestId));
}
function requestNetRead(audience, verification = null) {
state.netVerified = null;
const requestId = nextId();
state.pending.netRead = {
requestId,
audience,
revision: state.netRevision,
verification
};
send(workflow.createNetSellReadRequest(requestId, audience));
}
function requestViRead(verification = null) {
state.viVersion = null;
state.viVerified = null;
const requestId = nextId();
state.pending.viRead = {
requestId,
revision: state.viRevision,
verification
};
send(workflow.createViReadRequest(requestId));
}
function invalidateNetVerification() {
state.netRevision += 1;
state.netVerified = null;
}
function invalidateViVerification() {
state.viRevision += 1;
state.viVersion = null;
state.viVerified = null;
}
function setAudience(audience) {
const normalized = workflow.normalizeAudience(audience);
if (!normalized) throw new RangeError("Unknown FSell audience.");
state.audience = normalized;
state.netRows = emptyRows();
invalidateNetVerification();
requestNetRead(normalized);
render();
}
function setNetCell(rowIndex, key, value) {
if (locked() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 ||
!NET_KEYS.includes(key) || typeof value !== "string" || value.length > 256) return false;
state.netRows[rowIndex][key] = value;
invalidateNetVerification();
updateGateButtons();
return true;
}
function saveNetSell() {
if (!canMutate() || state.pending.netRead || state.pending.netSave) return false;
let request;
try {
request = workflow.createNetSellSaveRequest(nextId(), state.audience, state.netRows);
} catch {
notify("순매도 입력 5행의 값을 확인하세요.", "error");
return false;
}
const snapshot = copyRows(request.payload.rows);
state.pending.netSave = {
requestId: request.payload.requestId,
audience: request.payload.audience,
rows: snapshot,
revision: state.netRevision
};
state.netVerified = null;
send(request);
render();
return true;
}
function addNetSellCut() {
const verified = state.netVerified;
if (!canMutate() || !verified || verified.audience !== state.audience ||
!rowsEqual(verified.rows, state.netRows)) return false;
try {
const entry = workflow.createNetSellPlaylistEntry(state.audience, {
id: nextId(),
fadeDuration: DEFAULT_FADE_DURATION
});
appendPlaylistEntry(entry);
notify("수동 순매도 컷을 playlist에 추가했습니다.", "success");
log(`수동 순매도 컷 추가 · ${state.audience}`);
return true;
} catch {
notify("수동 순매도 컷을 추가하지 못했습니다.", "error");
return false;
}
}
function searchStocks(query = state.searchQuery) {
const normalizedQuery = typeof query === "string" ? query.trim() : "";
if (!safeText(normalizedQuery, 100)) return false;
const requestId = nextId();
const request = {
requestId,
query: normalizedQuery,
maximumResults: MAXIMUM_STOCK_RESULTS
};
state.searchQuery = normalizedQuery;
state.searchResults = [];
state.searchMessage = "검색 중…";
state.pending.stockSearch = request;
postNative("search-stocks", request);
render();
return true;
}
function toViItem(stock) {
const prefix = stock.market.endsWith("kospi") ? "P" : "D";
return workflow.normalizeViItem({ code: `${prefix}${stock.code}`, name: stock.name });
}
function addSearchResult(index) {
if (locked() || !Number.isInteger(index) || index < 0 ||
index >= state.searchResults.length || state.viItems.length >= workflow.maximumViItems) return false;
const item = toViItem(state.searchResults[index]);
if (!item) return false;
state.viItems.push({ code: item.code, name: item.name });
invalidateViVerification();
render();
return true;
}
function removeViItem(index) {
if (locked() || !Number.isInteger(index) || index < 0 || index >= state.viItems.length) return false;
state.viItems.splice(index, 1);
invalidateViVerification();
render();
return true;
}
function moveViItem(index, delta) {
if (locked() || !Number.isInteger(index) || ![-1, 1].includes(delta)) return false;
const target = index + delta;
if (index < 0 || index >= state.viItems.length || target < 0 || target >= state.viItems.length) return false;
[state.viItems[index], state.viItems[target]] = [state.viItems[target], state.viItems[index]];
invalidateViVerification();
render();
return true;
}
function clearViItems() {
if (locked() || state.viItems.length === 0) return false;
state.viItems = [];
invalidateViVerification();
render();
return true;
}
function saveVi() {
if (!canMutate() || !state.viBaseVersion || state.pending.viRead || state.pending.viSave) return false;
let request;
try {
request = workflow.createViSaveRequest(nextId(), state.viItems, state.viBaseVersion);
} catch {
notify("VI 목록 값을 확인하세요.", "error");
return false;
}
state.pending.viSave = {
requestId: request.payload.requestId,
expectedVersion: request.payload.expectedVersion,
items: copyItems(request.payload.items),
revision: state.viRevision
};
state.viVerified = null;
send(request);
render();
return true;
}
function addViCut() {
const verified = state.viVerified;
if (!canMutate() || state.viItems.length === 0 || !verified ||
verified.version !== state.viVersion || !itemsEqual(verified.items, state.viItems)) return false;
try {
const entry = workflow.createViPlaylistEntry(state.viItems, state.viVersion, {
id: nextId(),
fadeDuration: DEFAULT_FADE_DURATION
});
appendPlaylistEntry(entry);
notify("VI 발동 컷을 playlist에 추가했습니다.", "success");
log(`VI 발동 컷 추가 · ${state.viItems.length}종목 · ${entry.pageCount}페이지`);
return true;
} catch {
notify("최신 VI 목록을 다시 조회한 뒤 추가하세요.", "error");
return false;
}
}
function handleStatus(payload) {
const pending = state.pending.status;
if (!pending) return false;
const normalized = workflow.normalizeStatusResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.status = null;
state.available = normalized.available;
if (normalized.writeQuarantined) {
state.quarantined = true;
state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다.";
}
render();
return true;
}
function handleNetData(payload) {
const pending = state.pending.netRead;
if (!pending) return false;
const normalized = workflow.normalizeNetSellDataResponse(payload, pending);
if (!normalized) return false;
state.pending.netRead = null;
if (pending.audience !== state.audience || pending.revision !== state.netRevision) return true;
state.netRows = copyRows(normalized.rows).map(row => ({ ...row }));
if (pending.verification &&
pending.verification.audience === state.audience &&
rowsEqual(pending.verification.rows, normalized.rows)) {
state.netVerified = {
audience: state.audience,
rows: copyRows(normalized.rows)
};
notify("저장 파일 재조회가 입력과 일치합니다.", "success");
} else {
state.netVerified = null;
}
render();
return true;
}
function handleNetSave(payload) {
const pending = state.pending.netSave;
if (!pending) return false;
const normalized = workflow.normalizeNetSellSaveResponse(payload, pending);
if (!normalized) return false;
state.pending.netSave = null;
requestNetRead(pending.audience, {
audience: pending.audience,
rows: pending.rows
});
notify("저장했습니다. 파일을 다시 확인하고 있습니다.", "success");
render();
return true;
}
function handleViData(payload) {
const pending = state.pending.viRead;
if (!pending) return false;
const normalized = workflow.normalizeViDataResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.viRead = null;
if (pending.revision !== state.viRevision) return true;
state.viItems = copyItems(normalized.items).map(item => ({ ...item }));
state.viBaseVersion = normalized.version;
state.viVersion = normalized.version;
const verification = pending.verification;
if (verification && verification.persisted &&
(verification.version !== normalized.version ||
!itemsEqual(verification.items, normalized.items))) {
state.viVerified = null;
notify("저장 후 VI 파일 내용이 달라 컷 추가를 차단했습니다.", "error");
} else {
state.viVerified = {
version: normalized.version,
items: copyItems(normalized.items)
};
if (verification) {
notify(
verification.persisted
? "저장 파일 재조회가 VI 입력과 일치합니다."
: "빈 목록은 원본 동작에 따라 파일을 변경하지 않았습니다.",
"success");
}
}
render();
return true;
}
function handleViSave(payload) {
const pending = state.pending.viSave;
if (!pending) return false;
const normalized = workflow.normalizeViSaveResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.viSave = null;
requestViRead({
items: pending.items,
version: normalized.version,
persisted: normalized.persisted
});
notify(
normalized.persisted
? "VI 목록을 저장했습니다. 파일을 다시 확인하고 있습니다."
: "빈 VI 목록은 원본 동작에 따라 저장 파일을 변경하지 않았습니다.",
"success");
render();
return true;
}
const pendingOperations = Object.freeze({
status: "status",
netRead: "read-net-sell",
netSave: "save-net-sell",
viRead: "read-vi",
viSave: "save-vi"
});
function handleManualError(payload) {
for (const [key, operation] of Object.entries(pendingOperations)) {
const pending = state.pending[key];
if (!pending || payload?.requestId !== pending.requestId) continue;
const normalized = workflow.normalizeError(payload, pending.requestId);
if (!normalized || normalized.operation !== operation) return false;
state.pending[key] = null;
if (key === "status") state.available = false;
if (key === "viSave" && normalized.code === "STALE_DATA") {
state.viBaseVersion = null;
state.viVersion = null;
state.viVerified = null;
}
if (normalized.outcomeUnknown) {
state.quarantined = true;
state.quarantineMessage = normalized.message;
state.netVerified = null;
state.viVerified = null;
notify("저장 결과가 불명확하여 앱 재시작 전까지 재저장을 차단합니다.", "error");
} else {
notify(normalized.message, "error");
}
log(`수동 목록 오류 · ${normalized.operation} · ${normalized.code}`);
render();
return true;
}
return false;
}
function handleStockResults(payload) {
const pending = state.pending.stockSearch;
if (!pending) return false;
const normalized = normalizeStockSearchResponse(payload, pending);
if (!normalized) return false;
state.pending.stockSearch = null;
state.searchResults = [...normalized.results];
state.searchMessage = normalized.results.length === 0
? "검색 결과가 없습니다."
: `${normalized.results.length}${normalized.truncated ? " · 일부 결과" : ""}`;
render();
return true;
}
function handleStockError(payload) {
const pending = state.pending.stockSearch;
if (!pending) return false;
const normalized = normalizeStockSearchError(payload, pending);
if (!normalized) return false;
state.pending.stockSearch = null;
state.searchResults = [];
state.searchMessage = normalized.message;
notify(normalized.message, "error");
render();
return true;
}
function handleMessage(type, payload) {
switch (type) {
case workflow.bridgeContract.statusResultType: return handleStatus(payload);
case workflow.bridgeContract.netSellDataType: return handleNetData(payload);
case workflow.bridgeContract.netSellSaveResultType: return handleNetSave(payload);
case workflow.bridgeContract.viDataType: return handleViData(payload);
case workflow.bridgeContract.viSaveResultType: return handleViSave(payload);
case workflow.bridgeContract.errorType: return handleManualError(payload);
case "stock-search-results": return handleStockResults(payload);
case "stock-search-error": return handleStockError(payload);
default: return false;
}
}
function element(tag, className, text) {
const node = state.root.ownerDocument.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(text, action, options = {}) {
const node = element("button", options.className || "manual-lists-button", text);
node.type = "button";
node.disabled = Boolean(options.disabled);
if (options.title) node.title = options.title;
node.addEventListener("click", action);
return node;
}
function renderHeader(panel) {
const header = element("header", "manual-lists-header");
const title = element(
"h2",
"manual-lists-title",
state.mode === "net" ? "수동 순매도 상위" : "VI 발동 목록");
const close = button("닫기", () => {
state.open = false;
render();
}, { className: "manual-lists-close" });
header.append(title, close);
panel.append(header);
}
function renderBanner(panel) {
if (state.quarantined) {
const banner = element(
"div",
"manual-lists-banner manual-lists-banner-danger",
state.quarantineMessage || "저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다.");
banner.setAttribute("role", "alert");
panel.append(banner);
} else if (locked()) {
panel.append(element(
"div",
"manual-lists-banner",
"PREPARE 또는 송출 중에는 목록을 변경할 수 없습니다."));
} else if (state.available === false) {
panel.append(element(
"div",
"manual-lists-banner manual-lists-banner-danger",
"수동 데이터 저장소를 사용할 수 없습니다."));
}
}
function renderNet(panel) {
const tabs = element("div", "manual-lists-tabs");
for (const audience of workflow.audiences) {
const tab = button(workflow.audienceLabels[audience], () => setAudience(audience), {
className: audience === state.audience
? "manual-lists-tab is-active"
: "manual-lists-tab",
disabled: Boolean(state.pending.netSave)
});
tab.setAttribute("aria-pressed", String(audience === state.audience));
tabs.append(tab);
}
panel.append(tabs);
const table = element("table", "manual-lists-table manual-lists-net-table");
const head = element("thead");
const headRow = element("tr");
for (const text of ["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"]) {
headRow.append(element("th", "", text));
}
head.append(headRow);
table.append(head);
const body = element("tbody");
state.netRows.forEach((row, rowIndex) => {
const tr = element("tr");
tr.append(element("th", "manual-lists-row-number", String(rowIndex + 1)));
for (const key of NET_KEYS) {
const td = element("td");
const input = element("input", "manual-lists-input");
input.type = "text";
input.maxLength = 256;
input.value = row[key];
input.disabled = locked() || Boolean(state.pending.netSave);
input.setAttribute("aria-label", `${rowIndex + 1}${key}`);
input.addEventListener("input", event => setNetCell(rowIndex, key, event.currentTarget.value));
td.append(input);
tr.append(td);
}
body.append(tr);
});
table.append(body);
const tableWrap = element("div", "manual-lists-table-wrap");
tableWrap.append(table);
panel.append(tableWrap);
const actions = element("div", "manual-lists-actions");
actions.append(
button("재조회", () => requestNetRead(state.audience), {
disabled: Boolean(state.pending.netRead || state.pending.netSave)
}),
button("저장", saveNetSell, {
className: "manual-lists-button is-primary",
disabled: !canMutate() || Boolean(state.pending.netRead || state.pending.netSave)
}),
button("s5025 추가", addNetSellCut, {
className: "manual-lists-button is-accent",
disabled: !canMutate() || !state.netVerified ||
!rowsEqual(state.netVerified.rows, state.netRows)
})
);
panel.append(actions);
}
function renderSearch(panel) {
const section = element("section", "manual-lists-search");
const controls = element("div", "manual-lists-search-controls");
const input = element("input", "manual-lists-input manual-lists-search-input");
input.type = "search";
input.maxLength = 100;
input.placeholder = "종목명 검색";
input.value = state.searchQuery;
input.addEventListener("input", event => { state.searchQuery = event.currentTarget.value; });
input.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
searchStocks(event.currentTarget.value);
}
});
controls.append(input, button("검색", () => searchStocks(input.value), {
disabled: Boolean(state.pending.stockSearch)
}));
section.append(controls);
section.append(element("p", "manual-lists-search-state", state.searchMessage));
const results = element("div", "manual-lists-search-results");
state.searchResults.forEach((stock, index) => {
results.append(button(
`${stock.name} · ${stock.code} · ${stock.market}`,
() => addSearchResult(index),
{
className: "manual-lists-search-result",
disabled: locked() || state.viItems.length >= workflow.maximumViItems
}));
});
section.append(results);
panel.append(section);
}
function renderVi(panel) {
renderSearch(panel);
const summary = element(
"div",
"manual-lists-summary",
`${state.viItems.length}/${workflow.maximumViItems}종목 · ${state.viItems.length === 0 ? 0 : Math.ceil(state.viItems.length / workflow.viPageSize)}페이지`);
panel.append(summary);
const tableWrap = element("div", "manual-lists-table-wrap");
const table = element("table", "manual-lists-table manual-lists-vi-table");
const head = element("thead");
const headRow = element("tr");
for (const text of ["#", "코드", "종목명", "순서/삭제"]) headRow.append(element("th", "", text));
head.append(headRow);
table.append(head);
const body = element("tbody");
state.viItems.forEach((item, index) => {
const row = element("tr");
row.append(
element("td", "manual-lists-row-number", String(index + 1)),
element("td", "manual-lists-code", item.code),
element("td", "manual-lists-name", item.name)
);
const controls = element("td", "manual-lists-row-actions");
controls.append(
button("↑", () => moveViItem(index, -1), {
disabled: locked() || index === 0,
title: "위로"
}),
button("↓", () => moveViItem(index, 1), {
disabled: locked() || index === state.viItems.length - 1,
title: "아래로"
}),
button("삭제", () => removeViItem(index), { disabled: locked() })
);
row.append(controls);
body.append(row);
});
table.append(body);
tableWrap.append(table);
panel.append(tableWrap);
const actions = element("div", "manual-lists-actions");
actions.append(
button("전체 삭제", clearViItems, {
disabled: locked() || state.viItems.length === 0
}),
button("재조회", () => requestViRead(), {
disabled: Boolean(state.pending.viRead || state.pending.viSave)
}),
button("저장", saveVi, {
className: "manual-lists-button is-primary",
disabled: !canMutate() || !state.viBaseVersion ||
Boolean(state.pending.viRead || state.pending.viSave)
}),
button("s5074 추가", addViCut, {
className: "manual-lists-button is-accent",
disabled: !canMutate() || state.viItems.length === 0 || !state.viVerified ||
state.viVerified.version !== state.viVersion ||
!itemsEqual(state.viVerified.items, state.viItems)
})
);
panel.append(actions);
}
function updateGateButtons() {
if (!state.root) return;
const add = state.root.querySelector(".manual-lists-button.is-accent");
if (!add) return;
add.disabled = state.mode === "net"
? !canMutate() || !state.netVerified || !rowsEqual(state.netVerified.rows, state.netRows)
: !canMutate() || state.viItems.length === 0 || !state.viVerified ||
state.viVerified.version !== state.viVersion || !itemsEqual(state.viVerified.items, state.viItems);
}
function snapshot() {
return Object.freeze({
open: state.open,
mode: state.mode,
locked: locked(),
available: state.available,
quarantined: state.quarantined,
audience: state.audience,
netRows: Object.freeze(copyRows(state.netRows)),
netVerified: Boolean(state.netVerified && rowsEqual(state.netVerified.rows, state.netRows)),
viItems: Object.freeze(copyItems(state.viItems)),
viBaseVersion: state.viBaseVersion,
viVersion: state.viVersion,
viVerified: Boolean(state.viVerified && state.viVerified.version === state.viVersion &&
itemsEqual(state.viVerified.items, state.viItems)),
searchResults: Object.freeze(state.searchResults.map(item => ({ ...item }))),
pending: Object.freeze(Object.fromEntries(
Object.entries(state.pending).map(([key, value]) => [key, value?.requestId || null])))
});
}
function render() {
if (!state.root) return snapshot();
state.root.hidden = !state.open;
state.root.replaceChildren();
if (!state.open) return snapshot();
const backdrop = element("div", "manual-lists-backdrop");
const panel = element("section", "manual-lists-dialog");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
renderHeader(panel);
renderBanner(panel);
if (state.mode === "net") renderNet(panel);
if (state.mode === "vi") renderVi(panel);
backdrop.append(panel);
state.root.append(backdrop);
return snapshot();
}
function mount(host) {
const resolvedHost = host || root?.document?.body;
if (!resolvedHost || typeof resolvedHost.append !== "function" || !resolvedHost.ownerDocument) {
throw new TypeError("A DOM host is required to mount the manual-list UI.");
}
if (state.root?.parentNode) state.root.remove();
state.host = resolvedHost;
state.root = resolvedHost.ownerDocument.createElement("div");
state.root.className = "manual-lists-root";
state.root.hidden = true;
resolvedHost.append(state.root);
render();
return state.root;
}
function openNetSell(audience = state.audience) {
const normalized = workflow.normalizeAudience(audience);
if (!normalized) throw new RangeError("Unknown FSell audience.");
state.open = true;
state.mode = "net";
state.audience = normalized;
state.netRows = emptyRows();
invalidateNetVerification();
requestStatus();
requestNetRead(normalized);
render();
return snapshot();
}
function openVi() {
state.open = true;
state.mode = "vi";
requestStatus();
requestViRead();
render();
return snapshot();
}
function setLocked(value) {
state.manualLocked = Boolean(value);
render();
return state.manualLocked;
}
const actions = Object.freeze({
setAudience,
setNetCell,
saveNetSell,
addNetSellCut,
requestNetRead: () => requestNetRead(state.audience),
searchStocks,
addSearchResult,
removeViItem,
moveViItem,
clearViItems,
saveVi,
addViCut,
requestViRead: () => requestViRead(),
close: () => {
state.open = false;
render();
}
});
return Object.freeze({
mount,
openNetSell,
openVi,
handleMessage,
render,
setLocked,
actions,
getState: snapshot
});
}
return { createController };
});

View File

@@ -0,0 +1,390 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnManualListsWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const SCHEMA_VERSION = 1;
const MAXIMUM_VI_ITEMS = 100;
const VI_PAGE_SIZE = 5;
const MAXIMUM_PAGE_COUNT = 20;
const VI_SNAPSHOT_SUBJECT = "@MBN_VI_SNAPSHOT_V1";
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const rowVersionPattern = /^[a-f0-9]{64}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const audiences = Object.freeze(["INDIVIDUAL", "FOREIGN", "INSTITUTION"]);
const audienceLabels = Object.freeze({
INDIVIDUAL: "개인",
FOREIGN: "외국인",
INSTITUTION: "기관"
});
const bridgeContract = Object.freeze({
statusRequestType: "request-manual-operator-data-status",
statusResultType: "manual-operator-data-status",
netSellReadType: "request-manual-net-sell-data",
netSellDataType: "manual-net-sell-data",
netSellSaveType: "save-manual-net-sell-data",
netSellSaveResultType: "manual-net-sell-save-result",
viReadType: "request-vi-manual-list",
viDataType: "vi-manual-list-data",
viSaveType: "save-vi-manual-list",
viSaveResultType: "vi-manual-list-save-result",
errorType: "manual-operator-data-error"
});
function hasExactKeys(value, keys) {
if (!value || typeof value !== "object" || Array.isArray(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 isRequestId(value) {
return typeof value === "string" && identifierPattern.test(value);
}
function isRowVersion(value) {
return typeof value === "string" && rowVersionPattern.test(value);
}
function isSafeText(value, maximumLength, allowEmpty = true) {
return typeof value === "string" &&
(allowEmpty || value.length > 0) &&
value.length <= maximumLength &&
!value.includes("^") &&
!unsafeTextPattern.test(value);
}
function normalizeAudience(value) {
return typeof value === "string" && audiences.includes(value) ? value : null;
}
function normalizeNetSellRow(value) {
if (!hasExactKeys(value, ["leftName", "leftAmount", "rightName", "rightAmount"])) return null;
const keys = ["leftName", "leftAmount", "rightName", "rightAmount"];
if (!keys.every(key => isSafeText(value[key], 256))) return null;
return Object.freeze({
leftName: value.leftName,
leftAmount: value.leftAmount,
rightName: value.rightName,
rightAmount: value.rightAmount
});
}
function normalizeNetSellRows(value) {
if (!Array.isArray(value) || value.length !== 5) return null;
const rows = value.map(normalizeNetSellRow);
return rows.every(Boolean) ? Object.freeze(rows) : null;
}
function normalizeViItem(value) {
if (!hasExactKeys(value, ["code", "name"]) ||
!isSafeText(value.code, 64, false) ||
!isSafeText(value.name, 200, false) ||
value.code !== value.code.trim() || value.name !== value.name.trim() ||
value.name.includes(",")) return null;
return Object.freeze({ code: value.code, name: value.name });
}
function normalizeViItems(value, allowEmpty = true) {
if (!Array.isArray(value) || value.length > MAXIMUM_VI_ITEMS || (!allowEmpty && value.length === 0)) {
return null;
}
const items = value.map(normalizeViItem);
return items.every(Boolean) ? Object.freeze(items) : null;
}
function request(type, requestId, extra = {}) {
if (!isRequestId(requestId)) throw new TypeError("A safe manual-data request id is required.");
return Object.freeze({ type, payload: Object.freeze({ requestId, ...extra }) });
}
function createStatusRequest(requestId) {
return request(bridgeContract.statusRequestType, requestId);
}
function createNetSellReadRequest(requestId, audience) {
const normalized = normalizeAudience(audience);
if (!normalized) throw new RangeError("The manual net-sell audience is invalid.");
return request(bridgeContract.netSellReadType, requestId, { audience: normalized });
}
function createNetSellSaveRequest(requestId, audience, rows) {
const normalizedAudience = normalizeAudience(audience);
const normalizedRows = normalizeNetSellRows(rows);
if (!normalizedAudience || !normalizedRows) throw new TypeError("Five valid manual net-sell rows are required.");
return request(bridgeContract.netSellSaveType, requestId, {
audience: normalizedAudience,
rows: normalizedRows
});
}
function createViReadRequest(requestId) {
return request(bridgeContract.viReadType, requestId);
}
function createViSaveRequest(requestId, items, expectedVersion) {
const normalized = normalizeViItems(items);
if (!normalized || !isRowVersion(expectedVersion)) {
throw new TypeError("A valid VI manual list and its last-read version are required.");
}
return request(bridgeContract.viSaveType, requestId, {
expectedVersion,
items: normalized
});
}
function normalizeStatusResponse(value, expectedRequestId) {
if (!hasExactKeys(value, ["requestId", "available", "writeQuarantined", "message"]) ||
!isRequestId(value.requestId) ||
typeof value.available !== "boolean" ||
typeof value.writeQuarantined !== "boolean" ||
!(value.message === null || isSafeText(value.message, 512, false)) ||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
return Object.freeze({ ...value });
}
function normalizeNetSellDataResponse(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "audience", "rows"]) ||
!isRequestId(value.requestId) ||
!normalizeAudience(value.audience) ||
(expectedRequest &&
(value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null;
const rows = normalizeNetSellRows(value.rows);
return rows && Object.freeze({ requestId: value.requestId, audience: value.audience, rows });
}
function normalizeNetSellSaveResponse(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "audience", "saved", "recovered"]) ||
!isRequestId(value.requestId) ||
!normalizeAudience(value.audience) ||
value.saved !== true || typeof value.recovered !== "boolean" ||
(expectedRequest &&
(value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null;
return Object.freeze({ ...value });
}
function normalizeViDataResponse(value, expectedRequestId) {
if (!hasExactKeys(value, ["requestId", "itemCount", "pageCount", "items", "version"]) ||
!isRequestId(value.requestId) ||
!isRowVersion(value.version) ||
!Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS ||
!Number.isInteger(value.pageCount) || value.pageCount < 0 || value.pageCount > MAXIMUM_PAGE_COUNT ||
value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) ||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
const items = normalizeViItems(value.items);
if (!items || items.length !== value.itemCount) return null;
return Object.freeze({
requestId: value.requestId,
itemCount: value.itemCount,
pageCount: value.pageCount,
items,
version: value.version
});
}
function normalizeViSaveResponse(value, expectedRequestId) {
if (!hasExactKeys(value, [
"requestId", "saved", "recovered", "persisted", "itemCount", "pageCount", "version"
]) ||
!isRequestId(value.requestId) || value.saved !== true || typeof value.recovered !== "boolean" ||
typeof value.persisted !== "boolean" || !isRowVersion(value.version) ||
!Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS ||
!Number.isInteger(value.pageCount) ||
value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) ||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
return Object.freeze({ ...value });
}
function normalizeError(value, expectedRequestId) {
if (!hasExactKeys(value, ["requestId", "operation", "code", "message", "retryable", "outcomeUnknown"]) ||
!(value.requestId === "" || isRequestId(value.requestId)) ||
!isSafeText(value.operation, 64, false) ||
!isSafeText(value.code, 64, false) ||
!isSafeText(value.message, 512, false) ||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
(value.outcomeUnknown && value.retryable) ||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
return Object.freeze({ ...value });
}
function requireOptions(options) {
if (!hasExactKeys(options, ["id", "fadeDuration"]) || !isRequestId(options.id) ||
!Number.isInteger(options.fadeDuration) || options.fadeDuration < 0 || options.fadeDuration > 60) {
throw new TypeError("Safe manual-list playlist options are required.");
}
return options;
}
function createNetSellPlaylistEntry(audience, options) {
const normalizedAudience = normalizeAudience(audience);
const normalizedOptions = requireOptions(options);
if (!normalizedAudience) throw new RangeError("The manual net-sell audience is invalid.");
const label = audienceLabels[normalizedAudience];
const selection = Object.freeze({
groupCode: normalizedAudience,
subject: "INDEX",
graphicType: "MANUAL_NET_SELL",
subtype: "MANUAL_NET_SELL",
dataCode: ""
});
return {
id: normalizedOptions.id,
builderKey: "s5025",
code: "5025",
aliases: ["5025"],
title: `${label} 순매도 상위(수동)`,
detail: "매매동향 · 검증된 수동 입력 5행",
category: "market",
market: "index",
selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator: Object.freeze({
source: "legacy-manual-lists-workflow",
schemaVersion: SCHEMA_VERSION,
kind: "net-sell",
audience: normalizedAudience
})
};
}
function createViPlaylistEntry(items, rowVersion, options) {
const normalizedItems = normalizeViItems(items, false);
const normalizedOptions = requireOptions(options);
if (!normalizedItems || !isRowVersion(rowVersion)) {
throw new TypeError("A versioned non-empty VI snapshot is required.");
}
const pageCount = Math.ceil(normalizedItems.length / VI_PAGE_SIZE);
const selection = Object.freeze({
groupCode: "PAGED_VI",
subject: VI_SNAPSHOT_SUBJECT,
graphicType: "INPUT_ORDER",
subtype: "CURRENT",
dataCode: rowVersion
});
return {
id: normalizedOptions.id,
builderKey: "s5074",
code: "5074",
aliases: ["5074"],
title: normalizedItems.map(item => item.name).join(", "),
detail: "5단 표그래프 · VI 발동(수동)",
category: "plate",
market: "index",
itemCount: normalizedItems.length,
pageSize: VI_PAGE_SIZE,
pageCount,
selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator: Object.freeze({
source: "legacy-manual-lists-workflow",
schemaVersion: SCHEMA_VERSION,
kind: "vi",
items: normalizedItems,
rowVersion,
pagePreview: Object.freeze({
itemCount: normalizedItems.length,
pageSize: VI_PAGE_SIZE,
pageCount,
maximumPageCount: MAXIMUM_PAGE_COUNT
})
})
};
}
function sameSelection(left, right) {
const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
return hasExactKeys(left, keys) && hasExactKeys(right, keys) && keys
.every(key => typeof left[key] === "string" && left[key] === right[key]);
}
function restorePlaylistEntry(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null;
const operator = value.operator;
if (!operator || operator.source !== "legacy-manual-lists-workflow" ||
operator.schemaVersion !== SCHEMA_VERSION || !["net-sell", "vi"].includes(operator.kind)) return null;
const topKeys = operator.kind === "net-sell"
? [
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
"selection", "enabled", "fadeDuration", "graphicType", "subtype", "operator"
]
: [
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
"itemCount", "pageSize", "pageCount", "selection", "enabled", "fadeDuration",
"graphicType", "subtype", "operator"
];
const operatorKeys = operator.kind === "net-sell"
? ["source", "schemaVersion", "kind", "audience"]
: ["source", "schemaVersion", "kind", "items", "rowVersion", "pagePreview"];
if (!hasExactKeys(value, topKeys) || !hasExactKeys(operator, operatorKeys)) return null;
if (operator.kind === "vi" &&
(!isRowVersion(operator.rowVersion) ||
!hasExactKeys(operator.pagePreview, ["itemCount", "pageSize", "pageCount", "maximumPageCount"]))) {
return null;
}
let recreated;
try {
const options = { id: value.id, fadeDuration: value.fadeDuration };
recreated = operator.kind === "net-sell"
? createNetSellPlaylistEntry(operator.audience, options)
: createViPlaylistEntry(operator.items, operator.rowVersion, options);
} catch {
return null;
}
const scalars = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"fadeDuration", "graphicType", "subtype"
];
if (!scalars.every(key => value[key] === recreated[key]) ||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.aliases[0] ||
!sameSelection(value.selection, recreated.selection)) return null;
if (operator.kind === "vi" &&
(value.itemCount !== recreated.itemCount || value.pageSize !== recreated.pageSize ||
value.pageCount !== recreated.pageCount ||
operator.pagePreview.itemCount !== recreated.operator.pagePreview.itemCount ||
operator.pagePreview.pageSize !== recreated.operator.pagePreview.pageSize ||
operator.pagePreview.pageCount !== recreated.operator.pagePreview.pageCount ||
operator.pagePreview.maximumPageCount !== recreated.operator.pagePreview.maximumPageCount)) return null;
return { ...recreated, enabled: value.enabled };
}
return {
bridgeContract,
audiences,
audienceLabels,
maximumViItems: MAXIMUM_VI_ITEMS,
viPageSize: VI_PAGE_SIZE,
maximumPageCount: MAXIMUM_PAGE_COUNT,
viSnapshotSubject: VI_SNAPSHOT_SUBJECT,
normalizeAudience,
normalizeNetSellRow,
normalizeNetSellRows,
normalizeViItem,
normalizeViItems,
createStatusRequest,
createNetSellReadRequest,
createNetSellSaveRequest,
createViReadRequest,
createViSaveRequest,
normalizeStatusResponse,
normalizeNetSellDataResponse,
normalizeNetSellSaveResponse,
normalizeViDataResponse,
normalizeViSaveResponse,
normalizeError,
createNetSellPlaylistEntry,
createViPlaylistEntry,
restorePlaylistEntry,
refreshPlaylistEntry: restorePlaylistEntry
};
});

View File

@@ -0,0 +1,204 @@
(function (root, factory) {
"use strict";
const manualLists = typeof module === "object" && module.exports
? require("./manual-lists-workflow.js")
: root.MbnManualListsWorkflow;
const manualFinancial = typeof module === "object" && module.exports
? require("./manual-financial-workflow.js")
: root.MbnManualFinancialWorkflow;
const api = Object.freeze(factory(manualLists, manualFinancial));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnNamedManualRestoreWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (manualLists, manualFinancial) {
"use strict";
if (!manualLists || !manualFinancial) throw new Error("Manual restore dependencies are unavailable.");
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const versionPattern = /^[a-f0-9]{64}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const pendingValues = new WeakSet();
const legacyAudienceByGroup = Object.freeze({
"개인 순매도 상위(수동)": "INDIVIDUAL",
"외국인 순매도 상위(수동)": "FOREIGN",
"기관 순매도 상위(수동)": "INSTITUTION"
});
const marketByGroup = Object.freeze({
KOSPI: "kospi", KOSDAQ: "kosdaq", NXT_KOSPI: "nxt-kospi", NXT_KOSDAQ: "nxt-kosdaq",
"코스피": "kospi", "코스닥": "kosdaq", "코스피_NXT": "nxt-kospi", "코스닥_NXT": "nxt-kosdaq"
});
const financialByGraphic = Object.freeze(Object.fromEntries(
manualFinancial.sourceContract.screens.flatMap(screen => [
[screen.graphicType, screen],
[screen.label, screen]
])));
function safeText(value, maximum, allowEmpty = false) {
return typeof value === "string" && value.length <= maximum &&
(allowEmpty || value.length > 0) && value === value.trim() &&
!value.includes("^") && !unsafeTextPattern.test(value);
}
function selection(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw) ||
typeof raw.enabled !== "boolean" || !Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 ||
!safeText(raw.groupCode, 256, true) || !safeText(raw.subject, 4096, true) ||
!safeText(raw.graphicType, 256, true) || !safeText(raw.subtype, 256, true) ||
!safeText(raw.dataCode, 1024, true)) return null;
return Object.freeze({
groupCode: raw.groupCode,
subject: raw.subject,
graphicType: raw.graphicType,
subtype: raw.subtype,
dataCode: raw.dataCode
});
}
function options(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
!identifierPattern.test(value.id) || !Number.isInteger(value.fadeDuration) ||
value.fadeDuration < 0 || value.fadeDuration > 60) return null;
return Object.freeze({ id: value.id, fadeDuration: value.fadeDuration });
}
function exactHistoricalViNames(subject) {
if (!safeText(subject, 4096) || subject.startsWith("@MBN_VI_SNAPSHOT_")) return null;
const names = subject.split(",");
if (names.length < 1 || names.length > manualLists.maximumViItems ||
names.some(name => !safeText(name, 200) || name !== name.trim()) ||
new Set(names).size !== names.length) return null;
return Object.freeze(names);
}
function classify(raw, rawOptions) {
const selected = selection(raw);
const normalizedOptions = options(rawOptions);
if (!selected || !normalizedOptions) return null;
let descriptor = null;
const canonicalAudience = manualLists.normalizeAudience(selected.groupCode);
const legacyAudience = legacyAudienceByGroup[selected.groupCode] || null;
if ((canonicalAudience && selected.subject === "INDEX" &&
selected.graphicType === "MANUAL_NET_SELL" && selected.subtype === "MANUAL_NET_SELL") ||
(legacyAudience && selected.subject === "" &&
selected.graphicType === "순매도 상위" && selected.subtype === "순매도 상위")) {
if (selected.dataCode !== "") return null;
descriptor = {
kind: "net-sell",
audience: canonicalAudience || legacyAudience,
historical: Boolean(legacyAudience)
};
} else if (selected.groupCode === "PAGED_VI" && selected.graphicType === "INPUT_ORDER" &&
selected.subtype === "CURRENT") {
if (selected.subject === manualLists.viSnapshotSubject && versionPattern.test(selected.dataCode)) {
descriptor = { kind: "vi", expectedVersion: selected.dataCode, historicalNames: null };
} else if (selected.dataCode === "") {
const names = exactHistoricalViNames(selected.subject);
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
}
} else if (selected.groupCode === "" && selected.graphicType === "5단 표그래프" &&
selected.subtype === "VI 발동(수동)" && selected.dataCode === "") {
const names = exactHistoricalViNames(selected.subject);
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
} else {
const profile = financialByGraphic[selected.graphicType];
const market = marketByGroup[selected.groupCode];
if (profile && market && safeText(selected.subject, 200) &&
selected.subtype === "" && selected.dataCode === "") {
descriptor = {
kind: "financial",
screen: profile.screen,
builderKey: profile.builderKey,
code: profile.code,
market,
stockName: selected.subject,
identity: Object.freeze({ screen: profile.screen, stockName: selected.subject })
};
}
}
if (!descriptor) return null;
const pending = Object.freeze({
...descriptor,
itemIndex: raw.itemIndex,
enabled: raw.enabled,
entry: Object.freeze({ id: normalizedOptions.id }),
fadeDuration: normalizedOptions.fadeDuration,
rawSelection: selected
});
pendingValues.add(pending);
return pending;
}
function createManualListReadRequest(requestId, pending) {
if (!pendingValues.has(pending) || !["net-sell", "vi"].includes(pending.kind)) {
throw new TypeError("A validated named manual-list restore is required.");
}
return pending.kind === "net-sell"
? manualLists.createNetSellReadRequest(requestId, pending.audience)
: manualLists.createViReadRequest(requestId);
}
function materializeManualList(pending, payload, requestEnvelope) {
if (!pendingValues.has(pending) || !requestEnvelope ||
requestEnvelope.type !== (pending.kind === "net-sell"
? manualLists.bridgeContract.netSellReadType
: manualLists.bridgeContract.viReadType)) return null;
let entry;
if (pending.kind === "net-sell") {
const response = manualLists.normalizeNetSellDataResponse(payload, requestEnvelope.payload);
if (!response) return null;
entry = manualLists.createNetSellPlaylistEntry(pending.audience, {
id: pending.entry.id,
fadeDuration: pending.fadeDuration
});
} else if (pending.kind === "vi") {
const response = manualLists.normalizeViDataResponse(payload, requestEnvelope.payload.requestId);
if (!response || response.itemCount < 1 ||
(pending.expectedVersion !== null && response.version !== pending.expectedVersion)) return null;
if (pending.historicalNames !== null) {
const names = response.items.map(item => item.name);
if (names.length !== pending.historicalNames.length ||
names.some((name, index) => name !== pending.historicalNames[index])) return null;
}
entry = manualLists.createViPlaylistEntry(response.items, response.version, {
id: pending.entry.id,
fadeDuration: pending.fadeDuration
});
}
return manualLists.restorePlaylistEntry({ ...entry, enabled: pending.enabled });
}
function isPending(value, kind) {
return pendingValues.has(value) && (kind === undefined || value.kind === kind);
}
function matchesMaterializedEntry(pending, entry) {
if (!pendingValues.has(pending) || !entry || entry.id !== pending.entry.id ||
entry.enabled !== pending.enabled || entry.fadeDuration !== pending.fadeDuration) return false;
if (pending.kind === "net-sell") {
const restored = manualLists.restorePlaylistEntry(entry);
return !!restored && restored.builderKey === "s5025" &&
restored.operator.kind === "net-sell" && restored.operator.audience === pending.audience;
}
if (pending.kind === "vi") {
const restored = manualLists.restorePlaylistEntry(entry);
return !!restored && restored.builderKey === "s5074" && restored.operator.kind === "vi";
}
if (pending.kind === "financial") {
return manualFinancial.isPlaylistEntryPlayable(entry) &&
entry.builderKey === pending.builderKey && entry.code === pending.code &&
entry.stockName === pending.stockName && entry.market === pending.market;
}
return false;
}
return {
classify,
createManualListReadRequest,
materializeManualList,
isPending,
matchesMaterializedEntry
};
});

View File

@@ -0,0 +1,691 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnNamedPlaylistWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const SCHEMA_VERSION = 1;
const MAXIMUM_DEFINITIONS = 1000;
const MAXIMUM_ITEMS = 1000;
const MAXIMUM_PAGE_COUNT = 20;
const MAXIMUM_STORED_PAGE_COUNT = 9999;
const DEFAULT_MAXIMUM_DEFINITIONS = 200;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const programCodePattern = /^[0-9]{8}$/;
const cutCodePattern = /^[0-9]{1,8}$/;
const builderKeyPattern = /^s[0-9]+$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const pagedBuilderByCode = Object.freeze({
"5074": Object.freeze({ builderKey: "s5074", pageSize: 5 }),
"5077": Object.freeze({ builderKey: "s5077", pageSize: 6 }),
"5088": Object.freeze({ builderKey: "s5088", pageSize: 12 })
});
const bridgeContract = Object.freeze({
requests: Object.freeze({
list: "request-named-playlist-list",
load: "request-named-playlist-load",
create: "create-named-playlist",
replace: "replace-named-playlist",
delete: "delete-named-playlist"
}),
responses: Object.freeze({
list: "named-playlist-list-results",
load: "named-playlist-load-results",
mutation: "named-playlist-mutation-results",
error: "named-playlist-error"
}),
schemaVersion: SCHEMA_VERSION,
maximumDefinitions: MAXIMUM_DEFINITIONS,
maximumItems: MAXIMUM_ITEMS,
maximumPageCount: MAXIMUM_PAGE_COUNT,
maximumStoredPageCount: MAXIMUM_STORED_PAGE_COUNT
});
const pagePlanBridgeContract = Object.freeze({
request: "request-playlist-page-plans",
results: "playlist-page-plans-results",
error: "playlist-page-plans-error",
pagedBuilderByCode
});
const operations = Object.freeze(["list", "load", "create", "replace", "delete"]);
const operationSet = new Set(operations);
function isPlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function hasExactKeys(value, expected) {
if (!isPlainObject(value)) return false;
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
return actual.length === sortedExpected.length &&
actual.every((key, index) => key === sortedExpected[index]);
}
function exactText(value, maximumLength, allowEmpty = false, allowCaret = false) {
if (typeof value !== "string" || value.length > maximumLength ||
(!allowEmpty && value.length === 0) || value !== value.trim() ||
controlCharacterPattern.test(value) || (!allowCaret && value.includes("^"))) return null;
return value;
}
function requestId(value) {
const text = exactText(value, 128);
return text && identifierPattern.test(text) ? text : null;
}
function programCode(value) {
const text = exactText(value, 8);
return text && programCodePattern.test(text) ? text : null;
}
function isIsoTimestamp(value) {
return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
!Number.isNaN(Date.parse(value));
}
function createListRequest(requestIdValue, maximumResults = DEFAULT_MAXIMUM_DEFINITIONS) {
const id = requestId(requestIdValue);
if (!id) throw new TypeError("A safe named-playlist request id is required.");
if (!Number.isInteger(maximumResults) || maximumResults < 1 ||
maximumResults > MAXIMUM_DEFINITIONS) {
throw new RangeError(`The named-playlist definition limit must be 1 through ${MAXIMUM_DEFINITIONS}.`);
}
return Object.freeze({ requestId: id, maximumResults });
}
function createLoadRequest(requestIdValue, programCodeValue) {
const id = requestId(requestIdValue);
const code = programCode(programCodeValue);
if (!id || !code) throw new TypeError("A safe request id and eight-digit program code are required.");
return Object.freeze({ requestId: id, programCode: code });
}
function createDefinitionRequest(requestIdValue, programCodeValue, titleValue) {
const request = createLoadRequest(requestIdValue, programCodeValue);
const title = exactText(titleValue, 128, false, true);
if (!title) throw new TypeError("A canonical named-playlist title is required.");
return Object.freeze({ ...request, title });
}
function createDeleteRequest(requestIdValue, programCodeValue) {
return createLoadRequest(requestIdValue, programCodeValue);
}
function parseStoredPageText(value) {
if (value === "") return null;
if (typeof value !== "string" || !/^[1-9][0-9]{0,3}\/(?:0|[1-9][0-9]{0,3})$/.test(value)) {
return null;
}
const [currentText, totalText] = value.split("/");
const currentPage = Number(currentText);
const totalPages = Number(totalText);
if (totalPages > MAXIMUM_STORED_PAGE_COUNT ||
(totalPages === 0 ? currentPage !== 1 : currentPage > totalPages)) return null;
const isWithinPlayoutBounds = totalPages >= 1 &&
totalPages <= MAXIMUM_PAGE_COUNT && currentPage <= totalPages;
return Object.freeze({
pageText: value,
currentPage,
totalPages,
isWithinPlayoutBounds,
historicalPageOutOfBounds: !isWithinPlayoutBounds,
pageRecalculationRequired: true
});
}
function normalizeSelection(value) {
if (!hasExactKeys(value, ["groupCode", "subject", "graphicType", "subtype", "dataCode"])) {
return null;
}
const groupCode = exactText(value.groupCode, 256, true);
const subject = exactText(value.subject, 256, true);
const graphicType = exactText(value.graphicType, 256, true);
const subtype = exactText(value.subtype, 256, true);
const dataCode = exactText(value.dataCode, 256, true);
if ([groupCode, subject, graphicType, subtype, dataCode].some(field => field === null)) return null;
return Object.freeze({ groupCode, subject, graphicType, subtype, dataCode });
}
function normalizePageTextForSave(value) {
if (value === "") return "";
const page = parseStoredPageText(value);
if (!page) throw new RangeError("The stored named-playlist page text is invalid.");
return page.pageText;
}
function toLegacySevenFields(entry, pageTextValue = "1/1") {
if (!isPlainObject(entry) || typeof entry.enabled !== "boolean") {
throw new TypeError("A typed playlist entry with an enabled flag is required.");
}
const selection = normalizeSelection(entry.selection);
if (!selection) throw new TypeError("The playlist selection cannot be stored safely.");
const pageText = normalizePageTextForSave(pageTextValue);
return Object.freeze([
entry.enabled ? "1" : "0",
selection.groupCode,
selection.subject,
selection.graphicType,
selection.subtype,
pageText,
selection.dataCode
]);
}
function legacyListText(fieldsValue) {
if (!Array.isArray(fieldsValue) || fieldsValue.length !== 7 ||
fieldsValue.some(field => typeof field !== "string" || field.includes("^") ||
controlCharacterPattern.test(field)) ||
!["0", "1"].includes(fieldsValue[0]) ||
fieldsValue.slice(1, 5).some(field => exactText(field, 256, true) === null) ||
exactText(fieldsValue[6], 256, true) === null ||
(fieldsValue[5] !== "" && !parseStoredPageText(fieldsValue[5]))) {
throw new TypeError("Exactly seven safe legacy playlist fields are required.");
}
const value = fieldsValue.join("^");
if (value.length > 4000) throw new RangeError("The legacy LIST_TEXT value is too long.");
return value;
}
function createReplaceRequest(
requestIdValue,
programCodeValue,
playlistEntries,
options = {}) {
const request = createLoadRequest(requestIdValue, programCodeValue);
if (!Array.isArray(playlistEntries) || playlistEntries.length > MAXIMUM_ITEMS) {
throw new RangeError(`A named playlist can contain at most ${MAXIMUM_ITEMS} entries.`);
}
if (!isPlainObject(options) || typeof options.isTrustedEntry !== "function") {
throw new TypeError("A trusted-entry verifier is required before database storage.");
}
if (options.pageTextForEntry !== undefined && typeof options.pageTextForEntry !== "function") {
throw new TypeError("pageTextForEntry must be a function when supplied.");
}
const items = playlistEntries.map((entry, itemIndex) => {
if (options.isTrustedEntry(entry, itemIndex) !== true) {
throw new TypeError(`Playlist entry ${itemIndex} did not pass trusted restoration.`);
}
const pageText = options.pageTextForEntry
? options.pageTextForEntry(entry, itemIndex)
: "1/1";
const fields = toLegacySevenFields(entry, pageText);
legacyListText(fields);
return Object.freeze({
itemIndex,
enabled: fields[0] === "1",
groupCode: fields[1],
subject: fields[2],
graphicType: fields[3],
subtype: fields[4],
pageText: fields[5],
dataCode: fields[6]
});
});
return Object.freeze({ ...request, items: Object.freeze(items) });
}
function normalizeDefinition(value) {
if (!hasExactKeys(value, ["programCode", "title"])) return null;
const code = programCode(value.programCode);
const title = exactText(value.title, 128, false, true);
return code && title ? Object.freeze({ programCode: code, title }) : null;
}
function normalizeListResponse(value) {
const keys = [
"requestId", "retrievedAt", "totalRowCount", "truncated",
"suggestedProgramCode", "canMutate", "writeQuarantined", "definitions"
];
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
!isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) ||
value.totalRowCount < 0 || typeof value.truncated !== "boolean" ||
!programCode(value.suggestedProgramCode) || typeof value.canMutate !== "boolean" ||
typeof value.writeQuarantined !== "boolean" || !Array.isArray(value.definitions) ||
value.definitions.length !== value.totalRowCount ||
value.definitions.length > MAXIMUM_DEFINITIONS ||
(value.writeQuarantined && value.canMutate)) return null;
const definitions = [];
const codes = new Set();
for (const raw of value.definitions) {
const definition = normalizeDefinition(raw);
if (!definition || codes.has(definition.programCode)) return null;
codes.add(definition.programCode);
definitions.push(definition);
}
return Object.freeze({
requestId: value.requestId,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
suggestedProgramCode: value.suggestedProgramCode,
canMutate: value.canMutate,
writeQuarantined: value.writeQuarantined,
definitions: Object.freeze(definitions)
});
}
function normalizeRawRow(value, expectedIndex) {
const keys = [
"itemIndex", "enabled", "groupCode", "subject", "graphicType",
"subtype", "pageText", "dataCode"
];
if (!hasExactKeys(value, keys) || value.itemIndex !== expectedIndex ||
typeof value.enabled !== "boolean") return null;
const selection = normalizeSelection({
groupCode: value.groupCode,
subject: value.subject,
graphicType: value.graphicType,
subtype: value.subtype,
dataCode: value.dataCode
});
if (!selection || typeof value.pageText !== "string" || value.pageText.length > 9) return null;
const pageState = value.pageText === "" ? null : parseStoredPageText(value.pageText);
if (value.pageText !== "" && !pageState) return null;
const legacyFields = Object.freeze([
value.enabled ? "1" : "0",
selection.groupCode,
selection.subject,
selection.graphicType,
selection.subtype,
value.pageText,
selection.dataCode
]);
const listText = legacyListText(legacyFields);
return Object.freeze({
itemIndex: value.itemIndex,
enabled: value.enabled,
groupCode: selection.groupCode,
subject: selection.subject,
graphicType: selection.graphicType,
subtype: selection.subtype,
pageText: value.pageText,
dataCode: selection.dataCode,
legacyFields,
listText,
pageState,
requiresTrustedRestore: true,
pageRecalculationRequired: pageState !== null,
historicalPageOutOfBounds: pageState?.historicalPageOutOfBounds === true
});
}
function normalizeLoadResponse(value) {
const keys = [
"requestId", "definition", "retrievedAt", "totalRowCount",
"canMutate", "writeQuarantined", "rows"
];
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
!isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) ||
value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_ITEMS ||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.rows) ||
value.rows.length !== value.totalRowCount) return null;
const definition = normalizeDefinition(value.definition);
if (!definition) return null;
const rawRows = [];
for (let index = 0; index < value.rows.length; index += 1) {
const row = normalizeRawRow(value.rows[index], index);
if (!row) return null;
rawRows.push(row);
}
return Object.freeze({
kind: "named-playlist-load",
schemaVersion: SCHEMA_VERSION,
requestId: value.requestId,
definition,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
canMutate: value.canMutate,
writeQuarantined: value.writeQuarantined,
rawRows: Object.freeze(rawRows)
});
}
function sameSelection(entry, rawRow) {
const selection = normalizeSelection(entry?.selection);
return selection !== null && selection.groupCode === rawRow.groupCode &&
selection.subject === rawRow.subject && selection.graphicType === rawRow.graphicType &&
selection.subtype === rawRow.subtype && selection.dataCode === rawRow.dataCode;
}
function freezeTrustedEntry(entry) {
const copy = {
...entry,
selection: Object.freeze({ ...entry.selection })
};
if (Array.isArray(entry.aliases)) copy.aliases = Object.freeze([...entry.aliases]);
if (isPlainObject(entry.operator)) copy.operator = Object.freeze({ ...entry.operator });
return Object.freeze(copy);
}
function trustedEntryMatchesRaw(entry, rawRow) {
return isPlainObject(entry) && requestId(entry.id) &&
typeof entry.builderKey === "string" && builderKeyPattern.test(entry.builderKey) &&
typeof entry.code === "string" && cutCodePattern.test(entry.code) &&
entry.enabled === rawRow.enabled && sameSelection(entry, rawRow);
}
function restoreRawRows(loadValue, trustedResolver) {
const load = loadValue?.kind === "named-playlist-load"
? loadValue
: normalizeLoadResponse(loadValue);
if (!load || typeof trustedResolver !== "function") {
throw new TypeError("A normalized load response and trusted resolver are required.");
}
const rows = load.rawRows.map(rawRow => {
let candidate = null;
try {
candidate = trustedResolver(rawRow, rawRow.itemIndex);
} catch {
candidate = null;
}
const trusted = trustedEntryMatchesRaw(candidate, rawRow);
return Object.freeze({
rawRow,
entry: trusted ? freezeTrustedEntry(candidate) : null,
trusted,
pageRecalculationRequired: rawRow.pageRecalculationRequired,
pageRecalculated: !rawRow.pageRecalculationRequired,
pagePreflightCompleted: false,
pageUnavailableReason: null,
recalculatedPagePlan: null
});
});
return freezeRestoration(load.definition, rows);
}
function preparePagePreflight(restoration, isPagedEntry) {
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
typeof isPagedEntry !== "function") {
throw new TypeError("A valid restoration and paged-entry classifier are required.");
}
const rows = restoration.rows.map(row => {
const paged = row.trusted && isPagedEntry(row.entry, row.rawRow.itemIndex) === true;
return Object.freeze({
...row,
pageRecalculationRequired: paged,
pageRecalculated: !paged,
pagePreflightCompleted: false,
pageUnavailableReason: null,
recalculatedPagePlan: null
});
});
return freezeRestoration(restoration.definition, rows);
}
function pagePlanEntry(entry) {
const definition = pagedBuilderByCode[entry?.code];
const selection = normalizeSelection(entry?.selection);
const fadeDuration = Number(entry?.fadeDuration);
if (!definition || definition.builderKey !== entry?.builderKey ||
!requestId(entry?.id) || typeof entry?.enabled !== "boolean" ||
!selection || !Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
return null;
}
return Object.freeze({
id: entry.id,
code: entry.code,
enabled: entry.enabled,
fadeDuration,
selection
});
}
function createPagePlanRequest(requestIdValue, restoration) {
const id = requestId(requestIdValue);
if (!id || !restoration || restoration.kind !== "named-playlist-restoration") {
throw new TypeError("A safe request id and trusted restoration are required.");
}
const entries = [];
const identifiers = new Set();
for (const row of restoration.rows) {
if (!row.trusted || !row.pageRecalculationRequired) continue;
const entry = pagePlanEntry(row.entry);
if (!entry || identifiers.has(entry.id)) {
throw new TypeError("A paged restoration entry is not safe for page preflight.");
}
identifiers.add(entry.id);
entries.push(entry);
}
if (!entries.length) return null;
return Object.freeze({ requestId: id, entries: Object.freeze(entries) });
}
function normalizePagePlanResult(value, expectedEntry) {
const keys = [
"entryId", "itemCount", "pageSize", "pageCount",
"accessibleItemCount", "isTruncated", "builderKey"
];
const definition = pagedBuilderByCode[expectedEntry?.code];
if (!hasExactKeys(value, keys) || !definition ||
value.entryId !== expectedEntry.id || value.builderKey !== definition.builderKey ||
value.pageSize !== definition.pageSize ||
!Number.isSafeInteger(value.itemCount) || value.itemCount < 0 ||
!Number.isInteger(value.pageCount) || value.pageCount < 0 ||
value.pageCount > MAXIMUM_PAGE_COUNT ||
!Number.isSafeInteger(value.accessibleItemCount) || value.accessibleItemCount < 0 ||
typeof value.isTruncated !== "boolean") return null;
const expectedPageCount = value.itemCount === 0
? 0
: Math.min(MAXIMUM_PAGE_COUNT, Math.ceil(value.itemCount / value.pageSize));
const expectedAccessible = Math.min(
value.itemCount,
expectedPageCount * value.pageSize);
if (value.pageCount !== expectedPageCount ||
value.accessibleItemCount !== expectedAccessible ||
value.isTruncated !== (value.itemCount > expectedAccessible)) return null;
return Object.freeze({ ...value });
}
function normalizePagePlanResponse(value, expectedRequest) {
if (!expectedRequest || !hasExactKeys(expectedRequest, ["requestId", "entries"]) ||
!hasExactKeys(value, ["requestId", "calculatedAt", "plans"]) ||
value.requestId !== expectedRequest.requestId || !isIsoTimestamp(value.calculatedAt) ||
!Array.isArray(value.plans) || value.plans.length !== expectedRequest.entries.length) {
return null;
}
const plans = [];
for (let index = 0; index < expectedRequest.entries.length; index += 1) {
const plan = normalizePagePlanResult(value.plans[index], expectedRequest.entries[index]);
if (!plan) return null;
plans.push(plan);
}
return Object.freeze({
requestId: value.requestId,
calculatedAt: value.calculatedAt,
plans: Object.freeze(plans)
});
}
function normalizePagePlanError(value, expectedRequestId) {
if (!hasExactKeys(value, ["requestId", "code", "message", "retryable"]) ||
value.requestId !== expectedRequestId || !requestId(value.requestId) ||
!exactText(value.code, 64) || !exactText(value.message, 1024) ||
typeof value.retryable !== "boolean") return null;
return Object.freeze({ ...value });
}
function normalizePagePlan(value) {
if (!hasExactKeys(value, ["itemCount", "pageSize", "pageCount"]) ||
!Number.isInteger(value.itemCount) || value.itemCount < 0 ||
!Number.isInteger(value.pageSize) || value.pageSize < 0 || value.pageSize > 12 ||
!Number.isInteger(value.pageCount) || value.pageCount < 1 ||
value.pageCount > MAXIMUM_PAGE_COUNT) return null;
return Object.freeze({
itemCount: value.itemCount,
pageSize: value.pageSize,
pageCount: value.pageCount
});
}
function markPageRecalculated(restoration, itemIndex, pagePlanValue) {
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
!Number.isInteger(itemIndex) || itemIndex < 0 || itemIndex >= restoration.rows.length) {
throw new TypeError("A valid restoration row is required.");
}
const pagePlan = normalizePagePlan(pagePlanValue);
if (!pagePlan) throw new RangeError("The recalculated page plan is invalid or exceeds 20 pages.");
const rows = restoration.rows.map((row, index) => index === itemIndex
? Object.freeze({
...row,
pageRecalculated: true,
pagePreflightCompleted: true,
pageUnavailableReason: null,
recalculatedPagePlan: pagePlan
})
: row);
return freezeRestoration(restoration.definition, rows);
}
function applyPagePlanResponse(restoration, response) {
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
!response || !Array.isArray(response.plans)) {
throw new TypeError("A valid restoration and normalized page-plan response are required.");
}
let current = restoration;
for (const plan of response.plans) {
const itemIndex = current.rows.findIndex(row =>
row.trusted && row.entry?.id === plan.entryId &&
row.entry?.builderKey === plan.builderKey && row.pageRecalculationRequired);
if (itemIndex < 0) throw new TypeError("The page-plan result does not match the restoration.");
if (plan.itemCount === 0) {
const rows = current.rows.map((row, index) => index === itemIndex
? Object.freeze({
...row,
pageRecalculated: false,
pagePreflightCompleted: true,
pageUnavailableReason: "empty-result",
recalculatedPagePlan: Object.freeze({ ...plan })
})
: row);
current = freezeRestoration(current.definition, rows);
continue;
}
current = markPageRecalculated(current, itemIndex, {
itemCount: plan.itemCount,
pageSize: plan.pageSize,
pageCount: plan.pageCount
});
const rows = current.rows.map((row, index) => index === itemIndex
? Object.freeze({ ...row, recalculatedPagePlan: Object.freeze({ ...plan }) })
: row);
current = freezeRestoration(current.definition, rows);
}
return current;
}
function prepareBlockers(restoration) {
if (!restoration || restoration.kind !== "named-playlist-restoration") {
return Object.freeze([Object.freeze({ itemIndex: -1, reason: "invalid-restoration" })]);
}
const blockers = [];
for (const row of restoration.rows) {
if (!row.trusted) blockers.push(Object.freeze({
itemIndex: row.rawRow.itemIndex,
reason: "trusted-restore-required"
}));
if (row.pageRecalculationRequired && !row.pageRecalculated) blockers.push(Object.freeze({
itemIndex: row.rawRow.itemIndex,
reason: row.pagePreflightCompleted && row.pageUnavailableReason === "empty-result"
? "page-result-empty"
: "page-recalculation-required"
}));
}
return Object.freeze(blockers);
}
function freezeRestoration(definition, rowsValue) {
const rows = Object.freeze([...rowsValue]);
const provisional = { kind: "named-playlist-restoration", schemaVersion: SCHEMA_VERSION, definition, rows };
const blockers = prepareBlockers(provisional);
return Object.freeze({ ...provisional, blockers, readyForPrepare: blockers.length === 0 });
}
function canPrepareRestoredRows(restoration) {
return prepareBlockers(restoration).length === 0;
}
function materializeTrustedPlaylist(restoration) {
if (!canPrepareRestoredRows(restoration)) {
throw new Error("Trusted restoration and fresh page calculation are required before PREPARE.");
}
return Object.freeze(restoration.rows.map(row => row.entry));
}
function pageStatusLabel(rawRow) {
if (!rawRow || rawRow.requiresTrustedRestore !== true) return "Invalid stored row";
if (!rawRow.pageState) return "No stored page; trusted restoration is required";
if (rawRow.historicalPageOutOfBounds) {
return `Stored page ${rawRow.pageText}; recalculate before PREPARE (20-page maximum)`;
}
return `Stored page ${rawRow.pageText}; recalculate before PREPARE`;
}
function normalizeMutationResponse(value) {
const keys = ["requestId", "operation", "programCode", "completedAt", "writeQuarantined"];
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
!["create", "replace", "delete"].includes(value.operation) ||
!programCode(value.programCode) || !isIsoTimestamp(value.completedAt) ||
typeof value.writeQuarantined !== "boolean") return null;
return Object.freeze({ ...value });
}
function normalizeError(value) {
const keys = [
"requestId", "operation", "programCode", "code", "message",
"retryable", "outcomeUnknown", "writeQuarantined"
];
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
!operationSet.has(value.operation) ||
(value.programCode !== "" && !programCode(value.programCode)) ||
!exactText(value.code, 64) || !exactText(value.message, 1024) ||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
typeof value.writeQuarantined !== "boolean" ||
(value.outcomeUnknown && (value.retryable || !value.writeQuarantined))) return null;
return Object.freeze({ ...value });
}
function isWriteBlocked(value) {
return value?.writeQuarantined === true;
}
return {
bridgeContract,
pagePlanBridgeContract,
operations,
createListRequest,
createLoadRequest,
createDefinitionRequest,
createReplaceRequest,
createDeleteRequest,
parseStoredPageText,
toLegacySevenFields,
legacyListText,
normalizeListResponse,
normalizeLoadResponse,
restoreRawRows,
preparePagePreflight,
createPagePlanRequest,
normalizePagePlanResponse,
normalizePagePlanError,
applyPagePlanResponse,
markPageRecalculated,
prepareBlockers,
canPrepareRestoredRows,
materializeTrustedPlaylist,
pageStatusLabel,
normalizeMutationResponse,
normalizeError,
isWriteBlocked
};
});

View File

@@ -0,0 +1,56 @@
.mbn-operator-catalog-dialog {
width: min(980px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
padding: 0;
border: 1px solid #29415b;
border-radius: 12px;
background: #081522;
color: #c6d4e3;
box-shadow: 0 24px 80px rgba(0, 0, 0, .55);
}
.mbn-operator-catalog-dialog::backdrop { background: rgba(2, 8, 14, .72); }
.mbn-operator-catalog-shell { display: grid; gap: 10px; padding: 14px; }
.mbn-operator-catalog-header { display: flex; align-items: center; gap: 10px; }
.mbn-operator-catalog-header h2 { flex: 1; margin: 0; font-size: 16px; }
.mbn-operator-catalog-status { color: #8bdcc4; font: 11px Consolas, monospace; }
.mbn-operator-catalog-dialog button,
.mbn-operator-catalog-dialog input,
.mbn-operator-catalog-dialog select {
min-height: 32px;
border: 1px solid #29415b;
border-radius: 6px;
background: #0c1f31;
color: #c6d4e3;
}
.mbn-operator-catalog-dialog button { padding: 0 10px; cursor: pointer; }
.mbn-operator-catalog-dialog button:disabled { cursor: not-allowed; opacity: .38; }
.mbn-operator-catalog-dialog input,
.mbn-operator-catalog-dialog select { min-width: 0; padding: 0 8px; }
.mbn-operator-catalog-tabs,
.mbn-operator-catalog-toolbar,
.mbn-operator-catalog-actions,
.mbn-operator-catalog-item-form { display: flex; flex-wrap: wrap; gap: 6px; }
.mbn-operator-catalog-tabs button[aria-pressed="true"] { border-color: #32d5a4; color: #68e2bd; }
.mbn-operator-catalog-toolbar [data-role="query"] { flex: 1 1 240px; }
.mbn-operator-catalog-body { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(360px, 1.2fr); gap: 10px; min-height: 360px; }
.mbn-operator-catalog-list,
.mbn-operator-catalog-draft { min-height: 0; max-height: 52vh; margin: 0; padding: 6px; overflow: auto; border: 1px solid #1c334a; border-radius: 8px; list-style: none; }
.mbn-operator-catalog-row button { width: 100%; margin-bottom: 4px; text-align: left; }
.mbn-operator-catalog-row button.selected { border-color: #32d5a4; background: rgba(50, 213, 164, .12); }
.mbn-operator-catalog-editor { display: grid; align-content: start; gap: 10px; }
.mbn-operator-catalog-identity { display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: center; }
.mbn-operator-catalog-identity output { color: #8bdcc4; font: 12px Consolas, monospace; }
.mbn-operator-catalog-item-form [data-role="stock-code"] { width: 120px; }
.mbn-operator-catalog-item-form [data-role="stock-name"] { flex: 1 1 180px; }
.mbn-operator-catalog-item-form [data-role="buy-amount"] { width: 130px; }
.mbn-operator-catalog-draft-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: 5px; align-items: center; padding: 5px; border-bottom: 1px solid #172b3e; }
.mbn-operator-catalog-draft-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mbn-operator-catalog-dialog .mbn-danger { border-color: rgba(255, 102, 128, .45); color: #ff9aad; }
.mbn-operator-catalog-error { margin: 0; padding: 8px; border: 1px solid rgba(255, 102, 128, .35); border-radius: 6px; color: #ff9aad; }
@media (max-width: 720px) {
.mbn-operator-catalog-body { grid-template-columns: 1fr; }
.mbn-operator-catalog-list,
.mbn-operator-catalog-draft { max-height: 30vh; }
}

1018
Web/operator-catalog-ui.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,761 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnOperatorCatalogWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const MAXIMUM_THEME_ITEMS = 240;
const MAXIMUM_RECOMMENDATIONS = 100;
const MAXIMUM_RESULTS = 500;
const MAXIMUM_QUERY_LENGTH = 64;
const MAXIMUM_WEB_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const themeCodePattern = /^[0-9]{8}$/;
const expertCodePattern = /^[0-9]{4}$/;
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const bridgeContract = Object.freeze({
requests: Object.freeze({
themeList: "request-theme-catalog-list",
expertList: "request-expert-catalog-list",
schemaPreflight: "request-operator-catalog-schema",
themeNextCode: "request-theme-catalog-next-code",
expertNextCode: "request-expert-catalog-next-code",
themeCreate: "create-theme-catalog",
themeReplace: "replace-theme-catalog",
themeDelete: "delete-theme-catalog",
expertCreate: "create-expert-catalog",
expertReplace: "replace-expert-catalog",
expertDelete: "delete-expert-catalog"
}),
events: Object.freeze({
themeList: "theme-catalog-list-results",
expertList: "expert-catalog-list-results",
schemaPreflight: "operator-catalog-schema-results",
themeNextCode: "theme-catalog-next-code-results",
expertNextCode: "expert-catalog-next-code-results",
mutation: "operator-catalog-mutation-results",
error: "operator-catalog-error"
})
});
function hasExactKeys(value, keys) {
if (!value || typeof value !== "object" || Array.isArray(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 hasOnlyKeys(value, keys) {
return value && typeof value === "object" && !Array.isArray(value) &&
Object.keys(value).every(key => keys.includes(key));
}
function isSafeCanonicalText(value, maximumLength, allowEmpty = false) {
return typeof value === "string" &&
(allowEmpty ? value.length >= 0 : value.length > 0) &&
value.length <= maximumLength && value === value.trim() &&
!unsafeTextPattern.test(value);
}
function requireRequestId(value) {
if (typeof value !== "string" || !requestIdPattern.test(value)) {
throw new TypeError("A safe operator-catalog request id is required.");
}
return value;
}
function normalizeQuery(value) {
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
const query = value.trim();
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query)
? query
: null;
}
function normalizeMarket(value) {
return value === "krx" || value === "nxt" ? value : null;
}
function normalizeSession(value, market) {
if (market === "krx") return value === "notApplicable" ? value : null;
if (market === "nxt") {
return value === "preMarket" || value === "afterMarket" ? value : null;
}
return null;
}
function normalizeThemeIdentity(value) {
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle"])) return null;
const market = normalizeMarket(value.market);
if (!market || !normalizeSession(value.session, market) ||
!themeCodePattern.test(value.themeCode) ||
!isSafeCanonicalText(value.themeTitle, 128) ||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
return Object.freeze({
market,
session: value.session,
themeCode: value.themeCode,
themeTitle: value.themeTitle
});
}
function normalizeThemeItem(value, expectedIndex, market) {
if (!hasExactKeys(value, ["inputIndex", "itemCode", "itemName"]) ||
value.inputIndex !== expectedIndex || !Number.isInteger(value.inputIndex) ||
!isSafeCanonicalText(value.itemCode, 13) ||
!isSafeCanonicalText(value.itemName, 200)) return null;
const prefix = value.itemCode[0];
const suffix = value.itemCode.slice(1);
if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) ||
(market === "krx" && prefix !== "P" && prefix !== "D") ||
(market === "nxt" && prefix !== "X" && prefix !== "T")) return null;
return Object.freeze({
inputIndex: value.inputIndex,
itemCode: value.itemCode,
itemName: value.itemName
});
}
function normalizeThemeItems(value, market) {
if (!Array.isArray(value) || value.length > MAXIMUM_THEME_ITEMS) return null;
const items = [];
const codes = new Set();
const names = new Set();
for (let index = 0; index < value.length; index += 1) {
const item = normalizeThemeItem(value[index], index, market);
if (!item || codes.has(item.itemCode) || names.has(item.itemName)) return null;
codes.add(item.itemCode);
names.add(item.itemName);
items.push(item);
}
return Object.freeze(items);
}
function normalizeThemeDraft(value) {
if (!hasExactKeys(value, ["market", "themeCode", "themeTitle", "items"])) return null;
const market = normalizeMarket(value.market);
if (!market || !themeCodePattern.test(value.themeCode) ||
!isSafeCanonicalText(value.themeTitle, 128) ||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
const items = normalizeThemeItems(value.items, market);
return items && Object.freeze({
market,
themeCode: value.themeCode,
themeTitle: value.themeTitle,
items
});
}
function normalizeExpertIdentity(value) {
if (!hasExactKeys(value, ["expertCode", "expertName"]) ||
!expertCodePattern.test(value.expertCode) ||
!isSafeCanonicalText(value.expertName, 128)) return null;
return Object.freeze({
expertCode: value.expertCode,
expertName: value.expertName
});
}
function normalizeRecommendation(value, expectedIndex) {
if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
value.playIndex !== expectedIndex || !Number.isInteger(value.playIndex) ||
!stockCodePattern.test(value.stockCode) ||
!isSafeCanonicalText(value.stockName, 200) ||
!Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0 ||
value.buyAmount > MAXIMUM_WEB_SAFE_INTEGER) return null;
return Object.freeze({
playIndex: value.playIndex,
stockCode: value.stockCode,
stockName: value.stockName,
buyAmount: value.buyAmount
});
}
function normalizeRecommendations(value) {
if (!Array.isArray(value) || value.length > MAXIMUM_RECOMMENDATIONS) return null;
const items = [];
const codes = new Set();
const names = new Set();
for (let index = 0; index < value.length; index += 1) {
const item = normalizeRecommendation(value[index], index);
if (!item || codes.has(item.stockCode) || names.has(item.stockName)) return null;
codes.add(item.stockCode);
names.add(item.stockName);
items.push(item);
}
return Object.freeze(items);
}
function normalizeExpertDraft(value) {
if (!hasExactKeys(value, ["expertCode", "expertName", "recommendations"])) return null;
const identity = normalizeExpertIdentity({
expertCode: value.expertCode,
expertName: value.expertName
});
const recommendations = normalizeRecommendations(value.recommendations);
return identity && recommendations && Object.freeze({
expertCode: identity.expertCode,
expertName: identity.expertName,
recommendations
});
}
function createThemeCatalogListRequest(requestId, query = "", nxtSession = "preMarket", maximumResults) {
requireRequestId(requestId);
const normalizedQuery = normalizeQuery(query);
if (normalizedQuery === null) throw new TypeError("A safe theme catalog query is required.");
if (nxtSession !== "preMarket" && nxtSession !== "afterMarket") {
throw new TypeError("An explicit NXT catalog session is required.");
}
if (maximumResults !== undefined &&
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
throw new RangeError("Theme catalog results must be limited from 1 through 500.");
}
const request = { requestId, query: normalizedQuery, nxtSession };
if (maximumResults !== undefined) request.maximumResults = maximumResults;
return Object.freeze(request);
}
function createExpertCatalogListRequest(requestId, query = "", maximumResults) {
requireRequestId(requestId);
const normalizedQuery = normalizeQuery(query);
if (normalizedQuery === null) throw new TypeError("A safe expert catalog query is required.");
if (maximumResults !== undefined &&
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
throw new RangeError("Expert catalog results must be limited from 1 through 500.");
}
const request = { requestId, query: normalizedQuery };
if (maximumResults !== undefined) request.maximumResults = maximumResults;
return Object.freeze(request);
}
function createSchemaPreflightRequest(requestId) {
return Object.freeze({ requestId: requireRequestId(requestId) });
}
function createThemeNextCodeRequest(requestId, market) {
requireRequestId(requestId);
if (!normalizeMarket(market)) throw new TypeError("A KRX or NXT market is required.");
return Object.freeze({ requestId, market });
}
function createExpertNextCodeRequest(requestId) {
return Object.freeze({ requestId: requireRequestId(requestId) });
}
function normalizeThemeCatalogSelection(value) {
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle", "source"])) return null;
const identity = normalizeThemeIdentity({
market: value.market,
session: value.session,
themeCode: value.themeCode,
themeTitle: value.themeTitle
});
if (!identity ||
(identity.market === "krx" && value.source !== "oracle") ||
(identity.market === "nxt" && value.source !== "mariaDb")) return null;
return Object.freeze({ ...identity, source: value.source });
}
function normalizeExpertCatalogSelection(value) {
if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) || value.source !== "oracle") {
return null;
}
const identity = normalizeExpertIdentity({
expertCode: value.expertCode,
expertName: value.expertName
});
return identity && Object.freeze({ ...identity, source: "oracle" });
}
function normalizeThemePreviewForEdit(value, selection) {
if (!hasExactKeys(value, [
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
]) || !requestIdPattern.test(value.requestId) ||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
value.totalRowCount > MAXIMUM_THEME_ITEMS || value.truncated !== false ||
!hasExactKeys(value.selection, ["market", "session", "themeCode", "title"])) return null;
const previewIdentity = normalizeThemeIdentity({
market: value.selection.market,
session: value.selection.session,
themeCode: value.selection.themeCode,
themeTitle: value.selection.title
});
if (!previewIdentity || ["market", "session", "themeCode", "themeTitle"]
.some(key => previewIdentity[key] !== selection[key])) return null;
if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
const items = [];
const codes = new Set();
const names = new Set();
let previousIndex = -1;
for (const raw of value.items) {
if (!hasExactKeys(raw, ["inputIndex", "itemCode", "itemName"]) ||
!Number.isInteger(raw.inputIndex) || raw.inputIndex <= previousIndex || raw.inputIndex > 9999 ||
!isSafeCanonicalText(raw.itemCode, 13) || !isSafeCanonicalText(raw.itemName, 200)) return null;
const prefix = raw.itemCode[0];
const suffix = raw.itemCode.slice(1);
if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) ||
(selection.market === "krx" && prefix !== "P" && prefix !== "D") ||
(selection.market === "nxt" && prefix !== "X" && prefix !== "T") ||
codes.has(raw.itemCode) || names.has(raw.itemName)) return null;
previousIndex = raw.inputIndex;
codes.add(raw.itemCode);
names.add(raw.itemName);
items.push(Object.freeze({
inputIndex: items.length,
itemCode: raw.itemCode,
itemName: raw.itemName
}));
}
return Object.freeze(items);
}
function normalizeExpertPreviewForEdit(value, selection) {
if (!hasExactKeys(value, [
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
]) || !requestIdPattern.test(value.requestId) ||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
value.totalRowCount > MAXIMUM_RECOMMENDATIONS || value.truncated !== false) return null;
const previewIdentity = normalizeExpertIdentity(value.selection);
if (!previewIdentity || previewIdentity.expertCode !== selection.expertCode ||
previewIdentity.expertName !== selection.expertName) return null;
if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
const items = [];
const codes = new Set();
const names = new Set();
let previousIndex = -1;
for (const raw of value.items) {
if (!hasExactKeys(raw, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
!Number.isInteger(raw.playIndex) || raw.playIndex <= previousIndex || raw.playIndex > 9999 ||
!stockCodePattern.test(raw.stockCode) || !isSafeCanonicalText(raw.stockName, 200) ||
!Number.isSafeInteger(raw.buyAmount) || raw.buyAmount <= 0 ||
codes.has(raw.stockCode) || names.has(raw.stockName)) return null;
previousIndex = raw.playIndex;
codes.add(raw.stockCode);
names.add(raw.stockName);
items.push(Object.freeze({
playIndex: items.length,
stockCode: raw.stockCode,
stockName: raw.stockName,
buyAmount: raw.buyAmount
}));
}
return Object.freeze(items);
}
function createThemeEditDraft(selectionValue, previewValue) {
const selection = normalizeThemeCatalogSelection(selectionValue);
if (!selection) throw new TypeError("A typed theme catalog selection is required.");
const items = normalizeThemePreviewForEdit(previewValue, selection);
if (!items) throw new TypeError("A complete, correlated theme preview is required for editing.");
return {
expectedIdentity: {
market: selection.market,
session: selection.session,
themeCode: selection.themeCode,
themeTitle: selection.themeTitle
},
draft: {
market: selection.market,
themeCode: selection.themeCode,
themeTitle: selection.themeTitle,
items: items.map(item => ({ ...item }))
}
};
}
function createExpertEditDraft(selectionValue, previewValue) {
const selection = normalizeExpertCatalogSelection(selectionValue);
if (!selection) throw new TypeError("A typed expert catalog selection is required.");
const recommendations = normalizeExpertPreviewForEdit(previewValue, selection);
if (!recommendations) throw new TypeError("A complete, correlated expert preview is required for editing.");
return {
expectedIdentity: {
expertCode: selection.expertCode,
expertName: selection.expertName
},
draft: {
expertCode: selection.expertCode,
expertName: selection.expertName,
recommendations: recommendations.map(item => ({ ...item }))
}
};
}
function createBlankThemeDraft(market, themeCode) {
if (!normalizeMarket(market) || !themeCodePattern.test(themeCode)) {
throw new TypeError("A typed market and suggested eight-digit theme code are required.");
}
return { market, themeCode, themeTitle: "", items: [] };
}
function createBlankExpertDraft(expertCode) {
if (!expertCodePattern.test(expertCode)) {
throw new TypeError("A suggested four-digit expert code is required.");
}
return { expertCode, expertName: "", recommendations: [] };
}
function cloneThemeDraft(draft) {
const normalized = normalizeThemeDraft(draft);
return normalized && {
market: normalized.market,
themeCode: normalized.themeCode,
themeTitle: normalized.themeTitle,
items: normalized.items.map(item => ({ ...item }))
};
}
function cloneExpertDraft(draft) {
const normalized = normalizeExpertDraft(draft);
return normalized && {
expertCode: normalized.expertCode,
expertName: normalized.expertName,
recommendations: normalized.recommendations.map(item => ({ ...item }))
};
}
function createThemeCreateRequest(requestId, draftValue) {
requireRequestId(requestId);
const draft = cloneThemeDraft(draftValue);
if (!draft) throw new TypeError("A complete theme draft is required.");
return Object.freeze({ requestId, draft });
}
function createThemeReplaceRequest(requestId, editValue) {
requireRequestId(requestId);
if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) {
throw new TypeError("An explicit theme edit identity and draft are required.");
}
const identity = normalizeThemeIdentity(editValue.expectedIdentity);
const draft = cloneThemeDraft(editValue.draft);
if (!identity || !draft || identity.market !== draft.market ||
identity.themeCode !== draft.themeCode) {
throw new TypeError("The theme edit identity does not match its draft.");
}
return Object.freeze({
requestId,
expectedIdentity: { ...identity },
newTitle: draft.themeTitle,
items: draft.items
});
}
function createThemeDeleteRequest(requestId, identityValue) {
requireRequestId(requestId);
const identity = normalizeThemeIdentity(identityValue);
if (!identity) throw new TypeError("An explicit theme identity is required.");
return Object.freeze({ requestId, identity: { ...identity } });
}
function createExpertCreateRequest(requestId, draftValue) {
requireRequestId(requestId);
const draft = cloneExpertDraft(draftValue);
if (!draft) throw new TypeError("A complete expert draft is required.");
return Object.freeze({ requestId, draft });
}
function createExpertReplaceRequest(requestId, editValue) {
requireRequestId(requestId);
if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) {
throw new TypeError("An explicit expert edit identity and draft are required.");
}
const identity = normalizeExpertIdentity(editValue.expectedIdentity);
const draft = cloneExpertDraft(editValue.draft);
if (!identity || !draft || identity.expertCode !== draft.expertCode) {
throw new TypeError("The expert edit identity does not match its draft.");
}
return Object.freeze({
requestId,
expectedIdentity: { ...identity },
newName: draft.expertName,
recommendations: draft.recommendations
});
}
function createExpertDeleteRequest(requestId, identityValue) {
requireRequestId(requestId);
const identity = normalizeExpertIdentity(identityValue);
if (!identity) throw new TypeError("An explicit expert identity is required.");
return Object.freeze({ requestId, identity: { ...identity } });
}
function normalizeThemeListResult(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "query", "nxtSession", "retrievedAt", "totalRowCount", "truncated",
"canMutate", "writeQuarantined", "results"
]) || !requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query ||
(value.nxtSession !== "preMarket" && value.nxtSession !== "afterMarket") ||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) ||
value.results.length !== value.totalRowCount) return null;
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
value.query !== expectedRequest.query || value.nxtSession !== expectedRequest.nxtSession ||
value.results.length > (expectedRequest.maximumResults ?? 100))) return null;
const results = [];
const identities = new Set();
const titles = new Set();
for (const raw of value.results) {
const item = normalizeThemeCatalogSelection(raw);
const key = item && `${item.market}:${item.themeCode}`;
const titleKey = item && `${item.market}:${item.themeTitle}`;
if (!item || identities.has(key) || titles.has(titleKey)) return null;
identities.add(key);
titles.add(titleKey);
results.push(item);
}
return Object.freeze({ ...value, results: Object.freeze(results) });
}
function normalizeExpertListResult(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "canMutate",
"writeQuarantined", "results"
]) || !requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query ||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) ||
value.results.length !== value.totalRowCount) return null;
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
value.query !== expectedRequest.query ||
value.results.length > (expectedRequest.maximumResults ?? 100))) return null;
const results = [];
const codes = new Set();
const names = new Set();
for (const raw of value.results) {
const item = normalizeExpertCatalogSelection(raw);
if (!item || codes.has(item.expertCode) || names.has(item.expertName)) return null;
codes.add(item.expertCode);
names.add(item.expertName);
results.push(item);
}
return Object.freeze({ ...value, results: Object.freeze(results) });
}
function normalizeThemeNextCodeResult(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "market", "themeCode", "canMutate", "writeQuarantined"
]) || !requestIdPattern.test(value.requestId) || !normalizeMarket(value.market) ||
!themeCodePattern.test(value.themeCode) || typeof value.canMutate !== "boolean" ||
typeof value.writeQuarantined !== "boolean" ||
(value.writeQuarantined && value.canMutate)) return null;
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
value.market !== expectedRequest.market)) return null;
return Object.freeze({ ...value });
}
function normalizeExpertNextCodeResult(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "expertCode", "canMutate", "writeQuarantined"
]) || !requestIdPattern.test(value.requestId) || !expertCodePattern.test(value.expertCode) ||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
(value.writeQuarantined && value.canMutate)) return null;
if (expectedRequest && value.requestId !== expectedRequest.requestId) return null;
return Object.freeze({ ...value });
}
function normalizeSchemaResult(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "validatedAt", "validatedSources", "themeCanMutate", "expertCanMutate",
"writeQuarantined"
]) || !requestIdPattern.test(value.requestId) ||
!isSafeCanonicalText(value.validatedAt, 64) || !Number.isFinite(Date.parse(value.validatedAt)) ||
!Array.isArray(value.validatedSources) || value.validatedSources.length !== 2 ||
value.validatedSources[0] !== "oracle" || value.validatedSources[1] !== "mariaDb" ||
typeof value.themeCanMutate !== "boolean" || typeof value.expertCanMutate !== "boolean" ||
typeof value.writeQuarantined !== "boolean" ||
(value.writeQuarantined && (value.themeCanMutate || value.expertCanMutate))) return null;
if (expectedRequest && value.requestId !== expectedRequest.requestId) return null;
return Object.freeze({ ...value, validatedSources: Object.freeze([...value.validatedSources]) });
}
const mutationOperations = new Set(["create", "replace", "delete"]);
function normalizeMutationResult(value, expected) {
if (!hasExactKeys(value, [
"requestId", "entity", "operation", "identityCode", "operationId", "committedAt",
"writeQuarantined"
]) || !requestIdPattern.test(value.requestId) ||
(value.entity !== "theme" && value.entity !== "expert") ||
!mutationOperations.has(value.operation) ||
!(value.entity === "theme" ? themeCodePattern : expertCodePattern).test(value.identityCode) ||
!uuidPattern.test(value.operationId) || !isSafeCanonicalText(value.committedAt, 64) ||
!Number.isFinite(Date.parse(value.committedAt)) || value.writeQuarantined !== false) return null;
if (expected && ["requestId", "entity", "operation", "identityCode"]
.some(key => value[key] !== expected[key])) return null;
return Object.freeze({ ...value });
}
const errorCodes = new Set([
"INVALID_REQUEST", "DATABASE_UNAVAILABLE", "INVALID_DATA", "INVALID_SCHEMA", "DATABASE_ERROR",
"PLAYOUT_PRIORITY", "PLAYOUT_ACTIVE", "DATABASE_BUSY", "WRITE_BUSY", "CONFLICT", "WRITE_FAILED",
"CANCELLED", "OUTCOME_UNKNOWN"
]);
function normalizeCatalogError(value, expected) {
if (!hasExactKeys(value, [
"requestId", "entity", "operation", "identityCode", "code", "message", "retryable",
"outcomeUnknown", "writeQuarantined"
]) || (value.requestId !== "" && !requestIdPattern.test(value.requestId)) ||
!["theme", "expert", "catalog"].includes(value.entity) ||
!["list", "nextCode", "create", "replace", "delete", "schemaPreflight"].includes(value.operation) ||
typeof value.identityCode !== "string" || value.identityCode.length > 8 ||
!errorCodes.has(value.code) || !isSafeCanonicalText(value.message, 512) ||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
typeof value.writeQuarantined !== "boolean" ||
(value.outcomeUnknown && (value.retryable || !value.writeQuarantined)) ||
(mutationOperations.has(value.operation) && value.retryable)) return null;
if (expected && ["requestId", "entity", "operation", "identityCode"]
.some(key => value[key] !== expected[key])) return null;
return Object.freeze({ ...value });
}
function describeMutationRequest(type, payload) {
try {
switch (type) {
case bridgeContract.requests.themeCreate: {
const request = createThemeCreateRequest(payload?.requestId, payload?.draft);
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "create",
identityCode: request.draft.themeCode });
}
case bridgeContract.requests.themeReplace: {
if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newTitle", "items"])) return null;
const request = createThemeReplaceRequest(payload.requestId, {
expectedIdentity: payload.expectedIdentity,
draft: {
market: payload.expectedIdentity?.market,
themeCode: payload.expectedIdentity?.themeCode,
themeTitle: payload.newTitle,
items: payload.items
}
});
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "replace",
identityCode: request.expectedIdentity.themeCode });
}
case bridgeContract.requests.themeDelete: {
if (!hasExactKeys(payload, ["requestId", "identity"])) return null;
const request = createThemeDeleteRequest(payload.requestId, payload.identity);
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "delete",
identityCode: request.identity.themeCode });
}
case bridgeContract.requests.expertCreate: {
const request = createExpertCreateRequest(payload?.requestId, payload?.draft);
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "create",
identityCode: request.draft.expertCode });
}
case bridgeContract.requests.expertReplace: {
if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newName", "recommendations"])) return null;
const request = createExpertReplaceRequest(payload.requestId, {
expectedIdentity: payload.expectedIdentity,
draft: {
expertCode: payload.expectedIdentity?.expertCode,
expertName: payload.newName,
recommendations: payload.recommendations
}
});
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "replace",
identityCode: request.expectedIdentity.expertCode });
}
case bridgeContract.requests.expertDelete: {
if (!hasExactKeys(payload, ["requestId", "identity"])) return null;
const request = createExpertDeleteRequest(payload.requestId, payload.identity);
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "delete",
identityCode: request.identity.expertCode });
}
default:
return null;
}
} catch {
return null;
}
}
function createMutationCoordinator() {
let active = null;
let quarantined = false;
return Object.freeze({
begin(type, payload) {
if (quarantined) throw new Error("Operator catalog writes are quarantined for this process.");
if (active) throw new Error("Another operator catalog write is already active.");
const description = describeMutationRequest(type, payload);
if (!description) throw new TypeError("A valid operator catalog mutation request is required.");
active = description;
return description;
},
acceptResult(payload) {
if (!active) return null;
const result = normalizeMutationResult(payload, active);
if (!result) return null;
active = null;
return result;
},
acceptError(payload) {
if (!active) return null;
const error = normalizeCatalogError(payload, active);
if (!error) return null;
if (error.outcomeUnknown || error.writeQuarantined) quarantined = true;
active = null;
return error;
},
loseCorrelation() {
if (active) quarantined = true;
active = null;
},
getState() {
return Object.freeze({ active, quarantined });
}
});
}
return {
bridgeContract,
limits: Object.freeze({
maximumThemeItems: MAXIMUM_THEME_ITEMS,
maximumRecommendations: MAXIMUM_RECOMMENDATIONS,
maximumResults: MAXIMUM_RESULTS,
maximumQueryLength: MAXIMUM_QUERY_LENGTH
}),
normalizeThemeIdentity,
normalizeThemeDraft,
normalizeExpertIdentity,
normalizeExpertDraft,
createThemeCatalogListRequest,
createExpertCatalogListRequest,
createSchemaPreflightRequest,
createThemeNextCodeRequest,
createExpertNextCodeRequest,
normalizeThemeCatalogSelection,
normalizeExpertCatalogSelection,
createThemeEditDraft,
createExpertEditDraft,
createBlankThemeDraft,
createBlankExpertDraft,
createThemeCreateRequest,
createThemeReplaceRequest,
createThemeDeleteRequest,
createExpertCreateRequest,
createExpertReplaceRequest,
createExpertDeleteRequest,
normalizeThemeListResult,
normalizeExpertListResult,
normalizeThemeNextCodeResult,
normalizeExpertNextCodeResult,
normalizeSchemaResult,
normalizeMutationResult,
normalizeCatalogError,
describeMutationRequest,
createMutationCoordinator
};
});

440
Web/operator-workflow.js Normal file
View File

@@ -0,0 +1,440 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnOperatorWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const MAX_TEXT_LENGTH = 128;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const runtimeStockAsset = Object.freeze({
relativePath: "bin/Debug/Res/종목.ini",
sha256: "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20"
});
function freezeCut(cut) {
return Object.freeze({
...cut,
section: cut.group,
alias: cut.aliases[0],
cutCode: cut.aliases[0],
aliases: Object.freeze([...cut.aliases])
});
}
// bin/Debug/Res/종목.ini (SHA-256 45DFB180...940409E20), in runtime order.
// The two '-' separator rows are deliberately absent. The source RES copy differs
// at the after-hours label, so the operator-visible runtime asset is authoritative.
const stockCuts = Object.freeze([
freezeCut({ id: "basic-expected", label: "1열판기본_예상체결가", group: "1열판 기본", detail: "예상체결가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "EXPECTED_OPENING" }),
freezeCut({ id: "basic-current", label: "1열판기본_현재가", group: "1열판 기본", detail: "현재가", builderKey: "s5001", aliases: ["5001", "N5001"], kind: "basic", subtype: "CURRENT" }),
freezeCut({ id: "basic-after-hours", label: "1열판기본_시간외단일가", group: "1열판 기본", detail: "시간외단일가", builderKey: "s5001", aliases: ["5001"], kind: "basic", subtype: "AFTER_HOURS_SINGLE_PRICE" }),
freezeCut({ id: "detail-open", label: "1열판상세_시가", group: "1열판 상세", detail: "시가", builderKey: "s5006", aliases: ["5006"], kind: "open-detail", subtype: "시가" }),
freezeCut({ id: "detail-face-value", label: "1열판상세_액면가", group: "1열판 상세", detail: "액면가", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "FaceValue" }),
freezeCut({ id: "detail-pbr", label: "1열판상세_PBR", group: "1열판 상세", detail: "PBR", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Valuation" }),
freezeCut({ id: "detail-volume", label: "1열판상세_거래량", group: "1열판 상세", detail: "거래량", builderKey: "s5011", aliases: ["5011"], kind: "detail", subtype: "Volume" }),
freezeCut({ id: "yield-5d", label: "수익률그래프_5일", group: "수익률 그래프", detail: "5일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "5일" }),
freezeCut({ id: "yield-20d", label: "수익률그래프_20일", group: "수익률 그래프", detail: "20일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "20일" }),
freezeCut({ id: "yield-60d", label: "수익률그래프_60일", group: "수익률 그래프", detail: "60일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "60일" }),
freezeCut({ id: "yield-120d", label: "수익률그래프_120일", group: "수익률 그래프", detail: "120일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "120일" }),
freezeCut({ id: "yield-240d", label: "수익률그래프_240일", group: "수익률 그래프", detail: "240일", builderKey: "s5086", aliases: ["5086"], kind: "yield", subtype: "240일" }),
freezeCut({ id: "candle-daily", label: "캔들그래프_일봉", group: "캔들 그래프", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-5d", label: "캔들그래프_5일", group: "캔들 그래프", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-20d", label: "캔들그래프_20일", group: "캔들 그래프", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-60d", label: "캔들그래프_60일", group: "캔들 그래프", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-120d", label: "캔들그래프_120일", group: "캔들 그래프", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-240d", label: "캔들그래프_240일", group: "캔들 그래프", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "PRICE" }),
freezeCut({ id: "candle-volume-daily", label: "캔들그래프(거래량)_일봉", group: "캔들 그래프 · 거래량", detail: "일봉", builderKey: "s8010", aliases: ["8035"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-5d", label: "캔들그래프(거래량)_5일", group: "캔들 그래프 · 거래량", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-20d", label: "캔들그래프(거래량)_20일", group: "캔들 그래프 · 거래량", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-60d", label: "캔들그래프(거래량)_60일", group: "캔들 그래프 · 거래량", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-120d", label: "캔들그래프(거래량)_120일", group: "캔들 그래프 · 거래량", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-volume-240d", label: "캔들그래프(거래량)_240일", group: "캔들 그래프 · 거래량", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "VOLUME" }),
freezeCut({ id: "candle-expected-5d", label: "캔들그래프(예상체결가)_5일", group: "캔들 그래프 · 예상체결가", detail: "5일", builderKey: "s8010", aliases: ["8061"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-20d", label: "캔들그래프(예상체결가)_20일", group: "캔들 그래프 · 예상체결가", detail: "20일", builderKey: "s8010", aliases: ["8040"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-60d", label: "캔들그래프(예상체결가)_60일", group: "캔들 그래프 · 예상체결가", detail: "60일", builderKey: "s8010", aliases: ["8046"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-120d", label: "캔들그래프(예상체결가)_120일", group: "캔들 그래프 · 예상체결가", detail: "120일", builderKey: "s8010", aliases: ["8051"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "candle-expected-240d", label: "캔들그래프(예상체결가)_240일", group: "캔들 그래프 · 예상체결가", detail: "240일", builderKey: "s8010", aliases: ["8056"], kind: "candle", subtype: "EXPECTED" }),
freezeCut({ id: "order-book", label: "호가창_표그래프", group: "표 그래프", detail: "호가창", builderKey: "s8003", aliases: ["8003"], kind: "order-book", subtype: "TABLE_GRAPH" }),
freezeCut({ id: "traders", label: "거래원_표그래프", group: "표 그래프", detail: "거래원", builderKey: "s5037", aliases: ["5037"], kind: "traders", subtype: "TABLE_GRAPH" })
]);
if (stockCuts.length !== 31 || new Set(stockCuts.map(cut => cut.id)).size !== 31) {
throw new Error("The legacy stock workflow must contain exactly 31 unique cut variants.");
}
function manualAction(id, label, builderKey, alias, table, prerequisite) {
return Object.freeze({
id,
label,
builderKey,
alias,
cutCode: alias,
aliases: Object.freeze([alias]),
available: false,
autoAdd: false,
status: "unavailable",
prerequisiteTable: table,
prerequisites: Object.freeze([
`선택 종목에 대한 ${table} 수동 입력 데이터`,
prerequisite,
"수동 데이터 검증과 명시적 플레이리스트 추가 흐름"
])
});
}
// These buttons opened GraphE/INPUT_* manual-data workflows in the source program.
// A stock search result alone is not sufficient input, so they must never auto-add a cut.
const manualStockActions = Object.freeze([
manualAction("manual-revenue-mix", "주요매출 구성", "s5076", "5076", "INPUT_PIE", "구성 항목과 비중 및 기준일 입력"),
manualAction("manual-growth-metrics", "성장성 지표", "s5079", "5079", "INPUT_GROW", "성장성 지표 값과 분기 입력"),
manualAction("manual-sales", "매출액", "s5080", "5080", "INPUT_SELL", "기간별 매출액 수동 값 입력"),
manualAction("manual-operating-profit", "영업이익", "s5081", "5081", "INPUT_PROFIT", "기간별 영업이익 수동 값 입력")
]);
const cutsById = new Map(stockCuts.map(cut => [cut.id, cut]));
function valueFrom(raw, names) {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(raw, name) && raw[name] !== undefined && raw[name] !== null) {
return raw[name];
}
}
const keys = Object.keys(raw);
for (const name of names) {
const key = keys.find(candidate => candidate.toLocaleUpperCase("en-US") === name.toLocaleUpperCase("en-US"));
if (key !== undefined && raw[key] !== undefined && raw[key] !== null) return raw[key];
}
return undefined;
}
function cleanText(value) {
if (typeof value !== "string") return null;
const text = value.trim();
return text.length > 0 && text.length <= MAX_TEXT_LENGTH && !controlCharacterPattern.test(text)
? text
: null;
}
function normalizeMarket(value) {
const text = String(value ?? "")
.trim()
.toLocaleUpperCase("en-US")
.replace(/[\s-]+/g, "_");
const isNxt = text.includes("NXT");
if (["KOSPI", "KOSPI_STOCK", "코스피", "코스피종목", "NXT_KOSPI", "KOSPI_NXT", "코스피_NXT", "NXT코스피"].includes(text)) {
return { market: "kospi", isNxt };
}
if (["KOSDAQ", "KOSDAQ_STOCK", "코스닥", "코스닥종목", "NXT_KOSDAQ", "KOSDAQ_NXT", "코스닥_NXT", "NXT코스닥"].includes(text)) {
return { market: "kosdaq", isNxt };
}
return null;
}
function normalizeBoolean(value) {
if (value === true || value === false) return value;
if (value === 1 || value === "1") return true;
if (value === 0 || value === "0") return false;
return null;
}
function validateStockResult(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { valid: false, error: "stock-object-required", value: null };
}
const marketValue = valueFrom(raw, ["market", "MARKET", "marketName", "MARKET_NAME", "groupCode", "GROUP_CODE"]);
const normalizedMarket = normalizeMarket(marketValue);
if (!normalizedMarket) return { valid: false, error: "unsupported-market", value: null };
const rawStockName = cleanText(valueFrom(raw, [
"stockName", "STOCK_NAME", "F_STOCK_NAME", "F_STOCK_WANNAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME"
]));
const rawDisplayName = cleanText(valueFrom(raw, [
"displayName", "DISPLAY_NAME", "F_STOCK_WANNAME", "F_STOCK_NAME", "F_KNAM", "F_INPUT_NAME", "NAME", "WANNAME"
]));
if (!rawStockName && !rawDisplayName) {
return { valid: false, error: "stock-name-required", value: null };
}
const rawCode = valueFrom(raw, [
"stockCode", "STOCK_CODE", "F_STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE"
]);
const stockCode = typeof rawCode === "number" && Number.isSafeInteger(rawCode) && rawCode >= 0
? String(rawCode)
: cleanText(rawCode);
if (!stockCode || !stockCodePattern.test(stockCode)) {
return { valid: false, error: "invalid-stock-code", value: null };
}
const explicitNxtValue = valueFrom(raw, ["isNxt", "IS_NXT", "nxt", "NXT"]);
const explicitNxt = explicitNxtValue === undefined ? null : normalizeBoolean(explicitNxtValue);
if (explicitNxtValue !== undefined && explicitNxt === null) {
return { valid: false, error: "invalid-nxt-flag", value: null };
}
const nameImpliesNxt = `${rawStockName || ""} ${rawDisplayName || ""}`.includes("(NXT)");
const inferredNxt = normalizedMarket.isNxt || nameImpliesNxt;
if (explicitNxt === false && inferredNxt) {
return { valid: false, error: "inconsistent-nxt-selection", value: null };
}
const isNxt = explicitNxt === true || inferredNxt;
const withoutNxtSuffix = value => value.replace(/\s*\(NXT\)\s*/g, "").trim();
const stockName = cleanText(withoutNxtSuffix(rawStockName || rawDisplayName));
if (!stockName) return { valid: false, error: "stock-name-required", value: null };
const baseDisplayName = cleanText(rawDisplayName || rawStockName);
if (!baseDisplayName) return { valid: false, error: "display-name-required", value: null };
const displayName = isNxt
? `${withoutNxtSuffix(baseDisplayName)}(NXT)`
: baseDisplayName;
return {
valid: true,
error: null,
value: Object.freeze({
market: normalizedMarket.market,
stockName,
displayName,
stockCode,
isNxt
})
};
}
function normalizeStockResult(raw) {
const validation = validateStockResult(raw);
return validation.valid ? validation.value : null;
}
function getStockCut(cutId) {
return typeof cutId === "string" ? cutsById.get(cutId) || null : null;
}
function isStockCutAllowed(stock, cutId) {
const normalized = normalizeStockResult(stock);
const cut = getStockCut(cutId);
return Boolean(normalized && cut && (!normalized.isNxt || cut.id === "basic-current"));
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Stock playlist options are required.");
}
const id = cleanText(options.id);
if (!id || !identifierPattern.test(id)) {
throw new TypeError("A safe stock playlist entry id is required.");
}
const ma5 = options.ma5 === undefined ? false : normalizeBoolean(options.ma5);
const ma20 = options.ma20 === undefined ? false : normalizeBoolean(options.ma20);
if (ma5 === null || ma20 === null) {
throw new TypeError("Moving-average flags must be boolean values.");
}
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, ma5, ma20, fadeDuration };
}
function marketGroup(stock, stockSuffix) {
const prefix = stock.isNxt ? "NXT_" : "";
return `${prefix}${stock.market.toLocaleUpperCase("en-US")}${stockSuffix}`;
}
function selectionFor(stock, cut, movingAverages) {
const common = {
subject: stock.displayName,
dataCode: stock.stockCode
};
switch (cut.kind) {
case "basic":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "1열판기본",
subtype: cut.subtype
};
case "open-detail":
return {
groupCode: marketGroup(stock, "_STOCK"),
...common,
graphicType: "1열판상세",
subtype: cut.subtype
};
case "detail":
return {
groupCode: marketGroup(stock, "_STOCK"),
...common,
graphicType: "1열판상세",
subtype: cut.subtype
};
case "yield":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "수익률그래프",
subtype: cut.subtype
};
case "candle": {
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
return {
groupCode: marketGroup(stock, "_STOCK"),
...common,
graphicType: flags.join(","),
subtype: cut.subtype
};
}
case "order-book":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "ORDER_BOOK",
subtype: "TABLE_GRAPH"
};
case "traders":
return {
groupCode: marketGroup(stock, ""),
...common,
graphicType: "TRADER",
subtype: "TABLE_GRAPH"
};
default:
throw new Error("The stock cut mapping is unavailable.");
}
}
function createStockPlaylistEntry(stock, cutId, options) {
const normalized = normalizeStockResult(stock);
if (!normalized) throw new TypeError("A valid selected stock is required.");
const cut = getStockCut(cutId);
if (!cut) throw new RangeError("The selected stock cut is unknown.");
if (!isStockCutAllowed(normalized, cut.id)) {
throw new RangeError("NXT stocks support only 1열판기본_현재가.");
}
const normalizedOptions = requireOptions(options);
const movingAverages = Object.freeze({
ma5: cut.kind === "candle" && normalizedOptions.ma5,
ma20: cut.kind === "candle" && normalizedOptions.ma20
});
const selection = Object.freeze(selectionFor(normalized, cut, movingAverages));
const code = normalized.isNxt ? "N5001" : cut.aliases[0];
const operator = Object.freeze({
source: "legacy-stock-workflow",
cutId: cut.id,
legacyLabel: cut.label,
legacyGroup: cut.group,
legacyDetail: cut.detail,
stock: normalized,
movingAverages
});
return {
id: normalizedOptions.id,
builderKey: cut.builderKey,
code,
aliases: [...cut.aliases],
title: normalized.displayName,
detail: cut.label,
category: "stock",
market: normalized.market,
stockName: normalized.stockName,
displayName: normalized.displayName,
stockCode: normalized.stockCode,
isNxt: normalized.isNxt,
selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator
};
}
function sameStringArray(left, right) {
return Array.isArray(left) && Array.isArray(right) &&
left.length === right.length && left.every((value, index) => value === right[index]);
}
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 restoreStockPlaylistEntry(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-stock-workflow" ||
typeof operator.cutId !== "string" ||
!operator.movingAverages || typeof operator.movingAverages !== "object" ||
typeof operator.movingAverages.ma5 !== "boolean" ||
typeof operator.movingAverages.ma20 !== "boolean") {
return null;
}
let recreated;
try {
recreated = createStockPlaylistEntry(operator.stock, operator.cutId, {
id: value.id,
ma5: operator.movingAverages.ma5,
ma20: operator.movingAverages.ma20,
fadeDuration: value.fadeDuration
});
} catch {
return null;
}
const trustedScalarKeys = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"stockName", "displayName", "stockCode", "isNxt", "fadeDuration",
"graphicType", "subtype"
];
if (!trustedScalarKeys.every(key => value[key] === recreated[key]) ||
!sameStringArray(value.aliases, recreated.aliases) ||
!sameSelection(value.selection, recreated.selection) ||
operator.legacyLabel !== recreated.operator.legacyLabel ||
operator.legacyGroup !== recreated.operator.legacyGroup ||
operator.legacyDetail !== recreated.operator.legacyDetail) {
return null;
}
return { ...recreated, enabled: value.enabled };
}
return {
runtimeStockAsset,
stockCuts,
manualStockActions,
normalizeMarket,
validateStockResult,
normalizeStockResult,
getStockCut,
isStockCutAllowed,
createStockPlaylistEntry,
restoreStockPlaylistEntry
};
});

521
Web/overseas-workflow.js Normal file
View File

@@ -0,0 +1,521 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnOverseasWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const DEFAULT_MAXIMUM_RESULTS = 100;
const MAXIMUM_RESULTS = 500;
const MAXIMUM_QUERY_LENGTH = 64;
const SCHEMA_VERSION = 1;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const symbolPattern = /^[A-Za-z0-9@._$:-]{1,64}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const bridgeContracts = Object.freeze({
industry: Object.freeze({
requestType: "search-overseas-industries",
resultType: "overseas-industry-results",
errorType: "overseas-industry-error",
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
maximumResults: MAXIMUM_RESULTS,
maximumQueryLength: MAXIMUM_QUERY_LENGTH
}),
stock: Object.freeze({
requestType: "search-world-stocks",
resultType: "world-stock-search-results",
errorType: "world-stock-search-error",
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
maximumResults: MAXIMUM_RESULTS,
maximumQueryLength: MAXIMUM_QUERY_LENGTH
})
});
const sourceContract = Object.freeze({
originalControl: "Control/UC5.cs",
industry: Object.freeze({
fdtc: "0",
nationCodes: Object.freeze(["US"]),
lookupField: "F_KNAM",
currentCutCode: "5001"
}),
stock: Object.freeze({
fdtc: "1",
nationCodes: Object.freeze(["US", "TW"]),
lookupField: "F_INPUT_NAME",
currentCutCode: "5001",
candleCutCodes: Object.freeze(["8061", "8040", "8046", "8051", "8056"])
})
});
function action(id, label, builderKey, code, kind, periodDays = null) {
return Object.freeze({
id,
label,
builderKey,
code,
aliases: Object.freeze([code]),
kind,
periodDays
});
}
const industryActions = Object.freeze([
action("industry-current", "\uBBF8\uAD6D \uD574\uC678\uC5C5\uC885 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current")
]);
const stockActions = Object.freeze([
action("stock-current", "\uD574\uC678\uC885\uBAA9 \uD604\uC7AC\uAC00 1\uC5F4\uD310", "s5001", "5001", "current"),
action("stock-candle-5d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 5\uC77C", "s8010", "8061", "candle", 5),
action("stock-candle-20d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 20\uC77C", "s8010", "8040", "candle", 20),
action("stock-candle-60d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 60\uC77C", "s8010", "8046", "candle", 60),
action("stock-candle-120d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 120\uC77C", "s8010", "8051", "candle", 120),
action("stock-candle-240d", "\uD574\uC678\uC885\uBAA9 \uCE94\uB4E4 240\uC77C", "s8010", "8056", "candle", 240)
]);
const actionsById = new Map(
[...industryActions, ...stockActions].map(value => [value.id, value]));
function hasExactKeys(value, expected) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const actual = Object.keys(value).sort();
const required = [...expected].sort();
return actual.length === required.length && actual.every((key, index) => key === required[index]);
}
function isSafeCanonicalText(value, maximumLength, allowEmpty = false) {
return typeof value === "string" &&
value.length <= maximumLength &&
(allowEmpty || value.length > 0) &&
value === value.trim() &&
!unsafeTextPattern.test(value);
}
function normalizeQuery(value) {
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
const query = value.trim();
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null;
}
function normalizeSymbol(value) {
return isSafeCanonicalText(value, 64) && symbolPattern.test(value) ? value : null;
}
function normalizeIndustry(value) {
if (!hasExactKeys(value, [
"koreanName", "inputName", "symbol", "nationCode", "fdtc"
])) return null;
if (!isSafeCanonicalText(value.koreanName, 200) || value.koreanName.includes("|") ||
!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
!normalizeSymbol(value.symbol) || value.nationCode !== "US" || value.fdtc !== "0") {
return null;
}
return Object.freeze({
koreanName: value.koreanName,
inputName: value.inputName,
symbol: value.symbol,
nationCode: "US",
fdtc: "0"
});
}
function normalizeStock(value) {
if (!hasExactKeys(value, ["inputName", "symbol", "nationCode", "fdtc"])) return null;
if (!isSafeCanonicalText(value.inputName, 200) || value.inputName.includes("|") ||
!normalizeSymbol(value.symbol) || !["US", "TW"].includes(value.nationCode) ||
value.fdtc !== "1") {
return null;
}
return Object.freeze({
inputName: value.inputName,
symbol: value.symbol,
nationCode: value.nationCode,
fdtc: "1"
});
}
function createSearchRequest(requestId, query, maximumResults) {
if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) {
throw new TypeError("A safe overseas search request id is required.");
}
const normalizedQuery = normalizeQuery(query ?? "");
if (normalizedQuery === null) {
throw new TypeError(`An overseas search query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
}
if (maximumResults !== undefined &&
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
throw new RangeError(`Overseas results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
}
const result = { requestId, query: normalizedQuery };
if (maximumResults !== undefined) result.maximumResults = maximumResults;
return Object.freeze(result);
}
function createIndustrySearchRequest(requestId, query = "", maximumResults) {
return createSearchRequest(requestId, query, maximumResults);
}
function createStockSearchRequest(requestId, query = "", maximumResults) {
return createSearchRequest(requestId, query, maximumResults);
}
function compareUpper(left, right) {
const upperLeft = left.toLocaleUpperCase("en-US");
const upperRight = right.toLocaleUpperCase("en-US");
if (upperLeft < upperRight) return -1;
if (upperLeft > upperRight) return 1;
return 0;
}
function compareOrdinal(left, right) {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function compareIndustries(left, right) {
return compareUpper(left.koreanName, right.koreanName) ||
compareOrdinal(left.inputName, right.inputName) ||
compareOrdinal(left.symbol, right.symbol);
}
function compareStocks(left, right) {
return compareUpper(left.inputName, right.inputName) ||
compareOrdinal(left.symbol, right.symbol) ||
compareOrdinal(left.nationCode, right.nationCode);
}
function normalizeSearchResponse(value, expectedRequest, normalizeItem, compare, duplicateKeys) {
if (!hasExactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
]) || !isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) ||
normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.retrievedAt, 64) ||
!Number.isFinite(Date.parse(value.retrievedAt)) || !Number.isInteger(value.totalRowCount) ||
value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_RESULTS ||
typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
value.results.length !== value.totalRowCount) {
return null;
}
if (expectedRequest) {
let expected;
try {
expected = createSearchRequest(
expectedRequest.requestId,
expectedRequest.query,
expectedRequest.maximumResults);
} catch {
return null;
}
const expectedLimit = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
if (value.requestId !== expected.requestId || value.query !== expected.query ||
value.results.length > expectedLimit) {
return null;
}
}
const results = [];
const keySets = duplicateKeys.map(() => new Set());
for (const raw of value.results) {
const item = normalizeItem(raw);
if (!item) return null;
for (let index = 0; index < duplicateKeys.length; index += 1) {
const key = duplicateKeys[index](item);
if (keySets[index].has(key)) return null;
keySets[index].add(key);
}
if (results.length && compare(results[results.length - 1], item) > 0) return null;
results.push(item);
}
return Object.freeze({
requestId: value.requestId,
query: value.query,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
results: Object.freeze(results)
});
}
function normalizeIndustrySearchResponse(value, expectedRequest) {
return normalizeSearchResponse(
value,
expectedRequest,
normalizeIndustry,
compareIndustries,
[item => item.koreanName, item => item.inputName, item => item.symbol]);
}
function normalizeStockSearchResponse(value, expectedRequest) {
return normalizeSearchResponse(
value,
expectedRequest,
normalizeStock,
compareStocks,
[item => item.inputName, item => `${item.inputName}\u0000${item.symbol}\u0000${item.nationCode}`]);
}
function normalizeSearchError(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
!isSafeCanonicalText(value.requestId, 128) || !identifierPattern.test(value.requestId) ||
normalizeQuery(value.query) !== value.query || !isSafeCanonicalText(value.message, 512)) {
return null;
}
if (expectedRequest &&
(value.requestId !== expectedRequest.requestId ||
value.query !== normalizeQuery(expectedRequest.query))) return null;
return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
}
function normalizeIndustrySearchError(value, expectedRequest) {
return normalizeSearchError(value, expectedRequest);
}
function normalizeStockSearchError(value, expectedRequest) {
return normalizeSearchError(value, expectedRequest);
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options) ||
!isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) {
throw new TypeError("Safe overseas playlist options are required.");
}
const fadeDuration = options.fadeDuration === undefined
? DEFAULT_FADE_DURATION
: options.fadeDuration;
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
throw new RangeError("Fade duration must be an integer from 0 through 60.");
}
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
return Object.freeze({ id: options.id, fadeDuration, ma5, ma20 });
}
function encodeIndustryIdentity(value) {
return `${value.inputName}|${value.symbol}|${value.nationCode}|${value.fdtc}`;
}
function encodeStockIdentity(value) {
return `${value.symbol}|${value.nationCode}|${value.fdtc}`;
}
function movingAverageFlags(actionValue, options) {
const enabled = actionValue.kind === "candle";
const movingAverages = Object.freeze({
ma5: enabled && options.ma5,
ma20: enabled && options.ma20
});
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
return Object.freeze({ movingAverages, graphicType: flags.join(",") });
}
function buildOverseasIndustrySelection(industryValue) {
const industry = normalizeIndustry(industryValue);
if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
return Object.freeze({
builderKey: "s5001",
code: "5001",
aliases: Object.freeze(["5001"]),
selection: Object.freeze({
groupCode: "FOREIGN_INDUSTRY",
subject: industry.koreanName,
graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8",
subtype: "CURRENT",
dataCode: encodeIndustryIdentity(industry)
}),
movingAverages: Object.freeze({ ma5: false, ma20: false })
});
}
function buildOverseasStockSelection(stockValue, actionId, options = {}) {
const stock = normalizeStock(stockValue);
const selectedAction = actionsById.get(actionId);
if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
if (!selectedAction || !stockActions.includes(selectedAction)) {
throw new RangeError("The overseas-stock action is unknown.");
}
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
const flags = movingAverageFlags(selectedAction, { ma5, ma20 });
return Object.freeze({
builderKey: selectedAction.builderKey,
code: selectedAction.code,
aliases: selectedAction.aliases,
selection: Object.freeze({
groupCode: "FOREIGN_STOCK",
subject: stock.inputName,
graphicType: selectedAction.kind === "current"
? "\u0031\uC5F4\uD310\uAE30\uBCF8"
: flags.graphicType,
subtype: selectedAction.kind === "current" ? "CURRENT" : "PRICE",
dataCode: encodeStockIdentity(stock)
}),
movingAverages: flags.movingAverages
});
}
function createOverseasIndustryPlaylistEntry(industryValue, options) {
const industry = normalizeIndustry(industryValue);
if (!industry) throw new TypeError("A valid US overseas-industry identity is required.");
const normalizedOptions = requireOptions(options);
const mapping = buildOverseasIndustrySelection(industry);
const actionValue = industryActions[0];
return {
id: normalizedOptions.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: [...mapping.aliases],
title: industry.koreanName,
detail: actionValue.label,
category: "plate",
market: "overseas",
symbol: industry.symbol,
nationCode: industry.nationCode,
fdtc: industry.fdtc,
selection: mapping.selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
operator: Object.freeze({
source: "legacy-overseas-workflow",
schemaVersion: SCHEMA_VERSION,
kind: "industry",
actionId: actionValue.id,
identity: industry,
movingAverages: mapping.movingAverages
})
};
}
function createOverseasStockPlaylistEntry(stockValue, actionId, options) {
const stock = normalizeStock(stockValue);
if (!stock) throw new TypeError("A valid US/TW overseas-stock identity is required.");
const selectedAction = actionsById.get(actionId);
if (!selectedAction || !stockActions.includes(selectedAction)) {
throw new RangeError("The overseas-stock action is unknown.");
}
const normalizedOptions = requireOptions(options);
const mapping = buildOverseasStockSelection(stock, actionId, normalizedOptions);
return {
id: normalizedOptions.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: [...mapping.aliases],
title: stock.inputName,
detail: selectedAction.label,
category: selectedAction.kind === "candle" ? "chart" : "stock",
market: "overseas",
symbol: stock.symbol,
nationCode: stock.nationCode,
fdtc: stock.fdtc,
selection: mapping.selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
operator: Object.freeze({
source: "legacy-overseas-workflow",
schemaVersion: SCHEMA_VERSION,
kind: "stock",
actionId,
identity: stock,
movingAverages: mapping.movingAverages
})
};
}
function sameSelection(left, right) {
return hasExactKeys(left, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
hasExactKeys(right, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => left[key] === right[key]);
}
function sameAliases(value, recreated) {
return Array.isArray(value.aliases) && value.aliases.length === 1 &&
value.aliases[0] === recreated.code;
}
function restoreOverseasPlaylistEntry(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
typeof value.enabled !== "boolean") return null;
const operator = value.operator;
if (!hasExactKeys(operator, [
"source", "schemaVersion", "kind", "actionId", "identity", "movingAverages"
]) || operator.source !== "legacy-overseas-workflow" ||
operator.schemaVersion !== SCHEMA_VERSION ||
!hasExactKeys(operator.movingAverages, ["ma5", "ma20"]) ||
typeof operator.movingAverages.ma5 !== "boolean" ||
typeof operator.movingAverages.ma20 !== "boolean") return null;
let recreated;
try {
recreated = operator.kind === "industry"
? createOverseasIndustryPlaylistEntry(operator.identity, {
id: value.id,
fadeDuration: value.fadeDuration,
ma5: operator.movingAverages.ma5,
ma20: operator.movingAverages.ma20
})
: operator.kind === "stock"
? createOverseasStockPlaylistEntry(operator.identity, operator.actionId, {
id: value.id,
fadeDuration: value.fadeDuration,
ma5: operator.movingAverages.ma5,
ma20: operator.movingAverages.ma20
})
: null;
} catch {
return null;
}
if (!recreated || operator.actionId !== recreated.operator.actionId) return null;
const scalars = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"symbol", "nationCode", "fdtc", "fadeDuration", "graphicType", "subtype"
];
if (!scalars.every(key => value[key] === recreated[key]) ||
!sameAliases(value, recreated) || !sameSelection(value.selection, recreated.selection) ||
JSON.stringify(operator) !== JSON.stringify(recreated.operator)) return null;
return { ...recreated, enabled: value.enabled };
}
function isActionAllowed(kind, actionId, identity) {
if (kind === "industry") {
return actionId === "industry-current" && Boolean(normalizeIndustry(identity));
}
return kind === "stock" && Boolean(normalizeStock(identity)) &&
stockActions.some(value => value.id === actionId);
}
return {
bridgeContracts,
sourceContract,
industryActions,
stockActions,
normalizeIndustry,
normalizeStock,
createIndustrySearchRequest,
createStockSearchRequest,
normalizeIndustrySearchResponse,
normalizeStockSearchResponse,
normalizeIndustrySearchError,
normalizeStockSearchError,
isActionAllowed,
buildOverseasIndustrySelection,
buildOverseasStockSelection,
createOverseasIndustryPlaylistEntry,
createOverseasStockPlaylistEntry,
restoreOverseasPlaylistEntry,
refreshOverseasPlaylistEntry: restoreOverseasPlaylistEntry
};
});

View File

@@ -42,6 +42,14 @@
return null;
}
function legacyTakeInShortcutAction(snapshot) {
if (!snapshot || snapshot.pending) return "blocked";
if (!snapshot.takeInAllowed) return "take-in-locked";
if (snapshot.engineState === "PROGRAM" || snapshot.onAirCode) return "next-required";
if (snapshot.engineState === "PREPARED" || snapshot.preparedCode) return "take-in";
return "prepare-then-take-in";
}
function matchesPendingCommand(pending, payload) {
return Boolean(
pending && payload &&
@@ -94,6 +102,7 @@
normalizeConnectionState,
responseTimeoutFromNative,
commandBlockReason,
legacyTakeInShortcutAction,
matchesPendingCommand,
canClearCommandError,
playlistSnapshotLocked,

View File

@@ -28,6 +28,7 @@ body { background: var(--bg); color: var(--text); font-size: 13px; }
button, input { font: inherit; }
button { color: inherit; }
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); width: 100%; height: 100%; }
@@ -160,6 +161,324 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.catalog-tabs { display: flex; gap: 5px; padding: 0 13px 11px; border-bottom: 1px solid var(--border-soft); }
.catalog-tabs button { padding: 5px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; }
.catalog-tabs button.active { border-color: var(--border); background: var(--surface-3); color: white; }
.stock-workflow { display: flex; min-height: 405px; max-height: 56%; flex: 0 0 430px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
.stock-workflow[hidden] { display: none; }
.stock-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; }
.stock-workflow.collapsed > :not(.stock-workflow-header) { display: none; }
.stock-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 6px; border-bottom: 1px solid var(--border-soft); }
.stock-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.stock-workflow-header .panel-kicker { margin-bottom: 3px; }
.stock-workflow-tools { display: flex; align-items: center; gap: 5px; }
.stock-workflow-tools button { min-height: 23px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; }
.stock-search-form { display: grid; grid-template-columns: minmax(0, 1fr) 48px; gap: 6px; padding: 8px 10px 6px; }
.stock-search-form input { min-width: 0; height: 31px; padding: 0 9px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #d4e0ec; font-size: 10px; }
.stock-search-form input:focus { border-color: rgba(86,168,255,.68); box-shadow: 0 0 0 2px rgba(86,168,255,.08); }
.stock-search-form button { height: 31px; min-height: 31px; border-color: rgba(86,168,255,.35); background: rgba(86,168,255,.1); color: #91c8ff; font-size: 9px; cursor: pointer; }
.stock-search-form button:disabled { cursor: wait; opacity: .55; }
.stock-search-state { min-height: 27px; margin: 0 10px 6px; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 5px; color: #70849c; font-size: 8px; line-height: 1.45; }
.stock-search-state.loading { border-color: rgba(86,168,255,.22); color: #8ec4ff; }
.stock-search-state.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
.stock-search-state.ready { border-color: rgba(50,213,164,.18); color: #8bdcc4; }
.stock-search-results { min-height: 55px; max-height: 112px; margin: 0 10px 7px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
.stock-result { display: grid; width: 100%; min-height: 37px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 5px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.32); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
.stock-result:last-child { border-bottom: 0; }
.stock-result:hover, .stock-result.selected { background: rgba(86,168,255,.08); }
.stock-result.selected { box-shadow: inset 2px 0 var(--mint); }
.stock-result strong, .stock-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stock-result strong { margin-bottom: 2px; color: #d6e2ef; font-size: 9px; }
.stock-result small { color: #68809a; font: 7px Consolas, monospace; }
.stock-market-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 7px Consolas, monospace; white-space: nowrap; }
.stock-results-empty { padding: 18px 8px; color: #5f7288; font-size: 8px; text-align: center; }
.selected-stock { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 7px; margin: 0 10px 7px; padding: 6px 7px; border: 1px solid rgba(50,213,164,.2); border-radius: 6px; background: rgba(50,213,164,.055); }
.selected-stock[hidden] { display: none; }
.selected-stock span { color: #6f8da4; font-size: 7px; }
.selected-stock strong { overflow: hidden; color: #9ce4cf; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.selected-stock small { color: #6f8da4; font: 7px Consolas, monospace; white-space: nowrap; }
.stock-cut-header { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 7px; min-height: 34px; padding: 0 10px; border-top: 1px solid rgba(53,75,99,.25); border-bottom: 1px solid rgba(53,75,99,.25); }
.stock-cut-header strong, .stock-cut-header small { display: block; }
.stock-cut-header strong { color: #aebed0; font-size: 9px; }
.stock-cut-header small { margin-top: 1px; color: #5f7288; font-size: 7px; }
.stock-cut-header label { display: flex; align-items: center; gap: 3px; color: #8296ad; font-size: 7px; white-space: nowrap; }
.stock-cut-header input { width: 11px; height: 11px; }
.stock-cut-list { min-height: 90px; flex: 1; overflow-y: auto; }
.stock-cut-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas, monospace; letter-spacing: .05em; }
.stock-cut-row { display: grid; min-height: 31px; grid-template-columns: 28px minmax(0, 1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); cursor: default; }
.stock-cut-row:hover { background: rgba(255,255,255,.025); }
.stock-cut-row.disabled { opacity: .38; }
.stock-cut-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; }
.stock-cut-copy strong, .stock-cut-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stock-cut-copy strong { color: #b6c6d8; font-size: 8px; }
.stock-cut-copy small { margin-top: 2px; color: #5f7288; font: 7px Consolas, monospace; }
.stock-cut-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; }
.stock-cut-add:disabled { cursor: not-allowed; opacity: .35; }
.stock-cut-separator { height: 5px; border-bottom: 1px solid rgba(53,75,99,.35); background: rgba(255,255,255,.012); }
.manual-stock-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 6px 8px; border-top: 1px solid var(--border-soft); }
.manual-stock-actions button { min-width: 0; height: 25px; min-height: 25px; padding: 0 4px; overflow: hidden; color: #7489a0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.manual-stock-actions button:disabled { cursor: not-allowed; opacity: .55; }
.legacy-fixed-workflow { display: flex; min-height: 405px; max-height: 64%; flex: 0 0 455px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
.legacy-fixed-workflow[hidden] { display: none; }
.legacy-fixed-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; }
.legacy-fixed-workflow.collapsed > :not(.legacy-fixed-header) { display: none; }
.legacy-fixed-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 6px; border-bottom: 1px solid var(--border-soft); }
.legacy-fixed-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.legacy-fixed-header .panel-kicker { margin-bottom: 3px; }
.legacy-fixed-tools { display: flex; align-items: center; gap: 5px; }
.legacy-fixed-tools button { min-height: 23px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; }
.legacy-fixed-search { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; height: 36px; margin: 8px 10px 5px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: #07111d; }
.legacy-fixed-search span { color: #60748c; font-size: 14px; }
.legacy-fixed-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #d4e0ec; font-size: 9px; }
.legacy-fixed-state { min-height: 24px; padding: 3px 11px 5px; color: #6d8299; font-size: 7px; }
.legacy-fixed-sections { min-height: 0; flex: 1; overflow-y: auto; }
.legacy-fixed-section { border-top: 1px solid rgba(53,75,99,.28); }
.legacy-fixed-section > header { position: sticky; z-index: 2; top: 0; display: flex; min-height: 28px; align-items: center; justify-content: space-between; padding: 0 9px; background: #0b1827; color: #7890aa; font: 8px Consolas, "Malgun Gothic", sans-serif; }
.legacy-fixed-section > header span { color: #536b84; font-size: 7px; }
.legacy-fixed-row { display: grid; min-height: 34px; grid-template-columns: 29px minmax(0, 1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-top: 1px solid rgba(53,75,99,.2); }
.legacy-fixed-row:hover { background: rgba(255,255,255,.025); }
.legacy-fixed-row.unavailable { opacity: .48; }
.legacy-fixed-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; }
.legacy-fixed-copy strong, .legacy-fixed-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legacy-fixed-copy strong { color: #bac9d9; font-size: 8px; }
.legacy-fixed-copy small { margin-top: 2px; color: #60748c; font: 7px Consolas, monospace; }
.legacy-fixed-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; }
.legacy-fixed-add:disabled { cursor: not-allowed; color: #697b90; opacity: .45; }
.industry-workflow { display: flex; min-height: 405px; max-height: 64%; flex: 0 0 455px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
.industry-workflow[hidden] { display: none; }
.industry-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; }
.industry-workflow.collapsed > :not(.industry-workflow-header) { display: none; }
.industry-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
.industry-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.industry-workflow-header .panel-kicker { margin-bottom: 3px; }
.industry-workflow-tools { display: flex; align-items: center; gap: 4px; }
.industry-workflow-tools button { min-height: 23px; padding: 0 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; }
.industry-workflow-tools button:disabled { cursor: wait; opacity: .5; }
.industry-search { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; height: 34px; margin: 7px 9px 4px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: #07111d; }
.industry-search span { color: #60748c; font-size: 14px; }
.industry-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #d4e0ec; font-size: 9px; }
.industry-state { min-height: 23px; padding: 3px 10px 5px; color: #6d8299; font-size: 7px; }
.industry-state.error { color: #ff9bad; }
.industry-results { min-height: 70px; max-height: 105px; margin: 0 9px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
.industry-result { display: grid; width: 100%; min-height: 30px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
.industry-result:last-child { border-bottom: 0; }
.industry-result:hover, .industry-result.selected { background: rgba(86,168,255,.075); }
.industry-result strong { overflow: hidden; color: #b9c8d8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.industry-result small { color: #60748c; font: 7px Consolas, monospace; }
.industry-pair { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) 27px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
.industry-pair div { min-width: 0; }
.industry-pair span, .industry-pair strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.industry-pair span { margin-bottom: 2px; color: #5e748c; font-size: 6px; }
.industry-pair strong { color: #aebed0; font-size: 8px; }
.industry-pair button { min-height: 25px; padding: 0 6px; color: #8298af; font-size: 8px; }
.industry-cut-list { min-height: 90px; flex: 1; overflow-y: auto; }
.industry-cut-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas, monospace; letter-spacing: .04em; }
.industry-cut-row { display: grid; min-height: 31px; grid-template-columns: 28px minmax(0,1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
.industry-cut-row:hover { background: rgba(255,255,255,.025); }
.industry-cut-row.disabled { opacity: .4; }
.industry-cut-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; }
.industry-cut-copy strong, .industry-cut-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.industry-cut-copy strong { color: #b6c6d8; font-size: 8px; }
.industry-cut-copy small { margin-top: 2px; color: #5f7288; font: 7px Consolas, monospace; }
.industry-cut-add { display: grid; width: 24px; height: 24px; min-height: 24px; place-items: center; padding: 0; border-color: #29415b; color: var(--mint); cursor: pointer; }
.industry-cut-add:disabled { cursor: not-allowed; opacity: .35; }
.comparison-workflow { display: flex; min-height: 560px; max-height: 76%; flex: 0 0 620px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
.comparison-workflow[hidden] { display: none; }
.comparison-workflow-header { display: flex; min-height: 49px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
.comparison-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.comparison-workflow-header .panel-kicker { margin-bottom: 3px; }
.comparison-source-tabs { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); }
.comparison-source-tabs button { min-height: 26px; padding: 0 5px; color: #6f849c; font-size: 7px; }
.comparison-source-tabs button.active { border-color: rgba(50,213,164,.35); background: rgba(50,213,164,.08); color: #8be2c8; }
.comparison-source-panel { flex: 0 0 auto; }
.comparison-source-panel[hidden] { display: none; }
.comparison-search-form { display: grid; grid-template-columns: minmax(0,1fr) 45px; gap: 5px; padding: 6px 8px 4px; }
.comparison-search-form input { min-width: 0; height: 29px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #d4e0ec; font-size: 8px; }
.comparison-search-form button { min-height: 29px; padding: 0 6px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; }
.comparison-search-state { min-height: 21px; padding: 2px 9px 4px; color: #667c94; font-size: 7px; }
.comparison-search-state.loading { color: #8ec4ff; }
.comparison-search-state.error { color: #ff9bad; }
.comparison-search-state.ready { color: #8bdcc4; }
.comparison-target-results { min-height: 70px; max-height: 105px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
.comparison-target-results.fixed { display: grid; max-height: 125px; grid-template-columns: repeat(2,minmax(0,1fr)); margin-top: 6px; }
.comparison-target { display: grid; width: 100%; min-height: 29px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; color: #b9c8d8; text-align: left; cursor: pointer; }
.comparison-target:hover { background: rgba(86,168,255,.075); }
.comparison-target strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.comparison-target small { color: #60748c; font: 6px Consolas,monospace; white-space: nowrap; }
.comparison-target-empty { padding: 18px 8px; color: #5f7288; font-size: 8px; text-align: center; }
.comparison-draft { display: grid; min-height: 51px; grid-template-columns: minmax(0,1fr) 25px minmax(0,1fr) auto auto; align-items: center; gap: 4px; padding: 5px 7px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
.comparison-draft div { min-width: 0; }
.comparison-draft span,.comparison-draft strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.comparison-draft span { margin-bottom: 2px; color: #5e748c; font-size: 6px; }
.comparison-draft strong { color: #aebed0; font-size: 8px; }
.comparison-draft button { min-height: 25px; padding: 0 5px; color: #8298af; font-size: 7px; }
.comparison-draft button.primary { border-color: rgba(50,213,164,.3); color: #83dec3; }
.comparison-pair-header,.comparison-action-header { display: flex; min-height: 35px; align-items: center; justify-content: space-between; gap: 6px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
.comparison-pair-header strong,.comparison-pair-header small,.comparison-action-header strong,.comparison-action-header small { display: block; }
.comparison-pair-header strong,.comparison-action-header strong { color: #aebed0; font-size: 8px; }
.comparison-pair-header small,.comparison-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; }
.comparison-pair-header > div:last-child { display: flex; gap: 3px; }
.comparison-pair-header button,.comparison-action-header button { min-height: 23px; padding: 0 5px; color: #8298af; font-size: 6px; }
.comparison-pair-list { min-height: 55px; max-height: 95px; overflow-y: auto; }
.comparison-pair-row { display: grid; width: 100%; min-height: 31px; grid-template-columns: 25px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.24); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
.comparison-pair-row:hover,.comparison-pair-row.selected { background: rgba(50,213,164,.055); }
.comparison-pair-row.selected { box-shadow: inset 2px 0 var(--mint); }
.comparison-pair-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
.comparison-pair-row strong,.comparison-pair-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.comparison-pair-row strong { color: #b6c6d8; font-size: 8px; }
.comparison-pair-row small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
.comparison-pair-row > span:last-child { color: #68809a; font: 6px Consolas,monospace; }
.comparison-action-list { min-height: 120px; flex: 1 0 auto; }
.comparison-action-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas,monospace; }
.comparison-action-row { display: grid; min-height: 31px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
.comparison-action-row.disabled { opacity: .38; }
.comparison-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
.comparison-action-copy strong,.comparison-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.comparison-action-copy strong { color: #b6c6d8; font-size: 8px; }
.comparison-action-copy small { margin-top: 1px; color: #5f7288; font: 6px Consolas,monospace; }
.comparison-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
.theme-workflow { display: flex; min-height: 545px; max-height: 76%; flex: 0 0 610px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
.theme-workflow[hidden] { display: none; }
.theme-workflow-header { display: flex; min-height: 49px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
.theme-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.theme-workflow-header .panel-kicker { margin-bottom: 3px; }
.theme-search-form { display: grid; grid-template-columns: minmax(0,1fr) 76px 42px; gap: 5px; padding: 7px 8px 5px; }
.theme-search-form input,.theme-search-form select { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 7px; }
.theme-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 7px; }
.theme-search-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; }
.theme-search-state.loading,.theme-preview-state.loading { color: #8ec4ff; }
.theme-search-state.error,.theme-preview-state.error { color: #ff9bad; }
.theme-search-state.ready,.theme-preview-state.ready { color: #8bdcc4; }
.theme-results { min-height: 78px; max-height: 118px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
.theme-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
.theme-result:hover,.theme-result.selected { background: rgba(86,168,255,.075); }
.theme-result.selected { box-shadow: inset 2px 0 var(--mint); }
.theme-result strong,.theme-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.theme-result strong { color: #b9c8d8; font-size: 8px; }
.theme-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
.theme-market-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 6px Consolas,monospace; }
.selected-theme { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 7px; min-height: 47px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
.selected-theme[hidden] { display: none; }
.selected-theme span,.selected-theme strong,.selected-theme small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selected-theme span { color: #5e748c; font-size: 6px; }
.selected-theme strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; }
.selected-theme small { color: #60748c; font: 6px Consolas,monospace; }
.selected-theme label { color: #667c94; font-size: 6px; }
.selected-theme select { display: block; min-width: 80px; height: 26px; margin-top: 2px; padding: 0 5px; border: 1px solid #29415b; border-radius: 5px; background: #07111d; color: #aebed0; font-size: 7px; }
.theme-preview-header { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 7px; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
.theme-preview-header strong,.theme-preview-header small { display: block; }
.theme-preview-header strong { color: #aebed0; font-size: 8px; }
.theme-preview-header small { color: #5f7288; font-size: 6px; }
.theme-preview-header > span { color: #67809a; font: 6px Consolas,monospace; }
.theme-preview-state { min-height: 20px; padding: 3px 9px; color: #667c94; font-size: 7px; }
.theme-preview-items { min-height: 55px; max-height: 92px; overflow-y: auto; }
.theme-preview-item { display: grid; min-height: 25px; grid-template-columns: 28px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 8px; border-bottom: 1px solid rgba(53,75,99,.2); }
.theme-preview-item span { color: #536b84; font: 6px Consolas,monospace; text-align: center; }
.theme-preview-item strong { overflow: hidden; color: #aebed0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.theme-preview-item small { color: #60748c; font: 6px Consolas,monospace; }
.theme-action-header { min-height: 33px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.28); border-bottom: 1px solid rgba(53,75,99,.28); }
.theme-action-header strong,.theme-action-header small { display: block; }
.theme-action-header strong { color: #aebed0; font-size: 8px; }
.theme-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; }
.theme-action-list { min-height: 120px; }
.theme-action-row { display: grid; min-height: 32px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
.theme-action-row.disabled { opacity: .38; }
.theme-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
.theme-action-copy strong,.theme-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.theme-action-copy strong { color: #b6c6d8; font-size: 8px; }
.theme-action-copy small { color: #5f7288; font: 6px Consolas,monospace; }
.theme-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
.theme-crud-notice { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-top: 1px solid rgba(255,190,80,.14); color: #8a7554; font-size: 6px; }
.theme-crud-notice button,.expert-crud-notice button { min-height: 25px; padding: 0 8px; border: 1px solid rgba(255,190,80,.24); border-radius: 6px; background: rgba(255,190,80,.08); color: #d3a85f; cursor: pointer; font-size: 7px; }
.theme-crud-notice button:disabled,.expert-crud-notice button:disabled { cursor: not-allowed; opacity: .4; }
.overseas-workflow,.expert-workflow { display: flex; min-height: 470px; max-height: 76%; flex: 0 0 540px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
.overseas-workflow[hidden],.expert-workflow[hidden] { display: none; }
.overseas-workflow-header,.expert-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
.overseas-workflow-header h3,.expert-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.overseas-workflow-header .panel-kicker,.expert-workflow-header .panel-kicker { margin-bottom: 3px; }
.overseas-source-tabs { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; padding: 6px 8px 2px; }
.overseas-source-tabs button { min-height: 27px; color: #6f849c; font-size: 7px; }
.overseas-source-tabs button.active { border-color: rgba(50,213,164,.35); background: rgba(50,213,164,.08); color: #8be2c8; }
.overseas-search-form,.expert-search-form { display: grid; grid-template-columns: minmax(0,1fr) 44px; gap: 5px; padding: 6px 8px 5px; }
.overseas-search-form input,.expert-search-form input { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 8px; }
.overseas-search-form button,.expert-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; }
.overseas-search-state,.expert-search-state,.expert-preview-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; }
.overseas-search-state.loading,.expert-search-state.loading,.expert-preview-state.loading { color: #8ec4ff; }
.overseas-search-state.error,.expert-search-state.error,.expert-preview-state.error { color: #ff9bad; }
.overseas-search-state.ready,.expert-search-state.ready,.expert-preview-state.ready { color: #8bdcc4; }
.overseas-results,.expert-results { min-height: 82px; max-height: 135px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
.overseas-result,.expert-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
.overseas-result:hover,.overseas-result.selected,.expert-result:hover,.expert-result.selected { background: rgba(86,168,255,.075); }
.overseas-result.selected,.expert-result.selected { box-shadow: inset 2px 0 var(--mint); }
.overseas-result strong,.overseas-result small,.expert-result strong,.expert-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.overseas-result strong,.expert-result strong { color: #b9c8d8; font-size: 8px; }
.overseas-result small,.expert-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
.overseas-result > span:last-child,.expert-result > span:last-child { padding: 3px 5px; border: 1px solid rgba(86,168,255,.22); border-radius: 5px; color: #8ec4ff; font: 6px Consolas,monospace; }
.selected-overseas { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
.selected-overseas[hidden],.selected-expert[hidden] { display: none; }
.selected-overseas span,.selected-overseas strong,.selected-overseas small,.selected-expert span,.selected-expert strong,.selected-expert small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selected-overseas span,.selected-expert span { color: #5e748c; font-size: 6px; }
.selected-overseas strong,.selected-expert strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; }
.selected-overseas small,.selected-expert small { color: #60748c; font: 6px Consolas,monospace; }
.overseas-ma-options { display: flex; gap: 6px; color: #72879e; font-size: 7px; }
.overseas-ma-options label { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
.overseas-action-header,.expert-action-header,.expert-preview-header { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
.overseas-action-header strong,.overseas-action-header small,.expert-action-header strong,.expert-action-header small,.expert-preview-header strong,.expert-preview-header small { display: block; }
.overseas-action-header strong,.expert-action-header strong,.expert-preview-header strong { color: #aebed0; font-size: 8px; }
.overseas-action-header small,.expert-action-header small,.expert-preview-header small { color: #5f7288; font-size: 6px; }
.expert-preview-header > span { color: #67809a; font: 6px Consolas,monospace; }
.overseas-action-list,.expert-action-list { min-height: 75px; }
.overseas-action-row,.expert-action-row { display: grid; min-height: 32px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
.overseas-action-row.disabled,.expert-action-row.disabled { opacity: .38; }
.overseas-action-row > span:first-child,.expert-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
.overseas-action-copy strong,.overseas-action-copy small,.expert-action-copy strong,.expert-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.overseas-action-copy strong,.expert-action-copy strong { color: #b6c6d8; font-size: 8px; }
.overseas-action-copy small,.expert-action-copy small { color: #5f7288; font: 6px Consolas,monospace; }
.overseas-action-add,.expert-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
.selected-expert { min-height: 45px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
.expert-preview-items { min-height: 66px; max-height: 116px; overflow-y: auto; }
.expert-preview-item { display: grid; min-height: 27px; grid-template-columns: 28px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 8px; border-bottom: 1px solid rgba(53,75,99,.2); }
.expert-preview-item span { color: #536b84; font: 6px Consolas,monospace; text-align: center; }
.expert-preview-item strong { overflow: hidden; color: #aebed0; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.expert-preview-item small { color: #60748c; font: 6px Consolas,monospace; }
.expert-crud-notice { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-top: 1px solid rgba(255,190,80,.14); color: #8a7554; font-size: 6px; }
.trading-halt-workflow { display: flex; min-height: 420px; max-height: 76%; flex: 0 0 475px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow-y: auto; }
.trading-halt-workflow[hidden] { display: none; }
.trading-halt-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
.trading-halt-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.trading-halt-workflow-header .panel-kicker { margin-bottom: 3px; }
.trading-halt-search-form { display: grid; grid-template-columns: minmax(0,1fr) 44px; gap: 5px; padding: 7px 8px 5px; }
.trading-halt-search-form input { min-width: 0; height: 29px; padding: 0 7px; border: 1px solid #29415b; border-radius: 6px; outline: 0; background: #07111d; color: #c7d5e4; font-size: 8px; }
.trading-halt-search-form button { min-height: 29px; padding: 0 5px; border-color: rgba(86,168,255,.35); color: #91c8ff; font-size: 8px; }
.trading-halt-search-state { min-height: 22px; padding: 2px 9px 5px; color: #667c94; font-size: 7px; }
.trading-halt-search-state.loading { color: #8ec4ff; }
.trading-halt-search-state.error { color: #ff9bad; }
.trading-halt-search-state.ready { color: #8bdcc4; }
.trading-halt-results { min-height: 100px; max-height: 175px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
.trading-halt-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
.trading-halt-result:hover,.trading-halt-result.selected { background: rgba(86,168,255,.075); }
.trading-halt-result.selected { box-shadow: inset 2px 0 var(--mint); }
.trading-halt-result strong,.trading-halt-result small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trading-halt-result strong { color: #b9c8d8; font-size: 8px; }
.trading-halt-result small { margin-top: 1px; color: #60748c; font: 6px Consolas,monospace; }
.trading-halt-market { padding: 3px 5px; border: 1px solid rgba(255,102,128,.2); border-radius: 5px; color: #ff9bad; font: 6px Consolas,monospace; }
.selected-trading-halt { display: grid; min-height: 48px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 5px 8px; border-top: 1px solid rgba(53,75,99,.3); border-bottom: 1px solid rgba(53,75,99,.3); }
.selected-trading-halt[hidden] { display: none; }
.selected-trading-halt span,.selected-trading-halt strong,.selected-trading-halt small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selected-trading-halt span { color: #5e748c; font-size: 6px; }
.selected-trading-halt strong { margin: 2px 0; color: #9ce4cf; font-size: 9px; }
.selected-trading-halt small { color: #60748c; font: 6px Consolas,monospace; }
.trading-halt-ma-options { display: flex; gap: 6px; color: #72879e; font-size: 7px; }
.trading-halt-ma-options label { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
.trading-halt-action-header { padding: 6px 8px; border-bottom: 1px solid rgba(53,75,99,.28); }
.trading-halt-action-header strong,.trading-halt-action-header small { display: block; }
.trading-halt-action-header strong { color: #aebed0; font-size: 8px; }
.trading-halt-action-header small { margin-top: 1px; color: #5f7288; font-size: 6px; }
.trading-halt-action-list { min-height: 74px; }
.trading-halt-action-row { display: grid; min-height: 34px; grid-template-columns: 27px minmax(0,1fr) 25px; align-items: center; gap: 5px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); }
.trading-halt-action-row.disabled { opacity: .38; }
.trading-halt-action-row > span:first-child { color: #536b84; font: 7px Consolas,monospace; text-align: center; }
.trading-halt-action-copy strong,.trading-halt-action-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trading-halt-action-copy strong { color: #b6c6d8; font-size: 8px; }
.trading-halt-action-copy small { color: #5f7288; font: 6px Consolas,monospace; }
.trading-halt-action-add { display: grid; width: 23px; height: 23px; min-height: 23px; place-items: center; padding: 0; color: var(--mint); }
.live-data-panel { display: flex; min-height: 235px; max-height: 54%; flex: 0 1 365px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
.live-data-header { display: flex; min-height: 51px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 7px; border-bottom: 1px solid var(--border-soft); }
.live-data-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
@@ -217,10 +536,43 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.add-cut:hover { border-color: var(--mint); background: var(--mint-soft); }
.catalog-empty { padding: 35px 10px; color: var(--muted); text-align: center; }
.playlist-actions { display: grid; grid-template-columns: auto 30px 30px auto 1fr auto auto; gap: 5px; padding: 10px 12px; border-bottom: 1px solid var(--border-soft); }
.playlist-actions { display: grid; grid-template-columns: auto auto 30px 30px auto auto 1fr auto auto auto; gap: 5px; padding: 10px 12px; border-bottom: 1px solid var(--border-soft); }
.playlist-actions button { padding: 0 9px; font-size: 9px; }
.playlist-actions button.danger { color: #ff9aad; }
.playlist-actions button.primary-soft { border-color: rgba(50,213,164,.23); background: var(--mint-soft); color: var(--mint); }
.playlist-actions button.database-soft { border-color: rgba(86,168,255,.25); background: rgba(86,168,255,.1); color: #8fc3fa; }
.global-candle-options { display: flex; align-items: center; justify-content: flex-end; min-width: 0; gap: 7px; padding: 0 4px; color: #8196ad; font-size: 8px; white-space: nowrap; }
.global-candle-options > span { color: #5f7690; font: 7px Consolas, monospace; letter-spacing: .08em; }
.global-candle-options label { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; }
.global-candle-options input { width: 13px; height: 13px; margin: 0; accent-color: var(--mint); }
.global-candle-options input:disabled + * { opacity: .45; }
.named-playlist-panel { flex: 0 0 auto; max-height: 352px; padding: 10px 12px 9px; border-bottom: 1px solid var(--border); background: linear-gradient(145deg, rgba(12,31,49,.98), rgba(8,21,35,.98)); overflow-y: auto; }
.named-playlist-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.named-playlist-header h3 { margin: 0; color: #d9e6f4; font-size: 11px; }
.named-playlist-header-actions { display: flex; align-items: center; gap: 5px; }
.named-playlist-panel button { min-height: 27px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: var(--surface-2); color: #9db0c5; font-size: 8px; cursor: pointer; }
.named-playlist-panel button:hover:not(:disabled) { border-color: #426487; color: white; }
.named-playlist-panel button.primary-soft { border-color: rgba(50,213,164,.25); background: var(--mint-soft); color: var(--mint); }
.named-playlist-panel button.danger { border-color: rgba(255,102,128,.2); color: #ff9aad; }
.named-playlist-panel button:disabled { cursor: not-allowed; opacity: .38; }
.named-playlist-status { min-height: 29px; margin: 8px 0; padding: 6px 8px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,18,30,.72); color: #7f94ab; font-size: 8px; line-height: 1.45; }
.named-playlist-status.error { border-color: rgba(255,102,128,.25); color: #ff9aad; }
.named-playlist-status.ready { border-color: rgba(50,213,164,.2); color: #8bdcc4; }
.named-playlist-status.quarantined { border-color: rgba(255,186,85,.28); color: var(--amber); }
.named-playlist-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 5px; }
.named-playlist-definitions-label, .named-playlist-create label { color: #637991; font: 7px Consolas, monospace; text-transform: uppercase; }
.named-playlist-definitions { width: 100%; min-height: 88px; padding: 3px; border: 1px solid var(--border); border-radius: 6px; outline: none; background: #081522; color: #b8c8da; font: 9px/1.5 Consolas, "Malgun Gothic", sans-serif; }
.named-playlist-definitions:focus { border-color: #426487; }
.named-playlist-definitions option { padding: 3px 5px; }
.named-playlist-selection-summary { min-height: 17px; color: #70869e; font-size: 8px; }
.named-playlist-load-actions, .named-playlist-mutation-actions { display: flex; flex-wrap: wrap; gap: 5px; }
.named-playlist-create { display: grid; grid-template-columns: auto 78px auto minmax(100px,1fr) auto; align-items: center; gap: 5px; padding-top: 5px; border-top: 1px solid var(--border-soft); }
.named-playlist-create input { min-width: 0; height: 27px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; outline: none; background: #081522; color: #c6d4e3; font-size: 8px; }
.named-playlist-create input:focus { border-color: #426487; }
.named-playlist-guard-summary { margin: 7px 0 0; color: #61778f; font-size: 7px; line-height: 1.45; }
.named-playlist-guard-summary.blocked { color: var(--amber); }
tbody tr.named-restore-invalid { box-shadow: inset 3px 0 var(--red); }
tbody tr.named-page-pending { box-shadow: inset 3px 0 var(--amber); }
.table-wrap { position: relative; min-height: 0; flex: 1; overflow: auto; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
thead { position: sticky; z-index: 2; top: 0; background: #0d1929; }
@@ -229,10 +581,15 @@ td { height: 47px; border-bottom: 1px solid var(--border-soft); color: #b9c7d8;
th, td { padding: 0 8px; }
.check-cell { width: 35px; text-align: center; }
.number-cell { width: 34px; color: var(--muted-2); text-align: center; }
.page-cell { width: 48px; color: var(--muted-2); text-align: center; }
.drag-cell { width: 42px; color: var(--muted-2); text-align: center; }
.playlist-target-cell { width: 24%; }
.playlist-cut-cell { width: 25%; }
.playlist-detail-cell { width: 28%; }
tbody tr { cursor: pointer; transition: .12s ease; }
tbody tr:hover { background: rgba(255,255,255,.022); }
tbody tr.active { background: var(--mint-soft); }
tbody tr.selected { box-shadow: inset 2px 0 rgba(86,168,255,.8); }
tbody tr.dragging { opacity: .42; }
tbody tr.drag-over { box-shadow: inset 0 2px var(--mint); }
td strong, td small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
@@ -257,6 +614,14 @@ input[type="checkbox"] { accent-color: var(--mint); }
.playout-health-item.safety strong { grid-column: 1; color: var(--amber); }
.playout-health-item.safety.live-allowed strong { color: var(--mint); }
.playout-health-item.safety.live-locked strong { color: var(--red); }
.background-control-bar { display: grid; min-height: 42px; grid-template-columns: minmax(0,1fr) auto auto; align-items: center; gap: 6px; margin: 7px 13px 0; padding: 5px 7px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.6); }
.background-control-bar > div { min-width: 0; }
.background-control-bar small,.background-control-bar strong,.background-control-bar span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.background-control-bar small { color: #5e748c; font: 6px Consolas,monospace; }
.background-control-bar strong { margin: 1px 0; color: #aebed0; font-size: 8px; }
.background-control-bar span { color: #60748c; font-size: 6px; }
.background-control-bar button { min-height: 27px; padding: 0 7px; border-color: #29415b; color: #8fa7c0; font-size: 7px; }
.background-control-bar button:disabled { cursor: not-allowed; opacity: .4; }
.playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; }
.playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; }
.playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }

325
Web/theme-workflow.js Normal file
View File

@@ -0,0 +1,325 @@
(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}$/;
const themeCodePattern = /^[0-9]{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
};
});

View File

@@ -0,0 +1,361 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnTradingHaltWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const DEFAULT_MAXIMUM_RESULTS = 100;
const MAXIMUM_RESULTS = 500;
const MAXIMUM_QUERY_LENGTH = 64;
const SCHEMA_VERSION = 1;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const markets = Object.freeze(["kospi", "kosdaq"]);
const bridgeContract = Object.freeze({
requestType: "search-trading-halts",
resultType: "trading-halt-results",
errorType: "trading-halt-error",
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
maximumResults: MAXIMUM_RESULTS,
maximumQueryLength: MAXIMUM_QUERY_LENGTH
});
const sourceContract = Object.freeze({
originalControl: "Control/UC7.cs",
currentCutCode: "5001",
candleCutCode: "8051",
candlePeriodDays: 120,
currentResolverGroups: Object.freeze({ kospi: "KOSPI", kosdaq: "KOSDAQ" }),
candleResolverGroups: Object.freeze({ kospi: "KOSPI_STOCK", kosdaq: "KOSDAQ_STOCK" })
});
function action(id, label, builderKey, code, kind) {
return Object.freeze({
id,
label,
builderKey,
code,
aliases: Object.freeze([code]),
kind
});
}
const actions = Object.freeze([
action("current", "\uAC70\uB798\uC815\uC9C0-\uD604\uC7AC\uAC00", "s5001", "5001", "current"),
action("candle-120d", "\uCE94\uB4E4\uADF8\uB798\uD504-\uAC70\uB798\uC815\uC9C0 · 120\uC77C", "s8010", "8051", "candle")
]);
const actionsById = new Map(actions.map(value => [value.id, value]));
function hasExactKeys(value, keys) {
if (!value || typeof value !== "object" || Array.isArray(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 isSafeCanonicalText(value, maximumLength) {
return typeof value === "string" &&
value.length > 0 &&
value.length <= maximumLength &&
value === value.trim() &&
!unsafeTextPattern.test(value);
}
function normalizeQuery(value) {
if (typeof value !== "string" || value.length > MAXIMUM_QUERY_LENGTH || unsafeTextPattern.test(value)) {
return null;
}
const query = value.trim();
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query) ? query : null;
}
function normalizeTradingHalt(value) {
if (!hasExactKeys(value, ["market", "stockCode", "displayName"]) ||
!markets.includes(value.market) ||
!isSafeCanonicalText(value.stockCode, 32) ||
!stockCodePattern.test(value.stockCode) ||
!isSafeCanonicalText(value.displayName, 200)) {
return null;
}
return Object.freeze({
market: value.market,
stockCode: value.stockCode,
displayName: value.displayName
});
}
function createTradingHaltSearchRequest(requestId, query = "", maximumResults) {
if (!isSafeCanonicalText(requestId, 128) || !identifierPattern.test(requestId)) {
throw new TypeError("A safe trading-halt request id is required.");
}
const normalizedQuery = normalizeQuery(query);
if (normalizedQuery === null) {
throw new TypeError(`A trading-halt query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
}
if (maximumResults !== undefined &&
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
throw new RangeError(`Trading-halt results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
}
const request = { requestId, query: normalizedQuery };
if (maximumResults !== undefined) request.maximumResults = maximumResults;
return Object.freeze(request);
}
function compareTradingHalts(left, right) {
const marketOrder = markets.indexOf(left.market) - markets.indexOf(right.market);
if (marketOrder !== 0) return marketOrder;
if (left.displayName < right.displayName) return -1;
if (left.displayName > right.displayName) return 1;
if (left.stockCode < right.stockCode) return -1;
if (left.stockCode > right.stockCode) return 1;
return 0;
}
function normalizeTradingHaltSearchResponse(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
]) ||
!isSafeCanonicalText(value.requestId, 128) ||
!identifierPattern.test(value.requestId) ||
normalizeQuery(value.query) !== value.query ||
!isSafeCanonicalText(value.retrievedAt, 64) ||
!Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) ||
value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_RESULTS ||
typeof value.truncated !== "boolean" ||
!Array.isArray(value.results) ||
value.results.length !== value.totalRowCount) {
return null;
}
if (expectedRequest !== undefined) {
let expected;
try {
expected = createTradingHaltSearchRequest(
expectedRequest?.requestId,
expectedRequest?.query,
expectedRequest?.maximumResults);
} catch {
return null;
}
if (value.requestId !== expected.requestId || value.query !== expected.query ||
(expected.maximumResults !== undefined && value.results.length > expected.maximumResults)) {
return null;
}
}
const results = [];
const identities = new Set();
const lookupKeys = new Set();
for (const raw of value.results) {
const item = normalizeTradingHalt(raw);
if (!item) return null;
const identity = `${item.market}\u0000${item.stockCode}`;
const lookupKey = `${item.market}\u0000${item.displayName}`;
if (identities.has(identity) || lookupKeys.has(lookupKey)) return null;
if (results.length && compareTradingHalts(results[results.length - 1], item) > 0) return null;
identities.add(identity);
lookupKeys.add(lookupKey);
results.push(item);
}
return Object.freeze({
requestId: value.requestId,
query: value.query,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
results: Object.freeze(results)
});
}
function normalizeTradingHaltError(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
!isSafeCanonicalText(value.requestId, 128) ||
!identifierPattern.test(value.requestId) ||
normalizeQuery(value.query) !== value.query ||
!isSafeCanonicalText(value.message, 512)) {
return null;
}
if (expectedRequest &&
(value.requestId !== expectedRequest.requestId || value.query !== normalizeQuery(expectedRequest.query))) {
return null;
}
return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
}
function getAction(actionId) {
return typeof actionId === "string" ? actionsById.get(actionId) || null : null;
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Trading-halt playlist options are required.");
}
if (!isSafeCanonicalText(options.id, 128) || !identifierPattern.test(options.id)) {
throw new TypeError("A safe trading-halt playlist id is required.");
}
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : options.fadeDuration;
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
throw new RangeError("Fade duration must be an integer from 0 through 60.");
}
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
return Object.freeze({ id: options.id, fadeDuration, ma5, ma20 });
}
function buildTradingHaltSelection(actionId, tradingHalt, options = {}) {
const selectedAction = getAction(actionId);
const selected = normalizeTradingHalt(tradingHalt);
if (!selectedAction) throw new RangeError("The trading-halt action is unknown.");
if (!selected) throw new TypeError("A valid trading-halt selection is required.");
const ma5 = options.ma5 === undefined ? false : options.ma5;
const ma20 = options.ma20 === undefined ? false : options.ma20;
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
throw new TypeError("Moving-average flags must be boolean values.");
}
const movingAverages = Object.freeze({
ma5: selectedAction.kind === "candle" && ma5,
ma20: selectedAction.kind === "candle" && ma20
});
const flags = [];
if (movingAverages.ma5) flags.push("MA5");
if (movingAverages.ma20) flags.push("MA20");
const selection = selectedAction.kind === "current"
? Object.freeze({
groupCode: sourceContract.currentResolverGroups[selected.market],
subject: selected.displayName,
graphicType: "\u0031\uC5F4\uD310\uAE30\uBCF8",
subtype: "HALTED",
dataCode: selected.stockCode
})
: Object.freeze({
groupCode: sourceContract.candleResolverGroups[selected.market],
subject: selected.displayName,
graphicType: flags.join(","),
subtype: "TRADING_HALT",
dataCode: selected.stockCode
});
return Object.freeze({
builderKey: selectedAction.builderKey,
code: selectedAction.code,
aliases: selectedAction.aliases,
selection,
movingAverages
});
}
function createTradingHaltPlaylistEntry(tradingHalt, actionId, options) {
const selected = normalizeTradingHalt(tradingHalt);
if (!selected) throw new TypeError("A valid trading-halt selection is required.");
const selectedAction = getAction(actionId);
if (!selectedAction) throw new RangeError("The trading-halt action is unknown.");
const normalizedOptions = requireOptions(options);
const mapping = buildTradingHaltSelection(actionId, selected, normalizedOptions);
return {
id: normalizedOptions.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: [...mapping.aliases],
title: selected.displayName,
detail: selectedAction.label,
category: selectedAction.kind === "candle" ? "chart" : "stock",
market: selected.market,
stockCode: selected.stockCode,
displayName: selected.displayName,
selection: mapping.selection,
enabled: true,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
operator: Object.freeze({
source: "legacy-trading-halt-workflow",
schemaVersion: SCHEMA_VERSION,
actionId: selectedAction.id,
tradingHalt: selected,
movingAverages: mapping.movingAverages,
resolverGroup: mapping.selection.groupCode
})
};
}
function sameStringArray(left, right) {
return Array.isArray(left) && Array.isArray(right) &&
left.length === right.length && left.every((value, index) => value === right[index]);
}
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 restoreTradingHaltPlaylistEntry(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-trading-halt-workflow" ||
operator.schemaVersion !== SCHEMA_VERSION ||
!actionsById.has(operator.actionId) ||
!operator.movingAverages || typeof operator.movingAverages !== "object" ||
typeof operator.movingAverages.ma5 !== "boolean" ||
typeof operator.movingAverages.ma20 !== "boolean") {
return null;
}
let recreated;
try {
recreated = createTradingHaltPlaylistEntry(operator.tradingHalt, operator.actionId, {
id: value.id,
fadeDuration: value.fadeDuration,
ma5: operator.movingAverages.ma5,
ma20: operator.movingAverages.ma20
});
} catch {
return null;
}
const scalars = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"stockCode", "displayName", "fadeDuration", "graphicType", "subtype"
];
if (!scalars.every(key => value[key] === recreated[key]) ||
!sameStringArray(value.aliases, recreated.aliases) ||
!sameSelection(value.selection, recreated.selection) ||
operator.movingAverages.ma5 !== recreated.operator.movingAverages.ma5 ||
operator.movingAverages.ma20 !== recreated.operator.movingAverages.ma20 ||
operator.resolverGroup !== recreated.operator.resolverGroup) {
return null;
}
return { ...recreated, enabled: value.enabled };
}
return {
bridgeContract,
sourceContract,
markets,
actions,
tradingHaltActions: actions,
normalizeTradingHalt,
normalizeTradingHaltResult: normalizeTradingHalt,
createTradingHaltSearchRequest,
normalizeTradingHaltSearchResponse,
normalizeTradingHaltError,
getAction,
buildTradingHaltSelection,
createTradingHaltPlaylistEntry,
restoreTradingHaltPlaylistEntry,
refreshTradingHaltPlaylistEntry: restoreTradingHaltPlaylistEntry
};
});