0.9.0 까지가 "감지하는 시스템"이었다면 1.0.0 은 "감지에 대응하는 시스템"이다. 그 차이를
만드는 것은 e00ad69 의 통보 경로이고, 이 커밋은 그것을 실제로 납품 가능한 형태로 만든다.
VERSION 0.9.0 -> 1.0.0
릴리스 규칙 위반 3건 수정 (rules/release-versioning.md)
- 산출물 파일명에 버전·날짜가 없었다. move-detect-cafe24.zip / 2_pi_install-<arch>.tar.gz
는 같은 날 두 번 빌드하면 구분이 안 되고, 현장이 무엇을 돌리는지 파일명으로 답할 수
없었다. 이제 move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip /
move-detect-pi-<VERSION>-<YYYYMMDD>-<arch>-<abi>-<hailo>.tar.gz.
- 두 빌드 스크립트가 VERSION 파일이 없으면 "unknown" 으로 넘어갔다. 무버전 산출물은
빌드머신을 떠나는 순간 커밋과 대조할 방법이 사라진다 — 이제 exit 1 로 거부한다.
- deploy/README.md 의 stale 파일명 표기 갱신.
납품 동봉물 (skill delivery-audit: "설명서 + 점검 리포트 동봉")
- CHANGELOG.md 신설. 업그레이드 절차와 알려진 제약(모델 미확보 3종)을 명시.
- 역할별 사용 설명서를 패키지에 동봉. 운영자/안전관리자/시스템관리자 3역할 14절 + FAQ 6항,
실제 화면 캡처 8장을 base64 로 박은 단일 HTML(665KB). 폐쇄망에서 메일·USB 로 돌아다녀도
도판이 깨지지 않아야 하므로 단일 파일이다.
생성물은 커밋하지 않는다 — 소스(manual.json + shots/)만 두고 패키저가 매번 빌드한다.
커밋된 사본은 화면이 바뀐 뒤에도 최신처럼 보이고, 구 UI 캡처가 남은 매뉴얼은 납품물
자체의 결함이다.
- 감사 리포트를 '납품점검-리포트.md' 로 동봉. 무엇을 점검했고 무엇이 잔여인지 패키지가
스스로 답하지 못하면, 인수인계 몇 달 뒤 그 답을 아는 사람이 남지 않는다.
README-UPLOAD 에 크론 안내 신설 (누락이었다)
설치 안내서에 자동 실행 등록이 아예 없었다. 이 릴리스의 핵심인 통보가 크론 없이는 한 건도
나가지 않는데, 운영자가 읽는 유일한 문서가 그 사실을 말하지 않았다.
8번(알림 켜기: 크론 2개 + --dry-run 선행 + 경광등 우선) / 9번(설치 점검 /setup) 추가,
마지막 체크리스트에 3항목 추가.
부수 수정
- 셸 스크립트에 CRLF 가 섞여 bash 가 여는 중괄호에서 죽었다(빌드 자체가 불가). LF 로 정규화.
cafe24 스크립트에는 bash -n 가드가 이미 있어 이번에 실제로 잡았고, Pi 쪽에는 없어서
그대로 지나갈 뻔했다 — tests/test_deploy_artifacts.py 에 CRLF·bash -n 가드 추가.
산출물
dist/move-detect-cafe24-1.0.0-20260818.zip
dist/move-detect-pi-1.0.0-20260818-aarch64-cp311-hailo8.tar.gz
검증: pytest 통과(skip 3 = 하드웨어·환경) · PHPUnit 224 (skip 0).
매뉴얼 실렌더 확인(목차 정합·도판 8장·콜아웃). 패키지 내부 검증: 버전 스탬프 1.0.0,
마이그레이션 8/8 동봉, config.php 는 CHANGEME 템플릿, tools/ 는 www 밖에만,
웹 루트 PHP 는 index.php/_preflight.php 뿐.
227 lines
9.9 KiB
Python
227 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""역할별 사용자 설명서 생성기 — 캡처를 base64 로 박아 넣은 단일 HTML.
|
|
|
|
WHY A SINGLE FILE
|
|
납품처는 폐쇄망이고, 매뉴얼은 메일로 돌거나 USB 로 옮겨 다닌다. 이미지가 옆 폴더에
|
|
있으면 그 순간 도판 없는 문서가 된다. 전부 한 파일에 넣으면 어디로 옮겨도 온전하다.
|
|
|
|
WHY REGENERATE INSTEAD OF EDITING HTML
|
|
화면이 바뀌면 도판도 바뀌어야 한다. 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다
|
|
(rules: delivery-audit '매뉴얼 캡처 실물 정합'). 내용은 manual.json, 도판은 shots/ 에
|
|
두고 이 스크립트로 다시 뽑는 구조라, 화면이 바뀌면 캡처만 갈아끼우고 재실행하면 된다.
|
|
|
|
사용법:
|
|
python server/deploy/manual/build_manual.py # -> 사용설명서.html
|
|
python server/deploy/manual/build_manual.py --out X # 출력 경로 지정
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import datetime
|
|
import html
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
REPO = HERE.parents[2]
|
|
|
|
CALLOUT_LABEL = {"info": "참고", "warn": "주의", "danger": "경고"}
|
|
|
|
|
|
def data_uri(path: pathlib.Path) -> str:
|
|
return "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii")
|
|
|
|
|
|
CSS = """
|
|
:root{
|
|
--bg:#eef1f5; --card:#fff; --line:#dde3ea; --line-2:#eef2f6;
|
|
--ink:#131824; --ink-2:#464e5c; --ink-3:#646c7a;
|
|
--accent:#0b6b7a; --accent-wash:#e2f0f3; --accent-line:#b9d9df;
|
|
--warn-bg:#fbf6e6; --warn-line:#efe0b4; --warn-ink:#7a5606;
|
|
--crit-bg:#fdf2f2; --crit-line:#f2cccc; --crit-ink:#a51d1d;
|
|
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
|
|
--mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);
|
|
line-height:1.75;-webkit-font-smoothing:antialiased}
|
|
.wrap{max-width:880px;margin:0 auto;padding:0 1.2rem 5rem}
|
|
|
|
/* 표지 */
|
|
.cover{padding:3.5rem 0 2rem;border-bottom:3px solid var(--ink);margin-bottom:2rem}
|
|
.cover .kind{margin:0;font-size:.72rem;font-weight:700;letter-spacing:.14em;
|
|
text-transform:uppercase;color:var(--ink-3)}
|
|
.cover h1{margin:.5rem 0 .3rem;font-size:2.1rem;letter-spacing:-.02em}
|
|
.cover .sub{margin:0;color:var(--ink-2);font-size:1rem}
|
|
.cover .meta{margin:1.2rem 0 0;font-family:var(--mono);font-size:.76rem;color:var(--ink-3)}
|
|
|
|
/* 목차 */
|
|
.toc{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
|
padding:1.1rem 1.3rem;margin-bottom:2.5rem}
|
|
.toc h2{margin:0 0 .6rem;font-size:.72rem;font-weight:700;letter-spacing:.08em;
|
|
text-transform:uppercase;color:var(--ink-3)}
|
|
.toc ul{margin:.2rem 0 0;padding-left:1.1rem;list-style:none}
|
|
.toc li{margin:.15rem 0}
|
|
.toc li::before{content:"·";color:var(--ink-3);margin-right:.5rem}
|
|
.toc a{color:var(--accent);text-decoration:none}
|
|
.toc a:hover{text-decoration:underline;text-underline-offset:3px}
|
|
.toc-role{margin:.9rem 0 0;font-weight:700;color:var(--ink);font-size:.9rem}
|
|
.toc-role:first-of-type{margin-top:0}
|
|
|
|
/* 역할 */
|
|
.role{margin:0 0 3rem}
|
|
.role-head{padding:1rem 1.2rem;background:var(--accent-wash);
|
|
border:1px solid var(--accent-line);border-radius:10px;margin-bottom:1.5rem}
|
|
.role-head h2{margin:0;font-size:1.25rem;color:var(--accent)}
|
|
.role-head p{margin:.35rem 0 0;font-size:.9rem;color:var(--ink-2)}
|
|
|
|
section.step{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
|
padding:1.3rem 1.4rem 1.5rem;margin-bottom:1rem}
|
|
section.step h3{margin:0 0 .8rem;font-size:1.05rem;letter-spacing:-.01em}
|
|
section.step p{margin:0 0 .7rem;font-size:.94rem;color:var(--ink-2)}
|
|
section.step p:last-child{margin-bottom:0}
|
|
section.step strong{color:var(--ink)}
|
|
figure{margin:0 0 1.1rem}
|
|
figure img{width:100%;height:auto;display:block;border:1px solid var(--line);border-radius:8px}
|
|
figcaption{margin-top:.35rem;font-size:.74rem;color:var(--ink-3)}
|
|
|
|
.callout{margin:1rem 0 0;padding:.75rem .9rem;border-radius:8px;font-size:.9rem;
|
|
border:1px solid var(--line);background:var(--line-2);color:var(--ink-2)}
|
|
.callout .tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em;
|
|
margin-right:.45rem;text-transform:uppercase}
|
|
.callout.warn{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn-ink)}
|
|
.callout.danger{background:var(--crit-bg);border-color:var(--crit-line);color:var(--crit-ink)}
|
|
.callout.info{background:var(--accent-wash);border-color:var(--accent-line);color:var(--accent)}
|
|
|
|
code{font-family:var(--mono);font-size:.86em;background:var(--line-2);
|
|
padding:.1em .35em;border-radius:4px}
|
|
kbd{font-family:var(--mono);font-size:.8em;background:var(--card);border:1px solid var(--line);
|
|
border-bottom-width:2px;border-radius:4px;padding:.05em .4em}
|
|
.chip{display:inline-block;font-size:.72em;font-weight:700;color:#fff;background:var(--crit-ink);
|
|
padding:.1em .5em;border-radius:999px}
|
|
|
|
/* FAQ */
|
|
.faq{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.3rem 1.4rem}
|
|
.faq h2{margin:0 0 1rem;font-size:1.15rem}
|
|
.faq dt{font-weight:700;margin-top:1rem;color:var(--ink)}
|
|
.faq dt:first-of-type{margin-top:0}
|
|
.faq dd{margin:.3rem 0 0;font-size:.93rem;color:var(--ink-2)}
|
|
|
|
footer.doc{margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--line);
|
|
font-size:.76rem;color:var(--ink-3);line-height:1.7}
|
|
|
|
@media print{
|
|
body{background:#fff}
|
|
.wrap{max-width:none;padding:0}
|
|
section.step,.toc,.faq,.role-head{break-inside:avoid;box-shadow:none}
|
|
.toc{display:none}
|
|
}
|
|
"""
|
|
|
|
|
|
def render(doc: dict, shots: pathlib.Path, version: str) -> str:
|
|
out: list[str] = []
|
|
a = out.append
|
|
today = datetime.date.today().isoformat()
|
|
|
|
a("<!doctype html>")
|
|
a('<html lang="ko"><head><meta charset="utf-8">')
|
|
a('<meta name="viewport" content="width=device-width, initial-scale=1">')
|
|
a(f"<title>{html.escape(doc['title'])}</title>")
|
|
a(f"<style>{CSS}</style>")
|
|
a("</head><body><div class=\"wrap\">")
|
|
|
|
# 표지
|
|
a('<header class="cover">')
|
|
a('<p class="kind">사용 설명서</p>')
|
|
a(f"<h1>{html.escape(doc['title'])}</h1>")
|
|
a(f"<p class=\"sub\">{html.escape(doc['subtitle'])}</p>")
|
|
a(f'<p class="meta">버전 {html.escape(version)} · {today} 기준 화면</p>')
|
|
a("</header>")
|
|
|
|
# 목차. 항목 번호는 제목이 이미 달고 있으므로(1., 2., …) 목록 자체는 번호를 매기지
|
|
# 않는다. 역할 이름을 <li> 로 끼워 넣고 list-style 로만 숨기면 그 줄이 카운터를 먹어
|
|
# 본문 번호와 목차 번호가 어긋난다(7 다음이 9로 뛴다).
|
|
a('<nav class="toc"><h2>목차</h2>')
|
|
n = 1
|
|
for role in doc["roles"]:
|
|
a(f'<p class="toc-role">{html.escape(role["name"])}</p><ul>')
|
|
for sec in role["sections"]:
|
|
anchor = f"sec-{n}"
|
|
n += 1
|
|
sec["_anchor"] = anchor
|
|
a(f'<li><a href="#{anchor}">{html.escape(sec["heading"])}</a></li>')
|
|
a("</ul>")
|
|
a('<p class="toc-role">자주 묻는 질문</p>')
|
|
a("</nav>")
|
|
|
|
# 본문
|
|
for role in doc["roles"]:
|
|
a('<div class="role">')
|
|
a('<div class="role-head">')
|
|
a(f"<h2>{html.escape(role['name'])}</h2>")
|
|
a(f"<p>{html.escape(role['summary'])}</p>")
|
|
a("</div>")
|
|
for sec in role["sections"]:
|
|
a(f'<section class="step" id="{sec["_anchor"]}">')
|
|
a(f"<h3>{html.escape(sec['heading'])}</h3>")
|
|
shot = sec.get("shot")
|
|
if shot:
|
|
p = shots / shot
|
|
if not p.is_file():
|
|
# 도판 누락을 조용히 넘기면 구멍 난 매뉴얼이 납품된다.
|
|
raise SystemExit(f"ERROR: 캡처가 없습니다: {p}")
|
|
a("<figure>")
|
|
a(f'<img alt="{html.escape(sec["heading"])} 화면" src="{data_uri(p)}">')
|
|
a(f"<figcaption>{html.escape(sec['heading'])} — 실제 화면</figcaption>")
|
|
a("</figure>")
|
|
for para in sec["body"]:
|
|
a(f"<p>{para}</p>") # 본문은 의도된 인라인 마크업을 허용한다
|
|
co = sec.get("callout")
|
|
if co:
|
|
label = CALLOUT_LABEL.get(co["kind"], "참고")
|
|
a(f'<div class="callout {co["kind"]}">'
|
|
f'<span class="tag">{label}</span>{co["text"]}</div>')
|
|
a("</section>")
|
|
a("</div>")
|
|
|
|
# FAQ
|
|
a('<div class="faq"><h2>자주 묻는 질문</h2><dl>')
|
|
for item in doc["faq"]:
|
|
a(f"<dt>{html.escape(item['q'])}</dt><dd>{item['a']}</dd>")
|
|
a("</dl></div>")
|
|
|
|
a('<footer class="doc">')
|
|
a(f"이 문서는 버전 {html.escape(version)} 화면을 기준으로 작성되었습니다. "
|
|
"화면이 바뀌면 캡처와 함께 다시 생성됩니다.<br>"
|
|
"설치·업그레이드 절차는 <code>README-UPLOAD.md</code>, "
|
|
"변경 내역은 <code>CHANGELOG.md</code>, "
|
|
"점검 결과는 <code>납품점검-리포트.md</code> 를 참조하세요.")
|
|
a("</footer>")
|
|
|
|
a("</div></body></html>")
|
|
return "\n".join(out)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--out", default=str(HERE / "사용설명서.html"))
|
|
ap.add_argument("--shots", default=str(HERE / "shots"))
|
|
args = ap.parse_args()
|
|
|
|
version_file = REPO / "VERSION"
|
|
if not version_file.is_file():
|
|
raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 매뉴얼은 만들지 않습니다.")
|
|
version = version_file.read_text(encoding="utf-8").strip()
|
|
|
|
doc = json.loads((HERE / "manual.json").read_text(encoding="utf-8"))
|
|
out = pathlib.Path(args.out)
|
|
out.write_text(render(doc, pathlib.Path(args.shots), version), encoding="utf-8")
|
|
size_kb = out.stat().st_size / 1024
|
|
print(f"{out} ({size_kb:.0f} KB, 단일 파일 · 이미지 포함)", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|