feat(733-3): 서고 중복 찾기 틀 — 서버 길(목록 · 합치기 · 되돌림) · 화면 창 · 나무 메뉴 (등록 전)

- 새 M02_Library_Same_Router.py — GET {kind}-same · POST merge · POST undo (main.py 등록 · 시험은 다음 걸음)
- 새 M02_MasterTemplete_Library_Same.ts · .css — 묶음 표 · 남김 고르기 · 합치기 물음 · 합친 것 되돌림
- 서고 나무 뿌리 메뉴 [중복 찾기]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:39:47 +09:00
co-authored by Claude Opus 5.5
parent 8c5ef34365
commit 6cf57dc289
4 changed files with 697 additions and 0 deletions
@@ -0,0 +1,352 @@
"""M02 서고 [중복 찾기] · 합치기 · 되돌림(PLAN 733-3 · 요구서_572 3장 4 · 분석 D-3) —
단가산출(`sangeun` SK) · 일위대가(`precombo` SC).
GET /layers/{층}/{kind}-same — 이 층 + 위 층(회사 · 마스터) 같은 지문 묶음(이 층 문서가 든
묶음만) · 문서마다 층 · 키 · 이름 · 구분 · 쓰는 곳 수 · 꼬리표 「내용 같음」 ·
SC 「수량만 다름」(`본뜬.실무수량` 다름) · 합친 것 목록(되돌림)
POST …/merge {남김, 지움[], 판} — 서버가 지문 다시 셈(다르면 409) · 쓰는 곳 참조를 남김으로
(쓰기 권한 닿는 곳만 · `store.write` 정본 다시 셈) · 남은 쓰는 곳이 있는 문서 = 안 지움
「남김 — 쓰는 곳 N」 · 나머지 = `{층}/_합친것/<키>.json` 으로 옮김 · 나무 자리 걷음
POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되돌림
지움 = 이 층 문서만 · 남김 = 이 층 또는 위 층(위 층과 같은 아래 층 문서 = 남김 = 위 층 키) ·
판 = 이 층 서고 나무 판(`{종류}_폴더.json`) · 권한 = 그 층 쓰기 · 프로젝트 사본 = 굳힌 값이라
안 건드림 · 지문 = `M02_Library_Same`(733-1) 읽기만.
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_Library_Same as same
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _root
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Library Same"])
Kind = Literal["precombo", "sangeun"]
GONE = "_합친것"
USER_KINDS = (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출"))
_ORDER = {"system": 0, "company": 1, "personal": 2}
class MergeBody(BaseModel):
"""`남김` = {층, 주인, 키}(이 층 · 위 층) · `지움` = 이 층 키들 · `판` = 이 층 서고 나무 판."""
남김: dict[str, Any]
지움: list[str]
판: str
class UndoBody(BaseModel):
키: str
def _bad(status: int, text: Any) -> store.StoreError:
return store.StoreError(status, text)
def _ref(at: refs.Here, key: str) -> refs.Ref:
return {"층": at[0], "주인": at[1], "키": key}
def _uppers(at: refs.Here) -> list[refs.Here]:
"""이 층이 읽는 위 층 — 개인 → 그 회사 · 마스터 / 회사 → 마스터 / 마스터 → 없음."""
layer, owner = at
if layer == "personal":
return [("company", str(owner).split("/")[0]), ("system", None)]
return [("system", None)] if layer == "company" else []
def _docs(at: refs.Here, kind: str) -> list[tuple[str, dict[str, Any]]]:
folder = refs.folder_of(*at) / kind
out = []
for path in sorted(folder.glob("[!._]*.json")) if folder.is_dir() else []:
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
if isinstance(doc, dict):
out.append((path.stem, doc))
return out
def _finger(at: refs.Here, kind: str, doc: dict[str, Any]) -> str | None:
"""문서 내용 지문(733-1 · 빈 문서 None) — 참조는 가리킨 문서 지문(층 넘어 사본도 같음)."""
with store.at(refs.folder_of(*at)):
return same.finger(kind, doc)
# ── 쓰는 곳 ───────────────────────────────────────────────────────────
def _user_index() -> dict[str, list[tuple[Path, str, str]]]:
"""모든 층(프로젝트 뺌) 훑기 한 번 — {ref_id: [(파일, 종류, 「어디 이름」)]}."""
out: dict[str, list[tuple[Path, str, str]]] = {}
for kind, what in USER_KINDS:
for where, path in layers.layer_files(kind):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
name = doc.get("일위대가번") or doc.get("이름") or path.stem
for rid in {refs.ref_id(one) for one in refs.refs_in(doc)}:
out.setdefault(rid, []).append((path, kind, f"{what} {where} 「{name}」"))
return out
def _can_write(session: Session, at: refs.Here) -> bool:
try:
return access.grant(session, at[0], at[1]).write
except HTTPException:
return False
def _retarget(doc: dict[str, Any], olds: set[str], new: refs.Ref) -> dict[str, Any]:
"""문서의 `olds` 참조를 모두 `new` 로(규격 열 수량 열쇠까지 · `M02_Labor_Ref` 한 벌)."""
done: dict[str, refs.Ref] = {}
def move(ref: refs.Ref, _depth: int) -> refs.Ref:
if refs.ref_id(ref) in olds:
done[refs.ref_id(ref)] = new
return new
return ref
return refs._spec_moved(refs._move_doc(doc, move, 1), done)
def _rewrite(path: Path, kind: str, olds: set[str], new: refs.Ref) -> None:
"""쓰는 곳 한 파일 — 참조를 바꿔 `store.write`(서버 정본 다시 셈 · 판 검사)."""
raw = path.read_bytes()
doc = _retarget(json.loads(raw), olds, new)
with store.at(path.parent.parent):
store.write(kind, path.stem, store.version_of(raw), doc)
# ── 목록 ──────────────────────────────────────────────────────────────
def _qty(doc: dict[str, Any]) -> str:
return json.dumps((doc.get("본뜬") or {}).get("실무수량") or {}, sort_keys=True)
def _row(at: refs.Here, key: str, doc: dict[str, Any], used: int, mine: refs.Here) -> dict:
return {
"층": at[0],
"주인": at[1],
"키": key,
"이름": doc.get("이름") or key,
"구분": doc.get("구분") or "",
"상세구분": doc.get("상세구분") or "",
"쓰는곳수": used,
"위층": at != mine,
}
def _gone_rows(folder: Path, kind: str) -> list[dict[str, Any]]:
out = []
for path in sorted((folder / GONE).glob("*.json")):
try:
rec = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
if rec.get("종류") == kind:
doc = rec.get("문서") or {}
out.append(
{
"키": rec.get("키"),
"이름": doc.get("이름") or rec.get("키"),
"남김": rec.get("남김"),
"때": rec.get("때"),
"옮긴곳수": len(rec.get("옮긴곳") or []),
}
)
return out
def find(folder: Path, kind: str) -> dict[str, Any]:
"""이 층 + 위 층 같은 지문 묶음 — 이 층 문서가 든 묶음 · 둘 이상만."""
mine = refs.where(folder)
index = _user_index()
groups: dict[str, list[tuple[refs.Here, str, dict[str, Any]]]] = {}
for at in [mine, *_uppers(mine)]:
for key, doc in _docs(at, kind):
if (mark := _finger(at, kind, doc)) is not None:
groups.setdefault(mark, []).append((at, key, doc))
out = []
for mark, docs in groups.items():
if len(docs) < 2 or all(at != mine for at, _, _ in docs):
continue
docs.sort(key=lambda d: (_ORDER[d[0][0]], d[1]))
rows = [
_row(at, key, doc, len(index.get(refs.ref_id(_ref(at, key)), [])), mine)
for at, key, doc in docs
]
qty = kind == "precombo" and len({_qty(doc) for _, _, doc in docs}) > 1
out.append({"지문": mark, "꼬리표": "수량만 다름" if qty else "내용 같음", "문서": rows})
out.sort(key=lambda g: (g["꼬리표"] != "내용 같음", g["문서"][0]["이름"]))
with store.at(folder):
판 = pre_folders.view(kind)["판"]
return {"판": 판, "묶음": out, "합친것": _gone_rows(folder, kind)}
# ── 합치기 ────────────────────────────────────────────────────────────
def merge(session: Session, folder: Path, kind: str, body: MergeBody) -> dict[str, Any]:
mine = refs.where(folder)
raw = body.남김 or {}
keep_at: refs.Here = (str(raw.get("층") or ""), raw.get("주인") or None)
keep = _ref(keep_at, str(raw.get("키") or ""))
if keep_at not in [mine, *_uppers(mine)]:
raise _bad(400, "남김은 이 층 또는 위 층 문서")
if refs.kind_of(keep["키"]) != kind or (keep_doc := refs.read_ref(keep)) is None:
raise _bad(404, f"없는 {refs.TITLE[kind]} 「{keep['키']}」")
gone = list(dict.fromkeys(str(k) for k in body.지움))
if not gone or (keep_at == mine and keep["키"] in gone):
raise _bad(400, "지울 문서를 고름 — 남김과 달라야 함")
have = dict(_docs(mine, kind))
if miss := [k for k in gone if k not in have]:
raise _bad(404, f"이 층에 없는 문서 「{' · '.join(miss)}」")
with store.at(folder):
tree_doc, 판 = pre_folders._read(kind)
if 판 != body.판:
raise _bad(409, {"stale": [pre_folders.file_of(kind)], "판": 판})
mark = _finger(keep_at, kind, keep_doc)
if mark is None or any(_finger(mine, kind, have[k]) != mark for k in gone):
raise _bad(409, "내용이 그새 달라짐 — [중복 찾기]를 다시 누름")
rids = {refs.ref_id(_ref(mine, k)): k for k in gone}
by_file: dict[Path, tuple[str, set[str]]] = {}
labels: dict[Path, str] = {}
index = _user_index()
for rid in rids:
for path, ukind, label in index.get(rid, []):
by_file.setdefault(path, (ukind, set()))[1].add(rid)
labels[path] = label
moved: dict[str, list[dict[str, Any]]] = {k: [] for k in gone}
left: dict[str, list[str]] = {k: [] for k in gone}
for path, (ukind, olds) in by_file.items():
at = refs.where(path.parent.parent)
why = ""
if not (_can_write(session, at) and refs.readable(at, keep)):
why = "권한 밖"
else:
try:
_rewrite(path, ukind, olds, keep)
except store.StoreError as error: # 같은 참조 두 번 등 — 그 쓰는 곳은 그대로 둠
why = str(error.detail)
for rid in olds:
if why:
left[rids[rid]].append(f"{labels[path]} — {why}")
else:
moved[rids[rid]].append(
{"층": at[0], "주인": at[1], "종류": ukind, "이름": path.stem}
)
places = dict(tree_doc.get("자리") or {})
done, kept = [], []
when = datetime.now().isoformat(timespec="seconds")
# ponytail: 여러 파일을 차례로 씀(잠금 없음) — 동시에 같은 층을 합치면 앞 것이 이김
for key in gone:
if left[key]:
kept.append({"키": key, "쓰는곳": left[key]})
continue
path = folder / kind / f"{key}.json"
record = {
"키": key,
"종류": kind,
"문서": json.loads(path.read_bytes()),
"자리": places.pop(key, None),
"남김": keep,
"옮긴곳": moved[key],
"때": when,
"누가": session.get("name"),
}
atomic_write_json(folder / GONE / f"{key}.json", record)
path.unlink()
done.append({"키": key, "옮긴곳수": len(moved[key])})
if done:
atomic_write_json(folder / pre_folders.file_of(kind), {**tree_doc, "자리": places})
return {"합침": done, "남김": kept, **find(folder, kind)}
def undo(session: Session, folder: Path, kind: str, key: str) -> dict[str, Any]:
"""`_합친것/<키>` 되돌림 — 문서 · 나무 자리 · 옮긴 참조(아직 남김을 가리키고 쓸 수 있는 곳)."""
mine = refs.where(folder)
rec_path = folder / GONE / f"{key}.json"
try:
rec = json.loads(rec_path.read_bytes())
except (OSError, ValueError):
raise _bad(404, f"합친 것에 없음 「{key}」") from None
if rec.get("종류") != kind:
raise _bad(404, f"합친 것에 없음 「{key}」")
path = folder / kind / f"{key}.json"
if path.exists():
raise _bad(409, f"「{key}」 키를 그새 다른 문서가 씀 — 되돌릴 수 없음")
atomic_write_json(path, rec["문서"])
with store.at(folder):
tree_doc, _ = pre_folders._read(kind)
if rec.get("자리"):
places = {**(tree_doc.get("자리") or {}), key: rec["자리"]}
atomic_write_json(folder / pre_folders.file_of(kind), {**tree_doc, "자리": places})
keep, back, miss = rec.get("남김") or {}, 0, []
new = _ref(mine, key)
# ponytail: 옮긴 뒤 그 쓰는 곳이 남김을 다른 줄에서 따로 담았으면 그 줄까지 되돌림 — 드문 꼴
for one in rec.get("옮긴곳") or []:
at = (one.get("층"), one.get("주인"))
user = refs.folder_of(*at) / str(one.get("종류")) / f"{one.get('이름')}.json"
try:
if not _can_write(session, at) or not user.is_file():
raise store.StoreError(403, "권한 밖 · 없음")
_rewrite(user, str(one.get("종류")), {refs.ref_id(keep)}, new)
back += 1
except store.StoreError as error:
miss.append(f"{one.get('종류')} {one.get('이름')} — {error.detail}")
rec_path.unlink()
return {"되돌림": key, "되돌린곳수": back, "못한곳": miss, **find(folder, kind)}
def _run(fn, *args):
try:
return fn(*args)
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/layers/{layer}/{kind}-same")
def library_same(
layer: Layer, kind: Kind, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""[중복 찾기] — 이 층 + 위 층 같은 내용 묶음 · 합친 것 목록(읽기 = 그 층 쓰기 권한)."""
return _run(find, _root(session, layer, owner, write=True), kind)
@router.post("/layers/{layer}/{kind}-same/merge")
def library_same_merge(
layer: Layer,
kind: Kind,
body: MergeBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""합치기 — 쓰는 곳 참조 → 남김 · 지운 문서 `_합친것/` · 판 · 지문 다르면 409."""
return _run(merge, session, _root(session, layer, owner, write=True), kind, body)
@router.post("/layers/{layer}/{kind}-same/undo")
def library_same_undo(
layer: Layer,
kind: Kind,
body: UndoBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""되돌림 — `_합친것/<키>` 문서 · 나무 자리 · 옮긴 참조."""
return _run(undo, session, _root(session, layer, owner, write=True), kind, body.키)
@@ -0,0 +1,30 @@
/* M02 서고 [중복 찾기](733-3) — 자리만 · 단추 · 표 · 배지 꼴은 부품 상자 */
.m02-same {
display: flex;
flex-direction: column;
gap: 16px;
}
.m02-same__note:empty {
display: none;
}
.m02-same__group {
display: flex;
flex-direction: column;
gap: 8px;
}
.m02-same__head {
display: flex;
align-items: center;
gap: 8px;
}
.m02-same__head > span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-same__group h4 {
margin: 0;
font-size: var(--text-body);
}
@@ -0,0 +1,308 @@
/* =============================================================================
* M02_MasterTemplete_Library_Same.ts
* 서고 [중복 찾기](733-3 · 요구서_572 3장 4) — 좌측 일위대가 · 단가산출 나무 메뉴에서 엶.
* 창 = 같은 내용 묶음(이 층 + 위 층 · 서버 `GET {kind}-same`) · 묶음마다 꼬리표(내용 같음 · 수량만 다름) ·
* 문서 줄(층 · 키 · 이름 · 구분 · 쓰는 곳 수) · [남김] 고르기 · [합치기](물음 → 서버 `merge` · 이 층 나머지 문서를
* 지움 · 쓰는 곳 참조를 남김으로) · 결과(합침 · 권한 밖 남김) · 합친 것 목록 [되돌림](`undo`).
* 권한 = 그 층 쓰기(서버가 봄) · 판 다르면 409 → 다시 찾음.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { askConfirm, openModal } from "@ui/ui_template_modal";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { call, StaleError, type Layer, type LibraryKind } from "./M02_MasterTemplete_Api_Fetch";
import "./M02_MasterTemplete_Library_Same.css";
const TEXT = {
Title: ["중복 찾기", "Find duplicates"],
Close: ["닫기", "Close"],
Intro: [
"내용이 같은 문서 묶음 — [남김]을 고르고 [합치기]하면 이 층의 나머지 문서를 지우고 쓰는 곳을 남김으로 옮김 · [되돌림]으로 되살림",
"Groups with the same content — pick [Keep] and [Merge]: other docs on this layer are removed and their users point to the kept one · [Undo] restores",
],
None: ["같은 내용 문서 없음", "No duplicates"],
Loading: ["찾는 중…", "Searching…"],
Same: ["내용 같음", "Same content"],
QtyOnly: ["수량만 다름", "Quantity differs"],
Layer: ["층", "Layer"],
Key: ["키", "Key"],
Name: ["이름", "Name"],
Class: ["구분", "Category"],
Used: ["쓰는 곳", "Used by"],
Keep: ["남김", "Keep"],
Kept: ["남김 ✓", "Kept ✓"],
Merge: ["합치기", "Merge"],
Upper: ["위 층", "Upper"],
system: ["마스터", "Master"],
company: ["회사", "Company"],
personal: ["개인", "Personal"],
project: ["프로젝트", "Project"],
Ask: [
"「{keep}」 하나로 합침 — 이 층 문서 {n} 개를 지우고 쓰는 곳 참조를 옮김 · 지운 문서는 [되돌림]으로 되살림",
"Merge into “{keep}” — remove {n} doc(s) on this layer and move their users · [Undo] restores",
],
AskQty: [
"⚠ 실무 수량(엑셀 규격)이 서로 다름 — 지운 문서의 실무 수량 기록은 되돌림 때까지 안 보임",
"⚠ Practice quantities differ — removed docs' quantity record is hidden until undo",
],
Done: ["합침 {n} · 옮긴 쓰는 곳 {m}", "Merged {n} · moved users {m}"],
Left: [
"남김 — 「{key}」 쓰는 곳 {n}(권한 밖 · 못 옮김): {list}",
"Kept “{key}” — {n} user(s) not moved: {list}",
],
Gone: ["합친 것(되돌림)", "Merged (undo)"],
GoneNone: ["합친 것 없음", "Nothing merged"],
When: ["때", "When"],
Into: ["남김", "Kept as"],
Undo: ["되돌림", "Undo"],
AskUndo: [
"「{key}」 되돌림 — 문서를 되살리고 옮긴 쓰는 곳 {n} 을 다시 이 문서로",
"Undo “{key}” — restore it and point its {n} user(s) back",
],
Undone: ["되돌림 — 쓰는 곳 {n}", "Undone — users {n}"],
Stale: ["그새 바뀜 — 다시 찾음", "Changed meanwhile — searching again"],
} as const;
type TextKey = keyof typeof TEXT;
const tx = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(s, [k, v]) => s.replace(`{${k}}`, String(v)),
String(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]),
);
/** 나무 메뉴 글 */
export const sameLabel = (): string => tx("Title");
export interface SameRef {
층: Layer;
주인: string | null;
키: string;
}
export interface SameDoc extends SameRef {
이름: string;
구분: string;
상세구분: string;
쓰는곳수: number;
위층: boolean;
}
export interface SameGroup {
지문: string;
꼬리표: "내용 같음" | "수량만 다름";
문서: SameDoc[];
}
export interface GoneRow {
키: string;
이름: string;
남김: SameRef;
때: string;
옮긴곳수: number;
}
export interface SameView {
판: string;
묶음: SameGroup[];
합친것: GoneRow[];
}
export interface MergeResult extends SameView {
합침: { 키: string; 옮긴곳수: number }[];
남김: { 키: string; 쓰는곳: string[] }[];
}
const path = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string =>
`/layers/${layer}/${kind}-same${tail}${owner && layer !== "system" ? `?owner=${encodeURIComponent(owner)}` : ""}`;
const post = <T>(url: string, body: unknown): Promise<T> =>
call<T>(url, { method: "POST", body: JSON.stringify(body) });
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 묶음 하나에서 남김을 고를 때 지울 것 — 이 층(위 층 아님) 문서 가운데 남김 아닌 것 */
export const goneOf = (group: SameGroup, keep: SameRef): string[] =>
group.문서
.filter((d) => !d.위층 && !(d.층 === keep.층 && d.주인 === keep.주인 && d.키 === keep.키))
.map((d) => d.키);
export interface SameOptions {
kind: LibraryKind;
layer: Layer;
owner: string | null;
/** 합치기 · 되돌림 뒤 — 좌측 나무 다시 그림 */
changed: () => void;
}
export function openLibrarySame(opt: SameOptions): void {
const host = el("div", { className: "m02-same", attrs: { "data-library-same": opt.kind } });
const note = el("div", { className: "m02-same__note" });
let view: SameView | null = null;
const load = async (): Promise<void> => {
host.replaceChildren(createEmpty({ text: tx("Loading"), small: true }));
try {
view = await call<SameView>(path(opt.kind, opt.layer, opt.owner));
} catch (error) {
host.replaceChildren(createText(why(error), "error"));
return;
}
draw();
};
const run = async (job: () => Promise<SameView>, done: (got: SameView) => void) => {
try {
const got = await job();
view = got;
done(got);
opt.changed();
draw();
} catch (error) {
showToast(
error instanceof StaleError ? `${tx("Stale")} — ${why(error)}` : why(error),
"error",
);
if (error instanceof StaleError) void load();
}
};
const merge = async (group: SameGroup, keep: SameDoc): Promise<void> => {
const gone = goneOf(group, keep);
const message = [
tx("Ask", { keep: `${keep.키} ${keep.이름}`, n: gone.length }),
...(group.꼬리표 === "수량만 다름" ? [tx("AskQty")] : []),
].join("\n");
if (!view || !gone.length) return;
if (!(await askConfirm({ message, okLabel: tx("Merge"), title: tx("Title") }))) return;
const 판 = view.판;
await run(
() =>
post<MergeResult>(path(opt.kind, opt.layer, opt.owner, "/merge"), {
남김: { 층: keep.층, 주인: keep.주인, 키: keep.키 },
지움: gone,
판,
}),
(got) => {
const res = got as MergeResult;
const moved = res.합침.reduce((n, r) => n + r.옮긴곳수, 0);
showToast(tx("Done", { n: res.합침.length, m: moved }), "success");
note.replaceChildren(
...res.남김.map((k) =>
createText(
tx("Left", { key: k.키, n: k.쓰는곳.length, list: k.쓰는곳.join(" · ") }),
"warning",
),
),
);
},
);
};
const undo = async (row: GoneRow): Promise<void> => {
const message = tx("AskUndo", { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 });
if (!(await askConfirm({ message, okLabel: tx("Undo"), title: tx("Title") }))) return;
await run(
() => post<SameView>(path(opt.kind, opt.layer, opt.owner, "/undo"), { 키: row.키 }),
(got) => {
const back = (got as SameView & { 되돌린곳수?: number }).되돌린곳수 ?? 0;
showToast(tx("Undone", { n: back }), "success");
note.replaceChildren();
},
);
};
const groupBlock = (group: SameGroup): HTMLElement => {
let keep = group.문서[0];
const columns = [
{ label: tx("Keep"), width: "88px" },
{ label: tx("Layer"), width: "88px" },
{ label: tx("Key"), width: "112px" },
{ label: tx("Name") },
{ label: tx("Class") },
{ label: tx("Used"), width: "72px", align: "end" as const },
];
const tableHost = el("div");
const paint = (): void => {
const rows: Cell[][] = group.문서.map((d) => [
createButton({
label: d === keep ? tx("Kept") : tx("Keep"),
variant: d === keep ? "filled" : "ghost",
size: "sm",
onClick: () => {
keep = d;
paint();
},
}),
d.위층 ? `${tx(d.층)} · ${tx("Upper")}` : tx(d.층),
d.키,
d.이름,
[d.구분, d.상세구분].filter(Boolean).join(" / "),
String(d.쓰는곳수),
]);
tableHost.replaceChildren(createTable(columns, rows, { density: "data" }));
mergeButton.disabled = !goneOf(group, keep).length;
};
const mergeButton = createButton({
label: tx("Merge"),
size: "sm",
onClick: () => void merge(group, keep),
});
const tag = group.꼬리표 === "수량만 다름" ? tx("QtyOnly") : tx("Same");
const head = el("div", {
className: "m02-same__head",
children: [
createBadge(tag, group.꼬리표 === "수량만 다름" ? "warning" : "info"),
el("span", { text: `${group.문서[0].이름} · ${group.문서.length}` }),
mergeButton,
],
});
paint();
return el("section", {
className: "m02-same__group",
attrs: { "data-same-group": group.지문 },
children: [head, tableHost],
});
};
const goneBlock = (rows: GoneRow[]): HTMLElement => {
const columns = [
{ label: tx("Key"), width: "112px" },
{ label: tx("Name") },
{ label: tx("Into"), width: "140px" },
{ label: tx("When"), width: "160px" },
{ label: "", width: "88px" },
];
const cells: Cell[][] = rows.map((r) => [
r.키,
r.이름,
`${tx(r.남김.층)} ${r.남김.키}`,
r.때.replace("T", " "),
createButton({
label: tx("Undo"),
variant: "ghost",
size: "sm",
onClick: () => void undo(r),
}),
]);
return el("section", {
className: "m02-same__group",
attrs: { "data-same-gone": "" },
children: [
el("h4", { text: tx("Gone") }),
createTable(columns, cells, { density: "data", empty: tx("GoneNone") }),
],
});
};
const draw = (): void => {
if (!view) return;
const groups = view.묶음.length
? view.묶음.map(groupBlock)
: [createEmpty({ text: tx("None"), small: true })];
host.replaceChildren(createText(tx("Intro"), "muted"), note, ...groups, goneBlock(view.합친것));
};
openModal({
title: tx("Title"),
closeLabel: tx("Close"),
width: "960px",
mount: (body) => body.append(host),
});
void load();
}
@@ -37,6 +37,7 @@ import {
type PrecomboTree,
} from "./M02_MasterTemplete_Api_Fetch";
import { nameDialog } from "./M02_MasterTemplete_Library_List";
import { openLibrarySame, sameLabel } from "./M02_MasterTemplete_Library_Same";
import type { Root } from "./M02_MasterTemplete_Layer";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
@@ -307,8 +308,14 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
...(got
? [item(L("M02_TreePaste").replace("{n}", String(got.refs.length)), () => void paste(path))]
: []),
...(path.length ? [] : [item(sameLabel(), () => same())]),
];
};
/** [중복 찾기](733-3) — 그 층 같은 내용 묶음 · 합치기 · 되돌림 */
const same = (): void => {
const at = opt.scope();
openLibrarySame({ kind, layer: at.layer, owner: at.owner, changed: () => void refresh() });
};
/** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(복사 · 지우기) · 아니면 그 줄 하나 */
const menuFor = (id: string, single: () => TreeMenuItem[]): TreeMenuItem[] => {
if (!picked.has(id)) setPicked(new Set([id]), id);