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