Migrate remaining legacy operator workflows
This commit is contained in:
209
Web/named-comparison-restore-workflow.js
Normal file
209
Web/named-comparison-restore-workflow.js
Normal file
@@ -0,0 +1,209 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const comparison = typeof module === "object" && module.exports
|
||||
? require("./comparison-workflow.js")
|
||||
: root.MbnComparisonWorkflow;
|
||||
const api = Object.freeze(factory(comparison));
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnNamedComparisonRestoreWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function (comparison) {
|
||||
"use strict";
|
||||
|
||||
if (!comparison) throw new Error("Comparison workflow is unavailable.");
|
||||
|
||||
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
const pendingValues = new WeakSet();
|
||||
const failureCodes = new Set([
|
||||
"INVALID_ROW",
|
||||
"MISSING_MARKET_IDENTITY",
|
||||
"IDENTITY_NOT_FOUND",
|
||||
"IDENTITY_AMBIGUOUS",
|
||||
"UNSUPPORTED_ACTION",
|
||||
"DATABASE_DATA_INVALID",
|
||||
"RESTORE_FAILED"
|
||||
]);
|
||||
const actionIds = new Set(comparison.actions.map(action => action.id));
|
||||
const returnActionBySubtype = Object.freeze({
|
||||
"5일": "return-5d",
|
||||
"1개월": "return-1m",
|
||||
"3개월": "return-3m",
|
||||
"6개월": "return-6m",
|
||||
"12개월": "return-12m"
|
||||
});
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
if (!isPlainObject(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function safeText(value, maximum, allowEmpty = false) {
|
||||
return typeof value === "string" && value.length <= maximum &&
|
||||
(allowEmpty || value.length > 0) && value === value.trim() &&
|
||||
!value.includes("^") && !unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function actionForRaw(raw) {
|
||||
if (raw.graphicType === "2열판") {
|
||||
if (raw.subtype === "") return "two-column-current";
|
||||
if (raw.subtype === "예상체결") return "two-column-expected";
|
||||
return null;
|
||||
}
|
||||
if (raw.graphicType === "종목별 비교분석_캔들 그래프" && raw.subtype === "") {
|
||||
return "comparison-candle";
|
||||
}
|
||||
if (raw.graphicType === "종목별 비교분석_라인 그래프" && raw.subtype === "") {
|
||||
return "comparison-line";
|
||||
}
|
||||
return raw.graphicType === "종목별 수익률 비교"
|
||||
? returnActionBySubtype[raw.subtype] || null
|
||||
: null;
|
||||
}
|
||||
|
||||
function classify(raw, options = {}) {
|
||||
if (!isPlainObject(raw) || typeof raw.enabled !== "boolean" ||
|
||||
!Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 || raw.itemIndex >= 1000 ||
|
||||
!safeText(raw.groupCode, 256, true) || !safeText(raw.subject, 4000) ||
|
||||
!safeText(raw.graphicType, 256) || !safeText(raw.subtype, 256, true) ||
|
||||
raw.pageText !== "1/1" || raw.dataCode !== "" ||
|
||||
!safeText(raw.pageText, 9) || !safeText(raw.dataCode, 1024, true) ||
|
||||
!requestIdPattern.test(options.id) || !Number.isInteger(options.fadeDuration) ||
|
||||
options.fadeDuration < 0 || options.fadeDuration > 60) return null;
|
||||
const names = raw.subject.split(",");
|
||||
const actionId = actionForRaw(raw);
|
||||
if (!actionId || names.length !== 2 || names.some(name => !safeText(name, 200))) return null;
|
||||
const pending = Object.freeze({
|
||||
itemIndex: raw.itemIndex,
|
||||
enabled: raw.enabled,
|
||||
actionId,
|
||||
entry: Object.freeze({ id: options.id }),
|
||||
fadeDuration: options.fadeDuration,
|
||||
rawSelection: Object.freeze({
|
||||
groupCode: raw.groupCode,
|
||||
subject: raw.subject,
|
||||
graphicType: raw.graphicType,
|
||||
subtype: raw.subtype,
|
||||
dataCode: raw.dataCode
|
||||
})
|
||||
});
|
||||
pendingValues.add(pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function normalizeWireTarget(value) {
|
||||
if (!isPlainObject(value)) return null;
|
||||
let candidate;
|
||||
switch (value.kind) {
|
||||
case "market-target":
|
||||
if (!hasExactKeys(value, ["kind", "target"])) return null;
|
||||
candidate = value;
|
||||
break;
|
||||
case "krx-stock":
|
||||
case "nxt-stock":
|
||||
if (!hasExactKeys(value, ["kind", "market", "stockName", "stockCode"])) return null;
|
||||
candidate = value;
|
||||
break;
|
||||
case "world-stock":
|
||||
if (!hasExactKeys(value, ["kind", "inputName", "symbol", "nation"])) return null;
|
||||
candidate = { ...value, displayName: value.inputName };
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return comparison.normalizeTarget(candidate);
|
||||
}
|
||||
|
||||
function normalizeOutcome(value, pending) {
|
||||
if (!pendingValues.has(pending) || !isPlainObject(value) ||
|
||||
value.itemIndex !== pending.itemIndex) return null;
|
||||
if (value.status === "failed") {
|
||||
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
|
||||
!failureCodes.has(value.failure)) return null;
|
||||
return Object.freeze({
|
||||
itemIndex: value.itemIndex,
|
||||
status: value.status,
|
||||
failure: value.failure
|
||||
});
|
||||
}
|
||||
if (value.status !== "resolved" ||
|
||||
!hasExactKeys(value, ["itemIndex", "status", "actionId", "first", "second"]) ||
|
||||
value.actionId !== pending.actionId || !actionIds.has(value.actionId)) return null;
|
||||
const first = normalizeWireTarget(value.first);
|
||||
const second = normalizeWireTarget(value.second);
|
||||
if (!first || !second || !comparison.isActionAllowed(value.actionId, [first, second])) return null;
|
||||
return Object.freeze({
|
||||
itemIndex: value.itemIndex,
|
||||
status: value.status,
|
||||
actionId: value.actionId,
|
||||
first,
|
||||
second
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeResponse(value, expectedRequestId, pendingRows) {
|
||||
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
|
||||
pendingRows.length < 1 || pendingRows.length > 1000 ||
|
||||
pendingRows.some(pending => !pendingValues.has(pending)) ||
|
||||
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
|
||||
value.requestId !== expectedRequestId ||
|
||||
typeof value.retrievedAt !== "string" ||
|
||||
!isoTimestampPattern.test(value.retrievedAt) ||
|
||||
!Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
|
||||
value.rows.length !== pendingRows.length) return null;
|
||||
const rows = [];
|
||||
for (let index = 0; index < pendingRows.length; index += 1) {
|
||||
const outcome = normalizeOutcome(value.rows[index], pendingRows[index]);
|
||||
if (!outcome) return null;
|
||||
rows.push(outcome);
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
retrievedAt: value.retrievedAt,
|
||||
rows: Object.freeze(rows)
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeError(value, expectedRequestId) {
|
||||
return requestIdPattern.test(expectedRequestId) &&
|
||||
hasExactKeys(value, ["requestId", "code", "message", "retryable"]) &&
|
||||
value.requestId === expectedRequestId && safeText(value.code, 64) &&
|
||||
safeText(value.message, 1024) && value.retryable === false
|
||||
? Object.freeze({ ...value })
|
||||
: null;
|
||||
}
|
||||
|
||||
function materialize(pending, outcome) {
|
||||
if (!pendingValues.has(pending) || !outcome || outcome.status !== "resolved" ||
|
||||
outcome.itemIndex !== pending.itemIndex || outcome.actionId !== pending.actionId) return null;
|
||||
try {
|
||||
const entry = comparison.createComparisonPlaylistEntry(
|
||||
outcome.actionId,
|
||||
[outcome.first, outcome.second],
|
||||
{ id: pending.entry.id, fadeDuration: pending.fadeDuration });
|
||||
return comparison.restoreComparisonPlaylistEntry({ ...entry, enabled: pending.enabled });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPending(value) {
|
||||
return pendingValues.has(value);
|
||||
}
|
||||
|
||||
return {
|
||||
classify,
|
||||
normalizeResponse,
|
||||
normalizeError,
|
||||
materialize,
|
||||
isPending
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user