diff --git a/M02_MasterTemplete/M02_Library_Same_Router.py b/M02_MasterTemplete/M02_Library_Same_Router.py new file mode 100644 index 000000000..0d0012d16 --- /dev/null +++ b/M02_MasterTemplete/M02_Library_Same_Router.py @@ -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.키) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_Same.css b/M02_MasterTemplete/M02_MasterTemplete_Library_Same.css new file mode 100644 index 000000000..1250994b8 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_Same.css @@ -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); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts new file mode 100644 index 000000000..29e286a67 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts @@ -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 => + 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 = (url: string, body: unknown): Promise => + call(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 => { + host.replaceChildren(createEmpty({ text: tx("Loading"), small: true })); + try { + view = await call(path(opt.kind, opt.layer, opt.owner)); + } catch (error) { + host.replaceChildren(createText(why(error), "error")); + return; + } + draw(); + }; + + const run = async (job: () => Promise, 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 => { + 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(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 => { + const message = tx("AskUndo", { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 }); + if (!(await askConfirm({ message, okLabel: tx("Undo"), title: tx("Title") }))) return; + await run( + () => post(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(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts index 7bb16a460..c355ce634 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -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);