Coverage for pyPhotoAlbum/async_project_loader.py: 15%
131 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Async project loader for pyPhotoAlbum
4Loads projects asynchronously with progress updates to prevent UI freezing.
5"""
7import os
8import json
9import zipfile
10import tempfile
11from typing import Optional, Tuple
12from pathlib import Path
13from PyQt6.QtCore import QThread, pyqtSignal
15from pyPhotoAlbum.project import Project
16from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
17from pyPhotoAlbum.version_manager import (
18 CURRENT_DATA_VERSION,
19 check_version_compatibility,
20 VersionCompatibility,
21 DataMigration,
22)
25class AsyncProjectLoader(QThread):
26 """
27 Async worker thread for loading projects from ZIP files.
29 Signals:
30 progress_updated(int, int, str): Emitted with (current, total, message)
31 load_complete(Project): Emitted when loading succeeds
32 load_failed(str): Emitted when loading fails with error message
33 """
35 progress_updated = pyqtSignal(int, int, str) # current, total, message
36 load_complete = pyqtSignal(object) # Project instance
37 load_failed = pyqtSignal(str) # error message
39 def __init__(self, zip_path: str, extract_to: Optional[str] = None):
40 super().__init__()
41 self.zip_path = zip_path
42 self.extract_to = extract_to
43 self._cancelled = False
45 def cancel(self):
46 """Cancel the loading operation"""
47 self._cancelled = True
49 def run(self):
50 """Run the async loading operation"""
51 try:
52 if not os.path.exists(self.zip_path):
53 self.load_failed.emit(f"ZIP file not found: {self.zip_path}")
54 return
56 if self._cancelled:
57 return
59 # Progress: Starting
60 self.progress_updated.emit(0, 100, "Preparing to load...")
62 # Track if we created a temp directory
63 temp_dir_obj = None
65 # Determine extraction directory
66 if self.extract_to is None:
67 zip_basename = os.path.splitext(os.path.basename(self.zip_path))[0]
68 temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
69 extract_to = temp_dir_obj.name
70 else:
71 os.makedirs(self.extract_to, exist_ok=True)
72 extract_to = self.extract_to
74 if self._cancelled:
75 return
77 # Progress: Extracting ZIP
78 self.progress_updated.emit(10, 100, "Extracting project files...")
80 # Extract ZIP contents with progress
81 with zipfile.ZipFile(self.zip_path, "r") as zipf:
82 file_list = zipf.namelist()
83 total_files = len(file_list)
85 for i, filename in enumerate(file_list):
86 if self._cancelled:
87 return
89 zipf.extract(filename, extract_to)
91 # Update progress every 10 files or on last file
92 if i % 10 == 0 or i == total_files - 1:
93 progress = 10 + int((i / total_files) * 30) # 10-40%
94 self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
96 if self._cancelled:
97 return
99 # Progress: Loading project data
100 self.progress_updated.emit(45, 100, "Loading project data...")
102 # Load project.json
103 project_json_path = os.path.join(extract_to, "project.json")
104 if not os.path.exists(project_json_path):
105 self.load_failed.emit("Invalid project file: project.json not found")
106 return
108 with open(project_json_path, "r") as f:
109 project_data = json.load(f)
111 if self._cancelled:
112 return
114 # Progress: Checking version
115 self.progress_updated.emit(55, 100, "Checking version compatibility...")
117 # Check version compatibility
118 file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
120 is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
121 if not is_compatible:
122 self.load_failed.emit(error_msg)
123 return
125 # Apply migrations if needed
126 if VersionCompatibility.needs_migration(file_version):
127 self.progress_updated.emit(60, 100, f"Migrating from version {file_version}...")
128 try:
129 project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
130 except Exception as e:
131 self.load_failed.emit(f"Migration failed: {str(e)}")
132 return
134 if self._cancelled:
135 return
137 # Progress: Creating project
138 self.progress_updated.emit(70, 100, "Creating project...")
140 # Create new project
141 project_name = project_data.get("name", "Untitled Project")
142 project = Project(name=project_name, folder_path=extract_to)
144 # Deserialize project data
145 project.deserialize(project_data)
147 # Update folder path to extraction location
148 project.folder_path = extract_to
149 project.asset_manager.project_folder = extract_to
150 project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
152 # Attach temporary directory to project (if we created one)
153 if temp_dir_obj is not None:
154 project._temp_dir = temp_dir_obj
156 if self._cancelled:
157 return
159 # Progress: Normalizing paths
160 self.progress_updated.emit(85, 100, "Normalizing asset paths...")
162 # Normalize asset paths
163 self._normalize_asset_paths(project, extract_to)
165 # Progress: Setting up asset resolution
166 self.progress_updated.emit(95, 100, "Setting up asset resolution...")
168 # Set asset resolution context
169 # Only set project folder - search paths are reserved for healing functionality
170 set_asset_resolution_context(extract_to)
172 if self._cancelled:
173 return
175 # Progress: Complete
176 self.progress_updated.emit(100, 100, "Loading complete!")
178 # Emit success
179 self.load_complete.emit(project)
181 except Exception as e:
182 error_msg = f"Error loading project: {str(e)}"
183 self.load_failed.emit(error_msg)
185 def _normalize_asset_paths(self, project: Project, project_folder: str):
186 """
187 Normalize asset paths in a loaded project to be relative to the project folder.
188 """
189 normalized_count = 0
191 for page in project.pages:
192 for element in page.layout.elements:
193 if isinstance(element, ImageData) and element.image_path:
194 original_path = element.image_path
196 # Skip if already a simple relative path
197 if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
198 continue
200 # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
201 if "/assets/" in original_path:
202 parts = original_path.split("/assets/")
203 if len(parts) == 2:
204 new_path = os.path.join("assets", parts[1])
205 element.image_path = new_path
206 normalized_count += 1
207 continue
209 # Pattern 2: Absolute path - try to make it relative
210 if os.path.isabs(original_path):
211 try:
212 new_path = os.path.relpath(original_path, project_folder)
213 element.image_path = new_path
214 normalized_count += 1
215 except ValueError:
216 pass
218 if normalized_count > 0:
219 print(f"Normalized {normalized_count} asset paths")
222def load_from_zip_async(
223 zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None
224) -> AsyncProjectLoader:
225 """
226 Load a project from a ZIP file asynchronously.
228 Args:
229 zip_path: Path to the ZIP file to load
230 extract_to: Optional directory to extract to. If None, uses a temporary directory.
231 progress_callback: Optional callback(current, total, message) for progress updates
232 complete_callback: Optional callback(project) when loading completes
233 error_callback: Optional callback(error_msg) when loading fails
235 Returns:
236 AsyncProjectLoader instance (already started)
237 """
238 loader = AsyncProjectLoader(zip_path, extract_to)
240 if progress_callback:
241 loader.progress_updated.connect(progress_callback)
242 if complete_callback:
243 loader.load_complete.connect(complete_callback)
244 if error_callback:
245 loader.load_failed.connect(error_callback)
247 loader.start()
248 return loader