11use std:: backtrace:: Backtrace ;
2+ use std:: io:: { BufReader , BufWriter } ;
23use std:: path:: { Path , PathBuf } ;
34use serde:: { Serialize } ;
45use tauri:: { Emitter , Manager } ;
@@ -9,7 +10,9 @@ use gtk::{EventBox, HeaderBar};
910
1011use crate :: dwca:: Archive ;
1112use crate :: error:: { ChuckError , Result } ;
13+ use crate :: photo_cache:: PhotoCache ;
1214use crate :: search_params:: SearchParams ;
15+ use crate :: ZipState ;
1316
1417#[ derive( Debug , Clone , Serialize ) ]
1518#[ serde( tag = "status" , rename_all = "camelCase" ) ]
@@ -117,19 +120,30 @@ pub async fn open_archive(
117120 app. emit ( "archive-open-progress" , ArchiveOpenProgress :: Importing )
118121 . map_err ( |e| ChuckError :: Tauri ( e. to_string ( ) ) ) ?;
119122
123+ // Drop the cached ZipArchive before opening a new archive. On Windows,
124+ // open file handles prevent deletion, so release it before the new archive
125+ // open removes old archive directories.
126+ if let Ok ( mut guard) = app. state :: < ZipState > ( ) . 0 . lock ( ) {
127+ * guard = None ;
128+ }
129+
120130 // Create a channel for progress updates
121131 let ( tx, rx) = mpsc:: channel ( ) ;
122132
123133 // Spawn blocking task
124134 let app_for_thread = app. clone ( ) ;
125135 let result = tauri:: async_runtime:: spawn_blocking ( move || {
126- Archive :: open (
136+ let archive = Archive :: open (
127137 Path :: new ( & path_clone) ,
128138 & base_dir,
129139 |stage| {
130140 let _ = tx. send ( stage. to_string ( ) ) ;
131141 } ,
132- )
142+ ) ?;
143+ // Parse the zip central directory once while still on a blocking thread.
144+ // Returns None on failure; get_photo will re-attempt lazily if needed.
145+ let zip_archive = build_zip_archive ( & archive. storage_dir ) ;
146+ Ok :: < _ , ChuckError > ( ( archive, zip_archive) )
133147 } ) ;
134148
135149 // Listen for progress updates and emit events
@@ -146,9 +160,15 @@ pub async fn open_archive(
146160 } ) ;
147161
148162 match result. await {
149- Ok ( Ok ( archive) ) => {
163+ Ok ( Ok ( ( archive, zip_archive ) ) ) => {
150164 let info = archive. info ( ) ?;
151165
166+ if let Some ( zip) = zip_archive {
167+ if let Ok ( mut guard) = app. state :: < ZipState > ( ) . 0 . lock ( ) {
168+ * guard = Some ( zip) ;
169+ }
170+ }
171+
152172 // Emit completion event
153173 app. emit (
154174 "archive-open-progress" ,
@@ -300,13 +320,101 @@ pub fn get_occurrence(
300320 archive. get_occurrence ( & occurrence_id)
301321}
302322
323+ /// Opens the archive zip and parses its central directory, returning a ZipArchive
324+ /// ready for repeated photo lookups. Returns None and logs a warning on failure.
325+ fn build_zip_archive ( storage_dir : & Path ) -> Option < zip:: ZipArchive < std:: fs:: File > > {
326+ let zip_path = storage_dir. join ( "archive.zip" ) ;
327+ let file = match std:: fs:: File :: open ( & zip_path) {
328+ Ok ( f) => f,
329+ Err ( e) => {
330+ log:: warn!( "Failed to open archive.zip for zip index: {e}" ) ;
331+ return None ;
332+ }
333+ } ;
334+ match zip:: ZipArchive :: new ( file) {
335+ Ok ( z) => {
336+ log:: debug!( "ZipArchive central directory cached ({} entries)" , z. len( ) ) ;
337+ Some ( z)
338+ }
339+ Err ( e) => {
340+ log:: warn!( "Failed to parse zip central directory: {e}" ) ;
341+ None
342+ }
343+ }
344+ }
345+
303346#[ tauri:: command]
304347pub fn get_photo (
305348 app : tauri:: AppHandle ,
349+ zip_state : tauri:: State < ' _ , ZipState > ,
306350 photo_path : String ,
307351) -> Result < String > {
308352 let archive = Archive :: current ( & get_archives_dir ( app) ?) ?;
309- archive. get_photo ( & photo_path)
353+
354+ let cache_dir = archive. storage_dir . join ( "photo_cache" ) ;
355+ std:: fs:: create_dir_all ( & cache_dir) . map_err ( |e| ChuckError :: DirectoryCreate {
356+ path : cache_dir. clone ( ) ,
357+ source : e,
358+ } ) ?;
359+ let photo_cache = PhotoCache :: new ( & cache_dir) ;
360+
361+ if let Some ( cached_path) = photo_cache. get_cached_photo ( & photo_path) ? {
362+ photo_cache. touch_file ( & cached_path) ?;
363+ return Ok ( cached_path. to_string_lossy ( ) . to_string ( ) ) ;
364+ }
365+
366+ let normalized_path = photo_path. replace ( '\\' , "/" ) ;
367+ let cached_file_path = photo_cache. get_cache_path ( & photo_path) ;
368+
369+ if let Some ( p) = cached_file_path. parent ( ) {
370+ if !p. exists ( ) {
371+ std:: fs:: create_dir_all ( p) . map_err ( |e| ChuckError :: DirectoryCreate {
372+ path : p. to_path_buf ( ) ,
373+ source : e,
374+ } ) ?;
375+ }
376+ }
377+
378+ // Use the shared ZipArchive so the central directory is only parsed once.
379+ // Initialise lazily here if open_archive hasn't run yet (e.g. after restart).
380+ {
381+ let mut guard = zip_state
382+ . 0
383+ . lock ( )
384+ . map_err ( |_| ChuckError :: Tauri ( "ZipState mutex poisoned" . to_string ( ) ) ) ?;
385+
386+ if guard. is_none ( ) {
387+ * guard = build_zip_archive ( & archive. storage_dir ) ;
388+ if guard. is_none ( ) {
389+ return Err ( ChuckError :: Tauri (
390+ "Failed to open archive zip for photo extraction" . to_string ( ) ,
391+ ) ) ;
392+ }
393+ log:: debug!( "ZipState initialised lazily in get_photo" ) ;
394+ }
395+
396+ let zip = guard. as_mut ( ) . unwrap ( ) ;
397+ let zip_file = zip
398+ . by_name ( & normalized_path)
399+ . map_err ( ChuckError :: ArchiveExtraction ) ?;
400+
401+ let outfile = std:: fs:: File :: create ( & cached_file_path) . map_err ( |e| ChuckError :: FileOpen {
402+ path : cached_file_path. clone ( ) ,
403+ source : e,
404+ } ) ?;
405+
406+ let mut reader = BufReader :: with_capacity ( 64 * 1024 , zip_file) ;
407+ let mut writer = BufWriter :: with_capacity ( 64 * 1024 , outfile) ;
408+ std:: io:: copy ( & mut reader, & mut writer) . map_err ( |e| ChuckError :: FileRead {
409+ path : cached_file_path. clone ( ) ,
410+ source : e,
411+ } ) ?;
412+ } // release the mutex before eviction
413+
414+ const MAX_CACHE_SIZE : u64 = 2 * 1024 * 1024 * 1024 ;
415+ photo_cache. evict_lru ( MAX_CACHE_SIZE ) ?;
416+
417+ Ok ( cached_file_path. to_string_lossy ( ) . to_string ( ) )
310418}
311419
312420#[ tauri:: command]
0 commit comments