feat(가이드): 538-3 글 더블클릭 바로 고침 — 시스템 관리자만 · 판 409 · 원자 쓰기

- 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 원문 줄 textarea(높이 자동)
  · 바깥 누름 · Ctrl+Enter 저장(안 바뀌면 요청 없음) · Esc 취소 · 한글 조합 중 Enter · Esc 무시
  · 고침 열린 채 모달 닫으면 저장 · 저장 뒤 그 항목만 다시 그림
- PUT /api/help/{page}/{key} {version, start, end, text} — require_system_admin(403) · 판 다르면 409 ·
  그 줄만 갈아 임시 파일 → os.replace · 줄바꿈(CRLF) · BOM 그대로 · 409 면 다시 불러오고 내 글은 절 머리에 보여 둠
- 모달 Esc: 몸이 막은 Esc · 한글 조합 중 Esc 는 안 닫음
- 시험: 줄 바꿈 · 빈 글 지움 · CRLF/BOM · 409 · 403 · 나쁜 범위/항목 · 원자 쓰기 실패 때 원본 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 08:53:34 +09:00
co-authored by Claude Opus 5.5
parent 02f15c3d6a
commit 73bdb9bc5b
6 changed files with 297 additions and 3 deletions
+50 -1
View File
@@ -5,19 +5,24 @@
`_` 로 시작하는 파일 · 그림은 항목 아님.
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`.
바로 고침(538-3) = 시스템 관리자만 · 판이 같을 때만 그 줄 범위를 갈아 원자 쓰기 · 다르면 409.
"""
from __future__ import annotations
import codecs
import hashlib
import re
import threading
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
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 +110,47 @@ 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)}
+74 -1
View File
@@ -88,7 +88,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 +130,76 @@ 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"
] # 임시 파일 남지 않음
+27
View File
@@ -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;
}
+5
View File
@@ -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));
+136
View File
@@ -0,0 +1,136 @@
/* =============================================================================
* ui_template_help_edit.ts
* [?] 사용자 가이드 바로 고침(538-3) — 시스템 관리자만(서버도 시스템 관리자만 받음).
* 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 덩어리 원문 마크다운 textarea 로 바꿔 끼움.
* 바깥 누름 · Ctrl+Enter = 저장(안 바뀌었으면 요청 안 함) · Esc = 취소 · 한글 조합 중 Enter · Esc 무시 ·
* 고침이 열린 채 모달을 닫으면 저장(`flush`).
* 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 ·
* 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠.
* ========================================================================== */
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: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음",
};
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();
};
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 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;
},
};
}
+5 -1
View File
@@ -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);