feat(b08): 라이브러리 [복제해서 내 것으로] — 기본·회사 항목을 개인 단에 베낌(출처 cloned_from 기록 · 새 코드 · 프로젝트 작업본 안 바꿈 · 개인 단 항목에선 안 눌림 · 같은 종류 내 것은 덮어씀을 먼저 물음)

화면(936be972 돌쌓기(찰) 장): 기본 항목 복제 → 목록 「개인 · [양식형] 돌쌓기(찰)」 + 「내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]」 · 프로젝트 라이브러리 폴더 안 생김 · [내 것 지우기]로 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 17:45:46 +09:00
co-authored by Claude Opus 5
parent a744388276
commit 64e43463f4
3 changed files with 131 additions and 4 deletions
@@ -6,15 +6,18 @@
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈.
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.)
"""
import asyncio
import io
from typing import Any
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_auth import verify_session
@@ -110,3 +113,39 @@ async def save_stmate_recipe(
"note": item["note"],
}
)
class LibraryCloneRequest(BaseModel):
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
async def clone_library_item(
project_id: UUID,
payload: LibraryCloneRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
find_item,
save_item_personal,
tier_dirs,
)
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
folder = dirs.get("personal")
if folder is None:
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
if item is None or item.get("type_id") != payload.type_id:
return _error(404, "베낄 항목을 찾지 못했습니다.")
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
code = await asyncio.to_thread(save_item_personal, folder, body)
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
@@ -80,6 +80,49 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
take.className = "b08-spec__save";
take.textContent = "가져오기";
take.hidden = true;
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
const clone = document.createElement("button");
clone.type = "button";
clone.className = "b08-quantity__tab";
clone.textContent = "복제해서 내 것으로";
clone.hidden = true;
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
let afterLoad = "";
const syncClone = (): void => {
const [tier] = list.value.split("|");
clone.disabled = tier === "personal";
};
list.addEventListener("change", syncClone);
clone.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
const label = list.selectedOptions[0]?.textContent ?? "";
if (!tier || !code || tier === "personal") return;
if (
!window.confirm(
`${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
)
) {
return;
}
void (async () => {
clone.disabled = true;
try {
await readJson(
await fetch(libraryUrl(projectId, "/clone"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, tier, code }),
}),
);
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
load.click();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "복제 못함";
syncClone();
}
})();
});
load.addEventListener("click", () => {
void (async () => {
@@ -101,8 +144,10 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
return option;
}),
);
list.hidden = take.hidden = items.length === 0;
status.textContent = items.length ? "" : "가져올 항목이 없음";
list.hidden = take.hidden = clone.hidden = items.length === 0;
syncClone();
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
afterLoad = "";
} catch (error) {
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
} finally {
@@ -191,7 +236,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
mine.className = "b08-sheet__actions";
mine.append(save, remove);
panel.append(title, scope, load, list, take, mine, status);
panel.append(title, scope, load, list, take, clone, mine, status);
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
panel.append(
buildStmatePanel({
@@ -216,6 +216,41 @@ def test_확정_때_기본_양식을_박는다(project: Path) -> None:
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
def test_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다(
client: TestClient, project: Path, storage: Path
) -> None:
"""PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌."""
import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router
client.app.include_router(extra_router.router)
base = load_template("masonry_wet")
folder = storage / "7" / "42" / "library"
for path in folder.glob("*.json"):
path.unlink() # 개인 단을 비워 새로 생기는지 봄
response = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
)
assert response.status_code == 200, response.text
code = response.json()["code"]
saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
assert saved["library_tier"] == "personal" and saved["code"] != base["code"]
assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]}
assert saved["rows"] == base["rows"]
assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로
missing = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"},
)
assert missing.status_code == 404
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
denied = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
)
assert denied.status_code == 403
def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None:
"""PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림."""
folder = tmp_path / "personal"
@@ -236,6 +271,14 @@ def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) ->
assert "양식형" in ui and "고정형" in ui and "item.kind" in ui
def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "/clone" in ui and "복제해서 내 것으로" in ui
assert 'clone.disabled = tier === "personal"' in ui
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
with pytest.raises(ValueError):
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")