diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 13e8a2c8..5aadc5ed 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -60,7 +60,13 @@ def version_of(raw: bytes) -> str: def _path(kind: str, name: str) -> Path: if kind not in KINDS: raise StoreError(404, f"없는 종류 「{kind}」") - if not name or name != name.strip() or name.startswith(".") or _BAD_NAME.search(name): + if ( + not name + or name != name.strip() + or name.startswith((".", "_")) # `_` 도 막음 — 새 프로젝트 복사(layers.check_name)가 거절함 + or len(name) > 80 # 파일 이름이 길면 OS 가 죽음(500) — 화면 규칙과 같은 80자 + or _BAD_NAME.search(name) + ): raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」") return FOLDER / kind / f"{name}.json" diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 207f3fe3..80b5c960 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -9,7 +9,6 @@ import { createButton, createInputField, - createSelectField, el, showConfirmDialog, showToast, @@ -87,7 +86,10 @@ export interface SideHandle { /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ const badName = (name: string): boolean => - /[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith("."); + /[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || + name.startsWith(".") || + name.startsWith("_") || + name.length > 80; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const failed = (error: unknown): void => @@ -113,25 +115,30 @@ export function buildSide(opt: SideOptions): SideHandle { const columnBodies = COLUMN_KINDS.map(() => el("div", { className: "m02-side__items ui-sidebar-list" }), ); + /** 새로 · 본떠 만들기 · 지우기 — 표 · 도면 컨테이너 안에 하나씩(그 종류로 동작) · 관리자만 */ + const editButtons = new Map(); + const actionRow = (kind: Kind): HTMLElement[] => { + if (!canEdit(layer, opt.isAdmin)) return []; + const btnNew = createButton({ label: L("M02_New"), variant: "filled" }); + const copy = createButton({ label: L("M02_Copy"), variant: "ghost" }); + const del = createButton({ label: L("M02_Delete"), variant: "danger" }); + editButtons.set(kind, { copy, del }); + btnNew.addEventListener("click", () => newTemplate(kind)); + copy.addEventListener("click", copyTemplate); + del.addEventListener("click", () => void deleteCurrent()); + return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })]; + }; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ - section(L("M02_KindTable"), bodies.get("table")!), - section(L("M02_KindDrawing"), bodies.get("drawing")!), + section(L("M02_KindTable"), bodies.get("table")!, ...actionRow("table")), + section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), ], }); attachCollapsible(listHost); - const btnNew = createButton({ label: L("M02_New"), variant: "filled" }); - const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost" }); - const btnDel = createButton({ label: L("M02_Delete"), variant: "danger" }); - const editRow = el("div", { - className: "m02-side__row ui-sidebar-card", - children: [btnNew, btnCopy, btnDel], - }); - - const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost, editRow] }); + const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); /** 고름 표시 — `picked`(방금 누름) 이 있으면 그것 · 없으면 지금 열린 것과 비교 */ const isPicked = (kind: Kind, name: string): boolean => { @@ -186,11 +193,10 @@ export function buildSide(opt: SideOptions): SideHandle { const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); + /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { - const editable = canEdit(layer, opt.isAdmin); - btnNew.disabled = !editable; - btnCopy.disabled = !editable || !currentInfo(); - btnDel.disabled = !editable || !currentInfo(); + const cur = currentInfo(); + for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind; } async function select(info: TemplateInfo): Promise { @@ -243,16 +249,8 @@ export function buildSide(opt: SideOptions): SideHandle { } /* --- 시스템 층 새로 · 본떠 · 지우기(표 · 도면만 — 구조물 도면은 집계표 열이 정함) --- */ - const nameDialog = ( - title: string, - withKind: boolean, - onOk: (kind: Kind, name: string) => Promise, - ): void => { + const nameDialog = (title: string, onOk: (name: string) => Promise): void => { const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); - const kind = createSelectField({ - label: L("M02_Kind"), - options: KINDS.map((k) => ({ value: k.kind, text: k.label() })), - }); openModal({ title, closeLabel: L("M02_Close"), @@ -265,7 +263,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (badName(text)) return showToast(L("M02_BadName"), "error"); ok.disabled = true; // 연달아 눌러도 한 번만 try { - await onOk(kind.select.value as Kind, text); + await onOk(text); close(); } catch (error) { if (error instanceof StaleError) showToast(L("M02_NameExists"), "error"); @@ -274,7 +272,7 @@ export function buildSide(opt: SideOptions): SideHandle { ok.disabled = false; } }); - body.append(...(withKind ? [kind.root] : []), name.root, ok); + body.append(name.root, ok); name.input.focus(); }, }); @@ -287,22 +285,25 @@ export function buildSide(opt: SideOptions): SideHandle { if (info) await select(info); }; - btnNew.addEventListener("click", () => - nameDialog(L("M02_NewTitle"), true, async (kind, name) => { + /** 이 컨테이너 종류의 새 양식 — 종류 고르기 없이 이름만 */ + function newTemplate(kind: Kind): void { + nameDialog(L("M02_NewTitle"), async (name) => { await saveTemplate("system", kind, name, null, "", stubDoc(kind, name)); await openCreated(kind, name); - }), - ); - btnCopy.addEventListener("click", () => { + }); + } + function copyTemplate(): void { const from = currentInfo(); if (!from) return; - nameDialog(L("M02_CopyTitle"), false, async (_kind, name) => { + nameDialog(L("M02_CopyTitle"), async (name) => { const got = await readTemplate("system", from.종류, from.이름, null); - await saveTemplate("system", from.종류, name, null, "", got.문서); + // 표 문서 머리 이름(`양식`)도 새 이름으로 — 안 바꾸면 복사본이 원본 이름을 달고 있음 + const doc = from.종류 === "table" ? { ...(got.문서 as object), 양식: name } : got.문서; + await saveTemplate("system", from.종류, name, null, "", doc); await openCreated(from.종류, name); }); - }); - btnDel.addEventListener("click", async () => { + } + async function deleteCurrent(): Promise { await refresh(); // 저장 직후여도 최신 판으로 const info = currentInfo(); if (!info) return; @@ -315,7 +316,7 @@ export function buildSide(opt: SideOptions): SideHandle { } catch (error) { failed(error); } - }); + } void refresh(); return { diff --git a/resources/tester/test_m02_store.py b/resources/tester/test_m02_store.py index 53f01325..33fa89a0 100644 --- a/resources/tester/test_m02_store.py +++ b/resources/tester/test_m02_store.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_MasterTemplete_Router import router @@ -47,10 +48,42 @@ def test_new_save_reopen_delete_and_stale(client): def test_bad_names_and_kinds(client): - for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab"): + for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab", "_밑줄", "가" * 81): got = client.put(f"/api/m02/templates/table/{bad}", json={"판": "", "문서": {"열": []}}) assert got.status_code == 400 and "쓸 수 없는" in got.json()["detail"] assert client.put("/api/m02/templates/other/x", json={"판": "", "문서": {}}).status_code == 404 + edge = "가" * 80 # 80자는 됨 + assert ( + client.put( + f"/api/m02/templates/table/{edge}", json={"판": "", "문서": {"열": []}} + ).status_code + == 200 + ) + + +def test_copy_is_independent_and_seeds_new_project(client, tmp_path, monkeypatch): + """본떠 만들기 = 읽어 새 이름으로 새로 저장 — 원본은 안 바뀜 · 새 프로젝트 복사에 실림.""" + doc = {"format": 6, "entities": [{"type": "line"}], "drawing_area": [1, 2, 3, 4]} + client.put("/api/m02/templates/drawing/원본", json={"판": "", "문서": doc}) + got = client.get("/api/m02/templates/drawing/원본").json() + made = client.put("/api/m02/templates/drawing/복사", json={"판": "", "문서": got["문서"]}) + assert made.status_code == 200 + assert ( + client.put("/api/m02/templates/drawing/복사", json={"판": "", "문서": doc}).status_code + == 409 + ) + copy = client.get("/api/m02/templates/drawing/복사").json() + assert copy["문서"] == doc and copy["판"] == got["판"] # 내용 같음 + client.put( + "/api/m02/templates/drawing/복사", + json={"판": copy["판"], "문서": {**doc, "entities": [{"type": "line"}, {"type": "arc"}]}}, + ) + assert client.get("/api/m02/templates/drawing/원본").json()["문서"] == doc # 원본 그대로 + monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path) + copied = layers.seed_project(tmp_path / "seed") + assert {"drawing/원본", "drawing/복사"} <= set(copied["작업본"]) + client.delete("/api/m02/templates/drawing/복사") + assert [r["이름"] for r in client.get("/api/m02/templates").json()] == ["원본"] def test_empty_doc_rejected_and_file_untouched(client): diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index fac6b471..ac370442 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -140,6 +140,14 @@ def test_미리보기_CAD_패널은_숨기지_않고_접힌_채로_엶(): assert "isInspectorCollapsed()" in _text(cad / "components/Toolbar.tsx") +def test_좌측_새로_본떠_지우기는_표_도면_컨테이너_안에_하나씩(): + side = _text(SIDE) + assert 'actionRow("table")' in side and 'actionRow("drawing")' in side + assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만 + assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만 + assert "양식: name" in side.split("function copyTemplate")[1] # 복사본 머리 이름 = 새 이름 + + def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘(): ribbon = _text(ROOT / "B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx") assert "if (collapsed) setPeek(true);" in ribbon # 탭 = 잠깐 펼침(접힘 모드 유지) diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c6c9b2fb..27c7a5a4 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -42,8 +42,8 @@ export const ui_locales_m2 = { M02_NoSpreadsheet: ["산출근거 편집 부품이 아직 없음", "Cost basis editor not available yet"], M02_NameExists: ["이미 있는 이름", "That name already exists"], M02_BadName: [ - '쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨', - 'Invalid name — no / \\ : * ? " < > | or .. or leading dot', + '쓸 수 없는 이름 — / \\ : * ? " < > | 와 .. · 앞의 점 · 밑줄은 안 됨 · 80자까지', + 'Invalid name — no / \\ : * ? " < > | or .. or leading dot/underscore · up to 80 characters', ], M02_DiscardConfirm: [ "저장 안 한 고침이 있음 — 버리고 넘어갈까?",