- 항목 추가(나무 「항목 추가」 · 「새 항목」) — 구조물 종류(있는 것 고름 · 새 이름 · 폴더를 골라 두었으면 그 안) + 이름 + 일위대가(세 층 조합 고르기 창 · 찾기 AND · 비워도 됨) · 단위 칸 없앰 · 빈 칸 안내 · 서버 add-item 이 일위대가 받음 - 일위대가 고르기 창을 pickCombo 로 내보내 표 칸 고르기와 같이 씀 - 카드 넷(도면 정보 · 면적산출 · 재료산출 · 선택 입력) 2행 2열 · 높이 360px · 넘치면 카드 안 스크롤 · 상세 칸 전체 스크롤(좁으면 1열) - 「구조물 조합」 제목을 누르면 표 접힘 · 펴짐 - 시험 — 항목 추가 서버 · 창 · 격자 · 접기 · 옛 단위 필수 시험을 31 정함으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
13 KiB
Python
319 lines
13 KiB
Python
"""M02 집계 항목 **폴더 구조**(PLAN 26-1 · 26-2a) — 항목 경로 · 집계표 맞춤 · 나무 고침.
|
|
|
|
항목 = 구조물집계표의 구조물 열(꼴=수) 하나 · 경로 = 폴더 이름 배열(깊이 자유 · 마지막이 항목 이름)
|
|
· 이름 = 경로를 이은 글. 자료의 자리는 집계표 한 파일 — 열 `머리`(제목행)가 곧 경로(빈 칸은 건너뜀)
|
|
이고, 열에 `경로` 를 같이 적어 둠. 머리 줄 수(`층`)는 가장 깊은 경로에 맞춤(넷보다 적게는 안 줄임 —
|
|
옛 코드가 종류 · 분류1~3 넷을 읽음). 빈 폴더는 집계표 `폴더`(경로 목록)에 적음.
|
|
|
|
나무 고침(폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기)은 집계표 문서를 고쳐
|
|
돌려줄 뿐 — 저장은 부르는 쪽이 집계표 저장(`store.write`)으로 해서 표 저장과 한 줄기
|
|
(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기)를 탐. 이 파일은 저장소를 부르지 않음(순수 함수).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import re
|
|
from typing import Any
|
|
|
|
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
|
|
_OLD_NAMES = ("종류", "분류1", "분류2", "분류3")
|
|
OPS = ("add-folder", "add-item", "rename", "move", "delete")
|
|
MAX_NAME = 60
|
|
_BAD = re.compile(r"[\x00-\x1f]")
|
|
|
|
|
|
class TreeError(Exception):
|
|
def __init__(self, status: int, detail: Any):
|
|
super().__init__(detail)
|
|
self.status, self.detail = status, detail
|
|
|
|
|
|
# ── 경로 읽기 ─────────────────────────────────────────
|
|
|
|
|
|
def _clean(parts: Any) -> list[str]:
|
|
if not isinstance(parts, list):
|
|
return []
|
|
return [str(p).strip() for p in parts if p is not None and str(p).strip()]
|
|
|
|
|
|
def path_of(col: dict[str, Any]) -> list[str]:
|
|
"""열의 경로 — 머리(제목행)의 빈 칸을 건너뛴 이름들 · 머리가 없으면 `경로`."""
|
|
heads = col.get("머리")
|
|
return _clean(heads) if isinstance(heads, list) else _clean(col.get("경로"))
|
|
|
|
|
|
def row_path(row: dict[str, Any]) -> list[str]:
|
|
"""SS 장부 줄의 경로 — `경로` 가 없던 옛 줄은 종류 · 분류1~3 에서(빈 분류 건너뜀)."""
|
|
got = _clean(row.get("경로"))
|
|
return got or _clean([row.get(k) for k in _OLD_NAMES])
|
|
|
|
|
|
def name_of(path: list[str]) -> str:
|
|
return " ".join(path)
|
|
|
|
|
|
def old_fields(path: list[str]) -> dict[str, str | None]:
|
|
"""옛 칸(종류 · 분류1~3) — 경로 앞 넷(다른 코드가 읽음)."""
|
|
padded = [*path, *[None] * MIN_LEVELS][:MIN_LEVELS]
|
|
return dict(zip(_OLD_NAMES, padded))
|
|
|
|
|
|
def is_item(col: Any) -> bool:
|
|
return isinstance(col, dict) and bool(col.get("id")) and col.get("꼴") == "수"
|
|
|
|
|
|
def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
|
|
return [c for c in doc.get("열") or [] if is_item(c)]
|
|
|
|
|
|
# ── 집계표 맞춤 ───────────────────────────────────────
|
|
|
|
|
|
def _trimmed(heads: Any) -> list[Any]:
|
|
out = list(heads) if isinstance(heads, list) else []
|
|
while out and (out[-1] is None or not str(out[-1]).strip()):
|
|
out.pop()
|
|
return out
|
|
|
|
|
|
def normalize_table(doc: dict[str, Any]) -> dict[str, Any]:
|
|
"""저장 앞 맞춤 — 항목 열: `경로` = 머리의 빈 칸 건너뛴 이름 · 머리 = 경로를 머리 줄 수만큼
|
|
채움 · 머리 줄 수(`층`) = 가장 깊은 경로(넷보다 적게는 안 줄임) ·
|
|
`폴더` = 경로 목록(겹침 · 빈 것 뺌).
|
|
글 열(NO · 측점 · 비고)은 머리 줄 수만 맞춤."""
|
|
out = copy.deepcopy(doc)
|
|
cols = [c for c in out.get("열") or [] if isinstance(c, dict)]
|
|
depth = max(
|
|
[MIN_LEVELS, *(len(path_of(c)) for c in cols if is_item(c))]
|
|
+ [len(_trimmed(c.get("머리"))) for c in cols if not is_item(c)]
|
|
)
|
|
names = [str(n) for n in (out.get("층") or [])][:depth]
|
|
names += [_OLD_NAMES[i] if i < MIN_LEVELS else f"분류{i}" for i in range(len(names), depth)]
|
|
out["층"] = names
|
|
for col in cols:
|
|
if is_item(col):
|
|
path = path_of(col)
|
|
col["경로"] = path
|
|
col["머리"] = [*path, *[None] * depth][:depth]
|
|
else:
|
|
col["머리"] = [*_trimmed(col.get("머리")), *[None] * depth][:depth]
|
|
folders: list[list[str]] = []
|
|
for raw in out.get("폴더") or []:
|
|
path = _clean(raw)
|
|
if path and path not in folders:
|
|
folders.append(path)
|
|
out["폴더"] = folders
|
|
return out
|
|
|
|
|
|
def keep_paths(doc: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
|
|
"""화면 표 [저장](PLAN 26-7e) — 정본은 나무: 있던 항목의 경로(머리) · 층 이름 · 빈 폴더는
|
|
저장본 그대로 · 브라우저가 보낸 머리는 안 받음. 머리는 저장 맞춤(`normalize_table`)이 경로에서
|
|
다시 만듦."""
|
|
out = copy.deepcopy(doc)
|
|
was = {c["id"]: path_of(c) for c in _items(stored)}
|
|
for col in _items(out):
|
|
if col["id"] in was:
|
|
_set_path(col, was[col["id"]])
|
|
out["층"] = copy.deepcopy(stored.get("층") or [])
|
|
out["폴더"] = copy.deepcopy(stored.get("폴더") or [])
|
|
return out
|
|
|
|
|
|
def folders_of(doc: dict[str, Any]) -> list[list[str]]:
|
|
"""나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례)."""
|
|
out: list[list[str]] = []
|
|
for col in _items(doc):
|
|
path = path_of(col)
|
|
for n in range(1, len(path)):
|
|
if path[:n] not in out:
|
|
out.append(path[:n])
|
|
for path in doc.get("폴더") or []:
|
|
path = _clean(path)
|
|
if path and path not in out:
|
|
out.append(path)
|
|
return out
|
|
|
|
|
|
# ── 나무 고침 ─────────────────────────────────────────
|
|
|
|
|
|
def _name(value: Any, what: str = "이름") -> str:
|
|
text = str(value).strip() if isinstance(value, str) else ""
|
|
if not text or len(text) > MAX_NAME or _BAD.search(text):
|
|
raise TreeError(422, f"「{what}」 — 빈 글 · {MAX_NAME}자 넘음 · 조절 문자는 못 씀")
|
|
return text
|
|
|
|
|
|
def _path(value: Any, what: str = "경로") -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list):
|
|
raise TreeError(422, f"「{what}」 이 폴더 이름 목록이 아님")
|
|
return [_name(v, what) for v in value]
|
|
|
|
|
|
def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]:
|
|
"""항목 키 · 옛 키(또는 열 id)로 항목 열 — 없으면 404."""
|
|
cid = ids.get(str(key), str(key)) if key else None
|
|
col = next((c for c in _items(doc) if c["id"] == cid), None)
|
|
if col is None:
|
|
raise TreeError(404, f"없는 항목 「{key}」")
|
|
return col
|
|
|
|
|
|
def _item_paths(doc: dict[str, Any], skip: list[dict[str, Any]] | None = None) -> list[list[str]]:
|
|
return [path_of(c) for c in _items(doc) if not skip or all(c is not s for s in skip)]
|
|
|
|
|
|
def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
|
|
if path in _item_paths(doc, skip):
|
|
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
|
|
|
|
|
|
def _set_path(col: dict[str, Any], path: list[str]) -> None:
|
|
col["경로"] = path
|
|
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
|
|
|
|
|
|
def _common(a: list[str], b: list[str]) -> int:
|
|
n = 0
|
|
while n < min(len(a), len(b)) and a[n] == b[n]:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def _place(doc: dict[str, Any], col: dict[str, Any]) -> None:
|
|
"""열을 가장 가까운 형제 뒤에 끼움(같은 앞마디가 긴 항목 중 마지막) — 제목행 병합이 이어지게.
|
|
항목이 없으면 글 열 가운데 마지막 앞(비고 앞) · 그것도 없으면 끝."""
|
|
cols = doc.setdefault("열", [])
|
|
path = path_of(col)
|
|
best, at = -1, None
|
|
for i, other in enumerate(cols):
|
|
if is_item(other):
|
|
share = _common(path, path_of(other))
|
|
if share >= best:
|
|
best, at = share, i
|
|
if at is None:
|
|
at = len(cols) - 2 if len(cols) >= 2 else len(cols) - 1
|
|
cols.insert(at + 1, col)
|
|
|
|
|
|
def _fresh_id(doc: dict[str, Any]) -> str:
|
|
used = {c.get("id") for c in doc.get("열") or [] if isinstance(c, dict)}
|
|
n = 1
|
|
while f"item{n}" in used:
|
|
n += 1
|
|
return f"item{n}"
|
|
|
|
|
|
def _under(path: list[str], folder: list[str]) -> bool:
|
|
return path[: len(folder)] == folder
|
|
|
|
|
|
def _folder(doc: dict[str, Any], where: list[str], what: str) -> None:
|
|
"""고칠 폴더가 있는지 — 경로가 비었으면 422 · 없으면 404."""
|
|
if not where:
|
|
raise TreeError(422, f"{what} 폴더 「경로」 가 없음")
|
|
if where not in folders_of(doc) and not any(_under(p, where) for p in _item_paths(doc)):
|
|
raise TreeError(404, f"없는 폴더 「{name_of(where)}」")
|
|
|
|
|
|
def apply(
|
|
doc: dict[str, Any], body: dict[str, Any], ids: dict[str, str] | None = None
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""나무 고침 하나 → (새 집계표 문서, 알림). `ids` = SS 키 → 열 id(항목을 키로 부를 때).
|
|
본문 `{op, 경로, 이름?, 새경로?, 키?, 단위?}` — 26-1 약속 모양 · 판은 부르는 쪽이 봄."""
|
|
out = copy.deepcopy(doc)
|
|
ids = ids or {}
|
|
op = body.get("op")
|
|
if op not in OPS:
|
|
raise TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}")
|
|
where = _path(body.get("경로"))
|
|
key = body.get("키")
|
|
note: dict[str, Any] = {"op": op}
|
|
folders = out.setdefault("폴더", [])
|
|
|
|
if op == "add-folder":
|
|
made = [*where, _name(body.get("이름"))]
|
|
if made in folders_of(out) or made in _item_paths(out):
|
|
raise TreeError(409, f"이미 있는 폴더 · 항목 「{name_of(made)}」")
|
|
folders.append(made)
|
|
note["경로"] = made
|
|
elif op == "add-item":
|
|
made = [*where, _name(body.get("이름"))]
|
|
_taken_item(out, made)
|
|
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
|
|
# 항목 추가 창에서 고른 일위대가(PLAN 31-2) — 없으면 비움 · 단위는 안 받아도 됨
|
|
col.update({"일위대가": body.get("일위대가") or None, "자재조합": None})
|
|
_set_path(col, made)
|
|
_place(out, col)
|
|
note.update({"경로": made, "열": col["id"]})
|
|
elif op == "rename":
|
|
new = _name(body.get("이름"))
|
|
if key:
|
|
col = _find(out, key, ids)
|
|
path = path_of(col)
|
|
moved = [*path[:-1], new]
|
|
_taken_item(out, moved, [col])
|
|
_set_path(col, moved)
|
|
note["경로"] = moved
|
|
else:
|
|
_folder(out, where, "바꿀")
|
|
moved = [*where[:-1], new]
|
|
if moved != where and (
|
|
moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out))
|
|
):
|
|
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
|
|
_move_folder(out, where, moved)
|
|
note["경로"] = moved
|
|
elif op == "move":
|
|
to = _path(body.get("새경로"), "새경로")
|
|
if key:
|
|
col = _find(out, key, ids)
|
|
moved = [*to, path_of(col)[-1]]
|
|
_taken_item(out, moved, [col])
|
|
out["열"] = [c for c in out["열"] if c is not col]
|
|
_set_path(col, moved)
|
|
_place(out, col)
|
|
note["경로"] = moved
|
|
else:
|
|
_folder(out, where, "옮길")
|
|
if _under(to, where):
|
|
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
|
|
moved = [*to, where[-1]]
|
|
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
|
|
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
|
|
_move_folder(out, where, moved, replace=True)
|
|
note["경로"] = moved
|
|
else: # delete
|
|
if key:
|
|
gone = [_find(out, key, ids)]
|
|
else:
|
|
_folder(out, where, "지울")
|
|
gone = [c for c in _items(out) if _under(path_of(c), where)]
|
|
out["폴더"] = [f for f in folders if not _under(_clean(f), where)]
|
|
out["열"] = [c for c in out["열"] if all(c is not g for g in gone)]
|
|
note["지운항목"] = [g["id"] for g in gone]
|
|
return out, note
|
|
|
|
|
|
def _move_folder(
|
|
doc: dict[str, Any], old: list[str], new: list[str], replace: bool = False
|
|
) -> None:
|
|
"""폴더 `old` 아래 항목 · 빈 폴더의 앞마디를 `new` 로 — 이름 바꾸기는 제자리 ·
|
|
옮기기(`replace`)는 새 형제 곁으로 열 차례도 옮김."""
|
|
moving = [c for c in _items(doc) if _under(path_of(c), old)]
|
|
if replace:
|
|
doc["열"] = [c for c in doc["열"] if all(c is not m for m in moving)]
|
|
for col in moving:
|
|
_set_path(col, [*new, *path_of(col)[len(old) :]])
|
|
if replace:
|
|
_place(doc, col)
|
|
doc["폴더"] = [
|
|
[*new, *_clean(f)[len(old) :]] if _under(_clean(f), old) else f
|
|
for f in doc.get("폴더") or []
|
|
]
|