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