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
|
# Installation script for pyPhotoAlbum
|
||||||
# Supports both system-wide and user-local installation
|
# 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
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@ -47,8 +48,11 @@ install_dependencies() {
|
|||||||
case "$distro" in
|
case "$distro" in
|
||||||
fedora)
|
fedora)
|
||||||
print_info "Installing dependencies for Fedora..."
|
print_info "Installing dependencies for Fedora..."
|
||||||
sudo dnf install -y python3 python3-pip python3-qt6 python3-pyopengl \
|
# Use --skip-unavailable and --allowerasing to handle already installed packages
|
||||||
python3-numpy python3-pillow python3-reportlab python3-lxml
|
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)
|
arch|cachyos)
|
||||||
print_info "Installing dependencies for Arch/CachyOS..."
|
print_info "Installing dependencies for Arch/CachyOS..."
|
||||||
@ -124,8 +128,9 @@ install_desktop_integration() {
|
|||||||
# Create directories if they don't exist
|
# Create directories if they don't exist
|
||||||
mkdir -p ~/.local/share/applications
|
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/
|
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
|
# Install icons in multiple sizes for GNOME
|
||||||
print_info "Installing application icons..."
|
print_info "Installing application icons..."
|
||||||
@ -134,6 +139,18 @@ install_desktop_integration() {
|
|||||||
mkdir -p ~/.local/share/icons/hicolor/256x256/apps
|
mkdir -p ~/.local/share/icons/hicolor/256x256/apps
|
||||||
cp pyPhotoAlbum/icons/icon.png ~/.local/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
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
|
# Install additional sizes if they exist
|
||||||
for size in 16 22 24 32 48 64 128 512; do
|
for size in 16 22 24 32 48 64 128 512; do
|
||||||
icon_file="pyPhotoAlbum/icons/icon-${size}x${size}.png"
|
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
|
# Copy the file
|
||||||
shutil.copy2(source_path, dest_path)
|
shutil.copy2(source_path, dest_path)
|
||||||
|
|
||||||
# Get relative path from project folder (for storage/serialization)
|
# Get relative path from project folder (for storage/serialization)
|
||||||
relative_path = os.path.relpath(dest_path, self.project_folder)
|
relative_path = os.path.relpath(dest_path, self.project_folder)
|
||||||
|
|
||||||
# Initialize reference count
|
# Initialize reference count
|
||||||
self.reference_counts[relative_path] = 1
|
self.reference_counts[relative_path] = 1
|
||||||
|
|
||||||
print(f"AssetManager: Imported {source_path} → {dest_path} (stored as {relative_path}, refs=1)")
|
print(f"AssetManager: Imported {source_path} → {dest_path} (stored as {relative_path}, refs=1)")
|
||||||
|
|
||||||
# Return absolute path for immediate use
|
# Return relative path for storage in elements
|
||||||
return dest_path
|
return relative_path
|
||||||
|
|
||||||
def acquire_reference(self, asset_path: str):
|
def acquire_reference(self, asset_path: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -74,7 +74,11 @@ class MainWindow(
|
|||||||
"""Initialize shared application state"""
|
"""Initialize shared application state"""
|
||||||
# Initialize project
|
# Initialize project
|
||||||
self._project = Project("My Photo Album")
|
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
|
# Initialize template manager
|
||||||
self._template_manager = TemplateManager()
|
self._template_manager = TemplateManager()
|
||||||
|
|
||||||
|
|||||||
@ -147,7 +147,11 @@ class FileOperationsMixin:
|
|||||||
self.project.page_size_mm = (width_mm, height_mm)
|
self.project.page_size_mm = (width_mm, height_mm)
|
||||||
self.project.working_dpi = working_dpi
|
self.project.working_dpi = working_dpi
|
||||||
self.project.export_dpi = export_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
|
# Update view
|
||||||
self.update_view()
|
self.update_view()
|
||||||
|
|
||||||
@ -223,7 +227,23 @@ class FileOperationsMixin:
|
|||||||
error_msg = f"Failed to save project: {error}"
|
error_msg = f"Failed to save project: {error}"
|
||||||
self.show_status(error_msg)
|
self.show_status(error_msg)
|
||||||
print(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(
|
@ribbon_action(
|
||||||
label="Project Settings",
|
label="Project Settings",
|
||||||
tooltip="Configure project-wide page size and defaults",
|
tooltip="Configure project-wide page size and defaults",
|
||||||
@ -525,3 +545,47 @@ class FileOperationsMixin:
|
|||||||
error_message += f":\n" + "\n".join(warnings)
|
error_message += f":\n" + "\n".join(warnings)
|
||||||
self.show_status(error_message)
|
self.show_status(error_message)
|
||||||
print(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 abc import ABC, abstractmethod
|
||||||
from typing import Tuple, Optional, Dict, Any
|
from typing import Tuple, Optional, Dict, Any, List
|
||||||
import json
|
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):
|
class BaseLayoutElement(ABC):
|
||||||
"""Abstract base class for all layout elements"""
|
"""Abstract base class for all layout elements"""
|
||||||
@ -65,25 +89,30 @@ class ImageData(BaseLayoutElement):
|
|||||||
|
|
||||||
# Try to load and render the actual image
|
# Try to load and render the actual image
|
||||||
texture_id = None
|
texture_id = None
|
||||||
|
|
||||||
# Handle both absolute and relative paths
|
# Handle both absolute and relative paths
|
||||||
image_full_path = self.image_path
|
image_full_path = self.image_path
|
||||||
if self.image_path and not os.path.isabs(self.image_path):
|
if self.image_path and not os.path.isabs(self.image_path):
|
||||||
# Relative path - need to resolve relative to project folder
|
# Relative path - use resolution context
|
||||||
# Try common locations
|
project_folder, search_paths = get_asset_search_paths()
|
||||||
possible_paths = [
|
|
||||||
self.image_path, # Try as-is first
|
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.getcwd(), self.image_path), # Relative to CWD
|
||||||
os.path.join(os.path.dirname(os.getcwd()), self.image_path), # Parent of 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:
|
for path in possible_paths:
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
image_full_path = path
|
image_full_path = path
|
||||||
|
|||||||
@ -10,10 +10,64 @@ import tempfile
|
|||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pyPhotoAlbum.project import Project
|
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
|
# Legacy constant for backward compatibility
|
||||||
SERIALIZATION_VERSION = "1.0"
|
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]]:
|
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
|
# Serialize project to dictionary
|
||||||
project_data = project.serialize()
|
project_data = project.serialize()
|
||||||
|
|
||||||
# Add version information
|
# 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
|
# Create ZIP file
|
||||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
# Write project.json
|
# 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:
|
with open(project_json_path, 'r') as f:
|
||||||
project_data = json.load(f)
|
project_data = json.load(f)
|
||||||
|
|
||||||
# Check version compatibility
|
# Check version compatibility
|
||||||
version = project_data.get('serialization_version', '1.0')
|
# Try new version field first, fall back to legacy field
|
||||||
if version != SERIALIZATION_VERSION:
|
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
|
||||||
print(f"Warning: Loading project with version {version}, current version is {SERIALIZATION_VERSION}")
|
|
||||||
|
# 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
|
# Create new project
|
||||||
project_name = project_data.get('name', 'Untitled Project')
|
project_name = project_data.get('name', 'Untitled Project')
|
||||||
project = Project(name=project_name, folder_path=extract_to)
|
project = Project(name=project_name, folder_path=extract_to)
|
||||||
|
|
||||||
# Deserialize project data
|
# Deserialize project data
|
||||||
project.deserialize(project_data)
|
project.deserialize(project_data)
|
||||||
|
|
||||||
# Update folder path to extraction location
|
# Update folder path to extraction location
|
||||||
project.folder_path = extract_to
|
project.folder_path = extract_to
|
||||||
project.asset_manager.project_folder = extract_to
|
project.asset_manager.project_folder = extract_to
|
||||||
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
|
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"Project loaded from {zip_path} to {extract_to}")
|
||||||
|
print(f"Additional search path: {zip_directory}")
|
||||||
return project, None
|
return project, None
|
||||||
|
|
||||||
except Exception as e:
|
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