Files
Aislo/M02_MasterTemplete/M02_Precombo_Folders.py
T
eomsangdonandClaude Opus 5.5 064d9ebae7 feat(m02): 일위대가 사전조합 나무 = 자유 폴더 — 분류 고정 폴더 걷어 냄(158-1)
- 서버: 층 뿌리 precombo_폴더.json 에 폴더 · 문서 자리만 적음(문서 파일 제자리) · GET/POST /layers/{층}/precombo-tree · 새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(빈 폴더만) · 판 409 · 층 권한 그대로
- 화면: 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기] · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기(구조물 조합 부품) · 자리 없는 문서 = 뿌리
- 구분 · 상세구분은 문서 칸 그대로(고르기 창 거름 precomboClasses)
- 시험: test_m02_precombo_folders 새로 · test_m02_precombo_scope 나무 시험 갈음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-09 21:30:49 +09:00

137 lines
5.8 KiB
Python

"""M02 일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1) — 구조물 조합 나무와 같은 꼴(깊이 자유).
자리 = 층 뿌리 `precombo_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` —
층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/precombo/SC-######.json`)은 안 옮김 ·
폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 ·
문서 옮기기)은 판이 다르면 409 · 지우기는 빈 폴더만(안에 문서가 있으면 409).
이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌.
"""
from __future__ import annotations
import json
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
FILE = "precombo_폴더.json"
OPS = ("add-folder", "rename", "move", "delete")
def _keys() -> set[str]:
"""지금 층에 있는 사전조합 키(파일 이름)."""
return {p.stem for p in (store.root() / "precombo").glob("[!._]*.json")}
def _read() -> tuple[dict[str, Any], str]:
path = store.root() / FILE
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 view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]:
places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys}
return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}}
def view() -> dict[str, Any]:
doc, 판 = _read()
return view_of(doc, 판, _keys())
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 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 = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in 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 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:
if key not in keys:
raise tree.TreeError(404, f"없는 사전조합 「{key}」")
to = tree._path(body.get("새경로"), "새경로")
need(to, "")
places[key] = to
note["경로"] = to
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 — 빈 폴더만
need(where, "지울")
inside = sorted(k for k, p in places.items() if tree._under(p, where))
if inside:
raise tree.TreeError(409, f"폴더 안에 사전조합 {len(inside)}개 — 먼저 옮기거나 지움")
folders = [f for f in folders if not tree._under(f, where)]
note["경로"] = where
out = {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}}
return out, note
# ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임
# (키 재사용을 막을 때 함께 고침)
def edit(body: dict[str, Any]) -> dict[str, Any]:
"""지금 층 자리 파일을 고쳐 씀 → 새 나무 + 알림 · 판 다르면 409."""
doc, 판 = _read()
if 판 != str(body.get("판") or ""):
raise store.StoreError(409, {"stale": [FILE], "판": 판})
keys = _keys()
try:
out, note = apply(doc, body, keys)
except tree.TreeError as e:
raise store.StoreError(e.status, e.detail) from e
atomic_write_json(store.root() / FILE, out)
return {**view(), "알림": note}