diff --git a/.gitignore b/.gitignore index f8b73e7..67092b8 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,5 @@ dmypy.json # Cython debug symbols cython_debug/ +# diskcache folder +cache/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..844b898 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run OriginDex App", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/main.py", + "console": "integratedTerminal", + "justMyCode": false + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5e56ca7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.languageServer": "Pylance", + "python.analysis.diagnosticSeverityOverrides": { + "reportMissingModuleSource": "none", + "reportShadowedImports": "none" + } +} \ No newline at end of file diff --git a/cache.py b/cache.py new file mode 100644 index 0000000..5f56e99 --- /dev/null +++ b/cache.py @@ -0,0 +1,2 @@ +from utility.cache_manager import CacheManager +cache = CacheManager() \ No newline at end of file diff --git a/data_gathering/__init__.py b/data_gathering/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..7dd806a --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +import sys + +from pathlib import Path +sys.path.append(str(Path(__file__).resolve().parent)) + +from PyQt6 import QtWidgets +from ui.main_window_view import PokemonUI + +from cache import cache + +def main(): + import sys + app = QtWidgets.QApplication(sys.argv) + ui = PokemonUI() + ui.show() + sys.exit(app.exec()) + +if __name__ == "__main__": + try: + main() + finally: + # Ensure the cache is closed at the end of the application + cache.close() \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/main_window_controller.py b/ui/main_window_controller.py new file mode 100644 index 0000000..602a48c --- /dev/null +++ b/ui/main_window_controller.py @@ -0,0 +1,120 @@ +from PyQt6.QtCore import Qt, QTimer, QThreadPool +from PyQt6.QtWidgets import QMenu +from PyQt6.QtGui import QAction + +from ui.workers import GatherPokemonFormsWorker + +class MainWindowController: + def __init__(self, view): + self.view = view + self.pokemon_data_cache = [] + self.filter_timer = QTimer() + self.filter_timer.setInterval(300) # 300 ms delay to wait for user to stop typing + self.filter_timer.setSingleShot(True) + self.filter_timer.timeout.connect(self.apply_filters) + self.thread_pool = QThreadPool() + + def initialize_pokemon_list(self, data): + self.pokemon_data_cache = data + self.view.update_pokemon_list(data) + + def filter_pokemon_list(self): + self.filter_timer.start() + + def apply_filters(self): + search_text = self.view.search_bar.text().lower() + show_only_home_storable = self.view.filter_home_storable.isChecked() + show_only_missing_encounters = self.view.highlight_no_encounters.isChecked() + + filtered_data = [] + for pfic, display_name in self.pokemon_data_cache: + # Check if the item matches the search text + text_match = search_text in display_name.lower() + + # Check if the item is storable in Home (if the filter is active) + home_storable = True + if show_only_home_storable: + # TODO: update the call to correctly filter the data, or better yet update the data at the source to include this info. + home_storable = True #event_system.call_sync('get_home_storable', pfic) + + # Check to see if the pokemon has encounters + has_encounters = True + if show_only_missing_encounters: + # TODO: reimplement this check. + has_encounters = True + + # If both conditions are met, add to filtered data + if text_match and home_storable: + filtered_data.append((pfic, display_name)) + + # Update the view with the filtered data + self.view.update_pokemon_list(filtered_data) + + def show_pokemon_context_menu(self, position): + item = self.view.pokemon_list.itemAt(position) + if item is not None: + context_menu = QMenu(self) + refresh_action = QAction("Refresh Encounters", self) + refresh_action.triggered.connect(lambda: self.refresh_pokemon_encounters(item)) + context_menu.addAction(refresh_action) + context_menu.exec(self.pokemon_list.viewport().mapToGlobal(position)) + + def on_pokemon_selected(self, item): + pfic = item.data(Qt.ItemDataRole.UserRole) + self.refresh_pokemon_details_panel(pfic) + + def edit_encounter(self): + pass + + def add_new_encounter(self): + pass + + def add_new_evolution(self): + pass + + def save_changes(self): + pass + + def export_database(self): + pass + + def gather_pokemon_forms(self): + worker = GatherPokemonFormsWorker() + worker.signals.finished.connect(self.on_forms_gathered) + self.thread_pool.start(worker) + + def on_forms_gathered(self, data): + # This method will be called in the main thread when the worker finishes + # Update the UI with the gathered forms + self.view.update_pokemon_forms(data) + print("Work's Done!", data) + + def gather_home_storage_info(self): + pass + + def gather_evolution_info(self): + pass + + def reinitialize_database(self): + pass + + def gather_encounter_info(self): + pass + + def gather_marks_info(self): + pass + + def load_shiftable_forms(self): + pass + + def on_exclusive_set_selected(self): + pass + + def add_new_exclusive_set(self): + pass + + def show_encounter_context_menu(self): + pass + + def add_encounter_to_set(self): + pass \ No newline at end of file diff --git a/ui/main_window_view.py b/ui/main_window_view.py new file mode 100644 index 0000000..b910d47 --- /dev/null +++ b/ui/main_window_view.py @@ -0,0 +1,229 @@ +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QLineEdit, + QLabel, QCheckBox, QPushButton, QFormLayout, QListWidgetItem, QSplitter, QTreeWidget, + QTreeWidgetItem, QDialog, QDialogButtonBox, QComboBox, QMessageBox, QSpinBox, QMenu, QTabWidget, + QTextEdit) +from PyQt6.QtCore import Qt, QSize, QTimer, QMetaObject +from PyQt6.QtGui import QPixmap, QFontMetrics, QColor, QAction +from .main_window_controller import MainWindowController + +class PokemonUI(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.controller = MainWindowController(self) + self.setup_ui() + + def setup_ui(self): + main_layout = QVBoxLayout(self) + + self.tab_widget = QTabWidget() + main_layout.addWidget(self.tab_widget) + self.setup_main_tab() + self.setup_db_operations_tab() + self.setup_manage_encounters_tab() + + self.save_button = QPushButton("Save Changes") + self.save_button.clicked.connect(self.controller.save_changes) + main_layout.addWidget(self.save_button) + + self.export_button = QPushButton("Export Database") + self.export_button.clicked.connect(self.controller.export_database) + main_layout.addWidget(self.export_button) + + def setup_main_tab(self): + main_tab = QWidget() + main_tab_layout = QHBoxLayout(main_tab) + self.tab_widget.addTab(main_tab, "Main") + + self.create_main_left_panel(main_tab_layout) + self.create_main_right_panel(main_tab_layout) + + def create_main_left_panel(self, main_tab_layout): + left_layout = QVBoxLayout() + search_layout = QHBoxLayout() + self.search_bar = QLineEdit() + self.search_bar.setPlaceholderText("Search Pokémon...") + self.search_bar.textChanged.connect(self.controller.filter_pokemon_list) + search_layout.addWidget(self.search_bar) + + left_layout.addLayout(search_layout) + + self.pokemon_list = QListWidget() + self.pokemon_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.pokemon_list.customContextMenuRequested.connect(self.controller.show_pokemon_context_menu) + self.pokemon_list.currentItemChanged.connect(self.controller.on_pokemon_selected) + left_layout.addWidget(self.pokemon_list) + + self.highlight_no_encounters = QCheckBox("Highlight Pokémon without encounters") + self.highlight_no_encounters.stateChanged.connect(self.controller.filter_pokemon_list) + left_layout.addWidget(self.highlight_no_encounters) + + self.filter_home_storable = QCheckBox("Show only Home-storable Pokémon") + self.filter_home_storable.stateChanged.connect(self.controller.filter_pokemon_list) + left_layout.addWidget(self.filter_home_storable) + + main_tab_layout.addLayout(left_layout, 1) + + def create_main_right_panel(self, main_tab_layout): + right_layout = QVBoxLayout() + + # Left side of right panel: Text information + info_layout = QHBoxLayout() + text_layout = QVBoxLayout() + self.edit_form = QFormLayout() + self.name_label = QLabel() + self.form_name_label = QLabel() + self.national_dex_label = QLabel() + self.generation_label = QLabel() + self.home_checkbox = QCheckBox("Available in Home") + self.is_baby_form_checkbox = QCheckBox("Is Baby Form") + + self.edit_form.addRow("Name:", self.name_label) + self.edit_form.addRow("Form:", self.form_name_label) + self.edit_form.addRow("National Dex:", self.national_dex_label) + self.edit_form.addRow("Generation:", self.generation_label) + self.edit_form.addRow(self.home_checkbox) + self.edit_form.addRow(self.is_baby_form_checkbox) + + text_layout.addLayout(self.edit_form) + + # Right side of right panel: Image + image_layout = QVBoxLayout() + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setFixedSize(150, 150) + image_layout.addWidget(self.image_label) + image_layout.addStretch(1) + + info_layout.addLayout(text_layout) + info_layout.addLayout(image_layout) + + second_half_layout = QVBoxLayout() + # Evolution chain tree + self.evolution_tree = QTreeWidget() + self.evolution_tree.setHeaderLabels(["Pokémon", "Evolution Method"]) + self.evolution_tree.setColumnWidth(0, 200) + second_half_layout.addWidget(self.evolution_tree) + + # Add Locations tree + self.locations_tree = QTreeWidget() + self.locations_tree.setHeaderLabels(["Game/Location", "Details"]) + self.locations_tree.setColumnWidth(0, 200) + self.locations_tree.itemDoubleClicked.connect(self.controller.edit_encounter) + second_half_layout.addWidget(QLabel("Locations:")) + second_half_layout.addWidget(self.locations_tree) + + # Add New Encounter button + self.add_encounter_button = QPushButton("Add New Encounter") + self.add_encounter_button.clicked.connect(self.controller.add_new_encounter) + second_half_layout.addWidget(self.add_encounter_button) + + # Move buttons to the bottom + second_half_layout.addStretch(1) + + # Add New Evolution button + self.add_evolution_button = QPushButton("Add New Evolution") + self.add_evolution_button.clicked.connect(self.controller.add_new_evolution) + second_half_layout.addWidget(self.add_evolution_button) + + right_layout.addLayout(info_layout) + right_layout.addLayout(second_half_layout) + + main_tab_layout.addLayout(right_layout, 1) + + def setup_db_operations_tab(self): + db_tab = QWidget() + db_tab_layout = QVBoxLayout(db_tab) + self.tab_widget.addTab(db_tab, "Database Operations") + + gather_forms_btn = QPushButton("Gather Pokémon Forms") + gather_forms_btn.clicked.connect(self.controller.gather_pokemon_forms) + db_tab_layout.addWidget(gather_forms_btn) + + gather_home_btn = QPushButton("Gather Home Storage Info") + gather_home_btn.clicked.connect(self.controller.gather_home_storage_info) + db_tab_layout.addWidget(gather_home_btn) + + gather_evolutions_btn = QPushButton("Gather Evolution Information") + gather_evolutions_btn.clicked.connect(self.controller.gather_evolution_info) + db_tab_layout.addWidget(gather_evolutions_btn) + + gather_encounters_btn = QPushButton("Gather Encounter Information") + gather_encounters_btn.clicked.connect(self.controller.gather_encounter_info) + db_tab_layout.addWidget(gather_encounters_btn) + + gather_marks_btn = QPushButton("Gather Marks Information") + gather_marks_btn.clicked.connect(self.controller.gather_marks_info) + db_tab_layout.addWidget(gather_marks_btn) + + load_shiftable_forms_btn = QPushButton("Load Shiftable Forms") + load_shiftable_forms_btn.clicked.connect(self.controller.load_shiftable_forms) + db_tab_layout.addWidget(load_shiftable_forms_btn) + + self.progress_text = QTextEdit() + self.progress_text.setReadOnly(True) + self.progress_text.setMinimumHeight(200) # Set a minimum height + db_tab_layout.addWidget(self.progress_text) + + db_tab_layout.addStretch(1) + + reinit_db_btn = QPushButton("Clear and Reinitialize Database") + reinit_db_btn.clicked.connect(self.controller.reinitialize_database) + db_tab_layout.addWidget(reinit_db_btn) + + def setup_manage_encounters_tab(self): + manage_encounters = QWidget() + self.manage_encounters_tab = QHBoxLayout(manage_encounters) + self.tab_widget.addTab(manage_encounters, "Manage Encounters") + + left_layout = QVBoxLayout() + self.exclusive_set_list = QListWidget() + self.exclusive_set_list.currentItemChanged.connect(self.controller.on_exclusive_set_selected) + left_layout.addWidget(QLabel("Exclusive Encounter Sets:")) + left_layout.addWidget(self.exclusive_set_list) + + add_set_button = QPushButton("Add New Set") + add_set_button.clicked.connect(self.controller.add_new_exclusive_set) + left_layout.addWidget(add_set_button) + + # Right side: Set details and encounters + right_layout = QVBoxLayout() + self.set_name_label = QLabel() + self.set_description_label = QLabel() + self.set_game_label = QLabel() + right_layout.addWidget(self.set_name_label) + right_layout.addWidget(self.set_description_label) + right_layout.addWidget(self.set_game_label) + + self.encounter_list = QListWidget() + self.encounter_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.encounter_list.customContextMenuRequested.connect(self.controller.show_encounter_context_menu) + right_layout.addWidget(QLabel("Encounters in Set:")) + right_layout.addWidget(self.encounter_list) + + add_encounter_button = QPushButton("Add Encounter to Set") + add_encounter_button.clicked.connect(self.controller.add_encounter_to_set) + right_layout.addWidget(add_encounter_button) + + self.manage_encounters_tab.addLayout(left_layout, 1) + self.manage_encounters_tab.addLayout(right_layout, 2) + + #self.load_exclusive_sets() + + def update_pokemon_list(self, data): + self.pokemon_list.clear() + + for pfic, display_name in data: + item = QListWidgetItem(display_name) + item.setData(Qt.ItemDataRole.UserRole, pfic) + self.pokemon_list.addItem(item) + + def update_pokemon_forms(self, data): + self.pokemon_list.clear() + + for pokemon in data: + display_name = f"{pokemon["national_dex"]:04d} - {pokemon["name"]}" + if pokemon["form_name"]: + display_name += f" ({pokemon["form_name"]})" + item = QListWidgetItem(display_name) + item.setData(Qt.ItemDataRole.UserRole, pokemon["pfic"]) + self.pokemon_list.addItem(item) \ No newline at end of file diff --git a/ui/workers.py b/ui/workers.py new file mode 100644 index 0000000..900ea77 --- /dev/null +++ b/ui/workers.py @@ -0,0 +1,170 @@ +from PyQt6.QtCore import QObject, pyqtSignal, QRunnable +from bs4 import BeautifulSoup +import re + +from cache import cache +from utility.functions import get_generation_from_national_dex, sanitise_pokemon_name_for_url, remove_accents, compare_pokemon_forms, find_game_generation, format_pokemon_id + +class GatherPokemonFormsWorkerSignals(QObject): + finished = pyqtSignal(list) + +class GatherPokemonFormsWorker(QRunnable): + def __init__(self): + super().__init__() + self.signals = GatherPokemonFormsWorkerSignals() + + def run(self): + try: + gathered_data = self.gather_forms_data() + self.signals.finished.emit(gathered_data) + except Exception as e: + print(f"Error gathering Pokémon forms: {e}") + + def gather_forms_data(self): + # Get the sprites page from pokemondb. + # This gives us every pokemon in its default form. + url = "https://pokemondb.net/sprites" + page_data = cache.fetch_url(url) + + if not page_data: + return None + + soup = BeautifulSoup(page_data, 'html.parser') + pokemon = soup.find_all('a', class_='infocard') + + # Loop through each card for the pokemon so we can extract out more information + pokemon_forms = [] + for index, mon in enumerate(pokemon): + new_forms = self.process_pokemon_entry(index+1, mon) + if new_forms: + pokemon_forms.extend(new_forms) + + return pokemon_forms + + def get_pokemon_sprites_page_data(self, pokemon_name: str): + url = f"https://pokemondb.net/sprites/{pokemon_name}" + return cache.fetch_url(url) + + def get_pokemon_dex_page(self, pokemon_name: str): + url = f"https://pokemondb.net/pokedex/{pokemon_name}" + return cache.fetch_url(url) + + def extract_form_name(self, soup): + if soup.find('small'): + smalls = soup.find_all('small') + form_name = "" + for small in smalls: + form_name += small.get_text(strip=True) + " " + form_name = form_name.strip() + return form_name + return "None" + + def process_pokemon_entry(self, national_dex_number, pokemon_soup, force_refresh = True): + found_forms = [] + generation = get_generation_from_national_dex(national_dex_number) + pokemon_name = pokemon_soup.get_text(strip=True) + print(f"Processing {pokemon_name}") + + url_name = sanitise_pokemon_name_for_url(pokemon_name) + + if force_refresh: + cache.purge(url_name) + + cached_entry = cache.get(url_name) + if cached_entry != None: + return cached_entry + + sprites_page_data = self.get_pokemon_sprites_page_data(url_name) + if not sprites_page_data: + return None + + form_pattern = re.compile(r'a(?:n)? (\w+) Form(?:,)? introduced in (?:the )?([\w\s:]+)(?:\/([\w\s:]+))?', re.IGNORECASE) + update_pattern = re.compile(r'a(?:n)? (\w+) form(?:,)? available in the latest update to ([\w\s:]+)(?:& ([\w\s:]+))?', re.IGNORECASE) + multiple_forms_pattern = re.compile(r'has (?:\w+) new (\w+) Form(?:s)?(?:,)? available in (?:the )?([\w\s:]+)(?:& ([\w\s:]+))?', re.IGNORECASE) + expansion_pass_pattern = re.compile(r'a(?:n)? (\w+) form(?:,)? introduced in the Crown Tundra Expansion Pass to ([\w\s:]+)(?:& ([\w\s:]+))?', re.IGNORECASE) + patterns = [form_pattern, update_pattern, multiple_forms_pattern, expansion_pass_pattern] + + sprites_soup = BeautifulSoup(sprites_page_data, 'html.parser') + generation_8_table = sprites_soup.find('h2', string='Generation 8') + if generation_8_table: + generation_8_table = generation_8_table.find_next('table') + + if generation_8_table: + generation_8_rows = generation_8_table.select('tbody > tr') + generation_8_rows = [row for row in generation_8_rows if "Home" in row.get_text(strip=True)] + for row in generation_8_rows: + sprites = row.find_all('span', class_='sprites-table-card') + if not sprites: + continue + form_index = 0 + for sprite in sprites: + sprite_img = sprite.find('img') + sprite_url = "missing" + if sprite_img: + sprite_url = sprite_img.get('src') + + if "shiny" in sprite_url: + continue + + form_name = self.extract_form_name(sprite) + #logger.info(f'{sprite_url}, {form_name}') + if form_name != "None": + form_index += 1 + gender = 0 + if form_name.startswith("Male"): + form_index -= 1 + gender = 1 + elif form_name.startswith("Female"): + form_index -= 1 + gender = 2 + + dex_page_data = self.get_pokemon_dex_page(url_name) + if dex_page_data: + dex_soup = BeautifulSoup(dex_page_data, 'html.parser') + + #Find a heading that has the pokemon name in it + dex_header = dex_soup.find('h1', string=pokemon_name) + if dex_header: + #The next

tag contains the generation number, in the format "{pokemon name} is a {type}(/{2nd_type}) type Pokémon introduced in Generation {generation number}." + generation_tag = dex_header.find_next('p') + dex_text = generation_tag.get_text() + pattern = r'^(.+?) is a (\w+)(?:/(\w+))? type Pokémon introduced in Generation (\d+)\.$' + match = re.match(pattern, dex_text) + if match: + name, type1, type2, gen = match.groups() + generation = int(gen) + + if form_name != "None": + next_tag = generation_tag.find_next('p') + if next_tag: + extra_text = next_tag.get_text() + extra_text = remove_accents(extra_text) + test_form = form_name.replace(pokemon_name, "").replace("Male", "").replace("Female", "").strip() + if pokemon_name == "Tauros" and (form_name == "Aqua Breed" or form_name == "Blaze Breed" or form_name == "Combat Breed"): + test_form = "Paldean" + for pattern in patterns: + matches = re.findall(pattern, extra_text) + generation_found = False + for i, (regional, game1, game2) in enumerate(matches, 1): + if compare_pokemon_forms(test_form, regional): + target_game = game1.replace("Pokemon", "").strip() + result = find_game_generation(target_game) + if result: + generation = result + generation_found = True + break + if generation_found: + break + + pokemon_form = { + "pfic":format_pokemon_id(national_dex_number, generation, form_index, gender), + "name":pokemon_name, + "form_name":form_name if form_name != "None" else None, + "sprite_url":sprite_url, + "national_dex":national_dex_number, + "generation":generation + } + found_forms.append(pokemon_form) + + cache.set(url_name, found_forms) + return found_forms \ No newline at end of file diff --git a/utility/__init__.py b/utility/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utility/cache_manager.py b/utility/cache_manager.py new file mode 100644 index 0000000..56d4248 --- /dev/null +++ b/utility/cache_manager.py @@ -0,0 +1,52 @@ +import time +import requests +from typing import Any, Optional, Dict, List +import diskcache as dc +from threading import Lock + +class CacheManager: + def __init__(self, cache_dir='cache', max_connections: int = 10): + # Initialize the disk cache + self.cache = dc.Cache(cache_dir) + self.fetch_url_lock = Lock() + + def get(self, key: str) -> Optional[Any]: + # Fetch the value from the cache + return self.cache.get(key) + + def set(self, key: str, value: Any, expire: int = None): + # Store the value in the cache with optional expiry + self.cache.set(key, value, expire=expire) + + def purge(self, key: str): + self.cache.delete(key) + + def bulk_get(self, keys: List[str]) -> Dict[str, Any]: + # Use a dictionary comprehension to fetch multiple values + return {key: self.cache.get(key) for key in keys if key in self.cache} + + def fetch_url(self, url: str, force_refresh: bool = False, expiry: int = 86400*30) -> Optional[str]: + cache_key = f"url_{url}" + if not force_refresh: + cached_data = self.get(cache_key) + if cached_data: + cached_time = cached_data['timestamp'] + if time.time() - cached_time < expiry: + return cached_data['content'] + + # Fetch the URL if not in cache or if a refresh is requested + with self.fetch_url_lock: + print(f"Fetching URL: {url}") + response = requests.get(url) + if response.status_code == 200: + content = response.text + self.set(cache_key, { + 'content': content, + 'timestamp': time.time() + }, expire=expiry) + time.sleep(0.25) # Throttle requests to avoid being blocked + return content + return None + + def close(self): + self.cache.close() \ No newline at end of file diff --git a/utility/data.py b/utility/data.py new file mode 100644 index 0000000..3973a11 --- /dev/null +++ b/utility/data.py @@ -0,0 +1,250 @@ +pokemon_generations = { + 1: {"min": 1, "max": 151}, + 2: {"min": 152, "max": 251}, + 3: {"min": 252, "max": 386}, + 4: {"min": 387, "max": 493}, + 5: {"min": 494, "max": 649}, + 6: {"min": 650, "max": 721}, + 7: {"min": 722, "max": 809}, + 8: {"min": 810, "max": 905}, + 9: {"min": 906, "max": 1025}, +} + +regional_descriptors = ["kantonian", "johtonian", "hoennian", "sinnohan", "unovan", "kalosian", "alolan", "galarian", "hisuian", "paldean"] + +yellow = { + "Name": "Yellow", + "AltNames": ["Pokemon Yellow", "Pokémon Yellow", "Y"], + "Generation": 1 +} + +red = { + "Name": "Red", + "AltNames": ["Pokemon Red", "Pokémon Red", "R"], + "Generation": 1 +} + +blue = { + "Name": "Blue", + "AltNames": ["Pokemon Blue", "Pokémon Blue", "B"], + "Generation": 1 +} + +crystal = { + "Name": "Crystal", + "AltNames": ["Pokemon Crystal", "Pokémon Crystal", "C"], + "Generation": 2 +} + +gold = { + "Name": "Gold", + "AltNames": ["Pokemon Gold", "Pokémon Gold", "G"], + "Generation": 2 +} + +silver = { + "Name": "Silver", + "AltNames": ["Pokemon Silver", "Pokémon Silver", "S"], + "Generation": 2 +} + +emerald = { + "Name": "Emerald", + "AltNames": ["Pokemon Emerald", "Pokémon Emerald", "E"], + "Generation": 3 +} + +fire_red = { + "Name": "FireRed", + "AltNames": ["Pokemon FireRed", "Pokémon FireRed", "FR"], + "Generation": 3 +} + +leaf_green = { + "Name": "LeafGreen", + "AltNames": ["Pokemon LeafGreen", "Pokémon LeafGreen", "LG"], + "Generation": 3 +} + +ruby = { + "Name": "Ruby", + "AltNames": ["Pokemon Ruby", "Pokémon Ruby", "R"], + "Generation": 3 +} + +sapphire = { + "Name": "Sapphire", + "AltNames": ["Pokemon Sapphire", "Pokémon Sapphire", "S"], + "Generation": 3 +} + +platinum = { + "Name": "Platinum", + "AltNames": ["Pokemon Platinum", "Pokémon Platinum", "Pt"], + "Generation": 4 +} + +heart_gold = { + "Name": "HeartGold", + "AltNames": ["Pokemon HeartGold", "Pokémon HeartGold", "HG"], + "Generation": 4 +} + +soul_silver = { + "Name": "SoulSilver", + "AltNames": ["Pokemon SoulSilver", "Pokémon SoulSilver", "SS"], + "Generation": 4 +} + +diamond = { + "Name": "Diamond", + "AltNames": ["Pokemon Diamond", "Pokémon Diamond", "D"], + "Generation": 4 +} + +pearl = { + "Name": "Pearl", + "AltNames": ["Pokemon Pearl", "Pokémon Pearl", "P"], + "Generation": 4 +} + +black = { + "Name": "Black", + "AltNames": ["Pokemon Black", "Pokémon Black", "B"], + "Generation": 5 +} + +white = { + "Name": "White", + "AltNames": ["Pokemon White", "Pokémon White", "W"], + "Generation": 5 +} + +black_2 = { + "Name": "Black 2", + "AltNames": ["Pokemon Black 2", "Pokémon Black 2", "B2"], + "Generation": 5 +} + +white_2 = { + "Name": "White 2", + "AltNames": ["Pokemon White 2", "Pokémon White 2", "W2"], + "Generation": 5 +} + +x = { + "Name": "X", + "AltNames": ["Pokemon X", "Pokémon X"], + "Generation": 6 +} + +y = { + "Name": "Y", + "AltNames": ["Pokemon Y", "Pokémon Y"], + "Generation": 6 +} + +omega_ruby = { + "Name": "Omega Ruby", + "AltNames": ["Pokemon Omega Ruby", "Pokémon Omega Ruby", "OR"], + "Generation": 6 +} + +alpha_sapphire = { + "Name": "Alpha Sapphire", + "AltNames": ["Pokemon Alpha Sapphire", "Pokémon Alpha Sapphire", "AS"], + "Generation": 6 +} + +sun = { + "Name": "Sun", + "AltNames": ["Pokemon Sun", "Pokémon Sun"], + "Generation": 7 +} + +moon = { + "Name": "Moon", + "AltNames": ["Pokemon Moon", "Pokémon Moon"], + "Generation": 7 +} + +ultra_sun = { + "Name": "Ultra Sun", + "AltNames": ["Pokemon Ultra Sun", "Pokémon Ultra Sun", "US"], + "Generation": 7 +} + +ultra_moon = { + "Name": "Ultra Moon", + "AltNames": ["Pokemon Ultra Moon", "Pokémon Ultra Moon", "UM"], + "Generation": 7 +} + +sword = { + "Name": "Sword", + "AltNames": ["Pokemon Sword", "Pokémon Sword", "Expansion Pass", "Expansion Pass (Sword)"], + "Generation": 8 +} + +shield = { + "Name": "Shield", + "AltNames": ["Pokemon Shield", "Pokémon Shield", "Expansion Pass", "Expansion Pass (Shield)"], + "Generation": 8 +} + +brilliant_diamond = { + "Name": "Brilliant Diamond", + "AltNames": ["Pokemon Brilliant Diamond", "Pokémon Brilliant Diamond", "BD"], + "Generation": 8 +} + +shining_pearl = { + "Name": "Shining Pearl", + "AltNames": ["Pokemon Shining Pearl", "Pokémon Shining Pearl", "SP"], + "Generation": 8 +} + +legends_arceus = { + "Name": "Legends: Arceus", + "AltNames": ["Pokemon Legends: Arceus", "Pokémon Legends: Arceus", "LA", "Legends Arceus", "Arceus"], + "Generation": 8 +} + +scarlet = { + "Name": "Scarlet", + "AltNames": ["Pokemon Scarlet", "Pokémon Scarlet", "The Hidden Treasure of Area Zero", "The Hidden Treasure of Area Zero (Scarlet)", "The Teal Mask", "The Teal Mask (Scarlet)"], + "Generation": 9 +} + +violet = { + "Name": "Violet", + "AltNames": ["Pokemon Violet", "Pokémon Violet", "The Hidden Treasure of Area Zero", "The Hidden Treasure of Area Zero (Violet)", "The Teal Mask", "The Teal Mask (Violet)"], + "Generation": 9 +} + +lets_go_pikachu = { + "Name": "Lets Go Pikachu", + "AltNames": [], + "Generation": 8 +} + +lets_go_eevee = { + "Name": "Lets Go Eevee", + "AltNames": [], + "Generation": 8 +} + +main_line_games = [ + yellow, red, blue, + crystal, gold, silver, + emerald, fire_red, leaf_green, ruby, sapphire, + platinum, heart_gold, soul_silver, diamond, pearl, + black_2, white_2, black, white, + x, y, omega_ruby, alpha_sapphire, + ultra_sun, ultra_moon, sun, moon, lets_go_pikachu, lets_go_eevee, + sword, shield, + brilliant_diamond, shining_pearl, + legends_arceus, + scarlet, violet, +] + diff --git a/utility/functions.py b/utility/functions.py new file mode 100644 index 0000000..b368391 --- /dev/null +++ b/utility/functions.py @@ -0,0 +1,48 @@ +from .data import pokemon_generations, main_line_games +import unicodedata + +def format_pokemon_id(national_dex: int, region_code: int, form_index: int, gender_code: int) -> str: + return f"{national_dex:04d}-{region_code:02d}-{form_index:03d}-{gender_code}" + +def compare_pokemon_forms(a, b): + if a == None or b == None: + return False + + if a == b: + return True + + temp_a = a.lower().replace("forme", "").replace("form", "").replace("é", "e").strip() + temp_b = b.lower().replace("forme", "").replace("form", "").replace("é", "e").strip() + + # Common spelling mistakes + temp_a = temp_a.replace("deputante", "debutante").replace("p'au", "pa'u").replace("blood moon", "bloodmoon") + temp_b = temp_b.replace("deputante", "debutante").replace("p'au", "pa'u").replace("blood moon", "bloodmoon") + + if temp_a == temp_b: + return True + + return False + +def get_generation_from_national_dex(national_dex_number): + generation = 1 + for gen in pokemon_generations: + if pokemon_generations[gen]["min"] <= national_dex_number <= pokemon_generations[gen]["max"]: + generation = gen + break + return generation + +def sanitise_pokemon_name_for_url(pokemon_name): + pokemon_url_name = pokemon_name.replace("♀", "-f").replace("♂", "-m").replace("'", "").replace(".", "").replace('é', 'e').replace(':', '') + pokemon_url_name = pokemon_url_name.replace(" ", "-") + return pokemon_url_name + +def remove_accents(input_str): + nfkd_form = unicodedata.normalize('NFKD', input_str) + return u"".join([c for c in nfkd_form if not unicodedata.combining(c)]) + +def find_game_generation(game_name: str) -> int: + game_name = game_name.lower() + for game in main_line_games: + if game_name == game["Name"].lower() or game_name in (name.lower() for name in game["AltNames"]): + return game["Generation"] + return None \ No newline at end of file