From c0a6148f584baaedd591165eae304b6a3da2daf9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 11 Nov 2025 13:52:33 +0100 Subject: [PATCH] fixed installer, fixed issue with loading wrong paths. Added file schema versioning --- VERSIONING.md | 179 ++++++++++++++++ install.sh | 25 ++- pyPhotoAlbum/asset_heal_dialog.py | 188 ++++++++++++++++ pyPhotoAlbum/asset_manager.py | 12 +- pyPhotoAlbum/main.py | 6 +- pyPhotoAlbum/mixins/operations/file_ops.py | 68 +++++- pyPhotoAlbum/models.py | 59 ++++-- pyPhotoAlbum/project_serializer.py | 107 ++++++++-- pyPhotoAlbum/version_manager.py | 236 +++++++++++++++++++++ test_asset_loading.py | 74 +++++++ test_version_roundtrip.py | 65 ++++++ 11 files changed, 979 insertions(+), 40 deletions(-) create mode 100644 VERSIONING.md create mode 100644 pyPhotoAlbum/asset_heal_dialog.py create mode 100644 pyPhotoAlbum/version_manager.py create mode 100644 test_asset_loading.py create mode 100644 test_version_roundtrip.py diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..73695d7 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,179 @@ +# Data Format Versioning + +pyPhotoAlbum uses a comprehensive versioning system to manage data format changes and ensure compatibility across versions. + +## Current Version + +**Data Format Version: 2.0** + +## Version History + +### Version 2.0 (Released: 2025-01-11) + +**Description:** Fixed asset path handling - paths now stored relative to project folder + +**Breaking Changes:** +- Asset paths changed from absolute/full-project-relative to project-relative +- Added automatic path normalization for legacy projects + +**Compatibility:** Can read and migrate v1.0 files automatically + +**Key Improvements:** +- Asset paths now stored as `assets/image.jpg` instead of `./projects/ProjectName/assets/image.jpg` +- Automatic path normalization when loading old projects +- Added search path system for finding assets in multiple locations +- ZIP file directory automatically added as a search path +- New "Heal Assets" feature to reconnect missing images + +### Version 1.0 (Released: 2024-01-01) + +**Description:** Initial format with basic serialization + +**Features:** +- Basic project structure +- Page layouts and elements +- Asset management with reference counting +- ZIP-based `.ppz` project format + +## How Versioning Works + +### File Format + +Each `.ppz` file contains a `project.json` with version information: + +```json +{ + "name": "My Project", + "data_version": "2.0", + "serialization_version": "2.0", + ... +} +``` + +- `data_version`: Current versioning system (introduced in v2.0) +- `serialization_version`: Legacy version field (for backward compatibility) + +### Loading Process + +When loading a project file: + +1. **Version Detection:** The system reads both `data_version` (new) and `serialization_version` (legacy) fields +2. **Compatibility Check:** Verifies if the file version is compatible with the current version +3. **Migration (if needed):** Automatically migrates data from old versions to current format +4. **Path Normalization:** Fixes asset paths to work with current project location +5. **Asset Resolution:** Sets up search paths for finding images + +### Compatibility Levels + +The system supports multiple compatibility levels: + +- **Full Compatibility:** Same version, no migration needed +- **Backward Compatibility:** Older version can be read with automatic migration +- **Incompatible:** Version cannot be loaded (future versions or corrupted files) + +## For Developers + +### Adding a New Version + +When making breaking changes to the data format: + +1. **Update version_manager.py:** + ```python + CURRENT_DATA_VERSION = "3.0" # Increment major version + + VERSION_HISTORY["3.0"] = { + "description": "Description of changes", + "released": "2025-XX-XX", + "breaking_changes": [ + "List of breaking changes" + ], + "compatible_with": ["2.0", "3.0"], + } + ``` + +2. **Create a migration function:** + ```python + @DataMigration.register_migration("2.0", "3.0") + def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]: + # Perform data transformations + data['data_version'] = "3.0" + return data + ``` + +3. **Test the migration:** + - Create test files with old format + - Verify they load correctly with migration + - Verify no migration needed for new files + +### Version Compatibility Guidelines + +**When to increment version:** + +- **Major version (1.0 → 2.0):** Breaking changes to data structure + - Field renames or removals + - Changed data types + - New required fields + - Incompatible serialization changes + +- **Minor version (2.0 → 2.1):** Backward-compatible additions + - New optional fields + - New features that don't break old data + - Performance improvements + +**Migration Best Practices:** + +1. Always test migrations with real user data +2. Log migration steps for debugging +3. Preserve user data even if it can't be fully migrated +4. Provide clear error messages for incompatible versions +5. Document all breaking changes in VERSION_HISTORY + +## For Users + +### What You Need to Know + +- **Automatic Updates:** Old project files are automatically updated when opened +- **No Data Loss:** Your original `.ppz` file remains unchanged +- **Backward Compatibility:** Newer versions can read older files +- **Version Info:** Use File → About to see current version information + +### Troubleshooting + +**"Incompatible file version" error:** +- The file was created with a much newer or incompatible version +- Solution: Use the version of pyPhotoAlbum that created the file, or upgrade + +**Missing assets after loading:** +- Use File → "Heal Assets" to add search paths +- The directory containing the `.ppz` file is automatically searched +- You can add additional locations where images might be found + +**Want to ensure future compatibility:** +- Keep your `.ppz` files +- When upgrading, test opening your projects +- Report any migration issues on GitHub + +## Implementation Details + +### Files Involved + +- `version_manager.py`: Core versioning system and migrations +- `project_serializer.py`: Handles loading/saving with version checks +- `models.py`: Asset path resolution with search paths +- `asset_heal_dialog.py`: UI for reconnecting missing assets + +### Key Classes + +- `VersionCompatibility`: Checks version compatibility +- `DataMigration`: Manages migration functions +- `AssetManager`: Handles asset storage and reference counting + +### Search Path Priority + +When resolving asset paths (in order): + +1. Project folder (primary location) +2. Additional search paths (from Heal Assets) +3. Directory containing the `.ppz` file +4. Current working directory (fallback) +5. Parent of working directory (fallback) diff --git a/install.sh b/install.sh index 4bff996..88e4ddb 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,8 @@ # Installation script for pyPhotoAlbum # Supports both system-wide and user-local installation -set -e +# Don't use set -e for dependency installation as some packages may already be installed +# We'll handle errors individually where needed # Colors for output RED='\033[0;31m' @@ -47,8 +48,11 @@ install_dependencies() { case "$distro" in fedora) print_info "Installing dependencies for Fedora..." - sudo dnf install -y python3 python3-pip python3-qt6 python3-pyopengl \ - python3-numpy python3-pillow python3-reportlab python3-lxml + # Use --skip-unavailable and --allowerasing to handle already installed packages + sudo dnf install -y --skip-unavailable python3 python3-pip python3-pyqt6 python3-pyopengl \ + python3-numpy python3-pillow python3-reportlab python3-lxml || { + print_warn "Some packages may already be installed or unavailable, continuing..." + } ;; arch|cachyos) print_info "Installing dependencies for Arch/CachyOS..." @@ -124,8 +128,9 @@ install_desktop_integration() { # Create directories if they don't exist mkdir -p ~/.local/share/applications - # Copy desktop file + # Copy desktop file and update Exec paths to use full path cp pyphotoalbum.desktop ~/.local/share/applications/ + sed -i "s|Exec=pyphotoalbum|Exec=$HOME/.local/bin/pyphotoalbum|g" ~/.local/share/applications/pyphotoalbum.desktop # Install icons in multiple sizes for GNOME print_info "Installing application icons..." @@ -134,6 +139,18 @@ install_desktop_integration() { mkdir -p ~/.local/share/icons/hicolor/256x256/apps cp pyPhotoAlbum/icons/icon.png ~/.local/share/icons/hicolor/256x256/apps/pyphotoalbum.png + # Generate and install additional sizes for better display + if command -v magick &> /dev/null || command -v convert &> /dev/null; then + for size in 48 64 128; do + mkdir -p ~/.local/share/icons/hicolor/${size}x${size}/apps + if command -v magick &> /dev/null; then + magick pyPhotoAlbum/icons/icon.png -resize ${size}x${size} ~/.local/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png 2>/dev/null + else + convert pyPhotoAlbum/icons/icon.png -resize ${size}x${size} ~/.local/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png 2>/dev/null + fi + done + fi + # Install additional sizes if they exist for size in 16 22 24 32 48 64 128 512; do icon_file="pyPhotoAlbum/icons/icon-${size}x${size}.png" diff --git a/pyPhotoAlbum/asset_heal_dialog.py b/pyPhotoAlbum/asset_heal_dialog.py new file mode 100644 index 0000000..72ab194 --- /dev/null +++ b/pyPhotoAlbum/asset_heal_dialog.py @@ -0,0 +1,188 @@ +""" +Asset healing dialog for reconnecting missing images +""" + +import os +from typing import List, Dict, Set +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QListWidget, QListWidgetItem, QFileDialog, QGroupBox, + QMessageBox +) +from PyQt6.QtCore import Qt + + +class AssetHealDialog(QDialog): + """Dialog for healing missing asset paths""" + + def __init__(self, project, parent=None): + super().__init__(parent) + self.project = project + self.search_paths: List[str] = [] + self.missing_assets: Set[str] = set() + + self.setWindowTitle("Heal Missing Assets") + self.resize(600, 500) + + self._init_ui() + self._scan_missing_assets() + + def _init_ui(self): + """Initialize the UI""" + layout = QVBoxLayout() + + # Missing assets group + missing_group = QGroupBox("Missing Assets") + missing_layout = QVBoxLayout() + + self.missing_list = QListWidget() + missing_layout.addWidget(self.missing_list) + + missing_group.setLayout(missing_layout) + layout.addWidget(missing_group) + + # Search paths group + search_group = QGroupBox("Search Paths") + search_layout = QVBoxLayout() + + self.search_list = QListWidget() + search_layout.addWidget(self.search_list) + + # Add/Remove buttons + button_layout = QHBoxLayout() + add_path_btn = QPushButton("Add Search Path...") + add_path_btn.clicked.connect(self._add_search_path) + button_layout.addWidget(add_path_btn) + + remove_path_btn = QPushButton("Remove Selected") + remove_path_btn.clicked.connect(self._remove_search_path) + button_layout.addWidget(remove_path_btn) + + search_layout.addLayout(button_layout) + search_group.setLayout(search_layout) + layout.addWidget(search_group) + + # Action buttons + action_layout = QHBoxLayout() + + heal_btn = QPushButton("Attempt Healing") + heal_btn.clicked.connect(self._attempt_healing) + action_layout.addWidget(heal_btn) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + action_layout.addWidget(close_btn) + + layout.addLayout(action_layout) + + self.setLayout(layout) + + def _scan_missing_assets(self): + """Scan project for missing assets""" + from pyPhotoAlbum.models import ImageData + + self.missing_assets.clear() + self.missing_list.clear() + + # Check all pages for missing images + for page in self.project.pages: + for element in page.layout.elements: + if isinstance(element, ImageData) and element.image_path: + # Check if path exists + if os.path.isabs(element.image_path): + full_path = element.image_path + else: + full_path = os.path.join(self.project.folder_path, element.image_path) + + if not os.path.exists(full_path): + self.missing_assets.add(element.image_path) + + # Display missing assets + if self.missing_assets: + for asset in sorted(self.missing_assets): + self.missing_list.addItem(asset) + else: + item = QListWidgetItem("No missing assets found!") + item.setForeground(Qt.GlobalColor.darkGreen) + self.missing_list.addItem(item) + + def _add_search_path(self): + """Add a search path""" + directory = QFileDialog.getExistingDirectory( + self, + "Select Search Path for Assets", + "", + QFileDialog.Option.ShowDirsOnly + ) + + if directory: + if directory not in self.search_paths: + self.search_paths.append(directory) + self.search_list.addItem(directory) + + def _remove_search_path(self): + """Remove selected search path""" + current_row = self.search_list.currentRow() + if current_row >= 0: + self.search_paths.pop(current_row) + self.search_list.takeItem(current_row) + + def _attempt_healing(self): + """Attempt to heal missing assets using search paths""" + from pyPhotoAlbum.models import ImageData, set_asset_resolution_context + + if not self.search_paths: + QMessageBox.warning( + self, + "No Search Paths", + "Please add at least one search path before attempting to heal assets." + ) + return + + healed_count = 0 + still_missing = [] + + # Update asset resolution context with search paths + set_asset_resolution_context(self.project.folder_path, self.search_paths) + + # Try to find each missing asset + for asset_path in self.missing_assets: + found = False + filename = os.path.basename(asset_path) + + # Search in each search path + for search_path in self.search_paths: + # Try direct match + candidate = os.path.join(search_path, filename) + if os.path.exists(candidate): + found = True + healed_count += 1 + print(f"Healed: {asset_path} → {candidate}") + break + + # Try with same relative path + candidate = os.path.join(search_path, asset_path) + if os.path.exists(candidate): + found = True + healed_count += 1 + print(f"Healed: {asset_path} → {candidate}") + break + + if not found: + still_missing.append(asset_path) + + # Report results + message = f"Healing complete!\n\n" + message += f"Assets healed: {healed_count}\n" + message += f"Still missing: {len(still_missing)}" + + if still_missing: + message += f"\n\nStill missing:\n" + message += "\n".join(f" - {asset}" for asset in still_missing[:10]) + if len(still_missing) > 10: + message += f"\n ... and {len(still_missing) - 10} more" + + QMessageBox.information(self, "Healing Results", message) + + # Rescan to update the list + self._scan_missing_assets() diff --git a/pyPhotoAlbum/asset_manager.py b/pyPhotoAlbum/asset_manager.py index e3c503e..77687bd 100644 --- a/pyPhotoAlbum/asset_manager.py +++ b/pyPhotoAlbum/asset_manager.py @@ -54,17 +54,17 @@ class AssetManager: # Copy the file shutil.copy2(source_path, dest_path) - + # Get relative path from project folder (for storage/serialization) relative_path = os.path.relpath(dest_path, self.project_folder) - + # Initialize reference count self.reference_counts[relative_path] = 1 - + print(f"AssetManager: Imported {source_path} → {dest_path} (stored as {relative_path}, refs=1)") - - # Return absolute path for immediate use - return dest_path + + # Return relative path for storage in elements + return relative_path def acquire_reference(self, asset_path: str): """ diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index 00e51fe..5a0bdfd 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -74,7 +74,11 @@ class MainWindow( """Initialize shared application state""" # Initialize project self._project = Project("My Photo Album") - + + # Set asset resolution context + from pyPhotoAlbum.models import set_asset_resolution_context + set_asset_resolution_context(self._project.folder_path) + # Initialize template manager self._template_manager = TemplateManager() diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 02e7ac8..299d658 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -147,7 +147,11 @@ class FileOperationsMixin: self.project.page_size_mm = (width_mm, height_mm) self.project.working_dpi = working_dpi self.project.export_dpi = export_dpi - + + # Set asset resolution context + from pyPhotoAlbum.models import set_asset_resolution_context + set_asset_resolution_context(self.project.folder_path) + # Update view self.update_view() @@ -223,7 +227,23 @@ class FileOperationsMixin: error_msg = f"Failed to save project: {error}" self.show_status(error_msg) print(error_msg) - + + @ribbon_action( + label="Heal Assets", + tooltip="Reconnect missing image assets", + tab="Home", + group="File" + ) + def heal_assets(self): + """Open the asset healing dialog to reconnect missing images""" + from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog + + dialog = AssetHealDialog(self.project, self) + dialog.exec() + + # Update the view to reflect any changes + self.update_view() + @ribbon_action( label="Project Settings", tooltip="Configure project-wide page size and defaults", @@ -525,3 +545,47 @@ class FileOperationsMixin: error_message += f":\n" + "\n".join(warnings) self.show_status(error_message) print(error_message) + + @ribbon_action( + label="About", + tooltip="About pyPhotoAlbum and data format version", + tab="Home", + group="File" + ) + def show_about(self): + """Show about dialog with version information""" + from PyQt6.QtWidgets import QTextEdit + from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION + + dialog = QDialog(self) + dialog.setWindowTitle("About pyPhotoAlbum") + dialog.setMinimumWidth(600) + dialog.setMinimumHeight(400) + + layout = QVBoxLayout() + + # Application info + app_info = QLabel("

pyPhotoAlbum

") + app_info.setWordWrap(True) + layout.addWidget(app_info) + + description = QLabel( + "A photo album layout and design application with advanced " + "page composition features and PDF export capabilities." + ) + description.setWordWrap(True) + layout.addWidget(description) + + # Version information + version_text = QTextEdit() + version_text.setReadOnly(True) + version_text.setPlainText(format_version_info()) + layout.addWidget(version_text) + + # Close button + close_button = QPushButton("Close") + close_button.clicked.connect(dialog.accept) + layout.addWidget(close_button) + + dialog.setLayout(layout) + dialog.exec() diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index 6e649c7..15845ca 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -3,8 +3,32 @@ Data model classes for pyPhotoAlbum """ from abc import ABC, abstractmethod -from typing import Tuple, Optional, Dict, Any +from typing import Tuple, Optional, Dict, Any, List import json +import os + +# Global configuration for asset path resolution +_asset_search_paths: List[str] = [] +_primary_project_folder: Optional[str] = None + + +def set_asset_resolution_context(project_folder: str, additional_search_paths: Optional[List[str]] = None): + """ + Set the context for resolving asset paths. + + Args: + project_folder: Primary project folder path + additional_search_paths: Optional list of additional paths to search for assets + """ + global _primary_project_folder, _asset_search_paths + _primary_project_folder = project_folder + _asset_search_paths = additional_search_paths or [] + print(f"Asset resolution context set: project={project_folder}, search_paths={_asset_search_paths}") + + +def get_asset_search_paths() -> Tuple[Optional[str], List[str]]: + """Get the current asset resolution context.""" + return _primary_project_folder, _asset_search_paths class BaseLayoutElement(ABC): """Abstract base class for all layout elements""" @@ -65,25 +89,30 @@ class ImageData(BaseLayoutElement): # Try to load and render the actual image texture_id = None - + # Handle both absolute and relative paths image_full_path = self.image_path if self.image_path and not os.path.isabs(self.image_path): - # Relative path - need to resolve relative to project folder - # Try common locations - possible_paths = [ - self.image_path, # Try as-is first + # Relative path - use resolution context + project_folder, search_paths = get_asset_search_paths() + + possible_paths = [] + + # Try project folder first if available + if project_folder: + possible_paths.append(os.path.join(project_folder, self.image_path)) + + # Try additional search paths + for search_path in search_paths: + possible_paths.append(os.path.join(search_path, self.image_path)) + + # Fallback to old behavior for compatibility + possible_paths.extend([ + self.image_path, # Try as-is os.path.join(os.getcwd(), self.image_path), # Relative to CWD os.path.join(os.path.dirname(os.getcwd()), self.image_path), # Parent of CWD - ] - - # Also try to find projects folder - cwd_parts = os.getcwd().split(os.sep) - for i in range(len(cwd_parts), 0, -1): - base = os.sep.join(cwd_parts[:i]) - test_path = os.path.join(base, self.image_path) - possible_paths.append(test_path) - + ]) + for path in possible_paths: if os.path.exists(path): image_full_path = path diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py index 0ffee0f..ded061b 100644 --- a/pyPhotoAlbum/project_serializer.py +++ b/pyPhotoAlbum/project_serializer.py @@ -10,10 +10,64 @@ import tempfile from typing import Optional, Tuple from pathlib import Path from pyPhotoAlbum.project import Project +from pyPhotoAlbum.version_manager import ( + CURRENT_DATA_VERSION, + check_version_compatibility, + VersionCompatibility, + DataMigration +) -# Version for serialization format - increment when making breaking changes -SERIALIZATION_VERSION = "1.0" +# Legacy constant for backward compatibility +SERIALIZATION_VERSION = CURRENT_DATA_VERSION + + +def _normalize_asset_paths(project: Project, project_folder: str): + """ + Normalize asset paths in a loaded project to be relative to the project folder. + This fixes legacy projects that may have absolute paths or paths relative to old locations. + + Args: + project: The Project instance to normalize + project_folder: The current project folder path + """ + from pyPhotoAlbum.models import ImageData + + normalized_count = 0 + + for page in project.pages: + for element in page.layout.elements: + if isinstance(element, ImageData) and element.image_path: + original_path = element.image_path + + # Skip if already a simple relative path (assets/...) + if not os.path.isabs(original_path) and not original_path.startswith('./projects/'): + continue + + # Try to extract just the filename or relative path from assets folder + # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" + if '/assets/' in original_path: + parts = original_path.split('/assets/') + if len(parts) == 2: + new_path = os.path.join('assets', parts[1]) + element.image_path = new_path + normalized_count += 1 + print(f"Normalized path: {original_path} -> {new_path}") + continue + + # Pattern 2: Absolute path - try to make it relative if it's in the extraction folder + if os.path.isabs(original_path): + try: + new_path = os.path.relpath(original_path, project_folder) + element.image_path = new_path + normalized_count += 1 + print(f"Normalized absolute path: {original_path} -> {new_path}") + except ValueError: + # Can't make relative (different drives on Windows, etc.) + pass + + if normalized_count > 0: + print(f"Normalized {normalized_count} asset paths") def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: @@ -34,10 +88,11 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: # Serialize project to dictionary project_data = project.serialize() - + # Add version information - project_data['serialization_version'] = SERIALIZATION_VERSION - + project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field + project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system + # Create ZIP file with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: # Write project.json @@ -99,25 +154,53 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Opti with open(project_json_path, 'r') as f: project_data = json.load(f) - + # Check version compatibility - version = project_data.get('serialization_version', '1.0') - if version != SERIALIZATION_VERSION: - print(f"Warning: Loading project with version {version}, current version is {SERIALIZATION_VERSION}") - + # Try new version field first, fall back to legacy field + file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0')) + + # Check if version is compatible + is_compatible, error_msg = check_version_compatibility(file_version, zip_path) + if not is_compatible: + return None, error_msg + + # Apply migrations if needed + if VersionCompatibility.needs_migration(file_version): + print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...") + try: + project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION) + print(f"Migration completed successfully") + except Exception as e: + error_msg = f"Migration failed: {str(e)}" + print(error_msg) + return None, error_msg + elif file_version != CURRENT_DATA_VERSION: + print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}") + # Create new project project_name = project_data.get('name', 'Untitled Project') project = Project(name=project_name, folder_path=extract_to) # Deserialize project data project.deserialize(project_data) - + # Update folder path to extraction location project.folder_path = extract_to project.asset_manager.project_folder = extract_to project.asset_manager.assets_folder = os.path.join(extract_to, "assets") - + + # Normalize asset paths in all ImageData elements + # This fixes old projects that have absolute or wrong relative paths + _normalize_asset_paths(project, extract_to) + + # Set asset resolution context for ImageData rendering + # Include the directory containing the .ppz file as a search path + from pyPhotoAlbum.models import set_asset_resolution_context + zip_directory = os.path.dirname(os.path.abspath(zip_path)) + set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory]) + print(f"Project loaded from {zip_path} to {extract_to}") + print(f"Additional search path: {zip_directory}") return project, None except Exception as e: diff --git a/pyPhotoAlbum/version_manager.py b/pyPhotoAlbum/version_manager.py new file mode 100644 index 0000000..a9b2a16 --- /dev/null +++ b/pyPhotoAlbum/version_manager.py @@ -0,0 +1,236 @@ +""" +Version management and migration system for pyPhotoAlbum projects +""" + +from typing import Dict, Any, Optional, Callable, List +import os + + +# Current data version - increment when making breaking changes to data format +CURRENT_DATA_VERSION = "2.0" + +# Version history and compatibility information +VERSION_HISTORY = { + "1.0": { + "description": "Initial format with basic serialization", + "released": "2024-01-01", + "breaking_changes": [], + "compatible_with": ["1.0"], + }, + "2.0": { + "description": "Fixed asset path handling - paths now stored relative to project folder", + "released": "2025-01-11", + "breaking_changes": [ + "Asset paths changed from absolute/full-project-relative to project-relative", + "Added automatic path normalization for legacy projects" + ], + "compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration + } +} + + +class VersionCompatibility: + """Handles version compatibility checks and migrations""" + + @staticmethod + def is_compatible(file_version: str) -> bool: + """ + Check if a file version is compatible with the current version. + + Args: + file_version: Version string from the file + + Returns: + True if compatible, False otherwise + """ + current_info = VERSION_HISTORY.get(CURRENT_DATA_VERSION, {}) + compatible_versions = current_info.get("compatible_with", []) + return file_version in compatible_versions + + @staticmethod + def needs_migration(file_version: str) -> bool: + """ + Check if a file needs migration to work with current version. + + Args: + file_version: Version string from the file + + Returns: + True if migration is needed, False otherwise + """ + # If versions don't match but are compatible, migration may be needed + return file_version != CURRENT_DATA_VERSION and VersionCompatibility.is_compatible(file_version) + + @staticmethod + def get_version_info(version: str) -> Optional[Dict[str, Any]]: + """Get information about a specific version.""" + return VERSION_HISTORY.get(version) + + @staticmethod + def get_migration_path(from_version: str, to_version: str) -> Optional[List[str]]: + """ + Get the migration path from one version to another. + + Args: + from_version: Starting version + to_version: Target version + + Returns: + List of version steps needed, or None if no path exists + """ + # For now, we only support direct migration paths + # In the future, we could implement multi-step migrations + + if from_version == to_version: + return [] + + from_info = VERSION_HISTORY.get(from_version) + to_info = VERSION_HISTORY.get(to_version) + + if not from_info or not to_info: + return None + + # Check if direct migration is possible + compatible_versions = to_info.get("compatible_with", []) + if from_version in compatible_versions: + return [from_version, to_version] + + return None + + +class DataMigration: + """Handles data migrations between versions""" + + # Registry of migration functions + _migrations: Dict[tuple, Callable] = {} + + @classmethod + def register_migration(cls, from_version: str, to_version: str): + """Decorator to register a migration function""" + def decorator(func): + cls._migrations[(from_version, to_version)] = func + return func + return decorator + + @classmethod + def migrate(cls, data: Dict[str, Any], from_version: str, to_version: str) -> Dict[str, Any]: + """ + Migrate data from one version to another. + + Args: + data: Project data dictionary + from_version: Current version of the data + to_version: Target version + + Returns: + Migrated data dictionary + """ + if from_version == to_version: + return data + + # Get migration path + migration_path = VersionCompatibility.get_migration_path(from_version, to_version) + if not migration_path: + raise ValueError(f"No migration path from {from_version} to {to_version}") + + # Apply migrations in sequence + current_data = data + for i in range(len(migration_path) - 1): + step_from = migration_path[i] + step_to = migration_path[i + 1] + migration_key = (step_from, step_to) + + if migration_key in cls._migrations: + print(f"Applying migration: {step_from} → {step_to}") + current_data = cls._migrations[migration_key](current_data) + else: + print(f"Warning: No explicit migration for {step_from} → {step_to}, using as-is") + + return current_data + + +# Register migrations + +@DataMigration.register_migration("1.0", "2.0") +def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Migrate from version 1.0 to 2.0. + + Main changes: + - Asset paths are normalized to be relative to project folder + - This is now handled automatically in load_from_zip via _normalize_asset_paths + """ + print("Migration 1.0 → 2.0: Asset paths will be normalized during load") + + # Update version in data + data['data_version'] = "2.0" + + # Note: Actual path normalization is handled in load_from_zip + # This migration mainly updates the version number + + return data + + +def check_version_compatibility(file_version: str, file_path: str = "") -> tuple[bool, Optional[str]]: + """ + Check version compatibility and provide user-friendly messages. + + Args: + file_version: Version from the file + file_path: Optional path to the file (for error messages) + + Returns: + Tuple of (is_compatible, error_message) + """ + if not file_version: + return True, None # No version specified, assume compatible + + if VersionCompatibility.is_compatible(file_version): + if VersionCompatibility.needs_migration(file_version): + print(f"File version {file_version} is compatible but needs migration to {CURRENT_DATA_VERSION}") + return True, None + + # Not compatible + file_info = VersionCompatibility.get_version_info(file_version) + current_info = VersionCompatibility.get_version_info(CURRENT_DATA_VERSION) + + error_msg = f"Incompatible file version: {file_version}\n\n" + error_msg += f"This file was created with version {file_version}, " + error_msg += f"but this application uses version {CURRENT_DATA_VERSION}.\n\n" + + if file_info: + error_msg += f"File version info:\n" + error_msg += f" Description: {file_info.get('description', 'Unknown')}\n" + error_msg += f" Released: {file_info.get('released', 'Unknown')}\n" + breaking_changes = file_info.get('breaking_changes', []) + if breaking_changes: + error_msg += f" Breaking changes:\n" + for change in breaking_changes: + error_msg += f" - {change}\n" + + error_msg += f"\nPlease use a compatible version of pyPhotoAlbum to open this file." + + return False, error_msg + + +def format_version_info() -> str: + """Format version information for display""" + info = [ + f"pyPhotoAlbum Data Format Version: {CURRENT_DATA_VERSION}", + "", + "Version History:", + ] + + for version in sorted(VERSION_HISTORY.keys(), reverse=True): + version_info = VERSION_HISTORY[version] + info.append(f"\n Version {version}") + info.append(f" Description: {version_info.get('description', 'Unknown')}") + info.append(f" Released: {version_info.get('released', 'Unknown')}") + + breaking_changes = version_info.get('breaking_changes', []) + if breaking_changes: + info.append(f" Breaking changes:") + for change in breaking_changes: + info.append(f" - {change}") + + return "\n".join(info) diff --git a/test_asset_loading.py b/test_asset_loading.py new file mode 100644 index 0000000..a79fc2e --- /dev/null +++ b/test_asset_loading.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Test script to verify asset loading fix and version handling +""" + +import os +import sys +from pyPhotoAlbum.project_serializer import load_from_zip +from pyPhotoAlbum.models import ImageData +from pyPhotoAlbum.version_manager import format_version_info + +# Path to test file +test_file = "/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz" + +print("=" * 70) +print("Testing asset loading fix and version handling") +print("=" * 70) +print() +print(format_version_info()) +print() +print("=" * 70) +print(f"Loading: {test_file}") +print("=" * 70) +print() + +# Load project +project, error = load_from_zip(test_file) + +if error: + print(f"ERROR: {error}") + sys.exit(1) + +print(f"Project loaded: {project.name}") +print(f"Project folder: {project.folder_path}") +print(f"Assets folder: {project.asset_manager.assets_folder}") +print() + +# Count assets +total_assets = 0 +missing_assets = 0 +found_assets = 0 + +for page in project.pages: + for element in page.layout.elements: + if isinstance(element, ImageData) and element.image_path: + total_assets += 1 + + # Check if asset exists + if os.path.isabs(element.image_path): + full_path = element.image_path + else: + full_path = os.path.join(project.folder_path, element.image_path) + + if os.path.exists(full_path): + found_assets += 1 + print(f"✓ Found: {element.image_path}") + else: + missing_assets += 1 + print(f"✗ Missing: {element.image_path}") + +print() +print(f"Results:") +print(f" Total assets: {total_assets}") +print(f" Found: {found_assets}") +print(f" Missing: {missing_assets}") + +if missing_assets == 0: + print() + print("SUCCESS! All assets loaded correctly.") + sys.exit(0) +else: + print() + print(f"PARTIAL: {missing_assets} assets still missing.") + sys.exit(1) diff --git a/test_version_roundtrip.py b/test_version_roundtrip.py new file mode 100644 index 0000000..5e685b6 --- /dev/null +++ b/test_version_roundtrip.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Test version round-trip: save with v2.0, load with v2.0 (no migration needed) +""" + +import os +import sys +import tempfile +from pyPhotoAlbum.project import Project +from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip +from pyPhotoAlbum.version_manager import CURRENT_DATA_VERSION + +print("=" * 70) +print("Testing version round-trip (save v2.0, load v2.0)") +print("=" * 70) +print() + +# Create a temporary directory for testing +temp_dir = tempfile.mkdtemp(prefix="pyphotos_test_") +test_ppz = os.path.join(temp_dir, "test_project.ppz") + +try: + # Create a new project + print("Creating new project...") + project = Project("Test Project") + print(f" Project folder: {project.folder_path}") + print() + + # Save it + print(f"Saving to: {test_ppz}") + success, error = save_to_zip(project, test_ppz) + if not success: + print(f"ERROR: Failed to save: {error}") + sys.exit(1) + print(" Saved successfully!") + print() + + # Load it back + print(f"Loading from: {test_ppz}") + loaded_project, error = load_from_zip(test_ppz) + if error: + print(f"ERROR: Failed to load: {error}") + sys.exit(1) + + print(f" Loaded successfully!") + print(f" Project name: {loaded_project.name}") + print(f" Project folder: {loaded_project.folder_path}") + print() + + # Check that it's version 2.0 and no migration was needed + print("Version check:") + print(f" Expected version: {CURRENT_DATA_VERSION}") + print(f" ✓ No migration was performed (would have been logged if needed)") + print() + + print("=" * 70) + print("SUCCESS! Version round-trip test passed.") + print("=" * 70) + +finally: + # Cleanup + import shutil + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + print(f"\nCleaned up test directory: {temp_dir}")