Files
Aislo/tools/est-test/native-exchange.js
T

280 lines
12 KiB
JavaScript

const EstExchange = (() => {
function xmlText(buffer) {
const bytes = new Uint8Array(buffer);
if (bytes[0] === 0xff && bytes[1] === 0xfe)
return new TextDecoder("utf-16le", { fatal: true }).decode(bytes);
if (bytes[0] === 0xfe && bytes[1] === 0xff)
return new TextDecoder("utf-16be", { fatal: true }).decode(bytes);
const header = new TextDecoder("ascii").decode(bytes.slice(0, 250)),
declared = header.match(/encoding\s*=\s*["']([^"']+)["']/i)?.[1]?.toLowerCase() || "utf-8",
encoding = ["cp949", "ks_c_5601-1987", "euc-kr", "ms949"].includes(declared)
? "euc-kr"
: declared;
if (!["utf-8", "utf8", "euc-kr", "utf-16", "utf-16le", "utf-16be"].includes(encoding))
throw Error("지원하지 않는 XML 인코딩: " + encoding);
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
}
function parse(source) {
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.querySelector("parsererror")) throw Error("XML 구문 오류");
return doc;
}
function read(files) {
const workbook = parse(files["xl/workbook.xml"]),
rels = parse(files["xl/_rels/workbook.xml.rels"]);
const shared = files["xl/sharedStrings.xml"]
? [...parse(files["xl/sharedStrings.xml"]).getElementsByTagNameNS("*", "si")].map((si) =>
[...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""),
)
: [];
const tables = {};
for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) {
const name = sheet.getAttribute("name");
if (!EstNative.empty().tables[name] || name.startsWith("Cad")) continue;
const id = sheet.getAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"id",
),
rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find(
(r) => r.getAttribute("Id") === id,
),
target = rel?.getAttribute("Target") || "";
const path = target.startsWith("/") ? target.slice(1) : "xl/" + target;
if (!files[path]) throw Error(`Excel 시트 파일 없음: ${name}`);
const rows = [...parse(files[path]).getElementsByTagNameNS("*", "row")]
.map((row) => {
const values = [];
for (const cell of row.getElementsByTagNameNS("*", "c")) {
const address = cell.getAttribute("r") || "",
letters = address.match(/^[A-Z]+/)?.[0];
if (!letters) throw Error("Excel 셀 주소 오류");
const at = [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
if (at >= 16384) throw Error("Excel 열 범위 오류");
if (cell.getElementsByTagNameNS("*", "f").length)
throw Error(`${name} ${address}: 수식은 Excel에서 값으로 붙여넣은 뒤 가져오세요.`);
const type = cell.getAttribute("t"),
v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
let value;
if (type === "s") {
value = shared[Number(v)];
if (value == null) throw Error("Excel 공유 문자열 오류");
} else if (type === "inlineStr")
value = [...cell.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join("");
else if (type === "b") value = v === "1";
else if (type === "e") throw Error(`${name} ${address}: Excel 오류값`);
else if (type === "str") value = v || "";
else if (v != null) {
value = Number(v);
if (!Number.isFinite(value)) throw Error("Excel 숫자 오류");
}
values[at] = value;
}
return values;
})
.filter((row) => row.some((v) => v != null));
const headers = rows.shift() || [];
if (
!headers.length ||
headers.some(
(k) =>
typeof k !== "string" ||
!/^[A-Za-z][A-Za-z0-9_]*$/.test(k) ||
["constructor", "prototype"].includes(k),
) ||
new Set(headers).size !== headers.length
)
throw Error(`${name}: 필드명 행이 올바르지 않습니다.`);
tables[name] = rows.map((values) =>
Object.fromEntries(headers.map((k, i) => [k, values[i] ?? null])),
);
}
if (!Object.keys(tables).length)
throw Error("가져올 EST 표 이름이 없습니다. 이 앱에서 내보낸 Excel 표를 사용하세요.");
return tables;
}
function apply(project, tables) {
const next = structuredClone(project);
for (const [table, rows] of Object.entries(tables)) {
const old = next.tables[table],
byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r]));
const keys = rows[0] ? Object.keys(rows[0]) : [];
if (
!keys.includes("PkID") &&
old.some((r) => Object.values(r).some((v) => v && typeof v === "object"))
)
throw Error(`${table}: 바이너리를 보존하려면 PkID 열이 필요합니다.`);
const seen = new Set();
next.tables[table] = rows.map((row) => {
if (row.PkID != null) {
if (seen.has(row.PkID)) throw Error(`${table}: PkID 중복`);
seen.add(row.PkID);
}
const previous = byId.get(row.PkID);
// Keep original blobs and fields deliberately omitted from the workbook.
const merged = { ...previous, ...row };
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table)) {
if (
previous &&
row.TC !== previous.TC &&
(row.InputTC ?? previous.InputTC) === previous.InputTC
) {
merged.InputTC = row.TC;
merged.ValueIndex = 4;
}
for (const component of ["NC", "GC", "JC", "SC"])
if (
previous &&
row[component] !== previous[component] &&
(row["Input" + component] ?? previous["Input" + component]) ===
previous["Input" + component]
)
merged["Input" + component] = row[component];
if (
!previous ||
Object.keys(row).some((key) => (row[key] ?? null) !== (previous[key] ?? null))
)
next.options.SourceRefresh = true;
}
for (const key of keys)
if (previous?.[key] && typeof previous[key] === "object") merged[key] = previous[key];
if (Object.hasOwn(row, "Code") && (previous?.Code ?? "") !== (row.Code ?? "") && previous)
throw Error("Excel에서 기존 코드를 바꿀 수 없습니다. 앱의 코드 편집을 사용하세요.");
return merged;
});
}
return EstModel.validate(next);
}
async function exportExcel() {
document.activeElement?.blur();
const response = await fetch("/api/export-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(EstApp.state.project),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${EstApp.state.project.name}.xlsx`,
await response.blob(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
}
async function exportNative() {
document.activeElement?.blur();
const response = await fetch("/api/export-estx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(EstApp.state.project),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${EstApp.state.project.name}_edited.estx`,
await response.blob(),
"application/octet-stream",
);
}
async function exportJdl() {
document.activeElement?.blur();
const project = EstApp.state.project,
out = await EstApp.api("/api/export-jdl", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(project),
});
if (!confirm(out.warnings.slice(0, 15).join("\n") + "\nJDL 기본 표로 내보낼까요?")) return;
EstApp.download(
`${project.name}.jdl`,
Uint8Array.from(atob(out.data), (c) => c.charCodeAt(0)),
"application/octet-stream",
);
}
function install() {
const jdlInput = document.createElement("input");
jdlInput.type = "file";
jdlInput.accept = ".jdl";
jdlInput.hidden = true;
jdlInput.id = "jdl-file";
document.body.append(jdlInput);
jdlInput.addEventListener("change", async () => {
const file = jdlInput.files[0],
key = EstApp.state.documentKey,
revision = EstApp.state.revision;
if (!file) return;
try {
const next = await EstApp.api("/api/import-jdl", {
method: "POST",
body: await file.arrayBuffer(),
});
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
if (confirm("현재 작업 대신 JDL 내역서를 여시겠습니까?")) EstApp.setProject(next);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
jdlInput.value = "";
}
});
const xmlInput = document.createElement("input");
xmlInput.type = "file";
xmlInput.accept = ".xml";
xmlInput.hidden = true;
xmlInput.id = "xml-file";
document.body.append(xmlInput);
xmlInput.addEventListener("change", async () => {
const file = xmlInput.files[0],
key = EstApp.state.documentKey,
revision = EstApp.state.revision;
if (!file) return;
try {
const next = EstC3r.read(xmlText(await file.arrayBuffer()));
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
if (confirm("현재 작업 대신 XML 내역서를 여시겠습니까?")) EstApp.setProject(next);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
xmlInput.value = "";
}
});
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.hidden = true;
input.id = "excel-file";
document.body.append(input);
input.addEventListener("change", async () => {
const file = input.files[0];
if (!file) return;
const key = EstApp.state.documentKey,
revision = EstApp.state.revision;
try {
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
}),
tables = read(files);
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. Excel을 다시 불러오세요.");
const next = apply(EstApp.state.project, tables);
if (
!confirm(
`${Object.entries(tables)
.map(([name, rows]) => `${EstApp.titles[name] || name}: ${rows.length}행`)
.join("\n")}\n현재 작업의 해당 표를 교체합니다.`,
)
)
return;
EstApp.change(() => (EstApp.state.project = next));
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
input.value = "";
}
});
}
return { read, apply, exportExcel, exportNative, exportJdl, install };
})();