"""M02 서고 나무 = 자유 폴더(PLAN 158-1 · 716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 한 벌. 자리 = 층 뿌리 `{종류}_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — 종류 · 층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/{종류}/SC-######.json`)은 안 옮김 · 폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · 문서 옮기기 · 지우기)은 판이 다르면 409 · 여럿(`대상`) = 옮기기 · 지우기를 차례로 · 저장 한 번. 지우기 = 안에 든 문서째(716-4) — 모두 검사 뒤 한 번에 · 바깥에 쓰는 곳이 있으면 409 + 그 이름(지우는 것끼리 참조는 막지 않음). 붙여넣기(`paste`) = 복사한 층 문서 · 폴더(안의 것까지) → 지금 층 새 키 · 같은 폴더 이름 「(1)」 · 못 읽는 참조는 같이 복사(`M02_Labor_Ref.carry`). 이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌. """ from __future__ import annotations import json from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_Template_Layers as layers OPS = ("add-folder", "rename", "move", "delete") USERS = (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")) # refs.users 꼴 def file_of(kind: str) -> str: return f"{kind}_폴더.json" def _keys(kind: str, root: Path | None = None) -> set[str]: """그 층에 있는 서고 키(파일 이름).""" return {p.stem for p in ((root or store.root()) / kind).glob("[!._]*.json")} def _read(kind: str, root: Path | None = None) -> tuple[dict[str, Any], str]: path = (root or store.root()) / file_of(kind) if not path.is_file(): return {"폴더": [], "자리": {}}, "" raw = path.read_bytes() return json.loads(raw), store.version_of(raw) def _folders(doc: dict[str, Any], keys: set[str]) -> list[list[str]]: """폴더 전부 — 적어 둔 폴더 + 있는 문서 자리의 앞마디(빈 경로 뺌 · 겹침 뺌).""" out: list[list[str]] = [] paths = [tree._clean(p) for p in doc.get("폴더") or []] paths += [tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys] for path in paths: for n in range(1, len(path) + 1): if path[:n] not in out: out.append(path[:n]) return out def _places(doc: dict[str, Any], keys: set[str]) -> dict[str, list[str]]: return {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} def view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]: places = _places(doc, keys) return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}} def view(kind: str = "precombo") -> dict[str, Any]: doc, 판 = _read(kind) return view_of(doc, 판, _keys(kind)) def _shift(path: list[str], old: list[str], new: list[str]) -> list[str]: return [*new, *path[len(old) :]] if tree._under(path, old) else path def _out(folders: list[list[str]], places: dict[str, list[str]]) -> dict[str, Any]: return {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} def apply( doc: dict[str, Any], body: dict[str, Any], keys: set[str] ) -> tuple[dict[str, Any], dict[str, Any]]: """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장 · 문서 파일 지우기는 `edit`). 지우기 알림 `지운문서` = 지울 문서 키(폴더 안 · 아래 폴더까지).""" op = body.get("op") if op not in OPS: raise tree.TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}") folders = _folders(doc, keys) places = _places(doc, keys) where = tree._path(body.get("경로")) key = str(body.get("키") or "") note: dict[str, Any] = {"op": op} def need(path: list[str], what: str) -> None: if path and path not in folders: raise tree.TreeError(404, f"없는 폴더 「{' / '.join(path)}」") if not path and what: raise tree.TreeError(422, f"{what} 폴더 「경로」 가 없음") if key and op in ("move", "delete") and key not in keys: raise tree.TreeError(404, f"없는 문서 「{key}」") if op == "add-folder": need(where, "") made = [*where, tree._name(body.get("이름"))] if made in folders: raise tree.TreeError(409, f"이미 있는 폴더 「{' / '.join(made)}」") folders.append(made) note["경로"] = made elif op == "move" and key: to = tree._path(body.get("새경로"), "새경로") need(to, "") places[key] = to note["경로"] = to elif op == "delete" and key: places.pop(key, None) note["지운문서"] = [key] elif op in ("rename", "move"): need(where, "바꿀" if op == "rename" else "옮길") if op == "rename": new = [*where[:-1], tree._name(body.get("이름"))] if new != where and new in folders: raise tree.TreeError(409, f"이미 있는 폴더 「{' / '.join(new)}」") else: to = tree._path(body.get("새경로"), "새경로") need(to, "") if tree._under(to, where): raise tree.TreeError(422, "폴더를 제 안으로 옮길 수 없음") new = tree.unique_path([*to, where[-1]], lambda p: p != where and p in folders) if new[-1] != where[-1]: note["바뀐이름"] = [new[-1]] folders = [_shift(f, where, new) for f in folders] places = {k: _shift(p, where, new) for k, p in places.items()} note["경로"] = new else: # delete — 안에 든 문서째(716-4) need(where, "지울") inside = sorted(k for k, p in places.items() if tree._under(p, where)) folders = [f for f in folders if not tree._under(f, where)] places = {k: p for k, p in places.items() if k not in inside} note["경로"] = where note["지운문서"] = inside return _out(folders, places), note def apply_many( doc: dict[str, Any], body: dict[str, Any], keys: set[str] ) -> tuple[dict[str, Any], dict[str, Any]]: """`대상` 이 있으면 옮기기 · 지우기를 하나씩 차례로(하나라도 막히면 아무것도 안 함) · 없으면 `apply`.""" targets = body.get("대상") if targets is None: return apply(doc, body, keys) op = body.get("op") if op not in ("move", "delete") or not targets: raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") note: dict[str, Any] = {"op": op, "지운문서": [], "바뀐이름": []} for one in targets: if not isinstance(one, dict): raise tree.TreeError(422, "「대상」 은 {경로, 키?} 목록") part = { "op": op, "경로": one.get("경로"), "키": one.get("키"), "새경로": body.get("새경로"), } if op == "delete" and not part["키"] and not tree._path(part["경로"]): continue # 맨 위는 못 지움 try: doc, got = apply(doc, part, keys) except tree.TreeError as e: if e.status == 404 and op == "delete": continue # 고른 폴더 안의 것 — 앞에서 이미 지움 raise note["지운문서"] += [k for k in got.get("지운문서", []) if k not in note["지운문서"]] note["바뀐이름"] += got.get("바뀐이름", []) return doc, note def _refuse_used(kind: str, gone: list[str]) -> None: """지울 문서를 바깥(지우는 것 밖)에서 쓰면 409 + 「어디 이름 ← 키」 — 모든 층 훑음(프로젝트 뺌).""" layer, owner = refs.here() rids = {refs.ref_id({"층": layer, "주인": owner, "키": k}) for k in gone} mine = {(store.root() / kind / f"{k}.json").resolve() for k in gone} found = [] for what_kind, what in USERS: for at, path in layers.layer_files(what_kind): if path.resolve() in mine: continue try: doc = json.loads(path.read_bytes()) except (OSError, ValueError): continue hit = sorted({r["키"] for r in refs.refs_in(doc) if refs.ref_id(r) in rids}) if hit: name = doc.get("일위대가번") or doc.get("이름") or path.stem found.append(f"{what} {at} 「{name}」 ← {' · '.join(hit)}") if found: raise store.StoreError(409, "쓰는 곳이 있어 못 지움 — " + " · ".join(found)) # ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임 # (키 재사용을 막을 때 함께 고침) def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]: """지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째) → 새 나무 + 알림 · 판 다르면 409.""" doc, 판 = _read(kind) if 판 != str(body.get("판") or ""): raise store.StoreError(409, {"stale": [file_of(kind)], "판": 판}) keys = _keys(kind) try: out, note = apply_many(doc, body, keys) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e gone = note.get("지운문서") or [] if gone: _refuse_used(kind, gone) # 모두 검사 뒤 한 번에 for key in gone: (store.root() / kind / f"{key}.json").unlink(missing_ok=True) atomic_write_json(store.root() / file_of(kind), out) return {**view(kind), "알림": note} # ponytail: 붙여넣기 도중 막히면 앞서 만든 사본은 뿌리에 남음 — 한 번에 되돌림이 필요하면 그때 def paste(source: Path, picks: list[dict[str, Any]], dest: Any, kind: str) -> dict[str, Any]: """복사한 층(`source`) 고른 것 `[{경로, 키?}]` → 지금 층 `dest` 아래 새 키(본뜬 = 원본) · 폴더는 안의 것 · 아래 폴더까지 · 같은 폴더 이름 「(1)」 → 새 나무 + 알림 `만든문서`.""" src = refs.where(source) src_keys = _keys(kind, source) src_doc, _ = _read(kind, source) src_folders, src_places = _folders(src_doc, src_keys), _places(src_doc, src_keys) doc, _ = _read(kind) keys = _keys(kind) folders, places = _folders(doc, keys), _places(doc, keys) try: to = tree._path(dest) if to and to not in folders: raise tree.TreeError(404, f"없는 폴더 「{' / '.join(to)}」") except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e def copy(key: str) -> str: ref = {"층": src[0], "주인": src[1], "키": key} got = refs.read_ref(ref) if got is None: raise store.StoreError(404, f"없는 문서 「{key}」") return refs.create(kind, refs.carry(refs.origin_mark(got, ref), src))["이름"] made: list[str] = [] for pick in picks: key = str(pick.get("키") or "") if isinstance(pick, dict) else "" path = tree._clean(pick.get("경로")) if isinstance(pick, dict) else [] if key: if key not in src_keys: raise store.StoreError(404, f"없는 문서 「{key}」") made.append(copy(key)) places[made[-1]] = to continue if not path or path not in src_folders: raise store.StoreError(404, f"없는 폴더 「{' / '.join(path)}」") top = tree.unique_path([*to, path[-1]], lambda p: p in folders) folders += [[*top, *f[len(path) :]] for f in src_folders if tree._under(f, path)] for k, p in sorted(src_places.items()): if tree._under(p, path): made.append(copy(k)) places[made[-1]] = [*top, *p[len(path) :]] atomic_write_json(store.root() / file_of(kind), _out(folders, places)) return {**view(kind), "알림": {"op": "paste", "경로": to, "만든문서": made}}