Skip to main content

falcon_library/
pack.rs

1//! `.eaglepack` export and import.
2//!
3//! An `.eaglepack` is a zip archive with no compression (Eagle's archiver
4//! runs with `store: true`). At its root is `pack.json`,
5//! `{"images": [...], "folder": {...}}`, followed by each item's whole
6//! `<ID>.info/` folder. `images` holds the item records (their
7//! `metadata.json` contents), newest `modificationTime` first; `folder` is
8//! present only when a folder was exported and carries its subtree. On
9//! import every item and folder gets a new id, so a pack can be imported
10//! into the library it came from.
11
12use std::collections::{HashMap, HashSet};
13use std::fs::{self, File};
14use std::io::{self, Read, Write};
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use chrono::{Datelike, Local, NaiveDate, TimeZone, Timelike};
19use filetime::FileTime;
20use serde_json::{Map, Value, json};
21use zip::write::SimpleFileOptions;
22use zip::{CompressionMethod, ZipArchive, ZipWriter};
23
24use crate::error::{Error, Result};
25use crate::ids;
26use crate::library::Library;
27use crate::model::{Folder, Item};
28
29const PACK_JSON: &str = "pack.json";
30
31/// What to put in a pack.
32#[derive(Debug, Clone, Copy)]
33pub enum PackSource<'a> {
34    /// These items, with their folder memberships cleared (Eagle's "export
35    /// selected"). Trashed items are allowed and come out untrashed.
36    Items(&'a [String]),
37    /// A folder, its subfolders and every untrashed item in any of them.
38    Folder(&'a str),
39    /// A smart folder's items, packed as one folder without subfolders:
40    /// Eagle gives the folder a new id, keeps the smart folder's name,
41    /// description and icon, and files every item in it.
42    Smart(&'a str),
43}
44
45/// A pack worked out but not yet written (see [`plan_pack`]). It holds no
46/// reference to the library, so it can be written on another thread.
47#[derive(Debug, Clone)]
48pub struct PackPlan {
49    root: PathBuf,
50    pack_json: Vec<u8>,
51    ids: Vec<String>,
52}
53
54impl PackPlan {
55    /// The number of items in the pack.
56    pub fn len(&self) -> usize {
57        self.ids.len()
58    }
59
60    pub fn is_empty(&self) -> bool {
61        self.ids.is_empty()
62    }
63}
64
65/// Settings for [`import_pack`].
66#[derive(Debug, Clone, Default)]
67pub struct PackImportOptions {
68    /// Put every item in this folder instead of the pack's own folders
69    /// (Eagle does this when a pack of items is dropped on a folder). Ignored
70    /// for a folder pack, whose folder tree is recreated.
71    pub folder: Option<String>,
72    /// Leave out items that look like ones already in the library, by
73    /// Eagle's size-and-dimensions key.
74    pub skip_duplicates: bool,
75}
76
77/// Writes an `.eaglepack` of `source` to `dest` and returns the number of
78/// items in it. The archive is checked after writing and removed if it
79/// can't be read back.
80pub fn export_pack(lib: &Library, source: PackSource<'_>, dest: &Path) -> Result<usize> {
81    let plan = plan_pack(lib, source).map_err(|e| match e {
82        Error::Pack { reason, .. } => Error::Pack { path: dest.to_path_buf(), reason },
83        e => e,
84    })?;
85    write_pack(&plan, dest, |_| {})
86}
87
88/// Works out the contents of a pack of `source`: the `pack.json` and the
89/// item folders to add.
90pub fn plan_pack(lib: &Library, source: PackSource<'_>) -> Result<PackPlan> {
91    let dest = lib.root();
92    let refuse = |reason: String| Error::Pack { path: dest.to_path_buf(), reason };
93    let mut images: Vec<(u64, Value)> = Vec::new();
94    let mut folder_record = None;
95    match source {
96        PackSource::Folder(id) => {
97            let folder = lib.all_folders().into_iter().find(|f| f.id == id).ok_or_else(|| refuse(format!("no folder {id}")))?;
98            let mut subtree = HashSet::new();
99            folder.walk(&mut |f| {
100                subtree.insert(f.id.as_str());
101            });
102            for item in lib.items().iter().filter(|i| !i.is_deleted && i.folders.iter().any(|f| subtree.contains(f.as_str()))) {
103                images.push((item.modification_time, to_record(item)?));
104            }
105            folder_record = Some(clone_tree(folder));
106        }
107        PackSource::Smart(id) => {
108            let record = find_smart_record(&lib.metadata.smart_folders, id).ok_or_else(|| refuse(format!("no smart folder {id}")))?;
109            let new_id = ids::new_id();
110            for item in lib.smart_folder_items(id) {
111                let mut record = to_record(item)?;
112                if let Value::Object(m) = &mut record {
113                    m.insert("folders".into(), json!([new_id]));
114                }
115                images.push((item.modification_time, record));
116            }
117            folder_record = Some(clone_smart(record, &new_id));
118        }
119        PackSource::Items(ids) => {
120            let mut seen = HashSet::new();
121            for id in ids.iter().filter(|id| seen.insert(id.as_str())) {
122                let item = lib.item(id).ok_or_else(|| refuse(format!("no item {id}")))?;
123                let mut record = to_record(item)?;
124                if let Value::Object(m) = &mut record {
125                    m.insert("folders".into(), json!([]));
126                    if item.is_deleted {
127                        m.shift_remove("isDeleted");
128                    }
129                }
130                images.push((item.modification_time, record));
131            }
132        }
133    }
134    // Stable, like Eagle's sort, so ties keep library order.
135    images.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
136
137    let mut pack = Map::new();
138    pack.insert("images".into(), Value::Array(images.iter().map(|(_, v)| v.clone()).collect()));
139    if let Some(f) = folder_record {
140        pack.insert("folder".into(), f);
141    }
142    let pack_json = serde_json::to_vec(&pack).map_err(|e| Error::json(dest, e))?;
143    Ok(PackPlan { root: lib.root().to_path_buf(), pack_json, ids: images.iter().map(|(_, v)| record_id(v)).collect() })
144}
145
146/// Writes a planned pack to `dest`, calling `on_item` with the number of
147/// item folders added so far, and returns the number of items. The archive
148/// is written beside `dest`, checked and only then moved into place.
149pub fn write_pack(plan: &PackPlan, dest: &Path, mut on_item: impl FnMut(usize)) -> Result<usize> {
150    let written = write_pack_until(plan, dest, |n| {
151        on_item(n);
152        true
153    })?;
154    Ok(written.unwrap_or(0))
155}
156
157/// Like [`write_pack`], but `on_item` returns whether to go on. Returning
158/// false stops after that item: nothing is left at `dest` (the archive is
159/// built in a temporary file that is removed) and the result is `None`.
160pub fn write_pack_until(plan: &PackPlan, dest: &Path, on_item: impl FnMut(usize) -> bool) -> Result<Option<usize>> {
161    let refuse = |reason: String| Error::Pack { path: dest.to_path_buf(), reason };
162    let parent = dest.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
163    let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| Error::io(parent, e))?;
164    let Some(entries) = write_zip(&plan.root, tmp.as_file_mut(), &plan.pack_json, &plan.ids, on_item).map_err(|e| refuse(e.to_string()))? else {
165        return Ok(None);
166    };
167    verify(tmp.path(), entries).map_err(refuse)?;
168    tmp.persist(dest).map_err(|e| Error::io(dest, e.error))?;
169    Ok(Some(plan.ids.len()))
170}
171
172/// What importing a pack would do, read before asking (Eagle confirms the
173/// disk space first, then asks about items it already has).
174#[derive(Debug, Clone, PartialEq)]
175pub struct PackPreview {
176    /// Items listed in `pack.json`.
177    pub items: usize,
178    /// Of those, how many look like untrashed items already in `lib`
179    /// (the key [`PackImportOptions::skip_duplicates`] uses).
180    pub duplicates: usize,
181    /// Bytes the import adds: the archive's uncompressed contents.
182    pub bytes: u64,
183}
184
185/// Reads `pack.json` and the archive's sizes without extracting anything.
186pub fn preview_pack(lib: &Library, pack: &Path) -> Result<PackPreview> {
187    let refuse = |reason: String| Error::Pack { path: pack.to_path_buf(), reason };
188    let file = File::open(pack).map_err(|e| Error::io(pack, e))?;
189    let mut zip = ZipArchive::new(file).map_err(|e| refuse(e.to_string()))?;
190    let mut bytes = 0;
191    for i in 0..zip.len() {
192        bytes += zip.by_index(i).map_err(|e| refuse(e.to_string()))?.size();
193    }
194    let mut text = Vec::new();
195    zip.by_name(PACK_JSON).map_err(|_| refuse("pack.json is missing".into()))?.read_to_end(&mut text).map_err(|e| refuse(e.to_string()))?;
196    let manifest: Value = serde_json::from_slice(&text).map_err(|e| refuse(format!("pack.json: {e}")))?;
197    let records = manifest.get("images").and_then(Value::as_array).ok_or_else(|| refuse("pack.json has no images list".into()))?;
198    let existing: HashSet<String> = lib.items().iter().filter(|i| !i.is_deleted).filter_map(duplicate_key).collect();
199    let items: Vec<Item> = records.iter().filter_map(|r| Item::from_json_str(&r.to_string()).ok()).collect();
200    let duplicates = items.iter().filter(|i| duplicate_key(i).is_some_and(|k| existing.contains(&k))).count();
201    Ok(PackPreview { items: records.len(), duplicates, bytes })
202}
203
204/// Imports the pack at `pack` into `lib` and returns the new item ids.
205/// Items and folders get new ids; a folder pack's tree is added at the root.
206pub fn import_pack(lib: &mut Library, pack: &Path, opts: &PackImportOptions) -> Result<Vec<String>> {
207    check_target(lib, pack, opts)?;
208    let extracted = extract_pack(pack)?;
209    let mut run = PackImport::prepare(lib, pack, extracted, opts)?;
210    run.start(lib)?;
211    let mut out = Vec::new();
212    while let Some(id) = run.step(lib)? {
213        out.push(id);
214    }
215    Ok(out)
216}
217
218fn check_target(lib: &Library, pack: &Path, opts: &PackImportOptions) -> Result<()> {
219    match &opts.folder {
220        Some(f) if !lib.folder_ids().contains(f.as_str()) => Err(Error::Pack { path: pack.to_path_buf(), reason: format!("no folder {f}") }),
221        _ => Ok(()),
222    }
223}
224
225/// Unpacks `pack` into a temporary folder, the slow, library-free part of
226/// an import (an app can do it without holding the library).
227pub fn extract_pack(pack: &Path) -> Result<tempfile::TempDir> {
228    let extracted = tempfile::Builder::new().prefix("falcon-eaglepack").tempdir().map_err(|e| Error::io(pack, e))?;
229    extract(pack, extracted.path()).map_err(|e| Error::Pack { path: pack.to_path_buf(), reason: e.to_string() })?;
230    Ok(extracted)
231}
232
233/// An import in steps, so a caller can lock the library per item, show
234/// progress and stop between items: [`PackImport::prepare`], then
235/// [`PackImport::start`], then [`PackImport::step`] until it returns `None`.
236/// [`import_pack`] runs all of it at once.
237pub struct PackImport {
238    /// Held so the unpacked items stay on disk until they are moved.
239    extracted: tempfile::TempDir,
240    items: std::vec::IntoIter<Item>,
241    count: usize,
242    new_ids: HashMap<String, String>,
243    folder: Option<Folder>,
244    now: u64,
245    index: usize,
246}
247
248impl PackImport {
249    /// Reads the unpacked pack and works out the new items and ids, without
250    /// changing the library.
251    pub fn prepare(lib: &Library, pack: &Path, extracted: tempfile::TempDir, opts: &PackImportOptions) -> Result<PackImport> {
252        let refuse = |reason: String| Error::Pack { path: pack.to_path_buf(), reason };
253        check_target(lib, pack, opts)?;
254        let manifest = extracted.path().join(PACK_JSON);
255        let text = fs::read(&manifest).map_err(|_| refuse("pack.json is missing".into()))?;
256        let mut manifest: Value = serde_json::from_slice(&text).map_err(|e| Error::json(&manifest, e))?;
257        let records = manifest.get_mut("images").and_then(Value::as_array_mut).map(std::mem::take).ok_or_else(|| refuse("pack.json has no images list".into()))?;
258
259        // Eagle walks the list oldest first.
260        let mut seen = HashSet::new();
261        let mut items = Vec::new();
262        for mut record in records.into_iter().rev() {
263            let Some(id) = record.get("id").and_then(Value::as_str).map(str::to_string) else { continue };
264            if !seen.insert(id.clone()) || !extracted.path().join(format!("{id}.info")).is_dir() {
265                continue;
266            }
267            if let Value::Object(m) = &mut record
268                && !m.contains_key("isDeleted")
269            {
270                m.insert("isDeleted".into(), Value::Bool(false));
271            }
272            let item = Item::from_json_str(&record.to_string()).map_err(|e| refuse(format!("item {id}: {e}")))?;
273            items.push(item);
274        }
275        if opts.skip_duplicates {
276            let existing: HashSet<String> = lib.items().iter().filter(|i| !i.is_deleted).filter_map(duplicate_key).collect();
277            items.retain(|i| duplicate_key(i).is_none_or(|k| !existing.contains(&k)));
278        }
279
280        let mut taken: HashSet<String> = lib.items().iter().map(|i| i.id.clone()).collect();
281        let mut fresh_id = || loop {
282            let id = ids::new_id();
283            if taken.insert(id.clone()) && !lib.root().join("images").join(format!("{id}.info")).exists() {
284                return id;
285            }
286        };
287        let new_ids: HashMap<String, String> = items.iter().map(|i| (i.id.clone(), fresh_id())).collect();
288
289        let mut folder = None;
290        if let Some(record) = manifest.get_mut("folder").filter(|f| f.is_object()) {
291            let old_root = record.get("id").and_then(Value::as_str).unwrap_or_default().to_string();
292            let mut folder_ids = HashMap::new();
293            remap_folder(record, &mut folder_ids, &new_ids, &mut fresh_id);
294            let tree: Folder = serde_json::from_value(record.take()).map_err(|e| refuse(format!("folder: {e}")))?;
295            // Keep the folder's custom order, else oldest first.
296            let rank = |i: &Item| i.extra.get("order").and_then(|o| o.get(&old_root)).and_then(as_number).unwrap_or(i.modification_time as f64);
297            items.sort_by(|a, b| rank(a).total_cmp(&rank(b)));
298            for item in &mut items {
299                item.folders = item.folders.iter().filter_map(|f| folder_ids.get(f).cloned()).collect();
300            }
301            folder = Some(tree);
302        } else {
303            let known = lib.folder_ids();
304            for item in &mut items {
305                match &opts.folder {
306                    Some(f) => item.folders = vec![f.clone()],
307                    None => item.folders.retain(|f| known.contains(f.as_str())),
308                }
309            }
310        }
311
312        let count = items.len();
313        Ok(PackImport { extracted, items: items.into_iter(), count, new_ids, folder, now: now_ms(), index: 0 })
314    }
315
316    /// Items left to import (before the first step: all of them).
317    pub fn len(&self) -> usize {
318        self.items.len()
319    }
320
321    pub fn is_empty(&self) -> bool {
322        self.items.len() == 0
323    }
324
325    /// How many items the import holds in all.
326    pub fn total(&self) -> usize {
327        self.count
328    }
329
330    /// Adds a folder pack's folder tree to the library.
331    pub fn start(&mut self, lib: &mut Library) -> Result<()> {
332        if let Some(tree) = self.folder.take() {
333            lib.metadata.folders.push(tree);
334            lib.save_metadata()?;
335        }
336        Ok(())
337    }
338
339    /// Imports the next item and returns its new id, or `None` when all are in.
340    pub fn step(&mut self, lib: &mut Library) -> Result<Option<String>> {
341        let Some(mut item) = self.items.next() else { return Ok(None) };
342        let from = self.extracted.path().join(format!("{}.info", item.id));
343        item.id = self.new_ids[&item.id].clone();
344        item.modification_time = lib.next_import_time(self.now + self.index as u64);
345        self.index += 1;
346        let to = item.info_dir(lib.root());
347        move_dir(&from, &to)?;
348        lib.register_item(item.clone())?;
349        Ok(Some(item.id))
350    }
351}
352
353/// An item's record as Eagle writes it into `pack.json`.
354fn to_record(item: &Item) -> Result<Value> {
355    serde_json::to_value(item).map_err(|e| Error::json(&item.id, e))
356}
357
358fn record_id(v: &Value) -> String {
359    v.get("id").and_then(Value::as_str).unwrap_or_default().to_string()
360}
361
362/// A folder and its subtree with the keys Eagle copies into a pack, in its
363/// order; keys a folder doesn't have are left out.
364fn clone_tree(f: &Folder) -> Value {
365    let mut m = Map::new();
366    m.insert("id".into(), json!(f.id));
367    m.insert("name".into(), json!(f.name));
368    m.insert("description".into(), json!(f.description));
369    m.insert("children".into(), Value::Array(f.children.iter().map(clone_tree).collect()));
370    m.insert("modificationTime".into(), json!(f.modification_time));
371    m.insert("tags".into(), json!(f.tags));
372    for key in ["extendTags", "icon", "iconColor", "pinyin"] {
373        if let Some(v) = f.extra.get(key).filter(|v| !v.is_null()) {
374            m.insert(key.into(), v.clone());
375        }
376    }
377    m.insert("password".into(), json!(f.password));
378    m.insert("passwordTips".into(), json!(f.password_tips));
379    if let Some(v) = f.extra.get("coverId").filter(|v| !v.is_null()) {
380        m.insert("coverId".into(), v.clone());
381    }
382    if let Some(order_by) = f.extra.get("orderBy").filter(|v| truthy(v)) {
383        m.insert("orderBy".into(), order_by.clone());
384        if let Some(v) = f.extra.get("sortIncrease") {
385            m.insert("sortIncrease".into(), v.clone());
386        }
387    }
388    Value::Object(m)
389}
390
391/// The smart folder `id`'s record in `metadata.json`, at any depth.
392fn find_smart_record<'a>(list: &'a [Value], id: &str) -> Option<&'a Value> {
393    list.iter().find_map(|v| {
394        if v.get("id").and_then(Value::as_str) == Some(id) {
395            Some(v)
396        } else {
397            find_smart_record(v.get("children").and_then(Value::as_array).map_or(&[][..], Vec::as_slice), id)
398        }
399    })
400}
401
402/// The folder a smart folder becomes in a pack: Eagle copies it with the
403/// same keys as a folder ([`clone_tree`]), under a new id and with no
404/// children.
405fn clone_smart(v: &Value, new_id: &str) -> Value {
406    let get = |k: &str| v.get(k).filter(|x| !x.is_null());
407    let mut m = Map::new();
408    m.insert("id".into(), json!(new_id));
409    m.insert("name".into(), get("name").cloned().unwrap_or(json!("")));
410    m.insert("description".into(), get("description").filter(|d| truthy(d)).cloned().unwrap_or(json!("")));
411    m.insert("children".into(), json!([]));
412    if let Some(t) = get("modificationTime") {
413        m.insert("modificationTime".into(), t.clone());
414    }
415    m.insert("tags".into(), get("tags").filter(|t| truthy(t)).cloned().unwrap_or(json!([])));
416    for key in ["extendTags", "icon", "iconColor", "pinyin"] {
417        if let Some(x) = get(key) {
418            m.insert(key.into(), x.clone());
419        }
420    }
421    m.insert("password".into(), json!(""));
422    m.insert("passwordTips".into(), json!(""));
423    if let Some(x) = get("coverId") {
424        m.insert("coverId".into(), x.clone());
425    }
426    if let Some(order_by) = get("orderBy").filter(|o| truthy(o)) {
427        m.insert("orderBy".into(), order_by.clone());
428        if let Some(x) = v.get("sortIncrease") {
429            m.insert("sortIncrease".into(), x.clone());
430        }
431    }
432    Value::Object(m)
433}
434
435/// Gives a pack folder and its children new ids, expands them and points
436/// their covers at the new item ids, filling keys falcon's model requires.
437fn remap_folder(v: &mut Value, folder_ids: &mut HashMap<String, String>, item_ids: &HashMap<String, String>, fresh_id: &mut impl FnMut() -> String) {
438    let Value::Object(m) = v else { return };
439    let new = fresh_id();
440    if let Some(old) = m.get("id").and_then(Value::as_str) {
441        folder_ids.insert(old.to_string(), new.clone());
442    }
443    m.insert("id".into(), json!(new));
444    for (key, default) in [("name", json!("")), ("description", json!("")), ("children", json!([])), ("modificationTime", json!(now_ms())), ("tags", json!([])), ("password", json!("")), ("passwordTips", json!(""))] {
445        if m.get(key).is_none_or(Value::is_null) {
446            m.insert(key.into(), default);
447        }
448    }
449    m.insert("isExpand".into(), Value::Bool(true));
450    let cover = m.get("coverId").and_then(Value::as_str).and_then(|c| item_ids.get(c)).cloned();
451    match cover {
452        Some(c) => {
453            m.insert("coverId".into(), json!(c));
454        }
455        None => {
456            m.shift_remove("coverId");
457        }
458    }
459    if let Some(Value::Array(children)) = m.get_mut("children") {
460        children.retain(Value::is_object);
461        for c in children {
462            remap_folder(c, folder_ids, item_ids, fresh_id);
463        }
464    }
465}
466
467/// Eagle's duplicate key for pack imports, simplified: videos by size and
468/// duration, other files by size, dimensions and extension. SVG, TIFF and
469/// BMP are never treated as duplicates.
470fn duplicate_key(item: &Item) -> Option<String> {
471    const VIDEO: &[&str] = &["mp4", "mov", "m4v", "webm", "avi", "mkv", "wmv", "flv", "3gp"];
472    match item.ext.as_str() {
473        "svg" | "tif" | "tiff" | "bmp" | "url" => None,
474        ext if VIDEO.contains(&ext) => Some(format!("v-{}{}", item.size, item.extra.get("duration").map(Value::to_string).unwrap_or_default())),
475        ext => Some(format!("image-{}{}{}{}", item.size, item.width, item.height, ext)),
476    }
477}
478
479fn write_zip(root: &Path, file: &mut File, pack_json: &[u8], ids: &[String], mut on_item: impl FnMut(usize) -> bool) -> io::Result<Option<usize>> {
480    let mut zip = ZipWriter::new(file);
481    let base = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
482    let mut entries = 1;
483    zip.start_file(PACK_JSON, base.last_modified_time(zip_time(SystemTime::now())).unix_permissions(0o644))?;
484    zip.write_all(pack_json)?;
485    for (n, id) in ids.iter().enumerate() {
486        let dir = root.join("images").join(format!("{id}.info"));
487        if dir.is_dir() {
488            entries += add_dir(&mut zip, base, &dir, &format!("{id}.info/"))?;
489        }
490        if !on_item(n + 1) {
491            return Ok(None);
492        }
493    }
494    zip.finish()?;
495    Ok(Some(entries))
496}
497
498/// Adds `dir` (as `name`, ending in `/`) and everything under it; returns
499/// the number of entries written.
500fn add_dir<W: Write + io::Seek>(zip: &mut ZipWriter<W>, base: SimpleFileOptions, dir: &Path, name: &str) -> io::Result<usize> {
501    let meta = fs::metadata(dir)?;
502    zip.add_directory(name, base.last_modified_time(zip_time(meta.modified()?)).unix_permissions(mode(&meta, 0o755)))?;
503    let mut count = 1;
504    let mut children: Vec<_> = fs::read_dir(dir)?.collect::<io::Result<_>>()?;
505    children.sort_by_key(|e| e.file_name());
506    for entry in children {
507        let path = entry.path();
508        let child = format!("{name}{}", entry.file_name().to_string_lossy());
509        if path.is_dir() {
510            count += add_dir(zip, base, &path, &format!("{child}/"))?;
511            continue;
512        }
513        let meta = fs::metadata(&path)?;
514        let opts = base
515            .last_modified_time(zip_time(meta.modified()?))
516            .unix_permissions(mode(&meta, 0o644))
517            .large_file(meta.len() >= u32::MAX as u64);
518        zip.start_file(child, opts)?;
519        io::copy(&mut File::open(&path)?, zip)?;
520        count += 1;
521    }
522    Ok(count)
523}
524
525/// Eagle's check is the zip signature; falcon also reads the archive back.
526fn verify(path: &Path, expected: usize) -> std::result::Result<(), String> {
527    let mut head = [0u8; 4];
528    File::open(path).and_then(|mut f| f.read_exact(&mut head)).map_err(|e| e.to_string())?;
529    if head != *b"PK\x03\x04" {
530        return Err("written file is not a zip archive".into());
531    }
532    let mut zip = ZipArchive::new(File::open(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
533    if zip.len() != expected {
534        return Err(format!("archive has {} entries, expected {expected}", zip.len()));
535    }
536    let mut manifest = String::new();
537    zip.by_name(PACK_JSON).map_err(|e| e.to_string())?.read_to_string(&mut manifest).map_err(|e| e.to_string())?;
538    serde_json::from_str::<Value>(&manifest).map_err(|e| e.to_string())?;
539    Ok(())
540}
541
542/// Unpacks `pack` into `dest`, keeping each file's time as `unzip` does.
543fn extract(pack: &Path, dest: &Path) -> std::result::Result<(), String> {
544    let mut zip = ZipArchive::new(File::open(pack).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
545    for i in 0..zip.len() {
546        let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
547        #[cfg(not(windows))]
548        let rel = entry.enclosed_name();
549        #[cfg(windows)]
550        let rel = entry.name().ok().map(|name| windows_entry_path(&name));
551        let Some(rel) = rel else { continue };
552        let out = dest.join(rel);
553        if entry.is_dir() {
554            fs::create_dir_all(&out).map_err(|e| e.to_string())?;
555            continue;
556        }
557        if let Some(p) = out.parent() {
558            fs::create_dir_all(p).map_err(|e| e.to_string())?;
559        }
560        let mut file = File::create(&out).map_err(|e| e.to_string())?;
561        io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
562        drop(file);
563        if let Some(t) = entry.last_modified().and_then(from_zip_time) {
564            let _ = filetime::set_file_mtime(&out, FileTime::from_system_time(t));
565        }
566    }
567    Ok(())
568}
569
570/// Where a zip entry unpacks to on Windows. `enclosed_name` drops any name
571/// Windows can't keep, but a pack from a Mac holds such names, so each part
572/// becomes the one the item's name maps to. A part can't lead anywhere else:
573/// `..` becomes `_`, and separators and colons in it become `_`.
574#[cfg(windows)]
575fn windows_entry_path(name: &str) -> PathBuf {
576    name.split('/').filter(|part| !part.is_empty() && *part != ".").map(|part| crate::disk_name::on_disk(part).into_owned()).collect()
577}
578
579fn move_dir(from: &Path, to: &Path) -> Result<()> {
580    if let Some(p) = to.parent() {
581        fs::create_dir_all(p).map_err(|e| Error::io(p, e))?;
582    }
583    if fs::rename(from, to).is_ok() {
584        return Ok(());
585    }
586    copy_tree(from, to)
587}
588
589fn copy_tree(from: &Path, to: &Path) -> Result<()> {
590    fs::create_dir_all(to).map_err(|e| Error::io(to, e))?;
591    for entry in fs::read_dir(from).map_err(|e| Error::io(from, e))? {
592        let entry = entry.map_err(|e| Error::io(from, e))?;
593        let dst: PathBuf = to.join(entry.file_name());
594        if entry.path().is_dir() {
595            copy_tree(&entry.path(), &dst)?;
596        } else {
597            fs::copy(entry.path(), &dst).map_err(|e| Error::io(&dst, e))?;
598            if let Ok(m) = entry.metadata() {
599                let _ = filetime::set_file_mtime(&dst, FileTime::from_last_modification_time(&m));
600            }
601        }
602    }
603    Ok(())
604}
605
606/// Zip entries store local time with two-second precision.
607fn zip_time(t: SystemTime) -> zip::DateTime {
608    let local: chrono::DateTime<Local> = t.into();
609    zip::DateTime::from_date_and_time(
610        local.year().clamp(1980, 2107) as u16,
611        local.month() as u8,
612        local.day() as u8,
613        local.hour() as u8,
614        local.minute() as u8,
615        local.second() as u8,
616    )
617    .unwrap_or_default()
618}
619
620fn from_zip_time(t: zip::DateTime) -> Option<SystemTime> {
621    let naive = NaiveDate::from_ymd_opt(t.year().into(), t.month().into(), t.day().into())?.and_hms_opt(t.hour().into(), t.minute().into(), t.second().into())?;
622    Some(Local.from_local_datetime(&naive).earliest()?.into())
623}
624
625#[cfg(unix)]
626fn mode(meta: &fs::Metadata, _default: u32) -> u32 {
627    use std::os::unix::fs::PermissionsExt;
628    meta.permissions().mode() & 0o777
629}
630
631#[cfg(not(unix))]
632fn mode(_meta: &fs::Metadata, default: u32) -> u32 {
633    default
634}
635
636fn as_number(v: &Value) -> Option<f64> {
637    v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
638}
639
640fn truthy(v: &Value) -> bool {
641    match v {
642        Value::Null => false,
643        Value::Bool(b) => *b,
644        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
645        Value::String(s) => !s.is_empty(),
646        _ => true,
647    }
648}
649
650fn now_ms() -> u64 {
651    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::import::ImportOptions;
658
659    fn png(path: &Path, w: u32, h: u32, shade: u8) {
660        image::RgbImage::from_fn(w, h, |x, y| image::Rgb([shade, (x * 7) as u8, (y * 5) as u8])).save(path).unwrap();
661    }
662
663    struct Setup {
664        _dir: tempfile::TempDir,
665        root: PathBuf,
666        lib: Library,
667        folder: String,
668        child: String,
669        ids: Vec<String>,
670    }
671
672    /// A library with a folder, a subfolder and two imported PNGs, one in
673    /// each, plus a third untouched item.
674    fn setup() -> Setup {
675        let dir = tempfile::tempdir().unwrap();
676        let root = dir.path().to_path_buf();
677        let mut lib = Library::create(root.join("A.library")).unwrap();
678        let folder = lib.create_folder(None, "Refs").unwrap();
679        let child = lib.create_folder(Some(&folder), "Inner").unwrap();
680        let mut ids = Vec::new();
681        for (i, f) in [&folder, &child].into_iter().enumerate() {
682            let src = root.join(format!("pic{i}.png"));
683            png(&src, 40 + i as u32, 30, 50 * i as u8);
684            let opts = ImportOptions { folders: vec![f.clone()], tags: vec![format!("t{i}")], palette: false, ..Default::default() };
685            ids.push(lib.import_file(&src, &opts).unwrap());
686        }
687        let src = root.join("loose.png");
688        png(&src, 10, 10, 200);
689        ids.push(lib.import_file(&src, &ImportOptions { palette: false, ..Default::default() }).unwrap());
690        Setup { _dir: dir, root, lib, folder, child, ids }
691    }
692
693    fn read_pack(path: &Path) -> (Value, Vec<String>) {
694        let mut zip = ZipArchive::new(File::open(path).unwrap()).unwrap();
695        let names = (0..zip.len()).map(|i| zip.by_index(i).unwrap().name().unwrap().to_string()).collect();
696        let mut text = String::new();
697        zip.by_name(PACK_JSON).unwrap().read_to_string(&mut text).unwrap();
698        (serde_json::from_str(&text).unwrap(), names)
699    }
700
701    #[test]
702    fn stopping_a_pack_leaves_no_file() {
703        let s = setup();
704        let plan = plan_pack(&s.lib, PackSource::Items(&s.ids)).unwrap();
705        let out = s.root.join("stopped.eaglepack");
706        assert_eq!(write_pack_until(&plan, &out, |n| n < 2).unwrap(), None);
707        assert!(!out.exists());
708        let leftovers = fs::read_dir(&s.root).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with(".tmp")).count();
709        assert_eq!(leftovers, 0);
710        assert_eq!(write_pack_until(&plan, &out, |_| true).unwrap(), Some(3));
711        assert!(out.exists());
712    }
713
714    #[test]
715    fn stepped_import_can_stop_between_items() {
716        let s = setup();
717        let out = s.root.join("all.eaglepack");
718        export_pack(&s.lib, PackSource::Items(&s.ids), &out).unwrap();
719        let mut target = Library::create(s.root.join("B.library")).unwrap();
720        let mut run = PackImport::prepare(&target, &out, extract_pack(&out).unwrap(), &PackImportOptions::default()).unwrap();
721        run.start(&mut target).unwrap();
722        assert_eq!(run.total(), 3);
723        assert!(run.step(&mut target).unwrap().is_some());
724        assert_eq!(target.items().len(), 1);
725        assert_eq!(run.len(), 2);
726        while run.step(&mut target).unwrap().is_some() {}
727        assert_eq!(target.items().len(), 3);
728    }
729
730    #[test]
731    fn items_pack_matches_eagle_layout() {
732        let mut s = setup();
733        let mut trashed = s.lib.item(&s.ids[1]).unwrap().clone();
734        trashed.is_deleted = true;
735        s.lib.update_item(trashed).unwrap();
736        let out = s.root.join("two.eaglepack");
737        assert_eq!(export_pack(&s.lib, PackSource::Items(&s.ids[..2]), &out).unwrap(), 2);
738
739        let (pack, names) = read_pack(&out);
740        assert_eq!(names[0], "pack.json");
741        assert_eq!(pack.as_object().unwrap().keys().collect::<Vec<_>>(), ["images"]);
742        let images = pack["images"].as_array().unwrap();
743        // Newest modificationTime first, folders cleared.
744        assert_eq!(images[0]["id"], json!(s.ids[1]));
745        assert_eq!(images[1]["id"], json!(s.ids[0]));
746        assert!(images.iter().all(|i| i["folders"] == json!([])));
747        // A trashed item loses isDeleted; the other keeps Eagle's key order.
748        assert!(images[0].get("isDeleted").is_none());
749        let keys: Vec<_> = images[1].as_object().unwrap().keys().take(9).cloned().collect();
750        assert_eq!(keys, ["id", "name", "size", "btime", "mtime", "ext", "tags", "folders", "isDeleted"]);
751        for id in &s.ids[..2] {
752            let item = s.lib.item(id).unwrap();
753            assert!(names.contains(&format!("{id}.info/")));
754            assert!(names.contains(&format!("{id}.info/metadata.json")));
755            assert!(names.contains(&format!("{id}.info/{}.png", item.name)));
756        }
757        let mut zip = ZipArchive::new(File::open(&out).unwrap()).unwrap();
758        assert!((0..zip.len()).all(|i| zip.by_index(i).unwrap().compression() == CompressionMethod::Stored));
759    }
760
761    #[test]
762    fn folder_pack_round_trips() {
763        let s = setup();
764        let out = s.root.join("refs.eaglepack");
765        assert_eq!(export_pack(&s.lib, PackSource::Folder(&s.folder), &out).unwrap(), 2);
766        let (pack, _) = read_pack(&out);
767        let folder = &pack["folder"];
768        assert_eq!(folder["id"], json!(s.folder));
769        assert_eq!(folder["children"][0]["id"], json!(s.child));
770        let keys: Vec<_> = folder.as_object().unwrap().keys().cloned().collect();
771        assert_eq!(keys, ["id", "name", "description", "children", "modificationTime", "tags", "password", "passwordTips"]);
772
773        let mut other = Library::create(s.root.join("B.library")).unwrap();
774        let new = import_pack(&mut other, &out, &PackImportOptions::default()).unwrap();
775        assert_eq!(new.len(), 2);
776        let reopened = Library::open(other.root()).unwrap();
777        assert_eq!(reopened.items().len(), 2);
778        let tree = &reopened.metadata.folders[0];
779        assert_eq!(tree.name, "Refs");
780        assert_eq!(tree.children[0].name, "Inner");
781        assert_ne!(tree.id, s.folder);
782        assert_eq!(tree.extra.get("isExpand"), Some(&json!(true)));
783        // Oldest first: the item from the parent folder was imported first.
784        for (new_id, old_id) in new.iter().zip(&s.ids) {
785            let a = reopened.item(new_id).unwrap();
786            let b = s.lib.item(old_id).unwrap();
787            assert_ne!(a.id, b.id);
788            assert_eq!((&a.name, &a.ext, a.size, a.width, a.height, &a.tags), (&b.name, &b.ext, b.size, b.width, b.height, &b.tags));
789            assert_eq!(fs::read(a.original_path(reopened.root())).unwrap(), fs::read(b.original_path(s.lib.root())).unwrap());
790            assert_eq!(a.thumbnail_path(reopened.root()).is_some(), b.thumbnail_path(s.lib.root()).is_some());
791        }
792        assert_eq!(reopened.item(&new[0]).unwrap().folders, vec![tree.id.clone()]);
793        assert_eq!(reopened.item(&new[1]).unwrap().folders, vec![tree.children[0].id.clone()]);
794        assert!(reopened.mtime.entries.iter().any(|(id, _)| *id == new[0]));
795    }
796
797    #[test]
798    fn smart_folder_pack_is_one_flat_folder() {
799        let mut s = setup();
800        let rule = json!({"rules": [{"property": "tags", "method": "union", "value": ["t0", "t1"]}], "match": "OR"});
801        let smart = s.lib.create_smart_folder("Tagged", vec![rule]).unwrap();
802        let out = s.root.join("tagged.eaglepack");
803        let plan = plan_pack(&s.lib, PackSource::Smart(&smart)).unwrap();
804        assert_eq!(plan.len(), 2);
805        let mut seen = Vec::new();
806        assert_eq!(write_pack(&plan, &out, |n| seen.push(n)).unwrap(), 2);
807        assert_eq!(seen, [1, 2]);
808
809        let (pack, _) = read_pack(&out);
810        let folder = &pack["folder"];
811        let new_id = folder["id"].as_str().unwrap();
812        assert_ne!(new_id, smart);
813        assert_eq!(folder["name"], json!("Tagged"));
814        assert_eq!(folder["children"], json!([]));
815        let keys: Vec<_> = folder.as_object().unwrap().keys().cloned().collect();
816        assert_eq!(keys, ["id", "name", "description", "children", "modificationTime", "tags", "password", "passwordTips"]);
817        assert!(pack["images"].as_array().unwrap().iter().all(|i| i["folders"] == json!([new_id])));
818
819        let mut other = Library::create(s.root.join("B.library")).unwrap();
820        let new = import_pack(&mut other, &out, &PackImportOptions::default()).unwrap();
821        assert_eq!(new.len(), 2);
822        let reopened = Library::open(other.root()).unwrap();
823        let tree = &reopened.metadata.folders[0];
824        assert_eq!((tree.name.as_str(), tree.children.len()), ("Tagged", 0));
825        assert!(new.iter().all(|id| reopened.item(id).unwrap().folders == vec![tree.id.clone()]));
826        assert!(plan_pack(&s.lib, PackSource::Smart("NOPE")).is_err());
827    }
828
829    #[test]
830    fn items_pack_into_a_folder_and_duplicates() {
831        let mut s = setup();
832        let out = s.root.join("all.eaglepack");
833        export_pack(&s.lib, PackSource::Items(&s.ids), &out).unwrap();
834
835        // Importing into its own library: everything is a duplicate.
836        let preview = preview_pack(&s.lib, &out).unwrap();
837        assert_eq!((preview.items, preview.duplicates), (3, 3));
838        assert!(preview.bytes >= fs::metadata(&out).unwrap().len() / 2);
839        assert_eq!(preview_pack(&Library::create(s.root.join("Empty.library")).unwrap(), &out).unwrap().duplicates, 0);
840        let opts = PackImportOptions { skip_duplicates: true, ..Default::default() };
841        assert!(import_pack(&mut s.lib, &out, &opts).unwrap().is_empty());
842
843        // Without the check the items come in again with new ids.
844        let opts = PackImportOptions { folder: Some(s.child.clone()), ..Default::default() };
845        let new = import_pack(&mut s.lib, &out, &opts).unwrap();
846        assert_eq!(new.len(), 3);
847        assert_eq!(s.lib.items().len(), 6);
848        assert!(new.iter().all(|id| s.lib.item(id).unwrap().folders == vec![s.child.clone()]));
849        assert!(new.iter().all(|id| !s.ids.contains(id)));
850
851        let bad = PackImportOptions { folder: Some("NOPE".into()), ..Default::default() };
852        assert!(import_pack(&mut s.lib, &out, &bad).is_err());
853    }
854
855    #[test]
856    fn rejects_unknown_sources_and_bad_packs() {
857        let s = setup();
858        let out = s.root.join("x.eaglepack");
859        assert!(export_pack(&s.lib, PackSource::Folder("NOPE"), &out).is_err());
860        assert!(export_pack(&s.lib, PackSource::Items(&["NOPE".into()]), &out).is_err());
861        assert!(!out.exists());
862
863        let mut lib = Library::create(s.root.join("C.library")).unwrap();
864        fs::write(&out, b"not a zip").unwrap();
865        assert!(import_pack(&mut lib, &out, &PackImportOptions::default()).is_err());
866        let mut zip = ZipWriter::new(File::create(&out).unwrap());
867        zip.start_file("other.txt", SimpleFileOptions::default()).unwrap();
868        zip.finish().unwrap();
869        assert!(import_pack(&mut lib, &out, &PackImportOptions::default()).is_err());
870        assert!(lib.items().is_empty());
871    }
872
873    #[test]
874    fn zip_times_round_trip_to_two_seconds() {
875        let t = UNIX_EPOCH + std::time::Duration::from_secs(1_793_005_755);
876        let back = from_zip_time(zip_time(t)).unwrap();
877        let diff = t.duration_since(back).unwrap_or_else(|e| e.duration());
878        assert!(diff.as_secs() <= 1);
879    }
880}