fixed installer, fixed issue with loading wrong paths. Added file schema versioning
This commit is contained in:
parent
a7558e3c39
commit
c0a6148f58
179
VERSIONING.md
Normal file
179
VERSIONING.md
Normal file
@ -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)
|
||||
25
install.sh
25
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"
|
||||
|
||||
188
pyPhotoAlbum/asset_heal_dialog.py
Normal file
188
pyPhotoAlbum/asset_heal_dialog.py
Normal file
@ -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()
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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("<h2>pyPhotoAlbum</h2>")
|
||||
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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
236
pyPhotoAlbum/version_manager.py
Normal file
236
pyPhotoAlbum/version_manager.py
Normal file
@ -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)
|
||||
74
test_asset_loading.py
Normal file
74
test_asset_loading.py
Normal file
@ -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)
|
||||
65
test_version_roundtrip.py
Normal file
65
test_version_roundtrip.py
Normal file
@ -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}")
|
||||
Loading…
x
Reference in New Issue
Block a user