1 """

2 Project serialization to/from ZIP files for pyPhotoAlbum

3 """

4

5 import os

6 import json

7 import zipfile

8 import shutil

9 import tempfile

10 import threading

11 from typing import Optional, Tuple, Callable

12 from pathlib import Path

13 from pyPhotoAlbum.project import Project

14 from pyPhotoAlbum.version_manager import (

15 CURRENT_DATA_VERSION,

16 check_version_compatibility,

17 VersionCompatibility,

18 DataMigration,

19 )

20

21 # Legacy constant for backward compatibility

22 SERIALIZATION_VERSION = CURRENT_DATA_VERSION

23

24

25 def _import_external_images(project: Project):

26 """

27 Find and import any images that have external (absolute or non-assets) paths.

28 This ensures all images are in the assets folder before saving.

29

30 Args:

31 project: The Project instance to check

32 """

33 from pyPhotoAlbum.models import ImageData

34

35 imported_count = 0

36

37 for page in project.pages:

38 for element in page.layout.elements:

39 if isinstance(element, ImageData) and element.image_path:

40 # Check if this is an external path (absolute or not in assets/)

41 is_external = False

42

43 if os.path.isabs(element.image_path):

44 # Absolute path - definitely external

45 is_external = True

46 external_path = element.image_path

47 elif not element.image_path.startswith("assets/"):

48 # Relative path but not in assets folder

49 # Check if it exists relative to project folder

50 full_path = os.path.join(project.folder_path, element.image_path)

51 if os.path.exists(full_path) and not full_path.startswith(project.asset_manager.assets_folder):

52 is_external = True

53 external_path = full_path

54 else:

55 # Path doesn't exist - skip it (will be caught as missing asset)

56 continue

57 else:

58 # Already in assets/ folder

59 continue

60

61 # Import the external image

62 if is_external and os.path.exists(external_path):

63 try:

64 new_asset_path = project.asset_manager.import_asset(external_path)

65 element.image_path = new_asset_path

66 imported_count += 1

67 print(f"Auto-imported external image: {external_path} → {new_asset_path}")

68 except Exception as e:

69 print(f"Warning: Failed to import external image {external_path}: {e}")

70

71 if imported_count > 0:

72 print(f"Auto-imported {imported_count} external image(s) to assets folder")

73

74

75 def _normalize_asset_paths(project: Project, project_folder: str):

76 """

77 Normalize asset paths in a loaded project to be relative to the project folder.

78 This fixes legacy projects that may have absolute paths or paths relative to old locations.

79

80 Args:

81 project: The Project instance to normalize

82 project_folder: The current project folder path

83 """

84 from pyPhotoAlbum.models import ImageData

85

86 normalized_count = 0

87

88 for page in project.pages:

89 for element in page.layout.elements:

90 if isinstance(element, ImageData) and element.image_path:

91 original_path = element.image_path

92

93 # Skip if already a simple relative path (assets/...)

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

95 continue

96

97 # Try to extract just the filename or relative path from assets folder

98 # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"

99 if "/assets/" in original_path:

100 parts = original_path.split("/assets/")

101 if len(parts) == 2:

102 new_path = os.path.join("assets", parts[1])

103 element.image_path = new_path

104 normalized_count += 1

105 print(f"Normalized path: {original_path} -> {new_path}")

106 continue

107

108 # Pattern 2: Absolute path - try to make it relative if it's in the extraction folder

109 if os.path.isabs(original_path):

110 try:

111 new_path = os.path.relpath(original_path, project_folder)

112 element.image_path = new_path

113 normalized_count += 1

114 print(f"Normalized absolute path: {original_path} -> {new_path}")

115 except ValueError:

116 # Can't make relative (different drives on Windows, etc.)

117 pass

118

119 if normalized_count > 0:

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

121

122

123 def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:

124 """

125 Save a project to a ZIP file, including all assets.

126

127 Args:

128 project: The Project instance to save

129 zip_path: Path where the ZIP file should be created

130

131 Returns:

132 Tuple of (success: bool, error_message: Optional[str])

133 """

134 try:

135 # Ensure .ppz extension

136 if not zip_path.lower().endswith(".ppz"):

137 zip_path += ".ppz"

138

139 # Check for and import any external images before saving

140 _import_external_images(project)

141

142 # Serialize project to dictionary

143 project_data = project.serialize()

144

145 # Add version information

146 project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field

147 project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system

148

149 # Create ZIP file

150 with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:

151 # Write project.json with stable sorting for git-friendly diffs

152 project_json = json.dumps(project_data, indent=2, sort_keys=True)

153 zipf.writestr("project.json", project_json)

154

155 # Add all files from the assets folder

156 assets_folder = project.asset_manager.assets_folder

157 if os.path.exists(assets_folder):

158 for root, dirs, files in os.walk(assets_folder):

159 for file in files:

160 file_path = os.path.join(root, file)

161 # Store with relative path from project folder

162 arcname = os.path.relpath(file_path, project.folder_path)

163 zipf.write(file_path, arcname)

164

165 print(f"Project saved to {zip_path}")

166 return True, None

167

168 except Exception as e:

169 error_msg = f"Error saving project: {str(e)}"

170 print(error_msg)

171 return False, error_msg

172

173

174 def save_to_zip_async(

175 project: Project,

176 zip_path: str,

177 on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,

178 on_progress: Optional[Callable[[int, str], None]] = None,

179 ) -> threading.Thread:

180 """

181 Save a project to a ZIP file asynchronously in a background thread.

182

183 This provides instant UI responsiveness by:

184 1. Immediately serializing project.json to a temp folder (fast)

185 2. Creating the ZIP file in a background thread (slow)

186 3. Calling on_complete when done

187

188 Args:

189 project: The Project instance to save

190 zip_path: Path where the ZIP file should be created

191 on_complete: Optional callback(success: bool, error_msg: Optional[str])

192 called when save completes

193 on_progress: Optional callback(progress: int, message: str) where

194 progress is 0-100 and message describes current step

195

196 Returns:

197 The background thread (already started)

198 """

199 # Ensure .ppz extension

200 final_zip_path = zip_path

201 if not final_zip_path.lower().endswith(".ppz"):

202 final_zip_path += ".ppz"

203

204 # ---- Work done on the CALLING (main) thread ----

205 # Serialization is pure Python and holds the GIL, but it's fast.

206 # Doing it here keeps the background thread to file-I/O only, which

207 # releases the GIL and keeps the UI responsive.

208 if on_progress:

209 on_progress(0, "Preparing to save...")

210

211 _import_external_images(project)

212

213 if on_progress:

214 on_progress(10, "Serializing project data...")

215 project_data = project.serialize()

216 project_data["serialization_version"] = SERIALIZATION_VERSION

217 project_data["data_version"] = CURRENT_DATA_VERSION

218 project_json_str = json.dumps(project_data, indent=2, sort_keys=True)

219

220 # Collect the asset file list now so the background thread doesn't

221 # need to touch the (potentially temporary) project folder.

222 assets_folder = project.asset_manager.assets_folder

223 folder_path = project.folder_path

224 asset_files: list[tuple[str, str]] = []

225 if os.path.exists(assets_folder):

226 for root, _dirs, files in os.walk(assets_folder):

227 for file in files:

228 file_path = os.path.join(root, file)

229 arcname = os.path.relpath(file_path, folder_path)

230 asset_files.append((file_path, arcname))

231

232 total_files = 1 + len(asset_files) # project.json + assets

233

234 if on_progress:

235 on_progress(20, f"Starting background write ({total_files} files)...")

236

237 # ---- Work done on the BACKGROUND thread ----

238 # Only file I/O here — zipfile/zlib/shutil all release the GIL.

239 def _background_save():

240 """Background thread: write ZIP file from pre-serialized data."""

241 temp_dir = None

242 try:

243 temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")

244 temp_zip_path = os.path.join(temp_dir, "project.ppz")

245

246 if on_progress:

247 on_progress(25, f"Creating ZIP archive ({total_files} files)...")

248

249 with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:

250 zipf.writestr("project.json", project_json_str)

251

252 if asset_files:

253 progress_range = 90 - 25

254 for idx, (file_path, arcname) in enumerate(asset_files):

255 zipf.write(file_path, arcname)

256 if idx % 10 == 0 or idx == len(asset_files) - 1:

257 progress = 25 + int((idx + 1) / len(asset_files) * progress_range)

258 if on_progress:

259 on_progress(progress, f"Adding assets... ({idx + 1}/{len(asset_files)})")

260

261 if on_progress:

262 on_progress(95, "Finalizing save...")

263

264 os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)

265 if os.path.exists(final_zip_path):

266 os.remove(final_zip_path)

267 shutil.move(temp_zip_path, final_zip_path)

268

269 if on_progress:

270 on_progress(100, "Save complete!")

271

272 print(f"Project saved to {final_zip_path}")

273

274 if on_complete:

275 on_complete(True, None)

276

277 except Exception as e:

278 error_msg = f"Error saving project: {str(e)}"

279 print(error_msg)

280 if on_complete:

281 on_complete(False, error_msg)

282

283 finally:

284 if temp_dir and os.path.exists(temp_dir):

285 try:

286 shutil.rmtree(temp_dir)

287 except Exception:

288 pass

289

290 save_thread = threading.Thread(target=_background_save, daemon=True)

291 save_thread.start()

292 return save_thread

293

294

295 def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:

296 """

297 Load a project from a ZIP file.

298

299 Args:

300 zip_path: Path to the ZIP file to load

301 extract_to: Optional directory to extract to. If None, uses a temporary

302 directory that will be cleaned up when the project is closed.

303

304 Returns:

305 Project instance (raises exception on error)

306 """

307 if not os.path.exists(zip_path):

308 raise FileNotFoundError(f"ZIP file not found: {zip_path}")

309

310 # Track if we created a temp directory

311 temp_dir_obj = None

312

313 # Determine extraction directory

314 if extract_to is None:

315 # Create a temporary directory using TemporaryDirectory

316 # This will be attached to the Project and auto-cleaned on deletion

317 zip_basename = os.path.splitext(os.path.basename(zip_path))[0]

318 temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")

319 extract_to = temp_dir_obj.name

320 else:

321 # Create extraction directory if it doesn't exist

322 os.makedirs(extract_to, exist_ok=True)

323

324 # Extract ZIP contents

325 with zipfile.ZipFile(zip_path, "r") as zipf:

326 zipf.extractall(extract_to)

327

328 # Load project.json

329 project_json_path = os.path.join(extract_to, "project.json")

330 if not os.path.exists(project_json_path):

331 raise ValueError("Invalid project file: project.json not found")

332

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

334 project_data = json.load(f)

335

336 # Check version compatibility

337 # Try new version field first, fall back to legacy field

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

339

340 # Check if version is compatible

341 is_compatible, error_msg = check_version_compatibility(file_version, zip_path)

342 if not is_compatible:

343 raise ValueError(error_msg)

344

345 # Apply migrations if needed

346 if VersionCompatibility.needs_migration(file_version):

347 print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")

348 project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)

349 print(f"Migration completed successfully")

350 elif file_version != CURRENT_DATA_VERSION:

351 print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")

352

353 # Create new project

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

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

356

357 # Deserialize project data

358 project.deserialize(project_data)

359

360 # Update folder path to extraction location

361 project.folder_path = extract_to

362 project.asset_manager.project_folder = extract_to

363 project.asset_manager.assets_folder = os.path.join(extract_to, "assets")

364

365 # Attach temporary directory to project (if we created one)

366 # The TemporaryDirectory will auto-cleanup when the project is deleted

367 if temp_dir_obj is not None:

368 project._temp_dir = temp_dir_obj

369 print(f"Project loaded to temporary directory: {extract_to}")

370

371 # Normalize asset paths in all ImageData elements

372 # This fixes old projects that have absolute or wrong relative paths

373 _normalize_asset_paths(project, extract_to)

374

375 # Set asset resolution context for ImageData rendering

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

377 from pyPhotoAlbum.models import set_asset_resolution_context

378

379 set_asset_resolution_context(extract_to)

380

381 print(f"Project loaded from {zip_path} to {extract_to}")

382 return project

383

384

385 def get_project_info(zip_path: str) -> Optional[dict]:

386 """

387 Get basic information about a project without fully loading it.

388

389 Args:

390 zip_path: Path to the ZIP file

391

392 Returns:

393 Dictionary with project info, or None if error

394 """

395 try:

396 with zipfile.ZipFile(zip_path, "r") as zipf:

397 # Read project.json

398 project_json = zipf.read("project.json").decode("utf-8")

399 project_data = json.loads(project_json)

400

401 return {

402 "name": project_data.get("name", "Unknown"),

403 "version": project_data.get("serialization_version", "Unknown"),

404 "page_count": len(project_data.get("pages", [])),

405 "page_size_mm": project_data.get("page_size_mm", (0, 0)),

406 "working_dpi": project_data.get("working_dpi", 300),

407 }

408 except Exception as e:

409 print(f"Error reading project info: {e}")

410 return None