From db9bc2dee46fba3be58e73e3e6d9ee0bbe209da2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 17:41:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=ED=92=88=EC=85=88=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EC=99=80=20=ED=92=88=EC=85=88=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 BookData 7권 해독(DBISAM 암호 표 Blowfish-CBC, zlib RTF) 서버 book-store.cjs, /api/book 경로 - 목차 트리·본문 표·제목/본문/키워드 검색·본문 강조·복사하기, 단가산출·내역·일위·기계 붙여넣기 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_book.py | 43 ++++ tools/est-test/PLAN.md | 2 + tools/est-test/book-store.cjs | 271 ++++++++++++++++++++++ tools/est-test/index.html | 3 + tools/est-test/native-book.js | 187 +++++++++++++++ tools/est-test/native-business-actions.js | 3 +- tools/est-test/server.cjs | 22 ++ 7 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_book.py create mode 100644 tools/est-test/book-store.cjs create mode 100644 tools/est-test/native-book.js diff --git a/resources/tester/test_est_book.py b/resources/tester/test_est_book.py new file mode 100644 index 000000000..eca7e7fe0 --- /dev/null +++ b/resources/tester/test_est_book.py @@ -0,0 +1,43 @@ +"""EST 테스트 앱 품셈 보기(원본 BookData 암호 표 해독 · 목차 · 본문 · 검색).""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BOOK = Path("C:/Dasansoft/Est Plus 2/System/BookData/forest.dat") + + +def node(code: str): + return json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8")) + + +def test_blowfish_constants_and_rtf_text(): + out = node(r""" + const b = require('./tools/est-test/book-store.cjs'); + const w = b.piWords(1042); + const rtf = Buffer.from(String.raw`{\rtf1{\fonttbl{\f0 x;}}\'b0\'a1\cell 1.2\row \u44032?\par}`, 'latin1'); + process.stdout.write(JSON.stringify({ p0: w[0].toString(16), last: w[1041].toString(16), text: b.rtfText(rtf) })); + """) + assert out["p0"] == "243f6a88" + assert out["last"] == "3ac372e6" # Blowfish S4[255] + assert out["text"] == "가\t1.2\n가\n" + + +def test_installed_forest_book(): + if not BOOK.exists(): + pytest.skip("installed EST book data unavailable") + out = node(r""" + const b = require('./tools/est-test/book-store.cjs'); + const list = b.list(), tree = b.tree('forest'), page = b.text('forest', 902); + process.stdout.write(JSON.stringify({ books: list.map((x) => x.name), forest: list.find((x) => x.name === 'forest'), + nodes: tree.length, page: page.text.split('\n').filter((l) => l.startsWith('1.0 이하'))[0], + title: b.search('forest', '교목 굴취').map((h) => h.pkid), body: b.search('forest', '근원직경을', 'body').length })); + """) + assert out["books"] == ["civil", "archit", "machine", "electric", "culture", "forest", "woods"] + assert out["forest"]["desc"].startswith("산림품셈(2026)") and out["forest"]["nodes"] == 563 + assert out["page"] == "1.0 이하\t0.072\t0.012\t" + assert 902 in out["title"] + assert out["body"] >= 1 diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 02631c567..571bb77fb 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 품셈 보기·품셈자료 붙여넣기(원본 barbtnBook → ProductBook.exe/TfrmBookViewer, popupPasteBook 10d6e28 — spec_book) — 서버 `book-store.cjs` 가 원본 설치 BookData(7권, DBISAM 암호 표: MD5 키 Blowfish-CBC, BLOB zlib RTF)를 읽음(Node 에 Blowfish 가 없어 π 자리로 상수 만들어 직접 구현, `EST_BOOK_DIR` 로 경로 변경). 화면: 품셈 고르기·목차 트리(펼치기·접기)·본문(표 칸 나눔)·검색(제목 / 제목+본문 / 키워드, 검색어 빈칸·'|' 지움)·본문 검색 강조·복사하기. 붙여넣기(복사한 뒤만): 단가산출은 첫 줄을 마지막 산출근거 칸 커서 자리, 둘째 줄부터 아래 새 줄, 내역·일위·기계는 마지막 칸에 마지막 줄(수치 칸 검사). 사용자 결정: 원본처럼 잠긴 품셈 파일을 읽음. + - 검증완료: `test_est_book.py`(Blowfish 상수 첫·끝 값, RTF 글자, 설치 7권·산림 563마디·교목굴취 표 줄·제목·본문 검색) 및 바인딩 시험 통과. ORCA 브라우저에서 산림품셈 목차 펼치기 → 5-1-2 교목굴취 표 34칸, 「보통인부」 강조, 복사 → 단가산출 붙여넣기 363→385줄, 샘플 복구. - [x] 수량산출서 삽입·선택공종 삭제(원본 TFRMMULLANG btnInsert·btnSelectDel) — 「삽입」: 현재 산출서 앞에 공종명칭·규격을 물어 새 산출서, 「선택공종 삭제」: 고른 여러 산출서를 한 번에 지움. - 검증완료: 산출서·바인딩 시험 7개 통과. ORCA 브라우저에서 둘째 위치에 「끼운 공종/1m」 삽입, 1·3번째 고른 삭제 → 「끼운 공종」만 남음, 샘플 복구. - [x] 새내역서 파일로저장(원본 mnuSaveNewEst 0x113384c) — 현재 화면은 그대로, 표 12개의 「_」 당초값 칸 비움·신규 표시 끔·원가 안전관리비 특례 줄(CalcIndex 4)→1·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음. diff --git a/tools/est-test/book-store.cjs b/tools/est-test/book-store.cjs new file mode 100644 index 000000000..28e3fd1bf --- /dev/null +++ b/tools/est-test/book-store.cjs @@ -0,0 +1,271 @@ +// 원본 품셈 보기 자료(System\BookData — civil·archit·machine·electric·culture·forest·woods) 읽기. +// DBISAM 4 암호 표: 키 = MD5(원본 세션 암호), Blowfish-CBC(IV 0) — .dat 머리 0x200 은 평문, 필드 설명은 한 사슬, +// 레코드는 레코드마다·.blb 512 바이트 블록은 블록마다 사슬을 새로 시작. 필드 pkid·upperid·orderno·chapter·title·keywords·content(BLOB). +// 본문 BLOB = zlib 압축 RTF(WPTools) → 글자(문단 줄바꿈, 표 칸 탭). Node 가 Blowfish 를 빼서 직접 구현(상수는 π 16진 자리). +// 설치 폴더는 EST_BOOK_DIR 로 바꿀 수 있다. 읽기 전용. +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const crypto = require("crypto"); + +const DIR = process.env.EST_BOOK_DIR || "C:/Dasansoft/Est Plus 2/System/BookData"; +const PASSWORD = 'LW`bdGD"4['; + +// π 소수부 16진 자리 → Blowfish P(18)·S(4×256) 초기값 +function piWords(count) { + const digits = count * 8 + 8, + scale = 1n << BigInt(digits * 4 + 64); + const atan = (x) => { + const xx = x * x; + let term = scale / x, + sum = term, + k = 1n; + for (let sign = -1n; term; sign = -sign, k += 2n) { + term /= xx; + sum += (sign * term) / (k + 2n); + } + return sum; + }; + const pi = 16n * atan(5n) - 4n * atan(239n); + const frac = (pi - 3n * scale) >> 64n; + const hex = frac.toString(16).padStart(digits, "0"); + return Array.from({ length: count }, (_, i) => parseInt(hex.slice(i * 8, i * 8 + 8), 16) >>> 0); +} + +function blowfish(key) { + const init = piWords(18 + 1024), + P = init.slice(0, 18), + S = [0, 1, 2, 3].map((i) => init.slice(18 + i * 256, 18 + (i + 1) * 256)); + const F = (x) => + ((((S[0][x >>> 24] + S[1][(x >>> 16) & 255]) >>> 0) ^ S[2][(x >>> 8) & 255]) + + S[3][x & 255]) >>> + 0; + function encrypt(l, r) { + for (let i = 0; i < 16; i++) { + l = (l ^ P[i]) >>> 0; + r = (r ^ F(l)) >>> 0; + [l, r] = [r, l]; + } + [l, r] = [r, l]; + r = (r ^ P[16]) >>> 0; + l = (l ^ P[17]) >>> 0; + return [l, r]; + } + function decrypt(l, r) { + for (let i = 17; i > 1; i--) { + l = (l ^ P[i]) >>> 0; + r = (r ^ F(l)) >>> 0; + [l, r] = [r, l]; + } + [l, r] = [r, l]; + r = (r ^ P[1]) >>> 0; + l = (l ^ P[0]) >>> 0; + return [l, r]; + } + for (let i = 0, k = 0; i < 18; i++) { + let w = 0; + for (let j = 0; j < 4; j++, k++) w = ((w << 8) | key[k % key.length]) >>> 0; + P[i] = (P[i] ^ w) >>> 0; + } + let l = 0, + r = 0; + for (let i = 0; i < 18; i += 2) { + [l, r] = encrypt(l, r); + P[i] = l; + P[i + 1] = r; + } + for (const box of S) + for (let i = 0; i < 256; i += 2) { + [l, r] = encrypt(l, r); + box[i] = l; + box[i + 1] = r; + } + // CBC 풀기(IV 0), 8 바이트 미만 꼬리는 그대로 + return function cbc(buf) { + const out = Buffer.from(buf), + n = buf.length - (buf.length % 8); + let pl = 0, + pr = 0; + for (let o = 0; o < n; o += 8) { + const cl = buf.readUInt32BE(o), + cr = buf.readUInt32BE(o + 4), + [dl, dr] = decrypt(cl, cr); + out.writeUInt32BE((dl ^ pl) >>> 0, o); + out.writeUInt32BE((dr ^ pr) >>> 0, o + 4); + pl = cl; + pr = cr; + } + return out; + }; +} + +let cbc = null; +const dec = (buf) => + (cbc ||= blowfish(crypto.createHash("md5").update(PASSWORD, "latin1").digest()))(buf); +const cp949 = new TextDecoder("euc-kr"); +const books = new Map(); + +function load(name) { + if (books.has(name)) return books.get(name); + if (!/^[a-z]+$/.test(name)) throw Error("품셈 이름이 올바르지 않습니다."); + const d = fs.readFileSync(path.join(DIR, `${name}.dat`)); + const size = d.readUInt16LE(0x2d), + nf = d[0x2f], + descEnd = d.indexOf(0, 0x48), + desc = cp949.decode(d.subarray(0x48, Math.min(descEnd < 0 ? 0x68 : descEnd, 0x68))); + const fd = dec(d.subarray(0x200, 0x200 + nf * 0x300)); + const fields = []; + for (let i = 0; i < nf; i++) { + const b = fd.subarray(i * 0x300, (i + 1) * 0x300); + fields.push([b.subarray(3, 3 + b[2]).toString("latin1"), b[0xa4], b.readUInt16LE(0xac)]); + } + const rows = []; + for (let o = 0x200 + nf * 0x300; o + size <= d.length; o += size) { + const r = dec(d.subarray(o, o + size)), + row = {}; + for (const [fn, t, off] of fields) { + if (!r[off]) row[fn] = null; + else if (t === 6) row[fn] = r.readInt32LE(off + 1); + else if (t === 1) { + const end = r.indexOf(0, off + 1); + row[fn] = cp949.decode(r.subarray(off + 1, end < 0 ? off + 302 : Math.min(end, off + 302))); + } else row[fn] = r.readUInt32LE(off + 1); + } + if (row.pkid != null) rows.push(row); + } + const book = { name, desc, rows, byId: new Map(rows.map((r) => [r.pkid, r])), texts: new Map() }; + books.set(name, book); + return book; +} + +function blob(name, first) { + const b = fs.readFileSync(path.join(DIR, `${name}.blb`)); + const parts = []; + let block = first, + total = null, + got = 0; + const seen = new Set(); + while (block && !seen.has(block)) { + seen.add(block); + const x = dec(b.subarray(block * 512, (block + 1) * 512)), + next = x.readUInt32LE(4), + length = x.readUInt16LE(8); + if (total == null) total = x.readUInt32LE(14); + parts.push(x.subarray(18, 18 + length)); + got += length; + block = next; + if (got >= total) break; + } + return zlib.inflateSync(Buffer.concat(parts)); +} + +const SKIP = /^\{\\(\*|fonttbl|colortbl|stylesheet|info|pict|listtable|listoverridetable|object)/; +function rtfText(raw) { + const s = raw.toString("latin1"), + out = [], + pend = []; + let i = 0, + depth = 0, + skip = null, + uc = 1; + const flush = () => { + if (pend.length) out.push(cp949.decode(Buffer.from(pend.splice(0)))); + }; + while (i < s.length) { + const c = s[i]; + if (c === "{") { + depth++; + if (skip == null && SKIP.test(s.slice(i, i + 24))) skip = depth; + i++; + } else if (c === "}") { + if (skip === depth) skip = null; + depth--; + i++; + } else if (c === "\\") { + const hx = /^\\'([0-9a-fA-F]{2})/.exec(s.slice(i, i + 4)); + if (hx) { + if (skip == null) pend.push(parseInt(hx[1], 16)); + i += 4; + continue; + } + const cw = /^\\([a-zA-Z]+)(-?\d+)? ?/.exec(s.slice(i, i + 40)); + if (cw) { + const [whole, w, a] = cw; + i += whole.length; + if (skip != null) continue; + if (w === "u" && a) { + flush(); + out.push(String.fromCharCode(Number(a) & 0xffff)); + i += uc; + } else if (w === "uc" && a) uc = Number(a); + else if (["par", "line", "row"].includes(w)) { + flush(); + out.push("\n"); + } else if (["cell", "tab"].includes(w)) { + flush(); + out.push("\t"); + } + continue; + } + if (skip == null && "\\{}".includes(s[i + 1] ?? "")) pend.push(s.charCodeAt(i + 1)); + i += 2; + } else if (c === "\r" || c === "\n") i++; + else { + if (skip == null) pend.push(s.charCodeAt(i) & 0xff); + i++; + } + } + flush(); + return out.join(""); +} + +function list() { + const names = fs + .readFileSync(path.join(DIR, "filelist.ini"), "latin1") + .split(/\s+/) + .filter(Boolean); + return names.map((name) => { + const b = load(name); + return { name, desc: b.desc, nodes: b.rows.length }; + }); +} +function tree(name) { + const b = load(name); + return b.rows.map(({ pkid, upperid, orderno, chapter, title, content }) => ({ + pkid, + parent: b.byId.has(upperid) ? upperid : null, + order: orderno, + chapter: chapter ?? "", + title: title ?? "", + content: Boolean(content), + })); +} +function text(name, pkid) { + const b = load(name), + r = b.byId.get(Number(pkid)); + if (!r) throw Error("품셈 항목이 없습니다."); + if (!r.content) return { pkid: r.pkid, chapter: r.chapter, title: r.title, text: "" }; + if (!b.texts.has(r.pkid)) b.texts.set(r.pkid, rtfText(blob(name, r.content))); + return { + pkid: r.pkid, + chapter: r.chapter ?? "", + title: r.title ?? "", + text: b.texts.get(r.pkid), + }; +} +// 원본 전체검색(actFullSearch 0xdf8514): 검색어의 빈칸·'|' 를 지우고 제목(또는 제목+본문)에 들어 있으면. +// 키워드검색(actKeywordSearch): keywords 칸에 들어 있으면. +function search(name, word, mode = "title") { + const b = load(name), + kw = String(word ?? "").replace(/[\s|]/g, ""); + if (!kw) throw Error("검색어를 입력하세요."); + return b.rows + .filter((r) => { + if (mode === "keyword") return String(r.keywords ?? "").includes(kw); + if (String(r.title ?? "").includes(kw)) return true; + return mode === "body" && r.content && text(name, r.pkid).text.includes(kw); + }) + .map((r) => ({ pkid: r.pkid, chapter: r.chapter ?? "", title: r.title ?? "" })); +} + +module.exports = { list, tree, text, search, piWords, blowfish, rtfText }; diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 64716fc48..efcaa1d4b 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -65,6 +65,7 @@ + @@ -298,6 +299,8 @@ > { + let lastCell = null; + if (typeof document !== "undefined" && document.addEventListener) + document.addEventListener("focusin", (event) => { + const t = event.target.closest?.("[data-table][data-field][data-row]"); + if (t) lastCell = t; + }); + + const api = (p) => EstApp.api(`/api/book${p}`); + const st = (state) => (state.book ||= { name: "", open: new Set(), hits: null, pkid: null }); + + function treeHtml(b, nodes) { + const { esc } = EstApp, + kids = new Map(); + for (const n of nodes) { + const k = n.parent ?? "root"; + if (!kids.has(k)) kids.set(k, []); + kids.get(k).push(n); + } + for (const list of kids.values()) + list.sort((a, c) => (a.order == null) - (c.order == null) || (a.order ?? 0) - (c.order ?? 0)); + const walk = (key, depth) => + (kids.get(key) || []) + .map((n) => { + const has = kids.has(n.pkid), + open = b.open.has(n.pkid); + return `
${has ? `` : ""}${esc(n.chapter)} ${esc(n.title)}
${has && open ? walk(n.pkid, depth + 1) : ""}`; + }) + .join(""); + return walk("root", 0); + } + function pageHtml(page, mark) { + const { esc } = EstApp; + const hl = (t) => + mark + ? esc(t) + .split(esc(mark)) + .join(`${esc(mark)}`) + : esc(t); + const lines = String(page.text || "").split("\n"); + let html = "", + table = []; + const flush = () => { + if (table.length) + html += `${table.map((cells) => `${cells.map((c) => ``).join("")}`).join("")}
${hl(c)}
`; + table = []; + }; + for (const line of lines) { + if (line.includes("\t")) table.push(line.replace(/\t$/, "").split("\t")); + else { + flush(); + html += `
${hl(line) || " "}
`; + } + } + flush(); + return `

${esc(page.chapter)} ${esc(page.title)}

${html}`; + } + + async function render(state) { + const b = st(state), + { esc } = EstApp; + b.books ||= await api(""); + b.name ||= b.books[0]?.name; + b.tree = b.treeName === b.name ? b.tree : await api(`/${b.name}/tree`); + b.treeName = b.name; + const left = b.hits + ? `

검색 결과 ${b.hits.length}건

${b.hits.map((h) => `
${esc(h.chapter)} ${esc(h.title)}
`).join("")}` + : treeHtml(b, b.tree); + EstApp.showDialog( + "품셈", + `
${left}
${b.page ? pageHtml(b.page, b.mark) : "

왼쪽에서 항목을 고르세요.

"}
`, + ); + } + + async function run(name, state, node) { + const b = st(state), + v = (sel) => document.querySelector(`#dialog-content ${sel}`)?.value; + if (name === "book") return render(state); + if (name === "book-fold") { + const id = Number(node.dataset.pkid); + if (b.open.has(id)) b.open.delete(id); + else b.open.add(id); + } else if (name === "book-expand") b.open = new Set(b.tree.map((n) => n.pkid)); + else if (name === "book-collapse") b.open = new Set(); + else if (name === "book-tree") b.hits = null; + else if (name === "book-search") { + b.word = v("[data-book-word]") || ""; + b.hits = await api( + `/${b.name}/search?q=${encodeURIComponent(b.word)}&mode=${node.dataset.mode}`, + ); + } else if (name === "book-mark") b.mark = (v("[data-book-mark]") || "").trim(); + else if (name === "book-copy") { + const sel = String(getSelection()).trim(), + text = sel || b.page?.text || ""; + if (!text) throw Error("복사할 품셈 내용을 고르세요."); + state.bookClip = text; + navigator.clipboard?.writeText(text).catch(() => {}); + state.notice = "품셈 내용을 복사했습니다. 품셈자료 붙여넣기로 넣을 수 있습니다."; + } else if (name === "book-paste") return paste(state); + return render(state); + } + + function paste(state) { + if (!state.bookClip) throw Error("품셈에서 먼저 복사하기를 하세요."); + const lines = state.bookClip.split(/\r?\n/); + if (!lastCell) throw Error("붙여넣을 칸을 먼저 누르세요."); + const table = lastCell.dataset.table, + row = Number(lastCell.dataset.row), + field = lastCell.dataset.field; + if (table === "CostMainDat") { + if (field !== "LineData") throw Error("단가산출 산출근거 칸을 누른 뒤 붙여넣으세요."); + const caret = lastCell.selectionStart ?? lastCell.value.length, + shown = lastCell.value, + first = shown.slice(0, caret) + lines[0] + shown.slice(lastCell.selectionEnd ?? caret); + EstApp.change((project) => { + const rows = project.tables.CostMainDat; + rows[row].LineData = EstText.encode(first); + let pk = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0)); + rows.splice( + row + 1, + 0, + ...lines.slice(1).map((LineData) => ({ PkID: ++pk, Index: 0, LineData })), + ); + }); + } else if (["EstMainDat", "HeadMainDat", "MachMainDat"].includes(table)) { + const line = lines.at(-1).trim(); + if (["Code", "SCheck"].includes(field)) throw Error("이 칸에는 붙여넣을 수 없습니다."); + let value = line; + if (EstModel.numeric.has(field)) { + if (!/^[+-]?[\d,.]+$/.test(line)) throw Error("현재 칼럼에는 수치만 붙여넣을수 있습니다."); + value = Number(line) || 0; + } + EstApp.change((project) => (project.tables[table][row][field] = value)); + } else throw Error("내역서·일위대가·단가산출·기계경비 칸에 붙여넣으세요."); + return true; + } + + function install() { + document.addEventListener("click", (event) => { + const a = event.target.closest("[data-book-node]"); + if (!a) return; + event.preventDefault(); + const state = EstApp.state, + b = st(state); + b.pkid = Number(a.dataset.bookNode); + api(`/${b.name}/text/${b.pkid}`) + .then((page) => { + b.page = page; + return render(state); + }) + .catch((error) => (state.notice = error.message)); + }); + document.addEventListener("change", (event) => { + if (event.target.dataset.bookName === undefined) return; + const b = st(EstApp.state); + Object.assign(b, { name: event.target.value, open: new Set(), hits: null, page: null }); + render(EstApp.state).catch((error) => (EstApp.state.notice = error.message)); + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + + const names = new Set([ + "book", + "book-fold", + "book-expand", + "book-collapse", + "book-tree", + "book-search", + "book-mark", + "book-copy", + "book-paste", + ]); + function handle(name, state, node) { + if (!names.has(name)) return false; + run(name, state, node).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + return { handle }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index c0f860ad5..1dbe6be79 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -636,7 +636,8 @@ const EstBusinessActions = (() => { (typeof EstEsInfo !== "undefined" && EstEsInfo.handle(name, state)) || (typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) || (typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) || - (typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) + (typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) || + (typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) ) { EstApp.render(); } else if (name === "insert-annual") { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index f0b3eed65..e062da7f3 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -6,6 +6,7 @@ const http = require("node:http"), crypto = require("node:crypto"); const { importEstx, table: dbisamTable } = require("./import-estx.cjs"); const { readXls } = require("./xls-read.cjs"); +const bookStore = require("./book-store.cjs"); const exchange = require("./exchange.cjs"); const { exportEstx } = require("./export-estx.cjs"); const jdl = require("./jdl.cjs"); @@ -87,6 +88,7 @@ const allowed = new Set([ "native-jdl-compare.js", "native-global-vars.js", "native-new-estimate.js", + "native-book.js", "native-wonga-tools.js", "native-estimate-print.js", "native-xml-check.js", @@ -249,6 +251,26 @@ const server = http.createServer(async (req, res) => { reply(res, 200, importEstx(bytes, name)); return; } + if (req.method === "GET" && url.pathname.startsWith("/api/book")) { + // 품셈 보기(원본 BookData) — /api/book · /api/book/<책>/tree · /text/ · /search?q=&mode=title|body|keyword + const [, book, what, id] = url.pathname.slice(1).split("/").slice(1); + reply( + res, + 200, + !book + ? bookStore.list() + : what === "tree" + ? bookStore.tree(book) + : what === "text" + ? bookStore.text(book, id) + : what === "search" + ? bookStore.search(book, url.searchParams.get("q"), url.searchParams.get("mode")) + : (() => { + throw Error("품셈 요청 경로 없음"); + })(), + ); + return; + } if (req.method === "POST" && url.pathname === "/api/read-xls") { reply(res, 200, readXls(await body(req))); return;