Files
Aislo/tools/est-test/native-tidy.js
T
eomsangdonandClaude Opus 5.5 f6d73458bb feat(est-test): 근거자료보기 고치기·붙여넣기·원본 서식 저장
- 근거(메모)를 문단·굵게·표·그림 자리표로 펼쳐 편집, 붙여넣기(서식 있는 글·표), 저장
- WPTools 글 형식 읽기·쓰기(native-memo-wpt.cjs) — 그림 원문 보존, ASCII 로 써서 cp949 변환 없음
- ESTX 저장: 원래 메모가 있던 행만, 새 메모는 JSON 저장 안내

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:14:08 +09:00

270 lines
11 KiB
JavaScript

// 원본 표 정리 — 정렬(BarSort 0x110ADEC·TfrmSiljukSort), 데이타 위치정리(mnuTableSort 0x10CCAEC·TFRMTABLESORT),
// 호표 정리(popTitleSort 0x10D88EC), 근거(메모) 보기·고치기·붙여넣기·삭제(mnuGunguEdit·TfrmGunguEdit popPaste·mnuGunguDel, 필드 DifineMemo).
// 정렬: 기초단가 표를 관급구분(Gangub) 0·1·2 차례로 묶고 각 묶음을 품명+규격(실적은 코드 선택 가능) 순으로, Index 1..n.
// Index ≤ 0 이거나 품명·규격·수량·금액이 모두 빈 행은 버린다. 원본의 빈 행 30줄 채우기는 웹에서 하지 않는다.
// 위치정리: 기초단가·일위대가·단가산출·기계경비를 품명 오름/내림으로 다시 놓고 시작번호부터 Index — 묶음은 통째로 옮긴다.
// 원본 콤보 글자는 오름/내림이 뒤바뀌어 보이나(분석 추정) 웹은 실제 순서대로 적는다.
// 호표 정리: 현재 차례 그대로 묶음 머리 Index 를 시작번호부터 다시 매기고 제목표 같은 코드도 맞춘다(행·코드 불변).
const EstTidy = (() => {
const num = (value) => Number(value) || 0;
const base = ["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"];
const grouped = {
HeadMainDat: "HeadTitleDat",
CostMainDat: "CostTitleDat",
MachMainDat: "MachTitleDat",
};
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const key = (row) => `${row.Name ?? ""}${row.Size ?? ""}`;
function sortBase(project, table, byCode = false) {
if (!base.includes(table)) throw Error("자재·노임·실적·견적 단가 화면에서 정렬하세요.");
const rows = project.tables[table] || [];
const kept = rows.filter(
(row) => num(row.Index) > 0 && (key(row).trim() || num(row.GN) || num(row.TC)),
);
const out = [];
for (const gangub of [0, 1, 2])
out.push(
...kept
.filter((row) => num(row.Gangub) === gangub)
.sort((a, b) =>
byCode ? compare(String(a.Code ?? ""), String(b.Code ?? "")) : compare(key(a), key(b)),
),
);
out.forEach((row, i) => (row.Index = i + 1));
project.tables[table] = out;
return rows.length - out.length;
}
function arrange(project, table, descending, start) {
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const order = (a, b) => compare(String(a ?? ""), String(b ?? "")) * (descending ? -1 : 1);
if (base.includes(table)) {
const rows = (project.tables[table] || []).filter((row) => row.Code);
rows.sort((a, b) => order(a.Name, b.Name)).forEach((row, i) => (row.Index = start + i));
project.tables[table] = rows;
return rows.length;
}
if (!grouped[table]) throw Error("이 화면은 위치정리를 할 수 없습니다.");
const groups = [...EstNative.groups(project.tables[table] || []).values()].sort((a, b) =>
order(a.rows[0].Name, b.rows[0].Name),
);
project.tables[table] = groups.flatMap((g) => g.rows);
numberHeads(project, table, start);
return groups.length;
}
function numberHeads(project, table, start) {
if (!grouped[table]) throw Error("일위대가·단가산출·기계경비 화면에서 호표를 정리하세요.");
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const titles = new Map((project.tables[grouped[table]] || []).map((t) => [t.Code, t]));
let n = start;
for (const row of project.tables[table] || [])
if (num(row.Index) > 0) {
row.Index = n;
const title = titles.get(row.Code);
if (title) title.Index = n;
n++;
}
return n - start;
}
// 근거(메모): 원본 DifineMemo 는 WPTools 서식(zlib) — 글자만 꺼낸다.
async function memoText(memo) {
if (memo == null) return "";
if (typeof memo === "string") return memo;
if (!memo.binary) return "";
let bytes = Uint8Array.from(atob(memo.binary), (c) => c.charCodeAt(0));
if (bytes[0] === 0x78)
bytes = new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")),
).arrayBuffer(),
);
const source = new TextDecoder("euc-kr").decode(bytes);
return EstQuantity.entities(
source
.replace(/<(numberstyles|stylesheet)[\s\S]*?<\/\1>/gi, "")
.replace(/<\/(div|p)>|<br\s*\/?>/gi, "\n")
.replace(/<[^>]*>/g, ""),
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
async function memoHtml(memo, objects) {
if (memo == null) return "<div><br></div>";
if (typeof memo === "string")
return memo
.split(/\r?\n/)
.map((l) => `<div>${EstApp.esc(l) || "<br>"}</div>`)
.join("");
let bytes = Uint8Array.from(atob(memo.binary || ""), (c) => c.charCodeAt(0));
if (bytes[0] === 0x78)
bytes = new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")),
).arrayBuffer(),
);
return EstMemoWpt.html(EstMemoWpt.read(new TextDecoder("euc-kr").decode(bytes)), objects);
}
async function pack(text) {
const bytes = new Uint8Array(
await new Response(
new Blob([new TextEncoder().encode(text)])
.stream()
.pipeThrough(new CompressionStream("deflate")),
).arrayBuffer(),
);
let out = "";
for (let i = 0; i < bytes.length; i += 0x8000)
out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(out);
}
// 편집 창 → 마디 목록(문단·표·그림): 블록 요소·<br> 마다 문단을 끊고 굵게는 b/strong·굵은 글꼴
function memoNodes(editor, objects) {
const BLOCK = /^(DIV|P|LI|H[1-6]|PRE|BLOCKQUOTE)$/;
function walk(root) {
const out = [];
let para = null;
const close = () => (para = null);
const add = (run) => {
if (!para) out.push((para = { p: [] }));
const last = para.p.at(-1);
if (run.text != null && last?.text != null && last.bold === run.bold) last.text += run.text;
else para.p.push(run);
};
const visit = (node, bold) => {
if (node.nodeType === 3) {
if (node.data && !/^\s*\n\s*$/.test(node.data))
add({ text: node.data.replace(/\n/g, " "), bold });
return;
}
if (node.nodeType !== 1) return;
if (node.dataset.obj != null) return add({ object: objects[Number(node.dataset.obj)] });
if (node.tagName === "BR") {
if (!para) out.push({ p: [] });
return close();
}
if (node.tagName === "TABLE") {
close();
out.push({
table: [...node.rows].map((tr) => [...tr.cells].map((td) => walk(td))),
});
return;
}
const b =
bold ||
/^(B|STRONG)$/.test(node.tagName) ||
Number(node.style.fontWeight) >= 600 ||
node.style.fontWeight === "bold";
const block = BLOCK.test(node.tagName);
if (block) close();
for (const child of node.childNodes) visit(child, b);
if (block) close();
};
for (const child of root.childNodes) visit(child, false);
return out;
}
return walk(editor);
}
function memoTarget(state) {
const index = [...state.selection][0],
row = state.project.tables[state.view]?.[index];
if (state.selection.size !== 1 || !row) throw Error("근거(메모)를 볼 행 하나를 고르세요.");
return { index, row };
}
async function run(name, state) {
if (name === "tidy-sort") {
const table = state.view;
let byCode = false;
if (table === "SilJukDat") byCode = !confirm("품명,규격으로 정렬할까요? [취소] 코드로 정렬");
if (!confirm("가,나,다 순으로 정렬합니다. 빈 행과 번호 없는 행은 빠집니다.")) return;
let dropped;
EstApp.change((project) => (dropped = sortBase(project, table, byCode)));
state.notice = `정렬했습니다${dropped ? ` (빈 행 ${dropped}개 제외)` : ""}.`;
} else if (name === "tidy-arrange" || name === "tidy-number") {
EstActions.form(
name === "tidy-arrange" ? "데이타 위치정리" : "호표 정리(번호정렬)",
[
...(name === "tidy-arrange"
? [
{
name: "order",
label: "품명 정렬",
value: "asc",
choices: [
["asc", "오름차순(가나다)"],
["desc", "내림차순"],
],
},
]
: []),
{ name: "start", label: "시작번호", type: "number", value: 1 },
],
(values) =>
EstApp.change((project) =>
name === "tidy-arrange"
? arrange(project, state.view, values.order === "desc", values.start)
: numberHeads(project, state.view, values.start),
),
);
} else if (name === "memo-view") {
// 근거자료보기(TfrmGunguEdit): 원본 서식 메모를 문단·표·그림(자리표)으로 펼쳐 고치고 붙여넣고 저장
const { index, row } = memoTarget(state);
memo = { view: state.view, index, objects: [] };
EstApp.showDialog(
`근거자료보기 — ${row.Name ?? row.Code ?? ""}`,
`<div id="memo-editor" contenteditable="true" style="min-height:260px;max-height:420px;overflow:auto;border:1px solid #999;padding:6px;white-space:pre-wrap">${await memoHtml(row.DifineMemo, memo.objects)}</div><button data-action="memo-paste">붙여넣기</button> <button data-action="memo-save">저장</button>`,
);
} else if (name === "memo-paste") {
// popPaste(Ctrl+V): 클립보드 그대로(서식 있는 글·표 포함)
const editor = document.getElementById("memo-editor");
editor.focus();
let html = "";
try {
for (const item of await navigator.clipboard.read())
if (item.types.includes("text/html"))
html = await (await item.getType("text/html")).text();
} catch {}
if (html) document.execCommand("insertHTML", false, EstDocEditor.clean(html));
else document.execCommand("insertText", false, await navigator.clipboard.readText());
} else if (name === "memo-save") {
const editor = document.getElementById("memo-editor");
if (!editor || !memo) return;
const nodes = memoNodes(editor, memo.objects),
empty = !nodes.some((n) => n.table || n.p.some((r) => r.object || r.text.trim()));
const value = empty ? null : { binary: await pack(EstMemoWpt.write(nodes)) };
const { view, index } = memo;
EstApp.change((project) => (project.tables[view][index].DifineMemo = value), false);
EstApp.byId("dialog").close();
state.notice = "근거(메모)를 저장했습니다.";
} else if (name === "memo-delete") {
const { index } = memoTarget(state);
EstApp.change((project) => (project.tables[state.view][index].DifineMemo = null), false);
state.notice = "근거(메모)를 지웠습니다.";
}
}
let memo = null;
const names = new Set([
"tidy-sort",
"tidy-arrange",
"tidy-number",
"memo-view",
"memo-paste",
"memo-save",
"memo-delete",
]);
function handle(name, state) {
if (!names.has(name)) return false;
run(name, state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { sortBase, arrange, numberHeads, memoText, handle };
})();