diff --git a/M02_MasterTemplete/M02_Item_Paste.py b/M02_MasterTemplete/M02_Item_Paste.py new file mode 100644 index 00000000..29c9d167 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Paste.py @@ -0,0 +1,105 @@ +"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기]. + +붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) · +같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」). +파일(도면 · 면적 · 재료 · 입력 연결 · 자재 조합)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree + + +def _under(path: list[str], folder: list[str]) -> bool: + return path[: len(folder)] == folder + + +def outer(targets: list[dict[str, Any]]) -> list[dict[str, Any]]: + """고른 것 가운데 다른 고른 폴더 안에 든 것은 뺌(폴더가 안의 것까지 다룸) — + 옮기기 · 지우기 · 붙여넣기.""" + folders = [list(t.get("경로") or []) for t in targets if not t.get("키")] + + def inside(t: dict[str, Any]) -> bool: + path = list(t.get("경로") or []) + return any(_under(path, f) and (t.get("키") or path != f) for f in folders if f) + + return [t for t in targets if not inside(t)] + + +def _table(folder: Path) -> dict[str, Any]: + with store.at(folder): + return tree.normalize_table(store.read("table", store.STRUCTURE_TABLE)["문서"]) + + +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, + targets: list[dict[str, Any]], + dest: list[str], + origin: dict[str, Any], +) -> dict[str, Any]: + """`targets` = `[{경로, 키?}]`(키 = 항목 · 없으면 폴더 경로) → `target` 층 `dest` 아래 · + 돌려줌 알림 `{op: "paste", 경로, 열: [새 열 id]}`. `origin` = `{layer, owner}`(본뜬).""" + src = _table(source) + with store.at(source): + keys = {str(r.get("열")): str(r.get("키")) for r in store.read_combos().get("줄", [])} + src_items = [(tree.path_of(c), keys.get(c["id"])) for c in _items(src)] + src_folders = tree.folders_of(src) + items_store.ensure(target) + dst = _table(target) + taken = [tree.path_of(c) for c in _items(dst)] + taken_folders = tree.folders_of(dst) + plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로) + new_folders: list[list[str]] = [] + + def busy(path: list[str]) -> bool: + paths = [*taken, *(p for _, p in plan)] + return path in [*taken_folders, *new_folders] or any(_under(p, path) for p in paths) + + for one in outer(targets): + if one.get("키"): + key = str(one["키"]) + 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)]))) + 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 + 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] + + made = [ + items_store.copy_item(source, target, key, {**origin, "key": key}, path)["열"] + for key, path in plan + ] + if new_folders: # 빈 폴더도 남게 — 집계표 `폴더` + with store.at(target): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + doc["폴더"] = [*(doc.get("폴더") or []), *new_folders] + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + return {"op": "paste", "경로": dest, "열": made} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 265fa33b..281ee93c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -430,7 +430,8 @@ export interface TreeItem { export interface TreeNote { op: string; 경로?: string[]; - 열?: string; + /** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */ + 열?: string | string[]; 지운항목?: string[]; } export interface TreeView { @@ -464,6 +465,45 @@ export const editTree = ( ): Promise => call(treePath(layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); +/** 나무의 고른 것 하나(PLAN 30-2) — 항목 = `키` + 그 폴더 경로 · 폴더 = 경로만 */ +export interface TreeRef { + 경로: string[]; + 키?: string; +} + +/** 복사해 둔 것 — 나무 종류(`kind` · 구조물 · 일위대가) · 복사한 층 · 고른 것들 */ +export interface TreeClip { + kind: string; + from: { layer: Layer; owner: string | null }; + refs: TreeRef[]; +} + +/** 여러 개 옮기기 · 지우기 한 번에 — 판이 다르면 409 · 하나라도 막히면 아무것도 안 바뀜 */ +export const editTreeMany = ( + layer: Layer, + body: { op: "move" | "delete"; 대상: TreeRef[]; 새경로?: string[] }, + 판: string, + owner: string | null = null, +): Promise => + call(treePath(layer, owner), { method: "POST", body: JSON.stringify({ ...body, 판 }) }); + +/** 층 사이 붙여넣기 — 복사한 층 읽기 · 이 층 쓰기 권한 · 새 키 · 같은 이름 「이름 (2)」 · `dest` 폴더 아래 */ +export const pasteTree = ( + layer: Layer, + owner: string | null, + clip: TreeClip, + dest: string[], +): Promise => + call(`/layers/${layer}/items/paste${owner ? `?owner=${enc(owner)}` : ""}`, { + method: "POST", + body: JSON.stringify({ + from_layer: clip.from.layer, + from_owner: clip.from.owner, + 대상: clip.refs, + 경로: dest, + }), + }); + /** 남의 층 목록 한 줄(PLAN 27-1c) — 회사 `{C}` · 개인 `{C}/{U}` */ export interface LayerOwner { owner: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index c782deeb..90f0dacd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -96,11 +96,18 @@ def _fresh_id(want: str, used: set[str]) -> str: return out -def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> dict[str, Any]: +def copy_item( + source: Path, + target: Path, + key: str, + origin: dict[str, Any], + path: list[str] | None = None, +) -> dict[str, Any]: """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 · 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 · 새 번호 · 새 SS 키 · - 열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`.""" + 열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 · + 붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`.""" with store.at(source): row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도 if row is None: @@ -135,6 +142,8 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d doc = table["문서"] new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}) made = {**json.loads(json.dumps(col)), "id": new_id, "자재조합": new_um, "본뜬": origin} + if path: + made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움 doc["열"] = [*doc.get("열", []), made] store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS for kind, got in docs.items(): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index c7bcf636..296d339f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -17,6 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Item_Paste as paste_store from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Branch as branch from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store @@ -53,6 +54,17 @@ class TreeBody(BaseModel): 키: str | None = None 단위: str | None = None 판: str + # 여러 개 옮기기 · 지우기(PLAN 30-2) — `[{경로, 키?}]` · 있으면 이것마다 차례로 · 저장 한 번 + 대상: list[dict[str, Any]] | None = None + + +class PasteBody(BaseModel): + """30-2 층 사이 붙여넣기 — 복사한 층(`from_layer` · `from_owner`) `대상` → 이 층 `경로` 아래.""" + + from_layer: Layer + from_owner: str | None = None + 대상: list[dict[str, Any]] + 경로: list[str] = [] class CopyItemBody(BaseModel): @@ -192,7 +204,17 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]: for key in [r.get("키"), *(r.get("옛키") or [])]: ids[str(key)] = str(r.get("열")) try: - doc, note = tree.apply(got["문서"], body, ids) + targets = body.get("대상") + if targets is None: + doc, note = tree.apply(got["문서"], body, ids) + else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀) + if body.get("op") not in ("move", "delete") or not targets: + raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") + 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("지운항목", []) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e store.write("table", store.STRUCTURE_TABLE, got["판"], doc) @@ -212,6 +234,27 @@ def post_tree( return _call(folder, _edit_tree, folder, body.model_dump()) +@router.post("/layers/{layer}/items/paste") +def post_paste( + layer: Layer, + body: PasteBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 · + 새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림.""" + 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 + try: + note = paste_store.paste( + source, target, body.대상, body.경로, {"layer": body.from_layer, "owner": who} + ) + except store.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e + return {**_call(target, _tree_view, target), "알림": note} + + # ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css index cc6f3b9c..1c90945f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -64,6 +64,29 @@ font-weight: 600; } +/* 층 단추 줄 오른쪽 끝 — 「N개 고름」 · 「+」(PLAN 30-2) */ +.m02-tree__tools { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + margin-left: auto; +} + +.m02-tree__picked { + color: var(--color-accent); + font-size: var(--text-body-sm); + white-space: nowrap; +} + +.m02-tree__picked[hidden] { + display: none; +} + +/* 여러 개 고른 줄 배경 */ +.m02-tree__body .ui-tree__row.is-picked { + background: var(--color-mist-violet); +} + /* 층 단추 옆 「+」 — 나무 맨 위 폴더 · 항목 더하기(PLAN 29-6) */ .m02-tree__add { min-width: 1.75rem; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 50d53bdb..521d6a36 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -4,7 +4,9 @@ * 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 찾기 칸은 부르는 쪽이 상세 위쪽에 · 자료 길은 `api`. * 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목. * 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 — - * `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`. + * `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 [복사]만. 나무 자료는 `_Model`. + * 여러 개(PLAN 30-2) — Ctrl · Shift 클릭으로 고름(`_Pick`) · 오른쪽 클릭 메뉴 · Ctrl+C · Ctrl+V · Delete 가 + * 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(`api.many` · `api.paste`). * ========================================================================== */ import "./M02_MasterTemplete_TreePanel.css"; @@ -21,14 +23,25 @@ import { import { StaleError, editTree, + editTreeMany, fetchTree, + pasteTree, type Layer, + type TreeClip, type TreeEdit, type TreeItem, type TreeNote, + type TreeRef, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { + clipOf, + keepClip, + pickClick, + refOf, + type Target, +} from "./M02_MasterTemplete_TreePanel_Pick"; import { ancestorsOf, buildFolders, @@ -51,7 +64,23 @@ export interface TreePanelOptions { api?: { fetch: (layer: Layer, owner: string | null) => Promise; edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise; + /** 여러 개 옮기기 · 지우기 한 번에 — 없으면 여러 개 메뉴 없음 */ + many?: ( + layer: Layer, + body: { op: "move" | "delete"; 대상: TreeRef[]; 새경로?: string[] }, + 판: string, + owner: string | null, + ) => Promise; + /** 붙여넣기(복사해 둔 것 → 이 층 `dest` 아래) — 없으면 복사 · 붙여넣기 메뉴 없음 */ + paste?: ( + layer: Layer, + owner: string | null, + clip: TreeClip, + dest: string[], + ) => Promise; }; + /** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */ + clipKind?: string; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */ @@ -71,21 +100,20 @@ export interface TreePanelHandle { root: HTMLElement; /** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */ search: HTMLElement; - /** 「+」 — 맨 위 폴더 · 항목 더하기 · 부르는 쪽이 층 단추 옆에 붙임(읽기만 층 = 숨김 · 29-6) */ + /** 층 단추 줄 오른쪽 도구 — 「N개 고름」(30-2) · 「+」(맨 위 폴더 · 항목 더하기 · 읽기만 층 = 숨김 · 29-6) */ add: HTMLElement; refresh: () => Promise; /** 표에서 고른 열을 나무에 표시(null = 지움) */ select: (colId: string | null) => void; } -type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem }; - const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text }); export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const owner = opt.owner ?? null; - const api = opt.api ?? { fetch: fetchTree, edit: editTree }; + const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree }; + const clipKind = opt.clipKind ?? "구조물"; let view: TreeView | null = null; let text = ""; let activeCol: string | null = null; @@ -93,6 +121,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { let busy = false; const open = new Set([folderIdOf([])]); const targets = new Map(); + // 여러 개 고름(PLAN 30-2) — 줄 id(`f|경로` · `i|열`) · Shift 범위의 기준 + let picked = new Set(); + let anchor: string | null = null; const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true }); const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } }); @@ -105,11 +136,61 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { attrs: { type: "button", title: L("M02_TreeAddTop"), "aria-label": L("M02_TreeAddTop") }, }); add.hidden = !opt.canEdit; - add.addEventListener("click", (event) => - openTreeMenu(event, menuOf({ kind: "folder", path: [] })), - ); + add.addEventListener("click", (event) => openTreeMenu(event, topMenu())); + // 「N개 고름」 · 「+」 — 층 단추 줄 오른쪽 끝(30-2 · 부르는 쪽이 붙임) + const status = el("span", { className: "m02-tree__picked", attrs: { hidden: "" } }); + const tools = el("span", { className: "m02-tree__tools", children: [status, add] }); /* --- 그리기 --- */ + const canCopy = !!api.paste; + const pickedTargets = (): Target[] => + [...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t); + /** 붙여넣기 줄 — 같은 종류를 복사해 뒀고 이 층에 쓸 수 있을 때만 */ + const pasteItem = (dest: string[]): TreeMenuItem[] => { + const got = opt.canEdit && api.paste ? clipOf(clipKind) : null; + return got + ? [ + { + label: L("M02_TreePaste").replace("{n}", String(got.refs.length)), + run: () => void paste(dest), + }, + ] + : []; + }; + const copyItem = (list: Target[]): TreeMenuItem[] => + canCopy + ? [{ label: L("M02_TreeCopy").replace("{n}", String(list.length)), run: () => copy(list) }] + : []; + /** 맨 위(빈 자리 오른쪽 클릭 · 「+」) — 폴더 · 항목 더하기 · 붙여넣기 */ + const topMenu = (): TreeMenuItem[] => [ + ...(opt.canEdit ? menuOf({ kind: "folder", path: [] }) : []), + ...pasteItem([]), + ]; + /** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(옮기기 · 지우기 · 복사) · 아니면 그 줄 하나 */ + const menuFor = (target: Target, id: string): TreeMenuItem[] => { + if (!picked.has(id)) setPicked(new Set([id]), id); + const list = pickedTargets(); + if (list.length > 1) { + const n = String(list.length); + return [ + ...copyItem(list), + ...(opt.canEdit && api.many + ? [ + { label: L("M02_TreeMoveMany").replace("{n}", n), run: () => void moveMany(list) }, + { + label: L("M02_TreeDeleteMany").replace("{n}", n), + run: () => void removeMany(list), + }, + ] + : []), + ]; + } + return [ + ...(opt.canEdit ? menuOf(target) : []), + ...copyItem([target]), + ...(target.kind === "folder" ? pasteItem(target.path) : []), + ]; + }; const menuOf = (target: Target): TreeMenuItem[] => { const isTop = target.kind === "folder" && !target.path.length; return [ @@ -136,7 +217,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { function node(target: Target, base: Omit, id: string): TreeNode { targets.set(id, target); - return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) }; + return { ...base, id, ...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {}) }; } function folderNode(folder: FolderNode, searching: boolean): TreeNode { @@ -174,9 +255,42 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const button = createTreeRow(one, caret, depth, onClick); button.dataset.node = one.id; button.classList.toggle("is-active", !!activeCol && one.id === `i|${activeCol}`); + button.classList.toggle("is-picked", picked.size > 1 && picked.has(one.id)); + // Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture) + button.addEventListener( + "click", + (event) => { + const got = pickClick( + picked, + anchor, + one.id, + { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey }, + visibleIds(), + ); + if (got.handled) event.stopImmediatePropagation(); + setPicked(got.picked, got.anchor); + }, + { capture: true }, + ); return button; }; + const visibleIds = (): string[] => + [...body.querySelectorAll("[data-node]")] + .filter((b) => !b.closest("[hidden]")) + .map((b) => b.dataset.node ?? ""); + + /** 고름을 바꾸고 줄 배경 · 「N개 고름」을 맞춤 */ + function setPicked(next: Set, nextAnchor: string | null): void { + picked = new Set([...next].filter((id) => targets.has(id))); + anchor = nextAnchor; + body.querySelectorAll("[data-node]").forEach((b) => { + b.classList.toggle("is-picked", picked.size > 1 && picked.has(b.dataset.node ?? "")); + }); + status.hidden = picked.size < 2; + status.textContent = L("M02_TreePicked").replace("{n}", String(picked.size)); + } + function paint(): void { const focused = (document.activeElement as HTMLElement | null)?.dataset?.node; targets.clear(); @@ -196,6 +310,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { : []), ); if (focused) rowOf(focused)?.focus({ preventScroll: true }); + setPicked(picked, anchor); // 없어진 줄은 고름에서 뺌 } const rowOf = (id: string): HTMLElement | null => @@ -243,22 +358,32 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return true; } - async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise { + const edit = (body_: TreeEdit, keepCol: string | null = activeCol): Promise => + apply((판) => api.edit(opt.layer, body_, 판, owner), keepCol); + + /** 나무 고침 한 번(하나 · 여러 개 · 붙여넣기) — 판 다름 · 막힘 까닭 · 뒤 처리를 한 곳에서 */ + async function apply( + run: (판: string) => Promise, + keepCol: string | null = activeCol, + ): Promise { if (!view || busy) return; busy = true; try { - const got = await api.edit(opt.layer, body_, view.판, owner); + const got = await run(view.판); view = got; const note: TreeNote | undefined = got.알림; // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 const gone = note?.지운항목 ?? []; - const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); + const made = note?.열; + const col = + typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null; activeCol = col; const parent = note?.경로 ? note.경로.slice(0, -1) : []; parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); - if (note?.op === "add-folder" || note?.op === "rename" || note?.op === "move") { - if (note.경로) open.add(folderIdOf(note.경로)); + if (["add-folder", "rename", "move", "paste"].includes(note?.op ?? "")) { + if (note?.경로?.length) open.add(folderIdOf(note.경로)); } + picked = new Set(); paint(); showToast(L("M02_TreeDone"), "success"); await opt.onChanged(col); @@ -358,6 +483,45 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { ); } + /* --- 여러 개 · 복사 붙여넣기(PLAN 30-2) --- */ + /** 복사 — 읽기만 층에서도 · 층을 바꿔도 남음(붙여넣기는 쓸 수 있는 층에서) */ + function copy(list: Target[]): void { + if (!canCopy || !list.length) return; + keepClip({ kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) }); + showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); + } + + async function paste(dest: string[]): Promise { + const got = clipOf(clipKind); + if (!got || !opt.canEdit || !api.paste || !(await ready())) return; + const run = api.paste; + await apply(() => run(opt.layer, owner, got, dest)); + } + + async function moveMany(list: Target[]): Promise { + if (!api.many || !(await ready()) || !view) return; + const folders = list.flatMap((t) => (t.kind === "folder" ? [t.path] : [])); + const to = await askFolder( + L("M02_TreeMove"), + listFolders(buildFolders(view)), + (path) => folders.some((f) => isUnder(path, f)), + [], + ); + if (!to) return; + const run = api.many; + await apply((판) => + run(opt.layer, { op: "move", 대상: list.map(refOf), 새경로: to }, 판, owner), + ); + } + + async function removeMany(list: Target[]): Promise { + if (!api.many || !(await ready())) return; + const ask = L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length)); + if (!(await showConfirmDialog(ask, L("M02_Delete")))) return; + const run = api.many; + await apply((판) => run(opt.layer, { op: "delete", 대상: list.map(refOf) }, 판, owner)); + } + const findFolder = (from: FolderNode, path: string[]): FolderNode | undefined => samePath(from.path, path) ? from @@ -369,15 +533,21 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { paint(); }); - // 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기) + // 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기 · 붙여넣기) body.addEventListener("contextmenu", (event) => { - if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return; + if ((event.target as HTMLElement).closest("[data-node]")) return; + const items = topMenu(); + if (!items.length) return; event.preventDefault(); - openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2)); + openTreeMenu(event, items); }); body.addEventListener("keydown", (event) => { const row = (event.target as HTMLElement).closest("[data-node]"); + if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { + event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기 + return void paste([]); + } if (!row) return; const rows = [...body.querySelectorAll("[data-node]")].filter( (b) => !b.closest("[hidden]"), @@ -399,9 +569,17 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { if (!(target.kind === "folder" && !target.path.length)) void rename(target); } else if (opt.canEdit && target && event.key === "Delete") { event.preventDefault(); - if (!(target.kind === "folder" && !target.path.length)) void remove(target); + const many = picked.size > 1 && picked.has(row.dataset.node ?? ""); + if (many && api.many) void removeMany(pickedTargets()); + else if (!(target.kind === "folder" && !target.path.length)) void remove(target); + } else if (target && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") { + event.preventDefault(); + copy(picked.has(row.dataset.node ?? "") ? pickedTargets() : [target]); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { + event.preventDefault(); // 붙일 자리 = 초점 줄이 폴더면 그 안 · 아니면 맨 위 + void paste(target?.kind === "folder" ? target.path : []); } }); - return { root, search: search.root, add, refresh, select }; + return { root, search: search.root, add: tools, refresh, select }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Pick.ts new file mode 100644 index 00000000..6a86d457 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Pick.ts @@ -0,0 +1,46 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_Pick.ts + * 공용 나무 여러 개 고르기 · 복사 붙여넣기 자료(PLAN 30-2 · 화면 없음) — 고름(Ctrl · Shift 클릭) · + * 고른 것 → 서버 모양(`TreeRef`) · 복사해 둔 것(나무 종류마다 하나 · 층을 바꿔 화면을 다시 세워도 남음). + * 그리기 · 메뉴는 `M02_MasterTemplete_TreePanel.ts`. + * ========================================================================== */ + +import type { TreeClip, TreeItem, TreeRef } from "./M02_MasterTemplete_Api_Fetch"; +import { folderOf } from "./M02_MasterTemplete_TreePanel_Model"; + +/** 나무 줄 하나가 가리키는 것 — 폴더(경로) · 항목 */ +export type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem }; + +export const refOf = (target: Target): TreeRef => + target.kind === "item" + ? { 경로: folderOf(target.item), 키: target.item.key } + : { 경로: target.path }; + +let clip: TreeClip | null = null; +/** 이 나무 종류로 복사해 둔 것 — 없으면 null */ +export const clipOf = (kind: string): TreeClip | null => (clip?.kind === kind ? clip : null); +export const keepClip = (next: TreeClip): void => { + clip = next; +}; + +/** 클릭 한 번 뒤 고름 — Ctrl(⌘) = 하나 넣고 빼기 · Shift = 기준에서 여기까지(보이는 줄 차례) · + * 그냥 = 이것 하나. `handled` = 줄 본디 동작(항목 열기 · 폴더 접기)을 막음 */ +export function pickClick( + picked: Set, + anchor: string | null, + id: string, + mod: { ctrl: boolean; shift: boolean }, + order: string[], +): { picked: Set; anchor: string | null; handled: boolean } { + if (mod.ctrl) { + const next = new Set(picked); + if (next.has(id)) next.delete(id); + else next.add(id); + return { picked: next, anchor: id, handled: true }; + } + if (mod.shift && anchor && order.includes(anchor) && order.includes(id)) { + const [a, b] = [order.indexOf(anchor), order.indexOf(id)].sort((x, y) => x - y); + return { picked: new Set(order.slice(a, b + 1)), anchor, handled: true }; + } + return { picked: new Set([id]), anchor: id, handled: false }; +} diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 938be71b..c69f9c19 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -139,12 +139,11 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( - "api.fetch(opt.layer, owner)" in panel - and "api.edit(opt.layer, body_, view.판, owner)" in panel + "api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel ) - assert ( - "...(opt.canEdit ? { menu: () => menuOf(target) } : {})" in panel - ) # 읽기 뿌리 = 메뉴 없음 + # 읽기 층 = 고치기 메뉴 없음 · [복사]만(30-2) · 붙여넣기는 쓸 수 있는 층만 + assert "...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {})" in panel + assert "const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;" in panel api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") assert "owner=${enc(activeOwner)}" in api and "/owners" in api diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py new file mode 100644 index 00000000..85d2d266 --- /dev/null +++ b/resources/tester/test_m02_tree_paste.py @@ -0,0 +1,109 @@ +"""M02 구조물 라이브러리 여러 개 · 층 사이 붙여넣기(PLAN 30-2) — 임시 storage. + +① 회사 항목 · 폴더를 일반 사용자 개인 층에 붙임 — 새 SP 키 · 본뜬 = 원본 · 세 파일 복사 · + 폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름 (2)」 · 고른 폴더 안의 것은 한 번만. +② 권한 — 쓰기 층만(일반 사용자 회사 층 403 · 남의 층 403) · 복사한 층은 읽기 권한(남의 회사 403). +③ 여러 개 옮기기 · 지우기 한 번에(하나라도 막히면 아무것도 안 바뀜). +""" + +from __future__ import annotations + +import json +from typing import Any + +from resources.tester import test_m02_tree as base +from resources.tester.test_m02_tree import Tree, _as + +world = base.world # 같은 임시 storage 판 + +PASTE = "/api/m02/layers/{}/items/paste" + + +def _company(world: dict[str, Any]) -> Tree: + t = Tree(_as(world, "ADMIN"), "company") + t.do(op="add-folder", 경로=[], 이름="배수") + t.do(op="add-folder", 경로=["배수"], 이름="빈") + t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") + t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m") + return t + + +def test_회사_항목_폴더를_개인에_붙임_새_키_파일_이름2(world): + company = _company(world) + wall = company.item(["옹벽"])["key"] + client = _as(world) # 일반 사용자 42 + ditch = company.item(["배수", "측구"])["key"] + body = { + "from_layer": "company", + "대상": [{"경로": ["배수"]}, {"경로": ["배수"], "키": ditch}, {"경로": [], "키": wall}], + "경로": [], + } + res = client.post(PASTE.format("personal"), json=body) + assert res.status_code == 200, res.text + got = res.json() + paths = [r["경로"] for r in got["items"]] + assert paths == [["배수", "측구"], ["옹벽"]] # 폴더 안 측구는 한 번만 + assert ["배수", "빈"] in got["폴더"] # 빈 폴더까지 + assert all(r["key"].startswith("SP42-") for r in got["items"]) + assert len(got["알림"]["열"]) == 2 + root = world["storage"] / "7" / "42" / "templates" + table = json.loads((root / "table/구조물집계표.json").read_bytes()) + made = next(c for c in table["열"] if c.get("경로") == ["옹벽"]) + assert made["본뜬"] == {"layer": "company", "owner": "7", "key": wall} + for kind in ("structure", "area", "material"): + assert (root / kind / f"{made['id']}.json").is_file() + # 다시 붙이면 같은 이름 「(2)」 · 붙일 폴더 안으로 + 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"]] + inner = client.post(PASTE.format("personal"), json={**body, "대상": [body["대상"][2]], + "경로": ["배수"]}).json() # fmt: skip + assert ["배수", "옹벽"] in [r["경로"] for r in inner["items"]] + + +def test_붙여넣기_권한(world): + company = _company(world) + body = {"from_layer": "company", "대상": [{"경로": ["배수"]}], "경로": []} + client = _as(world) + assert client.post(PASTE.format("company"), json=body).status_code == 403 # 일반 = 회사 읽기만 + assert client.post(PASTE.format("personal") + "?owner=7/43", json=body).status_code == 403 + far = {**body, "from_owner": "9"} + assert client.post(PASTE.format("personal"), json=far).status_code == 403 # 남의 회사 읽기 막힘 + missing = {**body, "대상": [{"경로": [], "키": "SC7-999999"}]} + assert client.post(PASTE.format("personal"), json=missing).status_code == 404 + # 회사 관리자 = 개인 → 회사 붙여넣기 + admin = _as(world, "ADMIN") + mine = Tree(admin, "personal") + mine.do(op="add-item", 경로=[], 이름="내것", 단위="m") + up = {"from_layer": "personal", "대상": [{"경로": [], "키": mine.item(["내것"])["key"]}]} + res = admin.post(PASTE.format("company"), json={**up, "경로": ["배수"]}) + assert res.status_code == 200, res.text + assert ["배수", "내것"] in [r["경로"] for r in res.json()["items"]] + assert company.client is admin + + +def test_여러_개_옮기기_지우기_한_번에(world): + t = Tree(_as(world), "personal") + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=["가"], 이름="안") + t.do(op="add-item", 경로=["가", "안"], 이름="깊은") + t.do(op="add-item", 경로=[], 이름="하나") + t.do(op="add-item", 경로=[], 이름="둘") + t.do(op="add-folder", 경로=[], 이름="나") + one, two = t.item(["하나"])["key"], t.item(["둘"])["key"] + deep = t.item(["가", "안", "깊은"])["key"] + moves = [ + {"경로": [], "키": one}, + {"경로": [], "키": two}, + {"경로": ["가"]}, + {"경로": ["가", "안"], "키": deep}, + ] # fmt: skip — 가 안의 깊은 것은 폴더와 함께 + t.do(op="move", 대상=moves, 새경로=["나"]) + assert sorted(t.paths()) == [["나", "가", "안", "깊은"], ["나", "둘"], ["나", "하나"]] + before = t.now["판"] + t.do(422, op="move", 대상=[{"경로": ["나"], "키": one}, {"경로": ["나"]}], 새경로=["나"]) + assert t.client.get(t.url).json()["판"] == before # 막히면 아무것도 안 씀 + t.do(422, op="rename", 대상=[{"경로": ["나"]}], 이름="x") + note = t.do(op="delete", 대상=[{"경로": ["나"], "키": one}, {"경로": ["나", "가"]}]) + assert len(note["알림"]["지운항목"]) == 2 + assert t.paths() == [["나", "둘"]] diff --git a/resources/tester/test_m02_treepanel.py b/resources/tester/test_m02_treepanel.py index 324ad23c..f293aba0 100644 --- a/resources/tester/test_m02_treepanel.py +++ b/resources/tester/test_m02_treepanel.py @@ -85,3 +85,40 @@ def test_안_또는_같음_판정(results): def test_잎_이름과_폴더(results): assert results["leaf"] == "1.2m" assert results["dir"] == ["돌기슭막이", "메쌓기"] + + +def test_여러_개_고르기_Ctrl_Shift(): + """PLAN 30-2 — Ctrl = 하나 넣고 빼기(줄 동작 막음) · Shift = 기준에서 여기까지(보이는 차례) · + 그냥 클릭 = 그것 하나(줄 동작 그대로).""" + import re + + pick = os.path.join( + HERE, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_TreePanel_Pick.ts" + ) + src = open(pick, encoding="utf-8").read() + found = re.search(r"^export function pickClick[\s\S]*?^}", src, re.M) + assert found, "pickClick 을 못 찾음" + script = ( + found.group(0).replace("export ", "") + + "\nconst order = ['a', 'b', 'c', 'd', 'e'];" + + "\nconst show = (r) => [[...r.picked].sort(), r.anchor, r.handled];" + + "\nconst one = pickClick(new Set(), null, 'b', {ctrl: false, shift: false}, order);" + + "\nconst two = pickClick(one.picked, one.anchor, 'd', {ctrl: true, shift: false}, order);" + + "\nconst off = pickClick(two.picked, two.anchor, 'b', {ctrl: true, shift: false}, order);" + + "\nconst range = pickClick(one.picked, 'b', 'e', {ctrl: false, shift: true}, order);" + + "\nconst back = pickClick(one.picked, 'd', 'a', {ctrl: false, shift: true}, order);" + + "\nconsole.log(JSON.stringify([one, two, off, range, back].map(show)));" + ) + proc = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout) == [ + [["b"], "b", False], + [["b", "d"], "d", True], + [["d"], "b", True], + [["b", "c", "d", "e"], "b", True], + [["a", "b", "c", "d"], "d", True], + ] diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 047ce548..7358c725 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -118,6 +118,19 @@ export const ui_locales_m2 = { M02_TreeAddItem: ["항목 더하기", "Add item"], M02_TreeRename: ["이름 바꾸기", "Rename"], M02_TreeMove: ["옮기기", "Move"], + M02_TreeMoveMany: ["옮기기 ({n}개)", "Move ({n})"], + M02_TreeDeleteMany: ["지우기 ({n}개)", "Delete ({n})"], + M02_TreeDeleteManyAsk: [ + "고른 {n}개를 지움 — 폴더는 안의 것까지", + "Delete {n} selected — folders with their contents", + ], + M02_TreeCopy: ["복사 ({n}개)", "Copy ({n})"], + M02_TreePaste: ["붙여넣기 ({n}개)", "Paste ({n})"], + M02_TreeCopied: [ + "{n}개 복사함 — 쓸 수 있는 층의 폴더 · 빈 자리를 오른쪽 클릭하거나 Ctrl+V", + "Copied {n} — right-click a folder in a writable layer or press Ctrl+V", + ], + M02_TreePicked: ["{n}개 고름", "{n} selected"], M02_TreeFolderName: ["폴더 이름", "Folder name"], M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"],