Files
Aislo/M02_MasterTemplete/M02_Precombo_Folders.py
T
eomsangdonandClaude Opus 5.5 ceec2a99da feat(716-4): 서고 나무 폴더 — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기
- 서버 `M02_Precombo_Folders` — 종류 인자(precombo · sangeun · 자리 파일 `{종류}_폴더.json`) · 폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · 지우는 것끼리 참조는 됨) · 문서 지우기 · `대상` 여럿 옮기기 · 지우기 · 붙여넣기(새 키 · 본뜬 = 원본 · 같은 폴더 이름 「(1)」)
- 길 `M02_Labor_Router` — `/layers/{층}/{종류}-tree` GET · POST · `/paste`(옛 precombo-tree 주소 그대로)
- 화면 새 `UI_Side_Library.ts` — 일위대가 · 단가산출 두 칸이 같이 쓰는 서고 칸(Ctrl · Shift 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · Delete · 지우기 확인 창 · 끌어 옮기기 여럿) · `UI_Side_Precombo.ts` = 일위대가 몫만 넘김
- `Api_Fetch` — 나무 부르기 종류 인자 · `pasteLibraryTree` · `newLibrary` 종류 sangeun
- 시험 — 새 `test_716_4_library_folders.py`(임시 storage) · 낡은 것 고침: `test_m02_precombo_folders`(빈 폴더만 지움 → 안에 든 것째 · 「없는 사전조합」 → 「없는 문서」 · FILE → file_of) · `test_m02_precombo_scope` + helper(나무 짜기 · 서고 길 글이 Library.ts 로 옮김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:35:23 +09:00

267 lines
12 KiB
Python

"""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}}