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)}