dosctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dosctl/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,227 @@
1
+ import hashlib
2
+ import re
3
+ import requests
4
+ from pathlib import Path
5
+ from typing import List, Dict, Optional
6
+ import zipfile
7
+ from urllib.parse import quote, unquote
8
+ from tqdm import tqdm
9
+
10
+ from .base import BaseCollection
11
+
12
+ class ArchiveOrgCollection(BaseCollection):
13
+ """
14
+ Base class for Archive.org collection backends.
15
+ Handles common functionality like downloading, caching, and parsing.
16
+ """
17
+
18
+ def __init__(self, source: str, cache_dir: str, collection_name: str):
19
+ super().__init__(source)
20
+ self.cache_dir = Path(cache_dir)
21
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
22
+ self.collection_name = collection_name
23
+ self._games_data: List[Dict] = []
24
+
25
+ # The download URL for a file is different from the source URL of the list.
26
+ # We derive the base download URL from the source URL's item name.
27
+ # e.g., https://.../items/ITEM_NAME/file.txt -> https://archive.org/download/ITEM_NAME
28
+ self.item_name = self.source.split("/")[-2]
29
+ self.download_base_url = f"https://archive.org/download/{self.item_name}"
30
+
31
+ def ensure_cache_is_present(self, force_refresh: bool = False) -> None:
32
+ """
33
+ Ensures the game list cache exists, downloading it if it's missing or
34
+ if a refresh is forced.
35
+ """
36
+ cache_file = self.cache_dir / "games.txt"
37
+ if force_refresh or not cache_file.exists():
38
+ print(f"Downloading game list from {self.source}...")
39
+ headers = {'User-Agent': 'Mozilla/5.0'}
40
+ response = requests.get(self.source, headers=headers)
41
+ response.raise_for_status()
42
+ with open(cache_file, "w", encoding="utf-8") as f:
43
+ f.write(response.text)
44
+ print("✅ Game list refreshed successfully.")
45
+
46
+ def load(self, force_refresh: bool = False) -> None:
47
+ self.ensure_cache_is_present(force_refresh=force_refresh)
48
+ self._populate_games_data()
49
+
50
+ def _parse_filename(self, filename: str) -> Dict:
51
+ """
52
+ Parses a filename to extract the year and a clean name.
53
+ Can be overridden by subclasses for different parsing logic.
54
+ """
55
+ name_part = filename.replace(".zip", "")
56
+ year = None
57
+
58
+ # Try to find a year like (1995) in the name
59
+ match = re.search(r'\(([0-9]{4})\)', name_part)
60
+ if match:
61
+ year = match.group(1)
62
+
63
+ return {"name": name_part, "year": year}
64
+
65
+ def _populate_games_data(self) -> None:
66
+ cache_file = self.cache_dir / "games.txt"
67
+ if not cache_file.exists():
68
+ return
69
+
70
+ self._games_data = []
71
+ with open(cache_file, "r", encoding="utf-8") as f:
72
+ content = f.read()
73
+
74
+ zip_hrefs = re.findall(r'href="(.+?\.zip)"', content)
75
+
76
+ for href in zip_hrefs:
77
+ encoded_path = href.split("/")[-1]
78
+ full_path = unquote(encoded_path)
79
+
80
+ filename_with_ext = Path(full_path).name
81
+
82
+ # Use the parser (can be overridden by subclasses)
83
+ parsed_details = self._parse_filename(filename_with_ext)
84
+
85
+ # The game's unique ID is a short, stable hash of its full path.
86
+ # This prevents collisions and ensures the ID is always the same.
87
+ game_hash = hashlib.sha1(full_path.encode()).hexdigest()
88
+ game_id = game_hash[:8]
89
+
90
+ self._games_data.append({
91
+ "id": game_id,
92
+ "name": parsed_details["name"],
93
+ "year": parsed_details["year"],
94
+ "full_path": full_path,
95
+ })
96
+
97
+ def get_games(self) -> List[Dict]:
98
+ if not self._games_data:
99
+ self._populate_games_data()
100
+ return self._games_data
101
+
102
+ def _find_game(self, game_id: str) -> Optional[Dict]:
103
+ if not self._games_data:
104
+ self._populate_games_data()
105
+ for game in self._games_data:
106
+ if game["id"] == game_id:
107
+ return game
108
+ return None
109
+
110
+ def get_download_url(self, game_id: str) -> Optional[str]:
111
+ """
112
+ Constructs the download URL for a specific game.
113
+ Can be overridden by subclasses for different URL patterns.
114
+ """
115
+ game = self._find_game(game_id)
116
+ if not game:
117
+ return None
118
+
119
+ # The download URL is constructed from the base item name and the full path
120
+ encoded_full_path = quote(game["full_path"])
121
+ return self._build_download_url(encoded_full_path)
122
+
123
+ def _build_download_url(self, encoded_full_path: str) -> str:
124
+ """
125
+ Builds the actual download URL. Override this in subclasses for different URL patterns.
126
+ """
127
+ raise NotImplementedError("Subclasses must implement _build_download_url")
128
+
129
+ def download_game(self, game_id: str, destination: str, force: bool = False) -> None:
130
+ download_url = self.get_download_url(game_id)
131
+ if not download_url:
132
+ raise FileNotFoundError(f"Game with ID '{game_id}' not found.")
133
+
134
+ game = self._find_game(game_id)
135
+ destination_path = Path(destination)
136
+ destination_path.mkdir(parents=True, exist_ok=True)
137
+
138
+ filename = game["name"] + ".zip"
139
+ local_zip_path = destination_path / filename
140
+
141
+ if local_zip_path.exists() and not force:
142
+ print(f"'{filename}' already exists in '{destination_path}'. Use --force to overwrite.")
143
+ return local_zip_path
144
+
145
+ headers = {'User-Agent': 'Mozilla/5.0'}
146
+
147
+ # Use a try...except block for more specific error handling
148
+ try:
149
+ with requests.get(download_url, headers=headers, stream=True) as r:
150
+ r.raise_for_status()
151
+
152
+ total_size = int(r.headers.get('content-length', 0))
153
+
154
+ with tqdm.wrapattr(open(local_zip_path, "wb"), "write",
155
+ miniters=1,
156
+ total=total_size,
157
+ desc=f"Downloading '{filename}'") as fout:
158
+ for chunk in r.iter_content(chunk_size=8192):
159
+ fout.write(chunk)
160
+
161
+ print(f"Successfully downloaded '{filename}'")
162
+ except requests.exceptions.RequestException as e:
163
+ print(f"\nError downloading '{filename}': {e}")
164
+ # Clean up partially downloaded file
165
+ if local_zip_path.exists():
166
+ local_zip_path.unlink()
167
+ return None
168
+
169
+ return local_zip_path
170
+
171
+ def unzip_game(self, game_id: str, download_path: Path, install_path: Path) -> None:
172
+ """
173
+ Unzips a downloaded game to a specified installation directory.
174
+ """
175
+ game = self._find_game(game_id)
176
+ if not game:
177
+ raise FileNotFoundError(f"Game with ID '{game_id}' not found.")
178
+
179
+ zip_filename = game["name"] + ".zip"
180
+ zip_filepath = download_path / zip_filename
181
+
182
+ if not zip_filepath.exists():
183
+ raise FileNotFoundError(f"Downloaded game zip not found at '{zip_filepath}'")
184
+
185
+ print(f"Unzipping '{zip_filename}' to '{install_path}'...")
186
+ with zipfile.ZipFile(zip_filepath, 'r') as zip_ref:
187
+ zip_ref.extractall(install_path)
188
+ print("Unzip complete.")
189
+
190
+
191
+ class TotalDOSCollectionRelease14(ArchiveOrgCollection):
192
+ """
193
+ Specific implementation for Total DOS Collection Release 14.
194
+ """
195
+
196
+ def __init__(self, source: str, cache_dir: str):
197
+ super().__init__(source, cache_dir, "Total DOS Collection Release 14")
198
+
199
+ def _build_download_url(self, encoded_full_path: str) -> str:
200
+ """
201
+ Builds the download URL specific to TDC Release 14 structure.
202
+ """
203
+ return f"https://archive.org/download/{self.item_name}/TDC_Release_14.zip/{encoded_full_path}"
204
+
205
+
206
+ # Example of how to add a new version:
207
+ #
208
+ # class TotalDOSCollectionRelease15(ArchiveOrgCollection):
209
+ # """
210
+ # Specific implementation for Total DOS Collection Release 15.
211
+ # """
212
+ #
213
+ # def __init__(self, source: str, cache_dir: str):
214
+ # super().__init__(source, cache_dir, "Total DOS Collection Release 15")
215
+ #
216
+ # def _build_download_url(self, encoded_full_path: str) -> str:
217
+ # """
218
+ # Builds the download URL specific to TDC Release 15 structure.
219
+ # """
220
+ # return f"https://archive.org/download/{self.item_name}/TDC_Release_15.zip/{encoded_full_path}"
221
+ #
222
+ # def _parse_filename(self, filename: str) -> Dict:
223
+ # """
224
+ # Override if Release 15 has different filename patterns.
225
+ # """
226
+ # # Custom parsing logic for Release 15 if needed
227
+ # return super()._parse_filename(filename)
@@ -0,0 +1,21 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Dict
3
+
4
+ class BaseCollection(ABC):
5
+ """
6
+ Abstract base class for a game collection.
7
+ """
8
+ def __init__(self, source: str):
9
+ self.source = source
10
+
11
+ @abstractmethod
12
+ def load(self) -> None:
13
+ pass
14
+
15
+ @abstractmethod
16
+ def get_games(self) -> List[Dict]:
17
+ pass
18
+
19
+ @abstractmethod
20
+ def download_game(self, game_name: str, destination: str) -> None:
21
+ pass
@@ -0,0 +1,35 @@
1
+ """Collection factory for creating appropriate collection instances."""
2
+ from .archive_org import TotalDOSCollectionRelease14
3
+
4
+ # Registry of available collection implementations
5
+ COLLECTION_REGISTRY = {
6
+ "tdc_release_14": TotalDOSCollectionRelease14,
7
+ # Future versions can be added here:
8
+ # "tdc_release_15": TotalDOSCollectionRelease15,
9
+ }
10
+
11
+ def create_collection(collection_type: str, source: str, cache_dir: str):
12
+ """
13
+ Factory function to create collection instances.
14
+
15
+ Args:
16
+ collection_type: The type of collection (e.g., "tdc_release_14")
17
+ source: The source URL for the collection
18
+ cache_dir: Directory for caching collection data
19
+
20
+ Returns:
21
+ An instance of the appropriate collection class
22
+
23
+ Raises:
24
+ ValueError: If the collection_type is not supported
25
+ """
26
+ if collection_type not in COLLECTION_REGISTRY:
27
+ available = ", ".join(COLLECTION_REGISTRY.keys())
28
+ raise ValueError(f"Unknown collection type '{collection_type}'. Available: {available}")
29
+
30
+ collection_class = COLLECTION_REGISTRY[collection_type]
31
+ return collection_class(source, cache_dir)
32
+
33
+ def get_available_collections():
34
+ """Returns a list of available collection types."""
35
+ return list(COLLECTION_REGISTRY.keys())
@@ -0,0 +1,42 @@
1
+ import click
2
+ import shutil
3
+ from dosctl.config import INSTALLED_DIR, DOWNLOADS_DIR
4
+ from dosctl.lib.decorators import ensure_cache
5
+
6
+ @click.command()
7
+ @click.argument('game_id')
8
+ @ensure_cache
9
+ def delete(collection, game_id):
10
+ """
11
+ Deletes an installed game.
12
+ """
13
+ game_install_path = INSTALLED_DIR / game_id
14
+
15
+ if not game_install_path.exists() or not game_install_path.is_dir():
16
+ click.echo(f"Error: Game with ID '{game_id}' is not installed.", err=True)
17
+ return
18
+
19
+ game = collection._find_game(game_id)
20
+ game_name = game['name'] if game else f"Game ID {game_id}"
21
+ downloaded_zip = DOWNLOADS_DIR / f"{game_name}.zip"
22
+
23
+ click.echo(f"You are about to delete the files for '{game_name}'.")
24
+ click.echo(f"Installation: {game_install_path}")
25
+ if downloaded_zip.exists():
26
+ click.echo(f"Downloaded Archive: {downloaded_zip}")
27
+
28
+ if not click.confirm("Are you sure you want to continue?"):
29
+ click.echo("Deletion cancelled.")
30
+ return
31
+
32
+ try:
33
+ if game_install_path.exists():
34
+ shutil.rmtree(game_install_path)
35
+ click.echo(f"✅ Successfully deleted installation directory.")
36
+
37
+ if downloaded_zip.exists():
38
+ downloaded_zip.unlink()
39
+ click.echo(f"✅ Successfully deleted downloaded archive.")
40
+
41
+ except Exception as e:
42
+ click.echo(f"An error occurred during deletion: {e}", err=True)
@@ -0,0 +1,47 @@
1
+ import click
2
+ from dosctl.config import INSTALLED_DIR
3
+ from dosctl.lib.decorators import ensure_cache
4
+
5
+ @click.command()
6
+ @click.argument('game_id')
7
+ @click.option('-e', '--executables', is_flag=True, help='Show only executable files (.exe, .com, .bat).')
8
+ @ensure_cache
9
+ def inspect(collection, game_id, executables):
10
+ """
11
+ Inspects the installed files for a given game.
12
+ """
13
+ game_install_path = INSTALLED_DIR / game_id
14
+
15
+ if not game_install_path.exists() or not game_install_path.is_dir():
16
+ click.echo(f"Error: Game with ID '{game_id}' is not installed.", err=True)
17
+ return
18
+
19
+ game = collection._find_game(game_id)
20
+ game_name = game['name'] if game else "Unknown Game"
21
+
22
+ click.echo(f"Inspecting files for '{game_name}' (ID: {game_id})")
23
+ click.echo(f"Location: {game_install_path}")
24
+ click.echo("-" * 40)
25
+
26
+ # Use rglob to list all files recursively
27
+ files = sorted([f for f in game_install_path.rglob('*') if f.is_file()])
28
+
29
+ if executables:
30
+ # Filter to only executable file extensions
31
+ executable_extensions = {'.exe', '.com', '.bat'}
32
+ files = [f for f in files if f.suffix.lower() in executable_extensions]
33
+
34
+ if not files:
35
+ if executables:
36
+ click.echo("No executable files found in the installation directory.")
37
+ else:
38
+ click.echo("No files found in the installation directory.")
39
+ return
40
+
41
+ if executables:
42
+ click.echo("Executable files:")
43
+
44
+ for file_path in files:
45
+ # Get the path relative to the game's install directory
46
+ relative_path = file_path.relative_to(game_install_path)
47
+ click.echo(f" {relative_path}")
@@ -0,0 +1,25 @@
1
+ import click
2
+ from dosctl.lib.decorators import ensure_cache
3
+ from dosctl.lib.display import sort_games, display_games
4
+ from dosctl.config import INSTALLED_DIR
5
+
6
+ @click.command(name="list")
7
+ @click.option('-s', '--sort-by', type=click.Choice(['name', 'year'], case_sensitive=False), default='name', help='Sort the list by name or year.')
8
+ @click.option('-i', '--installed', is_flag=True, default=False, help='Only list installed games.')
9
+ @ensure_cache
10
+ def list_games(collection, sort_by, installed):
11
+ """Lists all available games from the local cache."""
12
+
13
+ games = collection.get_games()
14
+
15
+ if installed:
16
+ installed_ids = {p.name for p in INSTALLED_DIR.iterdir() if p.is_dir()}
17
+ games = [g for g in games if g['id'] in installed_ids]
18
+
19
+ if not games:
20
+ message = "No games are currently installed." if installed else "No games found in cache."
21
+ click.echo(message)
22
+ return
23
+
24
+ games = sort_games(games, sort_by)
25
+ display_games(games)
@@ -0,0 +1,25 @@
1
+ import click
2
+ from dosctl.collections.archive_org import TotalDOSCollectionRelease14
3
+ from dosctl.config import DEFAULT_COLLECTION_SOURCE, COLLECTION_CACHE_DIR, ensure_dirs_exist
4
+
5
+ @click.command()
6
+ @click.option('--force', is_flag=True, default=False, help='Force a re-download of the game list.')
7
+ def refresh(force):
8
+ """
9
+ Downloads the latest game list from the collection source.
10
+ """
11
+ if not force:
12
+ click.echo("This command will re-download the entire game list.")
13
+ click.echo("Use 'dosctl refresh --force' to confirm.")
14
+ return
15
+
16
+ click.echo("Ensuring application directories exist...")
17
+ ensure_dirs_exist()
18
+
19
+ collection = TotalDOSCollectionRelease14(
20
+ source=DEFAULT_COLLECTION_SOURCE,
21
+ cache_dir=COLLECTION_CACHE_DIR
22
+ )
23
+
24
+ click.echo("Forcing a refresh of the game lists...")
25
+ collection.ensure_cache_is_present(force_refresh=True)
dosctl/commands/run.py ADDED
@@ -0,0 +1,119 @@
1
+ import click
2
+ import subprocess
3
+ import textwrap
4
+ from dosctl.lib.decorators import ensure_cache
5
+ from dosctl.lib.game import install_game
6
+ from dosctl.lib.config_store import get_game_command, set_game_command
7
+ from dosctl.lib.system import is_dosbox_installed
8
+ from dosctl.lib.executables import find_executables, executable_exists
9
+
10
+ @click.command()
11
+ @click.argument('game_id')
12
+ @click.argument('command_parts', nargs=-1)
13
+ @click.option('-c', '--configure', is_flag=True, default=False, help='Force re-selection of the default executable.')
14
+ @ensure_cache
15
+ def run(collection, game_id, command_parts, configure):
16
+ """
17
+ Runs a game. Prompts for an executable on the first run or when --configure is used.
18
+ """
19
+ if not is_dosbox_installed():
20
+ help_text = textwrap.dedent("""
21
+ Error: 'dosbox' command not found in your PATH.
22
+
23
+ Please install DOSBox. We recommend DOSBox Staging for the best experience.
24
+
25
+ To install with Homebrew on macOS:
26
+ brew install dosbox # For standard DOSBox
27
+ brew install dosbox-staging # For DOSBox Staging (recommended)
28
+
29
+ If you install DOSBox Staging, you may need to create a symlink so `dosctl` can find it:
30
+ ln -s "$(brew --prefix dosbox-staging)/bin/dosbox-staging" ~/.local/bin/dosbox
31
+ """)
32
+ click.echo(help_text, err=True)
33
+ return
34
+
35
+ try:
36
+ _, game_install_path = install_game(collection, game_id)
37
+ if not game_install_path:
38
+ return
39
+
40
+ # Determine the command to run
41
+ chosen_command_str = _get_or_prompt_command(game_id, game_install_path, command_parts, configure)
42
+ if not chosen_command_str:
43
+ return
44
+
45
+ # Save the chosen command for future runs
46
+ set_game_command(game_id, chosen_command_str)
47
+
48
+ # Validate that the chosen executable exists
49
+ executable_name = chosen_command_str.split()[0]
50
+ if not executable_exists(game_install_path, executable_name):
51
+ click.echo(f"Error: Executable '{executable_name}' not found.", err=True)
52
+ set_game_command(game_id, None)
53
+ return
54
+
55
+ # Launch the game
56
+ _launch_game(game_install_path, chosen_command_str)
57
+
58
+ except FileNotFoundError as e:
59
+ click.echo(f"Error: {e}", err=True)
60
+ except Exception as e:
61
+ click.echo(f"An unexpected error occurred: {e}", err=True)
62
+
63
+ def _get_or_prompt_command(game_id, game_install_path, command_parts, configure):
64
+ """Get the command to run, prompting if necessary."""
65
+ if configure:
66
+ return _prompt_for_executable(game_install_path, configure)
67
+
68
+ # If user provides command on CLI, use it
69
+ if command_parts:
70
+ return " ".join(command_parts)
71
+
72
+ # Check for saved command
73
+ saved_command = get_game_command(game_id)
74
+ if saved_command:
75
+ return saved_command
76
+
77
+ # First run - prompt for executable
78
+ click.echo(f"No default executable set for game '{game_id}'. Searching...")
79
+ return _prompt_for_executable(game_install_path, configure)
80
+
81
+ def _prompt_for_executable(game_install_path, force_menu=False):
82
+ """Prompt user to select an executable."""
83
+ executables = find_executables(game_install_path)
84
+
85
+ if not executables:
86
+ click.echo(f"Error: No executables (.exe, .com, .bat) found in the archive.", err=True)
87
+ return None
88
+
89
+ # If there's only one option and we're not forcing the menu, choose it automatically
90
+ if len(executables) == 1 and not force_menu:
91
+ click.echo(f"Found a single executable: '{executables[0]}'. Setting as default.")
92
+ return executables[0]
93
+
94
+ # Show menu for user selection
95
+ while True:
96
+ menu_items = [f" {i}: {exe_name.upper()}" for i, exe_name in enumerate(executables, 1)]
97
+ click.echo("Please choose one of the following to run:")
98
+ click.echo("\n".join(menu_items))
99
+
100
+ choice = click.prompt("Select a file to execute", type=int)
101
+ if 1 <= choice <= len(executables):
102
+ return executables[choice - 1]
103
+ else:
104
+ click.echo("Invalid choice. Please try again.", err=True)
105
+
106
+ def _launch_game(game_install_path, chosen_command_str):
107
+ """Launch the game with DOSBox."""
108
+ click.echo(f"Starting '{chosen_command_str.upper()}' with DOSBox...")
109
+
110
+ command = [
111
+ 'dosbox',
112
+ '-c', f'MOUNT C "{game_install_path}"',
113
+ '-c', 'C:',
114
+ '-c', chosen_command_str
115
+ ]
116
+
117
+ subprocess.Popen(command,
118
+ stdout=subprocess.DEVNULL,
119
+ stderr=subprocess.DEVNULL)
@@ -0,0 +1,47 @@
1
+ import click
2
+ import re
3
+ from dosctl.lib.decorators import ensure_cache
4
+ from dosctl.lib.display import sort_games, display_games
5
+
6
+ @click.command()
7
+ @click.argument('query', required=False)
8
+ @click.option('-c', '--case-sensitive', is_flag=True, default=False, help='Perform a case-sensitive search.')
9
+ @click.option('-y', '--year', type=int, help='Filter search results by a specific year.')
10
+ @click.option('-s', '--sort-by', type=click.Choice(['name', 'year'], case_sensitive=False), default='name', help='Sort the results by name or year.')
11
+ @ensure_cache
12
+ def search(collection, query, case_sensitive, year, sort_by):
13
+ """
14
+ Searches for games in the local cache.
15
+ """
16
+ if not query and not year:
17
+ click.echo("Error: You must provide a search query or a year.", err=True)
18
+ return
19
+
20
+ games = collection.get_games()
21
+
22
+ if not games:
23
+ click.echo("No games found in cache.")
24
+ return
25
+
26
+ # Search Logic
27
+ results = []
28
+ flags = 0 if case_sensitive else re.IGNORECASE
29
+
30
+ for game in games:
31
+ # Year filter
32
+ if year and str(game.get('year')) != str(year):
33
+ continue
34
+
35
+ # Name filter (only if query is provided)
36
+ if query and not re.search(query, game['name'], flags):
37
+ continue
38
+
39
+ results.append(game)
40
+
41
+ # Display Logic
42
+ if not results:
43
+ click.echo("No games found matching your criteria.")
44
+ return
45
+
46
+ results = sort_games(results, sort_by)
47
+ display_games(results, f"Found {len(results)} game(s):")
dosctl/config.py ADDED
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ # Define the base directories for config and data
4
+ CONFIG_DIR = Path.home() / ".config" / "dosctl"
5
+ DATA_DIR = Path.home() / ".local" / "share" / "dosctl"
6
+
7
+ # Define the specific cache directory for collections
8
+ COLLECTION_CACHE_DIR = DATA_DIR / "collections"
9
+
10
+ # Define game-specific directories
11
+ DOWNLOADS_DIR = DATA_DIR / "downloads"
12
+ INSTALLED_DIR = DATA_DIR / "installed"
13
+
14
+ # Define the default source URL for the main game collection
15
+ DEFAULT_COLLECTION_SOURCE = "https://ia800906.us.archive.org/view_archive.php?archive=/4/items/Total_DOS_Collection_Release_14/TDC_Release_14.zip"
16
+
17
+ def ensure_dirs_exist():
18
+ """Create the config and data directories if they don't exist."""
19
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
20
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
21
+ COLLECTION_CACHE_DIR.mkdir(parents=True, exist_ok=True)
22
+ DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
23
+ INSTALLED_DIR.mkdir(parents=True, exist_ok=True)
dosctl/lib/__init__.py ADDED
File without changes
@@ -0,0 +1,44 @@
1
+ import json
2
+ from pathlib import Path
3
+ from dosctl.config import CONFIG_DIR
4
+
5
+ CONFIG_FILE = CONFIG_DIR / "run_config.json"
6
+
7
+ def load_run_config() -> dict:
8
+ """Loads the executable configuration file."""
9
+ if not CONFIG_FILE.exists():
10
+ return {}
11
+ try:
12
+ with open(CONFIG_FILE, "r") as f:
13
+ return json.load(f)
14
+ except (json.JSONDecodeError, OSError):
15
+ # If the file is corrupted or unreadable, treat it as empty
16
+ return {}
17
+
18
+ def save_run_config(config: dict) -> None:
19
+ """Saves the executable configuration file."""
20
+ # Ensure the config directory exists
21
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+ try:
24
+ with open(CONFIG_FILE, "w") as f:
25
+ json.dump(config, f, indent=2)
26
+ except OSError as e:
27
+ # Handle write errors gracefully
28
+ print(f"Warning: Could not save configuration: {e}")
29
+
30
+ def get_game_command(game_id: str) -> str | None:
31
+ """Gets the saved command for a specific game."""
32
+ config = load_run_config()
33
+ return config.get(game_id)
34
+
35
+ def set_game_command(game_id: str, command: str | None) -> None:
36
+ """Saves the chosen command for a specific game. If command is None, removes the entry."""
37
+ config = load_run_config()
38
+
39
+ if command is None:
40
+ config.pop(game_id, None) # Remove entry if exists
41
+ else:
42
+ config[game_id] = command
43
+
44
+ save_run_config(config)