1use 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#[derive(Debug, Clone, Copy)]
33pub enum PackSource<'a> {
34 Items(&'a [String]),
37 Folder(&'a str),
39 Smart(&'a str),
43}
44
45#[derive(Debug, Clone)]
48pub struct PackPlan {
49 root: PathBuf,
50 pack_json: Vec<u8>,
51 ids: Vec<String>,
52}
53
54impl PackPlan {
55 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#[derive(Debug, Clone, Default)]
67pub struct PackImportOptions {
68 pub folder: Option<String>,
72 pub skip_duplicates: bool,
75}
76
77pub 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
88pub 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 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
146pub 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
157pub 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#[derive(Debug, Clone, PartialEq)]
175pub struct PackPreview {
176 pub items: usize,
178 pub duplicates: usize,
181 pub bytes: u64,
183}
184
185pub 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
204pub 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
225pub 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
233pub struct PackImport {
238 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 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 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 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 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 pub fn total(&self) -> usize {
327 self.count
328 }
329
330 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 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
353fn 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
362fn 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
391fn 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
402fn 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
435fn 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
467fn 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
498fn 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
525fn 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
542fn 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#[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
606fn 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 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 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 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 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 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 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}