1 """

2 Async project loader for pyPhotoAlbum

3

4 Loads projects asynchronously with progress updates to prevent UI freezing.

5 """

6

7 import os

8 import json

9 import zipfile

10 import tempfile

11 from typing import Optional, Tuple

12 from pathlib import Path

13 from PyQt6.QtCore import QThread, pyqtSignal

14

15 from pyPhotoAlbum.project import Project

16 from pyPhotoAlbum.models import ImageData, set_asset_resolution_context

17 from pyPhotoAlbum.version_manager import (

18 CURRENT_DATA_VERSION,

19 check_version_compatibility,

20 VersionCompatibility,

21 DataMigration,

22 )

23

24

25 class AsyncProjectLoader(QThread):

26 """

27 Async worker thread for loading projects from ZIP files.

28

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 """

34

35 progress_updated = pyqtSignal(int, int, str) # current, total, message

36 load_complete = pyqtSignal(object) # Project instance

37 load_failed = pyqtSignal(str) # error message

38

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

44

45 def cancel(self):

46 """Cancel the loading operation"""

47 self._cancelled = True

48

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

55

56 if self._cancelled:

57 return

58

59 # Progress: Starting

60 self.progress_updated.emit(0, 100, "Preparing to load...")

61

62 # Track if we created a temp directory

63 temp_dir_obj = None

64

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

73

74 if self._cancelled:

75 return

76

77 # Progress: Extracting ZIP

78 self.progress_updated.emit(10, 100, "Extracting project files...")

79

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)

84

85 for i, filename in enumerate(file_list):

86 if self._cancelled:

87 return

88

89 zipf.extract(filename, extract_to)

90

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})")

95

96 if self._cancelled:

97 return

98

99 # Progress: Loading project data

100 self.progress_updated.emit(45, 100, "Loading project data...")

101

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

107

108 with open(project_json_path, "r") as f:

109 project_data = json.load(f)

110

111 if self._cancelled:

112 return

113

114 # Progress: Checking version

115 self.progress_updated.emit(55, 100, "Checking version compatibility...")

116

117 # Check version compatibility

118 file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))

119

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

124

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

133

134 if self._cancelled:

135 return

136

137 # Progress: Creating project

138 self.progress_updated.emit(70, 100, "Creating project...")

139

140 # Create new project

141 project_name = project_data.get("name", "Untitled Project")

142 project = Project(name=project_name, folder_path=extract_to)

143

144 # Deserialize project data

145 project.deserialize(project_data)

146

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")

151

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

155

156 if self._cancelled:

157 return

158

159 # Progress: Normalizing paths

160 self.progress_updated.emit(85, 100, "Normalizing asset paths...")

161

162 # Normalize asset paths

163 self._normalize_asset_paths(project, extract_to)

164

165 # Progress: Setting up asset resolution

166 self.progress_updated.emit(95, 100, "Setting up asset resolution...")

167

168 # Set asset resolution context

169 # Only set project folder - search paths are reserved for healing functionality

170 set_asset_resolution_context(extract_to)

171

172 if self._cancelled:

173 return

174

175 # Progress: Complete

176 self.progress_updated.emit(100, 100, "Loading complete!")

177

178 # Emit success

179 self.load_complete.emit(project)

180

181 except Exception as e:

182 error_msg = f"Error loading project: {str(e)}"

183 self.load_failed.emit(error_msg)

184

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

190

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

195

196 # Skip if already a simple relative path

197 if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):

198 continue

199

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

208

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

217

218 if normalized_count > 0:

219 print(f"Normalized {normalized_count} asset paths")

220

221

222 def 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.

227

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

234

235 Returns:

236 AsyncProjectLoader instance (already started)

237 """

238 loader = AsyncProjectLoader(zip_path, extract_to)

239

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)

246

247 loader.start()

248 return loader