"""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 [] ]