|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | + |
| 4 | +import subprocess |
| 5 | +import datetime |
| 6 | +import calendar |
| 7 | +import os |
| 8 | +import re |
| 9 | +from collections import defaultdict |
| 10 | + |
| 11 | +# ===== 설정 ===== |
| 12 | +NAMES = ["곽태근", "김호집", "오창은", "김태민", "추창우"] |
| 13 | +READ_ME = "README.md" |
| 14 | +START_MARK = "<!-- PROGRESS_START -->" |
| 15 | +END_MARK = "<!-- PROGRESS_END -->" |
| 16 | +TZ_OFFSET = "+0900" # Asia/Seoul (KST) |
| 17 | +WEEK_START = calendar.SUNDAY # 달력 머리: 일 ~ 토 |
| 18 | +DOT_O = "🟢" # O |
| 19 | +DOT_X = "🔴" # X |
| 20 | +# ============== |
| 21 | + |
| 22 | +calendar.setfirstweekday(WEEK_START) |
| 23 | + |
| 24 | +def run(cmd): |
| 25 | + return subprocess.check_output(cmd, shell=True, text=True, encoding="utf-8").strip() |
| 26 | + |
| 27 | +def git_subjects_for_date_and_path(date_str, path): |
| 28 | + since = f"{date_str} 00:00:00 {TZ_OFFSET}" |
| 29 | + until = f"{date_str} 23:59:59 {TZ_OFFSET}" |
| 30 | + cmd = ( |
| 31 | + f'git log --since="{since}" --until="{until}" ' |
| 32 | + f'--pretty=%s -- "{path}" || true' |
| 33 | + ) |
| 34 | + out = run(cmd) |
| 35 | + if not out: |
| 36 | + return [] |
| 37 | + return [line.strip() for line in out.splitlines() if line.strip()] |
| 38 | + |
| 39 | +def judge_day(date_str, name): |
| 40 | + """ |
| 41 | + 반환: 'O' 또는 'X' |
| 42 | + - 해당 날짜/이름 경로에 커밋이 없거나 봇 메시지만 => 'X' |
| 43 | + - 그 외 메시지 하나라도 => 'O' |
| 44 | + """ |
| 45 | + bot_msg = f"{date_str}일자 태스크 배정완료, 화이팅!" |
| 46 | + path = f"{date_str}/{name}" |
| 47 | + subjects = git_subjects_for_date_and_path(date_str, path) |
| 48 | + if not subjects: |
| 49 | + return "X" |
| 50 | + for s in subjects: |
| 51 | + if s != bot_msg: |
| 52 | + return "O" |
| 53 | + return "X" |
| 54 | + |
| 55 | +def find_all_date_dirs(): |
| 56 | + """리포 내 YYYY-MM-DD 디렉터리들을 찾아 실제 날짜 리스트 반환.""" |
| 57 | + dates = [] |
| 58 | + for entry in os.listdir("."): |
| 59 | + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", entry) and os.path.isdir(entry): |
| 60 | + try: |
| 61 | + y, m, d = map(int, entry.split("-")) |
| 62 | + dates.append(datetime.date(y, m, d)) |
| 63 | + except ValueError: |
| 64 | + pass |
| 65 | + return sorted(dates) |
| 66 | + |
| 67 | +def month_iter(start_date, end_date): |
| 68 | + """start_date의 1일 ~ end_date의 1일까지 월 단위로 이터레이션.""" |
| 69 | + y, m = start_date.year, start_date.month |
| 70 | + while (y < end_date.year) or (y == end_date.year and m <= end_date.month): |
| 71 | + yield y, m |
| 72 | + if m == 12: |
| 73 | + y += 1 |
| 74 | + m = 1 |
| 75 | + else: |
| 76 | + m += 1 |
| 77 | + |
| 78 | +def build_month_calendar(year, month, today_kst): |
| 79 | + """ |
| 80 | + 월별 달력(HTML table) 생성. |
| 81 | + - 어제까지 O/X 확정 |
| 82 | + - 오늘 이후 공백 |
| 83 | + """ |
| 84 | + cal = calendar.monthcalendar(year, month) # 주: [월..일]이 아니라 설정된 firstweekday 기준 |
| 85 | + # GitHub는 기본 마크다운 테이블보다 HTML 테이블이 칸 꾸미기 유리 |
| 86 | + header_days = ["일", "월", "화", "수", "목", "금", "토"] |
| 87 | + # firstweekday를 반영해서 회전 |
| 88 | + rotate = list(range(7)) |
| 89 | + header_days = header_days[-calendar.firstweekday():] + header_days[:-calendar.firstweekday()] |
| 90 | + |
| 91 | + rows_html = [] |
| 92 | + for week in cal: |
| 93 | + tds = [] |
| 94 | + for d in week: |
| 95 | + if d == 0: |
| 96 | + tds.append("<td></td>") |
| 97 | + continue |
| 98 | + |
| 99 | + date_obj = datetime.date(year, month, d) |
| 100 | + if date_obj >= today_kst: |
| 101 | + # 미래/오늘은 빈 칸 |
| 102 | + tds.append(f'<td align="center" valign="top"><div align="right"><sub>{d}</sub></div></td>') |
| 103 | + continue |
| 104 | + |
| 105 | + date_str = date_obj.isoformat() |
| 106 | + dots = [] |
| 107 | + for name in NAMES: |
| 108 | + flag = judge_day(date_str, name) # 'O' or 'X' |
| 109 | + dot = DOT_O if flag == "O" else DOT_X |
| 110 | + title = f'{name}: {flag}' |
| 111 | + dots.append(f'<span title="{title}">{dot}</span>') |
| 112 | + tds.append( |
| 113 | + '<td align="center" valign="top" style="min-width:96px">' |
| 114 | + f'<div align="right"><sub>{d}</sub></div>' |
| 115 | + f'<div style="font-size: 18px; line-height:1.2">{ "".join(dots) }</div>' |
| 116 | + "</td>" |
| 117 | + ) |
| 118 | + rows_html.append("<tr>" + "".join(tds) + "</tr>") |
| 119 | + |
| 120 | + # 범례(이름 순서 고정) |
| 121 | + legend_items = [f'<li><strong>{i+1}</strong>번째 점: {name}</li>' for i, name in enumerate(NAMES)] |
| 122 | + legend_html = ( |
| 123 | + '<details><summary>범례 보기</summary>' |
| 124 | + '<ul style="margin-top:6px">' |
| 125 | + + "".join(legend_items) + |
| 126 | + "</ul></details>" |
| 127 | + ) |
| 128 | + |
| 129 | + month_title = f"### {year}-{month:02d} 코딩테스트 달력 (KST)" |
| 130 | + table_html = ( |
| 131 | + f"{month_title}\n\n" |
| 132 | + + legend_html + "\n\n" |
| 133 | + + '<table>' |
| 134 | + + "<thead><tr>" + "".join([f"<th>{d}</th>" for d in header_days]) + "</tr></thead>" |
| 135 | + + "<tbody>" + "".join(rows_html) + "</tbody>" |
| 136 | + + "</table>\n" |
| 137 | + ) |
| 138 | + return table_html |
| 139 | + |
| 140 | +def build_all_months(today_kst): |
| 141 | + # 리포의 날짜 디렉터리를 스캔해서, 없으면 현재 달만 생성 |
| 142 | + date_dirs = find_all_date_dirs() |
| 143 | + if date_dirs: |
| 144 | + start = datetime.date(date_dirs[0].year, date_dirs[0].month, 1) |
| 145 | + else: |
| 146 | + start = datetime.date(today_kst.year, today_kst.month, 1) |
| 147 | + |
| 148 | + end = datetime.date(today_kst.year, today_kst.month, 1) # 오늘의 월까지 |
| 149 | + blocks = [] |
| 150 | + for y, m in month_iter(start, end): |
| 151 | + block = build_month_calendar(y, m, today_kst) |
| 152 | + # 과거 달은 접기, 이번 달은 펼침 |
| 153 | + is_current = (y == today_kst.year and m == today_kst.month) |
| 154 | + summary = f"{y}-{m:02d}" |
| 155 | + details_open = " open" if is_current else "" |
| 156 | + blocks.append(f"<details{details_open}><summary><strong>{summary}</strong></summary>\n\n{block}\n</details>") |
| 157 | + return "\n\n".join(blocks) |
| 158 | + |
| 159 | +def replace_block(original, new_block): |
| 160 | + if START_MARK in original and END_MARK in original: |
| 161 | + pattern = re.compile( |
| 162 | + rf"{re.escape(START_MARK)}.*?{re.escape(END_MARK)}", |
| 163 | + re.DOTALL |
| 164 | + ) |
| 165 | + return pattern.sub( |
| 166 | + f"{START_MARK}\n{new_block}\n{END_MARK}", original |
| 167 | + ) |
| 168 | + else: |
| 169 | + return original.rstrip() + "\n\n" + START_MARK + "\n" + new_block + "\n" + END_MARK + "\n" |
| 170 | + |
| 171 | +def main(): |
| 172 | + now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9))) |
| 173 | + today_kst = now.date() |
| 174 | + |
| 175 | + new_block = build_all_months(today_kst) |
| 176 | + |
| 177 | + if os.path.exists(READ_ME): |
| 178 | + with open(READ_ME, "r", encoding="utf-8") as f: |
| 179 | + content = f.read() |
| 180 | + else: |
| 181 | + content = "# 코딩테스트 연습\n" |
| 182 | + |
| 183 | + updated = replace_block(content, new_block) |
| 184 | + |
| 185 | + if updated != content: |
| 186 | + with open(READ_ME, "w", encoding="utf-8") as f: |
| 187 | + f.write(updated) |
| 188 | + |
| 189 | +if __name__ == "__main__": |
| 190 | + main() |
0 commit comments