1use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9
10use crate::disk_name::on_disk;
11use crate::error::{Error, Result};
12use crate::model::{Folder, Item, LibraryMetadata, MtimeIndex, SavedFilter, Tags};
13use crate::undo::{Committed, Dirty, Transaction, WriteMode};
14
15const METADATA: &str = "metadata.json";
16const MTIME: &str = "mtime.json";
17const TAGS: &str = "tags.json";
18const FILTERS: &str = "saved-filters.json";
19const IMAGES: &str = "images";
20
21pub const APP_VERSION: &str = "4.0.0";
25
26#[derive(Debug, Clone)]
28pub struct Library {
29 root: PathBuf,
30 pub metadata: LibraryMetadata,
32 pub mtime: MtimeIndex,
34 pub tags: Tags,
36 pub saved_filters: Vec<SavedFilter>,
38 items: Vec<Item>,
39 pub skipped: Vec<(PathBuf, String)>,
43 last_import_ms: u64,
45 pub(crate) journal: Option<Transaction>,
47 pub(crate) committed: Committed,
50 pub(crate) write_mode: WriteMode,
51 pub(crate) dirty: Dirty,
53 pub(crate) disk_stamp: u64,
56 conflict: bool,
59}
60
61impl Library {
62 pub fn open(path: impl AsRef<Path>) -> Result<Library> {
64 Library::open_with_progress(path, &|_, _| {})
65 }
66
67 pub fn open_with_progress(path: impl AsRef<Path>, progress: &dyn Fn(usize, usize)) -> Result<Library> {
71 let root = path.as_ref().to_path_buf();
72 if !root.join(METADATA).is_file() || !root.join(IMAGES).is_dir() {
73 return Err(Error::NotALibrary(root));
74 }
75 let metadata: LibraryMetadata = read_json(&root.join(METADATA))?;
76 if is_newer_than_app(&metadata.application_version) {
77 return Err(Error::NewerLibrary { path: root, version: metadata.application_version });
78 }
79 let mtime = read_json(&root.join(MTIME))?;
80 let tags: Tags = read_json(&root.join(TAGS))?;
81 let saved_filters: Vec<SavedFilter> = read_json(&root.join(FILTERS))?;
82
83 let images = root.join(IMAGES);
84 let mut dirs = Vec::new();
85 for entry in fs::read_dir(&images).map_err(|e| Error::io(&images, e))? {
86 let entry = entry.map_err(|e| Error::io(&images, e))?;
87 if entry.file_name().to_string_lossy().ends_with(".info") && entry.path().is_dir() {
88 dirs.push(entry.path());
89 }
90 }
91 let total = dirs.len();
92 progress(0, total);
93 let mut items = Vec::new();
94 let mut skipped = Vec::new();
95 for (n, dir) in dirs.into_iter().enumerate() {
96 let file = dir.join(METADATA);
98 match fs::read_to_string(&file).map_err(|e| e.to_string()).and_then(|t| Item::from_json_str(&t).map_err(|e| e.to_string())) {
99 Ok(item) => items.push(item),
100 Err(e) => skipped.push((dir, e)),
101 }
102 if (n + 1) % 200 == 0 || n + 1 == total {
103 progress(n + 1, total);
104 }
105 }
106 skipped.sort();
107 items.sort_by(|a, b| a.id.cmp(&b.id));
108 let committed = Committed { metadata: metadata.clone(), tags: tags.clone(), saved_filters: saved_filters.clone() };
109 let disk_stamp = metadata.modification_time;
110 Ok(Library {
111 root,
112 metadata,
113 mtime,
114 tags,
115 saved_filters,
116 items,
117 skipped,
118 last_import_ms: 0,
119 journal: None,
120 committed,
121 write_mode: WriteMode::Immediate,
122 dirty: Dirty::default(),
123 disk_stamp,
124 conflict: false,
125 })
126 }
127
128 pub fn create(path: impl AsRef<Path>) -> Result<Library> {
132 let root = path.as_ref().to_path_buf();
133 if root.exists() {
134 return Err(Error::Io {
135 path: root.clone(),
136 source: std::io::Error::new(std::io::ErrorKind::AlreadyExists, "already exists"),
137 });
138 }
139 mkdir(&root.join(IMAGES))?;
140 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64);
141 let metadata = LibraryMetadata {
142 folders: Vec::new(),
143 smart_folders: Vec::new(),
144 quick_access: Vec::new(),
145 tags_groups: Vec::new(),
146 modification_time: now,
147 application_version: APP_VERSION.into(),
148 extra: Default::default(),
149 };
150 write_json(&root.join(METADATA), &metadata)?;
151 write_json(&root.join(MTIME), &MtimeIndex { entries: Vec::new(), all: Some(0) })?;
152 write_json(&root.join(TAGS), &Tags::default())?;
153 write_json(&root.join(FILTERS), &Vec::<SavedFilter>::new())?;
154 Library::open(root)
155 }
156
157 pub fn root(&self) -> &Path {
159 &self.root
160 }
161
162 pub fn items(&self) -> &[Item] {
164 &self.items
165 }
166
167 pub fn item(&self, id: &str) -> Option<&Item> {
169 self.items.iter().find(|i| i.id == id)
170 }
171
172 pub fn items_mut(&mut self) -> &mut Vec<Item> {
174 &mut self.items
175 }
176
177 pub fn all_folders(&self) -> Vec<&Folder> {
179 let mut out = Vec::new();
180 for f in &self.metadata.folders {
181 f.walk(&mut |x| out.push(x));
182 }
183 out
184 }
185
186 pub fn folder_ids(&self) -> HashSet<&str> {
188 self.all_folders().into_iter().map(|f| f.id.as_str()).collect()
189 }
190
191 pub fn info_dir(&self, item: &Item) -> PathBuf {
193 item.info_dir(&self.root)
194 }
195
196 pub fn save_to(&self, dest: impl AsRef<Path>) -> Result<()> {
200 let dest = dest.as_ref();
201 mkdir(dest)?;
202 let same = |a: &Path, b: &Path| matches!((a.canonicalize(), b.canonicalize()), (Ok(a), Ok(b)) if a == b);
203 if same(dest, &self.root) {
204 return Err(Error::SaveOntoSelf(dest.to_path_buf()));
205 }
206
207 let modelled = [METADATA, MTIME, TAGS, FILTERS, IMAGES];
209 for entry in fs::read_dir(&self.root).map_err(|e| Error::io(&self.root, e))? {
210 let entry = entry.map_err(|e| Error::io(&self.root, e))?;
211 if !modelled.iter().any(|m| entry.file_name() == *m) {
212 copy_recursive(&entry.path(), &dest.join(entry.file_name()))?;
213 }
214 }
215
216 write_json(&dest.join(METADATA), &self.metadata)?;
217 write_json(&dest.join(MTIME), &self.mtime)?;
218 write_json(&dest.join(TAGS), &self.tags)?;
219 write_json(&dest.join(FILTERS), &self.saved_filters)?;
220
221 let images = dest.join(IMAGES);
222 mkdir(&images)?;
223 for item in &self.items {
224 let src = self.info_dir(item);
225 let out = item.info_dir(dest);
226 mkdir(&out)?;
227 if src.is_dir() {
228 for entry in fs::read_dir(&src).map_err(|e| Error::io(&src, e))? {
229 let entry = entry.map_err(|e| Error::io(&src, e))?;
230 if entry.file_name() != METADATA {
231 copy_recursive(&entry.path(), &out.join(entry.file_name()))?;
232 }
233 }
234 }
235 write_json(&out.join(METADATA), item)?;
236 }
237 for (dir, _) in &self.skipped {
239 if let Some(name) = dir.file_name() {
240 copy_recursive(dir, &images.join(name))?;
241 }
242 }
243 Ok(())
244 }
245}
246
247impl Library {
248 pub(crate) fn next_import_time(&mut self, now: u64) -> u64 {
251 self.last_import_ms = now.max(self.last_import_ms + 1);
252 self.last_import_ms
253 }
254
255 pub(crate) fn forget_item(&mut self, id: &str) -> Result<()> {
259 if let Some(j) = &mut self.journal {
260 j.irreversible = true;
261 }
262 self.dirty.items.remove(id);
263 self.items.retain(|i| i.id != id);
264 self.mtime.entries.retain(|(e, _)| e != id);
265 self.mtime.all = Some(self.items.len() as u64);
266 self.write_mtime()
267 }
268
269 pub(crate) fn register_item(&mut self, item: Item) -> Result<()> {
274 self.journal_item(&item.id);
275 match self.write_mode {
276 WriteMode::Immediate => write_json(&item.info_dir(&self.root).join(METADATA), &item)?,
277 WriteMode::Batched => {
278 self.dirty.items.insert(item.id.clone());
279 }
280 }
281 let stamp = item.last_modified;
282 match self.mtime.entries.iter_mut().find(|(id, _)| *id == item.id) {
283 Some(entry) => entry.1 = stamp,
284 None => self.mtime.entries.push((item.id.clone(), stamp)),
285 }
286 match self.items.binary_search_by(|i| i.id.cmp(&item.id)) {
287 Ok(pos) => self.items[pos] = item,
288 Err(pos) => self.items.insert(pos, item),
289 }
290 self.mtime.all = Some(self.items.len() as u64);
291 self.write_mtime()
292 }
293
294 pub(crate) fn write_metadata_file(&mut self) -> Result<()> {
302 let path = self.root.join(METADATA);
303 if let Some(disk) = fs::read(&path).ok().and_then(|b| serde_json::from_slice::<DiskStamp>(&b).ok()) {
304 if disk.application_version.as_deref().is_some_and(is_newer_than_app) {
305 self.conflict = true;
306 return Err(Error::NewerLibrary { path: self.root.clone(), version: disk.application_version.unwrap_or_default() });
307 }
308 if disk.modification_time.is_some_and(|t| t > self.disk_stamp) {
309 self.conflict = true;
310 return Err(Error::SyncConflict(self.root.clone()));
311 }
312 }
313 write_json(&path, &self.metadata)?;
314 self.disk_stamp = self.metadata.modification_time;
315 Ok(())
316 }
317
318 pub fn take_conflict(&mut self) -> bool {
323 std::mem::take(&mut self.conflict)
324 }
325
326 pub(crate) fn write_mtime(&mut self) -> Result<()> {
328 match self.write_mode {
329 WriteMode::Immediate => write_json(&self.root.join(MTIME), &self.mtime),
330 WriteMode::Batched => {
331 self.dirty.mtime = true;
332 Ok(())
333 }
334 }
335 }
336}
337
338impl Item {
339 pub fn info_dir(&self, library_root: &Path) -> PathBuf {
341 library_root.join(IMAGES).join(format!("{}.info", self.id))
342 }
343
344 pub fn original_path(&self, library_root: &Path) -> PathBuf {
346 let file = if self.ext.is_empty() {
347 self.name.clone()
348 } else {
349 format!("{}.{}", self.name, self.ext)
350 };
351 self.info_dir(library_root).join(&*on_disk(&file))
352 }
353
354 pub fn thumbnail_name(&self) -> String {
356 on_disk(&format!("{}_thumbnail.png", self.name)).into_owned()
357 }
358
359 pub fn thumbnail_path(&self, library_root: &Path) -> Option<PathBuf> {
361 let p = self.info_dir(library_root).join(self.thumbnail_name());
362 p.is_file().then_some(p)
363 }
364}
365
366#[derive(serde::Deserialize)]
368#[serde(rename_all = "camelCase")]
369struct DiskStamp {
370 modification_time: Option<u64>,
371 application_version: Option<String>,
372}
373
374pub fn is_newer_than_app(version: &str) -> bool {
377 compare_versions(APP_VERSION, version) == Some(std::cmp::Ordering::Less)
378}
379
380pub fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
387 use std::cmp::Ordering;
388 fn parse(v: &str) -> Option<(Vec<u64>, Option<Vec<&str>>)> {
389 let v = v.trim();
390 let v = v.strip_prefix('v').unwrap_or(v);
391 let v = v.split('+').next()?;
392 let (main, pre) = match v.split_once('-') {
393 Some((m, p)) => (m, Some(p.split('.').collect::<Vec<_>>())),
394 None => (v, None),
395 };
396 let nums = main.split('.').map(|p| p.parse::<u64>().ok()).collect::<Option<Vec<_>>>()?;
397 (!nums.is_empty() && nums.len() <= 4).then_some((nums, pre))
398 }
399 let (na, pa) = parse(a)?;
400 let (nb, pb) = parse(b)?;
401 for i in 0..na.len().max(nb.len()) {
402 match na.get(i).unwrap_or(&0).cmp(nb.get(i).unwrap_or(&0)) {
403 Ordering::Equal => {}
404 o => return Some(o),
405 }
406 }
407 Some(match (pa, pb) {
408 (None, None) => Ordering::Equal,
409 (None, Some(_)) => Ordering::Greater,
410 (Some(_), None) => Ordering::Less,
411 (Some(x), Some(y)) => {
412 for (p, q) in x.iter().zip(&y) {
413 let o = match (p.parse::<u64>(), q.parse::<u64>()) {
414 (Ok(m), Ok(n)) => m.cmp(&n),
415 _ => p.cmp(q),
416 };
417 if o != Ordering::Equal {
418 return Some(o);
419 }
420 }
421 x.len().cmp(&y.len())
422 }
423 })
424}
425
426fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
427 let bytes = fs::read(path).map_err(|e| Error::io(path, e))?;
428 serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))
429}
430
431pub(crate) fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
433 let bytes = serde_json::to_vec(value).map_err(|e| Error::json(path, e))?;
434 fs::write(path, bytes).map_err(|e| Error::io(path, e))
435}
436
437fn mkdir(p: &Path) -> Result<()> {
438 fs::create_dir_all(p).map_err(|e| Error::io(p, e))
439}
440
441fn copy_recursive(src: &Path, dst: &Path) -> Result<()> {
442 if src.is_dir() {
443 mkdir(dst)?;
444 for entry in fs::read_dir(src).map_err(|e| Error::io(src, e))? {
445 let entry = entry.map_err(|e| Error::io(src, e))?;
446 copy_recursive(&entry.path(), &dst.join(entry.file_name()))?;
447 }
448 } else {
449 fs::copy(src, dst).map_err(|e| Error::io(src, e))?;
450 }
451 Ok(())
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use std::cmp::Ordering::*;
458
459 #[test]
460 fn versions_compare_like_eagle() {
461 assert_eq!(compare_versions("4.0.0", "4.0.0"), Some(Equal));
462 assert_eq!(compare_versions("4.0.0", "4.0.1"), Some(Less));
463 assert_eq!(compare_versions("4.0.0", "4.1"), Some(Less));
464 assert_eq!(compare_versions("4.0.0", "4"), Some(Equal));
465 assert_eq!(compare_versions("4.0.0", "3.0.10"), Some(Greater));
466 assert_eq!(compare_versions("4.0.0", "10.0.0"), Some(Less), "numeric, not text");
467 assert_eq!(compare_versions("4.0.0", "4.0.0-beta.2"), Some(Greater));
468 assert_eq!(compare_versions("4.0.0-beta.10", "4.0.0-beta.2"), Some(Greater));
469 assert_eq!(compare_versions("4.0.0-alpha", "4.0.0-beta"), Some(Less));
470 assert_eq!(compare_versions("v4.0.0", "4.0.0+build7"), Some(Equal));
471 assert_eq!(compare_versions("4.0.0", "build 20"), None);
472 assert_eq!(compare_versions("4.0.0", ""), None);
473 assert!(is_newer_than_app("4.0.1"));
474 assert!(is_newer_than_app("5.0.0-beta"));
475 assert!(!is_newer_than_app("4.0.0"));
476 assert!(!is_newer_than_app("3.0.0"));
477 assert!(!is_newer_than_app("nonsense"));
478 }
479
480 fn set_disk_metadata(root: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
481 let path = root.join(METADATA);
482 let mut v: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
483 edit(&mut v);
484 fs::write(path, serde_json::to_vec(&v).unwrap()).unwrap();
485 }
486
487 #[test]
488 fn reports_loading_progress() {
489 let dir = tempfile::tempdir().unwrap();
490 let root = dir.path().join("P.library");
491 let mut lib = Library::create(&root).unwrap();
492 for n in 0..3 {
493 let src = dir.path().join(format!("{n}.txt"));
494 fs::write(&src, b"x").unwrap();
495 lib.import_file(&src, &crate::import::ImportOptions::default()).unwrap();
496 }
497 let seen = std::sync::Mutex::new(Vec::new());
498 let again = Library::open_with_progress(&root, &|a, b| seen.lock().unwrap().push((a, b))).unwrap();
499 assert_eq!(again.items().len(), 3);
500 assert_eq!(seen.into_inner().unwrap(), vec![(0, 3), (3, 3)]);
501 }
502
503 #[test]
504 fn refuses_a_library_from_a_newer_eagle() {
505 let dir = tempfile::tempdir().unwrap();
506 let root = dir.path().join("New.library");
507 Library::create(&root).unwrap();
508 set_disk_metadata(&root, |v| v["applicationVersion"] = "4.1.0".into());
509 match Library::open(&root) {
510 Err(Error::NewerLibrary { version, .. }) => assert_eq!(version, "4.1.0"),
511 other => panic!("expected NewerLibrary, got {other:?}"),
512 }
513 set_disk_metadata(&root, |v| v["applicationVersion"] = "3.0.5".into());
514 assert!(Library::open(&root).is_ok());
515 }
516
517 #[test]
518 fn metadata_writes_refuse_a_newer_copy_on_disk() {
519 let dir = tempfile::tempdir().unwrap();
520 let root = dir.path().join("Sync.library");
521 let mut lib = Library::create(&root).unwrap();
522 lib.create_folder(None, "Mine").unwrap();
523 lib.create_folder(None, "Mine too").unwrap();
525 let later = lib.metadata.modification_time + 60_000;
527 set_disk_metadata(&root, |v| {
528 v["modificationTime"] = later.into();
529 v["folders"] = serde_json::json!([]);
530 });
531 let before = fs::read(root.join(METADATA)).unwrap();
532 assert!(matches!(lib.create_folder(None, "Lost"), Err(Error::SyncConflict(_))));
533 assert!(lib.take_conflict());
534 assert!(!lib.take_conflict(), "taken once");
535 assert_eq!(fs::read(root.join(METADATA)).unwrap(), before, "nothing written");
536
537 let mut lib = Library::open(&root).unwrap();
539 lib.set_write_mode(WriteMode::Batched).unwrap();
540 lib.create_folder(None, "Pending").unwrap();
541 set_disk_metadata(&root, |v| v["modificationTime"] = (later + 60_000).into());
542 assert!(matches!(lib.flush(), Err(Error::SyncConflict(_))));
543 assert!(!lib.has_pending_writes());
544
545 let mut lib = Library::open(&root).unwrap();
547 set_disk_metadata(&root, |v| v["applicationVersion"] = "9.0.0".into());
548 assert!(matches!(lib.create_folder(None, "No"), Err(Error::NewerLibrary { .. })));
549 }
550}