diff --git a/docs/export-format.md b/docs/export-format.md index ddddc09..33ea298 100644 --- a/docs/export-format.md +++ b/docs/export-format.md @@ -15,7 +15,7 @@ decoder-only offsets. "capture_source": "npcap", "exporter": { "name": "nte-history-exporter", - "version": "0.1.6" + "version": "0.1.7" }, "banner": { "id": "Lottery_Permanent", diff --git a/pyproject.toml b/pyproject.toml index 05825f8..b6a6a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nte-history-exporter" -version = "0.1.6" +version = "0.1.7" description = "Cross-platform Neverness to Everness pull-history exporter." requires-python = ">=3.10" dependencies = [] diff --git a/src/nte_history_exporter/__init__.py b/src/nte_history_exporter/__init__.py index 0a8da88..f1380ee 100644 --- a/src/nte_history_exporter/__init__.py +++ b/src/nte_history_exporter/__init__.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.1.7" diff --git a/src/nte_history_exporter/cli.py b/src/nte_history_exporter/cli.py index c069f0d..09b7368 100644 --- a/src/nte_history_exporter/cli.py +++ b/src/nte_history_exporter/cli.py @@ -5,11 +5,13 @@ import json from nte_history_exporter import console from nte_history_exporter.adapters.mitmproxy_flows import decode_mitmproxy_flows +from nte_history_exporter.constants import EXPORTER_VERSION from nte_history_exporter.decoder.boundary import annotate_groups from nte_history_exporter.export.csv_export import write_csv from nte_history_exporter.export.json_export import build_export_json from nte_history_exporter.live_capture.libpcap import LibpcapUnavailable from nte_history_exporter.live_capture.runner import export_paths, run_live_capture +from nte_history_exporter.update_check import check_for_update def build_parser() -> argparse.ArgumentParser: @@ -38,6 +40,9 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) console.print_banner() + update = check_for_update(EXPORTER_VERSION) + if update: + console.print_update_available(update.current_version, update.latest_version, update.release_url) if args.live or not args.capture_source: try: run_live_capture( diff --git a/src/nte_history_exporter/console.py b/src/nte_history_exporter/console.py index cacc582..c24a053 100644 --- a/src/nte_history_exporter/console.py +++ b/src/nte_history_exporter/console.py @@ -13,6 +13,7 @@ BOLD = "\x1b[1m" DIM = "\x1b[2m" CYAN = "\x1b[36m" GREEN = "\x1b[32m" +RED = "\x1b[31m" YELLOW = "\x1b[33m" BRIGHT_WHITE = "\x1b[97m" @@ -62,7 +63,7 @@ def print_banner() -> None: print(style(f" {GAME_NAME} pull history -> tracker JSON", DIM)) print(style(f" {REPOSITORY_URL}", DIM)) heart = "❤️" if (getattr(sys.stdout, "encoding", None) or "").lower().replace("-", "") == "utf8" else "<3" - print(style(f" Created with {heart} by Golumpa", DIM)) + print(style(" Created with ", DIM) + style(heart, RED) + style(" by Golumpa", DIM)) print(rule("=")) @@ -162,6 +163,14 @@ def print_problem(text: str) -> None: print(style(f" {text}", YELLOW, BOLD)) +def print_update_available(current_version: str, latest_version: str, release_url: str) -> None: + print() + print(style(f" Update available: v{current_version} -> {latest_version}", YELLOW, BOLD)) + print(style(" Some banner/reward mappings may be incomplete in this version.", YELLOW)) + print(style(" Updating is recommended for the most accurate export labels.", YELLOW)) + print(style(f" Download when ready: {release_url}", YELLOW)) + + def prompt_user_uid() -> str | None: print() print_problem("User UID was not detected in this capture.") diff --git a/src/nte_history_exporter/constants.py b/src/nte_history_exporter/constants.py index 5f1b653..1eac2f8 100644 --- a/src/nte_history_exporter/constants.py +++ b/src/nte_history_exporter/constants.py @@ -11,7 +11,7 @@ LIMITED_CHARACTER_BANNER_NAME = "Limited Character Board" ARC_BANNER_ID = "Arc_MiracleBox" ARC_BANNER_NAME = "Arc Miracle Box" EXPORTER_NAME = "nte-history-exporter" -EXPORTER_VERSION = "0.1.6" +EXPORTER_VERSION = "0.1.5" HISTORY_REQUEST_BANNER = 4220 HISTORY_REQUEST_LENGTH = 45 diff --git a/src/nte_history_exporter/update_check.py b/src/nte_history_exporter/update_check.py new file mode 100644 index 0000000..53b484f --- /dev/null +++ b/src/nte_history_exporter/update_check.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import json +import re +import urllib.request +from dataclasses import dataclass +from typing import Any + +LATEST_RELEASE_API_URL = "https://api.github.com/repos/Golumpa/nte-exporter/releases/latest" +RELEASES_URL = "https://github.com/Golumpa/nte-exporter/releases" + + +@dataclass(frozen=True) +class UpdateInfo: + current_version: str + latest_version: str + release_url: str + + +def check_for_update(current_version: str, *, timeout: float = 1.5) -> UpdateInfo | None: + """Return update details when GitHub has a newer release. + + Update checks are intentionally best-effort. Network errors, invalid + responses, or unusual tag names should not stop the exporter from running. + """ + + try: + latest = fetch_latest_release(timeout=timeout) + except Exception: + return None + + latest_version = str(latest.get("tag_name", "")).strip() + release_url = str(latest.get("html_url", "")).strip() or RELEASES_URL + if not latest_version or not is_newer_version(latest_version, current_version): + return None + return UpdateInfo( + current_version=current_version, + latest_version=latest_version, + release_url=release_url, + ) + + +def fetch_latest_release(*, timeout: float) -> dict[str, Any]: + request = urllib.request.Request( + LATEST_RELEASE_API_URL, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "nte-history-exporter", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def is_newer_version(candidate: str, current: str) -> bool: + candidate_parts = _version_parts(candidate) + current_parts = _version_parts(current) + if candidate_parts is None or current_parts is None: + return False + width = max(len(candidate_parts), len(current_parts)) + normalized_candidate = candidate_parts + (0,) * (width - len(candidate_parts)) + normalized_current = current_parts + (0,) * (width - len(current_parts)) + return normalized_candidate > normalized_current + + +def _version_parts(version: str) -> tuple[int, ...] | None: + match = re.fullmatch(r"v?(\d+(?:\.\d+)*)", version.strip()) + if not match: + return None + return tuple(int(part) for part in match.group(1).split(".")) diff --git a/tests/test_boundary_export.py b/tests/test_boundary_export.py index bd93cd6..9e1c925 100644 --- a/tests/test_boundary_export.py +++ b/tests/test_boundary_export.py @@ -49,6 +49,7 @@ from nte_history_exporter.live_capture.backends import open_capture_backend from nte_history_exporter.export.json_export import build_export_json from nte_history_exporter.live_capture.runner import export_paths from nte_history_exporter.pool_mappings import load_pool_mappings, pool_meta_from_mapping +from nte_history_exporter.update_check import UpdateInfo, check_for_update, is_newer_version def load_reference_csv(name): @@ -199,6 +200,38 @@ class BoundaryExportTests(unittest.TestCase): with self.subTest(pool_key=pool_key): self.assertEqual(pool_meta_from_mapping(mapping), POOL_META[pool_key]) + def test_update_version_comparison_handles_release_tags(self): + self.assertTrue(is_newer_version("v0.1.7", "0.1.6")) + self.assertTrue(is_newer_version("0.2.0", "0.1.6")) + self.assertTrue(is_newer_version("v0.1.10", "0.1.9")) + self.assertFalse(is_newer_version("v0.1.6", "0.1.6")) + self.assertFalse(is_newer_version("v0.1.5", "0.1.6")) + self.assertFalse(is_newer_version("latest", "0.1.6")) + + def test_update_check_reports_newer_github_release(self): + latest = { + "tag_name": "v0.1.7", + "html_url": "https://github.com/Golumpa/nte-exporter/releases/tag/v0.1.7", + } + with patch("nte_history_exporter.update_check.fetch_latest_release", return_value=latest): + update = check_for_update("0.1.6", timeout=0.1) + + self.assertEqual( + update, + UpdateInfo( + current_version="0.1.6", + latest_version="v0.1.7", + release_url="https://github.com/Golumpa/nte-exporter/releases/tag/v0.1.7", + ), + ) + + def test_update_check_is_quiet_when_unavailable_or_current(self): + with patch("nte_history_exporter.update_check.fetch_latest_release", side_effect=OSError("offline")): + self.assertIsNone(check_for_update("0.1.6", timeout=0.1)) + + with patch("nte_history_exporter.update_check.fetch_latest_release", return_value={"tag_name": "v0.1.6"}): + self.assertIsNone(check_for_update("0.1.6", timeout=0.1)) + def test_reward_mapping_files_have_expected_shape(self): self.assertTrue(ARC_META) for arc_id, meta in ARC_META.items():