Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_6

This commit is contained in:
2026-09-30 19:14:01 +09:00
16 changed files with 782 additions and 37 deletions
+105
View File
@@ -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}
@@ -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<TreeView> =>
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<TreeView> =>
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<TreeView> =>
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;
+11 -2
View File
@@ -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():
+10 -2
View File
@@ -44,6 +44,8 @@ export interface LaborLibrary {
tree: HTMLElement;
/** 나무 맨 위 더하기 「+」(층 단추 옆) */
add: HTMLElement;
/** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */
back: () => Promise<void>;
refresh: () => Promise<void>;
/** 메인 칸 「저장 안 한 고침」 손잡이 */
bind: (setDirty: (dirty: boolean) => void) => void;
@@ -115,6 +117,12 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }),
tree: tree.root,
add: tree.add,
back: async () => {
await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움
if (editor.current() === null) return;
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
editor.showList();
},
refresh: () => tree.refresh(),
bind: (fn) => (setDirty = fn),
};
@@ -130,7 +138,7 @@ export interface LaborSectionOptions {
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
make: (reveal: () => Promise<void>) => LaborLibrary;
/** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */
bar?: (add: HTMLElement) => HTMLElement;
bar?: (add: HTMLElement, back: () => void) => HTMLElement;
/** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */
wrap?: (tree: HTMLElement) => HTMLElement;
}
@@ -163,7 +171,7 @@ export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
const body = el("div", {
className: "m02-side__items ui-sidebar-list",
children: [
...(opt.bar ? [opt.bar(library.add)] : []),
...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []),
opt.wrap ? opt.wrap(library.tree) : library.tree,
],
});
@@ -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}
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
@@ -64,6 +64,36 @@
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-drop,
.m02-tree__body.is-drop {
outline: 2px dashed var(--color-accent);
outline-offset: -2px;
}
/* 여러 개 고른 줄 배경 */
.m02-tree__body .ui-tree__row.is-picked {
background: var(--color-mist-violet);
}
/* 층 단추 옆 「+」 — 나무 맨 위 폴더 · 항목 더하기(PLAN 29-6) */
.m02-tree__add {
min-width: 1.75rem;
@@ -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,25 @@ export interface TreePanelOptions {
api?: {
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise<TreeView>;
/** 여러 개 옮기기 · 지우기 한 번에 — 없으면 여러 개 메뉴 없음 */
many?: (
layer: Layer,
body: { op: "move" | "delete"; 대상: TreeRef[]; 새경로?: string[] },
판: string,
owner: string | null,
) => Promise<TreeView>;
/** 붙여넣기(복사해 둔 것 → 이 층 `dest` 아래) — 없으면 복사 · 붙여넣기 메뉴 없음 */
paste?: (
layer: Layer,
owner: string | null,
clip: TreeClip,
dest: string[],
) => Promise<TreeView>;
};
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
clipKind?: string;
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
copyTo?: { layer: Layer; owner: string | null; label: string }[];
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
@@ -71,21 +102,20 @@ export interface TreePanelHandle {
root: HTMLElement;
/** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */
search: HTMLElement;
/** 「+」 — 맨 위 폴더 · 항목 더하기 · 부르는 쪽이 층 단추 옆에 붙임(읽기만 층 = 숨김 · 29-6) */
/** 층 단추 줄 오른쪽 도구 — 「N개 고름」(30-2) · 「+」(맨 위 폴더 · 항목 더하기 · 읽기만 층 = 숨김 · 29-6) */
add: HTMLElement;
refresh: () => Promise<void>;
/** 표에서 고른 열을 나무에 표시(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 +123,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
let busy = false;
const open = new Set<string>([folderIdOf([])]);
const targets = new Map<string, Target>();
// 여러 개 고름(PLAN 30-2) — 줄 id(`f|경로` · `i|열`) · Shift 범위의 기준
let picked = new Set<string>();
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 +138,68 @@ 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[] => {
if (!canCopy) return [];
const n = String(list.length);
return [
{ label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) },
...(opt.copyTo ?? []).map((to) => ({
label: to.label.replace("{n}", n),
run: () => void copyTo(list, to),
})),
];
};
/** 맨 위(빈 자리 오른쪽 클릭 · 「+」) — 폴더 · 항목 더하기 · 붙여넣기 */
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 +226,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
function node(target: Target, base: Omit<TreeNode, "id" | "menu">, 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 +264,105 @@ 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 },
);
// 끌어다 옮기기(30-2) — 쓸 수 있는 층만 · 고른 것 안의 줄을 끌면 고른 것 전부
if (opt.canEdit) {
button.draggable = true;
button.addEventListener("dragstart", (event) => {
const own = targets.get(one.id);
dragging = picked.size > 1 && picked.has(one.id) ? pickedTargets() : own ? [own] : null;
event.dataTransfer?.setData("text/plain", one.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
button.addEventListener("dragend", () => {
dragging = null;
dropMark(null);
});
}
return button;
};
/* --- 끌어다 놓기: 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 제 안으로는 안 됨 --- */
let dragging: Target[] | null = null;
const dropDest = (event: DragEvent): string[] | null => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
const target = row ? targets.get(row.dataset.node ?? "") : undefined;
const dest = !row ? [] : target?.kind === "folder" ? target.path : null;
const blocked = dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path));
return dest && !blocked ? dest : null;
};
const dropMark = (row: HTMLElement | null): void => {
body.querySelectorAll(".is-drop").forEach((b) => b.classList.remove("is-drop"));
row?.classList.add("is-drop");
};
body.addEventListener("dragover", (event) => {
if (!dragging || !dropDest(event)) return;
event.preventDefault();
dropMark((event.target as HTMLElement).closest<HTMLElement>("[data-node]") ?? body);
});
body.addEventListener("drop", (event) => {
const dest = dragging && dropDest(event);
const list = dragging;
dragging = null;
dropMark(null);
if (!dest || !list) return;
event.preventDefault();
void dropMove(list, dest);
});
/** 놓은 자리로 옮김 — 이미 그 자리인 것은 뺌 · 하나면 한 개 옮기기 · 여럿이면 한 번에 */
async function dropMove(list: Target[], dest: string[]): Promise<void> {
const moving = list.filter(
(t) => !samePath(t.kind === "item" ? folderOf(t.item) : t.path.slice(0, -1), dest),
);
if (!moving.length || !(await ready())) return;
if (moving.length === 1) {
const t = moving[0];
return edit(
t.kind === "item"
? { op: "move", 경로: folderOf(t.item), 키: t.item.key, 새경로: dest }
: { op: "move", 경로: t.path, 새경로: dest },
);
}
const run = api.many;
if (run)
await apply((판) =>
run(opt.layer, { op: "move", 대상: moving.map(refOf), 새경로: dest }, 판, owner),
);
}
const visibleIds = (): string[] =>
[...body.querySelectorAll<HTMLElement>("[data-node]")]
.filter((b) => !b.closest("[hidden]"))
.map((b) => b.dataset.node ?? "");
/** 고름을 바꾸고 줄 배경 · 「N개 고름」을 맞춤 */
function setPicked(next: Set<string>, nextAnchor: string | null): void {
picked = new Set([...next].filter((id) => targets.has(id)));
anchor = nextAnchor;
body.querySelectorAll<HTMLElement>("[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 +382,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 +430,32 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
return true;
}
async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> {
const edit = (body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> =>
apply((판) => api.edit(opt.layer, body_, 판, owner), keepCol);
/** 나무 고침 한 번(하나 · 여러 개 · 붙여넣기) — 판 다름 · 막힘 까닭 · 뒤 처리를 한 곳에서 */
async function apply(
run: (판: string) => Promise<TreeView>,
keepCol: string | null = activeCol,
): Promise<void> {
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 +555,64 @@ 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");
}
/** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */
async function copyTo(
list: Target[],
to: { layer: Layer; owner: string | null; label: string },
): Promise<void> {
if (!api.paste || !list.length) return;
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
try {
await api.paste(to.layer, to.owner, clip, []);
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, "");
showToast(
L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where),
"success",
);
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
}
async function paste(dest: string[]): Promise<void> {
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<void> {
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<void> {
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 +624,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<HTMLElement>("[data-node]");
if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기
return void paste([]);
}
if (!row) return;
const rows = [...body.querySelectorAll<HTMLElement>("[data-node]")].filter(
(b) => !b.closest("[hidden]"),
@@ -399,9 +660,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 };
}
@@ -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<string>,
anchor: string | null,
id: string,
mod: { ctrl: boolean; shift: boolean },
order: string[],
): { picked: Set<string>; 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 };
}
@@ -32,6 +32,8 @@ export interface LayerBarOptions {
go: (root: Root, owner: string | null) => void;
/** 단추 옆 「+」(고른 층 나무 맨 위 폴더 · 항목 더하기 · PLAN 29-6) */
add?: HTMLElement;
/** 켜진 단추를 다시 누름 — 그 층 목록 화면으로(열어 둔 항목 · 조합에서 · PLAN 30-4) */
reselect?: () => void;
}
/** 회사 단추의 주인 — 볼 회사가 자기 회사면 null(자기 층) */
@@ -71,6 +73,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement {
});
b.addEventListener("click", () => {
if (!current) opt.go(root, owner);
else opt.reselect?.();
});
return b;
}
@@ -64,9 +64,17 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
// 「〇〇로 복사」(30-2) — 이 사람이 쓸 수 있는 자기 층(지금 보는 층은 뺌) 맨 위로 바로
const copyTo = (["master", "company", "personal"] as Root[]).flatMap((one) => {
const to = layerOfRoot(one);
if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }];
});
const tree = createTreePanel({
layer,
owner,
copyTo,
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
@@ -90,13 +98,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
};
// 층 단추 셋(PLAN 29-2) — 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 한 벌씩
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
const layerBar = (open: OpenTarget, add: HTMLElement): HTMLElement =>
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 구조물 = 집계표 · 일위대가 = 조합 목록)
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
createLayerBar({
user,
active: { root: entry.root, owner },
company,
go: (next, nextOwner) => void go(next, nextOwner, open),
add,
reselect,
});
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
const peopleTree = (open: OpenTarget, panel: HTMLElement): HTMLElement =>
@@ -139,7 +149,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],
tree: [
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
peopleTree("구조물조합", tree.root),
],
labor: ({ onOpen }) =>
buildLaborSection({
show: main.showView,
@@ -148,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
onOpen();
mark("일위대가");
},
bar: (add) => layerBar("일위대가", add),
bar: (add, back) => layerBar("일위대가", add, back),
wrap: (panel) => peopleTree("일위대가", panel),
make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }),
}),
+1 -1
View File
@@ -96,7 +96,7 @@ def test_나무는_좌측_패널_찾기_칸은_상세_위쪽():
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "createTreePanel({" in page and "tree.select(column)" in page
assert (
'tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],' in page
'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
and "search: tree.search," in page
)
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
+2 -2
View File
@@ -83,8 +83,8 @@ def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤():
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길
assert (
'bar: (add) => layerBar("일위대가", add),' in page
and 'tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],' in page
'bar: (add, back) => layerBar("일위대가", add, back),' in page
and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
)
labor = page.split("buildLaborSection({")[1].split("make:")[0]
assert 'mark("일위대가")' in labor # 여는 순간 해시 open
+22 -6
View File
@@ -129,7 +129,7 @@ def test_층_단추_셋_남의_회사_사람_나무():
assert "side.reveal(entry.open)" in page
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert 'fetchOwners("company")' not in roots # 회사 목록 펼침 없앰
assert "select" not in roots # 사람 고르기 없앰
assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰
assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무
assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기
assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots
@@ -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
@@ -217,3 +216,20 @@ def test_읽기만_표는_단추_고치기_모두_막음():
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert re.search(r"readOnly,\s*lockHead: !!struct,\s*onChange", main)
assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css")
def test_켜진_층_단추는_목록으로_복사_끌기():
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 ·
저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) ·
끌어다 옮기기는 쓸 수 있는 층만."""
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page
assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page
labor = _text(M02 / "M02_MasterTemplete_Labor.ts")
assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "if (opt.canEdit) {\n button.draggable = true;" in panel
assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in panel
+109
View File
@@ -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() == [["나", "둘"]]
+37
View File
@@ -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],
]
+17
View File
@@ -118,6 +118,23 @@ 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_TreeCopyToMaster: ["마스터로 복사 ({n}개)", "Copy to master ({n})"],
M02_TreeCopyToCompany: ["회사로 복사 ({n}개)", "Copy to company ({n})"],
M02_TreeCopyToPersonal: ["개인으로 복사 ({n}개)", "Copy to personal ({n})"],
M02_TreeCopiedTo: ["{n}개를 {layer} 맨 위에 복사함", "Copied {n} to the top of {layer}"],
M02_TreeFolderName: ["폴더 이름", "Folder name"],
M02_TreeItemName: ["항목 이름", "Item name"],
M02_TreeUnit: ["단위", "Unit"],