diff --git a/.gitignore b/.gitignore index 7a56971..e8383c6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ __pycache__/ .flatpak-builder/ flatpak-build-dir/ +*.jpg diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index ba2d7d3..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,69 +0,0 @@ ---- -include: - - local: .gitlab-ci/versioning/gitversion.yml - - local: .gitlab-ci/git/create_tag.yml - -stages: - - build - - release - -gitversion: - extends: .versioning:gitversion - stage: .pre - tags: - - gitlab-org-docker - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch - -build: - stage: build - image: python:3.9.21 - tags: - - gitlab-org-docker - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch - needs: - - job: gitversion - artifacts: true - script: - - sed -i "s/0.0.1/${GitVersion_MajorMinorPatch}/" src/OptimaLab35/__init__.py - - cat src/OptimaLab35/__init__.py - - python3 -m pip install build - - python3 -m build --wheel --sdist -s src - artifacts: - paths: - - src/dist/* - expire_in: 1 day - -publish: - stage: release - image: python:3.9.21 - tags: - - gitlab-org-docker - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch - variables: - TWINE_USERNAME: "__token__" - TWINE_PASSWORD: $TWINE_API - needs: - - job: build - artifacts: true - script: - - python3 -m pip install twine - - python3 -m twine upload src/dist/* - -create_tag: - extends: .git:create_tag - stage: release - tags: - - gitlab-org-docker - variables: - VERSION: $GitVersion_SemVer - TOKEN: $GITLAB_TOKEN - needs: - - job: gitversion - artifacts: true - rules: - - if: $CI_COMMIT_TAG - when: never # Do not run this job when a tag is created manually - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when commits are pushed or merged to the default branch diff --git a/.gitlab-ci/git/create_tag.yml b/.gitlab-ci/git/create_tag.yml deleted file mode 100644 index 2c1afd7..0000000 --- a/.gitlab-ci/git/create_tag.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- - -.git:create_tag: - image: alpine:3.21 - variables: - GIT_STRATEGY: clone - GIT_DEPTH: 0 - GIT_LFS_SKIP_SMUDGE: 1 - VERSION: '' - TOKEN: '' # Token with push privileges - script: - - apk add git - - git remote set-url origin https://oauth2:$TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH - - git tag $VERSION - - git push origin tag $VERSION diff --git a/.gitlab-ci/versioning/gitversion.yml b/.gitlab-ci/versioning/gitversion.yml deleted file mode 100644 index dbbc149..0000000 --- a/.gitlab-ci/versioning/gitversion.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -.versioning:gitversion: - image: - name: mcr.microsoft.com/dotnet/sdk:9.0 - variables: - GIT_STRATEGY: clone - GIT_DEPTH: 0 # force a deep/non-shallow fetch need by gitversion - GIT_LFS_SKIP_SMUDGE: 1 - cache: [] # caches and before / after scripts can mess things up - script: - - | - dotnet tool install --global GitVersion.Tool --version 5.* - export PATH="$PATH:/root/.dotnet/tools" - - dotnet-gitversion -output buildserver - - # We could just collect the output file gitversion.properties (with artifacts:report:dotenv: gitversion.properties as it is already in DOTENV format, - # however it contains ~33 variables which unnecessarily consumes many of the 50 max DOTENV variables of the free GitLab version. - # Limits are higher for licensed editions, see https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportsdotenv - grep 'GitVersion_LegacySemVer=' gitversion.properties >> gitversion.env - grep 'GitVersion_SemVer=' gitversion.properties >> gitversion.env - grep 'GitVersion_FullSemVer=' gitversion.properties >> gitversion.env - grep 'GitVersion_Major=' gitversion.properties >> gitversion.env - grep 'GitVersion_Minor=' gitversion.properties >> gitversion.env - grep 'GitVersion_Patch=' gitversion.properties >> gitversion.env - grep 'GitVersion_MajorMinorPatch=' gitversion.properties >> gitversion.env - grep 'GitVersion_BuildMetaData=' gitversion.properties >> gitversion.env - artifacts: - reports: - # propagates variables into the pipeline level - dotenv: gitversion.env diff --git a/.woodpecker/woodpecker_ci.yml b/.woodpecker/woodpecker_ci.yml new file mode 100644 index 0000000..ba24276 --- /dev/null +++ b/.woodpecker/woodpecker_ci.yml @@ -0,0 +1,92 @@ +steps: + - name: gitversion + depends_on: [] # nothing start emititly + when: + event: push + branch: main + image: mcr.microsoft.com/dotnet/sdk:9.0 + environment: + CI_TOKEN: + from_secret: CI_TOKEN + commands: + - git remote set-url origin https://CodeByMrFinchum:$CI_TOKEN@code.boxyfoxy.net/$CI_REPO.git + - git fetch --unshallow --tags + - apt-get update && apt-get install -y jq + - dotnet tool install --global GitVersion.Tool --version 5.* + - export PATH="$PATH:/root/.dotnet/tools" + - dotnet-gitversion -output json > version.json + - ls + - cat version.json + - | + echo "GitVersion_SemVer=$(jq -r '.SemVer' version.json)" >> gitversion.env + echo "GitVersion_LegacySemVer=$(jq -r '.LegacySemVer' version.json)" >> gitversion.env + echo "GitVersion_FullSemVer=$(jq -r '.FullSemVer' version.json)" >> gitversion.env + echo "GitVersion_Major=$(jq -r '.Major' version.json)" >> gitversion.env + echo "GitVersion_Minor=$(jq -r '.Minor' version.json)" >> gitversion.env + echo "GitVersion_Patch=$(jq -r '.Patch' version.json)" >> gitversion.env + echo "GitVersion_MajorMinorPatch=$(jq -r '.MajorMinorPatch' version.json)" >> gitversion.env + echo "GitVersion_BuildMetaData=$(jq -r '.BuildMetaData' version.json)" >> gitversion.env + + - name: tagging + depends_on: [gitversion] + when: + event: push + branch: main + image: alpine/git + environment: + CI_TOKEN: + from_secret: CI_TOKEN + commands: + - ls + - cat gitversion.env + - git config --global user.email "ci@noreply.boxyfoxy.net" + - git config --global user.name "CI Bot" + - git remote set-url origin https://CodeByMrFinchum:$${CI_TOKEN}@code.boxyfoxy.net/$${CI_REPO}.git + - . gitversion.env + - git tag $GitVersion_SemVer + - git push origin tag $GitVersion_SemVer + + - name: build + depends_on: [gitversion, tagging] + when: + event: push + branch: main + image: python:3.9.21 + commands: + - ls + - cat gitversion.env + - export $(cat gitversion.env | xargs) + - sed -i "s/0.0.1/$GitVersion_SemVer/" src/OptimaLab35/__init__.py + - cat src/OptimaLab35/__init__.py + - python3 -m pip install build + - python3 -m build --wheel --sdist -s src + + - name: publish_pypi + depends_on: [gitversion, tagging, build] + when: + event: push + branch: main + image: python:3.9.21 + environment: + TWINE_PASSWORD: + from_secret: TWINE_API + TWINE_USERNAME: "__token__" + commands: + - ls + - python3 -m pip install twine + - python3 -m twine upload src/dist/* + + - name: publish_forgejo + depends_on: [gitversion, tagging, build] + when: + event: push + branch: main + image: python:3.9.21 + environment: + TWINE_PASSWORD: + from_secret: PKG_TOKEN + TWINE_USERNAME: "CodeByMrFinchum" + commands: + - ls + - python3 -m pip install twine + - python3 -m twine upload --repository-url https://code.boxyfoxy.net/api/packages/CodeByMrFinchum/pypi src/dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index baa5da1..a9b2781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,124 @@ # Changelog +## 1.5.x +### 1.5.0: Feature - Time of dateEdit now today +- Changes that instead of the dateEdit elements being always set to a last day of 2024 it is the current day. + +## 1.4.x +### 1.4.2: Fix links +- Fixed that changelog was linked to GitLab, not it is to code.boxyfoxy.net +- Fixed Changelog + +### 1.4.1: Fix CI +- Fixed pipline +### 1.4.0: New CI +- Migrated repo from GitLab to my forgejo instance, therefore switching to woodpecker CI + +--- + +## 1.3.x +### 1.3.4: Fix - Spelling (25.04.01) +- Fixed misspelling in preview window. + +### 1.3.3: Patch - Increased Preview Performance x2 (25.04.01) +- Reduced preview stutter: Previously, the image was updated twice when changing brightness or contrast. Now, it updates only once, improving performance by 100%. +- There is still room for improvement. Analysis shows that image processing takes the most time, while displaying in Qt is relatively fast. Reducing the image size impacts performance, so resizing to 50% is a good idea. +- There is an issue where `QRunner` does not always finish in the correct order when brightness or contrast values are changed rapidly. ATM I do not know how to fix this easly. +- The "Preview" watermark now displays the brightness and contrast levels of the shown image. + +### 1.3.2: Fix - Fixed problem with app folders (25.03.30) +- Fixed a problem that the app folder path was not generated correctly. + +### 1.3.1: Fix - Fixed insert exif not working (25.03.27) +- Fixed the feature that inserted exif into images without modifying them. + +### 1.3.0: Feature - Write Adjustments into EXIF (25.03.24) +- Changes to contrast and brightness are now recorded in the EXIF comment section (labeled *Scanner* in the program). +- This allows users to track what adjustments were made to an image. + +--- + +## 1.2.x +### 1.2.2: Patch - Pyproject file (25.03.23) +- Fixed `Development Status` Classifier +- Added <4.0 python version + +### 1.2.1: Patch - Changelog file (25.03.23) +- Patches formation in changelog file. + +### 1.2.0: Refactor - Splitting Classes into Separate Files (25.03.23) +- Refactored `gui.py`, which previously contained almost all the code, into multiple files. +- Each window's logic is now in its own file, improving code organization. +- Window layouts remain in `.ui` folder, while their logic is now properly separated. + +## 1.1.x +### 1.1.0: Feature - New Function in Preview Window (25.03.23) +- Added a new feature to the preview window: **Hold a button to temporarily view the original (unedited) image.** This makes it easier to compare changes. +- Minor UI adjustments. + +--- + +## 1.0.x (25.03.06) +### 1.0.1: Fixed spelling +- Fixes spelling some places + +### 1.0.0: Fix version bump +- Version was not bumped correctly + +--- + +## 0.15.1: Final Polish (25.03.06) +- Fixed a bug where the GPS field being empty but selected caused issues. +- EXIF insertion is now canceled if any image in the folder does not end with a number. +- Minor GUI adjustments for a more polished experience. +- Disabled preview adjustment controls until an image is loaded to prevent errors. + +## 0.15.0: Preview Image Resizing Update (25.02.12) +- Added the ability to change the preview image size dynamically. +- Previously, the image was processed and displayed at its original size, causing lag or unresponsiveness when adjusting the slider. +- Reducing the processed image size should help improve performance. +- Default preview image size is now **25%**, but users can adjust it between **10% and 100%**. + +--- + +## 0.14.x +### 0.14.1: Patch changelog (25.02.12) +- Updated the changelog to include missing entries. + +### 0.14.0: Code refactor (by Mr. Finch) (25.02.11) +- Introduced constants and optimized the code. + +## 0.13.x (25.02.11) +### 0.13.1: Fixed image processing +- Fixed a bug that made it impossible to process images. + +### 0.13.0: Requirements file (by Mr. Finch) +- Added a requirements file. + +--- ## 0.12.x -### 0.12.6: Disabled app restart on Windows +### 0.12.6: Disabled app restart on Windows (25.02.11) - The app can not restart properly on Windows, so the restart button has been hidden when the OS is `nt`. - Also updated tool tip to indicate that changing theme requeres are restart. -### 0.12.5: Fixed EXIF File Generation Bug +### 0.12.5: Fixed EXIF File Generation Bug (25.02.10) - Fixed a bug where the application failed to generate a new default EXIF file if none was available. Now, the file is correctly created when missing. -### 0.12.4: Updated README +### 0.12.4: Updated README (25.02.10) - The README file (project description) now includes updates description and screenshots. -### 0.12.3: UI Adjustments +### 0.12.3: UI Adjustments (25.02.10) - Minor changes to maintain a unified layout across all windows. - Added option to sync app theme with OS (if supported). - Set auto theme as the default value. -### 0.12.2: Minor UI Improvements for Theme Compatibility +### 0.12.2: Minor UI Improvements for Theme Compatibility (25.02.10) - Fixed text clipping issues when using the new theme options. -### 0.12.1: Removed Unnecessary Debug Prints +### 0.12.1: Removed Unnecessary Debug Prints (25.02.09) - Removed leftover debug statements. -### 0.12.0: New Settings Menu & Patches +### 0.12.0: New Settings Menu & Patches (25.02.09) - **New Settings Window:** - The updater window has been reworked into a settings window. - **Initial settings (first tab) include:** @@ -35,9 +131,10 @@ - Fixed an issue where links in labels (About window) did not open a browser. - Added a changelog link in the About window. - Minor changes to `utility.py` to handle settings. + --- -## 0.11.x +## 0.11.x (25.02.05) ### 0.11.1: Fixed pipeline - Fixed pipeline publish error @@ -49,7 +146,7 @@ --- -## 0.10.x +## 0.10.x (25.02.04) ### 0.10.1: Fixed Updater - Fixed an issue where the updater was permanently disabled. @@ -87,7 +184,7 @@ - Improved error handling for updater: now displays the specific error message instead of just **"error"** when an issue occurs during update checks. - Ensured all child windows close when the main window is closed. -### 0.8.3: Fix – OptimaLab35 Not Closing After Update +### 0.8.3: Fix - OptimaLab35 Not Closing After Update - Fixed an issue where **OptimaLab35** would not close properly when updating, resulting in an unresponsive instance and multiple running processes. ### 0.8.2: Patch for New PyPiUpdater Version diff --git a/README.md b/README.md index 89a6160..22efff4 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,17 @@ # **OptimaLab35** -_Last updated: 10 Feb 2025 (v.0.12.3)_ +Developed on my [forgejo instance](https://code.boxyfoxy.net/CodeByMrFinchum), [GitLab](https://gitlab.com/CodeByMrFinchum) is used as backup. ## **Overview** **OptimaLab35** enhances **OPTIMA35** (**Organizing, Processing, Tweaking Images, and Modifying scanned Analogs from 35mm Film**) by offering a user-friendly graphical interface for efficient image and metadata management. -It serves as a GUI for the [OPTIMA35 library](https://gitlab.com/CodeByMrFinchum/optima35), providing an intuitive way to interact with the core functionalities. +It serves as a GUI for the [optima35 library](https://code.boxyfoxy.net/CodeByMrFinchum/optima35), providing an intuitive way to interact with the core functionalities. --- ## **Current Status** -### **Early Development** -OptimaLab35 is actively developed using **PySide6** and **Qt**, providing a modern interface for **OPTIMA35**. - -The program is still in its early stages, and features may change drastically over time. Some features might be added but later removed if they don't prove useful. Expect significant changes to the UI and functionality between updates. - -For the most accurate and detailed update information, please refer to the [**CHANGELOG**](https://gitlab.com/CodeByMrFinchum/OptimaLab35/-/blob/main/CHANGELOG.md) as the readme might lack behind. +The program has reached a stable release. All functions have been tested, and there should be *no* bugs. +While there is always room for additional features and optimizations, the core functionality is complete and reliable. --- @@ -53,40 +49,45 @@ pip install OptimaLab35 --- -## Preview GUI **0.12.2** -**PREVIEW** might be out of date. +## GUI Preview +The layout remains consistent with v1.0.0. +The UI is OS-dependent, but a custom theme can be enabled in the settings. **Main tab** -{width=40%} -{width=40%} +{width=40%} +{width=40%} **Exif tab** -{width=40%} -{width=40%} +{width=40%} +{width=40%} **Preview window** -{width=40%} -{width=40%} +{width=40%} +{width=40%} **Settings** -{width=40%} -{width=40%} +{width=40%} +{width=40%} **Updater** -{width=40%} -{width=40%} +{width=40%} +{width=40%} --- -# Use of LLMs +# Contribution + +Thanks to developer [Mr Finch](https://gitlab.com/MrFinchMkV) for contributing to this project. + +## Use of LLMs In the interest of transparency, I disclose that Generative AI (GAI) large language models (LLMs), including OpenAI’s ChatGPT and Ollama models (e.g., OpenCoder and Qwen2.5-coder), have been used to assist in this project. -## Areas of Assistance: +### Areas of Assistance: - Project discussions and planning - Spelling and grammar corrections - Suggestions for suitable packages and libraries @@ -98,7 +99,7 @@ In cases where LLMs contribute directly to code or provide substantial optimizat - mradermacher gguf Q4K-M Instruct version of infly/OpenCoder-1.5B - unsloth gguf Q4K_M Instruct version of both Qwen/QWEN2 1.5B and 3B -### References +#### References 1. **Huang, Siming, et al.** *OpenCoder: The Open Cookbook for Top-Tier Code Large Language Models.* 2024. [PDF](https://arxiv.org/pdf/2411.04905) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 468cd9b..0000000 --- a/TODO.md +++ /dev/null @@ -1,6 +0,0 @@ -# TODO - -## Improvments -### Preview window -Improve performence by changing when the images is updated. -Right now, when moving the slider, the image gets updated for every single step increase resulting in lagging diff --git a/media/previewwindow_dark.png b/media/previewwindow_dark.png index ff4b610..618340c 100644 Binary files a/media/previewwindow_dark.png and b/media/previewwindow_dark.png differ diff --git a/media/previewwindow_light.png b/media/previewwindow_light.png index a9d2da9..aaf3d4d 100644 Binary files a/media/previewwindow_light.png and b/media/previewwindow_light.png differ diff --git a/pip_README.md b/pip_README.md index f36b255..576c8bf 100644 --- a/pip_README.md +++ b/pip_README.md @@ -1,4 +1,11 @@ -OptimaLab35 is in active development, and even *stable* versions may produce errors in unexpected situations. It is a GUI for optima35. -Please visit [OptimaLab35](https://gitlab.com/CodeByMrFinchum/OptimaLab35/-/blob/main/media/exif_editor.png?ref_type=heads) gitlab page for more information. +**[OptimaLab35](https://code.boxyfoxy.net/CodeByMrFinchum/OptimaLab35)** is a graphical interface for the [optima35 library](https://code.boxyfoxy.net/CodeByMrFinchum/optima35), designed for efficient image and metadata management. -Uses [optima35](https://gitlab.com/CodeByMrFinchum/optima35) to modify images. +Developed on my [forgejo instance](https://code.boxyfoxy.net/CodeByMrFinchum), [GitLab](https://gitlab.com/CodeByMrFinchum) is used as backup. + +### **Features** +- Resize, adjust brightness/contrast, and convert images to grayscale +- Add customizable text-based watermarks +- Manage EXIF data: add, copy, remove, and adjust timestamps +- Preview image adjustments in real time +- Theme selection (light, dark, auto) +- Automatic updates via PyPI diff --git a/src/OptimaLab35/__main__.py b/src/OptimaLab35/__main__.py index 557cfbf..512c7b3 100644 --- a/src/OptimaLab35/__main__.py +++ b/src/OptimaLab35/__main__.py @@ -1,7 +1,30 @@ -from OptimaLab35 import gui, __version__ +import sys +from PySide6 import QtWidgets +from .utils.utility import Utilities +from .mainWindow import OptimaLab35 +from .const import ( + CONFIG_BASE_PATH +) def main(): - gui.main() + u = Utilities(CONFIG_BASE_PATH) + app_settings = u.load_settings() + app = QtWidgets.QApplication(sys.argv) + + try: + import qdarktheme + app_settings["theme"]["theme_pkg"] = True + except ImportError: + app_settings["theme"]["theme_pkg"] = False + + if app_settings["theme"]["use_custom_theme"] and app_settings["theme"]["theme_pkg"]: + qdarktheme.setup_theme(app_settings["theme"]["mode"].lower()) + + u.save_settings(app_settings) + + window = OptimaLab35() + window.show() + app.exec() if __name__ == "__main__": main() diff --git a/src/OptimaLab35/const.py b/src/OptimaLab35/const.py new file mode 100644 index 0000000..2dcfd9c --- /dev/null +++ b/src/OptimaLab35/const.py @@ -0,0 +1,2 @@ +APPLICATION_NAME = "OptimaLab35" +CONFIG_BASE_PATH = "~/.config/OptimaLab35" diff --git a/src/OptimaLab35/gui.py b/src/OptimaLab35/gui.py deleted file mode 100644 index 0986f5e..0000000 --- a/src/OptimaLab35/gui.py +++ /dev/null @@ -1,1003 +0,0 @@ -import sys -import os -from datetime import datetime -import importlib.resources as pkg_resources -try: - from OptimaLab35.ui import resources_rc - # keep the try for now -except Exception as e: - print(e) - -from PyPiUpdater import PyPiUpdater -from optima35.core import OptimaManager -from OptimaLab35.utils.utility import Utilities -from OptimaLab35.ui.main_window import Ui_MainWindow -from OptimaLab35.ui.preview_window import Ui_Preview_Window -from OptimaLab35.ui.settings_window import Ui_Settings_Window -from OptimaLab35.ui.exif_handler_window import ExifEditor -from OptimaLab35.ui.simple_dialog import SimpleDialog # Import the SimpleDialog class -from OptimaLab35 import __version__ - -from PySide6.QtCore import QRunnable, QThreadPool, Signal, QObject, QRegularExpression, Qt, QTimer, Slot, QDir - -from PySide6 import QtWidgets, QtCore -from PySide6.QtWidgets import ( - QMessageBox, - QApplication, - QMainWindow, - QWidget, - QVBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QCheckBox, - QFileDialog, - QHBoxLayout, - QSpinBox, - QProgressBar, -) - -from PySide6.QtGui import QPixmap, QRegularExpressionValidator, QIcon - -class OptimaLab35(QMainWindow, Ui_MainWindow): - def __init__(self): - super(OptimaLab35, self).__init__() - self.name = "OptimaLab35" - self.version = __version__ - self.o = OptimaManager() - self.u = Utilities(os.path.expanduser("~/.config/OptimaLab35")) - self.app_settings = self.u.load_settings() - self.thread_pool = QThreadPool() # multi thread ChatGPT - # Initiate internal object - self.exif_file = os.path.expanduser("~/.config/OptimaLab35/exif.yaml") - self.available_exif_data = None - self.settings = {} - # UI elements - self.ui = Ui_MainWindow() - self.ui.setupUi(self) - self.sd = SimpleDialog() - - # Change UI elements - self.change_statusbar(f"{self.name} v{self.version}", 10000) - self.set_title() - self.default_ui_layout() - self.define_gui_interaction() - - try: - self.setWindowIcon(QIcon(":app-icon.png")) - # keep the try for now - except Exception as e: - print(e) - - - # Init function - def default_ui_layout(self): - self.ui.png_quality_spinBox.setVisible(False) - self.ui.png_quality_Slider.setVisible(False) - self.ui.quality_label_2.setVisible(False) - - def set_title(self): - if self.version == "0.0.1": - title = f"{self.name} DEV MODE" - else: - title = self.name - self.setWindowTitle(title) - - def define_gui_interaction(self): - self.ui.input_folder_button.clicked.connect(self.browse_input_folder) - self.ui.output_folder_button.clicked.connect(self.browse_output_folder) - self.ui.start_button.clicked.connect(self.start_process) - self.ui.insert_exif_Button.clicked.connect(self.startinsert_exif) - self.ui.image_type.currentIndexChanged.connect(self.update_quality_options) - - self.ui.exif_checkbox.stateChanged.connect( - lambda state: self.handle_checkbox_state(state, 2, self.populate_exif) - ) - self.ui.tabWidget.currentChanged.connect(self.on_tab_changed) - self.ui.edit_exif_button.clicked.connect(self.open_exif_editor) - - self.ui.actionAbout.triggered.connect(self.info_window) - self.ui.preview_Button.clicked.connect(self.open_preview_window) - self.ui.actionSettings.triggered.connect(self.open_updater_window) - - regex = QRegularExpression(r"^\d{1,2}\.\d{1,6}$") - validator = QRegularExpressionValidator(regex) - self.ui.lat_lineEdit.setValidator(validator) - self.ui.long_lineEdit.setValidator(validator) - #layout.addWidget(self.ui.lat_lineEdit) - #layout.addWidget(self.ui.long_lineEdit) - - # UI related function, changing parts, open, etc. - def open_preview_window(self): - self.preview_window = PreviewWindow() - self.preview_window.values_selected.connect(self.update_values) - self.preview_window.showMaximized() - - def open_updater_window(self): - self.updater_window = SettingsWindow(self.version, self.o.version) - self.updater_window.show() - - def update_values(self, value1, value2, checkbox_state): - # Update main window's widgets with the received values - # ChatGPT - self.ui.brightness_spinBox.setValue(value1) - self.ui.contrast_spinBox.setValue(value2) - self.ui.grayscale_checkBox.setChecked(checkbox_state) - - def info_window(self): - info_text = f""" -
(C) 2024-2025 Mr Finchum aka CodeByMrFinchum
-{self.name} is a GUI for {self.o.name} (v{self.o.version}), enhancing its functionality with a user-friendly interface for efficient image and metadata management.
- -For more details, visit:
- - """ - - self.sd.show_dialog(f"{self.name} v{self.version}", info_text) - - def handle_qprogressbar(self, value): - self.ui.progressBar.setValue(value) - - def toggle_buttons(self, state): - self.ui.start_button.setEnabled(state) - if self.ui.exif_checkbox.isChecked(): - self.ui.insert_exif_Button.setEnabled(state) - - def handle_checkbox_state(self, state, desired_state, action): - """Perform an action based on the checkbox state and a desired state. Have to use lambda when calling.""" - if state == desired_state: - action() - - def on_tab_changed(self, index): - """Handle tab changes.""" - # chatgpt - if index == 1: # EXIF Tab - self.handle_exif_file("read") - elif index == 0: # Main Tab - self.handle_exif_file("write") - - def sort_dict_of_lists(self, input_dict): - # Partily ChatGPT - sorted_dict = {} - for key, lst in input_dict.items(): - # Sort alphabetically for strings, numerically for numbers - if key == "iso": - lst = [int(x) for x in lst] - lst = sorted(lst) - lst = [str(x) for x in lst] - sorted_dict["iso"] = lst - - elif all(isinstance(x, str) for x in lst): - sorted_dict[key] = sorted(lst, key=str.lower) # Case-insensitive sort for strings - - return sorted_dict - - def populate_comboboxes(self, combo_mapping): - """Populate comboboxes with EXIF data.""" - # ChatGPT - for field, comboBox in combo_mapping.items(): - comboBox.clear() # Clear existing items - comboBox.addItems(map(str, self.available_exif_data.get(field, []))) - - def open_exif_editor(self): - """Open the EXIF Editor.""" - self.exif_editor = ExifEditor(self.available_exif_data) - self.exif_editor.exif_data_updated.connect(self.update_exif_data) - self.exif_editor.show() - - def update_exif_data(self, updated_exif_data): - """Update the EXIF data.""" - self.exif_data = updated_exif_data - self.populate_exif() - - def populate_exif(self): - # partly chatGPT - # Mapping of EXIF fields to comboboxes in the UI - combo_mapping = { - "make": self.ui.make_comboBox, - "model": self.ui.model_comboBox, - "lens": self.ui.lens_comboBox, - "iso": self.ui.iso_comboBox, - "image_description": self.ui.image_description_comboBox, - "user_comment": self.ui.user_comment_comboBox, - "artist": self.ui.artist_comboBox, - "copyright_info": self.ui.copyright_info_comboBox, - } - - self.populate_comboboxes(combo_mapping) - - def update_quality_options(self): - """Update visibility of quality settings based on selected format.""" - # Partly ChatGPT - selected_format = self.ui.image_type.currentText() - # Hide all quality settings - self.ui.png_quality_spinBox.setVisible(False) - self.ui.jpg_quality_spinBox.setVisible(False) - self.ui.jpg_quality_Slider.setVisible(False) - self.ui.png_quality_Slider.setVisible(False) - self.ui.quality_label_1.setVisible(False) - self.ui.quality_label_2.setVisible(False) - # Show relevant settings - if selected_format == "jpg": - self.ui.jpg_quality_spinBox.setVisible(True) - self.ui.jpg_quality_Slider.setVisible(True) - self.ui.quality_label_1.setVisible(True) - elif selected_format == "webp": - self.ui.jpg_quality_spinBox.setVisible(True) - self.ui.jpg_quality_Slider.setVisible(True) - self.ui.quality_label_1.setVisible(True) - elif selected_format == "png": - self.ui.png_quality_spinBox.setVisible(True) - self.ui.png_quality_Slider.setVisible(True) - self.ui.quality_label_2.setVisible(True) - - def browse_input_folder(self): - folder = QFileDialog.getExistingDirectory(self, "Select Input Folder") - if folder: - self.ui.input_path.setText(folder) - - def browse_output_folder(self): - folder = QFileDialog.getExistingDirectory(self, "Select Output Folder") - if folder: - self.ui.output_path.setText(folder) - - def change_statusbar(self, msg, timeout = 500): - self.ui.statusBar.showMessage(msg, timeout) - - # Core functions - def on_processing_finished(self): - self.toggle_buttons(True) - self.handle_qprogressbar(0) - QMessageBox.information(self, "Information", "Finished!") - - def image_list_from_folder(self, path): - image_files = [ - f for f in os.listdir(path) if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp")) - ] - image_files.sort() - return image_files - - def control_before_start(self, process): - input_folder = self.settings["input_folder"] - output_folder = self.settings["output_folder"] - image_list = self.image_list_from_folder(input_folder) - input_folder_valid = os.path.exists(input_folder) - - if isinstance(output_folder, str): - output_folder_valid = os.path.exists(output_folder) - - if process == "image": - if not input_folder or not output_folder: - QMessageBox.warning(self, "Warning", "Input or output folder not selected") - return False - - if not input_folder_valid or not output_folder_valid: - QMessageBox.warning(self, "Warning", f"Input location {input_folder_valid}\nOutput folder {output_folder_valid}...") - return False - - if len(self.image_list_from_folder(output_folder)) != 0: - reply = QMessageBox.question( - self, - "Confirmation", - "Output folder containes images, which might get overritten, continue?", - QMessageBox.Yes | QMessageBox.No, - ) - - if reply == QMessageBox.No: - return False - - elif process == "exif": - - if not input_folder: - QMessageBox.warning(self, "Warning", "Input not selected") - return False - - if output_folder: - reply = QMessageBox.question( - self, - "Confirmation", - "Output folder selected, but insert exif is done to images in input folder, Continue?", - QMessageBox.Yes | QMessageBox.No, - ) - - if reply == QMessageBox.No: - return False - - if not input_folder_valid : - QMessageBox.warning(self, "Warning", f"Input location {input_folder_valid}") - return False - - else: - print("Something went wrong") - - if len(image_list) == 0: - QMessageBox.warning(self, "Warning", "Selected folder has no supported files.") - return False - - return True - - def start_process(self): - self.toggle_buttons(False) - u = self.update_settings() - if u != None: # Get all user selected data - QMessageBox.warning(self, "Warning", f"Error: {u}") - self.toggle_buttons(True) - return - - if self.control_before_start("image") == False: - self.toggle_buttons(True) - return - - image_list = self.image_list_from_folder(self.settings["input_folder"]) - # Create a worker ChatGPT - worker = ImageProcessorRunnable(image_list, self.settings, self.handle_qprogressbar) - worker.signals.finished.connect(self.on_processing_finished) - # Start worker in thread pool ChatGPT - self.thread_pool.start(worker) - - def insert_exif(self, image_files): - input_folder = self.settings["input_folder"] - - i = 1 - for image_file in image_files: - - input_path = os.path.join(input_folder, image_file) - - self.o.insert_exif_to_image( - exif_dict = self.settings["user_selected_exif"], - image_path = input_path, - gps = self.settings["gps"]) - self.change_statusbar(image_file, 100) - self.handle_qprogressbar(int((i / len(image_files)) * 100)) - i += 1 - - self.ui.progressBar.setValue(0) - - def startinsert_exif(self): - self.toggle_buttons(False) - u = self.update_settings() - if u != None: # Get all user selected data - QMessageBox.warning(self, "Warning", f"Error: {u}") - self.toggle_buttons(True) - return - - if self.control_before_start("exif") == False: - self.toggle_buttons(True) - return - - image_list = self.image_list_from_folder(self.settings["input_folder"]) - self.insert_exif(image_list) - - self.toggle_buttons(True) - QMessageBox.information(self, "Information", "Finished") - - def get_checkbox_value(self, checkbox, default = None): - """Helper function to get the value of a checkbox or a default value.""" - return checkbox.isChecked() if checkbox else default - - def get_spinbox_value(self, spinbox, default = None): - """Helper function to get the value of a spinbox and handle empty input.""" - return int(spinbox.text()) if spinbox.text() else default - - def get_combobox_value(self, combobox, default = None): - """Helper function to get the value of a combobox.""" - return combobox.currentIndex() + 1 if combobox.currentIndex() != -1 else default - - def get_text_value(self, lineedit, default = None): - """Helper function to get the value of a text input field.""" - return lineedit.text() if lineedit.text() else default - - def get_date(self): - date_input = self.ui.dateEdit.date().toString("yyyy-MM-dd") - new_date = datetime.strptime(date_input, "%Y-%m-%d") - return new_date.strftime("%Y:%m:%d 00:00:00") - - def collect_selected_exif(self): - user_data = {} - user_data["make"] = self.ui.make_comboBox.currentText() - user_data["model"] = self.ui.model_comboBox.currentText() - user_data["lens"] = self.ui.lens_comboBox.currentText() - user_data["iso"] = self.ui.iso_comboBox.currentText() - user_data["image_description"] = self.ui.image_description_comboBox.currentText() - user_data["user_comment"] = self.ui.user_comment_comboBox.currentText() - user_data["artist"] = self.ui.artist_comboBox.currentText() - user_data["copyright_info"] = self.ui.copyright_info_comboBox.currentText() - user_data["software"] = f"{self.name} (v{self.version}) with {self.o.name} (v{self.o.version})" - return user_data - - def get_selected_exif(self): - """Collect selected EXIF data and handle date and GPS if necessary.""" - selected_exif = self.collect_selected_exif() if self.ui.exif_checkbox.isChecked() else None - if selected_exif: - if self.ui.add_date_checkBox.isChecked(): - selected_exif["date_time_original"] = self.get_date() - if self.ui.gps_checkBox.isChecked(): - self.settings["gps"] = [float(self.ui.lat_lineEdit.text()), float(self.ui.long_lineEdit.text())] - else: - self.settings["gps"] = None - return selected_exif - - def check_selected_exif(self, exif): - for key in exif: - if len(exif[key]) == 0: - return f"{key} is empty" - return True - - def update_settings(self): - """Update .settings from all GUI elements.""" - # Basic - self.settings["input_folder"] = self.get_text_value(self.ui.input_path) - self.settings["output_folder"] = self.get_text_value(self.ui.output_path) - self.settings["file_format"] = self.ui.image_type.currentText() - # Quality - self.settings["jpg_quality"] = self.get_spinbox_value(self.ui.jpg_quality_spinBox) - self.settings["png_compression"] = self.get_spinbox_value(self.ui.png_quality_spinBox) - self.settings["resize"] = int(self.ui.resize_spinBox.text()) if self.ui.resize_spinBox.text() != "100" else None - self.settings["optimize"] = self.get_checkbox_value(self.ui.optimize_checkBox) - # Changes for image - self.settings["brightness"] = int(self.ui.brightness_spinBox.text()) if self.ui.brightness_spinBox.text() != "0" else None - self.settings["contrast"] = int(self.ui.contrast_spinBox.text()) if self.ui.contrast_spinBox.text() != "0" else None - self.settings["grayscale"] = self.get_checkbox_value(self.ui.grayscale_checkBox) - # Watermark - self.settings["font_size"] = self.get_combobox_value(self.ui.font_size_comboBox) - self.settings["watermark"] = self.get_text_value(self.ui.watermark_lineEdit) - # Naming - new_name = self.get_text_value(self.ui.filename, False) if self.ui.rename_checkbox.isChecked() else False - if isinstance(new_name, str): new_name = new_name.replace(" ", "_") - self.settings["new_file_names"] = new_name - self.settings["invert_image_order"] = self.get_checkbox_value(self.ui.revert_checkbox) if new_name is not False else None - # Handle EXIF data selection - self.settings["copy_exif"] = self.get_checkbox_value(self.ui.exif_copy_checkBox) - self.settings["own_exif"] = self.get_checkbox_value(self.ui.exif_checkbox) - self.settings["own_date"] = self.get_checkbox_value(self.ui.add_date_checkBox) - if self.settings["own_exif"]: - self.settings["user_selected_exif"] = self.get_selected_exif() - else: - self.settings["user_selected_exif"] = None - self.settings["gps"] = None - - if self.settings["user_selected_exif"] is not None: - u = self.check_selected_exif(self.settings["user_selected_exif"]) - if u != True: - return u - - # Helper functions, low level - def handle_exif_file(self, do): - # TODO: add check if data is missing. - if do == "read": - file_dict = self.u.read_yaml(self.exif_file) - self.available_exif_data = self.sort_dict_of_lists(file_dict) - elif do == "write": - self.u.write_yaml(self.exif_file, self.available_exif_data) - - def closeEvent(self, event): - QApplication.closeAllWindows() - event.accept() - -class SettingsWindow(QMainWindow, Ui_Settings_Window): - # Mixture of code by me, code/functions refactored by ChatGPT and code directly from ChatGPT - def __init__(self, optimalab35_localversion, optima35_localversion): - super(SettingsWindow, self).__init__() - self.ui = Ui_Settings_Window() - self.ui.setupUi(self) - self.u = Utilities(os.path.expanduser("~/.config/OptimaLab35")) - self.app_settings = self.u.load_settings() - self.dev_mode = True if optimalab35_localversion == "0.0.1" else False - from PyPiUpdater import PyPiUpdater - # Update log file location - self.update_log_file = os.path.expanduser("~/.config/OptimaLab35/update_log.json") - # Store local versions - self.optimalab35_localversion = optimalab35_localversion - self.optima35_localversion = optima35_localversion - - # Create PyPiUpdater instances - self.ppu_ol35 = PyPiUpdater("OptimaLab35", self.optimalab35_localversion, self.update_log_file) - self.ppu_o35 = PyPiUpdater("optima35", self.optima35_localversion, self.update_log_file) - self.ol35_last_state = self.ppu_ol35.get_last_state() - self.o35_last_state = self.ppu_o35.get_last_state() - - # Track which packages need an update - self.updates_available = {"OptimaLab35": False, "optima35": False} - - self.define_gui_interaction() - - def define_gui_interaction(self): - """Setup UI interactions.""" - # Updater related - self.ui.label_optimalab35_localversion.setText(self.optimalab35_localversion) - self.ui.label_optima35_localversion.setText(self.optima35_localversion) - - self.ui.label_latest_version.setText("Latest version") - self.ui.label_optimalab35_latestversion.setText("...") - self.ui.label_optima35_latestversion.setText("...") - - self.ui.update_and_restart_Button.setEnabled(False) - - # Connect buttons to functions - self.ui.check_for_update_Button.clicked.connect(self.check_for_updates) - self.ui.update_and_restart_Button.clicked.connect(self.update_and_restart) - self.ui.label_last_check.setText(f"Last check: {self.time_to_string(self.ol35_last_state[0])}") - self.ui.dev_widget.setVisible(False) - - # Timer for long press detection - self.timer = QTimer() - self.timer.setSingleShot(True) - self.timer.timeout.connect(self.toggle_dev_ui) - - # Connect button press/release - self.ui.check_for_update_Button.pressed.connect(self.start_long_press) - self.ui.check_for_update_Button.released.connect(self.cancel_long_press) - self.ui.label_5.setText('(C) 2024-2025 Mr Finchum aka CodeByMrFinchum
+{self.name} is a GUI for {self.o.name} (v{self.o.version}), enhancing its functionality with a user-friendly interface for efficient image and metadata management.
+ +For more details, visit:
+ + """ + + self.sd.show_dialog(f"{self.name} v{self.version}", info_text) + + def handle_qprogressbar(self, value): + self.ui.progressBar.setValue(value) + + def toggle_buttons(self, state): + self.ui.start_button.setEnabled(state) + if self.ui.exif_checkbox.isChecked(): + self.ui.insert_exif_Button.setEnabled(state) + + def handle_checkbox_state(self, state, desired_state, action): + """Perform an action based on the checkbox state and a desired state. Have to use lambda when calling.""" + if state == desired_state: + action() + + def on_tab_changed(self, index): + """Handle tab changes.""" + # chatgpt + if index == 1: # EXIF Tab + self.handle_exif_file("read") + elif index == 0: # Main Tab + self.handle_exif_file("write") + + def sort_dict_of_lists(self, input_dict): + # Partily ChatGPT + sorted_dict = {} + for key, lst in input_dict.items(): + # Sort alphabetically for strings, numerically for numbers + if key == "iso": + lst = [int(x) for x in lst] + lst = sorted(lst) + lst = [str(x) for x in lst] + sorted_dict["iso"] = lst + + elif all(isinstance(x, str) for x in lst): + sorted_dict[key] = sorted(lst, key=str.lower) # Case-insensitive sort for strings + + return sorted_dict + + def populate_comboboxes(self, combo_mapping): + """Populate comboboxes with EXIF data.""" + # ChatGPT + for field, comboBox in combo_mapping.items(): + comboBox.clear() # Clear existing items + comboBox.addItems(map(str, self.available_exif_data.get(field, []))) + + def open_exif_editor(self): + """Open the EXIF Editor.""" + self.exif_editor = ExifEditor(self.available_exif_data) + self.exif_editor.exif_data_updated.connect(self.update_exif_data) + self.exif_editor.show() + + def update_exif_data(self, updated_exif_data): + """Update the EXIF data.""" + self.exif_data = updated_exif_data + self.populate_exif() + + def populate_exif(self): + # partly chatGPT + # Mapping of EXIF fields to comboboxes in the UI + combo_mapping = { + "make": self.ui.make_comboBox, + "model": self.ui.model_comboBox, + "lens": self.ui.lens_comboBox, + "iso": self.ui.iso_comboBox, + "image_description": self.ui.image_description_comboBox, + "user_comment": self.ui.user_comment_comboBox, + "artist": self.ui.artist_comboBox, + "copyright_info": self.ui.copyright_info_comboBox, + } + + self.populate_comboboxes(combo_mapping) + + def update_quality_options(self): + """Update visibility of quality settings based on selected format.""" + # Partly ChatGPT + selected_format = self.ui.image_type.currentText() + # Hide all quality settings + self.ui.png_quality_spinBox.setVisible(False) + self.ui.jpg_quality_spinBox.setVisible(False) + self.ui.jpg_quality_Slider.setVisible(False) + self.ui.png_quality_Slider.setVisible(False) + self.ui.quality_label_1.setVisible(False) + self.ui.quality_label_2.setVisible(False) + # Show relevant settings + if selected_format == "jpg": + self.ui.jpg_quality_spinBox.setVisible(True) + self.ui.jpg_quality_Slider.setVisible(True) + self.ui.quality_label_1.setVisible(True) + elif selected_format == "webp": + self.ui.jpg_quality_spinBox.setVisible(True) + self.ui.jpg_quality_Slider.setVisible(True) + self.ui.quality_label_1.setVisible(True) + elif selected_format == "png": + self.ui.png_quality_spinBox.setVisible(True) + self.ui.png_quality_Slider.setVisible(True) + self.ui.quality_label_2.setVisible(True) + + def browse_input_folder(self): + folder = QFileDialog.getExistingDirectory(self, "Select Input Folder") + if folder: + self.ui.input_path.setText(folder) + + def browse_output_folder(self): + folder = QFileDialog.getExistingDirectory(self, "Select Output Folder") + if folder: + self.ui.output_path.setText(folder) + + def change_statusbar(self, msg, timeout = 500): + self.ui.statusBar.showMessage(msg, timeout) + + # Core functions + def on_processing_finished(self): + self.toggle_buttons(True) + self.handle_qprogressbar(0) + QMessageBox.information(self, "Information", "Finished!") + + def image_list_from_folder(self, path): + image_files = [ + f for f in os.listdir(path) if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp")) + ] + image_files.sort() + return image_files + + def control_before_start(self, process): + input_folder = self.settings["input_folder"] + output_folder = self.settings["output_folder"] + image_list = self.image_list_from_folder(input_folder) + input_folder_valid = os.path.exists(input_folder) + + if isinstance(output_folder, str): + output_folder_valid = os.path.exists(output_folder) + + if process == "image": + if not input_folder or not output_folder: + QMessageBox.warning(self, "Warning", "Input or output folder not selected") + return False + + if not input_folder_valid or not output_folder_valid: + QMessageBox.warning(self, "Warning", f"Input location {input_folder_valid}\nOutput folder {output_folder_valid}...") + return False + + if len(self.image_list_from_folder(output_folder)) != 0: + reply = QMessageBox.question( + self, + "Confirmation", + "Output folder containes images, which might get overritten, continue?", + QMessageBox.Yes | QMessageBox.No, + ) + + if reply == QMessageBox.No: + return False + + elif process == "exif": + + if not input_folder: + QMessageBox.warning(self, "Warning", "Input not selected") + return False + + if output_folder: + reply = QMessageBox.question( + self, + "Confirmation", + "Output folder selected, but insert exif is done to images in input folder, Continue?", + QMessageBox.Yes | QMessageBox.No, + ) + + if reply == QMessageBox.No: + return False + + if not input_folder_valid : + QMessageBox.warning(self, "Warning", f"Input location {input_folder_valid}") + return False + + else: + print("Something went wrong") + + if len(image_list) == 0: + QMessageBox.warning(self, "Warning", "Selected folder has no supported files.") + return False + + return True + + def start_process(self): + self.toggle_buttons(False) + u = self.update_settings() + if u != None: # Get all user selected data + QMessageBox.warning(self, "Warning", f"Error: {u}") + self.toggle_buttons(True) + return + + if self.control_before_start("image") == False: + self.toggle_buttons(True) + return + + image_list = self.image_list_from_folder(self.settings["input_folder"]) + # Create a worker ChatGPT + worker = ImageProcessorRunnable(image_list, self.settings, self.handle_qprogressbar) + worker.signals.finished.connect(self.on_processing_finished) + # Start worker in thread pool ChatGPT + self.thread_pool.start(worker) + + def control_ending(self, name_lst): + for item in name_lst: + try: + int(item[-5]) + except ValueError: + return False + return True + + def insert_exif(self, image_files): + input_folder = self.settings["input_folder"] + + i = 1 + for image_file in image_files: + + input_path = os.path.join(input_folder, image_file) + + self.o.insert_exif_to_image( + exif_dict = self.settings["user_selected_exif"], + image_path = input_path, + gps = self.settings["gps"]) + self.change_statusbar(image_file, 100) + self.handle_qprogressbar(int((i / len(image_files)) * 100)) + i += 1 + + self.ui.progressBar.setValue(0) + + def startinsert_exif(self): + self.toggle_buttons(False) + u = self.update_settings() + if u != None: # Get all user selected data + QMessageBox.warning(self, "Warning", f"Error: {u}") + self.toggle_buttons(True) + return + + if self.control_before_start("exif") == False: + self.toggle_buttons(True) + return + + image_list = self.image_list_from_folder(self.settings["input_folder"]) + print(image_list) + if not self.control_ending(image_list): + QMessageBox.warning(self, "Warning", f"Error: one or more filenames do not end on a number.\nCan not adjust time") + self.toggle_buttons(True) + return + + self.insert_exif(image_list) + + self.toggle_buttons(True) + QMessageBox.information(self, "Information", "Finished") + + def get_checkbox_value(self, checkbox, default = None): + """Helper function to get the value of a checkbox or a default value.""" + return checkbox.isChecked() if checkbox else default + + def get_spinbox_value(self, spinbox, default = None): + """Helper function to get the value of a spinbox and handle empty input.""" + return int(spinbox.text()) if spinbox.text() else default + + def get_combobox_value(self, combobox, default = None): + """Helper function to get the value of a combobox.""" + return combobox.currentIndex() + 1 if combobox.currentIndex() != -1 else default + + def get_text_value(self, lineedit, default = None): + """Helper function to get the value of a text input field.""" + return lineedit.text() if lineedit.text() else default + + def get_date(self): + date_input = self.ui.dateEdit.date().toString("yyyy-MM-dd") + new_date = datetime.strptime(date_input, "%Y-%m-%d") + return new_date.strftime("%Y:%m:%d 00:00:00") + + def collect_selected_exif(self): + user_data = {} + user_data["make"] = self.ui.make_comboBox.currentText() + user_data["model"] = self.ui.model_comboBox.currentText() + user_data["lens"] = self.ui.lens_comboBox.currentText() + user_data["iso"] = self.ui.iso_comboBox.currentText() + user_data["image_description"] = self.ui.image_description_comboBox.currentText() + user_data["artist"] = self.ui.artist_comboBox.currentText() + user_data["copyright_info"] = self.ui.copyright_info_comboBox.currentText() + user_data["software"] = f"{self.name} {self.version} with {self.o.name} {self.o.version}" + if int(self.ui.contrast_spinBox.text()) != 0 or int(self.ui.brightness_spinBox.text()) != 0: + user_data["user_comment"] = f"{self.ui.user_comment_comboBox.currentText()}, contrast: {self.ui.contrast_spinBox.text()}, brightness: {self.ui.brightness_spinBox.text()}" + else: + user_data["user_comment"] = self.ui.user_comment_comboBox.currentText() + + return user_data + + def get_selected_exif(self): + """Collect selected EXIF data and handle date and GPS if necessary.""" + selected_exif = self.collect_selected_exif() if self.ui.exif_checkbox.isChecked() else None + if selected_exif: + if self.ui.add_date_checkBox.isChecked(): + selected_exif["date_time_original"] = self.get_date() + if self.ui.gps_checkBox.isChecked(): + try: + self.settings["gps"] = [float(self.ui.lat_lineEdit.text()), float(self.ui.long_lineEdit.text())] + except ValueError as e: + self.settings["gps"] = "Wrong gps data" + else: + self.settings["gps"] = None + return selected_exif + + def check_selected_exif(self, exif): + for key in exif: + if len(exif[key]) == 0: + return f"{key} is empty" + return True + + def update_settings(self): + """Update .settings from all GUI elements.""" + # Basic + self.settings["input_folder"] = self.get_text_value(self.ui.input_path) + self.settings["output_folder"] = self.get_text_value(self.ui.output_path) + self.settings["file_format"] = self.ui.image_type.currentText() + # Quality + self.settings["jpg_quality"] = self.get_spinbox_value(self.ui.jpg_quality_spinBox) + self.settings["png_compression"] = self.get_spinbox_value(self.ui.png_quality_spinBox) + self.settings["resize"] = int(self.ui.resize_spinBox.text()) if self.ui.resize_spinBox.text() != "100" else None + self.settings["optimize"] = self.get_checkbox_value(self.ui.optimize_checkBox) + # Changes for image + self.settings["brightness"] = int(self.ui.brightness_spinBox.text()) if self.ui.brightness_spinBox.text() != "0" else None + self.settings["contrast"] = int(self.ui.contrast_spinBox.text()) if self.ui.contrast_spinBox.text() != "0" else None + self.settings["grayscale"] = self.get_checkbox_value(self.ui.grayscale_checkBox) + # Watermark + self.settings["font_size"] = self.get_combobox_value(self.ui.font_size_comboBox) + self.settings["watermark"] = self.get_text_value(self.ui.watermark_lineEdit) + # Naming + new_name = self.get_text_value(self.ui.filename, False) if self.ui.rename_checkbox.isChecked() else False + if isinstance(new_name, str): new_name = new_name.replace(" ", "_") + self.settings["new_file_names"] = new_name + self.settings["invert_image_order"] = self.get_checkbox_value(self.ui.revert_checkbox) if new_name is not False else None + # Handle EXIF data selection + self.settings["copy_exif"] = self.get_checkbox_value(self.ui.exif_copy_checkBox) + self.settings["own_exif"] = self.get_checkbox_value(self.ui.exif_checkbox) + self.settings["own_date"] = self.get_checkbox_value(self.ui.add_date_checkBox) + if self.settings["own_exif"]: + self.settings["user_selected_exif"] = self.get_selected_exif() + if self.settings["gps"] is not None: + if len(self.settings["gps"]) != 2: + return self.settings["gps"] + else: + self.settings["user_selected_exif"] = None + self.settings["gps"] = None + + if self.settings["user_selected_exif"] is not None: + u = self.check_selected_exif(self.settings["user_selected_exif"]) + if u != True: + return u + + # Helper functions, low level + def handle_exif_file(self, do): + # TODO: add check if data is missing. + if do == "read": + file_dict = self.u.read_yaml(self.exif_file) + self.available_exif_data = self.sort_dict_of_lists(file_dict) + elif do == "write": + self.u.write_yaml(self.exif_file, self.available_exif_data) + + def closeEvent(self, event): + QApplication.closeAllWindows() + event.accept() + +class WorkerSignals(QObject): + # ChatGPT + progress = Signal(int) + finished = Signal() + +class ImageProcessorRunnable(QRunnable): + # ChatGPT gave rough function layout + def __init__(self, image_files, settings, progress_callback): + super().__init__() + self.image_files = image_files + self.settings = settings + self.signals = WorkerSignals() + self.signals.progress.connect(progress_callback) + self.o = OptimaManager() + self.u = Utilities(os.path.expanduser(CONFIG_BASE_PATH)) + + def run(self): + input_folder = self.settings["input_folder"] + output_folder = self.settings["output_folder"] + + for i, image_file in enumerate(self.image_files, start=1): + input_path = os.path.join(input_folder, image_file) + if self.settings["new_file_names"] != False: + image_name = self.u.append_number_to_name(self.settings["new_file_names"], i, len(self.image_files), self.settings["invert_image_order"]) + else: + image_name = os.path.splitext(image_file)[0] + output_path = os.path.join(output_folder, image_name) + + self.o.process_and_save_image( + image_input_file = input_path, + image_output_file = output_path, + file_type = self.settings["file_format"], + quality = self.settings["jpg_quality"], + compressing = self.settings["png_compression"], + optimize = self.settings["optimize"], + resize = self.settings["resize"], + watermark = self.settings["watermark"], + font_size = self.settings["font_size"], + grayscale = self.settings["grayscale"], + brightness = self.settings["brightness"], + contrast = self.settings["contrast"], + dict_for_exif = self.settings["user_selected_exif"], + gps = self.settings["gps"], + copy_exif = self.settings["copy_exif"] + ) + self.signals.progress.emit(int((i / len(self.image_files)) * 100)) + + self.signals.finished.emit() diff --git a/src/OptimaLab35/previewWindow.py b/src/OptimaLab35/previewWindow.py new file mode 100644 index 0000000..82161d2 --- /dev/null +++ b/src/OptimaLab35/previewWindow.py @@ -0,0 +1,166 @@ +import os +from optima35.core import OptimaManager + +from OptimaLab35 import __version__ + +from .ui import resources_rc +from .ui.preview_window import Ui_Preview_Window + +from PySide6 import QtWidgets, QtCore + +from PySide6.QtCore import ( + QRunnable, + QThreadPool, + Signal, + QObject, + QRegularExpression, + Qt, + QTimer, + Slot +) + +from PySide6.QtWidgets import ( + QMessageBox, + QApplication, + QMainWindow, + QFileDialog +) + +from PySide6.QtGui import QPixmap, QRegularExpressionValidator, QIcon + +class PreviewWindow(QMainWindow, Ui_Preview_Window): + values_selected = Signal(int, int, bool) + # Large ChatGPT with rewrite and bug fixes from me. + + def __init__(self): + super(PreviewWindow, self).__init__() + self.ui = Ui_Preview_Window() + self.ui.setupUi(self) + self.o = OptimaManager() + self.threadpool = QThreadPool() # Thread pool for managing worker threads + self.setWindowIcon(QIcon(":app-icon.png")) + self.ui.QLabel.setAlignment(Qt.AlignCenter) + + # UI interactions + self.ui.load_Button.clicked.connect(self.browse_file) + self.ui.update_Button.clicked.connect(self.update_preview) + self.ui.close_Button.clicked.connect(self.close_window) + + self.ui.reset_brightness_Button.clicked.connect(lambda: self.ui.brightness_spinBox.setValue(0)) + self.ui.reset_contrast_Button.clicked.connect(lambda: self.ui.contrast_spinBox.setValue(0)) + + # Connect UI elements to `on_ui_change` + self.ui.brightness_spinBox.valueChanged.connect(self.on_ui_change) # brightness slider changes spinbox value, do not need an event for the slider + self.ui.contrast_spinBox.valueChanged.connect(self.on_ui_change) # contrast slider changes spinbox value, do not need an event for the slider + self.ui.grayscale_checkBox.stateChanged.connect(self.on_ui_change) + self.ui_elements(False) + self.ui.show_OG_Button.pressed.connect(self.show_OG_image) + self.ui.show_OG_Button.released.connect(self.update_preview) + + def on_ui_change(self): + """Triggers update only if live update is enabled.""" + if self.ui.live_update.isChecked(): + self.update_preview() + + def browse_file(self): + file = QFileDialog.getOpenFileName(self, caption="Select File", filter="Images (*.png *.webp *.jpg *.jpeg)") + if file[0]: + self.ui.image_path_lineEdit.setText(file[0]) + self.update_preview() + self.ui_elements(True) + + def show_OG_image(self): + """Handles loading and displaying the image in a separate thread.""" + path = self.ui.image_path_lineEdit.text() + + worker = ImageProcessorWorker( + path = path, + optima_manager = self.o, + brightness = 0, + contrast = 0, + grayscale = False, + resize = self.ui.scale_Slider.value(), + callback = self.display_image # Callback to update UI + ) + self.threadpool.start(worker) + + def ui_elements(self, state): + self.ui.groupBox_2.setEnabled(state) + self.ui.groupBox.setEnabled(state) + self.ui.groupBox_5.setEnabled(state) + self.ui.show_OG_Button.setEnabled(state) + + def update_preview(self): + """Handles loading and displaying the image in a separate thread.""" + path = self.ui.image_path_lineEdit.text() + + worker = ImageProcessorWorker( + path = path, + optima_manager = self.o, + brightness = int(self.ui.brightness_spinBox.text()), + contrast = int(self.ui.contrast_spinBox.text()), + grayscale = self.ui.grayscale_checkBox.isChecked(), + resize = self.ui.scale_Slider.value(), + callback = self.display_image # Callback to update UI + ) + self.threadpool.start(worker) # Run worker in a thread + + def display_image(self, pixmap): + """Adjusts the image to fit within the QLabel.""" + if pixmap is None: + QMessageBox.warning(self, "Warning", "Error processing image...") + return + + max_size = self.ui.QLabel.size() + scaled_pixmap = pixmap.scaled(max_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.ui.QLabel.setPixmap(scaled_pixmap) + self.ui.QLabel.resize(scaled_pixmap.size()) + + def resizeEvent(self, event): + """Triggered when the preview window is resized.""" + file_path = self.ui.image_path_lineEdit.text() + if os.path.exists(file_path): + self.update_preview() # Re-process and display the image + super().resizeEvent(event) # Keep the default behavior + + def close_window(self): + """Emits signal and closes the window.""" + if self.ui.checkBox.isChecked(): + self.values_selected.emit(self.ui.brightness_spinBox.value(), self.ui.contrast_spinBox.value(), self.ui.grayscale_checkBox.isChecked()) + self.close() + +class ImageProcessorWorker(QRunnable): + """Worker class to load and process the image in a separate thread.""" + # ChatGPT + def __init__(self, path, optima_manager, brightness, contrast, grayscale, resize, callback): + super().__init__() + self.path = path + self.optima_manager = optima_manager + self.brightness = brightness + self.contrast = contrast + self.grayscale = grayscale + self.resize = resize + self.callback = callback # Function to call when processing is done + + @Slot() + def run(self): + """Runs the image processing in a separate thread.""" + if not os.path.isfile(self.path): + self.callback(None) + return + + try: + img = self.optima_manager.process_image_object( + image_input_file = self.path, + watermark = f"PREVIEW B:{self.brightness} C:{self.contrast}", + font_size = 1, + resize = self.resize, + grayscale = self.grayscale, + brightness = self.brightness, + contrast = self.contrast + ) + pixmap = QPixmap.fromImage(img) + self.callback(pixmap) + except Exception as e: + print(f"Error processing image: {e}") + self.callback(None) diff --git a/src/OptimaLab35/settingsWindow.py b/src/OptimaLab35/settingsWindow.py new file mode 100644 index 0000000..ffbd263 --- /dev/null +++ b/src/OptimaLab35/settingsWindow.py @@ -0,0 +1,357 @@ +import sys +import os +from datetime import datetime +from PyPiUpdater import PyPiUpdater + +from OptimaLab35 import __version__ +from .const import ( + CONFIG_BASE_PATH +) + +from .ui import resources_rc +from .utils.utility import Utilities +from .ui.settings_window import Ui_Settings_Window + +from PySide6 import QtWidgets, QtCore + +from PySide6.QtCore import ( + QRegularExpression, + Qt, + QTimer +) + +from PySide6.QtWidgets import ( + QMessageBox, + QApplication, + QMainWindow +) + +from PySide6.QtGui import QIcon + +class SettingsWindow(QMainWindow, Ui_Settings_Window): + # Mixture of code by me, code/functions refactored by ChatGPT and code directly from ChatGPT + def __init__(self, optimalab35_localversion, optima35_localversion): + super(SettingsWindow, self).__init__() + self.ui = Ui_Settings_Window() + self.ui.setupUi(self) + self.u = Utilities(os.path.expanduser(CONFIG_BASE_PATH)) + self.app_settings = self.u.load_settings() + self.dev_mode = True if optimalab35_localversion == "0.0.1" else False + self.setWindowIcon(QIcon(":app-icon.png")) + + # Update log file location + self.update_log_file = os.path.expanduser(f"{CONFIG_BASE_PATH}/update_log.json") + # Store local versions + self.optimalab35_localversion = optimalab35_localversion + self.optima35_localversion = optima35_localversion + # Create PyPiUpdater instances + self.ppu_ol35 = PyPiUpdater("OptimaLab35", self.optimalab35_localversion, self.update_log_file) + self.ppu_o35 = PyPiUpdater("optima35", self.optima35_localversion, self.update_log_file) + self.ol35_last_state = self.ppu_ol35.get_last_state() + self.o35_last_state = self.ppu_o35.get_last_state() + # Track which packages need an update + self.updates_available = {"OptimaLab35": False, "optima35": False} + self.define_gui_interaction() + + def define_gui_interaction(self): + """Setup UI interactions.""" + # Updater related + self.ui.label_optimalab35_localversion.setText(self.optimalab35_localversion) + self.ui.label_optima35_localversion.setText(self.optima35_localversion) + + self.ui.label_latest_version.setText("Latest version") + self.ui.label_optimalab35_latestversion.setText("...") + self.ui.label_optima35_latestversion.setText("...") + + self.ui.update_and_restart_Button.setEnabled(False) + + # Connect buttons to functions + self.ui.check_for_update_Button.clicked.connect(self.check_for_updates) + self.ui.update_and_restart_Button.clicked.connect(self.update_and_restart) + self.ui.label_last_check.setText(f"Last check: {self.time_to_string(self.ol35_last_state[0])}") + self.ui.dev_widget.setVisible(False) + + # Timer for long press detection + self.timer = QTimer() + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.toggle_dev_ui) + + # Connect button press/release + self.ui.check_for_update_Button.pressed.connect(self.start_long_press) + self.ui.check_for_update_Button.released.connect(self.cancel_long_press) + self.ui.label_5.setText('