diff --git a/run.py b/run.py index 93dee37..dfb831b 100755 --- a/run.py +++ b/run.py @@ -1,37 +1,75 @@ #!/usr/bin/env python3 import os -import sys import pathlib -import base64 -from enum import Enum, unique, auto -from dataclasses import dataclass import re import subprocess -import urllib.error -import urllib.parse -import urllib.request -from html import escape, unescape -import yaml +from enum import Enum, unique import click +import yaml try: from latex2mathml.converter import convert as latex_to_mathml except ImportError: latex_to_mathml = None -CFG_PATH = "./config.yml" -STATE_PATH = "./state.yml" +__version__ = "2.0.0" # new version -TC_DIR = "./_testcases" +CFG_PATH = pathlib.Path("./config.yml") +STATE_PATH = pathlib.Path("./state.yml") -TEMPLATES_DIR = "./templates" +TC_DIR = pathlib.Path("./_testcases") +TEMPLATES_DIR = pathlib.Path("./templates") +SRC_SPACE_DIR = pathlib.Path("./space") +STORAGE_DIR = pathlib.Path("./storage") +BUILD_DIR = pathlib.Path("./build") -SRC_SPACE_DIR = "./space" -STORAGE_DIR = "./storage" +# ====== +# Helper Functions +# ====== -BUILD_DIR = "./build" + +def _natural_sort_key(s: str): + """Natural sort key: splits string into text/number chunks for proper ordering.""" + return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", s)] + + +def _parse_range_string(range_str: str) -> list[int]: + """ + 범위 문자열을 정수 리스트로 변환. + * ..N: 1부터 N까지 + * M..N: M부터 N까지 + * N: 단일 숫자 + """ + if range_str.startswith(".."): + end_str = range_str[2:] + if not end_str.isdigit(): + raise ValueError("Invalid range format") + return list(range(1, int(end_str) + 1)) + + parts = range_str.split("..") + if len(parts) == 1: + if not parts[0].isdigit(): + raise ValueError("Invalid range format") + return [int(parts[0])] + elif len(parts) == 2: + if not parts[0].isdigit() or not parts[1].isdigit(): + raise ValueError("Invalid range format") + return list(range(int(parts[0]), int(parts[1]) + 1)) + else: + raise ValueError("Invalid range format") + + +def _parse_range_string_list(str_list: list[str]) -> list[int]: + """ + 여러 범위 문자열을 정수 리스트로 변환하는 함수; 이때 중복은 제거함. + * e.g. ["1..3", "5", "7..9"] -> [1, 2, 3, 5, 7, 8, 9] + """ + result = set() + for s in str_list: + result.update(_parse_range_string(s)) + return list(result) @unique @@ -45,6 +83,7 @@ class Language(Enum): - Kotlin: kt - Lua: lua """ + PYTHON = "py" C = "c" CPP = "cpp" @@ -107,11 +146,11 @@ class Language(Enum): Language.KOTLIN: "kt.jar", Language.LUA: "lua.luac", }[self] - + @property def executable_command(self): """ - 각 언어에 대한 실행 명령어. + 각 언어에 대한 실행 명령어. """ return { Language.PYTHON: ["python", f"{BUILD_DIR}/{self.build_executable}"], @@ -168,542 +207,312 @@ class Language(Enum): case _: return Language.UNDEFINED - -def natural_sort_key(s: str): - """Natural sort key: splits string into text/number chunks for proper ordering.""" - return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", s)] - -def parse_range_string(range_str: str) -> list[int]: - """ - 범위 문자열을 정수 리스트로 변환. - * ..N: 1부터 N까지 - * M..N: M부터 N까지 - * N: 단일 숫자 - """ - if range_str.startswith(".."): - end_str = range_str[2:] - if not end_str.isdigit(): - raise ValueError("Invalid range format") - return list(range(1, int(end_str) + 1)) - - parts = range_str.split("..") - if len(parts) == 1: - if not parts[0].isdigit(): - raise ValueError("Invalid range format") - return [int(parts[0])] - elif len(parts) == 2: - if not parts[0].isdigit() or not parts[1].isdigit(): - raise ValueError("Invalid range format") - return list(range(int(parts[0]), int(parts[1]) + 1)) - else: - raise ValueError("Invalid range format") + @staticmethod + def languages() -> list["Language"]: + return [ + Language.PYTHON, + Language.C, + Language.CPP, + Language.RUST, + Language.KOTLIN, + Language.LUA, + ] -def parse_range_string_list(str_list) -> list[int]: - """ - 여러 범위 문자열을 정수 리스트로 변환하는 함수; 이때 중복은 제거함. - * e.g. ["1..3", "5", "7..9"] -> [1, 2, 3, 5, 7, 8, 9] - """ - result = set() - for s in str_list: - result.update(parse_range_string(s)) - return list(result) +def _dispatch_target(target: str) -> tuple[str, str, Language]: + splited = target.split("/") + if len(splited) != 2: + raise ValueError( + "Invalid target format. Expected format: /." + ) + + prob_parts = splited[1].rsplit(".", 1) + if len(prob_parts) != 2: + raise ValueError( + "Invalid target format. Expected format: /." + ) + + prob_name, ext = prob_parts + lang = Language.convert_ext(ext) + if lang is Language.UNDEFINED: + raise ValueError(f"Unknown language: {ext}") + + return splited[0], prob_name, lang -def parse_fetchprob_target(target: str) -> tuple[str, str | None]: - """ - fetchprob target parser. - - zeta/: single mode - - zeta: batch mode - """ - parts = target.split("/", 1) - location = parts[0].strip() +class StateManager: + _instance: "StateManager | None" = None + _state: dict | None = None - if location != "zeta": - raise click.UsageError("fetchprob target must start with 'zeta'") + def __new__(cls) -> "StateManager": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance - if len(parts) == 1: - return location, None - - problem_id = parts[1].strip() - if not problem_id.isdigit(): - raise click.UsageError("problem id must be numeric (e.g. zeta/2447)") - - return location, problem_id - - -def extract_problem_id_from_stem(stem: str) -> str | None: - """ - Extract BOJ numeric id from file stem. - Accepted forms: , _, - - """ - m = re.match(r"^(\d+)(?:[_-].*)?$", stem) - return m.group(1) if m else None - - -def collect_zeta_problem_ids() -> list[str]: - """ - Collect problem ids from storage/zeta/* and storage/zeta/*/completed. - """ - zeta_dir = pathlib.Path(STORAGE_DIR) / "zeta" - if not zeta_dir.is_dir(): - raise click.ClickException(f"Storage location '{zeta_dir}' not found") - - ids: set[str] = set() - for lang_dir in sorted(zeta_dir.iterdir()): - if not lang_dir.is_dir() or lang_dir.name.startswith("_"): - continue - - for f in lang_dir.iterdir(): - if f.is_file(): - problem_id = extract_problem_id_from_stem(f.stem) - if problem_id: - ids.add(problem_id) - - completed_dir = lang_dir / "completed" - if completed_dir.is_dir(): - for f in completed_dir.iterdir(): - if f.is_file(): - problem_id = extract_problem_id_from_stem(f.stem) - if problem_id: - ids.add(problem_id) - - return sorted(ids, key=int) - - -def fetch_boj_problem_html(problem_id: str, timeout: int = 10) -> str: - """ - Download BOJ problem page raw HTML. - """ - url = f"https://www.acmicpc.net/problem/{problem_id}" - req = urllib.request.Request( - url, - headers={ - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/123.0.0.0 Safari/537.36" - ) - }, - ) - - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - status = getattr(resp, "status", 200) - if status != 200: - raise click.ClickException( - f"failed to fetch problem {problem_id}: HTTP {status}" - ) - return resp.read().decode("utf-8", errors="replace") - except urllib.error.HTTPError as e: - raise click.ClickException( - f"failed to fetch problem {problem_id}: HTTP {e.code}" - ) from e - except urllib.error.URLError as e: - raise click.ClickException( - f"network error while fetching problem {problem_id}: {e.reason}" - ) from e - - -def _problem_static_html_path(problem_id: str) -> pathlib.Path: - return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / f"{problem_id}.html" - - -def _problem_static_assets_dir(problem_id: str) -> pathlib.Path: - return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / "assets" / problem_id - - -def _guess_image_mime(src_url: str, content_type: str | None) -> str: - if content_type: - mime = content_type.split(";", 1)[0].strip().lower() - if mime.startswith("image/"): - return mime - - parsed = urllib.parse.urlparse(src_url) - ext = pathlib.Path(parsed.path).suffix.lower() - ext_to_mime = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", - ".ico": "image/x-icon", - } - return ext_to_mime.get(ext, "image/png") - - -def _download_image_for_offline(problem_id: str, src_url: str, seq: int, force: bool) -> str | None: - req = urllib.request.Request( - src_url, - headers={ - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/123.0.0.0 Safari/537.36" - ), - "Referer": f"https://www.acmicpc.net/problem/{problem_id}", - }, - ) - - try: - with urllib.request.urlopen(req, timeout=10) as resp: - status = getattr(resp, "status", 200) - if status != 200: - return None - content_type = resp.headers.get("Content-Type") - image_bytes = resp.read() - except (urllib.error.HTTPError, urllib.error.URLError): - return None - - mime = _guess_image_mime(src_url, content_type) - encoded = base64.b64encode(image_bytes).decode("ascii") - return f"data:{mime};base64,{encoded}" - - -def _localize_images_in_html(problem_id: str, html_fragment: str, force: bool) -> str: - base_url = f"https://www.acmicpc.net/problem/{problem_id}" - counter = {"i": 0} - cache: dict[str, str] = {} - - pattern = re.compile( - r'(]*?\bsrc\s*=\s*)(["\']?)([^"\'>\s]+)(["\']?)', - flags=re.IGNORECASE, - ) - - def repl(m: re.Match) -> str: - prefix = m.group(1) - q1 = m.group(2) - src = m.group(3) - - if src.startswith("data:"): - return m.group(0) - - abs_url = urllib.parse.urljoin(base_url, src) - if abs_url in cache: - local_src = cache[abs_url] - quote = q1 if q1 else '"' - return f"{prefix}{quote}{local_src}{quote}" - - counter["i"] += 1 - local_src = _download_image_for_offline(problem_id, abs_url, counter["i"], force=force) - if not local_src: - return m.group(0) - - cache[abs_url] = local_src - quote = q1 if q1 else '"' - return f"{prefix}{quote}{local_src}{quote}" - - return pattern.sub(repl, html_fragment) - - -def _extract_html_by_id(raw_html: str, tag: str, element_id: str) -> str | None: - pattern = rf"<{tag}[^>]*id=\"{re.escape(element_id)}\"[^>]*>(.*?)" - m = re.search(pattern, raw_html, flags=re.DOTALL | re.IGNORECASE) - if not m: - return None - return m.group(1).strip() - - -def _strip_tags(html_text: str) -> str: - text = re.sub(r"<[^>]+>", "", html_text, flags=re.DOTALL) - return " ".join(text.split()) - - -def _render_math_expressions(html_fragment: str) -> str: - """ - Convert TeX math delimiters to MathML for offline rendering. - - Inline: $...$ - - Block: $$...$$ - """ - if latex_to_mathml is None: - return html_fragment - - protected_blocks: list[str] = [] - - def protect(m: re.Match) -> str: - protected_blocks.append(m.group(0)) - return f"@@PROTECTED_{len(protected_blocks) - 1}@@" - - # Do not touch code/pre blocks. - temp = re.sub( - r"<(pre|code)\b[^>]*>.*?", - protect, - html_fragment, - flags=re.DOTALL | re.IGNORECASE, - ) - - def repl_block(m: re.Match) -> str: - expr = unescape(m.group(1).strip()) - if not expr: - return m.group(0) + def _load(self) -> dict: + if self._state is not None: + return self._state try: - mathml = latex_to_mathml(expr) - return f'
{mathml}
' - except Exception: - return m.group(0) + with open(STATE_PATH, "r", encoding="utf-8") as f: + self._state = yaml.safe_load(f) or {} + except FileNotFoundError as exc: + raise click.ClickException(f"state.yml not found at {STATE_PATH}") from exc + except yaml.YAMLError as e: + raise click.ClickException(f"state.yml parse error: {e}") from e + return self._state # type: ignore[return-value] - def repl_inline(m: re.Match) -> str: - expr = unescape(m.group(1).strip()) - if not expr: - return m.group(0) - try: - mathml = latex_to_mathml(expr) - return f'{mathml}' - except Exception: - return m.group(0) + def save(self) -> None: + if self._state is None: + return + with open(STATE_PATH, "w", encoding="utf-8") as f: + yaml.safe_dump(self._state, f) - temp = re.sub(r"\$\$(.+?)\$\$", repl_block, temp, flags=re.DOTALL) - temp = re.sub(r"(? bool: + state = self._load() + return lang.value in state.get("space", {}) and bool(state["space"][lang.value]) + + def get_space(self, lang: Language) -> dict: + state = self._load() + if not self.check_space(lang): + raise ValueError(f"No space found for language: {lang}") + return state["space"][lang.value] + + @_auto_save + def add_space( + self, lang: Language, file: str, location: str, is_completed: bool + ) -> None: + state = self._load() + if "space" not in state: + state["space"] = {} + state["space"][lang.value] = { + "file": file, + "location": location, + "is_completed": is_completed, + } + self._state = state + + @_auto_save + def remove_space(self, lang: Language) -> None: + state = self._load() + if "space" in state and lang.value in state["space"]: + state["space"][lang.value] = {} + self._state = state + + @_auto_save + def clear_space(self, lang: Language) -> None: + state = self._load() + if "space" in state and lang.value in state["space"]: + del state["space"][lang.value] + self._state = state + + def reload(self) -> None: + self._state = None + + @_auto_save + def set_completed(self, lang: Language, is_completed: bool) -> None: + state = self._load() + if lang.value not in state.get("space", {}): + raise ValueError(f"No space found for language: {lang}") + if not state["space"].get(lang.value): + raise ValueError(f"No space found for language: {lang}") + state["space"][lang.value]["is_completed"] = is_completed + self._state = state -def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> str: - """ - Build a self-contained offline-friendly HTML page from BOJ raw HTML. - """ - title = _extract_html_by_id(raw_html, "span", "problem_title") - if not title: - title = f"BOJ {problem_id}" +class StorageManager: + _instance: "StorageManager | None" = None - blocks: list[str] = [] - core_specs = [ - ("problem_description", "문제"), - ("problem_input", "입력"), - ("problem_output", "출력"), - ("problem_limit", "제한"), - ("problem_hint", "힌트"), - ] + def __new__(cls) -> "StorageManager": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance - for content_id, fallback_label in core_specs: - content = _extract_html_by_id(raw_html, "div", content_id) - if not content or not content.strip(): - continue + def register_location(self, location: str) -> None: + path = STORAGE_DIR / location + os.makedirs(path, exist_ok=True) - localized_content = _localize_images_in_html( - problem_id, - content, - force=force, + def check_location(self, location: str) -> bool: + path = STORAGE_DIR / location + return os.path.isdir(path) + + def register_location_lang(self, location: str, lang: Language) -> None: + path = STORAGE_DIR / location / lang.value + os.makedirs(path, exist_ok=True) + + def check_location_lang(self, location: str, lang: Language) -> bool: + path = STORAGE_DIR / location / lang.value + return os.path.isdir(path) + + def check_get_file( + self, location: str, lang: Language, filename: str + ) -> pathlib.Path | None: + completed_path = ( + STORAGE_DIR + / location + / lang.value + / "completed" + / f"{filename}.{lang.value}" + ) + uncompleted_path = ( + STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" ) - localized_content = _render_math_expressions(localized_content) - blocks.append( - "\n".join( - [ - "
", - f"

{fallback_label}

", - f"{localized_content}", - "
", - ] + if os.path.isfile(completed_path): + return completed_path + elif os.path.isfile(uncompleted_path): + return uncompleted_path + else: + return None + + def save_file( + self, + location: str, + lang: Language, + filename: str, + content: bytes, + completed: bool, + ) -> None: + path = STORAGE_DIR / location / lang.value / ("completed" if completed else "") + os.makedirs(path, exist_ok=True) + with open(path / f"{filename}.{lang.value}", "wb") as f: + f.write(content) + + def read_file(self, location: str, lang: Language, filename: str) -> bytes: + path = self.check_get_file(location, lang, filename) + if path is None: + raise FileNotFoundError( + f"File not found in storage: {location}/{filename}.{lang.value}" ) - ) + file_path = path / f"{filename}.{lang.value}" + if not file_path.is_file(): + raise FileNotFoundError(f"File not found in storage: {file_path}") + with open(file_path, "rb") as f: + return f.read() - for sample_type, sample_label in (("sampleinput", "예제 입력"), ("sampleoutput", "예제 출력")): - sample_pattern = rf"]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)" - sample_matches = list( - re.finditer(sample_pattern, raw_html, flags=re.DOTALL | re.IGNORECASE) - ) - sample_matches.sort(key=lambda m: int(m.group(1))) - - for m in sample_matches: - idx = m.group(1) - section_html = m.group(2) - pre_match = re.search( - r"(]*>.*?)", - section_html, - flags=re.DOTALL | re.IGNORECASE, + def remove_file(self, location: str, lang: Language, filename: str) -> None: + path = self.check_get_file(location, lang, filename) + if path is None: + raise FileNotFoundError( + f"File not found in storage: {location}/{filename}.{lang.value}" ) - if not pre_match: + os.remove(path) + + def mark_completed(self, location: str, lang: Language, filename: str) -> None: + uncompleted_file = ( + STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" + ) + completed_file = ( + STORAGE_DIR + / location + / lang.value + / "completed" + / f"{filename}.{lang.value}" + ) + + os.makedirs(completed_file.parent, exist_ok=True) + if os.path.isfile(uncompleted_file): + os.rename(uncompleted_file, completed_file) + + def unmark_completed(self, location: str, lang: Language, filename: str) -> None: + uncompleted_file = ( + STORAGE_DIR / location / lang.value / f"{filename}.{lang.value}" + ) + completed_file = ( + STORAGE_DIR + / location + / lang.value + / "completed" + / f"{filename}.{lang.value}" + ) + + os.makedirs(uncompleted_file.parent, exist_ok=True) + if os.path.isfile(completed_file): + os.rename(completed_file, uncompleted_file) + + @staticmethod + def construct_target_path( + location: str, lang: Language, filename: str, completed: bool + ) -> pathlib.Path: + return ( + STORAGE_DIR + / location + / lang.value + / ("completed" if completed else "") + / f"{filename}.{lang.value}" + ) + + def list_entries( + self, + location: str | None = None, + lang: Language | None = None, + completed: bool | None = None, + ) -> list[tuple[str, str, str, bool]]: + """저장된 파일 목록을 반환합니다. + + Args: + location: location 필터 (None이면 전체) + lang: language 필터 (None이면 전체) + completed: completed 필터 (None이면 both, True이면 completed만, False이면 uncompleted만) + + Returns: + list[tuple[str, str, str, bool]]: (location, lang, filename, is_completed) + """ + result: list[tuple[str, str, str, bool]] = [] + + if not STORAGE_DIR.is_dir(): + return result + + for loc_dir in sorted(STORAGE_DIR.iterdir()): + if not loc_dir.is_dir(): + continue + loc_name = loc_dir.name + + if location and loc_name != location: continue - pre_html = _localize_images_in_html( - problem_id, - pre_match.group(1), - force=force, - ) + for lang_dir in sorted(loc_dir.iterdir()): + if not lang_dir.is_dir(): + continue + lang_name = lang_dir.name - h2_match = re.search( - r"]*>(.*?)", - section_html, - flags=re.DOTALL | re.IGNORECASE, - ) - if h2_match: - h2 = _strip_tags(h2_match.group(1)) - else: - h2 = f"{sample_label} {idx}" + if Language.convert_ext(lang_name) is Language.UNDEFINED: + continue - blocks.append( - "\n".join( - [ - "
", - f"

{h2}

", - pre_html, - "
", - ] - ) - ) + if lang and lang_name != lang.value: + continue - if not blocks: - body_fallback = ( - "
" - "

원본 페이지

" - "

문제 본문 파싱에 실패하여 원본 HTML을 포함합니다.

" - f"
{escape(raw_html[:100000])}
" - "
" - ) - blocks.append(body_fallback) + for f in sorted( + lang_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem) + ): + if f.is_file(): + result.append((loc_name, lang_name, f.stem, False)) - source_url = f"https://www.acmicpc.net/problem/{problem_id}" - content_html = "\n".join(blocks) + completed_dir = lang_dir / "completed" + if completed_dir.is_dir(): + for f in sorted( + completed_dir.iterdir(), key=lambda p: _natural_sort_key(p.stem) + ): + if f.is_file(): + result.append((loc_name, lang_name, f.stem, True)) - return f""" - - - - - BOJ {problem_id} - Offline - - - -
-
-

{title}

-
- {content_html} -
- - -""" + if completed is not None: + result = [e for e in result if e[3] == completed] + + return result -def save_problem_html(problem_id: str, html: str, force: bool) -> str: - """ - Save html to storage/zeta/_static/.html - Return: fetched | skipped - """ - static_dir = pathlib.Path(STORAGE_DIR) / "zeta" / "_static" - static_dir.mkdir(parents=True, exist_ok=True) - - dest = _problem_static_html_path(problem_id) - if dest.exists() and not force: - return "skipped" - - dest.write_text(html, encoding="utf-8") - return "fetched" +# ======================= +# MAIN CLI LOGIC +# ======================= @click.group() @@ -711,64 +520,121 @@ def cli(): pass +@click.command(name="register") +@click.argument("location", type=str, nargs=1, required=True) +def register(location: str): + """ + 새로운 location을 storage에 등록하는 명령어. + """ + storage_manager = StorageManager() + if storage_manager.check_location(location): + click.secho( + f"Location '{location}' is already registered.", fg="yellow", bold=True + ) + else: + storage_manager.register_location(location) + click.secho( + f"Location '{location}' registered successfully.", fg="cyan", bold=True + ) + + +@click.command(name="create") +@click.argument("target", type=str, nargs=1, required=True) +@click.option( + "--completed/--no-completed", "-c/-nc", default=False, help="Mark as completed" +) +@click.option( + "--no-template", + "-nt", + is_flag=True, + help="Do not use template content (create empty file instead)", +) +@click.option( + "--force", "-f", is_flag=True, help="Overwrite existing file if it already exists" +) +def create(target: str, completed: bool, no_template: bool, force: bool): + """ + 지정된 location을 storage에 생성하는 명령어 + """ + loc, prob, lang = _dispatch_target(target) + + if not StorageManager().check_location(loc): + raise click.ClickException( + f"Location '{loc}' is not registered. Please register it first." + ) + + filename = prob + existing = StorageManager().check_get_file(loc, lang, filename) + if existing: + click.secho( + f"File '{filename}.{lang.value}' already exists in location '{loc}'", + fg="yellow", + bold=True, + ) + if not force: + return + + if no_template: + content = b"" + else: + template_path = TEMPLATES_DIR / f"template.{lang.value}" + if template_path.is_file(): + content = template_path.read_bytes() + click.secho(f"Using template: {template_path}", fg="cyan") + else: + content = b"" + click.secho(f"Template not found: {template_path}", fg="yellow") + + StorageManager().save_file( + loc, lang, filename, content, completed + ) + click.secho( + f"Created '{filename}.{lang.value}' in location '{loc}'", + fg="cyan", + bold=True, + ) + + @click.command(name="run") -@click.option("--from", "from_", type=str, required=True) -@click.option("--target", "-t", default=["1"], multiple=True) +@click.argument("lang", type=str, nargs=1, required=True) +@click.option("--testcase", "-t", default=["1"], multiple=True) @click.option("--verbose/--no-verbose", "-v/-nv", default=True) -def run(from_: str, target: str, verbose: bool): +def run(lang: str, testcase: tuple[str, ...], verbose: bool): """ 지정된 언어로 빌드 및 실행하는 명령어 """ + # Language 확인 - from_language: Language = Language.convert_name(from_) + target_language: Language = Language.convert_name(lang) - if from_language is Language.UNDEFINED: + if target_language is Language.UNDEFINED: raise click.ClickException("Undefined Language Exception") - try: - with open("state.yml", "r", encoding="utf-8") as f: - state = yaml.safe_load(f) - except FileNotFoundError as e: - raise click.ClickException(e) - except yaml.YAMLError as e: - raise click.ClickException(e) - if "space" not in state: - raise click.ClickException("State(state.yml) Exception") - elif not state["space"]: - click.secho("state.space has no member", fg="yellow", bold=True) - state["space"] = {} - - lang_space_state: dict | None - if from_language.value not in state["space"]: - lang_space_state = None - else: - lang_space_state = state["space"][from_language.value] - - if lang_space_state is None or not lang_space_state: - raise click.ClickException("Run Exception: There are no pre-defined state") + # load된 state가 있는지 확인 + if not StateManager().check_space(target_language): + raise click.ClickException( + f"No loaded file for language '{target_language.value}'. Please load a file first." + ) # 테케 분석 - try: - tcs: list[int] = parse_range_string_list(target) - except ValueError as e: - raise click.ClickException(e) + tcs: list[int] = _parse_range_string_list(list(testcase)) # build - try: s = subprocess.run( - from_language.build_command, + target_language.build_command, check=True, capture_output=True, text=True, - cwd=from_language.working_dir, + cwd=target_language.working_dir, ) print(s.stderr) except subprocess.CalledProcessError as e: - click.echo(">>>>>> [Error while BUILD process] >>>>>>") + click.secho(">>>>>> [BUILD TIME ERROR] >>>>>>", fg="red", bold=True) raise click.ClickException(e.stderr) + # execution for each test case for tc in tcs: if not ( os.path.isfile(f"{TC_DIR}/{tc}.in") and os.path.isfile(f"{TC_DIR}/{tc}.out") @@ -777,258 +643,217 @@ def run(from_: str, target: str, verbose: bool): else: f_in_path = f"{TC_DIR}/{tc}.in" f_out_path = f"{TC_DIR}/{tc}.out" - with open(f_in_path, "r", encoding="utf-8") as f_in: - try: - stdout = subprocess.run( - from_language.executable_command, - check=True, - capture_output=True, - text=True, - stdin=f_in, - ).stdout - except subprocess.CalledProcessError as e: - click.echo(">>>>>> [Error while RUNNING process] >>>>>>") - click.echo(f"{e.stderr}") - click.echo(f"returncode: {e.returncode}") - stdout = "" - continue - flag: bool = False - with open(f_out_path, "r", encoding="utf-8") as f_out: - flag = stdout.strip() == f_out.read().strip() - click.echo(f"{tc:02d}: {flag}") + + with ( + open(f_in_path, "r", encoding="utf-8") as f_in, + open(f_out_path, "r", encoding="utf-8") as f_out, + ): + expected = f_out.read().strip() + + proc = subprocess.Popen( + target_language.executable_command, + stdin=f_in, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if not verbose: + for line in proc.stdout: # type: ignore + print(line, end="") + + stdout, stderr = proc.communicate() + + if proc.returncode != 0: + click.secho(">>>>>> [RUN TIME ERROR] >>>>>>", fg="red", bold=True) + click.secho(f"{stderr}", fg="red") + click.secho(f"returncode: {proc.returncode}", fg="red") + continue + + flag = stdout.strip() == expected + symbol = ( + click.style("✓", fg="green") if flag else click.style("✗", fg="red") + ) + click.echo(f"{tc:02d}: {symbol}") if verbose: - click.echo("===" * 3) - click.echo(stdout) - click.echo("===" * 3) + click.secho("===" * 3, fg="bright_black") + click.secho("[INPUT]", fg="cyan", bold=True) + with open(f_in_path, "r") as f: + click.echo(f.read()) + click.secho("[ACTUAL]", fg="cyan", bold=True) + click.echo(stdout.strip() if stdout else "(empty)") + click.secho("===" * 3, fg="bright_black") @click.command(name="load") -@click.argument("file", type=str, nargs=1, required=True) -@click.option("--to", type=str, default="") # language value -@click.option("--from", "from_", type=str, required=True) +@click.argument("target", type=str, nargs=1, required=True) @click.option("--force", "-f", is_flag=True) -def load(file: str, to: str, from_: str, force: bool): +def load(target: str, force: bool): """ - 파일을 각 언어의 src-space로 이동시키는 명령어. - 이 명령어는 다음 과정을 수행함. - + Storage의 파일을 각 언어의 src-space로 이동시키는 명령. + - force는 src-space에 이미 파일이 존재할 때, 덮어쓸지 결정 """ # 파일의 src-space가 어딘지 확인 - to_language: Language - file_name: str - file_sep_ext = file.split(".") - if len(file_sep_ext) == 1: - file_name = file_sep_ext[0] - to_language = Language.convert_name(to) # if blank, undefined - elif len(file_sep_ext) == 2: - file_name = file_sep_ext[0] - to_language = Language.convert_name(file_sep_ext[1]) - else: - file_name = "" - to_language = Language.UNDEFINED + loc, prob, lang = _dispatch_target(target) - if to_language is Language.UNDEFINED: - raise click.UsageError("LoadError: `to` or `file` argument is wrong") - - try: - with open(STATE_PATH, "r", encoding="utf-8") as f_state: - state = yaml.safe_load(f_state) - except FileNotFoundError as e: - raise click.ClickException(e) - except yaml.YAMLError as e: - raise click.ClickException(e) - - if "space" not in state: - raise click.ClickException("state.yml is wrong") - elif not state["space"]: - click.secho("state.space has no member", fg="yellow", bold=True) - state["space"] = {} - - lang_space_state: dict | None - if to_language.value not in state["space"]: - lang_space_state = None - else: - lang_space_state = state["space"][to_language.value] - - if not force: - if lang_space_state is None or not lang_space_state: - pass - elif lang_space_state["file"] is None: - pass - else: + if StateManager().check_space(lang): + if not force: raise click.ClickException( - f"File {lang_space_state['file']}.{to_language.value} already in source space {to_language.value}" + f"Space for language '{lang.value}' is already occupied." ) - # 파일이 존재하는지 확인 - # uncompleted - file_is_exist: bool = False - file_is_completed: bool = False - file_loc: str = None - if os.path.isfile( - f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}" - ): - file_is_exist = True - file_is_completed = False - file_loc = ( - f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}" - ) - elif os.path.isfile( - f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}" - ): - file_is_exist = True - file_is_completed = True - file_loc = f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}" - # 존재하면 move - # 존재하지 않으면 빈 파일(또는 템플릿 파일)을 생성하고 - # state에 기록함 - if file_is_exist: - with ( - open(file_loc, "rb") as f_src, - open( - f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}", - "wb", - ) as f_dst, - ): - f_dst.write(f_src.read()) - os.remove(file_loc) - else: - if TEMPLATES_DIR and os.path.isfile( - f"{TEMPLATES_DIR}/template.{to_language.value}" - ): - with ( - open(f"{TEMPLATES_DIR}/template.{to_language.value}", "rb") as f_src, - open( - f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}", - "wb", - ) as f_dst, - ): - f_dst.write(f_src.read()) - else: - with open( - f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}", - "wb", - ) as f_dst: - pass + path = StorageManager().check_get_file(loc, lang, prob) - with open("state.yml", "w", encoding="utf-8") as f: - if lang_space_state is None: - state["space"][to_language.value] = {} - state["space"][to_language.value]["file"] = file_name - state["space"][to_language.value]["location"] = from_ - state["space"][to_language.value]["is_completed"] = file_is_completed - - yaml.safe_dump(state, f) - if file_is_exist: - click.echo( - f"File {file_name}.{to_language.value} from {from_}{'/completed' if file_is_completed else ''} is successfully loaded" + if path is None: + raise click.ClickException( + f"File '{prob}.{lang.value}' not found in location '{loc}'. Use 'create' command to create it first." ) - else: - click.echo(f"File {file_name}.{to_language.value} is successfully created") + + dest_path = SRC_SPACE_DIR / f"src-{lang.value}" / "src" / f"main.{lang.value}" + + with open(path, "rb") as f_src, open(dest_path, "wb") as f_dst: + f_dst.write(f_src.read()) + StateManager().remove_space(lang) + StateManager().add_space(lang, prob, loc, False) + StorageManager().remove_file(loc, lang, prob) + click.secho( + f"Loaded '{prob}.{lang.value}' from location '{loc}' to src-space.", + fg="cyan", + bold=True, + ) @click.command(name="export") -@click.option("--from", "from_", type=str, required=True) +@click.argument("from_", type=str, nargs=-1) @click.option( - "--completed/--no-completed", "-c/-nc", is_flag=True, flag_value=False, default=None + "--all", "all_", default=False, is_flag=True, help="Export all files in the space" ) -@click.option("--copy", is_flag=True, help="is copy from state or move") -def export(from_: str, completed: bool, copy: bool): - # Language 알기 - from_language = Language.convert_name(from_) +@click.option( + "--force", + "-f", + is_flag=True, + help="Overwrite existing file in storage if it already exists", +) +def export(from_: tuple[str, ...], all_: bool, force: bool): + """ + space에 있는 파일을 storage의 각 location으로 이동시키는 명령. + """ - if from_language is Language.UNDEFINED: - raise click.ClickException("Undefined Language Exception") - try: - with open("state.yml", "r", encoding="utf-8") as f: - state = yaml.safe_load(f) - except FileNotFoundError as e: - raise click.ClickException(e) - except yaml.YAMLError as e: - raise click.ClickException(e) + def _export_sub_command(from_language: Language): + space = StateManager().get_space(from_language) - if "space" not in state: - raise click.ClickException("State(state.yml) Exception") - elif not state["space"]: - click.secho("state.space has no member", fg="yellow", bold=True) - state["space"] = {} - - lang_space_state: dict | None - if from_language.value not in state["space"]: - lang_space_state = None - else: - lang_space_state = state["space"][from_language.value] - - if lang_space_state is None or not lang_space_state: - raise click.ClickException("Export Exception: There are no pre-defined state") - - s_file = lang_space_state["file"] - s_loc = lang_space_state["location"] - s_is_completed = lang_space_state["is_completed"] - - if not s_file: - raise click.ClickException( - f"Export Exception: There is no file in {from_language.value} space" + s_file, s_loc, s_is_completed = ( + space["file"], + space["location"], + space["is_completed"], ) - if not s_loc: - raise click.ClickException(f"Export Exception: Location {s_loc} is not defined") + path = StorageManager.construct_target_path( + s_loc, from_language, s_file, s_is_completed + ) - if completed: - s_is_completed = True + if path.is_file() and not force: + raise click.ClickException( + f"File '{path.name}' already exists in location '{s_loc}'. Use --force to overwrite." + ) - dest_path: str = f"{STORAGE_DIR}/{s_loc}/{from_language.value}{'/completed' if s_is_completed else ''}/{s_file}.{from_language.value}" - source_path: str = ( - f"{SRC_SPACE_DIR}/src-{from_language.value}/src/main.{from_language.value}" - ) + src_path = ( + SRC_SPACE_DIR + / f"src-{from_language.value}" + / "src" + / f"main.{from_language.value}" + ) - with open(source_path, "rb") as f_src, open(dest_path, "wb") as f_dst: - f_dst.write(f_src.read()) + with open(src_path, "rb") as f_src: + content = f_src.read() + os.remove(src_path) + StorageManager().save_file( + s_loc, from_language, s_file, content, s_is_completed + ) + StateManager().clear_space(from_language) + click.secho( + f"Exported '{s_file}.{from_language.value}' to location '{s_loc}'", + fg="cyan", + bold=True, + ) - click.echo(f"File {s_file}.{from_language.value} successfully ") - - if not copy: - with open("state.yml", "w", encoding="utf-8") as f: - state["space"][from_language.value] = {} - yaml.safe_dump(state, f) - - os.remove(source_path) + if all_: + for l in Language.languages(): + if StateManager().check_space(l): + _export_sub_command(l) + else: + froms = map(lambda x: Language.convert_name(x), from_) + for l in froms: + if not StateManager().check_space(l): + click.secho( + f"No loaded file for language '{l.value}' in space. Skipping export for this language.", + fg="yellow", + ) + continue + _export_sub_command(l) -@click.command(name="state") +@click.group(name="state") def state(): - try: - with open(STATE_PATH, "r", encoding="utf-8") as f_state: - state = yaml.safe_load(f_state) - except FileNotFoundError as e: - raise click.ClickException(e) - except yaml.YAMLError as e: - raise click.ClickException(e) + """State management commands""" + pass - if "space" not in state: - raise click.ClickException("state.yml is wrong") - elif not state["space"]: - click.secho("state.space has no member", fg="yellow", bold=True) - state["space"] = {} - active = sum(1 for v in state["space"].values() if v) - total = len(state["space"]) - click.secho(f"Space: {active}/{total} active", fg="cyan", bold=True) - click.echo() - - for k, v in state["space"].items(): - click.secho(f" {k}/", fg="yellow") - if not v: - click.echo(f" {click.style('—', fg='bright_black')} empty") - else: - status = ( +@state.command(name="show") +def state_show(): + """Show current space state""" + click.secho("Current Space State:", fg="cyan", bold=True) + for lang in Language.languages(): + space = ( + StateManager().get_space(lang) if StateManager().check_space(lang) else None + ) + if space: + status_symbol = ( click.style("✓", fg="green") - if v["is_completed"] + if space.get("is_completed") else click.style("○", fg="white") ) - click.echo(f" {status} {v['file']}.{k} @ {v['location']}") + click.echo( + f"{status_symbol} {lang.value}: {space['file']} (Location: {space['location']})" + ) + else: + click.echo(f"{click.style('○', fg='white')} {lang.value}: No file loaded") + + +@state.command(name="complete") +@click.argument("lang", type=str) +def state_complete(lang: str): + """Mark language space as completed""" + lang_obj = Language.convert_name(lang) + if lang_obj is Language.UNDEFINED: + raise click.ClickException(f"Unknown language: {lang}") + + state_manager = StateManager() + try: + state_manager.set_completed(lang_obj, True) + state_manager.save() + except ValueError as e: + raise click.ClickException(str(e)) + click.secho(f"Language '{lang}' marked as completed.", fg="green") + + +@state.command(name="uncomplete") +@click.argument("lang", type=str) +def state_uncomplete(lang: str): + """Mark language space as not completed""" + lang_obj = Language.convert_name(lang) + if lang_obj is Language.UNDEFINED: + raise click.ClickException(f"Unknown language: {lang}") + + state_manager = StateManager() + try: + state_manager.set_completed(lang_obj, False) + state_manager.save() + except ValueError as e: + raise click.ClickException(str(e)) + click.secho(f"Language '{lang}' marked as not completed.", fg="green") @click.command(name="init") @@ -1038,123 +863,58 @@ def init(count: int): init testcases with initial count and state.yml and config.yml """ if count < 1: - raise click.UsageError("var count should be greater than 1.") + raise click.UsageError("count should be greater than 0") - if not os.path.exists("./" + TC_DIR): - os.mkdir(TC_DIR) + TC_DIR.mkdir(exist_ok=True) for i in range(1, count + 1): - default_in_path = "./" + TC_DIR + f"/{i}.in" - default_out_path = "./" + TC_DIR + f"/{i}.out" - if not os.path.exists(default_in_path): - with open(default_in_path, "w", encoding="utf-8") as _: - pass - if not os.path.exists(default_out_path): - with open(default_out_path, "w", encoding="utf-8") as _: - pass - - click.echo(f"1. Testcases was made with count {count} successfully!") - - if not os.path.isfile("./state.yml"): - click.echo("2-1. state.yml does not exist, creating...") - with open("state.yml", "w", encoding="utf-8") as f: - f.write("space:\n") - click.echo("2-1. state.yml is created successfully!") + (TC_DIR / f"{i}.in").touch() + (TC_DIR / f"{i}.out").touch() + click.secho(f"1. Testcases created with count {count}", fg="cyan") + click.secho("2. Checking state.yml...", fg="cyan") + if not STATE_PATH.is_file(): + click.secho("2. state.yml does not exist, creating...", fg="yellow") + STATE_PATH.write_text("space:\n", encoding="utf-8") + click.secho("2. state.yml created", fg="cyan") else: - click.echo("2-1. state.yml exists") - if not os.path.isfile("./config.yml"): - click.echo("2-2. config.yml does not exist, creating...") - with open("config.yml", "w", encoding="utf-8") as f: - f.write("space:\n") - click.echo("2-2. config.yml is created successfully!") - else: - click.echo("2-2. config.yml exists") + click.secho("2. state.yml exists", fg="green") @click.command(name="show") -@click.argument("filter", type=str, default=None, required=False) -@click.option( - "--completed/--no-completed", - "-c/-nc", - default=False, - help="Filter by completed status (default: uncompleted only)", -) -@click.option( - "--all", - "show_all", - is_flag=True, - default=False, - help="Show all problems regardless of completed status", -) -def show(filter: str | None, completed: bool, show_all: bool): - """ - Show all stored problems in storage directory. +@click.argument("filter_", type=str, default=None, required=False) +@click.option("--completed", "completed_mode", flag_value="completed", default=True) +@click.option("--uncompleted", "completed_mode", flag_value="uncompleted") +@click.option("--both-completed", "completed_mode", flag_value="both") +def show(filter_: str | None, completed_mode: str): + """Show all stored problems in storage directory. - \b FILTER format (optional): location e.g. zeta location/lang e.g. zeta/rs /lang e.g. /py + + FILTER MODE (default: --completed): + --completed show completed only + --uncompleted show uncompleted only + --both-completed show both completed and uncompleted """ - location = None - lang = None - if filter: - parts = filter.split("/", 1) - if len(parts) == 2: - location = parts[0] or None - lang = parts[1] or None - else: - location = parts[0] + completed = {"completed": True, "uncompleted": False, "both": None}[completed_mode] - storage = pathlib.Path(STORAGE_DIR) - if not storage.is_dir(): - raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found") + location = lang = None + if filter_: + parts = filter_.split("/", 1) + location = parts[0] if parts[0].strip() else None + lang = parts[1] if len(parts) == 2 else None - # Collect all files - entries = [] # (location, language, filename, is_completed) - - for loc_dir in sorted(storage.iterdir()): - if not loc_dir.is_dir(): - continue - loc_name = loc_dir.name - - if location and loc_name != location: - continue - - for lang_dir in sorted(loc_dir.iterdir()): - if not lang_dir.is_dir(): - continue - lang_name = lang_dir.name - - if ( - lang - and lang_name != Language.convert_name(lang).value - and lang_name != lang - ): - continue - - # uncompleted files - for f in sorted(lang_dir.iterdir(), key=lambda p: natural_sort_key(p.stem)): - if f.is_file(): - entries.append((loc_name, lang_name, f.stem, False)) - - # completed files - completed_dir = lang_dir / "completed" - if completed_dir.is_dir(): - for f in sorted( - completed_dir.iterdir(), key=lambda p: natural_sort_key(p.stem) - ): - if f.is_file(): - entries.append((loc_name, lang_name, f.stem, True)) - - # Filter by completed status - if not show_all: - entries = [e for e in entries if e[3] == completed] + entries = StorageManager().list_entries( + location=location, + lang=Language.convert_name(lang) if lang else None, + completed=completed, + ) if not entries: - click.echo("No problems found.") + click.echo(" - ") return - # Display total = len(entries) completed_count = sum(1 for e in entries if e[3]) uncompleted_count = total - completed_count @@ -1166,7 +926,6 @@ def show(filter: str | None, completed: bool, show_all: bool): ) click.echo() - # Group by location current_loc = None current_lang = None for loc_name, lang_name, file_name, is_completed in entries: @@ -1188,90 +947,64 @@ def show(filter: str | None, completed: bool, show_all: bool): @click.command(name="find") -@click.argument("keyword", type=str) +@click.argument("filter_", type=str) @click.option( "--completed/--no-completed", "-c/-nc", default=None, help="Filter by completed status (default: all)", ) -def find(keyword: str, completed: bool | None): +def find(filter_: str, completed: bool | None): """ - Find problems by keyword in storage. + Find problems by filter in storage. - KEYWORD format: - keyword e.g. 2447 - keyword.ext e.g. 2447.py (filters by language) - .ext e.g. .py (all files of a language) - location/keyword e.g. zeta/2447 - location/keyword.ext e.g. zeta/2447.py - location/.ext e.g. zeta/.py + FILTER format: + filter e.g. 2447 + filter.ext e.g. 2447.py (filters by language) + .ext e.g. .py (all files of a language) + location/filter e.g. zeta/2447 + location/filter.ext e.g. zeta/2447.py + location/.ext e.g. zeta/.py """ - location = None - lang = None - if "/" in keyword: - parts = keyword.split("/", 1) + location: str | None = None + filter_base: str | None = None + lang: Language | None = None + + if "/" in filter_: + parts = filter_.split("/", 1) location = parts[0] or None - keyword = parts[1] + filter_ = parts[1] + else: + location = None - # Extract language from extension - if "." in keyword: - keyword_base, ext = keyword.rsplit(".", 1) + if "." in filter_: + filter_base, ext = filter_.rsplit(".", 1) lang_candidate = Language.convert_ext(ext) if lang_candidate is not Language.UNDEFINED: - lang = lang_candidate.value + lang = lang_candidate + filter_base = None if filter_base == "" else filter_base else: - keyword_base = keyword + filter_base = filter_ else: - keyword_base = keyword + filter_base = filter_ - storage = pathlib.Path(STORAGE_DIR) - if not storage.is_dir(): - raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found") - - entries = [] # (location, language, filename, is_completed) - - for loc_dir in sorted(storage.iterdir()): - if not loc_dir.is_dir(): - continue - loc_name = loc_dir.name - - if location and loc_name != location: - continue - - for lang_dir in sorted(loc_dir.iterdir()): - if not lang_dir.is_dir(): - continue - lang_name = lang_dir.name - - if lang and lang_name != lang: - continue - - for f in lang_dir.iterdir(): - if f.is_file() and keyword_base.lower() in f.stem.lower(): - entries.append((loc_name, lang_name, f.stem, False)) - - completed_dir = lang_dir / "completed" - if completed_dir.is_dir(): - for f in completed_dir.iterdir(): - if f.is_file() and keyword_base.lower() in f.stem.lower(): - entries.append((loc_name, lang_name, f.stem, True)) - - if completed is not None: - entries = [e for e in entries if e[3] == completed] - - entries.sort(key=lambda e: (e[0], e[1], natural_sort_key(e[2]))) + entries = StorageManager().list_entries( + location=location, lang=lang, completed=completed + ) + if filter_base is not None: + entries = [e for e in entries if filter_base.lower() in e[2].lower()] + entries.sort(key=lambda e: (e[0], e[1], _natural_sort_key(e[2]))) if not entries: click.echo("No problems found.") return - total = len(entries) + total_count = len(entries) completed_count = sum(1 for e in entries if e[3]) - uncompleted_count = total - completed_count + uncompleted_count = sum(1 for e in entries if not e[3]) click.secho( - f"Found: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})", + f"Found: {total_count} (completed: {completed_count}, uncompleted: {uncompleted_count})", fg="cyan", bold=True, ) @@ -1297,77 +1030,71 @@ def find(keyword: str, completed: bool | None): click.echo(f" {status} {file_name}.{lang_name}") -@click.command(name="fetchprob") -@click.argument("target", type=str, nargs=1, required=True) -@click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files") -def fetchprob(target: str, force: bool): - """ - Fetch BOJ problem HTML into storage/zeta/_static. +@click.command(name="version") +def version(): + """Show version information.""" + click.echo(f"CodeObject version {__version__}") - TARGET: - zeta/ Fetch one problem - zeta Fetch all detected problem ids under storage/zeta - """ - location, problem_id = parse_fetchprob_target(target) - if location != "zeta": - raise click.UsageError("only 'zeta' location is supported") - if problem_id is not None: - if _problem_static_html_path(problem_id).exists() and not force: - click.echo(f"{problem_id}: skipped (already exists)") - return +@click.command(name="status") +def status(): + """Show storage statistics.""" + all_entries = StorageManager().list_entries() - raw_html = fetch_boj_problem_html(problem_id) - offline_html = make_offline_problem_html(problem_id, raw_html, force=force) - result = save_problem_html(problem_id, offline_html, force=force) - if result == "skipped": - click.echo(f"{problem_id}: skipped (already exists)") - else: - click.echo(f"{problem_id}: fetched (offline processed + images)") + if not all_entries: + click.echo("No problems in storage.") return - ids = collect_zeta_problem_ids() - if not ids: - click.echo("No problem ids found in storage/zeta") - return + stats: dict[str, dict[str, dict[str, int]]] = {} + for loc, lang, _, is_completed in all_entries: + if loc not in stats: + stats[loc] = {} + if lang not in stats[loc]: + stats[loc][lang] = {"total": 0, "completed": 0} + stats[loc][lang]["total"] += 1 + if is_completed: + stats[loc][lang]["completed"] += 1 - attempted = len(ids) - fetched = 0 - skipped = 0 - failed = 0 - - for pid in ids: - try: - if _problem_static_html_path(pid).exists() and not force: - skipped += 1 - click.echo(f"{pid}: skipped") - continue - - raw_html = fetch_boj_problem_html(pid) - offline_html = make_offline_problem_html(pid, raw_html, force=force) - result = save_problem_html(pid, offline_html, force=force) - if result == "skipped": - skipped += 1 - click.echo(f"{pid}: skipped") - else: - fetched += 1 - click.echo(f"{pid}: fetched (offline processed + images)") - except click.ClickException as e: - failed += 1 - click.echo(f"{pid}: failed ({e.message})") + total_problems = len(all_entries) + total_completed = sum(1 for _, _, _, c in all_entries if c) + click.secho("Storage Status", fg="cyan", bold=True) click.echo() + + for loc_name, langs in sorted(stats.items()): + loc_total = sum(c["total"] for c in langs.values()) + loc_completed = sum(c["completed"] for c in langs.values()) + loc_uncompleted = loc_total - loc_completed + click.secho(f"[{loc_name}]", fg="green", bold=True, nl=False) + click.secho(f" {loc_total}", fg="yellow", bold=False, nl=False) + click.secho(" (", fg="white", bold=False, nl=False) + click.secho(f"{loc_completed}", fg="green", bold=False, nl=False) + click.secho(" completed, ", fg="cyan", bold=False, nl=False) + click.secho(f"{loc_uncompleted}", fg="red", bold=False, nl=False) + click.secho(" not completed", fg="cyan", bold=False, nl=False) + click.secho(")", fg="white", bold=False) + for lang_name, counts in sorted(langs.items()): + click.echo(f" {lang_name}: {counts['completed']}/{counts['total']}") + click.echo() + click.secho( - ( - f"Summary - attempted: {attempted}, fetched: {fetched}, " - f"skipped: {skipped}, failed: {failed}" - ), + f"Total: {total_problems} (completed: {total_completed}, " + f"uncompleted: {total_problems - total_completed})", fg="cyan", - bold=True, ) +@click.command(name="fetchprob") +@click.argument("target", type=str, nargs=1, required=True) +@click.option("--force", "-f", is_flag=True, help="Overwrite existing static files") +def fetchprob(target: str, force: bool): + """(disabled) Fetch BOJ problem HTML - BOJ is closing, keeping for future multi-location support.""" + del target, force + raise click.ClickException("fetchprob is currently disabled (BOJ closing)") + +cli.add_command(register) +cli.add_command(create) cli.add_command(run) cli.add_command(load) cli.add_command(init) @@ -1375,6 +1102,8 @@ cli.add_command(export) cli.add_command(state) cli.add_command(show) cli.add_command(find) +cli.add_command(version) +cli.add_command(status) cli.add_command(fetchprob) if __name__ == "__main__":