Skip to content

Commit 01ddb1e

Browse files
committed
feat: Enhanced Changelog Tab with auto-updates, images, and translations
1 parent 9689f52 commit 01ddb1e

File tree

6 files changed

+483
-21
lines changed

6 files changed

+483
-21
lines changed

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ pytest==7.4.4
99
pytest-qt==4.2.0
1010
flake8==7.0.0
1111
mypy==1.8.0
12+
markdown
13+
deep-translator

src/core/config_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ class ConfigManager:
1212
ORG_NAME = "Antigravity"
1313

1414
def __init__(self):
15+
# Determine application root for assets (not config writability)
16+
import sys
17+
if getattr(sys, 'frozen', False):
18+
self.base_path = os.path.dirname(sys.executable)
19+
else:
20+
# Assumes src/core/config_manager.py -> project_root/
21+
self.base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22+
1523
self.config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation)
1624
self.config_file = os.path.join(self.config_dir, "config.json")
1725
self._ensure_config_dir()

src/ui/main_window.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shutil
88
import zipfile
99
import random
10+
import json
1011
from pathlib import Path
1112
from datetime import datetime
1213

@@ -41,6 +42,7 @@
4142
from src.ui.tabs.create_tab import CreateTab
4243
from src.ui.components.activity_panel import ActivityPanel
4344
from src.core.automation_service import AutomationService
45+
from src.ui.tabs.changelog_tab import ChangelogTab
4446

4547

4648

@@ -316,6 +318,18 @@ def build_ui_content(self):
316318

317319

318320

321+
# Get version dynamically
322+
try:
323+
with open(os.path.join(self.config_manager.base_path, "version.json"), "r") as f:
324+
ver_data = json.load(f)
325+
current_ver = ver_data.get("latest_version", "1.0.0")
326+
except Exception as e:
327+
print(f"Error reading version.json: {e}")
328+
current_ver = "1.0.0"
329+
330+
self.changelog_tab = ChangelogTab(current_version=current_ver)
331+
self.tabs.addTab(self.changelog_tab, self.tr("changelog"))
332+
319333
self.about_tab = AboutTab()
320334
self.tabs.addTab(self.about_tab, self.tr("tab_credits"))
321335

src/ui/tabs/changelog_tab.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QTextBrowser,
2+
QComboBox, QHBoxLayout, QPushButton)
3+
from PySide6.QtCore import Qt, QThread, Signal
4+
from PySide6.QtGui import QTextDocument
5+
6+
import requests
7+
import json
8+
import markdown
9+
import base64
10+
from bs4 import BeautifulSoup
11+
from datetime import datetime
12+
from deep_translator import GoogleTranslator
13+
14+
class ChangelogFetcher(QThread):
15+
finished = Signal(list) # Returns list of releases
16+
error = Signal(str)
17+
18+
def run(self):
19+
try:
20+
# Fetch from GitHub API
21+
url = "https://api.github.com/repos/Spieler1ONE1/SCCharacters/releases"
22+
# GitHub requires a User-Agent
23+
headers = {"User-Agent": "SCCharactersApp"}
24+
response = requests.get(url, headers=headers, timeout=10)
25+
26+
if response.status_code == 200:
27+
releases = response.json()
28+
self.finished.emit(releases)
29+
else:
30+
self.error.emit(f"Failed to fetch releases: {response.status_code} {response.reason}")
31+
except Exception as e:
32+
self.error.emit(str(e))
33+
34+
class TranslationWorker(QThread):
35+
finished = Signal(str, str) # release_tag, translated_text
36+
37+
def __init__(self, release_tag, text, target_lang):
38+
super().__init__()
39+
self.release_tag = release_tag
40+
self.text = text
41+
self.target_lang = target_lang
42+
43+
def run(self):
44+
try:
45+
# Check for internet or just try
46+
# GoogleTranslator handles splitting chunks if needed, but for changelogs it might be fine directly
47+
# or we might want to split slightly. deep_translator usually handles reasonable lengths.
48+
translation = GoogleTranslator(source='auto', target=self.target_lang).translate(self.text)
49+
self.finished.emit(self.release_tag, translation)
50+
except Exception as e:
51+
# Fallback to original text on error
52+
self.finished.emit(self.release_tag, self.text)
53+
54+
class ChangelogTab(QWidget):
55+
def __init__(self, current_version="1.0.0", parent=None):
56+
super().__init__(parent)
57+
self.current_version = current_version
58+
self.releases = []
59+
self.translation_cache = {} # Map tag -> translated_body
60+
self.current_worker = None
61+
self.setup_ui()
62+
# Delay load slightly to ensure UI is ready
63+
from PySide6.QtCore import QTimer
64+
QTimer.singleShot(100, self.load_data)
65+
66+
def setup_ui(self):
67+
layout = QVBoxLayout(self)
68+
layout.setContentsMargins(20, 20, 20, 20)
69+
layout.setSpacing(15)
70+
71+
# Header
72+
header_layout = QHBoxLayout()
73+
header_layout.setSpacing(10)
74+
75+
title = QLabel(self.tr("Changelog"))
76+
title.setObjectName("H1")
77+
title.setStyleSheet("font-size: 24px; font-weight: bold; color: #f8fafc;")
78+
79+
self.version_combo = QComboBox()
80+
self.version_combo.setMinimumWidth(200)
81+
self.version_combo.currentIndexChanged.connect(self.on_version_changed)
82+
83+
button_style = """
84+
QPushButton {
85+
background-color: transparent;
86+
color: #94a3b8;
87+
border: 1px solid #334155;
88+
border-radius: 4px;
89+
padding: 4px 8px;
90+
}
91+
QPushButton:hover {
92+
background-color: #334155;
93+
color: #f8fafc;
94+
}
95+
"""
96+
97+
btn_refresh = QPushButton("↻")
98+
btn_refresh.setToolTip(self.tr("Reload"))
99+
btn_refresh.setCursor(Qt.PointingHandCursor)
100+
btn_refresh.setStyleSheet(button_style)
101+
btn_refresh.clicked.connect(self.load_data)
102+
103+
header_layout.addWidget(title)
104+
header_layout.addStretch()
105+
header_layout.addWidget(QLabel(self.tr("Version:")))
106+
header_layout.addWidget(self.version_combo)
107+
header_layout.addWidget(btn_refresh)
108+
109+
layout.addLayout(header_layout)
110+
111+
# Content Area
112+
self.content_area = QTextBrowser()
113+
self.content_area.setOpenExternalLinks(True)
114+
# Use document CSS to force styling on markdown elements
115+
self.content_area.setStyleSheet("""
116+
QTextBrowser {
117+
background-color: #1e293b;
118+
border: 1px solid #334155;
119+
border-radius: 8px;
120+
padding: 15px;
121+
color: #e2e8f0;
122+
font-family: 'Segoe UI', sans-serif;
123+
font-size: 14px;
124+
line-height: 1.6;
125+
}
126+
""")
127+
128+
layout.addWidget(self.content_area)
129+
130+
def load_data(self):
131+
self.content_area.setHtml(f"<div style='color: #94a3b8; font-style: italic;'>{self.tr('Loading updates...')}</div>")
132+
self.fetcher = ChangelogFetcher()
133+
self.fetcher.finished.connect(self.on_data_loaded)
134+
self.fetcher.error.connect(self.on_error)
135+
self.fetcher.start()
136+
137+
def on_data_loaded(self, releases):
138+
try:
139+
self.releases = releases
140+
self.version_combo.blockSignals(True) # Prevent triggering change during clear
141+
self.version_combo.clear()
142+
self.translation_cache = {} # Clear cache on reload
143+
144+
if not releases:
145+
self.content_area.setHtml("<div style='color: #94a3b8;'>No release notes found.</div>")
146+
self.version_combo.blockSignals(False)
147+
return
148+
149+
for release in releases:
150+
tag = release.get("tag_name", "Unknown")
151+
date_str = release.get("published_at", "")[:10]
152+
153+
display_text = f"{tag} ({date_str})"
154+
155+
# Normalize versions for comparison (remove 'v')
156+
tag_clean = tag.lstrip('v')
157+
curr_clean = self.current_version.lstrip('v')
158+
159+
if tag_clean == curr_clean:
160+
display_text += " (Current)"
161+
162+
self.version_combo.addItem(display_text, release)
163+
164+
self.version_combo.blockSignals(False)
165+
166+
if self.releases:
167+
self.version_combo.setCurrentIndex(0)
168+
self.display_release(self.releases[0])
169+
except Exception as e:
170+
self.on_error(f"UI Error: {str(e)}")
171+
172+
def on_error(self, msg):
173+
self.content_area.setHtml(f"<div style='color: #ef4444; font-weight: bold;'>Error loading changelog:</div><br>{msg}")
174+
175+
def on_version_changed(self, index):
176+
if index >= 0:
177+
release = self.version_combo.itemData(index)
178+
self.display_release(release)
179+
180+
def on_translation_finished(self, tag, translated_text):
181+
self.translation_cache[tag] = translated_text
182+
183+
# If currently selected release is the one we just translated, refresh view
184+
current_idx = self.version_combo.currentIndex()
185+
if current_idx >= 0:
186+
current_tag = self.version_combo.itemData(current_idx).get("tag_name")
187+
if current_tag == tag:
188+
self.display_release(self.version_combo.itemData(current_idx))
189+
190+
def display_release(self, release):
191+
try:
192+
body = release.get("body")
193+
if not body:
194+
body = "*No description provided for this release.*"
195+
tag = release.get("tag_name")
196+
197+
# Check Language
198+
from src.utils.translations import translator
199+
current_lang = translator.current_lang
200+
201+
# If not English and not cached, start translation
202+
if current_lang != 'en':
203+
if tag in self.translation_cache:
204+
body = self.translation_cache[tag]
205+
else:
206+
# Trigger translation if not running for this tag
207+
# We show original English meanwhile, maybe with a note
208+
body = f"> *{self.tr('Translating...')}*\n\n" + body
209+
210+
# Avoid spawning multiple workers for same tag
211+
# Simple check: we just fire it.
212+
worker = TranslationWorker(tag, release.get("body", ""), current_lang)
213+
worker.finished.connect(self.on_translation_finished)
214+
worker.start()
215+
# Store reference to prevent GC?
216+
self.current_worker = worker # Only keeps last one, but fine for sequential browsing
217+
218+
# Convert Markdown to HTML using the python-markdown library
219+
# 'extra' includes tables, fenced_code, etc.
220+
html_content = markdown.markdown(body, extensions=['extra', 'nl2br'])
221+
222+
# Process images to embed them directly (fixes Qt display issues)
223+
soup = BeautifulSoup(html_content, 'html.parser')
224+
for img in soup.find_all('img'):
225+
src = img.get('src')
226+
if src and src.startswith(('http://', 'https://')):
227+
try:
228+
# Fetch image
229+
headers = {"User-Agent": "SCCharactersApp"}
230+
response = requests.get(src, headers=headers, timeout=5)
231+
if response.status_code == 200:
232+
# Convert to base64
233+
b64_data = base64.b64encode(response.content).decode('utf-8')
234+
content_type = response.headers.get('Content-Type', 'image/png')
235+
img['src'] = f"data:{content_type};base64,{b64_data}"
236+
except Exception as img_err:
237+
print(f"Failed to load image {src}: {img_err}")
238+
239+
processed_html = str(soup)
240+
241+
# Create a full HTML document with embedded CSS
242+
full_html = f"""
243+
<!DOCTYPE html>
244+
<html>
245+
<head>
246+
<style>
247+
body {{
248+
color: #e2e8f0;
249+
font-family: 'Segoe UI', 'Segoe UI Emoji', 'Apple Color Emoji', sans-serif;
250+
font-size: 14pt;
251+
background-color: #1e293b;
252+
line-height: 1.6;
253+
}}
254+
h1 {{ color: #818cf8; font-size: 24pt; font-weight: bold; margin: 20px 0 10px 0; }}
255+
h2 {{ color: #a5b4fc; font-size: 20pt; font-weight: bold; margin: 15px 0 8px 0; border-bottom: 1px solid #334155; padding-bottom: 5px; }}
256+
h3 {{ color: #c7d2fe; font-size: 16pt; font-weight: bold; margin: 10px 0 5px 0; }}
257+
h4, h5, h6 {{ color: #e2e8f0; font-size: 14pt; font-weight: bold; }}
258+
259+
p {{ margin-bottom: 15px; }}
260+
ul, ol {{ margin-left: 20px; margin-bottom: 15px; }}
261+
li {{ margin-bottom: 5px; }}
262+
263+
a {{ color: #38bdf8; text-decoration: none; font-weight: bold; }}
264+
265+
img {{
266+
max-width: 100%;
267+
height: auto;
268+
display: block;
269+
margin: 15px auto;
270+
border-radius: 8px;
271+
border: 1px solid #334155;
272+
}}
273+
274+
code {{
275+
background-color: #334155;
276+
color: #f1f5f9;
277+
padding: 2px 4px;
278+
border-radius: 4px;
279+
font-family: 'Consolas', monospace;
280+
font-size: 13pt;
281+
}}
282+
283+
pre {{
284+
background-color: #0f172a;
285+
color: #cbd5e1;
286+
padding: 15px;
287+
border: 1px solid #334155;
288+
border-radius: 6px;
289+
margin: 15px 0;
290+
white-space: pre-wrap;
291+
}}
292+
293+
blockquote {{
294+
border-left: 4px solid #64748b;
295+
padding-left: 15px;
296+
color: #94a3b8;
297+
margin: 10px 0;
298+
background-color: #1e293b;
299+
}}
300+
301+
table {{
302+
border-collapse: collapse;
303+
width: 100%;
304+
margin: 20px 0;
305+
background-color: #1e293b;
306+
border: 1px solid #475569;
307+
}}
308+
th {{
309+
background-color: #334155;
310+
color: #f1f5f9;
311+
padding: 12px;
312+
border: 1px solid #475569;
313+
font-weight: bold;
314+
text-align: left;
315+
}}
316+
td {{
317+
padding: 10px;
318+
border: 1px solid #475569;
319+
color: #e2e8f0;
320+
}}
321+
322+
hr {{ color: #475569; background-color: #475569; height: 1px; border: none; margin: 30px 0; }}
323+
</style>
324+
</head>
325+
<body>
326+
{processed_html}
327+
<br><br>
328+
</body>
329+
</html>
330+
"""
331+
332+
self.content_area.setHtml(full_html)
333+
334+
except Exception as e:
335+
self.on_error(f"Render Error: {str(e)}")
336+
337+
def tr(self, text):
338+
# Fallback if no translator context
339+
from src.utils.translations import translator
340+
try:
341+
return translator.get(text)
342+
except Exception:
343+
return text

0 commit comments

Comments
 (0)