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()