feat(m02): 일위대가 사전조합 나무 = 자유 폴더 — 분류 고정 폴더 걷어 냄(158-1)

- 서버: 층 뿌리 precombo_폴더.json 에 폴더 · 문서 자리만 적음(문서 파일 제자리) · GET/POST /layers/{층}/precombo-tree · 새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(빈 폴더만) · 판 409 · 층 권한 그대로
- 화면: 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기] · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기(구조물 조합 부품) · 자리 없는 문서 = 뿌리
- 구분 · 상세구분은 문서 칸 그대로(고르기 창 거름 precomboClasses)
- 시험: test_m02_precombo_folders 새로 · test_m02_precombo_scope 나무 시험 갈음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-09 21:30:49 +09:00
co-authored by Claude Opus 5.5
parent c08d321af4
commit 064d9ebae7
8 changed files with 587 additions and 161 deletions
+31 -2
View File
@@ -2,7 +2,8 @@
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버).
지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) +
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기).
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
@@ -20,7 +21,14 @@ from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Material_Own as own
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import (
Layer,
Session,
TreeBody,
_call,
_root,
)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"])
@@ -105,6 +113,27 @@ def new_library(
return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref)
@router.get("/layers/{layer}/precombo-tree")
def precombo_tree(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""사전조합 나무 폴더 — `{판, 폴더: [경로 …], 자리: {키: 경로}}`(자리 없는 문서 = 뿌리)."""
return _call(_root(session, layer, owner), pre_folders.view)
@router.post("/layers/{layer}/precombo-tree")
def edit_precombo_tree(
layer: Layer,
body: TreeBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""새 폴더 · 이름 바꾸기 · 옮기기(폴더 · 문서 `키`) · 지우기(빈 폴더만) → 새 나무 ·
판 다르면 409."""
folder = _root(session, layer, owner, write=True)
return _call(folder, pre_folders.edit, body.model_dump())
@router.get("/layers/{layer}/materials")
def own_materials(
layer: Layer,
@@ -159,6 +159,27 @@ export const newLibrary = (
body: JSON.stringify(body),
});
/** 사전조합 나무 자유 폴더(158-1) — 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) */
export interface PrecomboTree {
판: string;
폴더: string[][];
자리: Record<string, string[]>;
}
export const fetchPrecomboTree = (layer: Layer, owner: string | null): Promise<PrecomboTree> =>
call(owned(layer, `/layers/${layer}/precombo-tree`, owner));
export const editPrecomboTree = (
layer: Layer,
edit: Pick<TreeEdit, "경로" | "이름" | "새경로" | "키"> & {
op: "add-folder" | "rename" | "move" | "delete";
},
판: string,
owner: string | null,
): Promise<PrecomboTree> =>
call(owned(layer, `/layers/${layer}/precombo-tree`, owner), {
method: "POST",
body: JSON.stringify({ ...edit, 판 }),
});
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
@@ -146,6 +146,11 @@ const TEXT = {
Lib_None: ["사전조합 없음", "No pre-combination"],
Lib_Unsorted: ["미분류", "Unsorted"],
Lib_NewHere: ["여기에 새 사전조합", "New pre-combination here"],
Lib_FolderFull: [
"폴더 안에 사전조합 {n}개 — 먼저 옮기거나 지운 뒤 지움",
"Folder holds {n} pre-combination(s) — move or delete them first",
],
Lib_FolderDelete: ["「{value}」 빈 폴더를 지움", "Delete empty folder “{value}”"],
Lib_Create: ["생성", "Create"],
Lib_Copy: ["복사", "Copy"],
Lib_Delete: ["삭제", "Delete"],
@@ -1,22 +1,42 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side_Precombo.ts
* 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류).
* 폴더 = 분류 뼈대 고정(149-1 · 구조물군 › 공통 + 종류 · 빈 폴더도 · 문서 수 · 문서 든 폴더만 펼침) ·
* 문서 옮김 = 상세 머리에서 분류 바꿈 · 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성.
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무.
* 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) —
* 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽
* 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김.
* 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관.
* 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표
* (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄.
* ========================================================================== */
import { el, showToast } from "@ui/ui_template_elements";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree";
import { listTemplates, newLibrary, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
createTreeRow,
openTreeMenu,
renderTree,
type TreeMenuItem,
type TreeNode,
} from "@ui/ui_template_tree";
import {
StaleError,
editPrecomboTree,
fetchPrecomboTree,
listTemplates,
newLibrary,
type Kind,
type Layer,
type PrecomboTree,
} from "./M02_MasterTemplete_Api_Fetch";
import { nameDialog } from "./M02_MasterTemplete_Library_List";
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import type { Root } from "./M02_MasterTemplete_Layer";
import type { StructDef } from "./StructOptions/M02_SO_Def_Model";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model";
import type { Target } from "./M02_MasterTemplete_TreePanel_Pick";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
export type PrecomboKind = Extract<Kind, "precombo">;
@@ -83,52 +103,49 @@ interface Row {
상세구분?: string | null;
}
type Classes = { name: string; details: string[] }[];
/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) ·
* `classes`(149-1) = 분류 뼈대 먼저(그 차례 · 빈 묶음도) → 목록 밖 값은 뒤에 가나다 */
export function groupRows(
rows: Row[],
unsorted: string,
classes: Classes = [],
): { 구분: string; 상세: Map<string, Row[]> }[] {
const out = new Map<string, Map<string, Row[]>>(
classes.map((c) => [c.name, new Map(c.details.map((d) => [d, []]))]),
);
for (const row of rows) {
const top = row.구분 || "";
const sub = row.구분 ? row.상세구분 || "" : "";
if (!out.has(top)) out.set(top, new Map());
const subs = out.get(top)!;
subs.set(sub, [...(subs.get(sub) ?? []), row]);
}
const order = (a: string, b: string): number =>
!a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true });
const rank = (list: string[], key: string): number =>
list.includes(key) ? list.indexOf(key) : list.length;
const tops = classes.map((c) => c.name);
return [...out.keys()]
.sort((a, b) => rank(tops, a) - rank(tops, b) || order(a, b))
.map((top) => {
const subs = classes.find((c) => c.name === top)?.details ?? [];
return {
구분: top || unsorted,
상세: new Map(
[...out.get(top)!].sort(([a], [b]) => rank(subs, a) - rank(subs, b) || order(a, b)),
),
};
});
export interface PrecomboFolder {
path: string[];
folders: PrecomboFolder[];
docs: Row[];
}
/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */
export function buildPrecomboFolders(
rows: Row[],
view: Pick<PrecomboTree, "폴더" | "자리">,
): PrecomboFolder {
const root: PrecomboFolder = { path: [], folders: [], docs: [] };
const at = (path: string[]): PrecomboFolder =>
path.reduce((node, name, i) => {
let next = node.folders.find((f) => f.path[i] === name);
if (!next) {
next = { path: path.slice(0, i + 1), folders: [], docs: [] };
node.folders.push(next);
}
return next;
}, root);
view.폴더.forEach(at);
for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row);
return root;
}
/** 폴더 안 문서 수(아래 폴더까지) */
export const docCount = (node: PrecomboFolder): number =>
node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0);
type TreeBody = Parameters<typeof editPrecomboTree>[1];
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
const barHost = el("div");
const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } });
const leaves = new Map<string, HTMLElement>();
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
const targets = new Map<string, Target>();
let rows: Row[] = [];
let classes: Promise<Classes> | null = null; // 분류 뼈대(구조물정의 · 한 번 받음 · 못 받으면 다음에 다시)
let shape: Classes = [];
let view: PrecomboTree | null = null;
let seq = 0;
let busy = false;
const open = async (row: Row): Promise<void> => {
const at = opt.scope();
@@ -139,85 +156,158 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
label: row.제목 ? `${row.이름} ${row.제목}` : row.이름,
});
};
const leaf = (row: Row): TreeNode => ({
id: `doc:${row.이름}`,
label: row.제목 || row.이름,
tip: row.이름,
count: null,
run: () => void open(row),
});
/** 폴더 메뉴 — [여기에 새 사전조합] = 그 구분 · 상세구분으로 생성 → 나무 다시 · 상세 엶 */
const make = (구분: string, 상세구분: string): void => {
/** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(빈 폴더 아님 · 같은 이름) 그대로 */
const edit = async (body: TreeBody): Promise<void> => {
const at = opt.scope();
if (!view || busy) return;
busy = true;
try {
view = await editPrecomboTree(at.layer, body, view.판, at.owner);
for (const path of [body.경로, body.새경로])
path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true));
draw();
showToast(L("M02_TreeDone"), "success");
} catch (error) {
if (error instanceof StaleError && error.message === "HTTP 409") {
await refresh();
showToast(L("M02_TreeStale"), "error");
} else {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
} finally {
busy = false;
}
};
/** [여기에 새 사전조합] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */
const make = (path: string[]): void => {
const at = opt.scope();
nameDialog(tc("Lib_NewHere"), "", async (name) => {
const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] };
const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner);
showToast(L("M02_Created"), "success");
await refresh();
if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });
await open({ 이름: got.이름, 종류: "precombo", 제목: name });
});
};
const docs = (node: TreeNode): number =>
node.run ? 1 : (node.children ?? []).reduce((n, c) => n + docs(c), 0);
const folder = (
id: string,
label: string,
children: TreeNode[],
at: [string, string],
): TreeNode => {
const count = children.reduce((n, c) => n + docs(c), 0);
const addFolder = async (parent: string[]): Promise<void> => {
const got = await askText(L("M02_TreeAddFolder"), [
{ key: "name", label: L("M02_TreeFolderName") },
]);
if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name });
};
const rename = async (path: string[]): Promise<void> => {
const was = path[path.length - 1];
const got = await askText(L("M02_TreeRename"), [
{ key: "name", label: L("M02_TreeFolderName"), value: was },
]);
if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name });
};
/** 지우기 — 빈 폴더만(안에 문서가 있으면 알림만) */
const remove = async (path: string[], count: number): Promise<void> => {
if (count) return void showToast(tc("Lib_FolderFull").replace("{n}", String(count)), "error");
const ask = tc("Lib_FolderDelete").replace("{value}", path[path.length - 1]);
if (await showConfirmDialog(ask, L("M02_Delete"))) await edit({ op: "delete", 경로: path });
};
const menuAt = (path: string[], count: number): TreeMenuItem[] => {
const off = opt.scope().canWrite ? undefined : tc("ReadOnly");
const item = (label: string, run: () => void): TreeMenuItem => ({
label,
run,
disabledReason: off,
});
return [
item(L("M02_TreeAddFolder"), () => void addFolder(path)),
item(tc("Lib_NewHere"), () => make(path)),
...(path.length
? [
item(L("M02_TreeRename"), () => void rename(path)),
item(L("M02_Delete"), () => void remove(path, count)),
]
: []),
];
};
const leaf = (row: Row, folder: string[]): TreeNode => {
const id = `doc:${row.이름}`;
const label = row.제목 || row.이름;
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
targets.set(id, { kind: "item", item });
return { id, label, tip: row.이름, count: null, run: () => void open(row) };
};
const folderNode = (node: PrecomboFolder): TreeNode => {
const id = folderIdOf(node.path);
const count = docCount(node);
targets.set(id, { kind: "folder", path: node.path });
return {
id,
label,
label: node.path[node.path.length - 1],
count,
folder: true,
open: toggled.get(id) ?? count > 0,
children,
menu: () => [
{
label: tc("Lib_NewHere"),
run: () => make(...at),
disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"),
},
],
children: kids(node),
menu: () => menuAt(node.path, count),
};
};
const kids = (node: PrecomboFolder): TreeNode[] => [
...node.folders.map(folderNode),
...node.docs.map((row) => leaf(row, node.path)),
];
/** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 · 제자리면 안 함 */
const drag = attachTreeDrag({
body: treeHost,
targetOf: (id) => targets.get(id),
listFor: (id) => (targets.has(id) ? [targets.get(id)!] : null),
move: ([t], dest) => {
const from = t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1);
if (samePath(from, dest)) return;
void edit(
t.kind === "item"
? { op: "move", 키: t.item.key, 새경로: dest }
: { op: "move", 경로: t.path, 새경로: dest },
);
},
});
const draw = (): void => {
leaves.clear();
if (!rows.length && !shape.length)
targets.clear();
const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} });
if (!tree.docs.length && !tree.folders.length)
return void treeHost.replaceChildren(
el("p", { className: "m02-side__empty", text: tc("Lib_None") }),
);
const unsorted = tc("Lib_Unsorted");
const nodes = groupRows(rows, unsorted, shape).map((g) => {
const top = g.구분 === unsorted ? "" : g.구분;
return folder(
`sub:${g.구분}`,
g.구분,
[...g.상세].flatMap(([sub, list]) =>
sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf), [top, sub])] : list.map(leaf),
),
[top, top ? ([...g.상세.keys()][0] ?? "") : ""],
);
});
const canWrite = opt.scope().canWrite;
treeHost.replaceChildren(
...renderTree(nodes, (node, caret, depth, onClick) => {
...renderTree(kids(tree), (node, caret, depth, onClick) => {
const row = createTreeRow(node, caret, depth, () => {
onClick();
if (!node.children?.length) return;
toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸");
});
row.dataset.node = node.id;
if (node.run) {
row.dataset.precomboKey = node.id.slice(4);
leaves.set(node.id.slice(4), row);
}
if (canWrite) drag.arm(row, node.id);
return row;
}),
);
paint();
};
// 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 사전조합)
treeHost.addEventListener("contextmenu", (event) => {
if (!view || (event.target as HTMLElement).closest("[data-node]")) return;
event.preventDefault();
openTreeMenu(event, menuAt([], 0));
});
const paint = (): void => {
const cur = opt.getOpen();
const at = opt.scope();
@@ -231,20 +321,17 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
const at = opt.scope();
barHost.replaceChildren(opt.bar());
treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") }));
const shapeAt = (classes ??= fetchStructureDef().then(precomboClasses, () => {
classes = null;
return []; // 못 받으면 문서 값만으로 묶음(지난 모양)
}));
let got: Row[] = [];
try {
got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo");
} catch {
got = []; // 못 받으면 빈 나무(목록표에서 까닭)
}
const cls = await shapeAt;
const [got, tree] = await Promise.all([
// 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리
listTemplates(at.layer, null, at.owner).then(
(list): Row[] => list.filter((r) => r.종류 === "precombo"),
(): Row[] => [],
),
fetchPrecomboTree(at.layer, at.owner).catch(() => null),
]);
if (mine !== seq) return;
rows = got;
shape = cls;
view = tree;
draw();
};
+136
View File
@@ -0,0 +1,136 @@
"""M02 일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1) — 구조물 조합 나무와 같은 꼴(깊이 자유).
자리 = 층 뿌리 `precombo_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` —
층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/precombo/SC-######.json`)은 안 옮김 ·
폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 ·
문서 옮기기)은 판이 다르면 409 · 지우기는 빈 폴더만(안에 문서가 있으면 409).
이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌.
"""
from __future__ import annotations
import json
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
FILE = "precombo_폴더.json"
OPS = ("add-folder", "rename", "move", "delete")
def _keys() -> set[str]:
"""지금 층에 있는 사전조합 키(파일 이름)."""
return {p.stem for p in (store.root() / "precombo").glob("[!._]*.json")}
def _read() -> tuple[dict[str, Any], str]:
path = store.root() / FILE
if not path.is_file():
return {"폴더": [], "자리": {}}, ""
raw = path.read_bytes()
return json.loads(raw), store.version_of(raw)
def _folders(doc: dict[str, Any], keys: set[str]) -> list[list[str]]:
"""폴더 전부 — 적어 둔 폴더 + 있는 문서 자리의 앞마디(빈 경로 뺌 · 겹침 뺌)."""
out: list[list[str]] = []
paths = [tree._clean(p) for p in doc.get("폴더") or []]
paths += [tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys]
for path in paths:
for n in range(1, len(path) + 1):
if path[:n] not in out:
out.append(path[:n])
return out
def view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]:
places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys}
return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}}
def view() -> dict[str, Any]:
doc, 판 = _read()
return view_of(doc, 판, _keys())
def _shift(path: list[str], old: list[str], new: list[str]) -> list[str]:
return [*new, *path[len(old) :]] if tree._under(path, old) else path
def apply(
doc: dict[str, Any], body: dict[str, Any], keys: set[str]
) -> tuple[dict[str, Any], dict[str, Any]]:
"""고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장은 `edit`)."""
op = body.get("op")
if op not in OPS:
raise tree.TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}")
folders = _folders(doc, keys)
places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys}
where = tree._path(body.get("경로"))
key = str(body.get("키") or "")
note: dict[str, Any] = {"op": op}
def need(path: list[str], what: str) -> None:
if path and path not in folders:
raise tree.TreeError(404, f"없는 폴더 「{' / '.join(path)}」")
if not path and what:
raise tree.TreeError(422, f"{what} 폴더 「경로」 가 없음")
if op == "add-folder":
need(where, "")
made = [*where, tree._name(body.get("이름"))]
if made in folders:
raise tree.TreeError(409, f"이미 있는 폴더 「{' / '.join(made)}」")
folders.append(made)
note["경로"] = made
elif op == "move" and key:
if key not in keys:
raise tree.TreeError(404, f"없는 사전조합 「{key}」")
to = tree._path(body.get("새경로"), "새경로")
need(to, "")
places[key] = to
note["경로"] = to
elif op in ("rename", "move"):
need(where, "바꿀" if op == "rename" else "옮길")
if op == "rename":
new = [*where[:-1], tree._name(body.get("이름"))]
if new != where and new in folders:
raise tree.TreeError(409, f"이미 있는 폴더 「{' / '.join(new)}」")
else:
to = tree._path(body.get("새경로"), "새경로")
need(to, "")
if tree._under(to, where):
raise tree.TreeError(422, "폴더를 제 안으로 옮길 수 없음")
new = tree.unique_path([*to, where[-1]], lambda p: p != where and p in folders)
if new[-1] != where[-1]:
note["바뀐이름"] = [new[-1]]
folders = [_shift(f, where, new) for f in folders]
places = {k: _shift(p, where, new) for k, p in places.items()}
note["경로"] = new
else: # delete — 빈 폴더만
need(where, "지울")
inside = sorted(k for k, p in places.items() if tree._under(p, where))
if inside:
raise tree.TreeError(409, f"폴더 안에 사전조합 {len(inside)}개 — 먼저 옮기거나 지움")
folders = [f for f in folders if not tree._under(f, where)]
note["경로"] = where
out = {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}}
return out, note
# ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임
# (키 재사용을 막을 때 함께 고침)
def edit(body: dict[str, Any]) -> dict[str, Any]:
"""지금 층 자리 파일을 고쳐 씀 → 새 나무 + 알림 · 판 다르면 409."""
doc, 판 = _read()
if 판 != str(body.get("판") or ""):
raise store.StoreError(409, {"stale": [FILE], "판": 판})
keys = _keys()
try:
out, note = apply(doc, body, keys)
except tree.TreeError as e:
raise store.StoreError(e.status, e.detail) from e
atomic_write_json(store.root() / FILE, out)
return {**view(), "알림": note}