Files
MBN_STOCK_WEBVIEW/Web/comparison-import-workflow.js

236 lines
9.6 KiB
JavaScript

(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.MbnComparisonImportWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (comparison) {
"use strict";
if (!comparison) throw new Error("Comparison workflow dependency is unavailable.");
const IMPORT_SCHEMA_VERSION = 1;
const STORAGE_ENVELOPE_SCHEMA_VERSION = 1;
const MAXIMUM_PAIRS = 500;
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const digestPattern = /^[A-F0-9]{64}$/;
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/u;
const importedRecordIdPattern = /^legacy-compare-[a-f0-9]{12}-[1-9][0-9]{0,2}$/u;
function hasOnlyKeys(value, expected) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const keys = Object.keys(value).sort();
const wanted = [...expected].sort();
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index]);
}
function safeText(value, maximumLength) {
return typeof value === "string" && value.length > 0 && value.length <= maximumLength &&
value === value.trim() && !controlCharacterPattern.test(value)
? value
: null;
}
function normalizeWireTarget(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
typeof value.kind !== "string") return null;
let candidate;
switch (value.kind) {
case "market-target":
if (!hasOnlyKeys(value, ["kind", "target"]) || !safeText(value.target, 64)) return null;
candidate = { kind: value.kind, target: value.target };
break;
case "krx-stock":
case "nxt-stock":
if (!hasOnlyKeys(value, ["kind", "market", "stockName", "stockCode"]) ||
!safeText(value.market, 8) || !safeText(value.stockName, 200) ||
!safeText(value.stockCode, 32)) return null;
candidate = {
kind: value.kind,
market: value.market,
stockName: value.stockName,
stockCode: value.stockCode
};
break;
case "world-stock":
if (!hasOnlyKeys(value, ["kind", "inputName", "symbol", "nation"]) ||
!safeText(value.inputName, 200) || !safeText(value.symbol, 64) ||
!safeText(value.nation, 2)) return null;
candidate = {
kind: value.kind,
inputName: value.inputName,
displayName: value.inputName,
symbol: value.symbol,
nation: value.nation
};
break;
default:
return null;
}
return comparison.normalizeTarget(candidate);
}
function normalizeImportResponse(value, expectedRequestId) {
if (!requestIdPattern.test(String(expectedRequestId || "")) ||
!hasOnlyKeys(value, ["requestId", "sourceSha256", "sourceRowCount", "retrievedAt", "pairs"]) ||
value.requestId !== expectedRequestId ||
typeof value.sourceSha256 !== "string" || !digestPattern.test(value.sourceSha256) ||
!Number.isInteger(value.sourceRowCount) || value.sourceRowCount < 1 ||
value.sourceRowCount > MAXIMUM_PAIRS ||
typeof value.retrievedAt !== "string" || !isoDatePattern.test(value.retrievedAt) ||
!Number.isFinite(Date.parse(value.retrievedAt)) ||
!Array.isArray(value.pairs) || value.pairs.length !== value.sourceRowCount) return null;
const pairs = [];
const signatures = new Set();
for (let index = 0; index < value.pairs.length; index += 1) {
const raw = value.pairs[index];
if (!hasOnlyKeys(raw, ["rowNumber", "first", "second"]) ||
raw.rowNumber !== index + 1) return null;
const first = normalizeWireTarget(raw.first);
const second = normalizeWireTarget(raw.second);
const pair = comparison.normalizePair([first, second]);
if (!pair) return null;
const signature = pair.map(comparison.targetKey).join("\u001f");
if (!signature || signatures.has(signature)) return null;
signatures.add(signature);
pairs.push(Object.freeze({ rowNumber: index + 1, pair }));
}
return Object.freeze({
requestId: value.requestId,
sourceSha256: value.sourceSha256,
sourceRowCount: value.sourceRowCount,
retrievedAt: value.retrievedAt,
pairs: Object.freeze(pairs)
});
}
function pairSignature(value) {
const pair = comparison.normalizePair(value);
return pair ? pair.map(comparison.targetKey).join("\u001f") : null;
}
function mergeImport(currentValue, importValue) {
const current = comparison.normalizePairList(currentValue);
if (!importValue || !Array.isArray(importValue.pairs) ||
!digestPattern.test(String(importValue.sourceSha256 || "")) ||
!Number.isInteger(importValue.sourceRowCount) ||
importValue.sourceRowCount !== importValue.pairs.length ||
importValue.sourceRowCount < 1 || importValue.sourceRowCount > MAXIMUM_PAIRS ||
current.pairs.length + importValue.pairs.length > MAXIMUM_PAIRS) {
throw new RangeError("The legacy comparison import cannot fit the saved-pair schema.");
}
const ids = new Set(current.pairs.map(record => record.id));
const signatures = new Set(current.pairs.map(record => pairSignature(record.pair)));
let merged = current;
for (let index = 0; index < importValue.pairs.length; index += 1) {
const imported = importValue.pairs[index];
if (imported?.rowNumber !== index + 1) {
throw new TypeError("The legacy comparison import row order is invalid.");
}
const signature = pairSignature(imported?.pair);
if (!signature || signatures.has(signature)) {
throw new RangeError("The legacy comparison import contains an existing pair.");
}
const id = `legacy-compare-${importValue.sourceSha256.slice(0, 12).toLocaleLowerCase("en-US")}-${imported.rowNumber}`;
if (ids.has(id)) {
throw new RangeError("The legacy comparison import id already exists.");
}
const record = comparison.createPairRecord(id, imported.pair);
if (!record) throw new TypeError("The legacy comparison import pair is invalid.");
merged = comparison.appendPair(merged, record);
ids.add(id);
signatures.add(signature);
}
return merged;
}
function createMarker(importValue, importedAt = new Date().toISOString()) {
if (!importValue || !digestPattern.test(String(importValue.sourceSha256 || "")) ||
!Number.isInteger(importValue.sourceRowCount) || importValue.sourceRowCount < 1 ||
importValue.sourceRowCount > MAXIMUM_PAIRS ||
typeof importedAt !== "string" || !isoDatePattern.test(importedAt) ||
!Number.isFinite(Date.parse(importedAt))) return null;
return Object.freeze({
schemaVersion: IMPORT_SCHEMA_VERSION,
sourceSha256: importValue.sourceSha256,
importedPairCount: importValue.sourceRowCount,
importedAt
});
}
function normalizeMarker(value) {
if (!hasOnlyKeys(value, ["schemaVersion", "sourceSha256", "importedPairCount", "importedAt"]) ||
value.schemaVersion !== IMPORT_SCHEMA_VERSION ||
!digestPattern.test(String(value.sourceSha256 || "")) ||
!Number.isInteger(value.importedPairCount) || value.importedPairCount < 1 ||
value.importedPairCount > MAXIMUM_PAIRS ||
typeof value.importedAt !== "string" || !isoDatePattern.test(value.importedAt) ||
!Number.isFinite(Date.parse(value.importedAt))) return null;
return Object.freeze({
schemaVersion: IMPORT_SCHEMA_VERSION,
sourceSha256: value.sourceSha256,
importedPairCount: value.importedPairCount,
importedAt: value.importedAt
});
}
function normalizeExactPairList(value) {
if (!hasOnlyKeys(value, ["schemaVersion", "pairs"]) || !Array.isArray(value.pairs) ||
value.pairs.length > MAXIMUM_PAIRS || value.pairs.some(record =>
!hasOnlyKeys(record, ["id", "pair"]))) return null;
const normalized = comparison.normalizePairList(value);
if (normalized.pairs.length !== value.pairs.length ||
normalized.pairs.some((record, index) => record.id !== value.pairs[index].id)) return null;
return normalized;
}
function normalizeStorageEnvelope(value) {
if (!hasOnlyKeys(value, ["schemaVersion", "pairList", "legacyImportMarker"]) ||
value.schemaVersion !== STORAGE_ENVELOPE_SCHEMA_VERSION) return null;
const pairList = normalizeExactPairList(value.pairList);
const marker = value.legacyImportMarker === null
? null
: normalizeMarker(value.legacyImportMarker);
if (!pairList || (value.legacyImportMarker !== null && !marker) ||
(!marker && pairList.pairs.some(record => importedRecordIdPattern.test(record.id)))) {
return null;
}
return Object.freeze({
schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
pairList,
legacyImportMarker: marker
});
}
function createStorageEnvelope(pairList, marker = null) {
return normalizeStorageEnvelope({
schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
pairList,
legacyImportMarker: marker
});
}
return {
schemaVersion: IMPORT_SCHEMA_VERSION,
storageEnvelopeSchemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
maximumPairs: MAXIMUM_PAIRS,
normalizeImportResponse,
mergeImport,
createMarker,
normalizeMarker,
createStorageEnvelope,
normalizeStorageEnvelope
};
});