Skip to content

Commit 4604ee6

Browse files
kuedaclaude
andauthored
fix: preserve parsed zip central directory between photo access (#48)
Previously we were discarding this potentially large index for every photo lookup, causing serious performance problems for archives with a lot of embedded photos. Co-authored-by: Claude (claude-sonnet-4-6) <noreply@anthropic.com>
1 parent 60c1cd2 commit 4604ee6

2 files changed

Lines changed: 120 additions & 4 deletions

File tree

src-tauri/src/commands/archive.rs

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::backtrace::Backtrace;
2+
use std::io::{BufReader, BufWriter};
23
use std::path::{Path, PathBuf};
34
use serde::{Serialize};
45
use tauri::{Emitter, Manager};
@@ -9,7 +10,9 @@ use gtk::{EventBox, HeaderBar};
910

1011
use crate::dwca::Archive;
1112
use crate::error::{ChuckError, Result};
13+
use crate::photo_cache::PhotoCache;
1214
use 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]
304347
pub 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]

src-tauri/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ use tauri::RunEvent;
2020
/// The frontend retrieves this once on startup via the `get_opened_file` command.
2121
struct OpenedFile(Mutex<Option<String>>);
2222

23+
/// Holds a cached ZipArchive for fast photo extraction.
24+
/// Parsing the central directory of a large ZIP is expensive; keeping one open
25+
/// means we only pay that cost once instead of on every photo request.
26+
pub(crate) struct ZipState(pub Mutex<Option<zip::ZipArchive<std::fs::File>>>);
27+
2328
#[cfg_attr(mobile, tauri::mobile_entry_point)]
2429
pub fn run() {
2530
tauri::Builder::default()
@@ -86,6 +91,9 @@ pub fn run() {
8691
// Initialize auth cache (lazy - won't access keychain until first use)
8792
app.manage(AuthCache::new());
8893

94+
// Initialize zip state (populated on first archive open or photo request)
95+
app.manage(ZipState(Mutex::new(None)));
96+
8997
// Check CLI args for a file path (Windows/Linux file association)
9098
let opened_file = std::env::args()
9199
.nth(1)

0 commit comments

Comments
 (0)