From f5d9a88f786d114d052bb22999114092c2670b88 Mon Sep 17 00:00:00 2001 From: Gregory Ployart <71974286+greg-ynx@users.noreply.github.com> Date: Mon, 16 Sep 2024 02:41:11 +0200 Subject: [PATCH] [RELEASE][2.1.0] CLI for NexusDownloadFlow (#32) * beginning of 2.0.1 * beginning of 2.1.0 * [0033] [ndf-script] [debt] Accessibility scope organization (dev) * [0018] [ndf-script] Commands (#23) This merge closes #5 * [0024] [ndf-script] Keyboard shortcuts for commands (#28) * Delete old template matching scripts * Delete the ask to keep the logfile workaround * Add the Pause, Resume, and Stop features * Update requirements file * Correcting doc * [0034] [ndf-script] Technical documentation for 2.1.0 (#29) * Add technical doc to typer cli * Add technical doc to typer cli 2 * update git ignore file * add technical documentation * add operating documentation (#30) * [0035] [ndf-script] Operating documentation for 2.1.0 #25 (#31) * add operating documentation * correction + ajout dans le dossier documentation * Update build.spec --- .gitignore | 9 +- README.md | 186 ++++++++++- _global/constants/messages.py | 16 + assets/issue/issue.template | 13 + assets/{ => template_matching}/template1.png | Bin assets/{ => template_matching}/template2.png | Bin assets/{ => template_matching}/template3.png | Bin build.spec | 3 +- config/application_properties.py | 5 + config/ascii_art.py | 4 +- config/definitions.py | 42 ++- config/ndf_logging.py | 53 +-- ...ating Documentation - NexusDownloadFlow.md | 60 ++++ documentation/technical/index.md | 15 + documentation/technical/main.md | 13 + .../technical/scripts.cli.add_templates.md | 40 +++ .../technical/scripts.cli.clear_logs.md | 33 ++ .../technical/scripts.cli.commands.md | 83 +++++ documentation/technical/scripts.cli.issue.md | 35 ++ .../technical/scripts.cli.remove_templates.md | 31 ++ documentation/technical/scripts.cli.run.md | 171 ++++++++++ .../technical/scripts.cli.run_mode_enum.md | 21 ++ .../technical/scripts.cli.version.md | 12 + main.py | 8 +- pyproject.toml | 8 +- requirements.txt | 9 +- scripts/cli/add_templates/add_templates.py | 71 ++++ scripts/cli/clear_logs/clear_logs.py | 38 +++ scripts/cli/commands.py | 117 +++++++ scripts/cli/issue/issue.py | 101 ++++++ .../cli/remove_templates/remove_templates.py | 84 +++++ scripts/cli/run/run.py | 316 ++++++++++++++++++ scripts/cli/run/run_mode_enum.py | 15 + scripts/cli/version/version.py | 8 + scripts/ndf_params.py | 24 -- scripts/ndf_run.py | 218 ------------ test/demo/ndf_1.0.0_template_matching_demo.py | 79 ----- ...f_2.0.0-snapshot_template_matching_demo.py | 79 ----- test/demo/ndf_2.0.0_template_matching_demo.py | 141 -------- 39 files changed, 1551 insertions(+), 610 deletions(-) create mode 100644 _global/constants/messages.py create mode 100644 assets/issue/issue.template rename assets/{ => template_matching}/template1.png (100%) rename assets/{ => template_matching}/template2.png (100%) rename assets/{ => template_matching}/template3.png (100%) create mode 100644 config/application_properties.py create mode 100644 documentation/operating/Operating Documentation - NexusDownloadFlow.md create mode 100644 documentation/technical/index.md create mode 100644 documentation/technical/main.md create mode 100644 documentation/technical/scripts.cli.add_templates.md create mode 100644 documentation/technical/scripts.cli.clear_logs.md create mode 100644 documentation/technical/scripts.cli.commands.md create mode 100644 documentation/technical/scripts.cli.issue.md create mode 100644 documentation/technical/scripts.cli.remove_templates.md create mode 100644 documentation/technical/scripts.cli.run.md create mode 100644 documentation/technical/scripts.cli.run_mode_enum.md create mode 100644 documentation/technical/scripts.cli.version.md create mode 100644 scripts/cli/add_templates/add_templates.py create mode 100644 scripts/cli/clear_logs/clear_logs.py create mode 100644 scripts/cli/commands.py create mode 100644 scripts/cli/issue/issue.py create mode 100644 scripts/cli/remove_templates/remove_templates.py create mode 100644 scripts/cli/run/run.py create mode 100644 scripts/cli/run/run_mode_enum.py create mode 100644 scripts/cli/version/version.py delete mode 100644 scripts/ndf_params.py delete mode 100644 scripts/ndf_run.py delete mode 100644 test/demo/ndf_1.0.0_template_matching_demo.py delete mode 100644 test/demo/ndf_2.0.0-snapshot_template_matching_demo.py delete mode 100644 test/demo/ndf_2.0.0_template_matching_demo.py diff --git a/.gitignore b/.gitignore index 567dc28..808dee1 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,9 @@ dmypy.json # Cython debug symbols cython_debug/ -# monitor-1.png temp file -monitor-1.png - +# NDF specific ignored files +screenshot.png +issue.txt +custom_templates/* +.distribution/* +__init__.py \ No newline at end of file diff --git a/README.md b/README.md index 47ab411..1d4399f 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,182 @@ -# NexusDownloadFlow: Auto-downloader for Nexus Mods +# NexusDownloadFlow (NDF) -NexusDownloadFlow (NDF) is a program that automates the download process with `Wabbajack modlist installation of Nexus -Mods` in which you have to manually click on `Slow download` button if your Nexus Mods account is not premium. +**NexusDownloadFlow (NDF)** +is an automated downloader designed to simplify the process of downloading mod lists from +[Nexus Mods](https://www.nexusmods.com/). +By automatically handling the download process, +it saves time and effort for users who frequently manage large collections of mods. -## How to use NexusDownloadFlow? +## Install -### Without Wabbajack +Download the latest version [here](https://github.com/greg-ynx/NexusDownloadFlow/releases). +NDF is only available for Windows as an executable. +If you wish to use it on another OS then download the project and run it with Python. -Execute `NexusDownloadFlow.exe` and open your Nexus Mods download page. +## Usage -### With Wabbajack +### Basic Usage -Execute `NexusDownloadFlow.exe` while the mod list is downloading. +While running `Wabbajack` modlist installer (for example), run `NexusDownloadFlow.exe`. +NDF should click on the `Slow download` button, automating the download process. -## Auto-clicker is not clicking +### Command-Line Interface (CLI) Usage -Open an issue [here](https://github.com/greg-ynx/NexusDownloadFlow/issues/new), and if possible, give the scenario in which you had this issue, which version of NDF you are using -and provide a screenshot of your logs or the contents of your current `{date}_ndf.log` file. +NexusDownloadFlow is a CLI application with commands that will allow you to configure and run the auto-downloader. -## Credits +To access detailed information about the available commands and options in NDF, you can use the `--help` option. +This will display a list of all commands and their descriptions. -Thanks to [parsiad](https://github.com/parsiad) for inspiring me with his repository named -[`parsiad/nexus-autodl`](https://github.com/parsiad/nexus-autodl). +Example: + +```bash +.\NexusDownloadFlow.exe --help +``` + +#### Run + +You can run NDF simply by clicking on the executable or with the command line: + +```bash +.\NexusDownloadFlow.exe +``` + +NDF offers three different launch modes to suit your specific needs when downloading mod lists. + +Each mode determines how the templates are handled during the download process. + +1. **Classic Mode (default)** +
+ This is the default mode. + It uses the predefined templates provided by NDF to automate the download of mods. + No additional setup is required. + To run in Classic mode, use: + +```bash +.\NexusDownloadFlow.exe +``` + +2. **Custom Mode** +
+ In Custom mode, NDF only uses the templates that you have manually added. + This allows NDF to be fully adapted to your environment. + To launch in Custom mode: + +```bash +.\NexusDownloadFlow.exe --mode custom # Or .\NexusDownloadFlow.exe -m custom +``` + +3. **Hybrid Mode** +
+ Hybrid mode combines both the default templates provided by NDF and your custom templates. + This allows for a more comprehensive approach, using both standard automation and user-specific customizations. + To launch in Hybrid mode: + +```bash +.\NexusDownloadFlow.exe --mode hybrid # Or .\NexusDownloadFlow.exe -m hybrid +``` + +#### Adding Custom Templates + +Users can enhance the flexibility of NDF by adding their own custom templates. +You can add your own templates using the `add-templates` command: + +```bash +.\NexusDownloadFlow.exe add-templates path/of/custom_template.png path/of/second_custom_template.png +``` + +Custom templates are stored in a directory (`custom_templates`) located where `NexusDownloadFlow.exe` is. + +#### Removing Custom Templates + +In addition to adding templates, +NDF allows users to remove one or more custom templates that they have previously added. +This can be done using the `remove-templates` command. + +**Removing a Specific Template** +To remove a specific custom template, use the following command: + +```bash +.\NexusDownloadFlow.exe remove-templates custom_templates/custom_template_to_delete.png +``` + +**Removing All Custom Templates** +If you want to remove all custom templates at once, you can use the `--all` option with the `remove-templates` command: + +```bash +.\NexusDownloadFlow.exe remove-templates --all +``` + +## Feature + +### Run Command + +The run feature is the core of NexusDownloadFlow (NDF), +allowing users to launch the automation process for downloading mods from Nexus Mods. +With this feature, users can choose between three distinct launch modes depending on their needs and preferences. + +Available Modes: + +1. Classic Mode (default) + Runs the automation using the default templates provided by NDF. + +2. Custom Mode + Uses only the custom templates added by the user. + +3. Hybrid Mode + Combines both default and custom templates. + +### Version Command + +The `version` command allows users to quickly check which version of NexusDownloadFlow they are using. +This can be useful for troubleshooting or ensuring that you have the latest features and updates. + +To display the current version, run: + +```bash +.\NexusDownloadFlow version # Or -v +``` + +This will output the version number of NDF installed on your system. + +### Add-Templates Command + +The `add-templates` command allows you to add your custom templates to NexusDownloadFlow. + +### Remove-Templates Command + +The `remove-templates` command allows you to delete your custom templates from NexusDownloadFlow. + +### Clear-Logs Command + +The `clear-logs` command is used to delete all log files generated by NDF. +This helps to free up disk space and remove old log data that is no longer needed. + +To clear the logs, use: + +```bash +.\NexusDownloadFlow clear-logs +``` + +### Writing issue template for GitHub + +The `issue` command helps users quickly create a pre-filled GitHub issue form for reporting bugs or requesting features +related to NexusDownloadFlow. + +To generate the issue form, use: + +```bash +.\NexusDownloadFlow issue +``` + +## Development requirements + +Python v3.11 and: + +```text +keyboard~=0.13.5 +opencv-python~=4.10.0.84 +mss~=9.0.2 +PyAutoGUI~=0.9.54 +psutil~=6.0.0 +typer~=0.12.5 +``` diff --git a/_global/constants/messages.py b/_global/constants/messages.py new file mode 100644 index 0000000..ef35165 --- /dev/null +++ b/_global/constants/messages.py @@ -0,0 +1,16 @@ +"""Messages constants module.""" + +from config.definitions import CUSTOM_TEMPLATES_DIRECTORY_PATH + +NO_ACTION_REQUIRED_MESSAGE: str = "No action required." +CUSTOM_TEMPLATES_DIRECTORY_IS_NOT_A_DIRECTORY_ERROR_MESSAGE: str = ( + f"'{CUSTOM_TEMPLATES_DIRECTORY_PATH}' exists but is not a directory." +) +CUSTOM_TEMPLATES_FOLDER_DOES_NOT_EXIST_WARNING_MESSAGE: str = ( + f"The custom templates folder does not exist at '{CUSTOM_TEMPLATES_DIRECTORY_PATH}'. {NO_ACTION_REQUIRED_MESSAGE}" +) +CUSTOM_TEMPLATES_FOLDER_EMPTY_WARNING_MESSAGE: str = ( + f"The custom templates folder is empty. {NO_ACTION_REQUIRED_MESSAGE}" +) +FILE_PATH_INVALID_FILE_ERROR_MESSAGE: str = "The provided path '{file_path}' is not a valid file." +FILE_PATH_INVALID_IMAGE_ERROR_MESSAGE: str = "The file '{file_path}' is not a valid image." diff --git a/assets/issue/issue.template b/assets/issue/issue.template new file mode 100644 index 0000000..3a0600b --- /dev/null +++ b/assets/issue/issue.template @@ -0,0 +1,13 @@ +# {issue_title} + +## Environment + +NexusDownloadFlow version: {ndf_version} +Operating System (OS) and its architecture (32/64 bits): {user_operating_system} - {user_operating_system_architecture} +System RAM capacity: {user_system_ram_capacity} GB +Monitors: {user_monitors_count} +Monitors resolution details: {user_monitors_resolutions} + +## Description + +{issue_description} diff --git a/assets/template1.png b/assets/template_matching/template1.png similarity index 100% rename from assets/template1.png rename to assets/template_matching/template1.png diff --git a/assets/template2.png b/assets/template_matching/template2.png similarity index 100% rename from assets/template2.png rename to assets/template_matching/template2.png diff --git a/assets/template3.png b/assets/template_matching/template3.png similarity index 100% rename from assets/template3.png rename to assets/template_matching/template3.png diff --git a/build.spec b/build.spec index c169116..0fdb939 100644 --- a/build.spec +++ b/build.spec @@ -1,7 +1,8 @@ # -*- mode: python ; coding: utf-8 -*- extra_files = [ - ('assets/*', 'assets'), + ('assets/template_matching/*', 'assets/template_matching/'), + ('assets/issue/*', 'assets/issue/'), ('pyproject.toml', '.') ] diff --git a/config/application_properties.py b/config/application_properties.py new file mode 100644 index 0000000..2964e3a --- /dev/null +++ b/config/application_properties.py @@ -0,0 +1,5 @@ +"""Public application properties.""" + +from config.definitions import PROJECT_DATA + +PROJECT_VERSION: str = str(PROJECT_DATA.get("version")) diff --git a/config/ascii_art.py b/config/ascii_art.py index 685cc09..d17696a 100644 --- a/config/ascii_art.py +++ b/config/ascii_art.py @@ -1,7 +1,7 @@ """Print NexusDownloadFlow ascii art.""" import sys -from config.definitions import PROJECT_DATA +from config.application_properties import PROJECT_VERSION __ASCII_COLOR: str = "\033[33m" __ASCII_TEXT: str = """ @@ -19,7 +19,7 @@ |___/ \\___/ \\_/\\_/ |_| |_|_|\\___/ \\__,_|\\__,_| \\_| |_|\\___/ \\_/\\_/\ """ -__PROJECT_VERSION: str = "v{0}".format(str(PROJECT_DATA.get("version"))) +__PROJECT_VERSION: str = "v{0}".format(PROJECT_VERSION) def print_ascii_art() -> None: diff --git a/config/definitions.py b/config/definitions.py index f7b2604..99e96d5 100644 --- a/config/definitions.py +++ b/config/definitions.py @@ -7,16 +7,29 @@ __TEMP_DIRECTORY: str __EXE_DIRECTORY: str = os.path.realpath(os.path.join(sys.executable, "..")) __DEV_DIRECTORY: str = os.path.realpath(os.path.join(os.path.dirname(__file__), "..")) + +# Paths constants definition +ROOT_DIRECTORY: str MAIN_PATH: str SCREENSHOT_PATH: str ASSETS_DIRECTORY: str +CONFIG_DIRECTORY: str LOGS_DIRECTORY: str PYPROJECT_PATH: str -MAIN_FILE_NAME: str = "main.py" -SCREENSHOT_FILE_NAME: str = "screenshot.png" + +# Directory names definition ASSETS_DIRECTORY_NAME: str = "assets" +ISSUE_TEMPLATE_DIRECTORY_NAME: str = "issue" +TEMPLATE_MATCHING_DIRECTORY_NAME: str = "template_matching" +CONFIG_DIRECTORY_NAME: str = "config" LOGS_DIRECTORY_NAME: str = "logs" +CUSTOM_TEMPLATES_DIRECTORY_NAME: str = "custom_templates" + +# File names definition +MAIN_FILE_NAME: str = "main.py" +SCREENSHOT_FILE_NAME: str = "screenshot.png" PYPROJECT_FILE_NAME: str = "pyproject.toml" +ISSUE_TEMPLATE_FILE_NAME: str = "issue.template" def __set_path(directory: str, name: str) -> str: @@ -32,21 +45,28 @@ def __set_path(directory: str, name: str) -> str: if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): __TEMP_DIRECTORY = os.path.join(sys._MEIPASS) + ROOT_DIRECTORY = __EXE_DIRECTORY MAIN_PATH = __set_path(__TEMP_DIRECTORY, MAIN_FILE_NAME) SCREENSHOT_PATH = __set_path(__TEMP_DIRECTORY, SCREENSHOT_FILE_NAME) ASSETS_DIRECTORY = __set_path(__TEMP_DIRECTORY, ASSETS_DIRECTORY_NAME) - LOGS_DIRECTORY = __set_path(__EXE_DIRECTORY, LOGS_DIRECTORY_NAME) + CONFIG_DIRECTORY = __set_path(__TEMP_DIRECTORY, CONFIG_DIRECTORY_NAME) PYPROJECT_PATH = __set_path(__TEMP_DIRECTORY, PYPROJECT_FILE_NAME) else: - MAIN_PATH = __set_path(__DEV_DIRECTORY, MAIN_FILE_NAME) - SCREENSHOT_PATH = __set_path(__DEV_DIRECTORY, SCREENSHOT_FILE_NAME) - ASSETS_DIRECTORY = __set_path(__DEV_DIRECTORY, ASSETS_DIRECTORY_NAME) - LOGS_DIRECTORY = __set_path(__DEV_DIRECTORY, LOGS_DIRECTORY_NAME) - PYPROJECT_PATH = __set_path(__DEV_DIRECTORY, PYPROJECT_FILE_NAME) + ROOT_DIRECTORY = __DEV_DIRECTORY + MAIN_PATH = __set_path(ROOT_DIRECTORY, MAIN_FILE_NAME) + SCREENSHOT_PATH = __set_path(ROOT_DIRECTORY, SCREENSHOT_FILE_NAME) + ASSETS_DIRECTORY = __set_path(ROOT_DIRECTORY, ASSETS_DIRECTORY_NAME) + CONFIG_DIRECTORY = __set_path(ROOT_DIRECTORY, CONFIG_DIRECTORY_NAME) + PYPROJECT_PATH = __set_path(ROOT_DIRECTORY, PYPROJECT_FILE_NAME) +LOGS_DIRECTORY = __set_path(ROOT_DIRECTORY, LOGS_DIRECTORY_NAME) +ISSUE_TEMPLATE_DIRECTORY_PATH: str = __set_path(ASSETS_DIRECTORY, ISSUE_TEMPLATE_DIRECTORY_NAME) +ISSUE_TEMPLATE_FILE_PATH: str = __set_path(ISSUE_TEMPLATE_DIRECTORY_PATH, ISSUE_TEMPLATE_FILE_NAME) +TEMPLATE_MATCHING_DIRECTORY_PATH: str = __set_path(ASSETS_DIRECTORY, TEMPLATE_MATCHING_DIRECTORY_NAME) +CUSTOM_TEMPLATES_DIRECTORY_PATH: str = __set_path(ROOT_DIRECTORY, CUSTOM_TEMPLATES_DIRECTORY_NAME) with open(PYPROJECT_PATH, "rb") as pyproject: PYPROJECT_DATA: dict[str, Any] = tomllib.load(pyproject) - PROJECT_DATA: dict[str, Any] = PYPROJECT_DATA.get("project") - GITHUB_DATA: dict[str, str] = PYPROJECT_DATA.get("github") - GITHUB_ISSUE_VALUE: str = GITHUB_DATA.get("issues") + PROJECT_DATA: dict[str, Any] = PYPROJECT_DATA.get("project") # type: ignore + GITHUB_DATA: dict[str, str] = PYPROJECT_DATA.get("github") # type: ignore + GITHUB_ISSUE_VALUE: str = str(GITHUB_DATA.get("issues")) diff --git a/config/ndf_logging.py b/config/ndf_logging.py index a866c86..28a8bcb 100644 --- a/config/ndf_logging.py +++ b/config/ndf_logging.py @@ -15,34 +15,11 @@ __LOGFILE_NAME: str = time.strftime("%Y_%m_%d_") + __NDF_STR + __LOG_EXTENSION -def __logs_directory_exists() -> bool: - """ - Check if the logs directory exists. - - :return: Bool value indicating if the logs directory exists. - """ - return os.path.exists(LOGS_DIRECTORY) - - -def __setup_logfile_path() -> str: - """ - Set up log file. - - :return: String representing the log file path. - """ - return os.path.join(LOGS_DIRECTORY, __LOGFILE_NAME) - - -def __stop_logging() -> None: - """Shut down the logger.""" - logging.shutdown() - - def delete_logfile() -> None: """Delete the log file.""" logging.debug("Try to delete the current logfile...") logfile_path: str = get_logfile_path() - __stop_logging() + stop_logging() if os.path.exists(logfile_path): os.remove(path=logfile_path) logging.debug("Logfile deleted.") @@ -59,7 +36,10 @@ def get_logfile_path() -> str: def logging_report() -> None: """Log report to open an issue on the project's repository.""" - logging.critical("Please report this exception to our repository on GitHub: " + __GITHUB_ISSUE_URL) + logging.critical( + f"Please report this exception to our repository on GitHub: {__GITHUB_ISSUE_URL}\n" + "You can use the issue command in order to fill our template." + ) def setup_logging() -> None: @@ -74,3 +54,26 @@ def setup_logging() -> None: datefmt="%d/%m/%Y - %H:%M:%S", ) logging.debug("Logger setup completed.") + + +def stop_logging() -> None: + """Shut down the logger.""" + logging.shutdown() + + +def __logs_directory_exists() -> bool: + """ + Check if the logs directory exists. + + :return: Bool value indicating if the logs directory exists. + """ + return os.path.exists(LOGS_DIRECTORY) + + +def __setup_logfile_path() -> str: + """ + Set up log file. + + :return: String representing the log file path. + """ + return os.path.join(LOGS_DIRECTORY, __LOGFILE_NAME) diff --git a/documentation/operating/Operating Documentation - NexusDownloadFlow.md b/documentation/operating/Operating Documentation - NexusDownloadFlow.md new file mode 100644 index 0000000..7eaf7c5 --- /dev/null +++ b/documentation/operating/Operating Documentation - NexusDownloadFlow.md @@ -0,0 +1,60 @@ +# Feature + +## Run Command + +The run feature is the core of NexusDownloadFlow (NDF), +allowing users to launch the automation process for downloading mods from Nexus Mods. +With this feature, users can choose between three distinct launch modes depending on their needs and preferences. + +Available Modes: +1. Classic Mode (default) + Runs the automation using the default templates provided by NDF. + +2. Custom Mode + Uses only the custom templates added by the user. + +3. Hybrid Mode + Combines both default and custom templates. + +## Version Command + +The `version` command allows users to quickly check which version of NexusDownloadFlow they are using. +This can be useful for troubleshooting or ensuring that you have the latest features and updates. + +To display the current version, run: + +```bash +.\NexusDownloadFlow version # Or -v +``` + +This will output the version number of NDF installed on your system. + +## Add-Templates Command + +The `add-templates` command allows you to add your custom templates to NexusDownloadFlow. + +## Remove-Templates Command + +The `remove-templates` command allows you to delete your custom templates from NexusDownloadFlow. + +## Clear-Logs Command + +The `clear-logs` command is used to delete all log files generated by NDF. +This helps to free up disk space and remove old log data that is no longer needed. + +To clear the logs, use: + +```bash +.\NexusDownloadFlow clear-logs +``` + +## Writing issue template for GitHub + +The `issue` command helps users quickly create a pre-filled GitHub issue form for reporting bugs or requesting features +related to NexusDownloadFlow. + +To generate the issue form, use: + +```bash +.\NexusDownloadFlow issue +``` \ No newline at end of file diff --git a/documentation/technical/index.md b/documentation/technical/index.md new file mode 100644 index 0000000..d90e712 --- /dev/null +++ b/documentation/technical/index.md @@ -0,0 +1,15 @@ +# NexusDownloadFlow documentation : + +* NexusDownloadFlow + * [Main module](main.md) + * Scripts package + * [Commands module](scripts.cli.commands.md) + * CLI package + * Run package + * [Run module](scripts.cli.run.md) + * [Run mode enum module](scripts.cli.run_mode_enum.md) + * [Version module](scripts.cli.version.md) + * [Add templates module](scripts.cli.add_template.md) + * [Remove templates module](scripts.cli.remove_templates.md) + * [Clear logs module](scripts.cli.clear_logs.md) + * [Issue module](scripts.cli.issue.md) diff --git a/documentation/technical/main.md b/documentation/technical/main.md new file mode 100644 index 0000000..ba00ed8 --- /dev/null +++ b/documentation/technical/main.md @@ -0,0 +1,13 @@ +# Main module + +Main executable file of NexusDownloadFlow. + +## main.main + +NexusDownloadFlow main function. + +```python +def main() -> None: + print_ascii_art() + cli() +``` diff --git a/documentation/technical/scripts.cli.add_templates.md b/documentation/technical/scripts.cli.add_templates.md new file mode 100644 index 0000000..2ba94d5 --- /dev/null +++ b/documentation/technical/scripts.cli.add_templates.md @@ -0,0 +1,40 @@ +# Add templates module + +Add template command module. + +## scripts.cli.add_template.add_templates.cli_add_templates + +Add custom templates. + +* **Parameters:** + **paths** – Path list of the templates. + +```python +def cli_add_templates(paths) -> None: + setup_logging() + try: + logging.info(__CLI_ADD_TEMPLATES_START_MESSAGE) + __check_paths(paths) + + for path in paths: + __verify_image(path) + + if not os.path.exists(CUSTOM_TEMPLATES_DIRECTORY_PATH): + os.makedirs(CUSTOM_TEMPLATES_DIRECTORY_PATH) + elif not os.path.isdir(CUSTOM_TEMPLATES_DIRECTORY_PATH): + raise NotADirectoryError(CUSTOM_TEMPLATES_DIRECTORY_IS_NOT_A_DIRECTORY_ERROR_MESSAGE) + + for path in paths: + file_path: str = shutil.copy(path, CUSTOM_TEMPLATES_DIRECTORY_PATH) + logging.info(__CLI_ADD_TEMPLATES_SUCCESS_MESSAGE.format(file_path=file_path)) + + except ValueError as e: + logging.error(e) + except NotADirectoryError as e: + logging.error(e) + except Exception as e: + logging.error(e) + logging_report() + finally: + stop_logging() +``` diff --git a/documentation/technical/scripts.cli.clear_logs.md b/documentation/technical/scripts.cli.clear_logs.md new file mode 100644 index 0000000..a7b0680 --- /dev/null +++ b/documentation/technical/scripts.cli.clear_logs.md @@ -0,0 +1,33 @@ +# Clear logs module + +Clear logs command module. + +## scripts.cli.clear_logs.clear_logs.cli_clear_logs + +Clear the logs’ folder. + +```python +def cli_clear_logs() -> None: + print(__STARTING_MESSAGE) + failed_count: int = 0 + if os.path.exists(LOGS_DIRECTORY): + for item in os.listdir(LOGS_DIRECTORY): + item_path: str = os.path.join(LOGS_DIRECTORY, item) + try: + if os.path.isfile(item_path) or os.path.islink(item_path): + os.unlink(item_path) + elif os.path.isdir(item_path): + shutil.rmtree(item_path) + except Exception as e: + failed_count += 1 + setup_logging() + logging.error(__DELETING_ITEM_ERROR_MESSAGE.format(file_path=item_path, error=e)) + logging_report() + if failed_count == 0: + print("The contents of the logs folder have been successfully deleted.") + else: + print(f"The contents of the logs folder have been partially deleted. {failed_count} items are remaining.") + else: + print("The logs folder do not exist.") + stop_logging() +``` diff --git a/documentation/technical/scripts.cli.commands.md b/documentation/technical/scripts.cli.commands.md new file mode 100644 index 0000000..9d81979 --- /dev/null +++ b/documentation/technical/scripts.cli.commands.md @@ -0,0 +1,83 @@ +# Commands module + +All CLI commands of Nexus Download Flow. + +## scripts.cli.commands.add_templates + +Add user’s custom templates to Nexus Download Flow. + +* **Parameters:** + **paths** – List of template paths to copy + +```python +@cli.command() +def add_templates(paths) -> None: + cli_add_templates(paths) +``` + +## scripts.cli.commands.clear_logs + +Clear all content contained in the logs’ folder. + +```python +@cli.command() +def clear_logs() -> None: + cli_clear_logs() +``` + +## scripts.cli.commands.issue + +Create an issue file for the user. + +* **Parameters:** + **issue_folder_path** – The path of the folder where the issue file should be created (optional) + +```python +@cli.command() +def issue(issue_folder_path = None) -> None: + cli_issue(issue_folder_path) +``` + +## scripts.cli.commands.remove_templates + +Remove user’s custom templates from Nexus Download Flow. + +* **Parameters:** + * **paths** – List of template paths to remove (optional) + * **remove_all** – A boolean flag to remove all templates included in the templates folder (optional) + +```python +@cli.command() +def remove_templates(paths = None, remove_all = False) -> None: + cli_remove_templates(paths, remove_all) +``` + +## scripts.cli.commands.run + +Run the auto downloader. + +* **Parameters:** + * **\_version** – Version option + * **ctx** – Context for exclusive executable callback + * **mode** – Mode to launch_ndf the auto downloader (optional) + +```python +@cli.callback(invoke_without_command=True) +def run(ctx: typer.Context, mode = RunModeEnum.CLASSIC, _version = False) -> None: + if _version: + cli_version() + return + + if ctx.invoked_subcommand is None: + cli_run(mode) +``` + +## scripts.cli.commands.version + +Print the current version number of Nexus Download Flow. + +```python +@cli.command() +def version() -> None: + cli_version() +``` \ No newline at end of file diff --git a/documentation/technical/scripts.cli.issue.md b/documentation/technical/scripts.cli.issue.md new file mode 100644 index 0000000..3dd4550 --- /dev/null +++ b/documentation/technical/scripts.cli.issue.md @@ -0,0 +1,35 @@ +# Issue module + +Issue file generator command module. + +## scripts.cli.issue.issue.cli_issue + +Create the issue text file to copy/paste to our repository on GitHub. + +```python +def cli_issue(issue_folder_path: str | None = None) -> None: + setup_logging() + logging.info(__STARTING_MESSAGE) + try: + output_file_path: str = __get_issue_file_path(issue_folder_path) + + issue_title: str = typer.prompt("Please enter a title for your issue") + issue_description: str = typer.prompt("Please describe your issue") + system_info: dict[str, str] = __get_user_system_info() + ndf_version: str = PROJECT_VERSION + + filled_issue_content: str = __fill_issue_template( + issue_title=issue_title, ndf_version=ndf_version, issue_description=issue_description, **system_info + ) + + __write_issue_file(output_file_path, filled_issue_content) + print(filled_issue_content) + logging.info(__SUCCESS_MESSAGE) + except FileNotFoundError as e: + logging.error(e) + except Exception as e: + logging.error(e) + logging_report() + finally: + stop_logging() +``` diff --git a/documentation/technical/scripts.cli.remove_templates.md b/documentation/technical/scripts.cli.remove_templates.md new file mode 100644 index 0000000..c841f8c --- /dev/null +++ b/documentation/technical/scripts.cli.remove_templates.md @@ -0,0 +1,31 @@ +# Remove templates module + +Remove template command module. + +## scripts.cli.remove_templates.remove_templates.cli_remove_templates + +Delete custom templates’ directory. + +```python +def cli_remove_templates(paths: List[str] | None, remove_all: bool = False) -> None: + setup_logging() + try: + if remove_all: + __delete_custom_templates_folder() + return + if not os.path.exists(CUSTOM_TEMPLATES_DIRECTORY_PATH): + logging.warning(CUSTOM_TEMPLATES_FOLDER_DOES_NOT_EXIST_WARNING_MESSAGE) + return + if paths: + for path in paths: + __delete_custom_template_file(path) + else: + logging.warning(__PATHS_NO_PATH_GIVEN_WARNING_MESSAGE) + except NotADirectoryError as e: + logging.error(e) + except Exception as e: + logging.error(e) + logging_report() + finally: + stop_logging() +``` diff --git a/documentation/technical/scripts.cli.run.md b/documentation/technical/scripts.cli.run.md new file mode 100644 index 0000000..62eee35 --- /dev/null +++ b/documentation/technical/scripts.cli.run.md @@ -0,0 +1,171 @@ +# Run module + +Run command module. + +## scripts.cli.run.run.cli_run + +Run the auto-downloader. + +* **Raises:** + * **KeyboardInterrupt** – Raised when the user interrupts the program. + * **FailSafeException** – Raised when the mouse position is on one of the corners of the screen. + +Should not be raised (open an issue on GitHub if it happens). +:raises ValueError: Should not be raised (open an issue on GitHub if it happens). +:raises Exception: For currently unknown exceptions (open an issue on GitHub if it happens). + +```python +def cli_run(mode: str) -> None: + setup_logging() + logging.info(__RUN_STARTING_MESSAGE) + try: + match mode: + case RunModeEnum.CLASSIC: + classic_run() + case RunModeEnum.CUSTOM: + custom_run() + case RunModeEnum.HYBRID: + hybrid_run() + case _: + hybrid_run() + except KeyboardInterrupt: + logging.info(__EXITING_INFO_MESSAGE) + except FailSafeException: + logging.error(__FAILSAFE_ERROR_MESSAGE) + except ValueError as e: + logging.error(e) + logging_report() + except Exception as e: + logging.error(e) + logging_report() + finally: + if os.path.exists(SCREENSHOT_PATH): + os.remove(SCREENSHOT_PATH) + else: + logging.warning(__SCREENSHOT_DOES_NOT_EXIST_MESSAGE) + logging.info(__PROGRAM_ENDED_MESSAGE) + stop_logging() + input("Press any key to exit...") +``` + +## scripts.cli.run.run.classic_run + +Launch classic execution method using built-in templates. + +```python +def classic_run() -> None: + logging.info(__RUNNING_MESSAGE.format(mode=RunModeEnum.CLASSIC)) + launch_ndf(__DEFAULT_TEMPLATES) +``` + +## scripts.cli.run.run.custom_run + +Launch custom execution method using user-provided templates. + +```python +def custom_run() -> None: + logging.info(__RUNNING_MESSAGE.format(mode=RunModeEnum.CUSTOM)) + custom_templates: list[MatLike] = __get_custom_templates() + if custom_templates: + launch_ndf(custom_templates) + return + logging.error(__CUSTOM_RUN_NO_CUSTOM_TEMPLATE_FOUND_ERROR_MESSAGE) +``` + +## scripts.cli.run.run.hybrid_run + +Launch hybrid execution method using built-in and custom templates. + +```python +def hybrid_run() -> None: + logging.info(__RUNNING_MESSAGE.format(mode=RunModeEnum.HYBRID)) + hybrid_templates: list[MatLike] = __DEFAULT_TEMPLATES + __get_custom_templates() + launch_ndf(hybrid_templates) +``` + +## scripts.cli.run.run.launch_ndf + +Launch the auto-downloader. + +```python +def launch_ndf(templates: list[MatLike]) -> None: + global is_running, is_paused + is_running = True + + __init_hotkeys() + + edged_templates: list[MatLike] = __get_edged_templates(templates) + with mss() as mss_instance: + while is_running: + __when_paused() + monitors_size: dict[str, int] = mss_instance.monitors[0] + monitors_left_top: tuple[int, int] = __if_monitors_left_top_present(monitors_size) + screenshot: MatLike = cv2.imread(next(mss_instance.save(mon=-1, output=SCREENSHOT_PATH))) + grayscale_screenshot: MatLike = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) + multiscale_match_template(edged_templates, grayscale_screenshot, monitors_left_top) +``` + +## scripts.cli.run.run.multiscale_match_template + +Apply multiscale template matching algorithm. + +* **Parameters:** + * **templates** – List of edged templates to match. + * **screenshot** – Screenshot where the search is running. + * **left_top_coordinates** – Left-top pixel of the system monitor(s). + +```python +def multiscale_match_template(templates: list[MatLike], screenshot: MatLike, left_top_coordinates: tuple[int, int]) -> None: + for scale in __SCALES: + resized_screenshot: MatLike = __resize_screenshot(screenshot, scale) + edged_screenshot: MatLike = cv2.Canny(resized_screenshot, 50, 200) + for template in templates: + potential_match: tuple[float, Sequence[int]] = __get_potential_match(edged_screenshot, template) + potential_match_value: float = potential_match[0] + potential_match_location: Sequence[int] = potential_match[1] + if __is_match_found(potential_match_value): + logging.info("Match found!") + match_location_x: int = potential_match_location[0] + match_location_y: int = potential_match_location[1] + match_left_top_location: tuple[int, int] = ( + match_location_x + left_top_coordinates[0], + match_location_y + left_top_coordinates[1], + ) + template_height: int = template.shape[0] + template_width: int = template.shape[1] + target: tuple[float, float] = ( + match_left_top_location[0] + template_width / 2, + match_left_top_location[1] + template_height / 2, + ) + __click_on_target(target) + sleep(6) + return +``` + +## scripts.cli.run.run.pause_resume + +Pause or resume the auto download process. + +```python +def pause_resume() -> None: + global is_paused + if is_paused: + is_paused = False + logging.info(__RESUME_NDF_MESSAGE) + else: + is_paused = True + logging.info(__PAUSE_NDF_MESSAGE) +``` + +## scripts.cli.run.run.stop + +Stop the auto download process. + +```python +def stop() -> None: + """Stop the auto download process.""" + global is_running, is_paused + is_running = False + is_paused = False + logging.info(__STOPPING_NDF_MESSAGE) +``` diff --git a/documentation/technical/scripts.cli.run_mode_enum.md b/documentation/technical/scripts.cli.run_mode_enum.md new file mode 100644 index 0000000..17ccb4e --- /dev/null +++ b/documentation/technical/scripts.cli.run_mode_enum.md @@ -0,0 +1,21 @@ +# Run mode Enum module + +Enum containing the mode choices. + +## *class* scripts.cli.run.run_mode_enum.RunModeEnum + +Bases: `StrEnum` + +Enumeration representing the different execution modes available. + +### CLASSIC *: auto* *= 'classic'* + +Classic execution mode with default parameters. + +### CUSTOM *: auto* *= 'custom'* + +Custom execution mode with specific parameters. + +### HYBRID *: auto* *= 'hybrid'* + +Hybrid mode combining classic and custom parameters. diff --git a/documentation/technical/scripts.cli.version.md b/documentation/technical/scripts.cli.version.md new file mode 100644 index 0000000..92540a7 --- /dev/null +++ b/documentation/technical/scripts.cli.version.md @@ -0,0 +1,12 @@ +# Version module + +Version command module. + +## scripts.cli.version.version.cli_version + +Print the current version of the program. + +```python +def cli_version() -> None: + print(f"v{ PROJECT_VERSION }") +``` diff --git a/main.py b/main.py index 3cb85e0..edbbe8d 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,13 @@ """Main executable file of NexusDownloadFlow.""" -import logging from config.ascii_art import print_ascii_art -from config.ndf_logging import setup_logging -from scripts.ndf_run import try_run +from scripts.cli.commands import cli def main() -> None: """NexusDownloadFlow main function.""" - setup_logging() print_ascii_art() - logging.info("NexusDownloadFlow is starting...") - try_run() + cli() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 7ed4b4f..3c3999a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "NexusDownloadFlow" -version = "2.0.1" +version = "2.1.0-SNAPSHOT" authors = [ {name = "Gregory Ployart", email = "greg.ynx@gmail.com", alias = "greg-ynx"}, ] @@ -28,7 +28,7 @@ show_error_codes = true warn_unused_ignores = true [tool.ruff] -extend-select = [ +lint.extend-select = [ "W", "I", "N", @@ -39,12 +39,12 @@ extend-select = [ "SIM", "TCH" ] -ignore = [ +lint.ignore = [ "D203", "D212" ] fix = true show-fixes = true -show-source = false +output-format = "concise" line-length = 120 target-version = "py311" diff --git a/requirements.txt b/requirements.txt index 1c6aba5..fa50aee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ -PyAutoGUI==0.9.54 -opencv-python==4.8.0.76 -mss==9.0.1 \ No newline at end of file +keyboard~=0.13.5 +opencv-python~=4.10.0.84 +mss~=9.0.2 +PyAutoGUI~=0.9.54 +psutil~=6.0.0 +typer~=0.12.5 \ No newline at end of file diff --git a/scripts/cli/add_templates/add_templates.py b/scripts/cli/add_templates/add_templates.py new file mode 100644 index 0000000..6a6c575 --- /dev/null +++ b/scripts/cli/add_templates/add_templates.py @@ -0,0 +1,71 @@ +"""Add templates command module.""" + +import imghdr +import logging +import os +import shutil +from typing import List + +from _global.constants.messages import ( + CUSTOM_TEMPLATES_DIRECTORY_IS_NOT_A_DIRECTORY_ERROR_MESSAGE, + FILE_PATH_INVALID_FILE_ERROR_MESSAGE, + FILE_PATH_INVALID_IMAGE_ERROR_MESSAGE, +) +from config.definitions import CUSTOM_TEMPLATES_DIRECTORY_PATH +from config.ndf_logging import logging_report, setup_logging, stop_logging + +# Messages +__PATHS_NO_PATH_PROVIDED_ERROR_MESSAGE: str = "No path provided." + +__CLI_ADD_TEMPLATES_START_MESSAGE: str = "Initiate the addition of custom templates..." +__CLI_ADD_TEMPLATES_SUCCESS_MESSAGE: str = "The user's template has been successfully added to '{file_path}'." + + +def cli_add_templates(paths: List[str]) -> None: + """ + Add custom templates. + + :param paths: Path list of the templates. + """ + setup_logging() + try: + logging.info(__CLI_ADD_TEMPLATES_START_MESSAGE) + __check_paths(paths) + + for path in paths: + __verify_image(path) + + # Check if the directory exists, create it if not + if not os.path.exists(CUSTOM_TEMPLATES_DIRECTORY_PATH): + os.makedirs(CUSTOM_TEMPLATES_DIRECTORY_PATH) + elif not os.path.isdir(CUSTOM_TEMPLATES_DIRECTORY_PATH): + raise NotADirectoryError(CUSTOM_TEMPLATES_DIRECTORY_IS_NOT_A_DIRECTORY_ERROR_MESSAGE) + + for path in paths: + file_path: str = shutil.copy(path, CUSTOM_TEMPLATES_DIRECTORY_PATH) + logging.info(__CLI_ADD_TEMPLATES_SUCCESS_MESSAGE.format(file_path=file_path)) + + except ValueError as e: + logging.error(e) + except NotADirectoryError as e: + logging.error(e) + except Exception as e: + logging.error(e) + logging_report() + finally: + stop_logging() + + +def __check_paths(paths: List[str]) -> None: + """Check that at least one path is provided.""" + if not paths: + raise ValueError(__PATHS_NO_PATH_PROVIDED_ERROR_MESSAGE) + + +def __verify_image(file_path: str) -> None: + """Verify that input file path is a valid image.""" + if not os.path.isfile(file_path): + raise ValueError(FILE_PATH_INVALID_FILE_ERROR_MESSAGE.format(file_path=file_path)) + + if imghdr.what(file_path) is None: + raise ValueError(FILE_PATH_INVALID_IMAGE_ERROR_MESSAGE.format(file_path=file_path)) diff --git a/scripts/cli/clear_logs/clear_logs.py b/scripts/cli/clear_logs/clear_logs.py new file mode 100644 index 0000000..a735b05 --- /dev/null +++ b/scripts/cli/clear_logs/clear_logs.py @@ -0,0 +1,38 @@ +"""Clear logs command module.""" + +import logging +import os +import shutil + +from config.definitions import LOGS_DIRECTORY +from config.ndf_logging import logging_report, setup_logging, stop_logging + +# Messages +__STARTING_MESSAGE: str = "Initiate deletion of log folder files..." +__DELETING_ITEM_ERROR_MESSAGE: str = "Error deleting '{file_path}: {error}'" + + +def cli_clear_logs() -> None: + """Clear the logs' folder.""" + print(__STARTING_MESSAGE) + failed_count: int = 0 + if os.path.exists(LOGS_DIRECTORY): + for item in os.listdir(LOGS_DIRECTORY): + item_path: str = os.path.join(LOGS_DIRECTORY, item) + try: + if os.path.isfile(item_path) or os.path.islink(item_path): + os.unlink(item_path) + elif os.path.isdir(item_path): + shutil.rmtree(item_path) + except Exception as e: + failed_count += 1 + setup_logging() + logging.error(__DELETING_ITEM_ERROR_MESSAGE.format(file_path=item_path, error=e)) + logging_report() + if failed_count == 0: + print("The contents of the logs folder have been successfully deleted.") + else: + print(f"The contents of the logs folder have been partially deleted. {failed_count} items are remaining.") + else: + print("The logs folder do not exist.") + stop_logging() diff --git a/scripts/cli/commands.py b/scripts/cli/commands.py new file mode 100644 index 0000000..778eae8 --- /dev/null +++ b/scripts/cli/commands.py @@ -0,0 +1,117 @@ +"""All CLI commands of Nexus Download Flow.""" + +from typing import Annotated, List, Optional + +import typer +from typer import Typer + +from scripts.cli.add_templates.add_templates import cli_add_templates +from scripts.cli.clear_logs.clear_logs import cli_clear_logs +from scripts.cli.issue.issue import cli_issue +from scripts.cli.remove_templates.remove_templates import cli_remove_templates +from scripts.cli.run.run import cli_run +from scripts.cli.run.run_mode_enum import RunModeEnum +from scripts.cli.version.version import cli_version + +RUN_MODE_NAME: str = "--mode" +RUN_MODE_SHORT_NAME: str = "-m" +RUN_MODE_HELP: str = "Execution mode for Nexus Download Flow" +RUN_VERSION_SHORTNAME: str = "-v" +RUN_VERSION_HELP: str = "Print the current version number of Nexus Download Flow." +ADD_TEMPLATES_HELP: str = "List of template paths to add to Nexus Download Flow." +ISSUE_ISSUE_FOLDER_PATH_NAME: str = "--folder" +ISSUE_ISSUE_FOLDER_PATH_SHORT_NAME: str = "-f" +ISSUE_ISSUE_FOLDER_PATH_HELP: str = "Path of the folder where the issue file will be stored." +REMOVE_TEMPLATES_PATHS_TEMPLATES_HELP: str = "List of template paths to remove." +REMOVE_TEMPLATES_REMOVE_ALL_TEMPLATES_NAME: str = "--all" +REMOVE_TEMPLATES_REMOVE_ALL_TEMPLATES_SHORT_NAME: str = "-a" +REMOVE_TEMPLATES_REMOVE_ALL_TEMPLATES_HELP: str = "Remove all custom templates from Nexus Download Flow." + +cli: Typer = typer.Typer(name="Nexus Download Flow") + + +@cli.callback(invoke_without_command=True) +def run(ctx: typer.Context, + mode: Annotated[ + RunModeEnum, + typer.Option(RUN_MODE_NAME, RUN_MODE_SHORT_NAME, help=RUN_MODE_HELP) + ] = RunModeEnum.CLASSIC, + _version: Annotated[ + bool, + typer.Option(RUN_VERSION_SHORTNAME, help=RUN_VERSION_HELP) + ] = False) -> None: + """ + Run the auto downloader. + + :param _version: Version option + :param ctx: Context for exclusive executable callback + :param mode: Mode to launch_ndf the auto downloader (optional) + """ + if _version: + cli_version() + return + + if ctx.invoked_subcommand is None: + cli_run(mode) + + +@cli.command() +def version() -> None: + """Print the current version number of Nexus Download Flow.""" + cli_version() + + +@cli.command() +def add_templates(paths: Annotated[ + List[str], + typer.Argument(help=ADD_TEMPLATES_HELP) +]) -> None: + """ + Add user's custom templates to Nexus Download Flow. + + :param paths: List of template paths to copy + """ + cli_add_templates(paths) + + +@cli.command() +def clear_logs() -> None: + """Clear all content contained in the logs' folder.""" + cli_clear_logs() + + +@cli.command() +def issue(issue_folder_path: Annotated[ + Optional[str], + typer.Option( + ISSUE_ISSUE_FOLDER_PATH_NAME, + ISSUE_ISSUE_FOLDER_PATH_SHORT_NAME, + help=ISSUE_ISSUE_FOLDER_PATH_HELP + ) +] = None) -> None: + """ + Create an issue file for the user. + + :param issue_folder_path: The path of the folder where the issue file should be created (optional) + """ + cli_issue(issue_folder_path) + + +@cli.command() +def remove_templates( + paths: Annotated[List[str] | None, typer.Argument(help=REMOVE_TEMPLATES_PATHS_TEMPLATES_HELP)] = None, + remove_all: Annotated[ + bool, + typer.Option( + REMOVE_TEMPLATES_REMOVE_ALL_TEMPLATES_NAME, + REMOVE_TEMPLATES_REMOVE_ALL_TEMPLATES_SHORT_NAME, + help=REMOVE_TEMPLATES_REMOVE_ALL_TEMPLATES_HELP + )] = False, +) -> None: + """ + Remove user's custom templates from Nexus Download Flow. + + :param paths: List of template paths to remove (optional) + :param remove_all: A boolean flag to remove all templates included in the templates folder (optional) + """ + cli_remove_templates(paths, remove_all) diff --git a/scripts/cli/issue/issue.py b/scripts/cli/issue/issue.py new file mode 100644 index 0000000..d4a5ac0 --- /dev/null +++ b/scripts/cli/issue/issue.py @@ -0,0 +1,101 @@ +"""Issue file generator command module.""" + +import logging +import os.path +import platform + +import psutil +import typer +from mss import mss + +from config.application_properties import PROJECT_VERSION +from config.definitions import ISSUE_TEMPLATE_FILE_PATH +from config.ndf_logging import logging_report, setup_logging, stop_logging + +__DEFAULT_ISSUE_FILE_NAME: str = "" +__DEFAULT_ISSUE_FILE_EXTENSION: str = ".txt" +__ISSUE_FILE_COMPLETE_NAME: str = __DEFAULT_ISSUE_FILE_NAME + __DEFAULT_ISSUE_FILE_EXTENSION + +# Messages +__ISSUE_TITLE_PROMPT_MESSAGE: str = "Please enter a title for your issue" +__ISSUE_DESCRIPTION_PROMPT_MESSAGE: str = "Please describe your issue" +__ISSUE_FILE_PATH_IS_NOT_A_DIRECTORY_WARNING_MESSAGE: str = ( + "Given option --issue-folder-path is not a directory. Creating issue text file at the executable location." +) +__STARTING_MESSAGE: str = "Initiate the creation of an issue file..." +__SUCCESS_MESSAGE: str = "The issue file was successfully created." + + +def cli_issue(issue_folder_path: str | None = None) -> None: + """Create the issue text file to copy/paste to our repository on GitHub.""" + setup_logging() + logging.info(__STARTING_MESSAGE) + try: + output_file_path: str = __get_issue_file_path(issue_folder_path) + + issue_title: str = typer.prompt("Please enter a title for your issue") + issue_description: str = typer.prompt("Please describe your issue") + system_info: dict[str, str] = __get_user_system_info() + ndf_version: str = PROJECT_VERSION + + filled_issue_content: str = __fill_issue_template( + issue_title=issue_title, ndf_version=ndf_version, issue_description=issue_description, **system_info + ) + + __write_issue_file(output_file_path, filled_issue_content) + print(filled_issue_content) + logging.info(__SUCCESS_MESSAGE) + except FileNotFoundError as e: + logging.error(e) + except Exception as e: + logging.error(e) + logging_report() + finally: + stop_logging() + + +def __get_user_system_info() -> dict[str, str]: + """Gather and return system information needed for the issue report.""" + user_operating_system = f"{platform.system()} {platform.release()}" + user_operating_system_architecture = platform.architecture()[0] + user_system_ram_capacity = str(round(psutil.virtual_memory().total / (1024**3), 2)) + + with mss() as mss_instance: + monitors = mss_instance.monitors[1:] + user_monitors_count = str(len(monitors)) + user_monitors_resolutions = " - ".join( + [f'{monitor.get("width")}x{monitor.get("height")}' for monitor in monitors] + ) + + return { + "user_operating_system": user_operating_system, + "user_operating_system_architecture": user_operating_system_architecture, + "user_system_ram_capacity": user_system_ram_capacity, + "user_monitors_count": user_monitors_count, + "user_monitors_resolutions": user_monitors_resolutions, + } + + +def __get_issue_file_path(issue_folder_path: str | None = __ISSUE_FILE_COMPLETE_NAME) -> str: + """Determine the output file path for the issue file.""" + if issue_folder_path is not None and os.path.isdir(issue_folder_path): + return os.path.join(issue_folder_path, __ISSUE_FILE_COMPLETE_NAME) + logging.warning(__ISSUE_FILE_PATH_IS_NOT_A_DIRECTORY_WARNING_MESSAGE) + return __ISSUE_FILE_COMPLETE_NAME + + +def __get_issue_template_content() -> str: + """Read and return the content of the issue template file.""" + with open(ISSUE_TEMPLATE_FILE_PATH, "r") as issue_template_content: + return issue_template_content.read() + + +def __fill_issue_template(**kwargs: str) -> str: + """Fill the issue template with provided details and return the filled content.""" + return __get_issue_template_content().format(**kwargs) + + +def __write_issue_file(output_file_path: str, issue_content: str) -> None: + """Write the filled issue content to a file.""" + with open(output_file_path, "w") as issue_file: + issue_file.write(issue_content) diff --git a/scripts/cli/remove_templates/remove_templates.py b/scripts/cli/remove_templates/remove_templates.py new file mode 100644 index 0000000..66f63bd --- /dev/null +++ b/scripts/cli/remove_templates/remove_templates.py @@ -0,0 +1,84 @@ +"""Remove template command module.""" + +import logging +import ntpath +import os +import shutil +from typing import List + +from _global.constants.messages import ( + CUSTOM_TEMPLATES_DIRECTORY_IS_NOT_A_DIRECTORY_ERROR_MESSAGE, + CUSTOM_TEMPLATES_FOLDER_DOES_NOT_EXIST_WARNING_MESSAGE, + FILE_PATH_INVALID_FILE_ERROR_MESSAGE, + NO_ACTION_REQUIRED_MESSAGE, +) +from config.definitions import CUSTOM_TEMPLATES_DIRECTORY_PATH +from config.ndf_logging import logging_report, setup_logging, stop_logging + +__PATHS_NO_PATH_GIVEN_WARNING_MESSAGE: str = f"No path given in input. {NO_ACTION_REQUIRED_MESSAGE}" +__DELETE_CUSTOM_TEMPLATES_FOLDER_START_MESSAGE: str = "Initiate custom_templates folder deletion..." +__DELETE_CUSTOM_TEMPLATES_FOLDER_SUCCESS_MESSAGE: str = "The custom templates folder has been successfully deleted!" +__GIVEN_PATH_NOT_IN_CUSTOM_TEMPLATES_DIRECTORY: str = ( + "File linked to given path '{path}' is not in custom_templates folder." +) +__DELETE_CUSTOM_TEMPLATE_FILE_START_MESSAGE: str = "Initiate '{filename}' file deletion..." +__DELETE_CUSTOM_TEMPLATE_FILE_SUCCESS_MESSAGE: str = ( + "The custom template file named '{filename}' has been successfully deleted!" +) + + +def cli_remove_templates(paths: List[str] | None, remove_all: bool = False) -> None: + """Delete custom templates' directory.""" + setup_logging() + try: + if remove_all: + __delete_custom_templates_folder() + return + if not os.path.exists(CUSTOM_TEMPLATES_DIRECTORY_PATH): + logging.warning(CUSTOM_TEMPLATES_FOLDER_DOES_NOT_EXIST_WARNING_MESSAGE) + return + if paths: + for path in paths: + __delete_custom_template_file(path) + else: + logging.warning(__PATHS_NO_PATH_GIVEN_WARNING_MESSAGE) + except NotADirectoryError as e: + logging.error(e) + except Exception as e: + logging.error(e) + logging_report() + finally: + stop_logging() + + +def __delete_custom_templates_folder() -> None: + """Delete all custom template files.""" + logging.info(__DELETE_CUSTOM_TEMPLATES_FOLDER_START_MESSAGE) + if os.path.exists(CUSTOM_TEMPLATES_DIRECTORY_PATH): + if os.path.isdir(CUSTOM_TEMPLATES_DIRECTORY_PATH): + shutil.rmtree(CUSTOM_TEMPLATES_DIRECTORY_PATH) + logging.info(__DELETE_CUSTOM_TEMPLATES_FOLDER_SUCCESS_MESSAGE) + else: + raise NotADirectoryError(CUSTOM_TEMPLATES_DIRECTORY_IS_NOT_A_DIRECTORY_ERROR_MESSAGE) + else: + logging.warning(CUSTOM_TEMPLATES_FOLDER_DOES_NOT_EXIST_WARNING_MESSAGE) + + +def __delete_custom_template_file(path: str) -> None: + """Delete one custom template file.""" + filename: str = ntpath.basename(path) + logging.info(__DELETE_CUSTOM_TEMPLATE_FILE_START_MESSAGE.format(filename=filename)) + try: + if os.path.isfile(path) or os.path.islink(path): + if CUSTOM_TEMPLATES_DIRECTORY_PATH in path: + os.unlink(path) + logging.info(__DELETE_CUSTOM_TEMPLATE_FILE_SUCCESS_MESSAGE.format(filename=filename)) + else: + raise ValueError(__GIVEN_PATH_NOT_IN_CUSTOM_TEMPLATES_DIRECTORY.format(path=path)) + else: + raise ValueError(FILE_PATH_INVALID_FILE_ERROR_MESSAGE.format(file_path=path)) + except ValueError as e: + logging.error(e) + except Exception as e: + logging.error(e) + logging_report() diff --git a/scripts/cli/run/run.py b/scripts/cli/run/run.py new file mode 100644 index 0000000..c1f104a --- /dev/null +++ b/scripts/cli/run/run.py @@ -0,0 +1,316 @@ +"""Run command module.""" + +import logging +import os +from time import sleep +from typing import Sequence, cast + +import cv2 +import keyboard +from cv2.typing import MatLike +from mss import mss +from pyautogui import FAILSAFE_POINTS, FailSafeException, Point, leftClick, moveTo, position + +from _global.constants.messages import ( + CUSTOM_TEMPLATES_FOLDER_DOES_NOT_EXIST_WARNING_MESSAGE, + CUSTOM_TEMPLATES_FOLDER_EMPTY_WARNING_MESSAGE, +) +from config.definitions import CUSTOM_TEMPLATES_DIRECTORY_PATH, SCREENSHOT_PATH, TEMPLATE_MATCHING_DIRECTORY_PATH +from config.ndf_logging import logging_report, setup_logging, stop_logging +from scripts.cli.run.run_mode_enum import RunModeEnum + +__EDGE_MIN_VALUE: int = 50 +__EDGE_MAX_VALUE: int = 200 + +__SCALES: list[float] = [ + 1.0, + 0.95789474, + 0.91578947, + 0.87368421, + 0.83157895, + 0.78947368, + 0.74736842, + 0.70526316, + 0.66315789, + 0.62105263, + 0.57894737, + 0.53684211, + 0.49473684, + 0.45263158, + 0.41052632, + 0.36842105, + 0.32631579, + 0.28421053, + 0.24210526, + 0.2, +] + +__DEFAULT_TEMPLATES: list[MatLike] = [ + cv2.imread(os.path.join(TEMPLATE_MATCHING_DIRECTORY_PATH, "template1.png")), + cv2.imread(os.path.join(TEMPLATE_MATCHING_DIRECTORY_PATH, "template2.png")), + cv2.imread(os.path.join(TEMPLATE_MATCHING_DIRECTORY_PATH, "template3.png")), +] + +__THRESHOLD: float = 0.65 + +__RUN_STARTING_MESSAGE: str = "NexusDownloadFlow is starting..." +__RUNNING_MESSAGE: str = "NexusDownloadFlow is running in {mode} mode." +__PAUSE_NDF_MESSAGE: str = "NexusDownloadFlow is now paused..." +__RESUME_NDF_MESSAGE: str = "NexusDownloadFlow has resumed..." +__STOPPING_NDF_MESSAGE: str = "Stopping NexusDownloadFlow..." +__EXITING_INFO_MESSAGE: str = "Exiting the program..." +__FAILSAFE_ERROR_MESSAGE: str = "Fail-safe triggered from mouse moving to a corner of the screen." +__SCREENSHOT_DOES_NOT_EXIST_MESSAGE: str = "The screenshot does not exist." +__PROGRAM_ENDED_MESSAGE: str = "Program ended." + +__CUSTOM_RUN_NO_CUSTOM_TEMPLATE_FOUND_ERROR_MESSAGE: str = ( + "No custom template found. Please add a custom template with the `add-template` command before trying again." +) + +is_running: bool = False +is_paused: bool = False + + +def cli_run(mode: str) -> None: + """ + Run the auto-downloader. + + :raises KeyboardInterrupt: Raised when the user interrupts the program. + :raises FailSafeException: Raised when the mouse position is on one of the corners of the screen. + Should not be raised (open an issue on GitHub if it happens). + :raises ValueError: Should not be raised (open an issue on GitHub if it happens). + :raises Exception: For currently unknown exceptions (open an issue on GitHub if it happens). + """ + setup_logging() + logging.info(__RUN_STARTING_MESSAGE) + try: + match mode: + case RunModeEnum.CLASSIC: + classic_run() + case RunModeEnum.CUSTOM: + custom_run() + case RunModeEnum.HYBRID: + hybrid_run() + case _: + hybrid_run() + except KeyboardInterrupt: + logging.info(__EXITING_INFO_MESSAGE) + except FailSafeException: + logging.error(__FAILSAFE_ERROR_MESSAGE) + except ValueError as e: + logging.error(e) + logging_report() + except Exception as e: + logging.error(e) + logging_report() + finally: + if os.path.exists(SCREENSHOT_PATH): + os.remove(SCREENSHOT_PATH) + else: + logging.warning(__SCREENSHOT_DOES_NOT_EXIST_MESSAGE) + logging.info(__PROGRAM_ENDED_MESSAGE) + stop_logging() + input("Press any key to exit...") + + +def classic_run() -> None: + """Launch classic execution method using built-in templates.""" + logging.info(__RUNNING_MESSAGE.format(mode=RunModeEnum.CLASSIC)) + launch_ndf(__DEFAULT_TEMPLATES) + + +def custom_run() -> None: + """Launch custom execution method using user-provided templates.""" + logging.info(__RUNNING_MESSAGE.format(mode=RunModeEnum.CUSTOM)) + custom_templates: list[MatLike] = __get_custom_templates() + if custom_templates: + launch_ndf(custom_templates) + return + logging.error(__CUSTOM_RUN_NO_CUSTOM_TEMPLATE_FOUND_ERROR_MESSAGE) + + +def hybrid_run() -> None: + """Launch hybrid execution method using built-in and custom templates.""" + logging.info(__RUNNING_MESSAGE.format(mode=RunModeEnum.HYBRID)) + hybrid_templates: list[MatLike] = __DEFAULT_TEMPLATES + __get_custom_templates() + launch_ndf(hybrid_templates) + + +def launch_ndf(templates: list[MatLike]) -> None: + """Launch the auto-downloader.""" + global is_running, is_paused + is_running = True + + __init_hotkeys() + + edged_templates: list[MatLike] = __get_edged_templates(templates) + with mss() as mss_instance: + while is_running: + __when_paused() + monitors_size: dict[str, int] = mss_instance.monitors[0] + monitors_left_top: tuple[int, int] = __if_monitors_left_top_present(monitors_size) + screenshot: MatLike = cv2.imread(next(mss_instance.save(mon=-1, output=SCREENSHOT_PATH))) + grayscale_screenshot: MatLike = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) + multiscale_match_template(edged_templates, grayscale_screenshot, monitors_left_top) + + +def multiscale_match_template( + templates: list[MatLike], screenshot: MatLike, left_top_coordinates: tuple[int, int] +) -> None: + """ + Apply multiscale template matching algorithm. + + :param templates: List of edged templates to match. + :param screenshot: Screenshot where the search is running. + :param left_top_coordinates: Left-top pixel of the system monitor(s). + """ + for scale in __SCALES: + resized_screenshot: MatLike = __resize_screenshot(screenshot, scale) + edged_screenshot: MatLike = cv2.Canny(resized_screenshot, 50, 200) + for template in templates: + potential_match: tuple[float, Sequence[int]] = __get_potential_match(edged_screenshot, template) + potential_match_value: float = potential_match[0] + potential_match_location: Sequence[int] = potential_match[1] + if __is_match_found(potential_match_value): + logging.info("Match found!") + match_location_x: int = potential_match_location[0] + match_location_y: int = potential_match_location[1] + match_left_top_location: tuple[int, int] = ( + match_location_x + left_top_coordinates[0], + match_location_y + left_top_coordinates[1], + ) + template_height: int = template.shape[0] + template_width: int = template.shape[1] + target: tuple[float, float] = ( + match_left_top_location[0] + template_width / 2, + match_left_top_location[1] + template_height / 2, + ) + __click_on_target(target) + sleep(6) + return + + +def pause_resume() -> None: + """Pause or resume the auto download process.""" + global is_paused + if is_paused: + is_paused = False + logging.info(__RESUME_NDF_MESSAGE) + else: + is_paused = True + logging.info(__PAUSE_NDF_MESSAGE) + + +def stop() -> None: + """Stop the auto download process.""" + global is_running, is_paused + is_running = False + is_paused = False + logging.info(__STOPPING_NDF_MESSAGE) + + +def __init_hotkeys() -> None: + """Initialize the hotkeys.""" + keyboard.add_hotkey("F3", pause_resume) + keyboard.add_hotkey("F4", stop) + + +def __when_paused() -> None: + """Do nothing while the auto download process is paused.""" + global is_paused + while is_paused: + continue + + +def __resize_screenshot(screenshot: MatLike, scale: float) -> MatLike: + """ + Resize the input screenshot. + + :param screenshot: Screenshot to cv2.resize. + :param scale: The scale factor to cv2.resize the screenshot. + :return: Resized screenshot. + """ + new_width: int = int(screenshot.shape[1] * scale) + new_height: int = int(screenshot.shape[0] * scale) + return cast(MatLike, cv2.resize(screenshot, (new_width, new_height))) + + +def __get_potential_match(screenshot: MatLike, template: MatLike) -> tuple[float, Sequence[int]]: + """ + Get the potential match value and its location. + + :param screenshot: Source for template matching. + :param template: Template to match. + :return: Tuple of potential match value and location. + """ + matches: MatLike = cv2.matchTemplate(screenshot, template, cv2.TM_CCOEFF_NORMED) + potential_match: tuple[float, float, Sequence[int], Sequence[int]] = cv2.minMaxLoc(matches) + max_value: float = potential_match[1] + max_location: Sequence[int] = potential_match[3] + return max_value, max_location + + +def __if_monitors_left_top_present(monitors_size: dict[str, int]) -> tuple[int, int]: + """ + Handle Optional of monitors_left_top (if_present like). + + :param monitors_size: Dictionary containing left and top properties of the system's monitor(s). + :raises ValueError: If any of the value is none. + :return: If present, tuple representing the left-top pixel's coordinates of the system's monitor(s). + """ + value_error_message: str = "Monitors size '{key}' value is None" + + monitors_left: int | None = monitors_size.get("left") + monitors_top: int | None = monitors_size.get("top") + if monitors_left is None: + raise ValueError(value_error_message.format(key="left")) + if monitors_top is None: + raise ValueError(value_error_message.format(key="top")) + return monitors_left, monitors_top + + +def __is_match_found(match_value: float) -> bool: + """ + Check if a match is found. + + :param match_value: Value of the match to check. + :return: Bool value indicating whether a match is found or not. + """ + return match_value > __THRESHOLD + + +def __click_on_target(target_location: tuple[float, float]) -> None: + """ + Click on the target that has been identified and move the cursor to its previous location. + + :param target_location: Tuple of target coordinates. + """ + original_position: Point | tuple[int, int] = position() + if original_position not in FAILSAFE_POINTS: + leftClick(target_location) + moveTo(original_position) + else: + logging.warning(f"Risk of fail-safe trigger: Mouse position is on a fail-safe point -> { original_position }") + logging.info("NexusDownloadFlow did not click on the target.") + + +def __get_custom_templates() -> list[MatLike]: + if not os.path.exists(CUSTOM_TEMPLATES_DIRECTORY_PATH): + logging.warning(CUSTOM_TEMPLATES_FOLDER_DOES_NOT_EXIST_WARNING_MESSAGE) + return [] + templates: list[MatLike] = [ + cv2.imread(custom_template) for custom_template in os.listdir(CUSTOM_TEMPLATES_DIRECTORY_PATH) + ] + if not templates: + logging.warning(CUSTOM_TEMPLATES_FOLDER_EMPTY_WARNING_MESSAGE) + return [] + return templates + + +def __get_edged_templates(templates: list[MatLike]) -> list[MatLike]: + """ + Return the list of edged templates. + + :return: List of edged templates. + """ + return [cv2.Canny(cv2.cvtColor(template, cv2.COLOR_BGR2GRAY), __EDGE_MIN_VALUE, __EDGE_MAX_VALUE) for template in templates] diff --git a/scripts/cli/run/run_mode_enum.py b/scripts/cli/run/run_mode_enum.py new file mode 100644 index 0000000..a6e573d --- /dev/null +++ b/scripts/cli/run/run_mode_enum.py @@ -0,0 +1,15 @@ +"""Enum containing the mode choices.""" +from enum import StrEnum, auto + + +class RunModeEnum(StrEnum): + """Enumeration representing the different execution modes available.""" + + CLASSIC: auto = auto() + """Classic execution mode with default parameters.""" + + CUSTOM: auto = auto() + """Custom execution mode with specific parameters.""" + + HYBRID: auto = auto() + """Hybrid mode combining classic and custom parameters.""" diff --git a/scripts/cli/version/version.py b/scripts/cli/version/version.py new file mode 100644 index 0000000..712cfe0 --- /dev/null +++ b/scripts/cli/version/version.py @@ -0,0 +1,8 @@ +"""Version command module.""" + +from config.application_properties import PROJECT_VERSION + + +def cli_version() -> None: + """Print the current version of the program.""" + print(f"v{ PROJECT_VERSION }") diff --git a/scripts/ndf_params.py b/scripts/ndf_params.py deleted file mode 100644 index 8f86290..0000000 --- a/scripts/ndf_params.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Parameters file.""" -import logging - - -def ask_to_keep_logfile() -> bool: - """ - Ask if the user wants to keep the log file. - - :return: Bool value representing whether to keep the log file or not. - True, if user's answer is "y" or "Y". - False, if user's answer is "n" or "N". - Will repeat if the input value is invalid. - """ - while True: - __keep: str = str(input("Would you like to save the logfile? (y/n)\n")) - match __keep: - case "y" | "Y": - logging.info("Logfile will be saved.") - return True - case "n" | "N": - logging.info("Logfile will be saved only if an exception/error occurred.") - return False - case _: - print("Please enter a valid value.") diff --git a/scripts/ndf_run.py b/scripts/ndf_run.py deleted file mode 100644 index 552e69d..0000000 --- a/scripts/ndf_run.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Run file.""" - -import logging -import os -from time import sleep -from typing import Sequence, cast - -from cv2 import COLOR_BGR2GRAY, TM_CCOEFF_NORMED, Canny, cvtColor, imread, matchTemplate, minMaxLoc, resize -from cv2.typing import MatLike -from mss import mss -from pyautogui import FAILSAFE_POINTS, FailSafeException, Point, leftClick, moveTo, position - -from config.definitions import ASSETS_DIRECTORY, SCREENSHOT_PATH -from config.ndf_logging import delete_logfile, get_logfile_path, logging_report -from scripts.ndf_params import ask_to_keep_logfile - -EDGE_MIN_VALUE: int = 50 -EDGE_MAX_VALUE: int = 200 -SCALES: list[float] = [ - 1.0, - 0.95789474, - 0.91578947, - 0.87368421, - 0.83157895, - 0.78947368, - 0.74736842, - 0.70526316, - 0.66315789, - 0.62105263, - 0.57894737, - 0.53684211, - 0.49473684, - 0.45263158, - 0.41052632, - 0.36842105, - 0.32631579, - 0.28421053, - 0.24210526, - 0.2, -] -TEMPLATES: list[MatLike] = [ - imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), - imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), - imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), -] -THRESHOLD: float = 0.65 - - -def click_on_target(target_location: tuple[float, float]) -> None: - """ - Click on the target that has been identified and move the cursor to its previous location. - - :param target_location: Tuple of target coordinates. - """ - original_position: Point | tuple[int, int] = position() - if original_position not in FAILSAFE_POINTS: - leftClick(target_location) - moveTo(original_position) - else: - logging.warning(f"Risk of fail-safe trigger: Mouse position is on a fail-safe point -> { original_position }") - logging.info("NexusDownloadFlow did not click on the target.") - - -def get_potential_match(screenshot: MatLike, template: MatLike) -> tuple[float, Sequence[int]]: - """ - Get the potential match value and its location. - - :param screenshot: Source for template matching. - :param template: Template to match. - :return: Tuple of potential match value and location. - """ - matches: MatLike = matchTemplate(screenshot, template, TM_CCOEFF_NORMED) - potential_match: tuple[float, float, Sequence[int], Sequence[int]] = minMaxLoc(matches) - max_value: float = potential_match[1] - max_location: Sequence[int] = potential_match[3] - return max_value, max_location - - -def if_monitors_left_top_present(monitors_size: dict[str, int]) -> tuple[int, int]: - """ - Handle Optional of monitors_left_top (if_present like). - - :param monitors_size: Dictionary containing left and top properties of the system's monitor(s). - :raises ValueError: If any of the value is none. - :return: If present, tuple representing the left-top pixel's coordinates of the system's monitor(s). - """ - - def error_message(_key: str) -> str: - return f"Monitors' size '{_key}' value is None." - - monitors_left: int | None = monitors_size.get("left") - monitors_top: int | None = monitors_size.get("top") - if monitors_left is None: - raise ValueError(error_message("left")) - if monitors_top is None: - raise ValueError(error_message("top")) - return monitors_left, monitors_top - - -def init_templates() -> list[MatLike]: - """ - Return the list of edged templates. - - :return: List of edged templates. - """ - return [Canny(cvtColor(template, COLOR_BGR2GRAY), EDGE_MIN_VALUE, EDGE_MAX_VALUE) for template in TEMPLATES] - - -def is_match_found(match_value: float) -> bool: - """ - Check if a match is found. - - :param match_value: Value of the match to check. - :return: Bool value indicating whether a match is found or not. - """ - return match_value > THRESHOLD - - -def resize_screenshot(screenshot: MatLike, scale: float) -> MatLike: - """ - Resize the input screenshot. - - :param screenshot: Screenshot to resize. - :param scale: The scale factor to resize the screenshot. - :return: Resized screenshot. - """ - new_width: int = int(screenshot.shape[1] * scale) - new_height: int = int(screenshot.shape[0] * scale) - return cast(MatLike, resize(screenshot, (new_width, new_height))) - - -def multiscale_match_template( - templates: list[MatLike], screenshot: MatLike, left_top_coordinates: tuple[int, int] -) -> None: - """ - Apply multiscale template matching algorithm. - - :param templates: List of edged templates to match. - :param screenshot: Screenshot where the search is running. - :param left_top_coordinates: Left-top pixel of the system monitor(s). - """ - for scale in SCALES: - resized_screenshot: MatLike = resize_screenshot(screenshot, scale) - edged_screenshot: MatLike = Canny(resized_screenshot, 50, 200) - for template in templates: - potential_match: tuple[float, Sequence[int]] = get_potential_match(edged_screenshot, template) - potential_match_value: float = potential_match[0] - potential_match_location: Sequence[int] = potential_match[1] - if is_match_found(potential_match_value): - logging.info("Match found!") - match_location_x: int = potential_match_location[0] - match_location_y: int = potential_match_location[1] - match_left_top_location: tuple[int, int] = ( - match_location_x + left_top_coordinates[0], - match_location_y + left_top_coordinates[1], - ) - template_height: int = template.shape[0] - template_width: int = template.shape[1] - target: tuple[float, float] = ( - match_left_top_location[0] + template_width / 2, - match_left_top_location[1] + template_height / 2, - ) - click_on_target(target) - sleep(6) - return - - -def run() -> None: - """Run the auto-downloader.""" - logging.info("NexusDownloadFlow is running.") - edged_templates: list[MatLike] = init_templates() - with mss() as mss_instance: - while True: - monitors_size: dict[str, int] = mss_instance.monitors[0] - monitors_left_top: tuple[int, int] = if_monitors_left_top_present(monitors_size) - screenshot: MatLike = imread(next(mss_instance.save(mon=-1, output=SCREENSHOT_PATH))) - grayscale_screenshot: MatLike = cvtColor(screenshot, COLOR_BGR2GRAY) - multiscale_match_template(edged_templates, grayscale_screenshot, monitors_left_top) - - -def try_run() -> None: - """ - Try to run the auto-downloader. - - :raises KeyboardInterrupt: Raised when the user interrupts the program. - :raises FailSafeException: Raised when the mouse position is on one of the corners of the screen. - Should not be raised (open an issue on GitHub if it happens). - :raises ValueError: Should not be raised (open an issue on GitHub if it happens). - :raises Exception: For currently unknown exceptions (open an issue on GitHub if it happens). - """ - keep_logfile: bool = False - try: - keep_logfile = ask_to_keep_logfile() - run() - except KeyboardInterrupt: - logging.info("Exiting the program...") - except FailSafeException: - logging.error("Fail-safe triggered from mouse moving to a corner of the screen.") - keep_logfile = True - except ValueError as e: - logging.error(e) - logging_report() - keep_logfile = True - except Exception as e: - logging.exception(e) - logging_report() - keep_logfile = True - finally: - if os.path.exists(SCREENSHOT_PATH): - os.remove(SCREENSHOT_PATH) - else: - logging.warning("The screenshot does not exist.") - logging.info("Program ended.") - if keep_logfile: - logging.info(f"Find logfile at: { get_logfile_path() }") - else: - delete_logfile() - input("Press any key to exit...") diff --git a/test/demo/ndf_1.0.0_template_matching_demo.py b/test/demo/ndf_1.0.0_template_matching_demo.py deleted file mode 100644 index c824b97..0000000 --- a/test/demo/ndf_1.0.0_template_matching_demo.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Test file is used to test NexusDownloadFlow's v1.0.0 algorithm. - -The algorithm used is the grayscale template matching and the TM_SQDIFF comparison method from OpenCV. -""" - -import os -import sys -import time -from typing import Any - -import cv2 -import pyautogui -from cv2.typing import MatLike -from mss import mss - -from config.definitions import ASSETS_DIRECTORY - -__SCREENSHOT: str = "screenshot.png" -__TEST_TEXT: str = "[TEST] [1.0.0] " -__THRESHOLD: int = 3000 - - -def __logging_test(text: str) -> None: - sys.stdout.write(__TEST_TEXT + text + "\n") - - -def __load_templates() -> list[MatLike]: - return [ - cv2.imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), - cv2.imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), - cv2.imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), - ] - - -def __init_templates() -> list[MatLike]: - return [cv2.cvtColor(template, cv2.COLOR_BGR2GRAY) for template in __load_templates()] - - -def __test_algorithm() -> None: - __logging_test("ndf-1.0.0-grayscale-template-matching.") - __logging_test("Comparaison method: TM_SQDIFF.") - try: - with mss() as mss_instance: - while True: - monitors_size: dict[str, int] = mss_instance.monitors[0] - monitors_left_top: tuple[Any, Any] = (monitors_size.get("left"), monitors_size.get("top")) - screenshot: MatLike = cv2.imread(next(mss_instance.save(mon=-1, output=__SCREENSHOT))) - screenshot = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) - for template in __init_templates(): - match_template: MatLike = cv2.matchTemplate(screenshot, template, cv2.TM_SQDIFF) - min_value, _, min_location, _ = cv2.minMaxLoc(match_template) - if min_value < __THRESHOLD: - __logging_test("Match found!") - match_left_top_location: tuple[int, int] = ( - min_location[0] + monitors_left_top[0], - min_location[1] + monitors_left_top[1], - ) - template_height, template_width = template.shape - target: tuple[float, float] = ( - match_left_top_location[0] + template_width / 2, - match_left_top_location[1] + template_height / 2, - ) - pyautogui.moveTo(target) - time.sleep(6) - break - except SystemExit: - __logging_test("Exiting the program...") - raise - finally: - if os.path.exists(__SCREENSHOT): - os.remove(__SCREENSHOT) - else: - __logging_test("The file does not exist") - __logging_test("Program ended") - - -if __name__ == "__main__": - __test_algorithm() diff --git a/test/demo/ndf_2.0.0-snapshot_template_matching_demo.py b/test/demo/ndf_2.0.0-snapshot_template_matching_demo.py deleted file mode 100644 index 0b90fbc..0000000 --- a/test/demo/ndf_2.0.0-snapshot_template_matching_demo.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Test file is used to test NexusDownloadFlow's v2.0.0-SNAPSHOT algorithm. - -The algorithm used is the edges template matching and the TM_CCOEFF_NORMED comparison method from OpenCV. -""" -import os -import sys -import time -from typing import Any - -import cv2 -import pyautogui -from cv2.typing import MatLike -from mss import mss - -from config.definitions import ASSETS_DIRECTORY - -__SCREENSHOT: str = "screenshot.png" -__TEST_TEXT: str = "[TEST] [2.0.0-SNAPSHOT] " -__THRESHOLD: float = 0.65 - - -def __logging_test(text: str) -> None: - sys.stdout.write(__TEST_TEXT + text + "\n") - - -def __load_templates() -> list[MatLike]: - return [ - cv2.imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), - cv2.imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), - cv2.imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), - ] - - -def __init_templates() -> list[MatLike]: - return [cv2.Canny(cv2.cvtColor(template, cv2.COLOR_BGR2GRAY), 50, 200) for template in __load_templates()] - - -def __test_algorithm() -> None: - __logging_test("ndf-2.0.0-snapshot-edges-template-matching.") - __logging_test("Comparaison method: TM_CCOEFF_NORMED.") - try: - with mss() as mss_instance: - while True: - monitors_size: dict[str, int] = mss_instance.monitors[0] - monitors_left_top: tuple[Any, Any] = (monitors_size.get("left"), monitors_size.get("top")) - screenshot: MatLike = cv2.imread(next(mss_instance.save(mon=-1, output=__SCREENSHOT))) - screenshot = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) - screenshot = cv2.Canny(screenshot, 50, 200) - for template in __init_templates(): - match_template: MatLike = cv2.matchTemplate(screenshot, template, cv2.TM_CCOEFF_NORMED) - _, max_value, _, max_location = cv2.minMaxLoc(match_template) - if max_value > __THRESHOLD: - __logging_test("Match found!") - match_left_top_location: tuple[int, int] = ( - max_location[0] + monitors_left_top[0], - max_location[1] + monitors_left_top[1], - ) - template_height, template_width = template.shape - target: tuple[float, float] = ( - match_left_top_location[0] + template_width / 2, - match_left_top_location[1] + template_height / 2, - ) - pyautogui.moveTo(target) - time.sleep(6) - break - except SystemExit: - __logging_test("Exiting the program...") - raise - finally: - if os.path.exists(__SCREENSHOT): - os.remove(__SCREENSHOT) - else: - __logging_test("The file does not exist") - __logging_test("Program ended") - - -if __name__ == "__main__": - __test_algorithm() diff --git a/test/demo/ndf_2.0.0_template_matching_demo.py b/test/demo/ndf_2.0.0_template_matching_demo.py deleted file mode 100644 index 501171c..0000000 --- a/test/demo/ndf_2.0.0_template_matching_demo.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Test file is used to test NexusDownloadFlow's v2.0.0 algorithm. - -The algorithm used is the multiscale template matching and the TM_CCOEFF_NORMED comparison method from OpenCV. -""" -import os -import sys -from time import sleep -from typing import Sequence, cast - -from cv2 import COLOR_BGR2GRAY, TM_CCOEFF_NORMED, Canny, cvtColor, imread, matchTemplate, minMaxLoc, resize -from cv2.typing import MatLike -from mss import mss -from pyautogui import moveTo - -from config.definitions import ASSETS_DIRECTORY - -__EDGE_MIN_VALUE: int = 50 -__EDGE_MAX_VALUE: int = 200 -__SCALES: list[float] = [ - 1.0, - 0.95789474, - 0.91578947, - 0.87368421, - 0.83157895, - 0.78947368, - 0.74736842, - 0.70526316, - 0.66315789, - 0.62105263, - 0.57894737, - 0.53684211, - 0.49473684, - 0.45263158, - 0.41052632, - 0.36842105, - 0.32631579, - 0.28421053, - 0.24210526, - 0.2, -] -__SCREENSHOT: str = "screenshot.png" -__TEMPLATES: list[MatLike] = [ - imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), - imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), - imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), -] -__TEST_TEXT: str = "[TEST] [2.0.0] " -__THRESHOLD: float = 0.65 - - -def __logging_test(text: str) -> None: - sys.stdout.write(__TEST_TEXT + text + "\n") - - -def __init_templates() -> list[MatLike]: - return [Canny(cvtColor(template, COLOR_BGR2GRAY), __EDGE_MIN_VALUE, __EDGE_MAX_VALUE) for template in __TEMPLATES] - - -def __resize_screenshot(screenshot: MatLike, scale: float) -> MatLike: - new_width: int = int(screenshot.shape[1] * scale) - new_height: int = int(screenshot.shape[0] * scale) - return cast(MatLike, resize(screenshot, (new_width, new_height))) - - -def __get_potential_match(screenshot: MatLike, template: MatLike) -> tuple[float, Sequence[int]]: - matches: MatLike = matchTemplate(screenshot, template, TM_CCOEFF_NORMED) - potential_match: tuple[float, float, Sequence[int], Sequence[int]] = minMaxLoc(matches) - max_value: float = potential_match[1] - max_location: Sequence[int] = potential_match[3] - return max_value, max_location - - -def __if_monitors_left_top_present(monitors_size: dict[str, int]) -> tuple[int, int]: - monitors_left: int | None = monitors_size.get("left") - monitors_top: int | None = monitors_size.get("top") - if monitors_left is None: - raise ValueError("monitors_size 'left' value is None") - if monitors_top is None: - raise ValueError("monitors_size 'top' value is None") - return monitors_left, monitors_top - - -def __is_match_found(match_value: float) -> bool: - return match_value > __THRESHOLD - - -def __multiscale_match_template( - templates: list[MatLike], screenshot: MatLike, left_top_coordinates: tuple[int, int] -) -> None: - for scale in __SCALES: - resized_screenshot: MatLike = __resize_screenshot(screenshot, scale) - edged_screenshot: MatLike = Canny(resized_screenshot, 50, 200) - for template in templates: - potential_match: tuple[float, Sequence[int]] = __get_potential_match(edged_screenshot, template) - potential_match_value: float = potential_match[0] - potential_match_location: Sequence[int] = potential_match[1] - if __is_match_found(potential_match_value): - __logging_test("Match found!") - match_location_x: int = potential_match_location[0] - match_location_y: int = potential_match_location[1] - match_left_top_location: tuple[int, int] = ( - match_location_x + left_top_coordinates[0], - match_location_y + left_top_coordinates[1], - ) - template_height: int = template.shape[0] - template_width: int = template.shape[1] - target: tuple[float, float] = ( - match_left_top_location[0] + template_width / 2, - match_left_top_location[1] + template_height / 2, - ) - moveTo(target) - sleep(6) - return - - -def __test_algorithm() -> None: - __logging_test("ndf-2.0.0-multiscale-template-matching.") - __logging_test("Comparaison method: TM_CCOEFF_NORMED.") - edged_templates: list[MatLike] = __init_templates() - try: - with mss() as mss_instance: - while True: - monitors_size: dict[str, int] = mss_instance.monitors[0] - monitors_left_top: tuple[int, int] = __if_monitors_left_top_present(monitors_size) - screenshot: MatLike = imread(next(mss_instance.save(mon=-1, output=__SCREENSHOT))) - grayscale_screenshot: MatLike = cvtColor(screenshot, COLOR_BGR2GRAY) - __multiscale_match_template(edged_templates, grayscale_screenshot, monitors_left_top) - except (SystemExit, KeyboardInterrupt): - __logging_test("Exiting the program...") - sys.exit(0) - finally: - if os.path.exists(__SCREENSHOT): - os.remove(__SCREENSHOT) - else: - __logging_test("The file does not exist") - __logging_test("Program ended") - - -if __name__ == "__main__": - __test_algorithm()