Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_7
This commit is contained in:
@@ -33,6 +33,8 @@
|
||||
단계 = 아래 열쇠 중 하나 + 덧붙임
|
||||
{"click": "<css>"} 누름 · {"click": …, "confirm": true} = 확인창만 여는 위험 단추(아래)
|
||||
{"fill": "<css>", "value": "글"} 칸 채움
|
||||
{"select": "<css>", "value": "값"} 고르기 칸(<select>) 값 고름(option value 또는 글)
|
||||
{"scroll": "<css>"} 그 요소가 화면에 들어오게 스크롤
|
||||
{"hover": "<css>"} 마우스 올림(툴팁)
|
||||
{"contextmenu": "<css>"} 오른쪽 누름(누름 메뉴 열기)
|
||||
{"press": "Escape"} 키 · {"press": "Enter", "sel": "<css>"} = 그 요소에서
|
||||
@@ -49,6 +51,9 @@
|
||||
- 확인창을 찍을 때만 `"confirm": true` 로 그 단추를 누름 — 그 장면은 after 에 누름 단계가 있어야 함([취소])
|
||||
- 페이지의 /api 쓰기 요청(PUT · PATCH · DELETE · allow_post 밖 POST)은 모두 끊음 · 끊은 요청은 표에 적음
|
||||
- 브라우저 확인창(window.confirm)은 늘 취소 · 새 컨텍스트라 사용자 브라우저 · 저장소와 안 섞임
|
||||
- 장면마다 새 탭 · sessionStorage 비우고 시작(앞 장면 조작이 안 남음) · localStorage 는 프로젝트 · 테마 ·
|
||||
말만 세움
|
||||
- clip · callouts 대상은 찍기 전 화면 안으로 스크롤(clip 먼저 · 번호 차례) 뒤 자리를 잼
|
||||
- `_manual.json`(관리자가 손으로 바꾼 그림 · 이름 목록 또는 이름 열쇠 사전) 이름은 건너뜀(--force 면 찍음)
|
||||
|
||||
3D(WebGL) · 지도(카카오) · CAD(iframe)
|
||||
@@ -80,7 +85,9 @@ STEP_TIMEOUT = 15000
|
||||
SETTLE_MS = 500
|
||||
CLIP_PAD = 16
|
||||
PROJECT_KEYS = ("road", "stab", "plan", "none")
|
||||
STEP_KINDS = ("click", "contextmenu", "fill", "hover", "press", "eval", "wait", "wait_ms")
|
||||
STEP_KINDS = (
|
||||
"click", "contextmenu", "fill", "select", "hover", "press", "eval", "wait", "wait_ms", "scroll",
|
||||
) # fmt: skip
|
||||
SCENE_KEYS = {
|
||||
"name", "route", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
|
||||
"callouts", "after", "allow_post",
|
||||
@@ -96,6 +103,7 @@ DANGER_RE = re.compile(
|
||||
SAFE_RE = re.compile(r"취소|닫기|\b(cancel|close)", re.I)
|
||||
THEME_KEY, LANG_KEY, PROJECT_KEY = "frd_theme", "frd_lang", "frd_current_project_id"
|
||||
CALLOUT_CLASS = "__guide_callout"
|
||||
SEEDED = "__guide_seeded" # 탭 이름(window.name)에 남겨 첫 적재를 가림
|
||||
YELLOW = "#FFD400"
|
||||
RING_PAD, DOT = 4, 28 # 테두리 바깥 여백 · 번호 원 지름(CSS px)
|
||||
|
||||
@@ -142,8 +150,8 @@ def _check_steps(steps: Any, where: str) -> tuple[list[str], bool]:
|
||||
if not isinstance(val, str) or not val.strip():
|
||||
errs.append(f"{at} {kind} 값이 빈 글")
|
||||
continue
|
||||
if kind == "fill" and not isinstance(st.get("value"), str):
|
||||
errs.append(f"{at} fill 은 value(글) 필요")
|
||||
if kind in ("fill", "select") and not isinstance(st.get("value"), str):
|
||||
errs.append(f"{at} {kind} 은 value(글) 필요")
|
||||
if st.get("confirm"):
|
||||
if kind != "click":
|
||||
errs.append(f"{at} confirm 은 click 에만")
|
||||
@@ -356,6 +364,8 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
|
||||
page.wait_for_timeout(st["wait_ms"])
|
||||
elif "wait" in st:
|
||||
_locate(page, st["wait"], frame).wait_for(state="visible", timeout=timeout)
|
||||
elif "scroll" in st:
|
||||
_locate(page, st["scroll"], frame).scroll_into_view_if_needed(timeout=timeout)
|
||||
elif "eval" in st:
|
||||
target = page.frame_locator(frame).locator(":root") if frame else page
|
||||
target.evaluate(f"() => ({st['eval']})")
|
||||
@@ -365,7 +375,7 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
|
||||
else:
|
||||
page.keyboard.press(st["press"])
|
||||
else:
|
||||
kind = next(k for k in ("click", "contextmenu", "fill", "hover") if k in st)
|
||||
kind = next(k for k in ("click", "contextmenu", "fill", "select", "hover") if k in st)
|
||||
loc = _locate(page, st[kind], frame)
|
||||
loc.wait_for(state="visible", timeout=timeout)
|
||||
if kind == "click":
|
||||
@@ -377,6 +387,8 @@ def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
|
||||
loc.click(button="right", timeout=timeout)
|
||||
elif kind == "fill":
|
||||
loc.fill(st["value"], timeout=timeout)
|
||||
elif kind == "select":
|
||||
loc.select_option(st["value"], timeout=timeout)
|
||||
else:
|
||||
loc.hover(timeout=timeout)
|
||||
|
||||
@@ -396,7 +408,7 @@ def _box(page: Any, sel: str, frame: str | None, what: str) -> dict[str, float]:
|
||||
def shoot_scene(
|
||||
context: Any, base: str, scene: dict[str, Any], pid: str | None
|
||||
) -> tuple[bytes, list[str]]:
|
||||
"""장면 하나 → (PNG 2배, 끊은 쓰기 요청). 새 탭마다 sessionStorage 새로."""
|
||||
"""장면 하나 → (PNG 2배, 끊은 쓰기 요청). 새 탭 · 첫 적재 때 sessionStorage 비움."""
|
||||
vw, vh = scene.get("viewport") or VIEWPORT
|
||||
allow = scene.get("allow_post") or []
|
||||
blocked: list[str] = []
|
||||
@@ -406,7 +418,10 @@ def shoot_scene(
|
||||
seed = json.dumps([THEME_KEY, LANG_KEY, PROJECT_KEY, pid])
|
||||
page.add_init_script(
|
||||
f"(([t, l, k, pid]) => {{ localStorage.setItem(t, 'light'); localStorage.setItem(l, 'ko');"
|
||||
f" pid ? localStorage.setItem(k, pid) : localStorage.removeItem(k); }})({seed});"
|
||||
f" pid ? localStorage.setItem(k, pid) : localStorage.removeItem(k);"
|
||||
# 첫 적재만 비움 — 장면 안 새로고침 · 이동은 그 장면 조작을 지킴
|
||||
f" if (!window.name.includes('{SEEDED}')) {{ sessionStorage.clear();"
|
||||
f" window.name += '{SEEDED}'; }} }})({seed});"
|
||||
)
|
||||
page.on("dialog", lambda d: d.dismiss())
|
||||
|
||||
@@ -431,6 +446,15 @@ def shoot_scene(
|
||||
run_steps(page, scene.get("before") or [])
|
||||
page.wait_for_timeout(scene.get("settle_ms", SETTLE_MS))
|
||||
|
||||
# 찍을 대상을 화면 안으로 — clip 먼저 · 번호 차례(어긋난 번호 · 잘린 그림 막음)
|
||||
targets = [(c["sel"], c.get("frame")) for c in scene.get("callouts") or []]
|
||||
if scene.get("clip"):
|
||||
targets.insert(0, (scene["clip"], None))
|
||||
for sel, frame in targets:
|
||||
try:
|
||||
_locate(page, sel, frame).scroll_into_view_if_needed(timeout=5000)
|
||||
except Exception:
|
||||
pass # 안 보이면 아래 _box 가 까닭을 냄
|
||||
items, boxes = [], []
|
||||
for c in scene.get("callouts") or []:
|
||||
ring, dot = callout_boxes(_box(page, c["sel"], c.get("frame"), f"번호 {c['n']}"),
|
||||
|
||||
@@ -5,19 +5,30 @@
|
||||
`_` 로 시작하는 파일 · 그림은 항목 아님.
|
||||
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
|
||||
그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`.
|
||||
바로 고침(538-3) = 시스템 관리자만 · 판이 같을 때만 그 줄 범위를 갈아 원자 쓰기 · 다르면 409.
|
||||
그림 바꾸기(538-4) = 시스템 관리자만 · png · jpg · webp · 5 MB · Pillow 확인 · 폭 1800 px 로 줄임 →
|
||||
WebP 로 그 이름에 덮음 · `_manual.json`(손으로 바꾼 그림 이름 — 자동 캡처가 안 덮음)에 더함.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image, ImageOps
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_atomic import atomic_write_bytes
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
|
||||
router = APIRouter(prefix="/api/help", tags=["help"])
|
||||
|
||||
@@ -105,3 +116,99 @@ def get_image(page: str, name: str) -> FileResponse:
|
||||
with path.open("rb") as f: # 올린 그림은 이름과 달리 WebP — 머리 글자로 가름
|
||||
media = MEDIA.get(f.read(4), "image/jpeg")
|
||||
return FileResponse(path, media_type=media, headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
class HelpEdit(BaseModel):
|
||||
version: str = Field(max_length=64)
|
||||
start: int = Field(ge=0)
|
||||
end: int = Field(ge=0)
|
||||
text: str = Field(max_length=200_000)
|
||||
|
||||
|
||||
# ponytail: 서버 한 벌 안 잠금 — 판 확인 ~ 쓰기 사이 경합만 막음 · 서버 여러 벌이면 파일 잠금
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
|
||||
def item_path(page: str, key: str, root: Path = HELP_ROOT) -> Path:
|
||||
folder = page_folder(page, root)
|
||||
bad = not key or key.startswith((".", "_")) or any(c in key for c in "/\\")
|
||||
path = folder / f"{key}.md"
|
||||
if bad or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="없는 항목")
|
||||
return path
|
||||
|
||||
|
||||
def replace_lines(page: str, key: str, edit: HelpEdit, root: Path = HELP_ROOT) -> dict[str, str]:
|
||||
"""판이 같을 때만 start~end 줄(0부터 · 끝 포함)을 글로 갈아 끼움 — 빈 글 = 그 줄 지움."""
|
||||
path = item_path(page, key, root)
|
||||
with _write_lock:
|
||||
blob = path.read_bytes()
|
||||
if file_version(blob) != edit.version:
|
||||
raise HTTPException(status_code=409, detail="다른 관리자가 먼저 고침")
|
||||
text = blob.decode("utf-8-sig")
|
||||
newline = "\r\n" if "\r\n" in text else "\n"
|
||||
lines = text.split(newline)
|
||||
if edit.start > edit.end or edit.end >= len(lines):
|
||||
raise HTTPException(status_code=400, detail="줄 범위 틀림")
|
||||
new = edit.text.replace("\r\n", "\n").split("\n") if edit.text else []
|
||||
lines[edit.start : edit.end + 1] = new
|
||||
out = newline.join(lines).encode("utf-8")
|
||||
atomic_write_bytes(path, codecs.BOM_UTF8 + out if blob.startswith(codecs.BOM_UTF8) else out)
|
||||
return next(item for item in read_help(page, root) if item["key"] == key)
|
||||
|
||||
|
||||
@router.put("/{page}/{key}", dependencies=[Depends(require_system_admin)])
|
||||
def put_item(page: str, key: str, edit: HelpEdit) -> dict[str, dict[str, str]]:
|
||||
return {"item": replace_lines(page, key, edit, HELP_ROOT)}
|
||||
|
||||
|
||||
MAX_IMG_BYTES = 5 * 1024 * 1024
|
||||
MAX_IMG_WIDTH = 1800
|
||||
UPLOAD_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
|
||||
|
||||
def to_webp(blob: bytes) -> bytes:
|
||||
if not blob or len(blob) > MAX_IMG_BYTES:
|
||||
raise HTTPException(status_code=400, detail="그림은 5 MB 이하")
|
||||
try:
|
||||
with Image.open(io.BytesIO(blob)) as raw:
|
||||
if raw.format not in {"PNG", "JPEG", "MPO", "WEBP"}: # MPO = 사진기 JPEG
|
||||
raise HTTPException(status_code=400, detail="png · jpg · webp 그림만")
|
||||
img = ImageOps.exif_transpose(raw)
|
||||
if img.width > MAX_IMG_WIDTH:
|
||||
img = img.resize((MAX_IMG_WIDTH, round(img.height * MAX_IMG_WIDTH / img.width)))
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert(
|
||||
"RGBA" if "transparency" in img.info or "A" in img.mode else "RGB"
|
||||
)
|
||||
out = io.BytesIO()
|
||||
img.save(out, "WEBP", quality=90)
|
||||
except (OSError, ValueError, Image.DecompressionBombError) as error:
|
||||
raise HTTPException(status_code=400, detail="그림 파일 아님") from error
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def save_image(page: str, name: str, blob: bytes, root: Path = HELP_ROOT) -> str:
|
||||
path = image_path(page, name, root)
|
||||
if not path.parent.is_dir():
|
||||
raise HTTPException(status_code=404, detail="없는 페이지")
|
||||
webp = to_webp(blob)
|
||||
manual = path.parent / "_manual.json"
|
||||
with _write_lock:
|
||||
atomic_write_bytes(path, webp)
|
||||
names = set(json.loads(manual.read_text(encoding="utf-8"))) if manual.is_file() else set()
|
||||
names.add(name)
|
||||
text = json.dumps(sorted(names), ensure_ascii=False, indent=2) + "\n"
|
||||
atomic_write_bytes(manual, text.encode("utf-8"))
|
||||
return image_version(path)
|
||||
|
||||
|
||||
@router.post("/{page}/img", dependencies=[Depends(require_system_admin)])
|
||||
async def post_image(
|
||||
page: str, name: str = Form(...), file: UploadFile = File(...)
|
||||
) -> dict[str, str]:
|
||||
if Path(file.filename or "").suffix.lower() not in UPLOAD_SUFFIXES:
|
||||
raise HTTPException(status_code=400, detail="png · jpg · webp 그림만")
|
||||
blob = await file.read(MAX_IMG_BYTES + 1)
|
||||
version = await run_in_threadpool(save_image, page, name, blob, HELP_ROOT)
|
||||
return {"name": name, "version": version}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -88,7 +89,7 @@ def test_html_은_먼저_막음(html) -> None:
|
||||
def root(monkeypatch):
|
||||
path = Path(tempfile.mkdtemp())
|
||||
(path / "B05").mkdir()
|
||||
(path / "B05" / "00_설명서.md").write_text("# 종단\n\n첫 문단\n", encoding="utf-8")
|
||||
(path / "B05" / "00_설명서.md").write_bytes("# 종단\n\n첫 문단\n".encode())
|
||||
monkeypatch.setattr(help_router, "HELP_ROOT", path)
|
||||
yield path
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
@@ -130,3 +131,143 @@ def test_그림_내려주기_no_cache(root) -> None:
|
||||
def test_그림_404(root, url) -> None:
|
||||
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
|
||||
assert client().get(url).status_code == 404
|
||||
|
||||
|
||||
# ── 538-3 글 바로 고침 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def put(c: TestClient, version: str, start: int, end: int, text: str, key: str = "00_설명서"):
|
||||
body = {"version": version, "start": start, "end": end, "text": text}
|
||||
return c.put(f"/api/help/B05/{key}", json=body)
|
||||
|
||||
|
||||
def version_of(root: Path) -> str:
|
||||
return help_router.file_version((root / "B05" / "00_설명서.md").read_bytes())
|
||||
|
||||
|
||||
def test_그_줄만_갈고_새_판(root) -> None:
|
||||
old = version_of(root)
|
||||
res = put(client(), old, 2, 2, "고친 문단\n둘째 줄")
|
||||
assert res.status_code == 200
|
||||
item = res.json()["item"]
|
||||
text = (root / "B05" / "00_설명서.md").read_bytes().decode()
|
||||
assert text == "# 종단\n\n고친 문단\n둘째 줄\n" == item["text"]
|
||||
assert item["version"] == version_of(root) != old
|
||||
assert put(client(), item["version"], 2, 3, "").status_code == 200 # 빈 글 = 그 줄 지움
|
||||
assert (root / "B05" / "00_설명서.md").read_bytes().decode() == "# 종단\n\n"
|
||||
|
||||
|
||||
def test_crlf_bom_그대로(root) -> None:
|
||||
path = root / "B05" / "00_설명서.md"
|
||||
path.write_bytes(b"\xef\xbb\xbf# A\r\n\r\nB\r\n")
|
||||
assert put(client(), version_of(root), 2, 2, "C").status_code == 200
|
||||
assert path.read_bytes() == b"\xef\xbb\xbf# A\r\n\r\nC\r\n"
|
||||
|
||||
|
||||
def test_판_다르면_409_파일_그대로(root) -> None:
|
||||
before = (root / "B05" / "00_설명서.md").read_bytes()
|
||||
assert put(client(), "0" * 16, 2, 2, "남의 글").status_code == 409
|
||||
assert (root / "B05" / "00_설명서.md").read_bytes() == before
|
||||
|
||||
|
||||
def test_시스템_관리자만_403(root) -> None:
|
||||
before = (root / "B05" / "00_설명서.md").read_bytes()
|
||||
for role in ["USER", "COMPANY_ADMIN"]:
|
||||
assert put(client(role), version_of(root), 2, 2, "x").status_code == 403
|
||||
assert (root / "B05" / "00_설명서.md").read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("start", "end", "key", "code"),
|
||||
[
|
||||
(3, 2, "00_설명서", 400),
|
||||
(0, 9, "00_설명서", 400),
|
||||
(0, 0, "없음", 404),
|
||||
(0, 0, "_manual", 404),
|
||||
(0, 0, "..%2F..%2FCLAUDE", 404),
|
||||
],
|
||||
)
|
||||
def test_나쁜_범위_항목(root, start, end, key, code) -> None:
|
||||
assert put(client(), version_of(root), start, end, "x", key).status_code == code
|
||||
|
||||
|
||||
def test_원자_쓰기_실패해도_원본_그대로(root, monkeypatch) -> None:
|
||||
before = (root / "B05" / "00_설명서.md").read_bytes()
|
||||
|
||||
def boom(*_args):
|
||||
raise OSError("쓰기 실패")
|
||||
|
||||
monkeypatch.setattr("common_util.common_util_atomic.os.replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
put(client(), version_of(root), 2, 2, "반쯤")
|
||||
assert (root / "B05" / "00_설명서.md").read_bytes() == before
|
||||
assert sorted(p.name for p in (root / "B05").iterdir()) == [
|
||||
"00_설명서.md"
|
||||
] # 임시 파일 남지 않음
|
||||
|
||||
|
||||
# ── 538-4 그림 바꾸기 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def png(width: int = 40, height: int = 20) -> bytes:
|
||||
from PIL import Image
|
||||
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", (width, height), (200, 30, 30)).save(out, "PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def upload(c: TestClient, name: str, blob: bytes, filename: str = "고른.png", page: str = "B05"):
|
||||
return c.post(
|
||||
f"/api/help/{page}/img",
|
||||
data={"name": name},
|
||||
files={"file": (filename, blob, "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
def test_그림_webp_로_덮고_manual_에_적음(root) -> None:
|
||||
from PIL import Image
|
||||
|
||||
(root / "B05" / "full.webp").write_bytes(b"old")
|
||||
res = upload(client(), "full.webp", png(3000, 1500))
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
with Image.open(root / "B05" / "full.webp") as img:
|
||||
assert (img.format, img.size) == ("WEBP", (1800, 900)) # 폭 1800 넘으면 줄임
|
||||
assert body == {
|
||||
"name": "full.webp",
|
||||
"version": help_router.image_version(root / "B05" / "full.webp"),
|
||||
}
|
||||
assert upload(client(), "new_one.webp", png(), "a.jpg").status_code == 200 # 새 이름도 받음
|
||||
manual = json.loads((root / "B05" / "_manual.json").read_text(encoding="utf-8"))
|
||||
assert manual == ["full.webp", "new_one.webp"]
|
||||
assert [i["key"] for i in client().get("/api/help").json()["pages"][0]["items"]] == [
|
||||
"00_설명서"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "blob", "filename", "code"),
|
||||
[
|
||||
("full.webp", png(), "고른.gif", 400), # 확장자
|
||||
("full.webp", b"not an image at all", "고른.png", 400), # 그림 아님
|
||||
("full.webp", b"", "고른.png", 400),
|
||||
("../full.webp", png(), "고른.png", 404), # 경로 탈출
|
||||
("full.gif", png(), "고른.png", 404),
|
||||
("한글.webp", png(), "고른.png", 404),
|
||||
],
|
||||
)
|
||||
def test_그림_거절(root, name, blob, filename, code) -> None:
|
||||
assert upload(client(), name, blob, filename).status_code == code
|
||||
assert sorted(p.name for p in (root / "B05").iterdir()) == ["00_설명서.md"]
|
||||
|
||||
|
||||
def test_그림_5MB_넘으면_거절(root, monkeypatch) -> None:
|
||||
monkeypatch.setattr(help_router, "MAX_IMG_BYTES", 1000)
|
||||
assert upload(client(), "full.webp", png(400, 400) + b"\0" * 2000).status_code == 400
|
||||
|
||||
|
||||
def test_그림_시스템_관리자만_403(root) -> None:
|
||||
assert upload(client("USER"), "full.webp", png()).status_code == 403
|
||||
assert upload(client(), "full.webp", png(), page="..%2FB05").status_code == 404
|
||||
assert not (root / "B05" / "full.webp").exists()
|
||||
|
||||
@@ -28,7 +28,14 @@ def _folder(scenes, manual=None) -> Path:
|
||||
|
||||
def test_good_scene_passes():
|
||||
sc = _scene(
|
||||
before=[{"click": ".card"}, {"wait": ".modal"}, {"wait_ms": 300}, {"hover": ".tip"}],
|
||||
before=[
|
||||
{"click": ".card"},
|
||||
{"wait": ".modal"},
|
||||
{"wait_ms": 300},
|
||||
{"hover": ".tip"},
|
||||
{"select": "#kind", "value": "plan"},
|
||||
{"scroll": ".far"},
|
||||
],
|
||||
clip=".modal",
|
||||
callouts=[{"n": 1, "sel": ".a"}, {"n": 2, "sel": ".b", "pos": "br"}],
|
||||
after=[{"press": "Escape"}],
|
||||
@@ -57,6 +64,8 @@ def test_name_rule(name):
|
||||
{"callouts": [{"n": 1, "sel": ".a", "pos": "mid"}]},
|
||||
{"before": [{"click": ".a", "wait": ".b"}]},
|
||||
{"before": [{"fill": ".a"}]},
|
||||
{"before": [{"select": ".a"}]},
|
||||
{"before": [{"scroll": ""}]},
|
||||
{"before": [{"jump": ".a"}]},
|
||||
{"allow_post": ["projects"]},
|
||||
{"extra": 1},
|
||||
|
||||
@@ -185,3 +185,30 @@
|
||||
.ui-help__text ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
/* 바로 고침(538-3) — 시스템 관리자만 · 덩어리 둘레 점선 · 원문 글 상자 */
|
||||
.ui-help__text.is-editable [data-src]:hover {
|
||||
outline: 1px dashed var(--color-border);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ui-help__edit {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ui-help__mine {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-warning);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import "./ui_template_help.css";
|
||||
import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend";
|
||||
import { createButton, el } from "./ui_template_elements";
|
||||
import { attachHelpEditing } from "./ui_template_help_edit";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
|
||||
export interface HelpItem {
|
||||
@@ -306,6 +307,10 @@ async function openHelp(): Promise<void> {
|
||||
ev.preventDefault();
|
||||
goTo(goto.dataset.helpGoto ?? "");
|
||||
});
|
||||
if (guide.admin)
|
||||
flush = attachHelpEditing(article, pages, (page, item) =>
|
||||
renderHelpItem(page, item, true),
|
||||
).flush;
|
||||
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
|
||||
// 모달이 붙은 뒤 이동 — mount 는 붙기 전
|
||||
requestAnimationFrame(() => goTo(start));
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* =============================================================================
|
||||
* ui_template_help_edit.ts
|
||||
* [?] 사용자 가이드 바로 고침(538-3) — 시스템 관리자만(서버도 시스템 관리자만 받음).
|
||||
* 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 덩어리 원문 마크다운 textarea 로 바꿔 끼움.
|
||||
* 바깥 누름 · Ctrl+Enter = 저장(안 바뀌었으면 요청 안 함) · Esc = 취소 · 한글 조합 중 Enter · Esc 무시 ·
|
||||
* 고침이 열린 채 모달을 닫으면 저장(`flush`).
|
||||
* 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 ·
|
||||
* 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠.
|
||||
* 그림 바꾸기(538-4) = 그림(또는 빈 그림 자리) 더블클릭 → 파일 고르기 → `POST /api/help/{page}/img`
|
||||
* (그 그림 이름에 WebP 로 덮음) → 새 판으로 그 항목만 다시 그림. 그림 한 번 누름 = 아무 일 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el, showToast } from "./ui_template_elements";
|
||||
import type { HelpGuide, HelpItem, HelpPage } from "./ui_template_help";
|
||||
|
||||
const TEXT = {
|
||||
conflict: "다른 관리자가 먼저 고침 — 다시 불러옴",
|
||||
failed: "저장 못 함 — 다시 불러옴",
|
||||
mine: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음",
|
||||
imgFailed: "그림 못 바꿈",
|
||||
};
|
||||
|
||||
type Render = (page: HelpPage, item: HelpItem) => string;
|
||||
|
||||
export function attachHelpEditing(
|
||||
article: HTMLElement,
|
||||
pages: HelpPage[],
|
||||
render: Render,
|
||||
): { flush: () => Promise<boolean> } {
|
||||
let open: (() => Promise<void>) | null = null;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
article.classList.add("is-editable");
|
||||
|
||||
const find = (section: HTMLElement): { page: HelpPage; item: HelpItem } | null => {
|
||||
const page = pages.find((p) => p.page === section.dataset.page);
|
||||
const item = page?.items.find((i) => i.key === section.dataset.key);
|
||||
return page && item ? { page, item } : null;
|
||||
};
|
||||
|
||||
/** 서버의 지금 글로 그 항목 · 그림 판을 바꿔 둠 */
|
||||
const reload = async (page: HelpPage, item: HelpItem): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE_URL}/help`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const fresh = ((await response.json()) as HelpGuide).pages.find((p) => p.page === page.page);
|
||||
const freshItem = fresh?.items.find((i) => i.key === item.key);
|
||||
if (fresh) page.images = fresh.images;
|
||||
if (freshItem) Object.assign(item, freshItem);
|
||||
};
|
||||
|
||||
const keepMine = (section: HTMLElement, text: string): void => {
|
||||
const area = el("textarea", { className: "ui-help__edit", attrs: { readonly: "" } });
|
||||
area.value = text;
|
||||
section.prepend(el("div", { className: "ui-help__mine", children: [TEXT.mine, area] }));
|
||||
};
|
||||
|
||||
const edit = (section: HTMLElement, block: HTMLElement): void => {
|
||||
const found = find(section);
|
||||
if (!found) return;
|
||||
const { page, item } = found;
|
||||
const [start, end] = (block.dataset.src ?? "").split("-").map(Number);
|
||||
const before = item.text
|
||||
.split(/\r?\n/)
|
||||
.slice(start, end + 1)
|
||||
.join("\n");
|
||||
const area = el("textarea", { className: "ui-help__edit" });
|
||||
area.value = before;
|
||||
const fit = (): void => {
|
||||
area.style.height = "auto";
|
||||
area.style.height = `${area.scrollHeight + 2}px`;
|
||||
};
|
||||
let done = false;
|
||||
const finish = async (keep: boolean): Promise<void> => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
open = null;
|
||||
const text = area.value;
|
||||
if (!keep || text === before) return void area.replaceWith(block);
|
||||
area.disabled = true;
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/help/${page.page}/${encodeURIComponent(item.key)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ version: item.version, start, end, text }),
|
||||
},
|
||||
).catch(() => null);
|
||||
if (response?.ok) {
|
||||
Object.assign(item, ((await response.json()) as { item: HelpItem }).item);
|
||||
section.innerHTML = render(page, item);
|
||||
return;
|
||||
}
|
||||
showToast(response?.status === 409 ? TEXT.conflict : TEXT.failed, "warning");
|
||||
await reload(page, item).catch(() => undefined);
|
||||
section.innerHTML = render(page, item);
|
||||
keepMine(section, text);
|
||||
};
|
||||
area.addEventListener("input", fit);
|
||||
area.addEventListener("keydown", (ev) => {
|
||||
if (ev.isComposing || ev.keyCode === 229) return;
|
||||
if (ev.key === "Escape") {
|
||||
ev.preventDefault(); // 모달 Esc 닫기도 막음
|
||||
ev.stopPropagation();
|
||||
void finish(false);
|
||||
} else if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
pending = finish(true);
|
||||
}
|
||||
});
|
||||
area.addEventListener("blur", () => (pending = finish(true)));
|
||||
open = () => (pending = finish(true));
|
||||
block.replaceWith(area);
|
||||
fit();
|
||||
area.focus();
|
||||
};
|
||||
|
||||
// 그림 고르기 — 하나를 돌려 씀 · 고른 뒤 값 비움(같은 파일 다시 골라도 change)
|
||||
const picker = el("input", {
|
||||
attrs: { type: "file", accept: "image/png,image/jpeg,image/webp" },
|
||||
});
|
||||
let pickFor: { section: HTMLElement; name: string } | null = null;
|
||||
picker.addEventListener("change", async () => {
|
||||
const file = picker.files?.[0];
|
||||
picker.value = "";
|
||||
const found = pickFor && find(pickFor.section);
|
||||
if (!file || !pickFor || !found) return;
|
||||
const { section, name } = pickFor;
|
||||
const form = new FormData();
|
||||
form.append("name", name);
|
||||
form.append("file", file);
|
||||
const response = await fetch(`${API_BASE_URL}/help/${found.page.page}/img`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
}).catch(() => null);
|
||||
const result = (await response?.json().catch(() => null)) as {
|
||||
version?: string;
|
||||
detail?: string;
|
||||
} | null;
|
||||
if (!response?.ok || !result?.version)
|
||||
return void showToast(result?.detail ?? TEXT.imgFailed, "error");
|
||||
found.page.images = { ...found.page.images, [name]: result.version };
|
||||
section.innerHTML = render(found.page, found.item);
|
||||
});
|
||||
|
||||
article.addEventListener("dblclick", (ev) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
const section = target.closest<HTMLElement>(".ui-help__item");
|
||||
if (!section || open || target.closest("textarea, .ui-help__mine")) return;
|
||||
const figure = target.closest<HTMLElement>("figure[data-img]");
|
||||
if (figure && !target.closest("figcaption")) {
|
||||
pickFor = { section, name: figure.dataset.img ?? "" };
|
||||
return void picker.click();
|
||||
}
|
||||
const block = target.closest<HTMLElement>("[data-src]");
|
||||
if (!block || !section.contains(block)) return;
|
||||
ev.preventDefault();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
edit(section, block);
|
||||
});
|
||||
|
||||
return {
|
||||
flush: async () => {
|
||||
if (open) await open();
|
||||
await pending;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -55,7 +55,11 @@ export function openModal(opts: ModalOptions): { close: () => void } {
|
||||
backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop));
|
||||
backdrop.addEventListener("pointerup", (ev) => (outside &&= ev.target === backdrop));
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && outside && void ask());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask());
|
||||
// 몸이 막은 Esc(글 고침 취소) · 한글 조합 중 Esc 는 안 닫음
|
||||
backdrop.addEventListener(
|
||||
"keydown",
|
||||
(ev) => ev.key === "Escape" && !ev.defaultPrevented && !ev.isComposing && void ask(),
|
||||
);
|
||||
opts.mount(body, close, actions);
|
||||
// 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)
|
||||
if (actions.childElementCount) closeButton.before(actions);
|
||||
|
||||
Reference in New Issue
Block a user