feat(est): manage local project attachments
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
from test_est_rea_parity import run
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_attachment_validation_keeps_valid_data_and_rejects_malformed_content():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.attachments=[{id:'one',name:'test.bin',type:'application/octet-stream',size:4,data:'AAECAw=='}];
|
||||
const valid=EstModel.validate(p).attachments[0];let rejected=0;
|
||||
for(const item of [{...valid,data:'AAECAw=A'},{...valid,size:5},{...valid,id:''},{...valid,name:''}]) {
|
||||
const q=EstNative.empty();q.attachments=[item];try{EstModel.validate(q)}catch{rejected++}
|
||||
}
|
||||
const dup=EstNative.empty();dup.attachments=[valid,valid];try{EstModel.validate(dup)}catch{rejected++}
|
||||
const empty=EstNative.empty();empty.attachments=[{id:'zero',name:'empty',type:'',size:0,data:''}];
|
||||
return {valid,rejected,empty:EstModel.validate(empty).attachments[0].size};
|
||||
})()""",
|
||||
)
|
||||
assert result["valid"]["data"] == "AAECAw=="
|
||||
assert result["rejected"] == 5
|
||||
assert result["empty"] == 0
|
||||
|
||||
|
||||
def test_estx_export_rejects_web_attachments_instead_of_dropping_them():
|
||||
script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',attachments:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=Path(__file__).resolve().parents[2],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
assert "JSON" in result.stdout.decode("utf-8")
|
||||
@@ -1,4 +1,7 @@
|
||||
## Codex · EST 독립 테스트 앱 (2026-11-04)
|
||||
- [x] 로컬 첨부파일 추가·내려받기·삭제·JSON 보관 구현 → 파일 바이트·저장·취소 시험.
|
||||
- 자체 검증: 입력 검증·ESTX 누락 차단 시험 2개 통과, Edge에서 파일 추가·JSON 저장·재열기·바이트 내려받기·삭제 취소 확인. 업무 회귀 시험 82개 통과(openpyxl 경고 7건).
|
||||
|
||||
- [x] 선택 행 자릿수·사사오입·절사·올림식 일괄 설정 → 선택 범위·재계산·취소 시험.
|
||||
- 자체 검증: 업무·수량 시험 21개 통과, Edge에서 두 행 절사 2자리 설정·다른 행 유지·재계산·실행 취소 확인.
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. JSON은 모든 웹 업무와 원본 압축 파일·바이너리를 보관하는 전체 저장 형식입니다. ESTX 수정 저장은 아래 지원 조건을 따릅니다.
|
||||
|
||||
작업모드의 `첨부파일`에서 파일을 추가·내려받기·삭제할 수 있습니다. 파일은 작업 JSON에 포함되며 이 앱의 저장 요청은 전체 256MB 이하입니다. 첨부파일이 있는 작업은 JSON으로 저장해야 하며, 첨부파일이 빠진 ESTX가 만들어지지 않도록 ESTX 저장을 차단합니다.
|
||||
|
||||
## 추가 입출력·문서 기능 (2026-11-06)
|
||||
|
||||
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드, 직접 작성 수식표·예정공정표·계약심사 입력·연차 수식·미확정 기성과 수량산출서의 문서 정보·산출 행·항목을 내보내고 다시 읽어 해당 자료를 교체합니다. 수량산출서는 `QDocuments`, `QLines`, `QItems`, 미확정 기성은 `ProgressDrafts`, `ProgressInputs` 시트를 함께 가져옵니다. 확정 기성의 계약 자료와 누계는 Excel 가져오기 시 유지합니다. 수식표·수량산출서·공정표의 내역 행 연결도 보존합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 산출근거의 내부 표지는 Excel이 열 수 있는 형식으로 바꿔 기록하고 다시 읽을 때 복원합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·원본 글자 서식·확정 기성 자료·당초 사본은 넣지 않으므로 전체 백업은 JSON을 사용합니다.
|
||||
|
||||
@@ -135,6 +135,8 @@ function updateBlob(file, start, id, bytes) {
|
||||
function exportEstx(project) {
|
||||
if (!project.originalArchive)
|
||||
throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다.");
|
||||
if (project.attachments?.length)
|
||||
throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
const original = Buffer.from(project.originalArchive, "base64"),
|
||||
base = importEstx(original),
|
||||
files = unzip(original);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<script defer src="native-resources.js"></script>
|
||||
<script defer src="native-schedule.js"></script>
|
||||
<script defer src="native-issues.js"></script>
|
||||
<script defer src="native-attachments.js"></script>
|
||||
<script defer src="native-model.js"></script>
|
||||
<script defer src="native-views.js"></script>
|
||||
<script defer src="native-extra.js"></script>
|
||||
@@ -118,6 +119,7 @@
|
||||
><button data-view="resources">동원인원·자재·장비</button>
|
||||
<button data-view="schedule">예정공정표</button>
|
||||
<button data-view="issues">자료 오류 검색</button>
|
||||
<button data-view="attachments">첨부파일</button>
|
||||
</div>
|
||||
</details>
|
||||
<button data-action="settings" class="menu-button">환경설정</button>
|
||||
|
||||
@@ -485,6 +485,7 @@ const EstActions = (() => {
|
||||
EstExchange.install();
|
||||
EstBusinessActions.install();
|
||||
EstIssues.install();
|
||||
EstAttachments.install();
|
||||
document.addEventListener("click", async (event) => {
|
||||
try {
|
||||
const marker = event.target.closest("[data-marker]");
|
||||
|
||||
@@ -35,6 +35,7 @@ const EstApp = (() => {
|
||||
resources: "동원 집계",
|
||||
schedule: "예정공정표",
|
||||
issues: "자료 오류 검색",
|
||||
attachments: "첨부파일",
|
||||
drawing: "도면·문서",
|
||||
};
|
||||
const esc = (value) =>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const EstAttachments = (() => {
|
||||
function render() {
|
||||
const { state, esc, number, byId } = EstApp;
|
||||
const rows = state.project.attachments || [];
|
||||
byId("work-content").innerHTML =
|
||||
`<div class="business-tools"><b>첨부파일</b><label>파일 추가 <input type="file" data-attachment-add multiple></label><span>${rows.length}개</span></div><p>첨부파일은 이 작업의 JSON 저장에 포함됩니다.</p><table class="sheet"><thead><tr><th>파일명</th><th>크기 (바이트)</th><th>작업</th></tr></thead><tbody>${rows.map((row) => `<tr><td>${esc(row.name)}</td><td>${number(row.size)}</td><td><button data-attachment-download="${esc(row.id)}">내려받기</button><button data-attachment-delete="${esc(row.id)}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
function read(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(Error(`파일 읽기 실패: ${file.name}`));
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
type: file.type || "",
|
||||
size: file.size,
|
||||
data: String(reader.result).split(",", 2)[1],
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
function install() {
|
||||
document.addEventListener("change", async (event) => {
|
||||
const input = event.target;
|
||||
if (input.dataset.attachmentAdd === undefined || !input.files.length) return;
|
||||
const { state } = EstApp,
|
||||
key = state.documentKey,
|
||||
revision = state.revision;
|
||||
try {
|
||||
const files = await Promise.all([...input.files].map(read));
|
||||
if (key !== state.documentKey || revision !== state.revision)
|
||||
throw Error("작업이 변경되었습니다. 첨부파일을 다시 추가하세요.");
|
||||
EstApp.change(() => (state.project.attachments ||= []).push(...files), false);
|
||||
} catch (error) {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const node = event.target.closest("[data-attachment-download],[data-attachment-delete]");
|
||||
if (!node) return;
|
||||
const { state } = EstApp,
|
||||
id = node.dataset.attachmentDownload ?? node.dataset.attachmentDelete;
|
||||
const index = (state.project.attachments || []).findIndex((item) => item.id === id);
|
||||
if (index < 0) return;
|
||||
if (node.dataset.attachmentDelete !== undefined)
|
||||
EstApp.change(() => state.project.attachments.splice(index, 1), false);
|
||||
else {
|
||||
const item = state.project.attachments[index],
|
||||
text = atob(item.data),
|
||||
bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
|
||||
EstApp.download(item.name, bytes, "application/octet-stream");
|
||||
}
|
||||
});
|
||||
}
|
||||
return { render, install };
|
||||
})();
|
||||
@@ -343,6 +343,7 @@ const EstNative = (() => {
|
||||
progress: [],
|
||||
annual: [],
|
||||
drawings: [],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
return { calculate, price, groups, empty, allTables, sourceTables, normalize, materialBase };
|
||||
|
||||
@@ -24,6 +24,10 @@ const EstExtra = (() => {
|
||||
EstIssues.render();
|
||||
return;
|
||||
}
|
||||
if (state.view === "attachments") {
|
||||
EstAttachments.render();
|
||||
return;
|
||||
}
|
||||
const { project, view } = state,
|
||||
{ esc, number, byId } = EstApp;
|
||||
let html = "";
|
||||
|
||||
@@ -348,10 +348,39 @@ const EstModel = (() => {
|
||||
if (!/^(current|\d+)$/.test(key) || typeof value !== "string" || value.length > 100000)
|
||||
throw Error("문서 본문 형식 오류");
|
||||
}
|
||||
for (const key of ["quantities", "progress", "annual", "drawings"]) {
|
||||
for (const key of ["quantities", "progress", "annual", "drawings", "attachments"]) {
|
||||
if (project[key] === undefined) project[key] = [];
|
||||
if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`);
|
||||
}
|
||||
const attachmentIds = new Set();
|
||||
for (const item of project.attachments) {
|
||||
if (
|
||||
!item ||
|
||||
typeof item.id !== "string" ||
|
||||
!item.id ||
|
||||
attachmentIds.has(item.id) ||
|
||||
typeof item.name !== "string" ||
|
||||
!item.name ||
|
||||
item.name.length > 1000 ||
|
||||
typeof item.type !== "string" ||
|
||||
item.type.length > 1000 ||
|
||||
!Number.isSafeInteger(item.size) ||
|
||||
item.size < 0 ||
|
||||
typeof item.data !== "string" ||
|
||||
item.data.length % 4 ||
|
||||
/[^A-Za-z0-9+/=]/.test(item.data)
|
||||
)
|
||||
throw Error("첨부파일 형식 오류");
|
||||
const body = item.data.replace(/={1,2}$/, ""),
|
||||
padding = item.data.length - body.length;
|
||||
if (
|
||||
body.includes("=") ||
|
||||
body.length % 4 === 1 ||
|
||||
(item.data.length / 4) * 3 - padding !== item.size
|
||||
)
|
||||
throw Error("첨부파일 데이터 오류");
|
||||
attachmentIds.add(item.id);
|
||||
}
|
||||
const ids = new Set();
|
||||
for (const round of project.progress) {
|
||||
if (
|
||||
|
||||
@@ -54,6 +54,7 @@ const allowed = new Set([
|
||||
"native-actions.js",
|
||||
"native-business-actions.js",
|
||||
"native-issues.js",
|
||||
"native-attachments.js",
|
||||
"native-extra.js",
|
||||
"native-settings.js",
|
||||
"native-report.js",
|
||||
|
||||
Reference in New Issue
Block a user