- 근거(메모)를 문단·굵게·표·그림 자리표로 펼쳐 편집, 붙여넣기(서식 있는 글·표), 저장 - WPTools 글 형식 읽기·쓰기(native-memo-wpt.cjs) — 그림 원문 보존, ASCII 로 써서 cp949 변환 없음 - ESTX 저장: 원래 메모가 있던 행만, 새 메모는 JSON 저장 안내 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
11 KiB
JavaScript
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 };
|
|
})();
|