Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ The application is structured into two main packages: `core` and `ui`.
- **`pyexiv2_init.py`**: Handles safe PyExiv2 initialization ensuring it loads before Qt libraries to prevent access violations.
- **`rating_loader_worker.py`**: A worker dedicated to loading image ratings and metadata.
- **`similarity_engine.py`**: Handles image feature extraction and clustering.
- **`update_checker.py`**: Handles checking for application updates from GitHub releases. Includes version comparison logic, automatic update scheduling, and update information parsing.
- **`ui/`**: Contains all UI-related components, following the Model-View-Controller (MVC) pattern.
- **`main_window.py`**: The main application window (the "View"). It should contain minimal business logic and delegate user actions to the `AppController`.
- **`app_controller.py`**: The controller that mediates between the UI and the `core` logic. It handles user actions, calls the appropriate `core` services, and updates the UI.
- **`app_state.py`**: Holds the application's runtime state, including caches and loaded data. This object is shared across the application.
- **`worker_manager.py`**: Manages all background threads and workers, decoupling the UI from long-running tasks.
- **`update_dialog.py`**: UI dialogs for displaying update notifications and manual update check results.
- **Controller Layer (Encapsulation Refactor)**: Non-trivial UI behaviors previously embedded in `MainWindow` have been extracted into focused controllers under `src/ui/controllers/`:
- `navigation_controller.py`: Linear & group-aware navigation (honors skip-deleted logic, smart up/down traversal). Consumes a minimal protocol for selection & model interrogation.
- `hotkey_controller.py`: Central mapping of key events to actions (allows headless tests to exercise hotkey dispatch logic).
Expand Down Expand Up @@ -67,12 +69,22 @@ The application is structured into two main packages: `core` and `ui`.

- If the feature is a core logic change (e.g., a new image analysis technique), it should be implemented in the `src/core` directory.
- If it's a new UI component, it should be in `src/ui`.
- If it's a new background task, a new worker should be added and managed by `src/ui/worker_manager.py`.
2. **Create new files when necessary:**
- If it's a new background task, a new worker should be added to `src/workers` and managed by `src/ui/worker_manager.py`.

2. **Workers Directory (`src/workers`):**
- Contains background worker classes for core business logic and application-level operations
- **`update_worker.py`**: Background worker for checking application updates without blocking the UI
- **`rating_loader_worker.py`**: Background worker for loading metadata and ratings for images
- Workers should inherit from QObject and use Qt signals for communication
- Managed by the `WorkerManager` in the UI layer
- UI-specific workers (like preview preloading, blur detection) remain in `src/ui/ui_components.py` due to tight coupling with UI operations

3. **Create new files when necessary:**

- Create a new file for a new class or a distinct set of functionalities. For example, a new image analysis feature like "sharpness detection" would warrant a new file `src/core/image_features/sharpness_detector.py`.
- For smaller, related functions, you can add them to an existing relevant file.
3. **Integrating the feature:**

4. **Integrating the feature:**

- The `AppController` is the primary point of integration. User actions from the UI (e.g., a button click in `MainWindow`) should call a method in the `AppController`.
- The `AppController` then calls the relevant service in the `core` package. For any file system operations, the `AppController` must call the appropriate method in `ImageFileOperations`.
Expand Down Expand Up @@ -132,6 +144,7 @@ The application is structured into two main packages: `core` and `ui`.
```
- **State Management**: The application's state (e.g., list of loaded images, cache data) is managed by the `AppState` class. Avoid storing state directly in the `MainWindow` or other UI components.
- **Styling**: All UI components should be styled using stylesheets. The application uses a dark theme defined in `src/ui/dark_theme.qss`. When creating new UI components or modifying existing ones, ensure that the styling is consistent with this theme.
- **Update Notifications**: The application includes an automatic update notification system that checks GitHub releases. Update checks run automatically on startup (with configurable intervals) and can be triggered manually from the Help menu. The system respects user preferences and can be disabled. All update-related constants are centralized in `app_settings.py`.

## 4. Rotation Suggestion Acceptance & Auto-Advance

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ PhotoSort is a powerful desktop application focused on speed designed to streaml
* **Optimized Image Handling**: Supports a wide range of formats, including various RAW types, with efficient caching.
* **Intelligent Image Rotation**: Smart rotation system that automatically tries lossless metadata rotation first, with optional fallback to pixel rotation when needed.

- **Update Notifications**: Automatically checks for new releases and notifies users when updates are available, with direct download links.
- **Metadata Display**: Shows EXIF information (camera model, exposure settings, etc.).

## Getting Started
Expand Down
36 changes: 36 additions & 0 deletions src/core/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@
ORIENTATION_MODEL_NAME_KEY = (
"Models/OrientationModelName" # Key for the orientation model file name
)
UPDATE_CHECK_ENABLED_KEY = "Updates/CheckEnabled" # Enable automatic update checks
UPDATE_LAST_CHECK_KEY = "Updates/LastCheckTime" # Last time updates were checked

# Default values
DEFAULT_PREVIEW_CACHE_SIZE_GB = 2.0 # Default to 2 GB for preview cache
DEFAULT_EXIF_CACHE_SIZE_MB = 256 # Default to 256 MB for EXIF cache
DEFAULT_ROTATION_CONFIRM_LOSSY = True # Default to asking before lossy rotation
MAX_RECENT_FOLDERS = 10 # Max number of recent folders to store
DEFAULT_ORIENTATION_MODEL_NAME = None # Default to None, so we can auto-detect
DEFAULT_UPDATE_CHECK_ENABLED = True # Default to enable automatic update checks

# --- UI Constants ---
# Grid view settings
Expand Down Expand Up @@ -105,6 +108,12 @@
"sentence-transformers/clip-ViT-B-32" # Common default, adjust if different
)

# --- Update Check Constants ---
UPDATE_CHECK_INTERVAL_HOURS = 24 # Check for updates every 24 hours
UPDATE_CHECK_TIMEOUT_SECONDS = 10 # Timeout for update check requests
GITHUB_REPO_OWNER = "duartebarbosadev" # GitHub repository owner
GITHUB_REPO_NAME = "PhotoSort" # GitHub repository name


def _get_settings() -> QSettings:
"""Get a QSettings instance with the application's organization and name."""
Expand Down Expand Up @@ -227,3 +236,30 @@ def set_orientation_model_name(model_name: str):
"""Sets the orientation model name in settings."""
settings = _get_settings()
settings.setValue(ORIENTATION_MODEL_NAME_KEY, model_name)


# --- Update Check Settings ---
def get_update_check_enabled() -> bool:
"""Gets whether automatic update checks are enabled."""
settings = _get_settings()
return settings.value(
UPDATE_CHECK_ENABLED_KEY, DEFAULT_UPDATE_CHECK_ENABLED, type=bool
)


def set_update_check_enabled(enabled: bool):
"""Sets whether automatic update checks are enabled."""
settings = _get_settings()
settings.setValue(UPDATE_CHECK_ENABLED_KEY, enabled)


def get_last_update_check_time() -> int:
"""Gets the timestamp of the last update check (seconds since epoch)."""
settings = _get_settings()
return settings.value(UPDATE_LAST_CHECK_KEY, 0, type=int)


def set_last_update_check_time(timestamp: int):
"""Sets the timestamp of the last update check."""
settings = _get_settings()
settings.setValue(UPDATE_LAST_CHECK_KEY, timestamp)
243 changes: 243 additions & 0 deletions src/core/update_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
"""
Update Checker Module
Handles checking for new application updates from GitHub releases.
"""

import json
import logging
import time
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError

from core.app_settings import (
UPDATE_CHECK_TIMEOUT_SECONDS,
GITHUB_REPO_OWNER,
GITHUB_REPO_NAME,
get_update_check_enabled,
get_last_update_check_time,
set_last_update_check_time,
UPDATE_CHECK_INTERVAL_HOURS,
)

logger = logging.getLogger(__name__)


@dataclass
class UpdateInfo:
"""Information about an available update."""

version: str
release_url: str
release_notes: str
published_at: str
download_url: Optional[str] = None


class UpdateChecker:
"""Handles checking for application updates from GitHub releases."""

def __init__(self):
self.github_api_url = f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/releases/latest"

def should_check_for_updates(self) -> bool:
"""Check if enough time has passed since the last update check."""
if not get_update_check_enabled():
logger.debug("Automatic update checks are disabled")
return False

# Skip automatic updates for development versions
from core.build_info import VERSION

current_version = VERSION or "dev"
if current_version == "dev" or current_version.startswith("dev"):
logger.debug("Skipping automatic update check for development version")
return False

last_check = get_last_update_check_time()
current_time = int(time.time())
time_since_last_check = current_time - last_check
check_interval = UPDATE_CHECK_INTERVAL_HOURS * 3600 # Convert to seconds

should_check = time_since_last_check >= check_interval
if should_check:
logger.debug(
f"Time for update check. Last check: {time_since_last_check // 3600} hours ago"
)
else:
next_check_hours = (check_interval - time_since_last_check) // 3600
logger.debug(f"Next update check in {next_check_hours} hours")

return should_check

def check_for_updates(
self, current_version: str
) -> Tuple[bool, Optional[UpdateInfo], Optional[str]]:
"""
Check for updates from GitHub releases.

Args:
current_version: The current application version

Returns:
Tuple of (update_available, update_info, error_message)
"""
logger.info("Checking for application updates...")

try:
# Update the last check time regardless of result
set_last_update_check_time(int(time.time()))

# Make request to GitHub API
request = Request(
self.github_api_url,
headers={
"User-Agent": f"{GITHUB_REPO_NAME}/{current_version}",
"Accept": "application/vnd.github.v3+json",
},
)

with urlopen(request, timeout=UPDATE_CHECK_TIMEOUT_SECONDS) as response:
if response.status != 200:
error_msg = f"GitHub API returned status {response.status}"
logger.warning(error_msg)
return False, None, error_msg

data = json.loads(response.read().decode("utf-8"))

# Parse release information
latest_version = data.get("tag_name", "").lstrip(
"v"
) # Remove 'v' prefix if present
release_url = data.get("html_url", "")
release_notes = data.get("body", "")
published_at = data.get("published_at", "")

if not latest_version:
error_msg = "Could not parse version from GitHub release"
logger.warning(error_msg)
return False, None, error_msg

# Find download URL for current platform
download_url = self._find_download_url(data.get("assets", []))

# Compare versions
if self._is_newer_version(latest_version, current_version):
logger.info(
f"New version available: {latest_version} (current: {current_version})"
)
update_info = UpdateInfo(
version=latest_version,
release_url=release_url,
release_notes=release_notes,
published_at=published_at,
download_url=download_url,
)
return True, update_info, None
else:
logger.info(
f"Application is up to date (current: {current_version}, latest: {latest_version})"
)
return False, None, None

except HTTPError as e:
error_msg = f"HTTP error checking for updates: {e.code} {e.reason}"
logger.warning(error_msg)
return False, None, error_msg
except URLError as e:
error_msg = f"Network error checking for updates: {e.reason}"
logger.warning(error_msg)
return False, None, error_msg
except json.JSONDecodeError as e:
error_msg = f"Error parsing GitHub API response: {e}"
logger.warning(error_msg)
return False, None, error_msg
except Exception as e:
error_msg = f"Unexpected error checking for updates: {e}"
logger.error(error_msg, exc_info=True)
return False, None, error_msg

def _find_download_url(self, assets: list) -> Optional[str]:
"""Find the appropriate download URL for the current platform."""
import platform

system = platform.system().lower()

for asset in assets:
name = asset.get("name", "").lower()
download_url = asset.get("browser_download_url")

if not download_url:
continue

# Match platform-specific assets
if system == "windows" and "windows" in name and name.endswith(".exe"):
return download_url
elif system == "darwin" and "macos" in name and name.endswith(".dmg"):
return download_url

return None

def _is_newer_version(self, latest: str, current: str) -> bool:
"""
Compare two version strings to determine if latest is newer than current.
Handles semantic versioning with optional pre-release suffixes (e.g., 1.0.2a).
"""
if not current: # Development version
return True

try:
# Parse version components
latest_parts = self._parse_version(latest)
current_parts = self._parse_version(current)

# Compare version components
return latest_parts > current_parts

except Exception as e:
logger.warning(f"Error comparing versions '{latest}' vs '{current}': {e}")
# If parsing fails, assume update is available to be safe
return True

def _parse_version(self, version: str) -> Tuple[int, ...]:
"""
Parse a version string into comparable components.
Examples: "1.0.2" -> (1, 0, 2), "1.0.2a" -> (1, 0, 2, -1)
Release versions are considered newer than pre-release versions of same number.
"""
# Handle development versions
if version.startswith("dev-") or not version:
return (0,) # Development versions are always older

# Remove 'v' prefix if present
version = version.lstrip("v")

# Split into numeric and suffix parts
import re

match = re.match(r"^(\d+(?:\.\d+)*)([a-zA-Z]*)$", version)
if not match:
# If we can't parse it, treat as (0,) to be safe
return (0,)

numeric_part, suffix = match.groups()

# Parse numeric components
parts = [int(x) for x in numeric_part.split(".")]

# Handle pre-release suffixes (alpha, beta, etc.)
if suffix:
# Pre-release versions are considered older than release versions
# Map common suffixes to negative numbers for proper ordering
suffix_map = {"a": -3, "alpha": -3, "b": -2, "beta": -2, "rc": -1}
suffix_value = suffix_map.get(
suffix.lower(), -10
) # Unknown suffixes are very old
parts.append(suffix_value)
else:
# For release versions (no suffix), add 0 to distinguish from pre-release
# This ensures 1.0.2 > 1.0.2a (where 1.0.2a has -3 as last component)
parts.append(0)

return tuple(parts)
Loading