Files
MBN_STOCK_WEBVIEW/Web/expert-workflow.js

503 lines
19 KiB
JavaScript

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