1use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::path::Path;
24use std::time::SystemTime;
25
26use crate::error::{Error, Result};
27use crate::library::Library;
28use crate::model::{Item, LibraryMetadata, MtimeIndex};
29
30pub const POLL: std::time::Duration = std::time::Duration::from_secs(4);
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub struct Probe {
36 pub present: bool,
38 pub metadata: Option<SystemTime>,
40 pub mtime: Option<SystemTime>,
43}
44
45impl Probe {
46 pub fn of(root: &Path) -> Probe {
48 let meta = fs::metadata(root.join("metadata.json")).ok().filter(|m| m.is_file());
49 let mtime = fs::metadata(root.join("mtime.json")).ok().filter(|m| m.is_file() && m.len() > 0);
50 Probe { present: meta.is_some(), metadata: meta.and_then(|m| m.modified().ok()), mtime: mtime.and_then(|m| m.modified().ok()) }
51 }
52}
53
54pub fn read_metadata(root: &Path) -> Result<LibraryMetadata> {
57 read(&root.join("metadata.json"))
58}
59
60pub fn read_mtime(root: &Path) -> Result<MtimeIndex> {
62 read(&root.join("mtime.json"))
63}
64
65fn read<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
66 let bytes = fs::read(path).map_err(|e| Error::io(path, e))?;
67 serde_json::from_slice(&bytes).map_err(|e| Error::json(path, e))
68}
69
70#[derive(Debug, Clone, Default, PartialEq)]
72pub struct ItemSync {
73 pub added: Vec<String>,
74 pub changed: Vec<String>,
75 pub removed: Vec<String>,
76}
77
78impl ItemSync {
79 pub fn is_empty(&self) -> bool {
80 self.added.is_empty() && self.changed.is_empty() && self.removed.is_empty()
81 }
82}
83
84impl Library {
85 pub fn take_external_metadata(&mut self, metadata: LibraryMetadata) -> bool {
91 if metadata.modification_time <= self.disk_stamp {
92 return false;
93 }
94 self.disk_stamp = metadata.modification_time;
95 self.metadata = metadata;
96 self.committed.metadata = self.metadata.clone();
97 self.dirty.metadata = false;
98 true
99 }
100
101 pub fn sync_items(&mut self, index: &MtimeIndex) -> ItemSync {
109 let mut out = ItemSync::default();
110 let known: HashMap<String, u64> = self.mtime.entries.iter().cloned().collect();
111 for (id, stamp) in &index.entries {
112 match known.get(id) {
113 Some(&mine) if *stamp <= mine => {}
114 had => {
115 if let Some(item) = self.read_item(id) {
116 let new = had.is_none() && self.item(id).is_none();
117 self.adopt(item, *stamp);
118 if new { out.added.push(id.clone()) } else { out.changed.push(id.clone()) }
119 }
120 }
121 }
122 }
123 let held = self.items().len() as u64;
124 if index.all.is_some_and(|all| all != held) || index.entries.len() as u64 != held {
125 let on_disk = self.item_dirs();
126 let gone: Vec<String> = self.items().iter().filter(|i| !on_disk.contains(&i.id)).map(|i| i.id.clone()).collect();
127 for id in gone {
128 self.drop_item(&id);
129 out.removed.push(id);
130 }
131 let mut fresh: Vec<&String> = on_disk.iter().filter(|id| self.item(id).is_none()).collect();
132 fresh.sort();
133 for id in fresh.into_iter().cloned().collect::<Vec<_>>() {
134 if let Some(item) = self.read_item(&id) {
135 let stamp = item.last_modified;
136 self.adopt(item, stamp);
137 out.added.push(id);
138 }
139 }
140 }
141 if !out.is_empty() {
142 self.mtime.all = Some(self.items().len() as u64);
143 }
144 out
145 }
146
147 pub fn rebuild_mtime(&mut self) -> Result<()> {
152 self.mtime.entries = self.items().iter().filter(|i| i.last_modified > 0).map(|i| (i.id.clone(), i.last_modified)).collect();
153 self.mtime.all = Some(self.items().len() as u64);
154 self.write_mtime()
155 }
156
157 pub fn discard_pending_writes(&mut self) {
161 self.dirty = Default::default();
162 }
163
164 fn read_item(&self, id: &str) -> Option<Item> {
167 if id.len() != 13 && id.len() != 36 {
168 return None;
169 }
170 let file = self.root().join("images").join(format!("{id}.info")).join("metadata.json");
171 let text = fs::read_to_string(file).ok()?;
172 Item::from_json_str(&text).ok().filter(|i| i.id == id)
173 }
174
175 fn item_dirs(&self) -> HashSet<String> {
177 let Ok(dir) = fs::read_dir(self.root().join("images")) else { return HashSet::new() };
178 dir.filter_map(|e| e.ok())
179 .filter(|e| e.path().is_dir())
180 .filter_map(|e| e.file_name().to_string_lossy().strip_suffix(".info").map(String::from))
181 .collect()
182 }
183
184 pub(crate) fn adopt(&mut self, item: Item, stamp: u64) {
187 self.dirty.items.remove(&item.id);
188 self.skipped.retain(|(dir, _)| !dir.ends_with(format!("{}.info", item.id)));
189 match self.mtime.entries.iter_mut().find(|(id, _)| *id == item.id) {
190 Some(e) => e.1 = stamp,
191 None => self.mtime.entries.push((item.id.clone(), stamp)),
192 }
193 let items = self.items_mut();
194 match items.binary_search_by(|i| i.id.cmp(&item.id)) {
195 Ok(pos) => items[pos] = item,
196 Err(pos) => items.insert(pos, item),
197 }
198 }
199
200 fn drop_item(&mut self, id: &str) {
202 self.dirty.items.remove(id);
203 self.items_mut().retain(|i| i.id != id);
204 self.mtime.entries.retain(|(e, _)| e != id);
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::import::ImportOptions;
212
213 fn library() -> (tempfile::TempDir, Library) {
214 let dir = tempfile::tempdir().unwrap();
215 let lib = Library::create(dir.path().join("S.library")).unwrap();
216 (dir, lib)
217 }
218
219 fn import(lib: &mut Library, name: &str) -> String {
220 let src = lib.root().parent().unwrap().join(name);
221 fs::write(&src, name.as_bytes()).unwrap();
222 lib.import_file(&src, &ImportOptions::default()).unwrap()
223 }
224
225 #[test]
226 fn rebuilds_a_broken_index() {
227 let (_d, mut lib) = library();
228 let a = import(&mut lib, "a.txt");
229 let b = import(&mut lib, "b.txt");
230 fs::write(lib.root().join("mtime.json"), b"{\"bogus\":1}").unwrap();
231 let mut fresh = Library::open(lib.root()).unwrap();
232 fresh.rebuild_mtime().unwrap();
233 let disk = read_mtime(lib.root()).unwrap();
234 let mut want = vec![(a.clone(), lib.item(&a).unwrap().last_modified), (b.clone(), lib.item(&b).unwrap().last_modified)];
235 want.sort();
236 assert_eq!(disk.entries, want);
237 assert_eq!(disk.all, Some(2));
238 }
239
240 #[test]
241 fn probe_sees_both_files() {
242 let (_d, lib) = library();
243 let p = Probe::of(lib.root());
244 assert!(p.present && p.metadata.is_some() && p.mtime.is_some());
245 fs::write(lib.root().join("mtime.json"), b"").unwrap();
246 assert_eq!(Probe::of(lib.root()).mtime, None, "an empty mtime.json is being rewritten");
247 assert!(!Probe::of(&lib.root().join("nope")).present);
248 }
249
250 #[test]
251 fn newer_metadata_from_disk_replaces_the_folder_tree() {
252 let (_d, mut lib) = library();
253 lib.create_folder(None, "Here").unwrap();
254 let mine = read_metadata(lib.root()).unwrap();
256 assert!(!lib.take_external_metadata(mine.clone()));
257 let mut other = Library::open(lib.root()).unwrap();
259 std::thread::sleep(std::time::Duration::from_millis(2));
260 other.create_folder(None, "There").unwrap();
261 let theirs = read_metadata(lib.root()).unwrap();
262 assert!(lib.take_external_metadata(theirs));
263 assert_eq!(lib.metadata.folders.len(), 2);
264 lib.create_folder(None, "Again").unwrap();
266 assert!(!lib.take_external_metadata(mine));
268 assert_eq!(lib.metadata.folders.len(), 3);
269 }
270
271 #[test]
272 fn items_added_edited_and_deleted_elsewhere_are_picked_up() {
273 let (_d, mut lib) = library();
274 let kept = import(&mut lib, "a.txt");
275 let doomed = import(&mut lib, "b.txt");
276 assert!(lib.sync_items(&read_mtime(lib.root()).unwrap()).is_empty());
278
279 let mut other = Library::open(lib.root()).unwrap();
280 let added = import(&mut other, "c.txt");
281 let mut item = other.item(&kept).unwrap().clone();
282 item.annotation = "edited elsewhere".into();
283 std::thread::sleep(std::time::Duration::from_millis(2));
284 other.update_item(item).unwrap();
285 other.delete_permanently(std::slice::from_ref(&doomed)).unwrap();
286
287 let got = lib.sync_items(&read_mtime(lib.root()).unwrap());
288 assert_eq!(got.added, vec![added.clone()]);
289 assert_eq!(got.changed, vec![kept.clone()]);
290 assert_eq!(got.removed, vec![doomed.clone()]);
291 assert_eq!(lib.item(&kept).unwrap().annotation, "edited elsewhere");
292 assert!(lib.item(&doomed).is_none());
293 assert_eq!(lib.mtime, other.mtime, "the index in memory matches the disk");
294 assert!(lib.sync_items(&read_mtime(lib.root()).unwrap()).is_empty());
295 }
296
297 #[test]
298 fn a_folder_the_index_missed_is_found_by_the_count() {
299 let (_d, mut lib) = library();
300 import(&mut lib, "a.txt");
301 let mut other = Library::open(lib.root()).unwrap();
302 let id = import(&mut other, "b.txt");
303 let mut stale = read_mtime(lib.root()).unwrap();
305 stale.entries.retain(|(e, _)| *e != id);
306 let got = lib.sync_items(&stale);
307 assert_eq!(got.added, vec![id]);
308 let mut third = Library::open(lib.root()).unwrap();
310 let late = import(&mut third, "c.txt");
311 fs::write(lib.root().join(format!("images/{late}.info/metadata.json")), b"{\"id\":").unwrap();
312 assert!(lib.sync_items(&read_mtime(lib.root()).unwrap()).added.is_empty());
313 }
314}