feat(M02): 옮기기 같은 이름 = 「이름(1)」 — 항목 · 폴더 · 여러 개 · 붙여넣기 · 복사 생성 한 벌(86-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기].
|
||||
|
||||
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
|
||||
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
|
||||
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」).
|
||||
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
@@ -40,13 +40,6 @@ def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [c for c in doc.get("열") or [] if tree.is_item(c)]
|
||||
|
||||
|
||||
def _unique(path: list[str], taken: list[list[str]]) -> list[str]:
|
||||
n, out = 2, path
|
||||
while out in taken:
|
||||
out, n = [*path[:-1], f"{path[-1]} ({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def paste(
|
||||
source: Path,
|
||||
target: Path,
|
||||
@@ -67,6 +60,7 @@ def paste(
|
||||
taken_folders = tree.folders_of(dst)
|
||||
plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로)
|
||||
new_folders: list[list[str]] = []
|
||||
renamed: list[str] = [] # 겹쳐서 번호 붙은 새 이름
|
||||
|
||||
def busy(path: list[str]) -> bool:
|
||||
paths = [*taken, *(p for _, p in plan)]
|
||||
@@ -78,16 +72,18 @@ def paste(
|
||||
found = next((p for p, k in src_items if k == key), None)
|
||||
if found is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
plan.append((key, _unique([*dest, found[-1]], [*taken, *(p for _, p in plan)])))
|
||||
used = [*taken, *(p for _, p in plan)]
|
||||
path = tree.unique_path([*dest, found[-1]], lambda p: p in used)
|
||||
renamed += [path[-1]] if path[-1] != found[-1] else []
|
||||
plan.append((key, path))
|
||||
continue
|
||||
folder = list(one.get("경로") or [])
|
||||
if not folder or (
|
||||
folder not in src_folders and not any(_under(p, folder) for p, _ in src_items)
|
||||
):
|
||||
raise store.StoreError(404, f"없는 폴더 「{' '.join(folder)}」")
|
||||
top, n = [*dest, folder[-1]], 2
|
||||
while busy(top):
|
||||
top, n = [*dest, f"{folder[-1]} ({n})"], n + 1
|
||||
top = tree.unique_path([*dest, folder[-1]], busy)
|
||||
renamed += [top[-1]] if top[-1] != folder[-1] else []
|
||||
new_folders.append(top)
|
||||
new_folders += [[*top, *f[len(folder) :]] for f in src_folders if _under(f, folder)]
|
||||
plan += [(k, [*top, *p[len(folder) :]]) for p, k in src_items if _under(p, folder) and k]
|
||||
@@ -102,4 +98,7 @@ def paste(
|
||||
doc = got["문서"]
|
||||
doc["폴더"] = [*(doc.get("폴더") or []), *new_folders]
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
return {"op": "paste", "경로": dest, "열": made}
|
||||
note: dict[str, Any] = {"op": "paste", "경로": dest, "열": made}
|
||||
if renamed:
|
||||
note["바뀐이름"] = renamed
|
||||
return note
|
||||
|
||||
@@ -34,13 +34,10 @@ def heads_of(path: list[str], name: str | None) -> list[str]:
|
||||
|
||||
|
||||
def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
|
||||
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로
|
||||
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름(1)」 로
|
||||
(나무는 같은 경로 두 항목을 안 둠)."""
|
||||
taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)]
|
||||
out, n = list(full), 2
|
||||
while out in taken:
|
||||
out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1
|
||||
return out
|
||||
return tree.unique_path(list(full), lambda p: p in taken)
|
||||
|
||||
|
||||
def create_item(
|
||||
|
||||
@@ -354,6 +354,8 @@ export interface TreeNote {
|
||||
/** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */
|
||||
열?: string | string[];
|
||||
지운항목?: string[];
|
||||
/** 같은 이름이 있어 「이름(1)」 꼴로 바뀐 새 이름 — 옮기기 · 붙여넣기(PLAN 86-2) */
|
||||
바뀐이름?: string[];
|
||||
}
|
||||
export interface TreeView {
|
||||
판: string;
|
||||
|
||||
@@ -235,11 +235,12 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
|
||||
else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀)
|
||||
if body.get("op") not in ("move", "delete") or not targets:
|
||||
raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함")
|
||||
doc, note = got["문서"], {"op": body["op"], "지운항목": []}
|
||||
doc, note = got["문서"], {"op": body["op"], "지운항목": [], "바뀐이름": []}
|
||||
for one in paste_store.outer(targets):
|
||||
step = {**body, "경로": one.get("경로"), "키": one.get("키")}
|
||||
doc, done = tree.apply(doc, step, ids)
|
||||
note["지운항목"] += done.get("지운항목", [])
|
||||
note["바뀐이름"] += done.get("바뀐이름", [])
|
||||
except tree.TreeError as e:
|
||||
raise store.StoreError(e.status, e.detail) from e
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
@@ -267,7 +268,7 @@ def post_paste(
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 ·
|
||||
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
|
||||
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름(1)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
|
||||
target = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.from_layer, body.from_owner)
|
||||
who = access.grant(session, body.from_layer, body.from_owner).owner
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
|
||||
@@ -173,6 +174,14 @@ def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
|
||||
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
|
||||
|
||||
|
||||
def unique_path(path: list[str], busy: Callable[[list[str]], bool]) -> list[str]:
|
||||
"""겹치는 경로면 마지막 이름을 「이름(1)」 「이름(2)」… 로 — 옮기기 · 붙여넣기 · 복사 생성이 한 벌을 씀."""
|
||||
out, n = path, 1
|
||||
while busy(out):
|
||||
out, n = [*path[:-1], f"{path[-1]}({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def _set_path(col: dict[str, Any], path: list[str]) -> None:
|
||||
col["경로"] = path
|
||||
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
|
||||
@@ -272,21 +281,28 @@ def apply(
|
||||
to = _path(body.get("새경로"), "새경로")
|
||||
if key:
|
||||
col = _find(out, key, ids)
|
||||
moved = [*to, path_of(col)[-1]]
|
||||
_taken_item(out, moved, [col])
|
||||
want = [*to, path_of(col)[-1]]
|
||||
taken = _item_paths(out, [col])
|
||||
moved = unique_path(want, lambda p: p in taken) # 같은 폴더 같은 이름 = 「이름(1)」
|
||||
out["열"] = [c for c in out["열"] if c is not col]
|
||||
_set_path(col, moved)
|
||||
_place(out, col)
|
||||
note["경로"] = moved
|
||||
if moved != want:
|
||||
note["바뀐이름"] = [moved[-1]]
|
||||
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)}」")
|
||||
folders_now, paths_now = folders_of(out), _item_paths(out)
|
||||
moved = unique_path(
|
||||
[*to, where[-1]],
|
||||
lambda p: p != where and (p in folders_now or any(_under(x, p) for x in paths_now)),
|
||||
)
|
||||
_move_folder(out, where, moved, replace=True)
|
||||
note["경로"] = moved
|
||||
if moved[-1] != where[-1]:
|
||||
note["바뀐이름"] = [moved[-1]]
|
||||
else: # delete
|
||||
if key:
|
||||
gone = [_find(out, key, ids)]
|
||||
|
||||
@@ -425,7 +425,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
}
|
||||
picked = new Set();
|
||||
paint();
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
const renamed = note?.바뀐이름 ?? [];
|
||||
showToast(
|
||||
renamed.length
|
||||
? L("M02_TreeRenamed").replace("{value}", renamed.join("」 · 「"))
|
||||
: L("M02_TreeDone"),
|
||||
"success",
|
||||
);
|
||||
await opt.onChanged(col);
|
||||
} catch (error) {
|
||||
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
|
||||
|
||||
@@ -198,6 +198,39 @@ def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(wor
|
||||
assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == []
|
||||
|
||||
|
||||
def test_옮기기_같은_이름은_이름_번호로_저장(world):
|
||||
"""옮기기(항목 · 폴더) 같은 폴더 같은 이름 = 말없이 「이름(1)」 「이름(2)」 · 키 · 번호 그대로.
|
||||
사용자가 직접 친 이름(더하기 · 이름 바꾸기) 겹침은 그대로 409."""
|
||||
root = world["storage"] / "7" / "42" / "templates"
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-folder", 경로=[], 이름="A")
|
||||
t.do(op="add-folder", 경로=[], 이름="B")
|
||||
for f in ("A", "B"):
|
||||
t.do(op="add-item", 경로=[f], 이름="같음")
|
||||
t.do(op="add-item", 경로=["B"], 이름="같음(1)")
|
||||
t.do(op="add-folder", 경로=["A"], 이름="하위")
|
||||
t.do(op="add-folder", 경로=["B"], 이름="하위")
|
||||
t.do(op="add-item", 경로=["B", "하위"], 이름="안쪽")
|
||||
mine = t.item(["A", "같음"])
|
||||
note = t.do(op="move", 키=mine["key"], 새경로=["B"])["알림"]
|
||||
assert note["경로"] == ["B", "같음(2)"] and note["바뀐이름"] == ["같음(2)"] # (1) 도 있어서
|
||||
moved = t.item(["B", "같음(2)"])
|
||||
assert moved["key"] == mine["key"] and _numbers(root, moved["열"]) == _numbers(root, mine["열"])
|
||||
# 겹침 없으면 이름 그대로 · 바뀐이름 없음 · 제자리 옮기기도 그대로
|
||||
free = t.do(op="move", 키=moved["key"], 새경로=["A"])["알림"]
|
||||
assert free["경로"] == ["A", "같음(2)"] and "바뀐이름" not in free
|
||||
stay = t.do(op="move", 키=t.item(["B", "같음"])["key"], 새경로=["B"])["알림"]
|
||||
assert stay["경로"] == ["B", "같음"] and "바뀐이름" not in stay
|
||||
# 폴더 — 같은 이름 폴더가 이미 있으면 「하위(1)」 · 안의 항목 따라감 · 같은 부모로는 그대로
|
||||
folder = t.do(op="move", 경로=["A", "하위"], 새경로=["B"])["알림"]
|
||||
assert folder["경로"] == ["B", "하위(1)"] and folder["바뀐이름"] == ["하위(1)"]
|
||||
t.do(op="move", 경로=["B", "하위"], 새경로=["B"]) # 제자리 — 409 아님
|
||||
assert ["B", "하위", "안쪽"] in t.paths()
|
||||
# 직접 친 이름은 지금처럼 막음
|
||||
t.do(409, op="add-item", 경로=["B"], 이름="같음")
|
||||
t.do(409, op="rename", 키=t.item(["B", "같음(1)"])["key"], 이름="같음")
|
||||
|
||||
|
||||
def test_판_409_권한_403(world):
|
||||
admin = Tree(_as(world, "ADMIN"), "company")
|
||||
stale = admin.now["판"]
|
||||
@@ -254,7 +287,7 @@ def test_표_저장과_나무가_한_줄기(world):
|
||||
|
||||
def test_프로젝트에서_새_항목도_경로_그대로(world):
|
||||
"""개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 ·
|
||||
같은 경로면 「이름 (2)」."""
|
||||
같은 경로면 「이름(1)」."""
|
||||
from M02_MasterTemplete import M02_Item_Personal as personal
|
||||
|
||||
folder = layers.personal_dir(7, 42)
|
||||
@@ -262,5 +295,5 @@ def test_프로젝트에서_새_항목도_경로_그대로(world):
|
||||
again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m")
|
||||
t = Tree(_as(world), "personal")
|
||||
assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"]
|
||||
assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"]
|
||||
assert t.item(["가", "나", "다", "라", "마(1)"])["key"] == again["key"]
|
||||
assert made["이름"] == "가 나 다 라 마"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""M02 구조물 라이브러리 여러 개 · 층 사이 붙여넣기(PLAN 30-2) — 임시 storage.
|
||||
|
||||
① 회사 항목 · 폴더를 일반 사용자 개인 층에 붙임 — 새 SP 키 · 본뜬 = 원본 · 세 파일 복사 ·
|
||||
폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름 (2)」 · 고른 폴더 안의 것은 한 번만.
|
||||
폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름(1)」 · 고른 폴더 안의 것은 한 번만.
|
||||
② 권한 — 쓰기 층만(일반 사용자 회사 층 403 · 남의 층 403) · 복사한 층은 읽기 권한(남의 회사 403).
|
||||
③ 여러 개 옮기기 · 지우기 한 번에(하나라도 막히면 아무것도 안 바뀜).
|
||||
"""
|
||||
@@ -52,10 +52,10 @@ def test_회사_항목_폴더를_개인에_붙임_새_키_파일_이름2(world):
|
||||
assert made["본뜬"] == {"layer": "company", "owner": "7", "key": wall}
|
||||
for kind in ("structure", "area"):
|
||||
assert (root / kind / f"{made['id']}.json").is_file()
|
||||
# 다시 붙이면 같은 이름 「(2)」 · 붙일 폴더 안으로
|
||||
# 다시 붙이면 같은 이름 「(1)」 · 붙일 폴더 안으로
|
||||
again = client.post(PASTE.format("personal"), json={**body, "경로": []}).json()
|
||||
assert ["배수 (2)", "측구"] in [r["경로"] for r in again["items"]]
|
||||
assert ["옹벽 (2)"] in [r["경로"] for r in again["items"]]
|
||||
assert ["배수(1)", "측구"] in [r["경로"] for r in again["items"]]
|
||||
assert ["옹벽(1)"] in [r["경로"] for r in again["items"]]
|
||||
inner = client.post(PASTE.format("personal"), json={**body, "대상": [body["대상"][2]],
|
||||
"경로": ["배수"]}).json() # fmt: skip
|
||||
assert ["배수", "옹벽"] in [r["경로"] for r in inner["items"]]
|
||||
@@ -109,6 +109,19 @@ def test_여러_개_옮기기_지우기_한_번에(world):
|
||||
assert t.paths() == [["나", "둘"]]
|
||||
|
||||
|
||||
def test_여러_개_옮기기_같은_이름은_이름_번호로(world):
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-folder", 경로=[], 이름="나")
|
||||
t.do(op="add-item", 경로=["나"], 이름="같음")
|
||||
t.do(op="add-item", 경로=[], 이름="같음")
|
||||
t.do(op="add-folder", 경로=[], 이름="묶음")
|
||||
t.do(op="add-item", 경로=["묶음"], 이름="같음")
|
||||
moves = [{"경로": [], "키": t.item(["같음"])["key"]}, {"경로": ["묶음"]}]
|
||||
note = t.do(op="move", 대상=moves, 새경로=["나"])["알림"]
|
||||
assert sorted(t.paths()) == [["나", "같음"], ["나", "같음(1)"], ["나", "묶음", "같음"]]
|
||||
assert note["바뀐이름"] == ["같음(1)"]
|
||||
|
||||
|
||||
def test_항목_추가_일위대가_단위_없이(world):
|
||||
"""PLAN 31-2 · 39-1d — 항목 추가 = 종류(폴더) + 이름 · 단위 안 받아도 됨 ·
|
||||
일위대가 키는 안 받음 — 열 칸에 안 적고 늘 빈 labor 문서
|
||||
|
||||
@@ -157,6 +157,10 @@ export const ui_locales_m2 = {
|
||||
M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"],
|
||||
M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"],
|
||||
M02_TreeDone: ["나무를 고침", "Tree updated"],
|
||||
M02_TreeRenamed: [
|
||||
"나무를 고침 — 같은 이름이 있어 「{value}」 로 저장",
|
||||
"Tree updated — name already taken, saved as “{value}”",
|
||||
],
|
||||
M02_PracticeTitle: ["실무문서", "Practice documents"],
|
||||
M02_PracticeRow: ["실무문서 목록", "Practice document list"],
|
||||
M02_PracticeCount: [
|
||||
|
||||
Reference in New Issue
Block a user