diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..033fd4339 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,140 @@ +# Contributing to TournamentStreamHelper + +## Tech Stack Overview + +| Layer | Technology | +|-------|-----------| +| Desktop UI | Python 3.10+ with PyQt6 | +| UI layout files | Qt Designer XML (`.ui` files) | +| State management | `StateManager.py` (custom, signal-based) | +| Web component | React 19 + Redux (stage strike feature only) | +| Executable build | PyInstaller | + +--- + +## Setting Up the Dev Environment + +### Prerequisites + +- Python 3.10+ +- Qt Designer (bundled with `pyqt6-tools`, or standalone via Qt installation) + +### Install dependencies + +```bash +pip install -r dependencies/requirements.txt +``` + +### Run the app + +```bash +python main.py +``` + +You do **not** need to compile the executable to develop or test — running from Python is the standard dev workflow. + +--- + +## Project Structure + +``` +TournamentStreamHelper/ +├── main.py # Entry point +├── src/ +│ ├── layout/ # Qt Designer .ui files (UI definitions) +│ │ ├── TSHTournamentInfo.ui +│ │ ├── TSHScoreboardPlayer.ui +│ │ ├── TSHCommentary.ui +│ │ └── ... +│ ├── TSHTournamentInfoWidget.py # Python widget classes +│ ├── TSHScoreboardPlayerWidget.py +│ ├── TSHCommentaryWidget.py +│ ├── StateManager.py # Central state (read/write/signals) +│ └── ... +├── assets/ # Icons, characters, rulesets, etc. +├── stage_strike_app/ # React web app (stage strike only) +├── dependencies/ +│ ├── requirements.txt # Python dependencies +│ └── tsh.spec # PyInstaller build spec +└── .github/workflows/ # CI/CD (build + release) +``` + +The naming convention is consistent: `TSHFoo.ui` is loaded and managed by `TSHFooWidget.py`. + +--- + +## Adding a New UI Field + +### Step 1 — Edit the `.ui` file in Qt Designer + +1. Open the relevant `src/layout/TSH*.ui` file in Qt Designer. +2. Drag in a new widget (e.g. `QLineEdit`, `QSpinBox`, `QCheckBox`). +3. Set a descriptive `objectName` in the Properties panel (e.g. `myNewField`). +4. Save the file. + +### Step 2 — Wire the widget up in the Python class + +Open the matching `src/TSH*Widget.py` file. Follow the pattern of existing fields: + +**Reading state on load:** +```python +self.ui.myNewField.setText(StateManager.get_state("my_new_field") or "") +``` + +**Saving state on change:** +```python +self.ui.myNewField.editingFinished.connect( + lambda: StateManager.set_state("my_new_field", self.ui.myNewField.text()) +) +``` + +Common signal-to-use mapping: + +| Widget | Signal | Value accessor | +|--------|--------|----------------| +| `QLineEdit` | `editingFinished` | `.text()` | +| `QSpinBox` | `valueChanged` | `.value()` | +| `QCheckBox` | `stateChanged` | `.isChecked()` | +| `QDateEdit` | `dateChanged` | `.date().toString(...)` | +| `QPushButton` | `clicked` | — | + +### Step 3 — Expose the value to layouts (optional) + +If the field value should be available to OBS browser source overlays, add the state key to the output in the relevant scoreboard/state export logic. Search for similar keys in `StateManager.py` to find the right place. + +--- + +## Building the Executable + +> For pull request contributions, you typically don't need to build the executable — maintainers handle releases via CI. + +If you need to build locally: + +```bash +# 1. Generate Qt translation files (requires pyside6-lrelease) +pyside6-lrelease + +# 2. Build the executable +pyinstaller --noconfirm ./dependencies/tsh.spec + +# Output: dist/TSH.exe +``` + +The CI pipeline (`.github/workflows/build_app.yml`) runs these steps automatically on push. + +--- + +## Making a Pull Request + +1. Fork the repository and create a branch from `main`. +2. Make your changes and test locally with `python main.py`. +3. Keep commits focused — one logical change per commit. +4. Open a PR against `main` with a clear description of what the change does and why. + +--- + +## Tips + +- The `StateManager` is the single source of truth. All UI fields should read from and write to it — never store widget state separately. +- `.ui` files are XML and can be diff'd normally in PRs; prefer Qt Designer over hand-editing them. +- The `stage_strike_app/` React app has its own `package.json` and is built separately with `npm run build` if you need to modify the stage strike web UI. diff --git a/dependencies/requirements.txt b/dependencies/requirements.txt index a11872444..2024f0ada 100644 --- a/dependencies/requirements.txt +++ b/dependencies/requirements.txt @@ -32,3 +32,4 @@ unidic-lite pypinyin koroman==1.0.14 arabic_buckwalter_transliteration +openpyxl diff --git a/layout b/layout index d0693efd2..dca7c4502 160000 --- a/layout +++ b/layout @@ -1 +1 @@ -Subproject commit d0693efd2345be62248dac786879900950e80435 +Subproject commit dca7c45029185bd0bf085a90182df7be893ecb45 diff --git a/src/TSHScoreboardWidget.py b/src/TSHScoreboardWidget.py index c328fe649..a76a62f5c 100644 --- a/src/TSHScoreboardWidget.py +++ b/src/TSHScoreboardWidget.py @@ -1,7 +1,9 @@ import math +import os import platform import socket import subprocess +from datetime import datetime from qtpy.QtGui import * from qtpy.QtWidgets import * @@ -572,6 +574,11 @@ def __init__(self, scoreboardNumber=1, *args): self.scoreColumn.findChild( QPushButton, "btResetScore").setIcon(QIcon('assets/icons/undo.svg')) + self.scoreColumn.findChild( + QPushButton, "btExportScore").clicked.connect(self.ExportScore) + self.scoreColumn.findChild( + QPushButton, "btExportScore").setIcon(QIcon('assets/icons/save.svg')) + # Add default and user tournament phase title files self.scoreColumn.findChild(QComboBox, "phase").addItem("") TSHLocaleHelper.LoadPhaseNamesToWidget(self.scoreColumn.findChild(QComboBox, "phase")) @@ -925,6 +932,104 @@ def ResetScore(self): carry_stage_codename=carry_stage ) + def ExportScore(self): + import openpyxl + from openpyxl.styles import PatternFill, Font + + HEADERS = [ + "Timestamp", "Phase", "Match", + "P1 Sponsor", "P1 Country", "P1 Characters", "P1 Name", "P1 Score", + "P2 Score", "P2 Name", "P2 Characters", "P2 Country", "P2 Sponsor", + ] + # Column ranges for each team (1-based), used for coloring + P1_COLS = range(4, 9) # P1 Sponsor → P1 Score + P2_COLS = range(9, 14) # P2 Score → P2 Characters + + GREEN = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + RED = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + + out_path = "./out/score_export.xlsx" + os.makedirs(os.path.dirname(out_path), exist_ok=True) + + if os.path.exists(out_path): + wb = openpyxl.load_workbook(out_path) + ws = wb.active + else: + wb = openpyxl.Workbook() + ws = wb.active + ws.append(HEADERS) + for cell in ws[1]: + cell.font = Font(bold=True) + + def player_fields(widgets): + names, sponsors, countries, chars = [], [], [], [] + for p in widgets: + names.append(StateManager.Get(f"{p.path}.name", "") or "") + sponsors.append(StateManager.Get(f"{p.path}.team", "") or "") + country_data = StateManager.Get(f"{p.path}.country", None) + if isinstance(country_data, dict): + countries.append(country_data.get("en_name") or country_data.get("name", "")) + else: + countries.append("") + characters = StateManager.Get(f"{p.path}.character", {}) or {} + char_names = [ + v.get("name", "") for v in characters.values() + if isinstance(v, dict) and v.get("name") + ] + chars.append(", ".join(char_names)) + sep = " / " + return sep.join(sponsors), sep.join(names), sep.join(countries), sep.join(chars) + + phase = StateManager.Get(f"score.{self.scoreboardNumber}.phase", "") or "" + match = StateManager.Get(f"score.{self.scoreboardNumber}.match", "") or "" + p1_score = StateManager.Get(f"score.{self.scoreboardNumber}.team.1.score", 0) + p2_score = StateManager.Get(f"score.{self.scoreboardNumber}.team.2.score", 0) + + p1_sponsor, p1_name, p1_country, p1_chars = player_fields(self.team1playerWidgets) + p2_sponsor, p2_name, p2_country, p2_chars = player_fields(self.team2playerWidgets) + + new_row = [ + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), phase, match, + p1_sponsor, p1_country, p1_chars, p1_name, p1_score, + p2_score, p2_name, p2_chars, p2_country, p2_sponsor, + ] + + # Skip export if all fields (except timestamp) match the last saved row + if ws.max_row > 1: + last_row = [ws.cell(row=ws.max_row, column=c).value for c in range(1, len(HEADERS) + 1)] + if last_row[1:] == new_row[1:]: + msgBox = QMessageBox() + msgBox.setWindowIcon(QIcon('assets/icons/icon.png')) + msgBox.setWindowTitle(QApplication.translate("app", "TSH - Export Score")) + msgBox.setText(QApplication.translate("app", "No changes since last export. Skipping.")) + msgBox.setIcon(QMessageBox.Icon.Information) + msgBox.exec() + return + + ws.append(new_row) + + # Color winner green, loser red + row = ws.max_row + if p1_score != p2_score: + winner_cols, loser_cols = (P1_COLS, P2_COLS) if p1_score > p2_score else (P2_COLS, P1_COLS) + for col in winner_cols: + ws.cell(row=row, column=col).fill = GREEN + for col in loser_cols: + ws.cell(row=row, column=col).fill = RED + + wb.save(out_path) + + msgBox = QMessageBox() + msgBox.setWindowIcon(QIcon('assets/icons/icon.png')) + msgBox.setWindowTitle(QApplication.translate("app", "TSH - Export Score")) + msgBox.setText( + QApplication.translate("app", "Score exported to {0}").format( + os.path.abspath(out_path) + ) + ) + msgBox.setIcon(QMessageBox.Icon.NoIcon) + msgBox.exec() + def AutoUpdate(self, data): TSHTournamentDataProvider.instance.GetMatch( self, data.get("id"), overwrite=False) diff --git a/src/layout/TSHScoreboardScore.ui b/src/layout/TSHScoreboardScore.ui index ccac9b9f3..7d2144f39 100644 --- a/src/layout/TSHScoreboardScore.ui +++ b/src/layout/TSHScoreboardScore.ui @@ -216,6 +216,13 @@ + + + + EXPORT SCORE + + +