From 576b012eae9dbeeea5a1dac6883acd8b517bdcde Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 16:52:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(M02):=20=EA=B0=9C=EC=9D=B8=20=C2=B7=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EC=B8=B5=20=EC=96=91=EC=8B=9D=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=E2=80=94=20DELETE=20/layers/{=EC=B8=B5}/t?= =?UTF-8?q?emplates/{=EC=A2=85=EB=A5=98}/{=EC=9D=B4=EB=A6=84}=20=C2=B7=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=EB=B3=B8=EC=9D=B8=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EA=B4=80=EB=A6=AC=EC=9E=90=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=C2=B7=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B1=B0=EC=A0=88=20=C2=B7=20?= =?UTF-8?q?=ED=8C=90=20=EB=8B=A4=EB=A5=B4=EB=A9=B4=20409=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EA=B2=80=EC=82=AC=20400=20+=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5 --- .../M02_MasterTemplete_Router_Layers.py | 33 +++++++++++++ M02_MasterTemplete/M02_Template_Layers.py | 17 ++++++- resources/tester/test_m02_template_layers.py | 48 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 1e342c88..a2730e68 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -279,6 +279,39 @@ async def save_layer_template( return {"종류": kind, "이름": name, "판": version, "층": layer} +@router.delete("/layers/{layer}/templates/{kind}/{name}") +async def delete_layer_template( + layer: Layer, + kind: str, + name: str, + 판: str | None = Query(None), + project_id: str | None = Query(None), + session: dict[str, Any] = Depends(verify_session), +) -> dict[str, Any]: + """개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 · 프로젝트 층은 거절 · 판이 다르면 409.""" + if layer in ("system", "project"): + raise HTTPException(status_code=403, detail="개인 · 회사 층 양식만 지울 수 있습니다.") + folder = await _layer_dir(session, layer, project_id=project_id, write=True) + try: + layers.check_kind(kind) + layers.check_name(name) + except ValueError as error: + raise _bad(error) from error + if not 판: + raise HTTPException(status_code=400, detail="지울 양식의 판이 필요합니다.") + try: + found = await asyncio.to_thread( + layers.delete_template, folder, kind, name, version=판, check_version=True + ) + except layers.StaleTemplate as error: + raise HTTPException( + status_code=409, detail={"message": str(error), "판": error.current} + ) from error + if not found: + raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.") + return {"종류": kind, "이름": name, "층": layer, "지움": True} + + # ── 프로젝트 길 ─────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 7d48de77..03d6a52f 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -100,7 +100,10 @@ def check_name(name: str) -> str: text = str(name or "").strip() bad = not text or text != name or text.startswith((".", "_")) or len(text) > 80 if bad or any(ch in text for ch in '/\\:*?"<>|') or ".." in text: - raise ValueError("양식 이름이 올바르지 않습니다.") + raise ValueError( + "양식 이름이 올바르지 않습니다 — 빈 이름 · 앞뒤 빈칸 · 80자 초과 · 앞머리 . _ · " + '`/ \ .. : * ? " < > |` 는 못 씁니다.' + ) return text @@ -189,10 +192,20 @@ def write_template( return version_of(path) or "" -def delete_template(layer_dir: str | Path, kind: str, name: str) -> bool: +def delete_template( + layer_dir: str | Path, + kind: str, + name: str, + *, + version: str | None = None, + check_version: bool = False, +) -> bool: + """양식 지움 · manifest 줄도 뺌. `check_version` 이면 지금 판과 달라 `StaleTemplate`.""" path = template_path(layer_dir, kind, name) if not path.is_file(): return False + if check_version and version_of(path) != version: + raise StaleTemplate(version_of(path)) path.unlink() manifest = read_manifest(layer_dir) if manifest.pop(f"{kind}/{name}", None) is not None: diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 4b79b0b3..c9723a67 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -354,3 +354,51 @@ def test_뼈대_없는_문서는_400으로_거절하고_파일을_안_씀(world: table = _url() assert client.put(table, json={"판": None, "문서": {}}).status_code == 400 assert client.put(url, json={"판": version, "문서": {"entities": []}}).status_code == 200 + + +def _mine(name: str = "내양식", layer: str = "personal") -> str: + return f"/api/m02/layers/{layer}/templates/table/{name}?project_id={P1}" + + +def test_개인_회사_양식_지우기_권한_판_이름_예외(world: dict[str, Any]) -> None: + client, session = world["client"], world["session"] + doc = {"열": []} + for layer in ("personal", "company"): + client.put(_mine(layer=layer), json={"판": None, "문서": doc}) + personal = layers.personal_dir(7, 42) / "table/내양식.json" + company = layers.company_dir(7) / "table/내양식.json" + assert personal.is_file() and not company.is_file() # 일반 사용자 회사 저장은 403 + version = layers.version_of(personal) + # 시스템 · 프로젝트 층은 거절 + for layer in ("system", "project"): + assert client.delete(_mine("구조물집계표", layer) + f"&판={version}").status_code == 403 + assert (_root(world, P1) / "templates/table/구조물집계표.json").is_file() + # 이름 예외 · 판 없음 · 판 다름 · 없는 양식 + for bad in ("%2E%2E", "a%2E%2Eb", "a%2Fb", "a%5Cb", "_initial", ".x", "a*b"): + got = client.delete(_mine(bad) + f"&판={version}") + assert got.status_code in (400, 404) and personal.is_file(), bad + assert client.delete(_mine("a%2Fb") + f"&판={version}").status_code in (400, 404) + assert client.delete(_mine()).status_code == 400 + stale = client.delete(_mine() + "&판=0000000000000000") + assert stale.status_code == 409 and stale.json()["detail"]["판"] == version + assert client.delete(_mine("없는것") + f"&판={version}").status_code == 404 + # 다른 회사 사람은 못 지움 · 회사 층은 관리자만 + assert client.delete(_mine("내양식", "company") + f"&판={version}").status_code == 403 + # 지움 · 연달아 누르면 둘째는 404 · manifest 줄도 없어짐 + assert client.delete(_mine() + f"&판={version}").status_code == 200 + assert not personal.exists() + assert client.delete(_mine() + f"&판={version}").status_code == 404 + session["role"] = "ADMIN" + client.put(_mine(layer="company"), json={"판": None, "문서": doc}) + assert company.is_file() + session.update(role="USER") + assert ( + client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code + == 403 + ) + session.update(role="ADMIN") + assert ( + client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code + == 200 + ) + assert "table/내양식" not in layers.read_manifest(layers.company_dir(7)) From 1e29f5e0b2d5a7e3865432d65885bcbb0555ab5a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 16:53:17 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(sheet):=20=EC=88=98=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=20=EC=88=98=20=EC=95=84=EB=8B=8C=20=EA=B8=80=EC=9D=B4?= =?UTF-8?q?=EB=A9=B4=20=EC=98=A4=EB=A5=98=20=EC=83=89=20+=20=ED=92=8D?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=ED=95=A9=EA=B3=84=20=EC=B9=B8=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=95=8C=EB=A6=BC=20(M02=20=EC=A0=84=EC=88=98=20?= =?UTF-8?q?=EC=9E=AC=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1 --- resources/tester/test_sheet_recalc.py | 20 ++++++++++++++ ui_template/sheet/ui_template_sheet_recalc.ts | 27 +++++++++++++++++++ ui_template/sheet/ui_template_sheet_render.ts | 4 +-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_sheet_recalc.py b/resources/tester/test_sheet_recalc.py index 07b21e6f..c1aa94e3 100644 --- a/resources/tester/test_sheet_recalc.py +++ b/resources/tester/test_sheet_recalc.py @@ -193,3 +193,23 @@ def test_mirror_random_docs_match_decimal(): assert out["계산"] == {r: {n: _plain(v) for n, v in w.items()} for r, w in want.items()} for name, total in sums.items(): assert out["합계"]["s"][name] == _plain(total) + + +def test_text_in_number_column_is_flagged_and_skipped(): + doc = _doc( + [{"id": "a"}, {"id": "n", "꼴": "글"}, {"id": "f", "식": "[a]*2"}], + [ + {"id": "r1", "값": {"a": 5, "n": "abc"}}, + {"id": "r2", "값": {"a": "abc", "n": "x"}}, + {"id": "r3", "값": {"a": "1,000"}}, + {"id": "r4", "값": {"a": ""}}, + ], + 합계줄=[{"id": "t", "이름": "합계", "식": "SUM"}], + ) + out = _run(doc) + assert out["합계"]["t"]["a"] == "1005" # abc 는 빼고 · 1,000 은 수 · 빈 칸은 0 + errors = {(e["줄"], e["열"]): e["까닭"] for e in out["오류"]} + assert errors[("r2", "a")] == "수가 아님" + assert errors[("t", "a")].startswith("수가 아닌 칸 1 개") + # 글 칸 · 정상 수 칸은 표시 없음(r2 의 식 칸은 글을 곱해 식 오류 — 따로) + assert not any(k[1] == "n" or k[0] in ("r1", "r3", "r4") for k in errors) diff --git a/ui_template/sheet/ui_template_sheet_recalc.ts b/ui_template/sheet/ui_template_sheet_recalc.ts index a19e1026..50eba71d 100644 --- a/ui_template/sheet/ui_template_sheet_recalc.ts +++ b/ui_template/sheet/ui_template_sheet_recalc.ts @@ -34,6 +34,13 @@ import type { const ROUND_MODE = { 반올림: "round", 올림: "away", 버림: "trunc" } as const; +/** 수 칸에 수가 아닌 글 — 계산은 그 칸을 빼고 하되 알림(엑셀처럼 무시 · 오류 목록에는 이 까닭으로). */ +export const NOT_A_NUMBER = "수가 아님"; +export const SKIPPED_PREFIX = "수가 아닌 칸"; +/** 값은 그대로 두고 알리기만 하는 까닭인가 — 화면이 글을 「#오류」 로 바꾸지 않게. */ +export const isNotice = (why: string): boolean => + why === NOT_A_NUMBER || why.startsWith(SKIPPED_PREFIX); + export const isNumberColumn = (col: SheetColumn): boolean => col.꼴 !== "글"; /** 합계 줄 한 칸의 식 — 없으면 null(빈 칸). `"SUM"` 은 그 열의 열 합. */ @@ -167,6 +174,26 @@ export function recalcSheet(doc: SheetDoc): SheetResult { }; for (const id of rows.keys()) solve(id, result.계산); for (const id of totals.keys()) solve(id, result.합계); + + // 수 칸에 든 수 아닌 글 — 그 칸 + 그 열을 더하는 합계 칸에 알림 + const badCount = new Map(); + for (const row of doc.줄) { + for (const col of doc.열) { + if (!isNumberColumn(col) || formulaOf(row.id, col)) continue; + if (typeof inputValue(row.값[col.id]) !== "string") continue; + result.오류.push({ 줄: row.id, 열: col.id, 까닭: NOT_A_NUMBER }); + badCount.set(col.id, (badCount.get(col.id) ?? 0) + 1); + } + } + for (const total of totals.values()) { + for (const col of doc.열) { + const n = badCount.get(col.id); + const formula = n && totalFormula(total, col); + if (!formula || !formula.includes(`[${col.id}`)) continue; + const 까닭 = `${SKIPPED_PREFIX} ${n} 개는 빼고 더함`; + result.오류.push({ 줄: total.id, 열: col.id, 까닭 }); + } + } return result; } diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 7bef596f..2ef7da45 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -18,7 +18,7 @@ import { pagePlan, type SheetMode, } from "./ui_template_sheet_ops"; -import { groupDigits, totalFormula } from "./ui_template_sheet_recalc"; +import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc"; import { st } from "./ui_template_sheet_text"; import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types"; @@ -130,7 +130,7 @@ export function renderSheet(state: RenderState): HTMLElement { const why = errors.get(`${key}|${col.id}`); if (why) { td.classList.add("is-error"); - td.textContent = st("Error"); + if (!isNotice(why)) td.textContent = st("Error"); td.title = why; } else if (text) td.title = text; if (state.sel?.r === key && state.sel.c === col.id) td.classList.add("is-selected"); From c411d866bbca86fa8390d427d80640952b9c580a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 16:55:05 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(sheet):=20=ED=95=A9=EA=B3=84=20?= =?UTF-8?q?=EC=B9=B8=20=ED=92=8D=EC=84=A0=EC=97=90=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B9=8C=EB=8B=AD=EA=B3=BC=20=EC=8B=9D=EC=9D=84=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20(M02=20=EC=A0=84=EC=88=98=20=EC=9E=AC=EA=B2=80?= =?UTF-8?q?=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1 --- ui_template/sheet/ui_template_sheet_render.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 2ef7da45..72737824 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -240,7 +240,11 @@ export function renderSheet(state: RenderState): HTMLElement { cols.forEach((col, j) => { const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id])); const formula = totalFormula(total, col); - if (formula) td.title = formulaLabel(formula, names); + if (formula) + td.title = td.classList.contains("is-error") + ? `${td.title} +${formulaLabel(formula, names)}` + : formulaLabel(formula, names); tr.append(td); }); return tr; From 6555313a228d60dc0908ccc60bb576d0b9007527 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 17:01:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(M02):=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=9D=A0=20=EB=84=B7=20=E2=80=94=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=C2=B7=20?= =?UTF-8?q?=EC=93=B8=20=EC=88=98=20=EC=97=86=EB=8A=94=20=EA=B8=80=EC=9E=90?= =?UTF-8?q?=20=EB=A7=89=EA=B8=B0(=ED=99=94=EB=A9=B4=C2=B7=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20400)=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EC=95=88=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=A0=EC=B9=A8=20=EB=B2=84=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=C2=B7=20=EB=8D=AE=EC=96=B4=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=99=95=EC=9D=B8=EA=B3=BC=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=C2=B7=ED=9A=8C=EC=82=AC=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95 --- .../M02_MasterTemplete_Api_Fetch.ts | 11 +++- .../M02_MasterTemplete_Drawing.ts | 8 ++- .../M02_MasterTemplete_Store.py | 4 +- .../M02_MasterTemplete_UI_Main.ts | 33 ++++++++-- .../M02_MasterTemplete_UI_Page.ts | 2 + .../M02_MasterTemplete_UI_Side.ts | 63 +++++++++++++++---- resources/tester/test_m02_store.py | 10 +-- ui_template/ui_template_locale_m2.ts | 10 +++ 8 files changed, 114 insertions(+), 27 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index df250b19..9fe59d66 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -35,7 +35,8 @@ async function call(path: string, init: RequestInit = {}): Promise { ...init, }); const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; - if (response.status === 409) throw new StaleError(String(body.detail ?? "409")); + if (response.status === 409) + throw new StaleError(typeof body.detail === "string" ? body.detail : "409"); if (!response.ok) { const detail = body.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); @@ -82,6 +83,14 @@ export const saveTemplate = ( export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); +/** 개인·회사 층에서 지움 — 서버 길은 sub4 (없으면 404) */ +export const deleteLayerTemplate = ( + layer: Layer, + kind: Kind, + name: string, +): Promise<{ ok: boolean }> => + call(`/layers/${layer}/templates/${kind}/${enc(name)}`, { method: "DELETE" }); + /* --- 프로젝트 층 단추 다섯 --- */ const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`; const post = (path: string, body: object = {}): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index b3bbbecb..4b441c12 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -34,6 +34,8 @@ export interface DrawingTemplateOptions { layer?: string; /** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */ projectId?: string | null; + /** 저장 안 한 고침이 생기면 true — CAD 편집 · 작도 영역 값 바꿈. */ + onChanged?: (dirty: boolean) => void; } const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__"; @@ -128,6 +130,7 @@ export function mountDrawingTemplate( const areaInputs = AREA_LABELS.map((label, index) => { const field = createInputField({ label, type: "number", value: String(start[index]) }); field.input.disabled = readOnly; + field.input.addEventListener("input", () => options.onChanged?.(true)); return field; }); area.append(areaTitle, ...areaInputs.map((field) => field.root)); @@ -167,7 +170,10 @@ export function mountDrawingTemplate( showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"), ); - const cad = createCadHost({ title: "도면 양식" }); + const cad = createCadHost({ + title: "도면 양식", + onChanged: (dirty) => options.onChanged?.(dirty), + }); const load = (drawing: DrawingTemplateDoc): void => { cad.beginLoading(); cad.load( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 5ee4c9e7..3573c1d4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -18,7 +18,7 @@ from common_util.common_util_json import atomic_write_json FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" KINDS = ("table", "drawing") # 시험은 FOLDER 를 사본으로 바꿈 -_BAD_NAME = re.compile(r'[\/:*?"<>|\x00-\x1f]') +_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -37,7 +37,7 @@ 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): - raise StoreError(422, f"쓸 수 없는 이름 「{name}」") + raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」") return FOLDER / kind / f"{name}.json" diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8df33ece..516c1bae 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -4,7 +4,7 @@ * 부품 파일이 없거나 못 읽어도 페이지는 뜸 — 빈 자리 글. * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { readFilled, @@ -48,6 +48,8 @@ export interface MainHandle { root: HTMLElement; open: (sel: Selection | null) => Promise; current: () => Selection | null; + /** 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ + confirmLeave: () => Promise; } /** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */ @@ -75,6 +77,21 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { let loaded: unknown = null; let editor: EditorHandle | null = null; let seq = 0; + let dirty = false; + + const guard = (event: BeforeUnloadEvent): void => { + if (!root.isConnected) return window.removeEventListener("beforeunload", guard); + if (!dirty) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", guard); + const confirmLeave = async (): Promise => { + if (!dirty) return true; + if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false; + dirty = false; + return true; + }; const showNotice = (nodes: (HTMLElement | string)[]): void => { notice.replaceChildren(...nodes); @@ -102,7 +119,10 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { "createSheet", ); if (create) { - editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master" }); + editor = create(host, doc, { + mode: at.layer === "project" ? "project" : "master", + onChange: () => (dirty = !readOnly), + }); return; } return empty(L("M02_NoSheet")); @@ -118,6 +138,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { name: at.name, layer: at.layer, projectId: at.projectId, + onChanged: (d: boolean) => (dirty = d && !readOnly), }); return; } @@ -126,6 +147,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { async function open(next: Selection | null): Promise { const mine = ++seq; + dirty = false; drop(); showNotice([]); sel = next; @@ -166,6 +188,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc); version = info.판; loaded = doc; + dirty = false; showNotice([]); showToast(L("M02_Saved"), "success"); onSaved?.(); // 좌측 목록의 판을 새 판으로 @@ -182,8 +205,10 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { } save.addEventListener("click", () => void doSave()); - reload.addEventListener("click", () => sel && void open(sel)); + reload.addEventListener("click", async () => { + if (sel && (await confirmLeave())) void open(sel); + }); bar(); empty(""); - return { root, open, current: () => sel }; + return { root, open, current: () => sel, confirmLeave }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 640f0dd4..f52b511b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -20,9 +20,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise const main = createMain(isAdmin, () => void side.refresh()); const side = buildSide({ isAdmin, + isCompanyAdmin: isAdmin || user?.role === "ADMIN" || !!user?.is_master, userName: user?.name, onOpen: (sel) => void main.open(sel), getOpen: main.current, + confirmLeave: main.confirmLeave, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 8a618795..b9474ecc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -17,6 +17,7 @@ import { openModal } from "@ui/ui_template_modal"; import { fetchUserProjects, type ProjectItem } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { applyToProject, + deleteLayerTemplate, deleteTemplate, fetchSources, listTemplates, @@ -24,6 +25,7 @@ import { resetProject, saveProjectAs, saveTemplate, + StaleError, type Kind, type Layer, type TemplateInfo, @@ -78,10 +80,14 @@ const store = { export interface SideOptions { isAdmin: boolean; + /** 회사 관리자(회사 층 지우기 · 회사 공식으로 저장) */ + isCompanyAdmin?: boolean; /** 로그인한 사용자 이름 — 프로젝트 기본값(내가 만든 최근 것)에 씀 */ userName?: string; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; } export interface SideHandle { @@ -89,6 +95,10 @@ export interface SideHandle { refresh: () => Promise; } +/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ +const badName = (name: string): boolean => + /[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith("."); + const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const failed = (error: unknown): void => showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); @@ -144,7 +154,8 @@ export function buildSide(opt: SideOptions): SideHandle { const layerButtons = LAYERS.map(({ layer: id, label }) => { const button = createButton({ label: label(), variant: "ghost" }); button.disabled = id === "system" && !opt.isAdmin; - button.addEventListener("click", () => { + button.addEventListener("click", async () => { + if (id !== layer && !(await opt.confirmLeave())) return; layer = id; store.set(LAYER_KEY, id); opt.onOpen(null); @@ -173,7 +184,11 @@ export function buildSide(opt: SideOptions): SideHandle { }; const projectField = createSelectField({ label: L("M02_Project"), options: [] }); - projectField.select.addEventListener("change", () => { + projectField.select.addEventListener("change", async () => { + if (!(await opt.confirmLeave())) { + projectField.select.value = projectId; + return; + } projectId = projectField.select.value; remember(projectId); opt.onOpen(null); @@ -221,7 +236,7 @@ export function buildSide(opt: SideOptions): SideHandle { text: info.이름, attrs: { type: "button", title: info.수정일 }, }); - b.addEventListener("click", () => select(info)); + b.addEventListener("click", () => void select(info)); return b; }) : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), @@ -235,14 +250,19 @@ export function buildSide(opt: SideOptions): SideHandle { layerButtons.forEach(({ id, button }) => button.classList.toggle("is-active", id === layer)); projectField.root.hidden = !isProject(); const editable = layer === "system" && canEdit(layer, opt.isAdmin); - editRow.hidden = layer !== "system"; + const canDelete = + editable || layer === "personal" || (layer === "company" && !!opt.isCompanyAdmin); + editRow.hidden = isProject(); + btnNew.hidden = btnCopy.hidden = layer !== "system"; btnNew.disabled = !editable; - btnCopy.disabled = btnDel.disabled = !editable || !currentInfo(); + btnCopy.disabled = !editable || !currentInfo(); + btnDel.disabled = !canDelete || !currentInfo(); projectRow.hidden = !isProject(); projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId)); } - function select(info: TemplateInfo): void { + async function select(info: TemplateInfo): Promise { + if (!isOpen(info) && !(await opt.confirmLeave())) return; opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 }); paintList(); paintButtons(); @@ -298,11 +318,16 @@ export function buildSide(opt: SideOptions): SideHandle { ok.addEventListener("click", async () => { const text = name.input.value.trim(); if (!text) return showToast(L("M02_NameNeeded"), "error"); + if (badName(text)) return showToast(L("M02_BadName"), "error"); + ok.disabled = true; // 연달아 눌러도 한 번만 try { await onOk(kind.select.value as Kind, text); close(); } catch (error) { - failed(error); + if (error instanceof StaleError) showToast(L("M02_NameExists"), "error"); + else failed(error); + } finally { + ok.disabled = false; } }); body.append(...(withKind ? [kind.root] : []), name.root, ok); @@ -315,7 +340,7 @@ export function buildSide(opt: SideOptions): SideHandle { showToast(L("M02_Created"), "success"); await refresh(); const info = items.find((i) => i.종류 === kind && i.이름 === name); - if (info) select(info); + if (info) await select(info); }; btnNew.addEventListener("click", () => @@ -339,7 +364,8 @@ export function buildSide(opt: SideOptions): SideHandle { if (!info) return; if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return; try { - await deleteTemplate(info.종류, info.이름, info.판); + if (layer === "system") await deleteTemplate(info.종류, info.이름, info.판); + else await deleteLayerTemplate(layer, info.종류, info.이름); showToast(L("M02_Deleted"), "success"); opt.onOpen(null); await refresh(); @@ -350,6 +376,7 @@ export function buildSide(opt: SideOptions): SideHandle { /* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */ const act = async (run: () => Promise): Promise => { + if (!(await opt.confirmLeave())) return; try { await run(); showToast(L("M02_Done"), "success"); @@ -364,9 +391,21 @@ export function buildSide(opt: SideOptions): SideHandle { b.addEventListener("click", onClick); projectRow.append(b); }; - const saveOpenAs = (to: "personal" | "company"): void => { + const saveOpenAs = async (to: "personal" | "company"): Promise => { const cur = opt.getOpen(); if (!cur) return void showToast(L("M02_PickTemplate"), "error"); + try { + const rows = await listTemplates(to, null); + const same = rows.some((r) => r.종류 === cur.kind && r.이름 === cur.name); + if ( + same && + !(await showConfirmDialog(L("M02_OverwriteConfirm").replace("{value}", cur.name))) + ) { + return; + } + } catch (error) { + return failed(error); + } void act(() => saveProjectAs(projectId, to, cur.kind, cur.name)); }; projectButton(L("M02_ProjectReset"), async () => { @@ -407,8 +446,8 @@ export function buildSide(opt: SideOptions): SideHandle { }, }); }); - projectButton(L("M02_ProjectSavePersonal"), () => saveOpenAs("personal")); - projectButton(L("M02_ProjectSaveCompany"), () => saveOpenAs("company")); + projectButton(L("M02_ProjectSavePersonal"), () => void saveOpenAs("personal")); + projectButton(L("M02_ProjectSaveCompany"), () => void saveOpenAs("company")); void refresh(); return { root, refresh }; diff --git a/resources/tester/test_m02_store.py b/resources/tester/test_m02_store.py index c6090378..53f01325 100644 --- a/resources/tester/test_m02_store.py +++ b/resources/tester/test_m02_store.py @@ -47,13 +47,9 @@ def test_new_save_reopen_delete_and_stale(client): def test_bad_names_and_kinds(client): - assert ( - client.put("/api/m02/templates/table/a..b", json={"판": "", "문서": {"열": []}}).status_code - == 200 - ) - assert ( - client.put("/api/m02/templates/table/.숨김", json={"판": "", "문서": {}}).status_code == 422 - ) + for bad in ("a..b", ".숨김", "a%5Cb", "a%3Ab"): + 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 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index cb4a8225..47757e43 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -59,6 +59,16 @@ export const ui_locales_m2 = { ], M02_ImportTitle: ["양식 가져오기", "Import template"], M02_ImportNone: ["가져올 양식 없음", "Nothing to import"], + M02_NameExists: ["이미 있는 이름", "That name already exists"], + M02_BadName: [ + '쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨', + 'Invalid name — no / \\ : * ? " < > | or .. or leading dot', + ], + M02_DiscardConfirm: [ + "저장 안 한 고침이 있음 — 버리고 넘어갈까?", + "Unsaved changes — discard and continue?", + ], + M02_OverwriteConfirm: ["「{value}」 이(가) 이미 있음 — 덮을까?", "“{value}” exists — overwrite?"], M02_Done: ["했음", "Done"], M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"], } as const;