feat(est-test): 품셈 보기와 품셈자료 붙여넣기

- 원본 BookData 7권 해독(DBISAM 암호 표 Blowfish-CBC, zlib RTF) 서버 book-store.cjs, /api/book 경로
- 목차 트리·본문 표·제목/본문/키워드 검색·본문 강조·복사하기, 단가산출·내역·일위·기계 붙여넣기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 17:41:02 +09:00
co-authored by Claude Opus 5.5
parent b97ecf47de
commit db9bc2dee4
7 changed files with 530 additions and 1 deletions
+43
View File
@@ -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
+2
View File
@@ -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·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음.
+271
View File
@@ -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 };
+3
View File
@@ -65,6 +65,7 @@
<script defer src="native-jdl-compare.js"></script>
<script defer src="native-global-vars.js"></script>
<script defer src="native-new-estimate.js"></script>
<script defer src="native-book.js"></script>
<script defer src="native-wonga-tools.js"></script>
<script defer src="native-estimate-print.js"></script>
<script defer src="native-xml-check.js"></script>
@@ -298,6 +299,8 @@
><button id="unit-calc-btn" data-action="unit-calc" hidden>단위환산</button
><button id="sources-btn" data-action="sources" hidden title="발췌근거 삭제,삽입 및 변경">발췌근거</button
><button id="uses-search-btn" data-action="uses-data-search">사용위치</button
><button data-action="book">품셈</button
><button data-action="book-paste">품셈자료 붙여넣기</button
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
><button data-action="paste-row">붙여넣기</button
><button data-action="delete-row">삭제</button
+187
View File
@@ -0,0 +1,187 @@
// 원본 품셈 보기(barbtnBook → ProductBook.exe · 같은 화면 TfrmBookViewer)와 품셈자료 붙여넣기(popupPasteBook 10d6e28).
// 보기: 품셈 고르기 → 장 트리(펼치기·접기) → 본문(표는 칸 나눔), 검색(제목 · 제목+본문 · 키워드, 검색어 빈칸·'|' 지움),
// 본문 검색(현재 쪽 강조), 복사하기(고른 글 또는 쪽 전체 → 클립보드, 붙여넣기 켬). 자료는 서버 book-store.cjs 가 원본 BookData 를 읽음.
// 붙여넣기(원본은 품셈에서 복사한 뒤에만 켜짐): 단가산출은 첫 줄을 마지막 산출근거 칸 커서 자리에, 둘째 줄부터 그 줄 아래 새 줄로(다듬지 않음).
// 내역서·일위대가·기계경비는 마지막으로 누른 칸 하나에 마지막 줄(글자 칸은 Trim, 수치 칸은 수치만).
const EstBook = (() => {
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 `<div style="padding-left:${depth * 14}px">${has ? `<button class="tiny" data-action="book-fold" data-pkid="${n.pkid}">${open ? "−" : "+"}</button>` : "<span style='display:inline-block;width:18px'></span>"}<a href="#" data-book-node="${n.pkid}"${n.pkid === b.pkid ? ' style="font-weight:bold"' : ""}>${esc(n.chapter)} ${esc(n.title)}</a></div>${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(`<mark>${esc(mark)}</mark>`)
: esc(t);
const lines = String(page.text || "").split("\n");
let html = "",
table = [];
const flush = () => {
if (table.length)
html += `<table class="sheet" style="font-size:12px">${table.map((cells) => `<tr>${cells.map((c) => `<td style="text-align:left">${hl(c)}</td>`).join("")}</tr>`).join("")}</table>`;
table = [];
};
for (const line of lines) {
if (line.includes("\t")) table.push(line.replace(/\t$/, "").split("\t"));
else {
flush();
html += `<div style="white-space:pre-wrap">${hl(line) || "&nbsp;"}</div>`;
}
}
flush();
return `<h4>${esc(page.chapter)} ${esc(page.title)}</h4>${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
? `<p>검색 결과 ${b.hits.length}건 <button data-action="book-tree">목차로</button></p>${b.hits.map((h) => `<div><a href="#" data-book-node="${h.pkid}">${esc(h.chapter)} ${esc(h.title)}</a></div>`).join("")}`
: treeHtml(b, b.tree);
EstApp.showDialog(
"품셈",
`<div class="business-tools"><select data-book-name>${b.books.map((x) => `<option value="${x.name}"${x.name === b.name ? " selected" : ""}>${esc(x.desc)}</option>`).join("")}</select><input data-book-word value="${esc(b.word || "")}" placeholder="검색어"><button data-action="book-search" data-mode="title">검 색</button><button data-action="book-search" data-mode="body">전체검색(제목+본문)</button><button data-action="book-search" data-mode="keyword">키워드검색</button><button data-action="book-expand">펼치기</button><button data-action="book-collapse">접기</button><input data-book-mark value="${esc(b.mark || "")}" placeholder="본문 검색" size="10"><button data-action="book-mark">본문 검색</button><button data-action="book-copy">복사하기</button></div><div style="display:flex;gap:10px;height:62vh"><div style="flex:0 0 38%;overflow:auto;border:1px solid #ccc;padding:4px;font-size:12px">${left}</div><div id="book-page" style="flex:1;overflow:auto;border:1px solid #ccc;padding:6px">${b.page ? pageHtml(b.page, b.mark) : "<p>왼쪽에서 항목을 고르세요.</p>"}</div></div>`,
);
}
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 };
})();
+2 -1
View File
@@ -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") {
+22
View File
@@ -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/<pkid> · /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;