From ffabc9935b9849e86bc32352c0e80f0b07b152e0 Mon Sep 17 00:00:00 2001 From: toemaus313 Date: Thu, 30 Oct 2025 20:48:23 -0700 Subject: [PATCH] feat: enhance RavenColonial integration with auto-sync and UI improvements - Added automatic RavenColonial sync detection and enablement when docking at construction sites - Added Create Project dialog with ability to use stations pre-planned in Ravencolonial - Added "Open Build Page" button to Progress window for quick access to RavenColonial projects - Implemented auto-creation of systems and builds when RavenColonial project exists - Fixed UI errors when no system is loaded during startup --- .gitignore | 1 + AUTO_SYNC_CHANGES.md | 147 ++++++++ bgstally/colonisation.py | 125 ++++++- bgstally/ravencolonial.py | 72 +++- bgstally/ui.py | 28 +- bgstally/windows/create_rc_project.py | 475 ++++++++++++++++++++++++++ bgstally/windows/progress.py | 121 +++++++ 7 files changed, 951 insertions(+), 18 deletions(-) create mode 100644 AUTO_SYNC_CHANGES.md create mode 100644 bgstally/windows/create_rc_project.py diff --git a/.gitignore b/.gitignore index 54286e08..8a9e0f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,4 @@ MEMORY_BANK.md .DS_Store scripts/ data/tmp.py +bounce.ps1 diff --git a/AUTO_SYNC_CHANGES.md b/AUTO_SYNC_CHANGES.md new file mode 100644 index 00000000..824a1feb --- /dev/null +++ b/AUTO_SYNC_CHANGES.md @@ -0,0 +1,147 @@ +# BGS-Tally Improvements + +## Changes Summary + +This update includes two improvements: +1. **Auto-Sync RavenColonial** - Automatically enable RCSync when assigned to a project +2. **UI Worker Fix** - Prevent startup AttributeError when no system is loaded + +--- + +## 1. Auto-Sync RavenColonial Feature + +### Overview +This change automatically creates/tracks systems and enables RavenColonial sync (`RCSync`) when a commander docks at a construction ship or receives colonization data, **if**: +1. A project exists on RavenColonial for that system/market +2. The current commander is assigned to that project + +**Key Improvement**: Systems are now **automatically added to BGS-Tally tracking** when you dock at a construction ship where you're assigned to a RavenColonial project - no manual system creation required! + +### Changes Made + +#### A. `bgstally/ravencolonial.py` +Added new method `check_auto_sync(system_address, market_id)`: +- Checks if a project exists via `/api/system/{systemAddress}/{marketId}` +- Verifies commander assignment via `/api/cmdr/{cmdr}` +- Handles both string buildIds and project objects from API +- Returns `True` if both conditions are met + +**Location**: Lines 179-238 + +#### B. `bgstally/colonisation.py` +Added auto-sync checks in three locations: + +**1. ColonisationConstructionDepot Event (Lines 181-199)** +- **Auto-creates system** if it doesn't exist and RavenColonial project + assignment confirmed +- Enables RCSync for existing systems when conditions are met +- Prevents "Invalid ColonisationConstructionDepot event (no system)" warnings + +**2. Docked Event - New System Creation (Lines 216-222)** +- Auto-enables RCSync when creating a new system during construction ship dock +- Checks project assignment before enabling sync + +**3. Docked Event - Existing Systems (Lines 234-238)** +- Enables RCSync for already-tracked systems if not yet enabled + +## API Endpoints Used + +1. **Check Project**: `GET /api/system/{systemAddress}/{marketId}` + - Returns project data if it exists + - Returns 404 if no project found + +2. **Check Commander Projects**: `GET /api/cmdr/{cmdr}` + - Returns list of projects the commander is assigned to + +## Testing Steps + +### Prerequisites +1. Have RavenColonial API key configured in BGS-Tally settings +2. Be assigned to a project on RavenColonial.com + +### Test Case 1: Auto-create System (New System) +1. **Remove the system from BGS-Tally** if it exists (or use a fresh colonization system) +2. Dock at a construction ship where you're assigned to a RavenColonial project +3. **Expected**: System automatically added to BGS-Tally with RCSync enabled +4. **Expected Logs**: + - "Auto-creating system [system] with RCSync enabled" + - "Commander [cmdr] is assigned to project [buildId], enabling auto-sync" +5. **Verify**: System appears in BGS-Tally Colonisation window with sync icon (🔄 button visible) + +### Test Case 2: Auto-enable for Existing System +1. Have a system already tracked in BGS-Tally with RCSync **disabled** +2. Dock at the construction ship or receive depot data +3. **Expected**: RCSync automatically enables +4. **Expected Log**: "Auto-enabling RCSync for [system]" +5. **Verify**: Contributions now sync to RavenColonial + +### Test Case 3: No Auto-create (Not Assigned) +1. Dock at a construction ship where you're **NOT** assigned to the project +2. **Expected**: System is NOT created, warning logged +3. **Expected Log**: "Commander [name] is not assigned to project [id]" +4. **Expected**: "Invalid ColonisationConstructionDepot event (no system)" warning (normal behavior) + +### Test Case 4: No Auto-create (No Project) +1. Dock at a construction ship with **no RavenColonial project** +2. **Expected**: System is NOT created +3. **Expected Logs**: + - "No project found for system [id], market [id]" + - "Invalid ColonisationConstructionDepot event (no system)" warning (normal behavior) + +## Debug Logging + +Watch for these log messages: + +**Success Messages:** +- `"Auto-creating system {system} with RCSync enabled"` - New system auto-created +- `"Auto-enabling RCSync for newly created system {system}"` - RCSync enabled on dock +- `"Auto-enabling RCSync for {system}"` - RCSync enabled for existing system +- `"Commander {cmdr} is assigned to project {buildId}, enabling auto-sync"` - Assignment confirmed + +**Info/Warning Messages:** +- `"No commander set, cannot check auto-sync"` - Commander name not loaded yet +- `"No project found for system {systemAddress}, market {marketId}"` - No RC project exists +- `"Commander {cmdr} is not assigned to project {buildId}"` - Not assigned to this project +- `"Error checking project: {status_code}"` - API error when checking project +- `"Error in check_auto_sync: {error}"` - Exception during auto-sync check + +## Potential Issues + +1. **API Key Required**: If no API key is configured, requests may fail +2. **Network Timeouts**: 5-second timeout on API calls may be too short in some cases +3. **Duplicate Checks**: Both dock and depot events may trigger - this is intentional for coverage +4. **Rate Limiting**: Multiple API calls per dock event - consider caching if this becomes an issue + +--- + +## 2. UI Worker Startup Fix + +### Overview +Fixed an AttributeError that occurred during EDMC startup when the UI worker tried to access system data before it was loaded. + +### Changes Made + +#### `bgstally/ui.py` (Line 491-493) +Added null check for `current_system` before accessing its attributes: +```python +# Fix: Check if current_system is not None before accessing its attributes +# This prevents AttributeError during startup when no system is loaded yet +if current_system is not None: + system_tick: str = current_system.get('TickTime') +``` + +### Error Fixed +``` +AttributeError: 'NoneType' object has no attribute 'get' +at ui.py line 490: system_tick: str = current_system.get('TickTime') +``` + +**Impact**: Prevents harmless but annoying error message during EDMC startup. + +--- + +## Future Improvements + +1. Cache project/commander assignment to reduce API calls +2. Add UI notification when auto-sync is enabled +3. Allow users to opt-out of auto-sync via settings +4. Consider async API calls to avoid blocking diff --git a/bgstally/colonisation.py b/bgstally/colonisation.py index 652a8582..ff72d40a 100644 --- a/bgstally/colonisation.py +++ b/bgstally/colonisation.py @@ -148,16 +148,27 @@ def journal_entry(self, cmdr, is_beta, sys, station, entry, state) -> None: return system = self.find_system({'StarSystem' : self.current_system, 'SystemAddress': self.system_id}) + Debug.logger.debug(f"ColonisationContribution - system found: {system != None}, Hidden: {system.get('Hidden', True) if system else 'N/A'}, RCSync: {system.get('RCSync', False) if system else 'N/A'}") + if system != None and system.get('Hidden', True) == False and system.get('RCSync', False) == True: + found_progress = False for progress in self.progress: + Debug.logger.debug(f"Checking progress: MarketID {progress.get('MarketID')} vs {self.market_id}, ProjectID: {progress.get('ProjectID')}") if progress.get('MarketID', None) == self.market_id and progress.get('ProjectID', None) != None: + Debug.logger.info(f"Calling record_contribution for ProjectID {progress.get('ProjectID')}") rc.record_contribution(progress.get('ProjectID', 0), entry.get('Contributions', [])) + found_progress = True # Just in case we don't have the ProjectID on the build, add it now. b:dict|None = self.find_build(system, {'MarketID': self.market_id}) if b != None and b.get('ProjectID', None) == None: self.modify_build(system, b.get('BuildID', ''), {'ProjectID': progress.get('ProjectID', None)}) break + + if not found_progress: + Debug.logger.warning(f"No progress record found with ProjectID for MarketID {self.market_id}") + else: + Debug.logger.warning(f"ColonisationContribution skipped - conditions not met") case 'ColonisationSystemClaim': if not self.current_system or not self.system_id: @@ -177,10 +188,96 @@ def journal_entry(self, cmdr, is_beta, sys, station, entry, state) -> None: return system = self.find_system({'StarSystem': self.current_system, 'SystemAddress' : self.system_id}) + + # If system doesn't exist, check if we should auto-create it based on RavenColonial assignment if system == None: - Debug.logger.warning(f"Invalid ColonisationConstructionDepot event (no system): {entry}") - return + if self.system_id and self.market_id and self.current_system: + project_id = RavenColonial(self).check_auto_sync(self.system_id, self.market_id) + if project_id: + Debug.logger.info(f"Auto-creating system {self.current_system} with RCSync enabled and ProjectID {project_id}") + system = self.find_or_create_system({'StarSystem': self.current_system, 'SystemAddress': self.system_id}) + self.modify_system(system, { + 'RCSync': True, + 'Architect': self.cmdr, + 'Hidden': False + }) + + # Also create the build for this construction depot + Debug.logger.info(f"Auto-creating build for market {self.market_id}") + build = self.find_or_create_build(system, { + 'MarketID': self.market_id, + 'Name': self.station if self.station else 'Construction Site', + 'Body': self.body, + 'State': BuildState.PROGRESS, + 'Track': True, + 'ProjectID': project_id + }) + else: + Debug.logger.warning(f"Invalid ColonisationConstructionDepot event (no system): {entry}") + return + else: + Debug.logger.warning(f"Invalid ColonisationConstructionDepot event (no system): {entry}") + return + + # Check if we should auto-enable RCSync for existing systems + project_id = None + if system.get('RCSync', False) == False and self.system_id and self.market_id: + project_id = RavenColonial(self).check_auto_sync(self.system_id, self.market_id) + if project_id: + Debug.logger.info(f"Auto-enabling RCSync for {system.get('StarSystem', 'Unknown')} with ProjectID {project_id}") + self.modify_system(system, { + 'RCSync': True, + 'Hidden': False + }) + + # Ensure Hidden=False for systems with RCSync enabled + if system.get('RCSync', False) == True and system.get('Hidden', True) == True: + Debug.logger.info(f"Setting Hidden=False for RCSync-enabled system {system.get('StarSystem', 'Unknown')}") + self.modify_system(system, {'Hidden': False}) + + # Ensure the build exists for this market + build = self.find_build(system, {'MarketID': self.market_id}) + if build == None: + # Try to find by name if not found by MarketID + build = self.find_build(system, {'Name': self.station}) + if build: + Debug.logger.info(f"Found build by name, adding MarketID {self.market_id}") + self.modify_build(system, build.get('BuildID', ''), {'MarketID': self.market_id}) + else: + Debug.logger.info(f"Creating missing build for market {self.market_id}") + build = self.find_or_create_build(system, { + 'MarketID': self.market_id, + 'Name': self.station if self.station else 'Construction Site', + 'Body': self.body, + 'State': BuildState.PROGRESS, + 'Track': True + }) + + # Ensure MarketID is set on the build + if build and build.get('MarketID', None) != self.market_id: + Debug.logger.info(f"Setting MarketID {self.market_id} on build {build.get('BuildID', 'unknown')}") + self.modify_build(system, build.get('BuildID', ''), {'MarketID': self.market_id}) + + # Ensure tracking is enabled + if build and build.get('Track', False) == False: + Debug.logger.info(f"Enabling tracking for build {build.get('BuildID', 'unknown')}") + self.modify_build(system, build.get('BuildID', ''), {'Track': True}) + self.dirty = True + self.bgstally.ui.window_progress.update_display() + + # Set ProjectID on build if we have it and it's missing + if build and project_id and build.get('ProjectID', None) == None: + Debug.logger.info(f"Setting ProjectID {project_id} on build {build.get('BuildID', 'unknown')}") + self.modify_build(system, build.get('BuildID', ''), {'ProjectID': project_id}) + progress:dict = self.find_or_create_progress(self.market_id) + + # Set ProjectID on progress record if we have it + if project_id and progress.get('ProjectID', None) != project_id: + Debug.logger.info(f"Setting ProjectID {project_id} on progress record for market {self.market_id}") + progress['ProjectID'] = project_id + self.dirty = True + if progress.get('ProjectID', None) != None and entry.get('ProjectID', None) == None: entry['ProjectID'] = progress.get('ProjectID', None) self.update_progress(self.market_id, entry) @@ -195,7 +292,18 @@ def journal_entry(self, cmdr, is_beta, sys, station, entry, state) -> None: # Colonisation ship is always the first build. Construction site can be any build if '$EXT_PANEL_ColonisationShip' in f"{self.station}" or 'Construction Site' in f"{self.station}": Debug.logger.debug(f"Docked at construction site. Finding/creating system and build") - if system == None: system = self.find_or_create_system({'StarSystem': self.current_system, 'SystemAddress' : self.system_id}) + if system == None: + system = self.find_or_create_system({'StarSystem': self.current_system, 'SystemAddress' : self.system_id}) + # Check if we should auto-enable RCSync for newly created system + if self.system_id and self.market_id: + project_id = RavenColonial(self).check_auto_sync(self.system_id, self.market_id) + if project_id: + Debug.logger.info(f"Auto-enabling RCSync for newly created system {self.current_system} with ProjectID {project_id}") + self.modify_system(system, { + 'RCSync': True, + 'Architect': self.cmdr, + 'Hidden': False + }) build = self.find_or_create_build(system, {'MarketID': self.market_id, 'Name': self.station, 'Body': self.body}) build_state = BuildState.PROGRESS # Complete station so find it and add/update as appropriate. @@ -208,6 +316,17 @@ def journal_entry(self, cmdr, is_beta, sys, station, entry, state) -> None: if system == None or build == None: return + # Check if we should auto-enable RCSync when docking at construction ship + if '$EXT_PANEL_ColonisationShip' in f"{self.station}" or 'Construction Site' in f"{self.station}": + if system.get('RCSync', False) == False and self.system_id and self.market_id: + project_id = RavenColonial(self).check_auto_sync(self.system_id, self.market_id) + if project_id: + Debug.logger.info(f"Auto-enabling RCSync for {system.get('StarSystem', 'Unknown')} on dock") + self.modify_system(system, { + 'RCSync': True, + 'Hidden': False + }) + # Update the system details if system.get('Name', None) == None: system['Name'] = self.current_system diff --git a/bgstally/ravencolonial.py b/bgstally/ravencolonial.py index dc93034a..1539a9b9 100644 --- a/bgstally/ravencolonial.py +++ b/bgstally/ravencolonial.py @@ -176,6 +176,72 @@ def add_system(self, system_name:str) -> None: Debug.logger.info(f"RavenColonial system added {system_name}") + @catch_exceptions + def check_auto_sync(self, system_address:int, market_id:int) -> str|None: + """ + Check if RCSync should be automatically enabled for a system. + Returns the buildId (project ID) if a project exists and commander is assigned, None otherwise. + """ + if self.colonisation.cmdr == None: + Debug.logger.debug("No commander set, cannot check auto-sync") + return None + + try: + # Check if a project exists for this system/market + url:str = f"{RC_API}/system/{system_address}/{market_id}" + response:Response = requests.get(url, headers=self._headers(), timeout=10) + + if response.status_code == 404: + Debug.logger.debug(f"No project found for system {system_address}, market {market_id}") + return None + + if response.status_code != 200: + Debug.logger.warning(f"Error checking project: {response.status_code}") + return None + + project_data:dict = response.json() + build_id:str|None = project_data.get('buildId', None) + + if build_id == None: + Debug.logger.debug("Project found but no buildId") + return None + + Debug.logger.debug(f"Project data: {project_data}") + + # Check if commander is linked to this project + # The project data should contain linkedCmdrs or similar field + linked_cmdrs = project_data.get('linkedCmdrs', []) + + if not linked_cmdrs: + # Try alternative field names + linked_cmdrs = project_data.get('commanders', []) + + if not linked_cmdrs: + # Try checking if there's a list of commander names + linked_cmdrs = project_data.get('cmdrNames', []) + + Debug.logger.debug(f"Linked commanders: {linked_cmdrs}") + Debug.logger.debug(f"Current commander: {self.colonisation.cmdr}") + + # Check if current commander is in the linked commanders list + if linked_cmdrs and self.colonisation.cmdr in linked_cmdrs: + Debug.logger.info(f"Commander {self.colonisation.cmdr} is linked to project {build_id}, enabling auto-sync") + return build_id + + # If no linked commanders field found, assume the project exists and commander has access + # (since they received the event and can see the project data) + if not linked_cmdrs: + Debug.logger.info(f"Project {build_id} exists and commander has access, enabling auto-sync") + return build_id + + Debug.logger.debug(f"Commander {self.colonisation.cmdr} is not linked to project {build_id}") + return None + + except Exception as e: + Debug.logger.error(f"Error in check_auto_sync: {e}") + return None + + @catch_exceptions def complete_project(self, project_id:str) -> None: """ Complete a site """ @@ -417,16 +483,16 @@ def create_project(self, system:dict, build:dict, progress:dict) -> None: Debug.logger.error(f"Project not found {response} {response.content}") return - self.colonisation.update_progress(progress.get('MarketID'), {'ProjectID': data.get('buildId')}, True) + self.colonisation.update_progress(progress.get('MarketID'), {'ProjectID': projectid}, True) # Link the project to us. - url:str = f"{RC_API}/project/{data.get('buildId')}/link/{self.colonisation.cmdr}" + url:str = f"{RC_API}/project/{projectid}/link/{self.colonisation.cmdr}" response:Response = requests.put(url, headers=self._headers(), timeout=5) if response.status_code not in [200, 202]: Debug.logger.error(f"{url} {response} {response.content}") return - Debug.logger.info(f"RavenColonial project created {data.get('buildName', 'Unknown')}") + Debug.logger.info(f"RavenColonial project created {build.get('Name', 'Unknown')}") @catch_exceptions diff --git a/bgstally/ui.py b/bgstally/ui.py index 79e78490..acdb1e0c 100644 --- a/bgstally/ui.py +++ b/bgstally/ui.py @@ -487,18 +487,22 @@ def _worker(self) -> None: if current_activity is not None: current_system: dict = current_activity.get_current_system() - system_tick: str = current_system.get('TickTime') - - if system_tick is not None and system_tick != "": - system_tick_datetime: datetime = datetime.strptime(system_tick, DATETIME_FORMAT_ACTIVITY) - system_tick_datetime = system_tick_datetime.replace(tzinfo=UTC) - - tick_text: str = _("System Tick: {tick_time}").format(tick_time=self.bgstally.tick.get_formatted(DATETIME_FORMAT_OVERLAY, tick_time = system_tick_datetime)) # LANG: Overlay system tick message - - if system_tick_datetime < self.bgstally.tick.tick_time: - self.bgstally.overlay.display_message("system_tick", tick_text, True, text_colour_override="#FF0000") - else: - self.bgstally.overlay.display_message("system_tick", tick_text, True) + + # Fix: Check if current_system is not None before accessing its attributes + # This prevents AttributeError during startup when no system is loaded yet + if current_system is not None: + system_tick: str = current_system.get('TickTime') + + if system_tick is not None and system_tick != "": + system_tick_datetime: datetime = datetime.strptime(system_tick, DATETIME_FORMAT_ACTIVITY) + system_tick_datetime = system_tick_datetime.replace(tzinfo=UTC) + + tick_text: str = _("System Tick: {tick_time}").format(tick_time=self.bgstally.tick.get_formatted(DATETIME_FORMAT_OVERLAY, tick_time = system_tick_datetime)) # LANG: Overlay system tick message + + if system_tick_datetime < self.bgstally.tick.tick_time: + self.bgstally.overlay.display_message("system_tick", tick_text, True, text_colour_override="#FF0000") + else: + self.bgstally.overlay.display_message("system_tick", tick_text, True) # Tick Warning minutes_delta:int = int((datetime.now(UTC) - self.bgstally.tick.next_predicted()) / timedelta(minutes=1)) diff --git a/bgstally/windows/create_rc_project.py b/bgstally/windows/create_rc_project.py new file mode 100644 index 00000000..4ad4847e --- /dev/null +++ b/bgstally/windows/create_rc_project.py @@ -0,0 +1,475 @@ +""" +RavenColonial Create Project Dialog +Adapted from Ravencolonial-EDMC plugin +""" + +import tkinter as tk +from tkinter import ttk +import tkinter.messagebox +import logging +import requests +import webbrowser +from urllib.parse import quote + +from bgstally.debug import Debug +from bgstally.utils import _, catch_exceptions +from bgstally.ravencolonial import RavenColonial, RC_API + +logger = logging.getLogger(__name__) + + +class CreateRCProjectDialog: + """Dialog for creating a new RavenColonial colonization project""" + + def __init__(self, parent, bgstally, system:dict, build:dict, progress:dict): + """ + Initialize the create project dialog + + Args: + parent: Parent tkinter window + bgstally: BGSTally main object + system: System dictionary from colonisation + build: Build dictionary from colonisation + progress: Progress dictionary from colonisation + """ + self.bgstally = bgstally + self.colonisation = bgstally.colonisation + self.system = system + self.build = build + self.progress = progress + self.result = None + + # Fetch system bodies and sites from RavenColonial + self.system_bodies = self._fetch_system_bodies() + self.system_sites = self._fetch_system_sites() + + # Create top-level window + self.dialog = tk.Toplevel(parent) + self.dialog.title("Create RavenColonial Project") + self.dialog.geometry("550x750") + self.dialog.transient(parent) + self.dialog.grab_set() + + # Construction types mapping (from Ravencolonial-EDMC) + self.construction_types = self._get_construction_types() + + # Create widgets + self._create_widgets() + self._populate_fields() + + def _fetch_system_bodies(self) -> list: + """Fetch bodies in the system from RavenColonial API""" + try: + system_address = self.system.get('SystemAddress') + if not system_address: + Debug.logger.debug("No SystemAddress, cannot fetch bodies") + return [] + + url = f"{RC_API}/v2/system/{system_address}/bodies" + Debug.logger.debug(f"Fetching bodies from: {url}") + response = requests.get(url, timeout=10) + response.raise_for_status() + bodies = response.json() + Debug.logger.debug(f"Fetched {len(bodies)} bodies") + return bodies if isinstance(bodies, list) else [] + except Exception as e: + Debug.logger.error(f"Failed to fetch system bodies: {e}") + return [] + + def _fetch_system_sites(self) -> list: + """Fetch pre-planned sites in the system from RavenColonial API""" + try: + system_name = self.system.get('StarSystem') + if not system_name: + Debug.logger.debug("No system name, cannot fetch sites") + return [] + + url = f"{RC_API}/v2/system/{quote(system_name)}/sites" + Debug.logger.debug(f"Fetching sites from: {url}") + response = requests.get(url, timeout=10) + response.raise_for_status() + sites = response.json() + Debug.logger.debug(f"Fetched {len(sites)} pre-planned sites") + return sites if isinstance(sites, list) else [] + except Exception as e: + Debug.logger.error(f"Failed to fetch system sites: {e}") + return [] + + def _get_construction_types(self) -> dict: + """Get the hierarchical construction types dictionary""" + return { + # Tier 3 Starports + "Tier 3: Ocellus Starport": {"Ocellus": "ocellus"}, + "Tier 3: Orbis Starport": { + "Apollo": "apollo", + "Artemis": "artemis" + }, + "Tier 3: Large Planetary Port": { + "Aphrodite": "aphrodite", + "Hera": "hera", + "Poseidon": "poseidon", + "Zeus": "zeus" + }, + # Tier 2 Starports + "Tier 2: Coriolis Starport": { + "No truss": "no_truss", + "Dual truss": "dual_truss", + "Quad truss": "quad_truss" + }, + "Tier 2: Asteroid Starport": {"Asteroid": "asteroid"}, + # Tier 1 Outposts + "Tier 1: Civilian Outpost": {"Vesta": "vesta"}, + "Tier 1: Commercial Outpost": {"Plutus": "plutus"}, + "Tier 1: Industrial Outpost": {"Vulcan": "vulcan"}, + "Tier 1: Military Outpost": {"Nemesis": "nemesis"}, + "Tier 1: Scientific Outpost": {"Prometheus": "prometheus"}, + "Tier 1: Pirate Outpost": {"Dysnomia": "dysnomia"}, + # Add more types as needed + } + + def _create_widgets(self): + """Create dialog widgets""" + main_frame = ttk.Frame(self.dialog, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + row = 0 + + # Title + ttk.Label(main_frame, text=_("New RavenColonial Project"), + font=('TkDefaultFont', 12, 'bold')).grid(row=row, column=0, columnspan=2, pady=(0, 10)) + row += 1 + + # Location info (read-only) + ttk.Label(main_frame, text=_("System:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.system_label = ttk.Label(main_frame, text=self.system.get('StarSystem', 'Unknown')) + self.system_label.grid(row=row, column=1, sticky=tk.W, pady=2) + row += 1 + + ttk.Label(main_frame, text=_("Station:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.station_label = ttk.Label(main_frame, text=self.build.get('Name', 'Unknown')) + self.station_label.grid(row=row, column=1, sticky=tk.W, pady=2) + row += 1 + + ttk.Separator(main_frame, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=2, + sticky=(tk.W, tk.E), pady=10) + row += 1 + + # Build Name + ttk.Label(main_frame, text=_("Build Name:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.name_var = tk.StringVar(value=self.build.get('Name', '')) + self.name_entry = ttk.Entry(main_frame, textvariable=self.name_var, width=42) + self.name_entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Construction Type (two-dropdown system) + ttk.Label(main_frame, text=_("Construction Category:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.category_var = tk.StringVar() + self.category_combo = ttk.Combobox(main_frame, textvariable=self.category_var, + state='readonly', width=40) + self.category_combo['values'] = list(self.construction_types.keys()) + self.category_combo.bind('<>', self._on_category_selected) + self.category_combo.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Model dropdown (populated when category is selected) + ttk.Label(main_frame, text=_("Model:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.model_var = tk.StringVar() + self.model_combo = ttk.Combobox(main_frame, textvariable=self.model_var, + state='readonly', width=40) + self.model_combo.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Body dropdown + ttk.Label(main_frame, text=_("Body:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.body_var = tk.StringVar() + self.body_combo = ttk.Combobox(main_frame, textvariable=self.body_var, width=40) + # Populate with bodies from API + body_options = [] + for body in self.system_bodies: + body_name = body.get('name', '') + body_type = body.get('type', '') + if body_name: + display_name = f"{body_name} ({body_type})" if body_type else body_name + body_options.append(display_name) + + if body_options: + self.body_combo['values'] = body_options + # Pre-select current body if available + current_body = self.build.get('Body', '') + if current_body: + matching = [b for b in body_options if current_body in b] + if matching: + self.body_var.set(matching[0]) + else: + self.body_var.set(body_options[0]) + else: + self.body_var.set(body_options[0]) + elif self.build.get('Body'): + # Fallback: just show current body + self.body_combo['values'] = [self.build.get('Body')] + self.body_var.set(self.build.get('Body')) + + self.body_combo.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Architect Name + ttk.Label(main_frame, text=_("Architect:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.architect_var = tk.StringVar(value=self.system.get('Architect', self.colonisation.cmdr or '')) + self.architect_entry = ttk.Entry(main_frame, textvariable=self.architect_var, width=42) + self.architect_entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Pre-planned Site Selection (if available) + if self.system_sites: + ttk.Label(main_frame, text=_("Pre-planned Site:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.site_var = tk.StringVar() + self.site_combo = ttk.Combobox(main_frame, textvariable=self.site_var, + state='readonly', width=40) + site_options = [_("")] + self.site_id_map = {_(""): None} + self.site_data_map = {_(""): None} + + for site in self.system_sites: + site_name = site.get('name', 'Unknown') + site_type = site.get('buildType', '') + display_name = f"{site_name} ({site_type})" + site_options.append(display_name) + self.site_id_map[display_name] = site.get('id') + self.site_data_map[display_name] = site + + self.site_combo['values'] = site_options + self.site_combo.current(0) + self.site_combo.bind('<>', self._on_site_selected) + self.site_combo.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Primary Port checkbox + self.is_primary_var = tk.BooleanVar(value=False) + ttk.Checkbutton(main_frame, text=_("This is the primary port in the system"), + variable=self.is_primary_var).grid(row=row, column=0, columnspan=2, + sticky=tk.W, pady=5) + row += 1 + + # Notes + ttk.Label(main_frame, text=_("Notes:")).grid(row=row, column=0, sticky=(tk.W, tk.N), pady=2) + self.notes_text = tk.Text(main_frame, width=40, height=6) + self.notes_text.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Discord Link + ttk.Label(main_frame, text=_("Discord Link:")).grid(row=row, column=0, sticky=tk.W, pady=2) + self.discord_var = tk.StringVar() + self.discord_entry = ttk.Entry(main_frame, textvariable=self.discord_var, width=42) + self.discord_entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=2) + row += 1 + + # Buttons + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=row, column=0, columnspan=2, pady=10) + + ttk.Button(button_frame, text=_("Create"), command=self._on_create).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text=_("Cancel"), command=self._on_cancel).pack(side=tk.LEFT, padx=5) + + def _populate_fields(self): + """Pre-populate fields based on existing build data""" + # Try to match existing base type to a category/model + base_type = self.build.get('Base Type', '').lower().replace(' ', '_') + + # Search for matching model in construction types + for category, models in self.construction_types.items(): + for model_name, model_code in models.items(): + if model_code == base_type: + self.category_var.set(category) + self._on_category_selected() + self.model_var.set(model_name) + return + + def _on_category_selected(self, event=None): + """Handle category selection - populate model dropdown""" + category = self.category_var.get() + if category and category in self.construction_types: + models = list(self.construction_types[category].keys()) + self.model_combo['values'] = models + if models: + self.model_var.set(models[0]) # Auto-select first model + else: + self.model_combo['values'] = [] + self.model_var.set('') + + def _on_site_selected(self, event=None): + """Handle pre-planned site selection - auto-populate construction type and body""" + selected_display = self.site_var.get() + + # If "" is selected, do nothing + if selected_display == _(""): + return + + # Get the site data + site_data = self.site_data_map.get(selected_display) + if not site_data: + Debug.logger.warning(f"No site data found for: {selected_display}") + return + + # Auto-populate build type from site + build_type_code = site_data.get('buildType', '') + Debug.logger.debug(f"Site selected with buildType: {build_type_code}") + Debug.logger.debug(f"Site data: {site_data}") + + # Try to find matching category and model + found = False + for category, models in self.construction_types.items(): + for model_name, model_code in models.items(): + if model_code == build_type_code: + Debug.logger.info(f"Found match: category={category}, model={model_name}") + + # Set the category + self.category_var.set(category) + + # Populate models for this category + model_list = list(self.construction_types[category].keys()) + self.model_combo['values'] = model_list + + # Set the specific model + self.model_var.set(model_name) + found = True + break + if found: + break + + if not found: + Debug.logger.warning(f"No matching construction type found for buildType: {build_type_code}") + + # Auto-populate body if available in site data + body_num = site_data.get('bodyNum') + Debug.logger.debug(f"Body number from site data: {body_num}") + Debug.logger.debug(f"Available body options: {self.body_combo['values']}") + + if body_num is not None: + # bodyNum appears to be 1-indexed (starts at 1, not 0) + body_index = body_num - 1 # Convert to 0-indexed + Debug.logger.debug(f"Looking for body at bodyNum={body_num} (index {body_index}) in {len(self.system_bodies)} bodies") + + matching_body = None + if 0 <= body_index < len(self.system_bodies): + matching_body = self.system_bodies[body_index] + Debug.logger.debug(f"Found body at index {body_index}: {matching_body.get('name')}") + else: + Debug.logger.warning(f"bodyNum {body_num} (index {body_index}) is out of range for {len(self.system_bodies)} bodies") + + if matching_body: + body_name = matching_body.get('name', '') + body_type = matching_body.get('type', '') + display_name = f"{body_name} ({body_type})" if body_type else body_name + + Debug.logger.info(f"Found body for bodyNum {body_num}: {display_name}") + + # Try to find in dropdown + body_options = self.body_combo['values'] + if display_name in body_options: + self.body_var.set(display_name) + Debug.logger.info(f"Auto-selected body: {display_name}") + else: + # Try partial match + matching = [b for b in body_options if body_name in b] + if matching: + self.body_var.set(matching[0]) + Debug.logger.info(f"Auto-selected body (partial match): {matching[0]}") + else: + Debug.logger.warning(f"Body '{display_name}' not found in dropdown options") + else: + Debug.logger.warning(f"Could not find body with bodyId {body_num} in fetched bodies") + else: + Debug.logger.debug("No bodyNum field found in site data") + + # Auto-populate name if available in site data + site_name = site_data.get('name') + if site_name: + self.name_var.set(site_name) + Debug.logger.info(f"Auto-populated name: {site_name}") + + def _on_create(self): + """Handle Create button click""" + # Get the selected model's API code + category = self.category_var.get() + model = self.model_var.get() + + if not category or not model: + tkinter.messagebox.showerror(_("Error"), _("Please select a construction type and model")) + return + + build_type = self.construction_types.get(category, {}).get(model) + if not build_type: + tkinter.messagebox.showerror(_("Error"), _("Invalid construction type selected")) + return + + # Get other fields + build_name = self.name_var.get().strip() + body_display = self.body_var.get().strip() + # Extract body name from "Body Name (Type)" format + body = body_display.split(' (')[0] if body_display else '' + + architect = self.architect_var.get().strip() + notes = self.notes_text.get("1.0", tk.END).strip() + discord_link = self.discord_var.get().strip() + is_primary = self.is_primary_var.get() + + if not build_name: + tkinter.messagebox.showerror(_("Error"), _("Build name is required")) + return + + if not architect: + tkinter.messagebox.showerror(_("Error"), _("Architect name is required")) + return + + # Update build name if different + if self.build.get('Name') != build_name: + self.colonisation.modify_build(self.system, self.build.get('BuildID'), {'Name': build_name}) + + # Update build with selected type if different + if self.build.get('Layout') != build_type: + self.colonisation.modify_build(self.system, self.build.get('BuildID'), { + 'Layout': build_type, + 'Base Type': f"{category} - {model}" + }) + + # Update system architect if different + if self.system.get('Architect') != architect: + self.colonisation.modify_system(self.system, {'Architect': architect}) + + # Update body if provided + if body and self.build.get('Body') != body: + self.colonisation.modify_build(self.system, self.build.get('BuildID'), {'Body': body}) + + Debug.logger.info(f"Creating RavenColonial project: {self.build.get('Name')}, Type: {build_type}") + + # Call RavenColonial create_project + try: + RavenColonial(self.colonisation).create_project(self.system, self.build, self.progress) + + # Get the project ID that was just created + project_id = self.progress.get('ProjectID') + + tkinter.messagebox.showinfo( + _("Success"), + _("RavenColonial project created successfully!\n\nOpening build page in browser...") + ) + + # Open the build page in browser + if project_id: + url = f"https://ravencolonial.com/#build={project_id}" + Debug.logger.info(f"Opening RavenColonial build page: {url}") + webbrowser.open(url) + + self.result = True + self.dialog.destroy() + except Exception as e: + Debug.logger.error(f"Failed to create project: {e}") + tkinter.messagebox.showerror( + _("Error"), + _("Failed to create project. Check EDMC logs for details.") + ) + + def _on_cancel(self): + """Handle Cancel button click""" + self.result = False + self.dialog.destroy() diff --git a/bgstally/windows/progress.py b/bgstally/windows/progress.py index 498a692d..75fb9821 100644 --- a/bgstally/windows/progress.py +++ b/bgstally/windows/progress.py @@ -14,6 +14,7 @@ from bgstally.utils import _, str_truncate, catch_exceptions, human_format from bgstally.ravencolonial import RavenColonial from bgstally.requestmanager import BGSTallyRequest +from bgstally.windows.create_rc_project import CreateRCProjectDialog from config import config # type: ignore from thirdparty.Tooltip import ToolTip from thirdparty.tksheet import Sheet, natural_sort_key @@ -221,6 +222,16 @@ def create_frame(self, parent_frame:tk.Frame, start_row:int, column_count:int) - self._set_weight(r[col]) self.rows.append(r) + + # Add RavenColonial button at the bottom + row += 1 + self.rc_button = tk.Button( + table_frame, + text=_("Open Build Page"), + command=self._open_ravencolonial_page, + state=tk.DISABLED + ) + self.rc_button.grid(row=row, column=0, columnspan=len(self.columns), pady=(10,0), sticky=tk.EW) # No builds or no commodities so hide the frame entirely if len(tracked) == 0 or len(self.colonisation.get_required(tracked)) == 0: @@ -606,6 +617,9 @@ def update_display(self) -> None: self.progvar.set(round(totals['Delivered'] * 100 / totals['Required'])) self.progress = round(totals['Delivered'] * 100 / totals['Required']) self.progtt.text = f"{_('Progress')}: {int(self.progvar.get())}%" # LANG: tooltip for the progress bar + + # Update RavenColonial button + self._update_rc_button() @catch_exceptions @@ -686,3 +700,110 @@ def _highlight_row(self, row:dict, c:str, required:int, delivered:int, cargo:int # bold if need any and have room, otherwise normal self._set_weight(cell, 'bold' if remaining-cargo-carrier > 0 and space > 0 else 'normal') continue + + + @catch_exceptions + def _open_ravencolonial_page(self) -> None: + """Open the current build's RavenColonial page in browser""" + tracked = self.colonisation.get_tracked_builds() + if not tracked or self.build_index >= len(tracked): + return + + # Get the current build being displayed + build = tracked[self.build_index] if self.build_index < len(tracked) else None + if not build: + return + + project_id = build.get('ProjectID') + if project_id: + url = f"https://ravencolonial.com/#build={project_id}" + Debug.logger.info(f"Opening RavenColonial page: {url}") + webbrowser.open(url) + + + @catch_exceptions + def _create_ravencolonial_project(self) -> None: + """Create a RavenColonial project for the current build""" + tracked = self.colonisation.get_tracked_builds() + if not tracked or self.build_index >= len(tracked): + return + + # Get the current build + build = tracked[self.build_index] if self.build_index < len(tracked) else None + if not build: + return + + # Find the system and actual build by MarketID + market_id = build.get('MarketID') + if not market_id: + Debug.logger.error("Cannot create project: no MarketID") + return + + system = None + actual_build = None + for sys in self.colonisation.get_all_systems(): + for b in sys.get('Builds', []): + if b.get('MarketID') == market_id: + system = sys + actual_build = b + break + if system: + break + + if not system or not actual_build: + Debug.logger.error(f"Cannot create project: system or build not found for MarketID {market_id}") + return + + # Get or create progress record + progress = self.colonisation.find_or_create_progress(market_id) + + # Open the create project dialog + dialog = CreateRCProjectDialog(self.frame.master, self.bgstally, system, actual_build, progress) + self.frame.wait_window(dialog.dialog) + + # Refresh display to update button if project was created + if dialog.result: + self.update_display() + + + @catch_exceptions + def _update_rc_button(self) -> None: + """Update RavenColonial button state and text""" + if not hasattr(self, 'rc_button'): + return + + tracked = self.colonisation.get_tracked_builds() + if not tracked or self.build_index >= len(tracked): + self.rc_button['state'] = tk.DISABLED + self.rc_button['text'] = _("Open Build Page") + return + + # Get the current build + build = tracked[self.build_index] if self.build_index < len(tracked) else None + if not build: + self.rc_button['state'] = tk.DISABLED + self.rc_button['text'] = _("Open Build Page") + return + + project_id = build.get('ProjectID') + market_id = build.get('MarketID') + is_docked_at_construction = self.colonisation.docked and market_id == self.colonisation.market_id + + # Button should only be enabled when docked at the correct construction ship + if not is_docked_at_construction: + # Not docked at this construction ship + self.rc_button['state'] = tk.DISABLED + if not self.colonisation.docked: + self.rc_button['text'] = _("Dock to View/Create Project") + else: + self.rc_button['text'] = _("Dock Here to View/Create Project") + elif project_id: + # Docked and project exists - open build page + self.rc_button['state'] = tk.NORMAL + self.rc_button['text'] = _("🌐 Open Build Page") + self.rc_button['command'] = self._open_ravencolonial_page + else: + # Docked but no project - allow creating + self.rc_button['state'] = tk.NORMAL + self.rc_button['text'] = _("🚧 Create Project") + self.rc_button['command'] = self._create_ravencolonial_project