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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user