wndrks
This commit is contained in:
886
tools/CreateDesignIssuePpt.py
Normal file
886
tools/CreateDesignIssuePpt.py
Normal file
@@ -0,0 +1,886 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from PIL import Image
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
AUDIT_DIR = ROOT / "artifacts" / "cut-file-audit" / "full_20260509_114655"
|
||||
STABLE_AUDIT_DIR = ROOT / "artifacts" / "cut-file-audit" / "stable_pgm_dwm_20260510_10s"
|
||||
RESULTS_CSV = AUDIT_DIR / "results.csv"
|
||||
CAPTURE_DIR = AUDIT_DIR / "captures"
|
||||
THUMB_DIR = ROOT / "Tornado3_2026Election" / "Assets" / "Thumbnail"
|
||||
LIVE_DIR = ROOT / "artifacts" / "live-cut-validation" / "20260418_232730"
|
||||
MEDIA_DIR = ROOT / "artifacts" / "design_issue_ppt_media_clean"
|
||||
OUT_PATH = ROOT / "artifacts" / "design_tag_color_issues_20260510_clean_v2.pptx"
|
||||
|
||||
FONT = "Malgun Gothic"
|
||||
|
||||
|
||||
SECTIONS = [
|
||||
{
|
||||
"title": "1-2위_ani_광역단체장",
|
||||
"keys": ["1-2위_ani_광역단체장"],
|
||||
"mode": "pair",
|
||||
"badges": ["색상/RGB", "확인 필요"],
|
||||
"bullets": [
|
||||
"정당명 좌/우 색상 적용 지침이 없음.",
|
||||
"RGB txt는 정당판/정당바/득표율 중심으로만 안내되어 있음.",
|
||||
"RGB txt와 기본 컷 색상 차이가 존재함.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "1-2위_ani_기초단체장",
|
||||
"keys": ["1-2위_ani_기초단체장"],
|
||||
"mode": "pair",
|
||||
"badges": ["색상/RGB", "확인 필요"],
|
||||
"bullets": [
|
||||
"득표수 색상값 지침이 없음.",
|
||||
"정당색 RGB를 차용하려 했으나 실제 화면 색상과 차이가 있음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "1-2위 시도별영상 계열",
|
||||
"keys": ["1-2위_광역단체장_시도별영상", "1-2위_기초단체장_시도별영상"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB", "운영 확인"],
|
||||
"bullets": [
|
||||
"RGB txt와 기본 컷 색상 차이가 존재함.",
|
||||
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
"시도별 영상이 있는 지역은 해당 지역 컷을 로드해 활용하면 되는지 확인 필요.",
|
||||
"예: 시도별_02_부산, 시도별_03_대구 등 지역별 컷 사용 여부.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "접전/초접전 단체장",
|
||||
"keys": ["접전_광역단체장", "접전_기초단체장", "초접전_광역단체장", "초접전_기초단체장"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB"],
|
||||
"bullets": [
|
||||
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
"정당바 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "1-3위_ani_광역단체장 / 1-3위_보궐선거",
|
||||
"keys": ["1-3위_ani_광역단체장", "1-3위_보궐선거"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 의심", "색상/RGB"],
|
||||
"bullets": [
|
||||
"공백/잘못된 suffix 의심 태그: '순위01 2'.",
|
||||
"'순위03' 태그 누락 의심. '순위01 2'를 '순위03'으로 변경 후 재검증 필요.",
|
||||
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "1-3위_ani_기초단체장",
|
||||
"keys": ["1-3위_ani_기초단체장"],
|
||||
"mode": "pair",
|
||||
"badges": ["색상/RGB", "확인 필요"],
|
||||
"bullets": [
|
||||
"순위 하늘색 그라데이션이 모든 정당에 적용되는 지침인지 확인 필요.",
|
||||
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
"기타 RGB txt 참조 시 색상 차이가 다소 존재함.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Bottom 1-3위/1위 단체장",
|
||||
"keys": ["1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 의심", "색상/RGB"],
|
||||
"bullets": [
|
||||
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
|
||||
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "1-3위_기초단체장 확장 계열",
|
||||
"keys": ["1-3위_기초단체장_5760", "1-3위_기초단체장_L", "2880_1-3위_기초단체장", "8316_1-3위_기초단체장"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB"],
|
||||
"bullets": [
|
||||
"1-3위_기초단체장_5760, L, 2880/810/8316 계열 대상.",
|
||||
"그룹, 득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "2880_1위 단체장",
|
||||
"keys": ["2880_1위_광역단체장", "2880_1위_기초단체장"],
|
||||
"mode": "grid",
|
||||
"badges": ["RGB 매핑", "확인 필요"],
|
||||
"bullets": [
|
||||
"RGB 기준 파일이 heuristic으로 연결됨.",
|
||||
"각각 이시각1위 계열 RGB txt를 기준으로 봐도 되는지 안내 필요.",
|
||||
"그룹, 득표율, 정당명, 정당바/정당색 색상 지침 일부가 RGB txt에 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "당선 계열",
|
||||
"keys": ["당선_광역단체장", "당선_광역의원", "당선_교육감", "당선_기초단체장", "당선_기초의원"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 의심", "색상/RGB", "색상 미적용"],
|
||||
"bullets": [
|
||||
"Bottom loop 포함 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
|
||||
"하단 RGB/당선.txt에 당선바(정당바) 색상 기준이 있으나 실제 하단 당선 컷에 적용되지 않음.",
|
||||
"같은 파일에 득표수/득표율 색상 기준도 있으므로 적용 대상 태그와 매핑 방식 확인 필요.",
|
||||
"2880/810/8316 및 HD/L 계열도 득표율 색상 지침 확인 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "광역의원표 / 기초의원표",
|
||||
"keys": ["광역의원표", "기초의원표", "광역의원표_HD", "광역의원표_L_1"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB", "태그 체계", "캡처 실패"],
|
||||
"bullets": [
|
||||
"정당바 색상 태그가 있으나 RGB 기준 파일 또는 같은 섹션 지침이 없음.",
|
||||
"loop/HD/L 계열에서 의석수 태그 체계가 기준과 다름.",
|
||||
"의석수01A~04A 누락, 의석수0101A 등 추가 태그 확인 필요.",
|
||||
"광역의원표_HD, 광역의원표_HD_loop, 광역의원표_L_1은 index out of range 캡처 실패 확인 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "모든후보 계열",
|
||||
"keys": ["모든후보_광역단체장", "모든후보_기초단체장", "모든후보_교육감", "모든후보_기초단체장_L"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB", "태그 체계", "캡처 실패"],
|
||||
"bullets": [
|
||||
"득표율, 정당명 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
"값이 0.3%처럼 매우 적은 경우 음수/역방향 표기 가능성 확인 필요.",
|
||||
"5760/L/2880/810/8316 계열에서 그룹01~03, 득표수02~03, 득표율02~03, 순위02~03 등 추가 태그가 기준과 다름.",
|
||||
"2880_모든후보_기초단체장은 '개표율01 1', '선거구명01 1' 태그가 있어 기준 태그와 불일치함.",
|
||||
"모든후보_기초단체장_L은 index out of range 캡처 실패 확인 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "전후보 계열",
|
||||
"keys": ["전후보_광역단체장", "전후보_교육감", "전후보_기초단체장"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 의심", "색상/RGB"],
|
||||
"bullets": [
|
||||
"공백/잘못된 suffix 의심 태그: 'data01 1', 'data01 2'.",
|
||||
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
"값이 0.3%처럼 매우 적은 경우 음수/역방향 표기 가능성 확인 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "경력 계열",
|
||||
"keys": ["경력_광역단체장_in", "경력_기초단체장_in"],
|
||||
"mode": "grid",
|
||||
"badges": ["레이아웃", "RGB 매핑"],
|
||||
"bullets": [
|
||||
"기호가 두 자리(11/12)일 때 영역 침범 여부 확인 필요.",
|
||||
"loop 컷은 RGB 기준 파일이 heuristic으로 '경력.txt'에 연결되어 있어 해당 기준 사용 가능 여부 안내 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "사전_역대투표율",
|
||||
"keys": ["사전_역대투표율", "사전_역대투표율_loop"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 누락", "알파 확인"],
|
||||
"bullets": [
|
||||
"원01~원06 태그 누락 의심.",
|
||||
"애니메이션 mid/final PGM 캡처 기준 알파 처리 확인 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "사전투표율",
|
||||
"keys": ["사전투표율"],
|
||||
"mode": "pair",
|
||||
"badges": ["태그 의심", "색상/RGB"],
|
||||
"bullets": [
|
||||
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1'~'data01 7'.",
|
||||
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "사전_역대당선자 계열",
|
||||
"keys": ["사전_역대당선자", "사전_역대당선자_교육감", "사전_역대당선자_기초단체장"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB"],
|
||||
"bullets": [
|
||||
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "시도별 지역 컷",
|
||||
"keys": ["시도별_02_부산", "시도별_03_대구", "시도별_05_전남광주", "시도별_16_경남"],
|
||||
"mode": "grid",
|
||||
"badges": ["RGB 기준 없음"],
|
||||
"bullets": [
|
||||
"대상: 부산, 대구, 전남광주, 대전, 세종, 울산, 강원, 충남, 전북, 경북, 경남.",
|
||||
"득표율, 정당명, 정당바 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "역대시도판세 계열",
|
||||
"keys": ["역대시도판세_광역단체장", "역대시도판세_기초단체장"],
|
||||
"mode": "grid",
|
||||
"badges": ["RGB 매핑", "색상/RGB"],
|
||||
"bullets": [
|
||||
"RGB 기준 파일이 heuristic으로 연결됨.",
|
||||
"광역은 '판세_광역단체장.txt', 기초는 '1-2위_ani_기초단체장.txt' 기준 사용 가능 여부 안내 필요.",
|
||||
"그룹 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "이시각1위_광역단체장",
|
||||
"keys": ["이시각1위_광역단체장", "이시각1위_광역단체장_HD"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB", "태그 불일치"],
|
||||
"bullets": [
|
||||
"그룹, 득표율, 정당명, 정당바, 정당색 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
"HD 컷은 기준 대비 개표율02~03, 그룹01~03, 득표수02~03, 득표율02~03, 선거구명02 등이 누락되어 태그 불일치 확인 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "이시각1위_기초단체장",
|
||||
"keys": ["이시각1위_기초단체장", "이시각1위_기초단체장_HD"],
|
||||
"mode": "grid",
|
||||
"badges": ["색상/RGB", "태그 불일치"],
|
||||
"bullets": [
|
||||
"그룹, 득표율 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
"HD 컷은 기준 대비 3위 관련 태그가 누락되어 확인 필요.",
|
||||
"누락 의심: 개표율03, 그룹03, 득표수03, 득표율03, 선거구명03, 시도명03, 유확당03, 정당명03, 정당바03, 정당색03.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "판세_광역단체장",
|
||||
"keys": ["판세_광역단체장"],
|
||||
"mode": "pair",
|
||||
"badges": ["색상/RGB"],
|
||||
"bullets": [
|
||||
"정당명, 정당바 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "판세_기초단체장",
|
||||
"keys": ["판세_기초단체장"],
|
||||
"mode": "pair",
|
||||
"badges": ["색상/RGB"],
|
||||
"bullets": [
|
||||
"득표율 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "판세 좌상단 계열",
|
||||
"keys": ["판세_광역단체장", "판세_광역의원", "판세_교육감", "판세_기초단체장", "판세_기초의원"],
|
||||
"mode": "grid",
|
||||
"badges": ["RGB 기준 없음"],
|
||||
"bullets": [
|
||||
"좌상단 판세 계열 대상.",
|
||||
"정당명, 정당바 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "투표율",
|
||||
"keys": ["투표율", "투표율_loop"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 의심", "태그 불일치", "색상/RGB"],
|
||||
"bullets": [
|
||||
"Bottom 컷에서 공백/잘못된 suffix 의심 태그: 'data01 1'~'data01 7'.",
|
||||
"loop 컷은 기준시 태그가 빠지고 기준시01/기준시02가 추가되어 태그 불일치 확인 필요.",
|
||||
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "투표율_사진",
|
||||
"keys": ["투표율_사진"],
|
||||
"mode": "pair",
|
||||
"badges": ["태그 누락"],
|
||||
"bullets": [
|
||||
"텍스트 '(14시 기준)' 태그 누락 의심.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "투표율_선거구별 (좌상단)",
|
||||
"keys": ["투표율_선거구별"],
|
||||
"mode": "pair",
|
||||
"badges": ["태그 의심", "색상/RGB"],
|
||||
"bullets": [
|
||||
"잘못된 태그 형태로 되어 있으나 사용자 요청에 따라 미변경.",
|
||||
"변경 시 재작업 필요 여부 별도 언급 필요.",
|
||||
"공백/잘못된 suffix 의심 태그: '시도명01 1'~'시도명01 3', '투표율01 1'~'투표율01 2'.",
|
||||
"그룹 색상 태그가 있으나 RGB 기준 파일 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "투표율_시도별",
|
||||
"keys": ["투표율_시도별", "투표율_시도별_L"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 불일치"],
|
||||
"bullets": [
|
||||
"loop 계열에서 기준 대비 '유권자수' 태그가 추가되어 태그 불일치 확인 필요.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "2인 후보 좌상단 계열",
|
||||
"keys": ["광역단체장_2인", "기초단체장_2인", "기초단체장_2인_텍스트"],
|
||||
"mode": "grid",
|
||||
"badges": ["태그 누락", "색상/RGB"],
|
||||
"bullets": [
|
||||
"광역단체장_2인, 기초단체장_2인, 기초단체장_2인_텍스트 대상.",
|
||||
"후보명01, 후보명02 태그 누락 의심.",
|
||||
"정당심볼 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "광역단체장_2인_텍스트",
|
||||
"keys": ["광역단체장_2인_텍스트"],
|
||||
"mode": "pair",
|
||||
"badges": ["색상/RGB"],
|
||||
"bullets": [
|
||||
"득표율, 정당심볼 색상 태그가 있으나 RGB txt에 같은 섹션 지침 없음.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "8316 계열",
|
||||
"keys": ["8316_광역단체장", "8316_광역의원"],
|
||||
"mode": "grid",
|
||||
"badges": ["RGB 매핑", "색상/RGB"],
|
||||
"bullets": [
|
||||
"8316_광역단체장은 RGB 기준 파일이 heuristic으로 '1-2위_ani_광역단체장.txt'에 연결되어 있어 기준 사용 가능 여부 안내 필요.",
|
||||
"8316_광역의원은 득표율, 정당명 색상 관련 태그가 있으나 RGB 기준 파일 없음.",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
NO_ISSUE_ITEMS = [
|
||||
"1-2위_광역단체장 (Bottom/Normal 5760/L 포함)",
|
||||
"1-2위_광역단체장_loop (Bottom)",
|
||||
"1-2위_기초단체장",
|
||||
"1-2위_기초단체장 (Bottom)",
|
||||
"1-2위_기초단체장_loop (Bottom)",
|
||||
"1-2위_교육감",
|
||||
"1-2위_보궐선거",
|
||||
"1-2위_ani_기초단체장_5760",
|
||||
"1-2위_ani_기초단체장_L",
|
||||
"2880_1-2위_ani_기초단체장",
|
||||
"810_1-2위_ani_기초단체장",
|
||||
"2880_1-2위_광역단체장",
|
||||
"810_1-2위_광역단체장",
|
||||
"8316_1-2위_광역단체장",
|
||||
"민방_타이틀 계열",
|
||||
"투표율_02_부산, 투표율_03_대구, 투표율_05_전남광주",
|
||||
"투표율_06_대전, 투표율_07_세종, 투표율_08_울산",
|
||||
"투표율_10_강원, 투표율_12_충남, 투표율_14_전북",
|
||||
"투표율_15_경북, 투표율_16_경남",
|
||||
"투표율_선거구별 사전",
|
||||
"투표율_시도별",
|
||||
"투표율_시도별_L",
|
||||
"투표율_영상",
|
||||
"투표율 (좌상단)",
|
||||
]
|
||||
|
||||
|
||||
def load_rows(paths: Iterable[Path]) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
||||
rows.extend(csv.DictReader(f))
|
||||
return rows
|
||||
|
||||
|
||||
def all_pngs(*dirs: Path) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
for d in dirs:
|
||||
if d.exists():
|
||||
paths.extend(d.rglob("*.png"))
|
||||
return paths
|
||||
|
||||
|
||||
def parse_captures(value: str) -> list[tuple[str, Path]]:
|
||||
captures: list[tuple[str, Path]] = []
|
||||
if not value:
|
||||
return captures
|
||||
for token in value.split(" | "):
|
||||
parts = token.split(":", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
label, _stage, path = parts
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
captures.append((label, p))
|
||||
return captures
|
||||
|
||||
|
||||
def row_sort_key(row: dict[str, str], key: str) -> tuple[int, int, int, int]:
|
||||
status = row.get("Status", "")
|
||||
basename = row.get("BaseName", "")
|
||||
folder = row.get("Folder", "")
|
||||
exact = 0 if basename == key else 1
|
||||
status_rank = {"issue": 0, "needs-guidance": 1, "capture-failed": 2, "pass": 3}.get(status, 4)
|
||||
folder_rank = 0 if "Normal" in folder else (1 if "Bottom" in folder else 2)
|
||||
return exact, status_rank, folder_rank, len(basename)
|
||||
|
||||
|
||||
def matching_rows(rows: list[dict[str, str]], key: str) -> list[dict[str, str]]:
|
||||
exact = [r for r in rows if r.get("BaseName") == key]
|
||||
contains = [
|
||||
r for r in rows
|
||||
if r not in exact and (key in r.get("BaseName", "") or r.get("BaseName", "") in key)
|
||||
]
|
||||
return sorted(exact + contains, key=lambda r: row_sort_key(r, key))
|
||||
|
||||
|
||||
def first_thumb(pngs: list[Path], key: str) -> Path | None:
|
||||
exact = [p for p in pngs if p.stem == key]
|
||||
if exact:
|
||||
return sorted(exact, key=lambda p: len(str(p)))[0]
|
||||
contains = [p for p in pngs if key in p.stem or p.stem in key]
|
||||
if contains:
|
||||
return sorted(contains, key=lambda p: len(str(p)))[0]
|
||||
return None
|
||||
|
||||
|
||||
def image_items_for_section(section: dict, rows: list[dict[str, str]], pngs: list[Path]) -> list[tuple[Path, str]]:
|
||||
items: list[tuple[Path, str]] = []
|
||||
keys = section["keys"]
|
||||
mode = section.get("mode", "pair")
|
||||
|
||||
if mode == "pair":
|
||||
for key in keys:
|
||||
for row in matching_rows(rows, key):
|
||||
captures = parse_captures(row.get("Captures", ""))
|
||||
if captures:
|
||||
preferred = []
|
||||
for label in ("baseline", "Basic", "Variant", "Stress"):
|
||||
preferred.extend([(p, f"{row['Folder']} / {row['BaseName']} - {label}") for lbl, p in captures if lbl == label])
|
||||
if preferred:
|
||||
return preferred[:2]
|
||||
thumb = first_thumb(pngs, key)
|
||||
if thumb:
|
||||
return [(thumb, f"thumbnail / {key}")]
|
||||
return []
|
||||
|
||||
for key in keys:
|
||||
added = False
|
||||
for row in matching_rows(rows, key):
|
||||
captures = parse_captures(row.get("Captures", ""))
|
||||
if captures:
|
||||
label, path = next(((lbl, p) for lbl, p in captures if lbl in ("Basic", "Variant")), captures[0])
|
||||
items.append((path, f"{row['Folder']} / {row['BaseName']} - {label}"))
|
||||
added = True
|
||||
break
|
||||
if not added:
|
||||
thumb = first_thumb(pngs, key)
|
||||
if thumb:
|
||||
items.append((thumb, f"thumbnail / {key}"))
|
||||
if len(items) >= 4:
|
||||
break
|
||||
return items[:4]
|
||||
|
||||
|
||||
def preprocess_image(path: Path) -> Path:
|
||||
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
|
||||
out = MEDIA_DIR / f"{digest}_{path.name}"
|
||||
if out.exists():
|
||||
return out
|
||||
with Image.open(path) as im:
|
||||
im = im.convert("RGBA")
|
||||
bg = Image.new("RGBA", im.size, (24, 29, 39, 255))
|
||||
composed = Image.alpha_composite(bg, im)
|
||||
composed.convert("RGB").save(out, quality=95)
|
||||
return out
|
||||
|
||||
|
||||
def set_run_font(run, size: float, color: tuple[int, int, int] = (20, 25, 35), bold: bool = False):
|
||||
run.font.name = FONT
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = RGBColor(*color)
|
||||
|
||||
|
||||
def add_text(slide, text: str, x: float, y: float, w: float, h: float, size: float = 14,
|
||||
color: tuple[int, int, int] = (20, 25, 35), bold: bool = False,
|
||||
align=PP_ALIGN.LEFT):
|
||||
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
tf = box.text_frame
|
||||
tf.clear()
|
||||
tf.margin_left = Inches(0.05)
|
||||
tf.margin_right = Inches(0.05)
|
||||
tf.margin_top = Inches(0.02)
|
||||
tf.margin_bottom = Inches(0.02)
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = align
|
||||
run = p.add_run()
|
||||
run.text = text
|
||||
set_run_font(run, size, color, bold)
|
||||
return box
|
||||
|
||||
|
||||
def add_badges(slide, badges: Iterable[str], x: float, y: float):
|
||||
colors = {
|
||||
"색상/RGB": (28, 98, 177),
|
||||
"RGB 기준 없음": (179, 87, 23),
|
||||
"RGB 매핑": (110, 73, 190),
|
||||
"태그 의심": (184, 51, 74),
|
||||
"태그 누락": (184, 51, 74),
|
||||
"태그 불일치": (184, 51, 74),
|
||||
"태그 체계": (184, 51, 74),
|
||||
"캡처 실패": (153, 52, 52),
|
||||
"확인 필요": (76, 115, 42),
|
||||
"운영 확인": (76, 115, 42),
|
||||
"레이아웃": (77, 91, 112),
|
||||
"알파 확인": (77, 91, 112),
|
||||
}
|
||||
cx = x
|
||||
for badge in badges:
|
||||
fill = colors.get(badge, (80, 90, 105))
|
||||
width = max(0.8, min(1.45, 0.24 + len(badge) * 0.16))
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(cx), Inches(y), Inches(width), Inches(0.28))
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = RGBColor(*fill)
|
||||
shape.line.fill.background()
|
||||
tf = shape.text_frame
|
||||
tf.clear()
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
run = p.add_run()
|
||||
run.text = badge
|
||||
set_run_font(run, 8.5, (255, 255, 255), True)
|
||||
cx += width + 0.12
|
||||
|
||||
|
||||
def add_bullets(slide, bullets: list[str], x: float, y: float, w: float, h: float):
|
||||
panel = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
panel.fill.solid()
|
||||
panel.fill.fore_color.rgb = RGBColor(246, 248, 251)
|
||||
panel.line.color.rgb = RGBColor(218, 224, 232)
|
||||
panel.line.width = Pt(1)
|
||||
tf = panel.text_frame
|
||||
tf.clear()
|
||||
tf.margin_left = Inches(0.22)
|
||||
tf.margin_right = Inches(0.16)
|
||||
tf.margin_top = Inches(0.15)
|
||||
tf.margin_bottom = Inches(0.12)
|
||||
tf.word_wrap = True
|
||||
for idx, bullet in enumerate(bullets):
|
||||
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||||
p.text = f"• {bullet}"
|
||||
p.level = 0
|
||||
p.space_after = Pt(4)
|
||||
p.font.name = FONT
|
||||
p.font.size = Pt(11.2 if len(bullets) <= 3 else 10.4)
|
||||
p.font.color.rgb = RGBColor(31, 41, 55)
|
||||
|
||||
|
||||
def fit_box(img_path: Path, x: float, y: float, w: float, h: float) -> tuple[float, float, float, float]:
|
||||
with Image.open(img_path) as im:
|
||||
iw, ih = im.size
|
||||
scale = min(w / iw, h / ih)
|
||||
nw = iw * scale
|
||||
nh = ih * scale
|
||||
return x + (w - nw) / 2, y + (h - nh) / 2, nw, nh
|
||||
|
||||
|
||||
def add_image(slide, path: Path, caption: str, x: float, y: float, w: float, h: float):
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = RGBColor(24, 29, 39)
|
||||
bg.line.color.rgb = RGBColor(200, 207, 217)
|
||||
bg.line.width = Pt(0.8)
|
||||
prepped = preprocess_image(path)
|
||||
ix, iy, iw, ih = fit_box(prepped, x + 0.04, y + 0.04, w - 0.08, h - 0.36)
|
||||
slide.shapes.add_picture(str(prepped), Inches(ix), Inches(iy), Inches(iw), Inches(ih))
|
||||
add_text(slide, caption, x + 0.08, y + h - 0.28, w - 0.16, 0.18, 6.8, (207, 216, 226), False, PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
def add_issue_slide(prs: Presentation, section: dict, rows: list[dict[str, str]], pngs: list[Path]):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (255, 255, 255))
|
||||
add_header(slide, section["title"])
|
||||
add_badges(slide, section.get("badges", []), 0.62, 0.72)
|
||||
|
||||
images = image_items_for_section(section, rows, pngs)
|
||||
if len(images) <= 2:
|
||||
left_w = 7.15
|
||||
if len(images) == 1:
|
||||
add_image(slide, images[0][0], images[0][1], 0.62, 1.22, left_w, 4.75)
|
||||
elif len(images) == 2:
|
||||
add_image(slide, images[0][0], images[0][1], 0.62, 1.22, left_w, 2.28)
|
||||
add_image(slide, images[1][0], images[1][1], 0.62, 3.72, left_w, 2.28)
|
||||
else:
|
||||
add_empty_image_note(slide, 0.62, 1.22, left_w, 4.75)
|
||||
else:
|
||||
boxes = [(0.62, 1.22), (4.25, 1.22), (0.62, 3.72), (4.25, 3.72)]
|
||||
for (path, caption), (x, y) in zip(images, boxes):
|
||||
add_image(slide, path, caption, x, y, 3.35, 2.28)
|
||||
|
||||
add_bullets(slide, section["bullets"], 8.18, 1.22, 4.55, 4.75)
|
||||
add_footer(slide, "Source: PGM DWM 10s recapture first, full_20260509_114655 and Assets/Thumbnail fallback")
|
||||
|
||||
|
||||
def add_empty_image_note(slide, x: float, y: float, w: float, h: float):
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = RGBColor(246, 248, 251)
|
||||
shape.line.color.rgb = RGBColor(218, 224, 232)
|
||||
tf = shape.text_frame
|
||||
tf.clear()
|
||||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
run = p.add_run()
|
||||
run.text = "매칭 가능한 캡처/썸네일 없음"
|
||||
set_run_font(run, 16, (95, 106, 122), True)
|
||||
|
||||
|
||||
def set_background(slide, color: tuple[int, int, int]):
|
||||
bg = slide.background
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = RGBColor(*color)
|
||||
|
||||
|
||||
def add_header(slide, title: str):
|
||||
add_text(slide, title, 0.55, 0.28, 9.2, 0.38, 20, (22, 27, 36), True)
|
||||
line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(0.98), Inches(12.25), Inches(0.02))
|
||||
line.fill.solid()
|
||||
line.fill.fore_color.rgb = RGBColor(222, 227, 235)
|
||||
line.line.fill.background()
|
||||
|
||||
|
||||
def add_footer(slide, text: str):
|
||||
add_text(slide, text, 0.6, 7.1, 12.1, 0.2, 7.5, (115, 126, 143))
|
||||
|
||||
|
||||
def add_title_slide(prs: Presentation):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (18, 24, 35))
|
||||
add_text(slide, "Tornado3 2026 Election", 0.75, 1.2, 11.8, 0.45, 18, (172, 190, 214), True)
|
||||
add_text(slide, "디자인 태그/색상 이슈 정리", 0.75, 1.72, 11.8, 0.82, 34, (255, 255, 255), True)
|
||||
add_text(slide, "스크린샷 포함 검토용 PPT · 2026-05-09", 0.78, 2.66, 9.2, 0.35, 15, (226, 232, 240))
|
||||
add_text(slide, "범위: RGB txt 지침 누락, 기본 컷 색상 차이, 태그 suffix/누락 의심, 계열별 태그 불일치, 캡처 실패", 0.78, 3.22, 11.6, 0.5, 13, (202, 213, 226))
|
||||
|
||||
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(0.82), Inches(4.6), Inches(11.68), Inches(1.35))
|
||||
card.fill.solid()
|
||||
card.fill.fore_color.rgb = RGBColor(30, 41, 59)
|
||||
card.line.color.rgb = RGBColor(64, 82, 110)
|
||||
add_text(slide, "사용 캡처", 1.08, 4.83, 2.0, 0.25, 12, (148, 163, 184), True)
|
||||
add_text(slide, "artifacts/cut-file-audit/stable_pgm_dwm_20260510_10s/captures + Assets/Thumbnail fallback", 1.08, 5.18, 10.8, 0.28, 12, (241, 245, 249))
|
||||
|
||||
|
||||
def add_summary_slide(prs: Presentation, rows: list[dict[str, str]]):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (255, 255, 255))
|
||||
add_header(slide, "요약")
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
counts[row.get("Status", "")] = counts.get(row.get("Status", ""), 0) + 1
|
||||
|
||||
cards = [
|
||||
("이슈/확인 필요 섹션", str(len(SECTIONS)), (28, 98, 177)),
|
||||
("issue", str(counts.get("issue", 0)), (184, 51, 74)),
|
||||
("needs-guidance", str(counts.get("needs-guidance", 0)), (179, 87, 23)),
|
||||
("capture-failed", str(counts.get("capture-failed", 0)), (153, 52, 52)),
|
||||
("pass", str(counts.get("pass", 0)), (76, 115, 42)),
|
||||
]
|
||||
x = 0.62
|
||||
for title, value, color in cards:
|
||||
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(1.35), Inches(2.3), Inches(1.18))
|
||||
card.fill.solid()
|
||||
card.fill.fore_color.rgb = RGBColor(247, 249, 252)
|
||||
card.line.color.rgb = RGBColor(218, 224, 232)
|
||||
add_text(slide, title, x + 0.18, 1.55, 1.95, 0.24, 9.5, (88, 101, 120), True, PP_ALIGN.CENTER)
|
||||
add_text(slide, value, x + 0.18, 1.88, 1.95, 0.4, 24, color, True, PP_ALIGN.CENTER)
|
||||
x += 2.45
|
||||
|
||||
add_text(slide, "핵심 판단 축", 0.7, 3.02, 4.0, 0.3, 17, (22, 27, 36), True)
|
||||
summary_bullets = [
|
||||
"RGB txt에 색상 섹션이 없거나, heuristic 매핑 기준 확인이 필요한 컷이 다수 있음.",
|
||||
"공백이 포함된 suffix 태그('data01 1', '순위01 2' 등)는 정식 태그인지 재검증 필요.",
|
||||
"HD/L/loop/2880/810/8316 계열에서 기준 컷과 태그 체계가 달라지는 항목이 있음.",
|
||||
"일부 컷은 index out of range 캡처 실패가 발생하여 런타임 쪽 확인이 필요함.",
|
||||
]
|
||||
add_bullets(slide, summary_bullets, 0.7, 3.45, 12.0, 2.4)
|
||||
add_footer(slide, "Counts are from results.csv; issue list follows the user-provided review notes.")
|
||||
|
||||
|
||||
def add_no_issue_slides(prs: Presentation):
|
||||
for idx in range(0, len(NO_ISSUE_ITEMS), 12):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (255, 255, 255))
|
||||
suffix = "" if idx == 0 else f" ({idx // 12 + 1})"
|
||||
add_header(slide, f"특이사항 발견 못함{suffix}")
|
||||
chunk = NO_ISSUE_ITEMS[idx:idx + 12]
|
||||
add_bullets(slide, chunk, 0.85, 1.35, 11.7, 5.25)
|
||||
add_footer(slide, "사용자 기록 기준: 별도 이상 징후 없음으로 분류된 컷")
|
||||
|
||||
|
||||
def add_badges(slide, badges: Iterable[str], x: float, y: float):
|
||||
if not badges:
|
||||
return
|
||||
add_text(slide, ", ".join(badges), x, y, 7.8, 0.24, 9.5, (104, 116, 133))
|
||||
|
||||
|
||||
def add_bullets(slide, bullets: list[str], x: float, y: float, w: float, h: float):
|
||||
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
tf = box.text_frame
|
||||
tf.clear()
|
||||
tf.margin_left = Inches(0.02)
|
||||
tf.margin_right = Inches(0.02)
|
||||
tf.margin_top = Inches(0.02)
|
||||
tf.margin_bottom = Inches(0.02)
|
||||
tf.word_wrap = True
|
||||
|
||||
size = 11.4 if len(bullets) <= 3 else 10.4
|
||||
for idx, bullet in enumerate(bullets):
|
||||
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||||
p.text = f"- {bullet}"
|
||||
p.space_after = Pt(6)
|
||||
p.font.name = FONT
|
||||
p.font.size = Pt(size)
|
||||
p.font.color.rgb = RGBColor(31, 41, 55)
|
||||
|
||||
|
||||
def preprocess_image(path: Path) -> Path:
|
||||
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
|
||||
out = MEDIA_DIR / f"clean_{digest}_{path.name}"
|
||||
if out.exists():
|
||||
return out
|
||||
with Image.open(path) as im:
|
||||
if im.mode in ("RGBA", "LA") or ("transparency" in im.info):
|
||||
im = im.convert("RGBA")
|
||||
bg = Image.new("RGBA", im.size, (255, 255, 255, 255))
|
||||
im = Image.alpha_composite(bg, im).convert("RGB")
|
||||
else:
|
||||
im = im.convert("RGB")
|
||||
im.save(out, quality=95)
|
||||
return out
|
||||
|
||||
|
||||
def add_image(slide, path: Path, caption: str, x: float, y: float, w: float, h: float):
|
||||
prepped = preprocess_image(path)
|
||||
caption_h = 0.28
|
||||
ix, iy, iw, ih = fit_box(prepped, x, y, w, h - caption_h)
|
||||
slide.shapes.add_picture(str(prepped), Inches(ix), Inches(iy), Inches(iw), Inches(ih))
|
||||
add_text(slide, caption, x, y + h - caption_h + 0.05, w, 0.18, 6.8, (92, 103, 118), False, PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
def add_issue_slide(prs: Presentation, section: dict, rows: list[dict[str, str]], pngs: list[Path]):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (255, 255, 255))
|
||||
add_header(slide, section["title"])
|
||||
add_badges(slide, section.get("badges", []), 0.67, 0.73)
|
||||
|
||||
images = image_items_for_section(section, rows, pngs)
|
||||
if not images:
|
||||
add_empty_image_note(slide, 0.68, 1.28, 7.25, 4.85)
|
||||
elif len(images) == 1:
|
||||
add_image(slide, images[0][0], images[0][1], 0.68, 1.28, 7.25, 4.85)
|
||||
else:
|
||||
boxes = [
|
||||
(0.68, 1.28, 3.55, 2.45),
|
||||
(4.40, 1.28, 3.55, 2.45),
|
||||
(0.68, 4.08, 3.55, 2.45),
|
||||
(4.40, 4.08, 3.55, 2.45),
|
||||
]
|
||||
for (path, caption), (x, y, w, h) in zip(images, boxes):
|
||||
add_image(slide, path, caption, x, y, w, h)
|
||||
|
||||
add_text(slide, "확인 내용", 8.45, 1.28, 3.95, 0.3, 13, (22, 27, 36), True)
|
||||
add_bullets(slide, section["bullets"], 8.45, 1.72, 4.15, 4.65)
|
||||
add_footer(slide, "Source: PGM DWM 10s recapture first, full_20260509_114655 and Assets/Thumbnail fallback")
|
||||
|
||||
|
||||
def add_empty_image_note(slide, x: float, y: float, w: float, h: float):
|
||||
add_text(slide, "매칭 가능한 캡처/썸네일 없음", x, y + h / 2 - 0.18, w, 0.35, 14, (95, 106, 122), True, PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
def add_header(slide, title: str):
|
||||
add_text(slide, title, 0.62, 0.28, 11.9, 0.42, 21, (22, 27, 36), True)
|
||||
|
||||
|
||||
def add_footer(slide, text: str):
|
||||
add_text(slide, text, 0.66, 7.08, 12.1, 0.2, 7.3, (130, 140, 154))
|
||||
|
||||
|
||||
def add_title_slide(prs: Presentation):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (255, 255, 255))
|
||||
add_text(slide, "Tornado3 2026 Election", 0.78, 1.25, 11.6, 0.4, 17, (91, 105, 124), True)
|
||||
add_text(slide, "디자인 태그/색상 이슈 정리", 0.78, 1.78, 11.6, 0.72, 33, (22, 27, 36), True)
|
||||
add_text(slide, "PGM 재캡처 적용본 · 2026-05-10", 0.8, 2.82, 8.8, 0.35, 14, (80, 92, 110))
|
||||
add_text(slide, "캡처 기준: artifacts/cut-file-audit/stable_pgm_dwm_20260510_10s", 0.8, 3.32, 10.8, 0.3, 11.5, (104, 116, 133))
|
||||
add_text(slide, "RGB txt 지침 누락, 기본 컷 색상 차이, 태그 suffix/누락 의심, 계열별 태그 불일치, 캡처 실패 항목을 정리했습니다.", 0.8, 4.08, 11.4, 0.55, 13, (49, 60, 75))
|
||||
|
||||
|
||||
def add_summary_slide(prs: Presentation, rows: list[dict[str, str]]):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (255, 255, 255))
|
||||
add_header(slide, "요약")
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
status = row.get("Status", "")
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
summary_lines = [
|
||||
f"이슈/확인 필요 섹션: {len(SECTIONS)}",
|
||||
f"issue: {counts.get('issue', 0)}",
|
||||
f"needs-guidance: {counts.get('needs-guidance', 0)}",
|
||||
f"capture-failed: {counts.get('capture-failed', 0)}",
|
||||
f"pass: {counts.get('pass', 0)}",
|
||||
]
|
||||
for idx, line in enumerate(summary_lines):
|
||||
add_text(slide, line, 0.82, 1.28 + idx * 0.44, 4.8, 0.32, 15, (31, 41, 55), idx == 0)
|
||||
|
||||
add_text(slide, "핵심 판단 축", 0.82, 4.0, 4.0, 0.3, 17, (22, 27, 36), True)
|
||||
summary_bullets = [
|
||||
"RGB txt에 색상 섹션이 없거나 heuristic 매핑 기준 확인이 필요한 컷이 다수 있음.",
|
||||
"공백이 포함된 suffix 태그와 누락 의심 태그는 정식 태그인지 재검증 필요.",
|
||||
"HD/L/loop/2880/810/8316 계열에서 기준 컷과 태그 체계가 달라지는 항목이 있음.",
|
||||
"일부 컷은 index out of range 캡처 실패가 발생해 원본 쪽 확인 필요.",
|
||||
]
|
||||
add_bullets(slide, summary_bullets, 0.82, 4.48, 11.5, 1.7)
|
||||
add_footer(slide, "Counts are from results.csv; issue list follows the user-provided review notes.")
|
||||
|
||||
|
||||
def add_no_issue_slides(prs: Presentation):
|
||||
for idx in range(0, len(NO_ISSUE_ITEMS), 12):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
set_background(slide, (255, 255, 255))
|
||||
suffix = "" if idx == 0 else f" ({idx // 12 + 1})"
|
||||
add_header(slide, f"특이사항 발견 못함{suffix}")
|
||||
chunk = NO_ISSUE_ITEMS[idx:idx + 12]
|
||||
add_bullets(slide, chunk, 0.86, 1.28, 11.5, 5.25)
|
||||
add_footer(slide, "사용자 기록 기준: 별도 이상 징후 없음으로 분류된 컷")
|
||||
|
||||
|
||||
def build():
|
||||
summary_rows = load_rows([STABLE_AUDIT_DIR / "results.csv"])
|
||||
image_rows = load_rows([STABLE_AUDIT_DIR / "results.csv", RESULTS_CSV])
|
||||
pngs = all_pngs(STABLE_AUDIT_DIR / "captures", CAPTURE_DIR, THUMB_DIR, LIVE_DIR)
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
|
||||
add_title_slide(prs)
|
||||
add_summary_slide(prs, summary_rows)
|
||||
for section in SECTIONS:
|
||||
add_issue_slide(prs, section, image_rows, pngs)
|
||||
add_no_issue_slides(prs)
|
||||
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(OUT_PATH)
|
||||
print(OUT_PATH)
|
||||
print(f"slides={len(prs.slides)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
File diff suppressed because it is too large
Load Diff
1916
tools/KarismaTcpProbe/CutFileAudit.cs
Normal file
1916
tools/KarismaTcpProbe/CutFileAudit.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tornado3_2026Election.Domain;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public sealed class KarismaThumbnailGeneratorService
|
||||
{
|
||||
public KarismaThumbnailGeneratorService(LogService logService)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<ThumbnailGenerationResult> GenerateAsync(
|
||||
TornadoManager manager,
|
||||
IReadOnlyList<FormatTemplateDefinition> templates,
|
||||
string t3CutPath,
|
||||
VideoWallLayoutPreset videoWallLayoutPreset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new ThumbnailGenerationResult(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct ThumbnailGenerationResult(int GeneratedCount, int FailedCount)
|
||||
{
|
||||
public int TotalCount => GeneratedCount + FailedCount;
|
||||
}
|
||||
@@ -337,6 +337,12 @@ if (args.Length > 0 && string.Equals(args[0], "--report-cut-debug-coverage", Str
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--audit-cut-files", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = await CutFileAudit.RunAsync(args[1..]).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var options = ProbeOptions.Parse(args);
|
||||
|
||||
Console.WriteLine($"Karisma TCP probe starting. target={options.Host}:{options.Port} timeout={options.Timeout.TotalSeconds:0}s");
|
||||
@@ -862,6 +868,25 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<SceneValidationOperation> operations = string.IsNullOrWhiteSpace(options.OperationsPath)
|
||||
? Array.Empty<SceneValidationOperation>()
|
||||
: LoadSceneOperations(options.ScenePath, options.OperationsPath);
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
var operationResult = ApplySceneOperation(handler, scene!, operation, options.Connection.Timeout);
|
||||
if (!string.Equals(operationResult.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(
|
||||
true,
|
||||
"SUCCESS",
|
||||
operationResult.Result,
|
||||
options.OutputPath,
|
||||
$"Operation {operationResult.Method} failed for '{operationResult.ObjectName}': {operationResult.Detail}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.MaterialOpacity is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
@@ -1283,7 +1308,16 @@ static Task<SceneCatalogProbeResult> CatalogScenesAsync(SceneCatalogOptions opti
|
||||
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
|
||||
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
|
||||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
RelativePath = Path.GetRelativePath(options.RootPath, path)
|
||||
})
|
||||
.OrderBy(item => item.RelativePath.Count(character =>
|
||||
character == Path.DirectorySeparatorChar ||
|
||||
character == Path.AltDirectorySeparatorChar))
|
||||
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => item.Path)
|
||||
.Take(options.MaxScenes ?? int.MaxValue)
|
||||
.ToArray();
|
||||
|
||||
@@ -2309,142 +2343,7 @@ static Task<SceneValidationProbeResult> ValidateSceneOperationsAsync(SceneValida
|
||||
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
|
||||
var sceneObject = scene.GetObject(operation.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"OBJECT_NOT_FOUND",
|
||||
"scene.GetObject returned null."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKACounter counter)
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_COUNTER",
|
||||
"Object does not implement IKACounter."));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetCounterNumberKeyTask();
|
||||
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
|
||||
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetCounterNumberKey timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.CounterNumberKeyTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
handler.ResetVisibleTask();
|
||||
sceneObject.SetVisible(operation.Visible ? 1 : 0);
|
||||
if (!WaitForTaskWithMessagePump(handler.VisibleTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetVisible timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.VisibleTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKAStyle style)
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_STYLE_OBJECT",
|
||||
"Object does not implement IKAStyle."));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetStyleColorTask();
|
||||
style.SetStyleColor(
|
||||
ParseStyleType(operation.StyleType),
|
||||
operation.Order,
|
||||
operation.R,
|
||||
operation.G,
|
||||
operation.B,
|
||||
operation.A);
|
||||
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetStyleColor timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.StyleColorTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetSetValueTask();
|
||||
sceneObject.SetValue(operation.Value ?? string.Empty);
|
||||
if (!WaitForTaskWithMessagePump(handler.SetValueTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetValue timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var setValueResult = handler.SetValueTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
setValueResult.ToString(),
|
||||
string.Empty));
|
||||
results.Add(ApplySceneOperation(handler, scene, operation, options.Connection.Timeout));
|
||||
}
|
||||
|
||||
WriteSceneValidationMarkdown(options, results);
|
||||
@@ -2532,7 +2431,16 @@ static Task<FolderInspectionProbeResult> InspectTscnFolderAsync(FolderInspection
|
||||
.EnumerateFiles(options.RootPath, "*.tscn", SearchOption.AllDirectories)
|
||||
.Where(path => string.IsNullOrWhiteSpace(options.SceneFilter) ||
|
||||
path.Contains(options.SceneFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
RelativePath = Path.GetRelativePath(options.RootPath, path)
|
||||
})
|
||||
.OrderBy(item => item.RelativePath.Count(character =>
|
||||
character == Path.DirectorySeparatorChar ||
|
||||
character == Path.AltDirectorySeparatorChar))
|
||||
.ThenBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => item.Path)
|
||||
.Take(options.MaxScenes ?? int.MaxValue)
|
||||
.ToArray();
|
||||
|
||||
@@ -2865,7 +2773,12 @@ static void WriteFolderInspectionMarkdown(
|
||||
|
||||
static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOptions options)
|
||||
{
|
||||
var json = File.ReadAllText(options.OperationsPath);
|
||||
return LoadSceneOperations(options.ScenePath, options.OperationsPath);
|
||||
}
|
||||
|
||||
static List<SceneValidationOperation> LoadSceneOperations(string scenePath, string operationsPath)
|
||||
{
|
||||
var json = File.ReadAllText(operationsPath);
|
||||
var operations = JsonSerializer.Deserialize<List<SceneValidationOperation>>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
@@ -2875,13 +2788,143 @@ static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOp
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(operation.Value))
|
||||
{
|
||||
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(options.ScenePath) ?? string.Empty, StringComparison.Ordinal);
|
||||
operation.Value = operation.Value.Replace("${SCENE_DIR}", Path.GetDirectoryName(scenePath) ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
static SceneOperationValidationResult ApplySceneOperation(
|
||||
ProbeEventHandler handler,
|
||||
IKAScene scene,
|
||||
SceneValidationOperation operation,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
Console.WriteLine($"[VALIDATE] {operation.Method} object={operation.ObjectName}");
|
||||
var sceneObject = scene.GetObject(operation.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"OBJECT_NOT_FOUND",
|
||||
"scene.GetObject returned null.");
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKACounter counter)
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_COUNTER",
|
||||
"Object does not implement IKACounter.");
|
||||
}
|
||||
|
||||
handler.ResetCounterNumberKeyTask();
|
||||
counter.SetCounterNumberKey(operation.KeyIndex, operation.Number);
|
||||
if (!WaitForTaskWithMessagePump(handler.CounterNumberKeyTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetCounterNumberKey timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.CounterNumberKeyTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
handler.ResetVisibleTask();
|
||||
sceneObject.SetVisible(operation.Visible ? 1 : 0);
|
||||
if (!WaitForTaskWithMessagePump(handler.VisibleTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetVisible timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.VisibleTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKAStyle style)
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_STYLE_OBJECT",
|
||||
"Object does not implement IKAStyle.");
|
||||
}
|
||||
|
||||
handler.ResetStyleColorTask();
|
||||
style.SetStyleColor(
|
||||
ParseStyleType(operation.StyleType),
|
||||
operation.Order,
|
||||
operation.R,
|
||||
operation.G,
|
||||
operation.B,
|
||||
operation.A);
|
||||
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetStyleColor timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.StyleColorTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
handler.ResetSetValueTask();
|
||||
sceneObject.SetValue(operation.Value ?? string.Empty);
|
||||
if (!WaitForTaskWithMessagePump(handler.SetValueTask, timeout))
|
||||
{
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetValue timed out.");
|
||||
}
|
||||
|
||||
return new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
handler.SetValueTask.Result.ToString(),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
static string DescribeOperationPayload(SceneValidationOperation operation)
|
||||
{
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -3393,6 +3436,7 @@ internal sealed record SaveSceneImageOptions(
|
||||
bool? VisibleObjectValue,
|
||||
VariableNameUpdate? VariableName,
|
||||
CloneObjectUpdate? CloneObject,
|
||||
string? OperationsPath,
|
||||
MaterialOpacityUpdate? MaterialOpacity,
|
||||
SizeUpdate? Size,
|
||||
PositionUpdate? Position,
|
||||
@@ -3419,6 +3463,7 @@ internal sealed record SaveSceneImageOptions(
|
||||
string? variableNameValue = null;
|
||||
string? cloneSourceObjectName = null;
|
||||
string? cloneVariableName = null;
|
||||
string? operationsPath = null;
|
||||
string? materialOpacityObjectName = null;
|
||||
float? materialOpacityValue = null;
|
||||
string? sizeObjectName = null;
|
||||
@@ -3481,6 +3526,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
case "--clone-name" when index + 1 < args.Length:
|
||||
cloneVariableName = args[++index];
|
||||
break;
|
||||
case "--operations" when index + 1 < args.Length:
|
||||
operationsPath = args[++index];
|
||||
break;
|
||||
case "--material-opacity-object" when index + 1 < args.Length:
|
||||
materialOpacityObjectName = args[++index];
|
||||
break;
|
||||
@@ -3558,6 +3606,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
|
||||
scenePath = Path.GetFullPath(scenePath);
|
||||
outputPath = Path.GetFullPath(outputPath);
|
||||
operationsPath = string.IsNullOrWhiteSpace(operationsPath)
|
||||
? null
|
||||
: Path.GetFullPath(operationsPath);
|
||||
sceneAlias ??= Path.GetFileNameWithoutExtension(scenePath);
|
||||
return new SaveSceneImageOptions(
|
||||
connection,
|
||||
@@ -3573,6 +3624,7 @@ internal sealed record SaveSceneImageOptions(
|
||||
visibleObjectValue,
|
||||
ParseVariableName(variableNameObjectName, variableNameValue),
|
||||
ParseCloneObject(cloneSourceObjectName, cloneVariableName),
|
||||
operationsPath,
|
||||
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
|
||||
ParseSize(sizeObjectName, sizeRaw),
|
||||
ParsePosition(positionObjectName, positionRaw),
|
||||
@@ -3857,7 +3909,7 @@ internal sealed record SceneCatalogOptions(
|
||||
switch (args[index])
|
||||
{
|
||||
case "--root" when index + 1 < args.Length:
|
||||
index++;
|
||||
rootPath = args[++index];
|
||||
break;
|
||||
case "--output" when index + 1 < args.Length:
|
||||
outputPath = args[++index];
|
||||
@@ -4195,7 +4247,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
|
||||
switch (args[index])
|
||||
{
|
||||
case "--root" when index + 1 < args.Length:
|
||||
index++;
|
||||
rootPath = args[++index];
|
||||
break;
|
||||
case "--output" when index + 1 < args.Length:
|
||||
outputPath = args[++index];
|
||||
|
||||
99
tools/msix/Install-ComtrophyMsixCertificate.ps1
Normal file
99
tools/msix/Install-ComtrophyMsixCertificate.ps1
Normal file
@@ -0,0 +1,99 @@
|
||||
param(
|
||||
[string]$CertificatePath = (Join-Path $PSScriptRoot "Comtrophy_MSIX_Signing.cer"),
|
||||
[string]$CertificateUri = "http://122.34.248.185/msix/Comtrophy_MSIX_Signing.cer",
|
||||
[ValidateSet("LocalMachine", "CurrentUser")]
|
||||
[string]$StoreScope = "LocalMachine",
|
||||
[switch]$NoElevate,
|
||||
[switch]$NoPause
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ExpectedThumbprint = "E691A33C64DF20A204FFD4F096B9C3EB4B95709C"
|
||||
$downloadedCertificate = $false
|
||||
|
||||
function Test-IsAdministrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Quote-Argument {
|
||||
param([Parameter(Mandatory)][string]$Value)
|
||||
|
||||
'"' + $Value.Replace('"', '\"') + '"'
|
||||
}
|
||||
|
||||
if ($StoreScope -eq "LocalMachine" -and -not (Test-IsAdministrator)) {
|
||||
if ($NoElevate) {
|
||||
throw "LocalMachine certificate import requires an elevated PowerShell session."
|
||||
}
|
||||
|
||||
if (-not $PSCommandPath) {
|
||||
throw "LocalMachine certificate import requires an elevated PowerShell session."
|
||||
}
|
||||
|
||||
Write-Host "Restarting as administrator to trust the MSIX signing certificate for this PC..."
|
||||
$arguments = @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", (Quote-Argument $PSCommandPath),
|
||||
"-CertificatePath", (Quote-Argument $CertificatePath),
|
||||
"-CertificateUri", (Quote-Argument $CertificateUri),
|
||||
"-StoreScope", $StoreScope,
|
||||
"-NoElevate"
|
||||
)
|
||||
if ($NoPause) {
|
||||
$arguments += "-NoPause"
|
||||
}
|
||||
|
||||
$process = Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Verb RunAs -Wait -PassThru
|
||||
exit $process.ExitCode
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CertificatePath)) {
|
||||
$certificateDirectory = Split-Path -Parent $CertificatePath
|
||||
if ($certificateDirectory -and -not (Test-Path -LiteralPath $certificateDirectory)) {
|
||||
New-Item -ItemType Directory -Path $certificateDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "Downloading MSIX signing certificate..."
|
||||
Invoke-WebRequest -Uri $CertificateUri -OutFile $CertificatePath -UseBasicParsing
|
||||
$downloadedCertificate = $true
|
||||
}
|
||||
|
||||
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath)
|
||||
if ($certificate.Thumbprint -ne $ExpectedThumbprint) {
|
||||
throw "Unexpected certificate thumbprint. Expected $ExpectedThumbprint but got $($certificate.Thumbprint)."
|
||||
}
|
||||
|
||||
$stores = @(
|
||||
"Cert:\$StoreScope\TrustedPeople",
|
||||
"Cert:\$StoreScope\Root"
|
||||
)
|
||||
|
||||
foreach ($store in $stores) {
|
||||
$existing = Get-ChildItem -Path $store | Where-Object { $_.Thumbprint -eq $ExpectedThumbprint }
|
||||
if ($existing) {
|
||||
Write-Host "Certificate already trusted in $store"
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "Importing certificate into $store"
|
||||
Import-Certificate -FilePath $CertificatePath -CertStoreLocation $store | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "MSIX signing certificate is trusted in $StoreScope for thumbprint $ExpectedThumbprint."
|
||||
Write-Host ""
|
||||
Write-Host "Certificate setup is complete."
|
||||
Write-Host "Install the app separately with this link:"
|
||||
Write-Host "http://122.34.248.185/msix/Tornado3_2026Election_x64.appinstaller"
|
||||
|
||||
if ($downloadedCertificate) {
|
||||
Write-Host "Certificate saved to $CertificatePath"
|
||||
}
|
||||
|
||||
if (-not $NoPause) {
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter to close this window"
|
||||
}
|
||||
475
tools/msix/Publish-MsixToNas.ps1
Normal file
475
tools/msix/Publish-MsixToNas.ps1
Normal file
@@ -0,0 +1,475 @@
|
||||
param(
|
||||
[string]$ProjectPath = (Join-Path $PSScriptRoot "..\..\Tornado3_2026Election\Tornado3_2026Election.csproj"),
|
||||
[ValidateSet("Debug", "Release")]
|
||||
[string]$Configuration = "Release",
|
||||
[string]$Platform = "x64",
|
||||
[string]$RuntimeIdentifier = "win-x64",
|
||||
[ValidatePattern("^\d+\.\d+\.\d+\.\d+$")]
|
||||
[string]$PackageVersion,
|
||||
[switch]$IncrementPackageRevision,
|
||||
[string]$PublicBaseUri = "http://122.34.248.185/msix/",
|
||||
[string]$NasHost = "192.168.200.129",
|
||||
[int]$NasSshPort = 22,
|
||||
[string]$NasUser = $env:NAS_USER,
|
||||
[string]$NasRemotePath = "/volume1/web/msix",
|
||||
[string]$SshKeyPath = $env:NAS_SSH_KEY,
|
||||
[string]$CertificateThumbprint = "E691A33C64DF20A204FFD4F096B9C3EB4B95709C",
|
||||
[string]$CertificateFileName = "Comtrophy_MSIX_Signing.cer",
|
||||
[string]$InstallCertificateScriptPath = (Join-Path $PSScriptRoot "Install-ComtrophyMsixCertificate.ps1"),
|
||||
[switch]$SkipPackageBuild,
|
||||
[switch]$NoUpload,
|
||||
[switch]$NoVerify
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-FullPath {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
return (Resolve-Path -LiteralPath $Path).Path
|
||||
}
|
||||
|
||||
$executionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
|
||||
}
|
||||
|
||||
function Invoke-Checked {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$FilePath,
|
||||
[Parameter(Mandatory)][string[]]$Arguments
|
||||
)
|
||||
|
||||
Write-Host "> $FilePath $($Arguments -join ' ')"
|
||||
& $FilePath @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$FilePath failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsUnderDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Parameter(Mandatory)][string]$Directory
|
||||
)
|
||||
|
||||
$normalizedPath = [System.IO.Path]::GetFullPath($Path).TrimEnd(
|
||||
[System.IO.Path]::DirectorySeparatorChar,
|
||||
[System.IO.Path]::AltDirectorySeparatorChar
|
||||
)
|
||||
$normalizedDirectory = [System.IO.Path]::GetFullPath($Directory).TrimEnd(
|
||||
[System.IO.Path]::DirectorySeparatorChar,
|
||||
[System.IO.Path]::AltDirectorySeparatorChar
|
||||
)
|
||||
|
||||
return $normalizedPath.Equals($normalizedDirectory, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$normalizedPath.StartsWith(
|
||||
$normalizedDirectory + [System.IO.Path]::DirectorySeparatorChar,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
$normalizedPath.StartsWith(
|
||||
$normalizedDirectory + [System.IO.Path]::AltDirectorySeparatorChar,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)
|
||||
}
|
||||
|
||||
function Find-NewestFile {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Root,
|
||||
[Parameter(Mandatory)][string]$Filter,
|
||||
[DateTime]$NotBefore = [DateTime]::MinValue,
|
||||
[string]$ExcludeDirectory
|
||||
)
|
||||
|
||||
$resolvedExcludeDirectory = if ($ExcludeDirectory -and (Test-Path -LiteralPath $ExcludeDirectory)) {
|
||||
Resolve-FullPath $ExcludeDirectory
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
|
||||
Get-ChildItem -Path $Root -Recurse -File -Filter $Filter |
|
||||
Where-Object {
|
||||
$_.LastWriteTime -ge $NotBefore -and
|
||||
(-not $resolvedExcludeDirectory -or -not (Test-IsUnderDirectory -Path $_.FullName -Directory $resolvedExcludeDirectory))
|
||||
} |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
function Find-PackageFileByLeafName {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$PackageRoot,
|
||||
[Parameter(Mandatory)][string]$LeafName,
|
||||
[string]$PreferredPathFragment,
|
||||
[string]$ExcludeDirectory
|
||||
)
|
||||
|
||||
$resolvedExcludeDirectory = if ($ExcludeDirectory -and (Test-Path -LiteralPath $ExcludeDirectory)) {
|
||||
Resolve-FullPath $ExcludeDirectory
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
|
||||
$candidates = Get-ChildItem -Path $PackageRoot -Recurse -File -Filter $LeafName |
|
||||
Where-Object {
|
||||
-not $resolvedExcludeDirectory -or
|
||||
-not (Test-IsUnderDirectory -Path $_.FullName -Directory $resolvedExcludeDirectory)
|
||||
}
|
||||
|
||||
if ($PreferredPathFragment) {
|
||||
$preferred = $candidates |
|
||||
Where-Object { $_.FullName -like "*$PreferredPathFragment*" } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($preferred) {
|
||||
return $preferred
|
||||
}
|
||||
}
|
||||
|
||||
$candidates | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Get-UriLeafName {
|
||||
param([Parameter(Mandatory)][string]$Uri)
|
||||
|
||||
try {
|
||||
return [System.IO.Path]::GetFileName(([System.Uri]$Uri).AbsolutePath)
|
||||
}
|
||||
catch {
|
||||
return [System.IO.Path]::GetFileName($Uri.Replace("/", "\"))
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-CertificateFile {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$CertificatePath,
|
||||
[Parameter(Mandatory)][string]$Thumbprint
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $CertificatePath) {
|
||||
return
|
||||
}
|
||||
|
||||
$certificate = Get-ChildItem -Path Cert:\CurrentUser\My |
|
||||
Where-Object { $_.Thumbprint -eq $Thumbprint } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $certificate) {
|
||||
throw "Could not find signing certificate $Thumbprint in Cert:\CurrentUser\My."
|
||||
}
|
||||
|
||||
Export-Certificate -Cert $certificate -FilePath $CertificatePath -Force | Out-Null
|
||||
}
|
||||
|
||||
function Get-PackageManifestVersion {
|
||||
param([Parameter(Mandatory)][string]$ManifestPath)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ManifestPath)) {
|
||||
throw "Package manifest was not found: $ManifestPath"
|
||||
}
|
||||
|
||||
$manifestXml = New-Object System.Xml.XmlDocument
|
||||
$manifestXml.Load($ManifestPath)
|
||||
|
||||
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable)
|
||||
$namespaceManager.AddNamespace("pkg", $manifestXml.DocumentElement.NamespaceURI)
|
||||
|
||||
$identityNode = $manifestXml.SelectSingleNode("/pkg:Package/pkg:Identity", $namespaceManager)
|
||||
if (-not $identityNode) {
|
||||
throw "Identity node was not found in $ManifestPath."
|
||||
}
|
||||
|
||||
$identityNode.GetAttribute("Version")
|
||||
}
|
||||
|
||||
function Get-NextPackageRevision {
|
||||
param([Parameter(Mandatory)][string]$Version)
|
||||
|
||||
$parts = $Version.Split(".")
|
||||
if ($parts.Count -ne 4) {
|
||||
throw "Package version must have four parts: $Version"
|
||||
}
|
||||
|
||||
$numbers = foreach ($part in $parts) {
|
||||
[int]$part
|
||||
}
|
||||
$numbers[3] += 1
|
||||
|
||||
$numbers -join "."
|
||||
}
|
||||
|
||||
function Set-PackageManifestVersion {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ManifestPath,
|
||||
[Parameter(Mandatory)][string]$Version
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ManifestPath)) {
|
||||
throw "Package manifest was not found: $ManifestPath"
|
||||
}
|
||||
|
||||
$manifestXml = New-Object System.Xml.XmlDocument
|
||||
$manifestXml.PreserveWhitespace = $true
|
||||
$manifestXml.Load($ManifestPath)
|
||||
|
||||
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($manifestXml.NameTable)
|
||||
$namespaceManager.AddNamespace("pkg", $manifestXml.DocumentElement.NamespaceURI)
|
||||
|
||||
$identityNode = $manifestXml.SelectSingleNode("/pkg:Package/pkg:Identity", $namespaceManager)
|
||||
if (-not $identityNode) {
|
||||
throw "Identity node was not found in $ManifestPath."
|
||||
}
|
||||
|
||||
$currentVersion = $identityNode.GetAttribute("Version")
|
||||
if ($currentVersion -eq $Version) {
|
||||
Write-Host "Package manifest version is already $Version"
|
||||
return
|
||||
}
|
||||
|
||||
$identityNode.SetAttribute("Version", $Version)
|
||||
$manifestXml.Save($ManifestPath)
|
||||
Write-Host "Updated package manifest version from $currentVersion to $Version"
|
||||
}
|
||||
|
||||
function Join-PublicUri {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$BaseUri,
|
||||
[Parameter(Mandatory)][string]$LeafName
|
||||
)
|
||||
|
||||
$normalizedBase = $BaseUri
|
||||
if (-not $normalizedBase.EndsWith("/")) {
|
||||
$normalizedBase += "/"
|
||||
}
|
||||
|
||||
"$normalizedBase$LeafName"
|
||||
}
|
||||
|
||||
$projectFullPath = Resolve-FullPath $ProjectPath
|
||||
$projectDirectory = Split-Path -Parent $projectFullPath
|
||||
$manifestPath = Join-Path $projectDirectory "Package.appxmanifest"
|
||||
$packageRoot = Join-Path $projectDirectory "AppPackages"
|
||||
$stagingRoot = Join-Path $packageRoot "msix-publish-flat"
|
||||
$certificatePath = Join-Path $stagingRoot $CertificateFileName
|
||||
$normalizedPublicBaseUri = $PublicBaseUri
|
||||
if (-not $normalizedPublicBaseUri.EndsWith("/")) {
|
||||
$normalizedPublicBaseUri += "/"
|
||||
}
|
||||
|
||||
if ($PackageVersion -and $IncrementPackageRevision) {
|
||||
throw "Use either PackageVersion or IncrementPackageRevision, not both."
|
||||
}
|
||||
|
||||
if ($PackageVersion -and $SkipPackageBuild) {
|
||||
throw "PackageVersion requires a new package build. Remove -SkipPackageBuild and run again."
|
||||
}
|
||||
|
||||
if ($IncrementPackageRevision -and $SkipPackageBuild) {
|
||||
throw "IncrementPackageRevision requires a new package build. Remove -SkipPackageBuild and run again."
|
||||
}
|
||||
|
||||
if ($IncrementPackageRevision) {
|
||||
$currentPackageVersion = Get-PackageManifestVersion -ManifestPath $manifestPath
|
||||
$PackageVersion = Get-NextPackageRevision -Version $currentPackageVersion
|
||||
Write-Host "Auto-incrementing package version from $currentPackageVersion to $PackageVersion"
|
||||
}
|
||||
|
||||
if ($PackageVersion) {
|
||||
Set-PackageManifestVersion -ManifestPath $manifestPath -Version $PackageVersion
|
||||
}
|
||||
|
||||
$signingCertificate = Get-ChildItem -Path Cert:\CurrentUser\My |
|
||||
Where-Object { $_.Thumbprint -eq $CertificateThumbprint } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $signingCertificate) {
|
||||
throw "Signing certificate $CertificateThumbprint was not found in Cert:\CurrentUser\My."
|
||||
}
|
||||
|
||||
if (-not $signingCertificate.HasPrivateKey) {
|
||||
throw "Signing certificate $CertificateThumbprint does not have a private key."
|
||||
}
|
||||
|
||||
$buildStartedAt = Get-Date
|
||||
|
||||
if (-not $SkipPackageBuild) {
|
||||
$dotnetArgs = @(
|
||||
"msbuild",
|
||||
$projectFullPath,
|
||||
"/restore",
|
||||
"/t:Build",
|
||||
"/p:Configuration=$Configuration",
|
||||
"/p:Platform=$Platform",
|
||||
"/p:RuntimeIdentifier=$RuntimeIdentifier",
|
||||
"/p:GenerateAppxPackageOnBuild=true",
|
||||
"/p:GenerateAppInstallerFile=true",
|
||||
"/p:AppxPackageSigningEnabled=true",
|
||||
"/p:PackageCertificateThumbprint=$CertificateThumbprint",
|
||||
"/p:AppInstallerUri=$normalizedPublicBaseUri",
|
||||
"/p:AppxBundle=Never"
|
||||
)
|
||||
Invoke-Checked -FilePath "dotnet" -Arguments $dotnetArgs
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $stagingRoot) {
|
||||
$resolvedStaging = Resolve-FullPath $stagingRoot
|
||||
$resolvedPackageRoot = Resolve-FullPath $packageRoot
|
||||
if (-not (Test-IsUnderDirectory -Path $resolvedStaging -Directory $resolvedPackageRoot)) {
|
||||
throw "Refusing to clean staging path outside package root: $resolvedStaging"
|
||||
}
|
||||
Get-ChildItem -LiteralPath $stagingRoot -Force | Remove-Item -Recurse -Force
|
||||
}
|
||||
else {
|
||||
New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
$appInstallerFile = Find-NewestFile -Root $packageRoot -Filter "*_${Platform}.appinstaller" -NotBefore $(if ($SkipPackageBuild) { [DateTime]::MinValue } else { $buildStartedAt.AddMinutes(-2) }) -ExcludeDirectory $stagingRoot
|
||||
if (-not $appInstallerFile) {
|
||||
$appInstallerFile = Find-NewestFile -Root $packageRoot -Filter "*.appinstaller" -ExcludeDirectory $stagingRoot
|
||||
}
|
||||
|
||||
if (-not $appInstallerFile) {
|
||||
throw "Could not find an .appinstaller file under $packageRoot."
|
||||
}
|
||||
|
||||
$stagedAppInstallerPath = Join-Path $stagingRoot $appInstallerFile.Name
|
||||
Copy-Item -LiteralPath $appInstallerFile.FullName -Destination $stagedAppInstallerPath -Force
|
||||
|
||||
$appInstallerXml = New-Object System.Xml.XmlDocument
|
||||
$appInstallerXml.PreserveWhitespace = $true
|
||||
$appInstallerXml.Load($stagedAppInstallerPath)
|
||||
|
||||
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($appInstallerXml.NameTable)
|
||||
$namespaceManager.AddNamespace("ai", $appInstallerXml.DocumentElement.NamespaceURI)
|
||||
|
||||
$mainPackageNode = $appInstallerXml.SelectSingleNode("//ai:MainPackage", $namespaceManager)
|
||||
if (-not $mainPackageNode) {
|
||||
throw "MainPackage node was not found in $($appInstallerFile.FullName)."
|
||||
}
|
||||
|
||||
$mainPackageLeafName = Get-UriLeafName $mainPackageNode.GetAttribute("Uri")
|
||||
$mainPackageFile = Find-PackageFileByLeafName -PackageRoot $packageRoot -LeafName $mainPackageLeafName -ExcludeDirectory $stagingRoot
|
||||
if (-not $mainPackageFile) {
|
||||
$mainPackageFile = Get-ChildItem -Path $packageRoot -Recurse -File -Filter "*.msix" |
|
||||
Where-Object {
|
||||
$_.FullName -notlike "*\Dependencies\*" -and
|
||||
-not (Test-IsUnderDirectory -Path $_.FullName -Directory $stagingRoot)
|
||||
} |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
if (-not $mainPackageFile) {
|
||||
throw "Could not find the main .msix package referenced by $($appInstallerFile.FullName)."
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -FilePath $mainPackageFile.FullName
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "MSIX signature is not valid: $($signature.StatusMessage)"
|
||||
}
|
||||
|
||||
if ($signature.SignerCertificate.Thumbprint -ne $CertificateThumbprint) {
|
||||
throw "MSIX was signed with $($signature.SignerCertificate.Thumbprint), expected $CertificateThumbprint."
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $mainPackageFile.FullName -Destination (Join-Path $stagingRoot $mainPackageFile.Name) -Force
|
||||
$appInstallerXml.DocumentElement.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $appInstallerFile.Name))
|
||||
$mainPackageNode.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $mainPackageFile.Name))
|
||||
|
||||
$dependencyNodes = $appInstallerXml.SelectNodes("//ai:Dependencies/ai:Package", $namespaceManager)
|
||||
foreach ($dependencyNode in $dependencyNodes) {
|
||||
$dependencyLeafName = Get-UriLeafName $dependencyNode.GetAttribute("Uri")
|
||||
$architecture = $dependencyNode.GetAttribute("ProcessorArchitecture")
|
||||
$preferredFragment = if ($architecture) { "Dependencies\$architecture" } else { $null }
|
||||
$dependencyFile = Find-PackageFileByLeafName -PackageRoot $packageRoot -LeafName $dependencyLeafName -PreferredPathFragment $preferredFragment -ExcludeDirectory $stagingRoot
|
||||
|
||||
if (-not $dependencyFile) {
|
||||
throw "Could not find dependency package $dependencyLeafName."
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $dependencyFile.FullName -Destination (Join-Path $stagingRoot $dependencyFile.Name) -Force
|
||||
$dependencyNode.SetAttribute("Uri", (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $dependencyFile.Name))
|
||||
}
|
||||
|
||||
$appInstallerXml.Save($stagedAppInstallerPath)
|
||||
|
||||
Ensure-CertificateFile -CertificatePath $certificatePath -Thumbprint $CertificateThumbprint
|
||||
|
||||
if (Test-Path -LiteralPath $InstallCertificateScriptPath) {
|
||||
Copy-Item -LiteralPath $InstallCertificateScriptPath -Destination (Join-Path $stagingRoot (Split-Path -Leaf $InstallCertificateScriptPath)) -Force
|
||||
}
|
||||
|
||||
$stagedFiles = Get-ChildItem -Path $stagingRoot -File | Sort-Object Name
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Prepared MSIX deployment files:"
|
||||
$stagedFiles | ForEach-Object {
|
||||
Write-Host (" - {0} ({1:N0} bytes)" -f $_.Name, $_.Length)
|
||||
}
|
||||
|
||||
if (-not $NoUpload) {
|
||||
if (-not $NasUser) {
|
||||
throw "NasUser was not provided. Pass -NasUser <user> or set NAS_USER."
|
||||
}
|
||||
|
||||
$sshArgs = @()
|
||||
if ($NasSshPort -ne 22) {
|
||||
$sshArgs += @("-p", [string]$NasSshPort)
|
||||
}
|
||||
if ($SshKeyPath) {
|
||||
$sshArgs += @("-i", (Resolve-FullPath $SshKeyPath))
|
||||
}
|
||||
$sshArgs += @("${NasUser}@${NasHost}", "mkdir -p '$NasRemotePath'")
|
||||
Invoke-Checked -FilePath "ssh" -Arguments $sshArgs
|
||||
|
||||
$scpArgs = @()
|
||||
if ($NasSshPort -ne 22) {
|
||||
$scpArgs += @("-P", [string]$NasSshPort)
|
||||
}
|
||||
if ($SshKeyPath) {
|
||||
$scpArgs += @("-i", (Resolve-FullPath $SshKeyPath))
|
||||
}
|
||||
$scpArgs += $stagedFiles.FullName
|
||||
$scpArgs += "${NasUser}@${NasHost}:$NasRemotePath/"
|
||||
Invoke-Checked -FilePath "scp" -Arguments $scpArgs
|
||||
}
|
||||
|
||||
if (-not $NoVerify) {
|
||||
foreach ($file in $stagedFiles) {
|
||||
$uri = Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $file.Name
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $uri -Method Head -UseBasicParsing -TimeoutSec 20
|
||||
}
|
||||
catch {
|
||||
Write-Warning "HEAD failed for $uri. Trying GET."
|
||||
$response = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 20
|
||||
}
|
||||
|
||||
if ($response.StatusCode -lt 200 -or $response.StatusCode -gt 299) {
|
||||
throw "Verification failed for $uri with status $($response.StatusCode)."
|
||||
}
|
||||
|
||||
if (-not $NoUpload) {
|
||||
$contentLengthHeader = $response.Headers["Content-Length"] | Select-Object -First 1
|
||||
if ($contentLengthHeader) {
|
||||
$remoteLength = [long]$contentLengthHeader
|
||||
if ($remoteLength -ne $file.Length) {
|
||||
throw "Verification failed for $uri. Remote length $remoteLength does not match local length $($file.Length)."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "No Content-Length header returned for $uri; status verification only."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Verified $uri"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Staging directory:"
|
||||
Write-Host $stagingRoot
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "App Installer URL:"
|
||||
Write-Host (Join-PublicUri -BaseUri $normalizedPublicBaseUri -LeafName $appInstallerFile.Name)
|
||||
120
tools/msix/README.md
Normal file
120
tools/msix/README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# MSIX publish workflow
|
||||
|
||||
This folder contains the scripts used to build the MSIX package, flatten the
|
||||
App Installer deployment files, upload them to the Synology NAS web folder, and
|
||||
verify the public download URLs.
|
||||
|
||||
## Files
|
||||
|
||||
- `Publish-MsixToNas.ps1`: builds the app package, stages the deployable files,
|
||||
uploads them to the NAS with SSH/SCP, and verifies the public URLs.
|
||||
- `Install-ComtrophyMsixCertificate.ps1`: installs the MSIX signing certificate
|
||||
for the current Windows user, then optionally opens the appinstaller URL.
|
||||
|
||||
## First-time NAS SSH setup
|
||||
|
||||
1. Create or choose a NAS user that can write to `/volume1/web/msix`.
|
||||
2. Enable SSH on the Synology NAS.
|
||||
3. Optional but recommended: create an SSH key for publishing.
|
||||
|
||||
```powershell
|
||||
ssh-keygen -t ed25519 -f $env:USERPROFILE\.ssh\nas_msix_ed25519
|
||||
type $env:USERPROFILE\.ssh\nas_msix_ed25519.pub
|
||||
```
|
||||
|
||||
Add the printed public key to the NAS user's `~/.ssh/authorized_keys`.
|
||||
|
||||
Test the connection:
|
||||
|
||||
```powershell
|
||||
ssh -i $env:USERPROFILE\.ssh\nas_msix_ed25519 <nas-user>@192.168.200.129 "ls -ld /volume1/web/msix"
|
||||
```
|
||||
|
||||
## Publish a new build
|
||||
|
||||
Set the NAS login once in the current PowerShell session:
|
||||
|
||||
```powershell
|
||||
$env:NAS_USER = "<nas-user>"
|
||||
$env:NAS_SSH_KEY = "$env:USERPROFILE\.ssh\nas_msix_ed25519"
|
||||
```
|
||||
|
||||
Build, package, upload, and verify:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Release -IncrementPackageRevision
|
||||
```
|
||||
|
||||
Use `-IncrementPackageRevision` for normal approved deployments. It reads the
|
||||
current `Package.appxmanifest` version and increments the fourth version part
|
||||
before building. App Installer uses the MSIX package version to decide whether a
|
||||
client should receive an update.
|
||||
|
||||
In Codex sessions, this is the command to run only after the user explicitly
|
||||
approves publishing the finished work.
|
||||
|
||||
To publish a specific version instead:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Release -PackageVersion 1.0.3.2
|
||||
```
|
||||
|
||||
To upload the latest already-built package without rebuilding:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Debug -SkipPackageBuild
|
||||
```
|
||||
|
||||
To prepare files locally without uploading:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\tools\msix\Publish-MsixToNas.ps1 -Configuration Debug -SkipPackageBuild -NoUpload
|
||||
```
|
||||
|
||||
The default public base URL is:
|
||||
|
||||
```text
|
||||
http://122.34.248.185/msix/
|
||||
```
|
||||
|
||||
If the deployment should use the Synology DDNS name instead, pass:
|
||||
|
||||
```powershell
|
||||
-PublicBaseUri "http://comtropy.synology.me/msix/"
|
||||
```
|
||||
|
||||
## Installer link
|
||||
|
||||
After publish, the installer URL is:
|
||||
|
||||
```text
|
||||
http://122.34.248.185/msix/Tornado3_2026Election_x64.appinstaller
|
||||
```
|
||||
|
||||
The user PC must trust the signing certificate before installing the MSIX for
|
||||
the first time. The script only installs the certificate; it does not run the
|
||||
app installer. Approve the UAC administrator prompt when Windows asks:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\Install-ComtrophyMsixCertificate.ps1
|
||||
```
|
||||
|
||||
To run it directly from the NAS on a target PC:
|
||||
|
||||
```powershell
|
||||
$script = Join-Path $env:TEMP "Install-ComtrophyMsixCertificate.ps1"
|
||||
Invoke-WebRequest "http://122.34.248.185/msix/Install-ComtrophyMsixCertificate.ps1" -OutFile $script
|
||||
powershell -ExecutionPolicy Bypass -File $script
|
||||
```
|
||||
|
||||
After the certificate setup is complete, open the appinstaller link once to
|
||||
install the app. After installation, run the app from the Windows Start menu, not
|
||||
from the appinstaller link.
|
||||
|
||||
If installation fails with `0x800B0109`, confirm the certificate is present in
|
||||
both local computer stores:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem Cert:\LocalMachine\TrustedPeople, Cert:\LocalMachine\Root |
|
||||
Where-Object Thumbprint -eq "E691A33C64DF20A204FFD4F096B9C3EB4B95709C"
|
||||
```
|
||||
Reference in New Issue
Block a user