smugmug_cli/dated/
index.rs1use anyhow::{Context, Result};
10use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14use crate::cache::hash_store::HashStore;
15use crate::dated::date::DateSource;
16use crate::dated::scan::ScannedFile;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct FileRecord {
22 #[serde(rename = "s")]
23 pub size: u64,
24 #[serde(rename = "m")]
25 pub mtime_ns: i64,
26 #[serde(rename = "h")]
28 pub sha256: String,
29 #[serde(rename = "u")]
31 pub uploaded_md5: Option<String>,
32 #[serde(rename = "d")]
33 pub capture_date: NaiveDate,
34 #[serde(rename = "ds")]
35 pub date_source: DateSource,
36 #[serde(rename = "i", default, skip_serializing_if = "Option::is_none")]
38 pub image_uri: Option<String>,
39 #[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
40 pub album_key: Option<String>,
41 #[serde(rename = "f", default, skip_serializing_if = "Option::is_none")]
44 pub failed: Option<String>,
45}
46
47impl FileRecord {
48 pub fn matches(&self, file: &ScannedFile) -> bool {
51 self.size == file.size && self.mtime_ns == file.mtime_ns
52 }
53
54 pub fn is_settled(&self) -> bool {
56 self.image_uri.is_some() || self.failed.is_some()
57 }
58}
59
60#[derive(Clone)]
61pub struct FileIndex {
62 files: sled::Tree,
63 refs: sled::Tree,
64}
65
66fn key(path: &Path) -> Vec<u8> {
67 path.to_string_lossy().into_owned().into_bytes()
68}
69
70impl FileIndex {
71 pub fn open(store: &HashStore) -> Result<Self> {
72 Ok(FileIndex {
73 files: store.open_tree("files")?,
74 refs: store.open_tree("refs")?,
75 })
76 }
77
78 pub fn get(&self, path: &Path) -> Result<Option<FileRecord>> {
79 match self
80 .files
81 .get(key(path))
82 .context("Failed to read file index")?
83 {
84 Some(bytes) => Ok(serde_json::from_slice(&bytes).ok()),
85 None => Ok(None),
86 }
87 }
88
89 pub fn put(&self, path: &Path, record: &FileRecord) -> Result<()> {
92 let previous = self.get(path)?;
93 let old_uri = previous.and_then(|r| r.image_uri);
94 if old_uri != record.image_uri {
95 if let Some(uri) = &old_uri {
96 self.add_ref(uri, -1)?;
97 }
98 if let Some(uri) = &record.image_uri {
99 self.add_ref(uri, 1)?;
100 }
101 }
102 let bytes = serde_json::to_vec(record).context("Failed to serialize file record")?;
103 self.files
104 .insert(key(path), bytes)
105 .context("Failed to write file index")?;
106 Ok(())
107 }
108
109 pub fn refs(&self, image_uri: &str) -> Result<u32> {
111 Ok(self
112 .refs
113 .get(image_uri.as_bytes())
114 .context("Failed to read image references")?
115 .map(|v| u32::from_be_bytes(v.as_ref().try_into().unwrap_or([0; 4])))
116 .unwrap_or(0))
117 }
118
119 fn add_ref(&self, image_uri: &str, delta: i64) -> Result<()> {
120 self.refs
121 .update_and_fetch(image_uri.as_bytes(), |old| {
122 let count = old
123 .map(|v| u32::from_be_bytes(v.try_into().unwrap_or([0; 4])))
124 .unwrap_or(0) as i64
125 + delta;
126 (count > 0).then(|| (count as u32).to_be_bytes().to_vec())
127 })
128 .context("Failed to update image references")?;
129 Ok(())
130 }
131
132 #[cfg(test)]
134 fn len(&self) -> usize {
135 self.files.len()
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use std::path::PathBuf;
143 use tempfile::TempDir;
144
145 fn record(uri: Option<&str>) -> FileRecord {
146 FileRecord {
147 size: 10,
148 mtime_ns: 20,
149 sha256: "abc".into(),
150 uploaded_md5: Some("def".into()),
151 capture_date: NaiveDate::from_ymd_opt(2014, 7, 12).unwrap(),
152 date_source: DateSource::Exif,
153 image_uri: uri.map(String::from),
154 album_key: None,
155 failed: None,
156 }
157 }
158
159 fn index() -> (TempDir, FileIndex) {
160 let dir = TempDir::new().unwrap();
161 let store = HashStore::new(dir.path().to_str().unwrap()).unwrap();
162 let index = FileIndex::open(&store).unwrap();
163 (dir, index)
164 }
165
166 #[test]
167 fn round_trips_records() {
168 let (_dir, index) = index();
169 let path = PathBuf::from("/photos/a.jpg");
170 assert_eq!(index.get(&path).unwrap(), None);
171 index.put(&path, &record(Some("/img/1"))).unwrap();
172 assert_eq!(index.get(&path).unwrap(), Some(record(Some("/img/1"))));
173 assert_eq!(index.len(), 1);
174 }
175
176 #[test]
177 fn counts_paths_per_image() {
178 let (_dir, index) = index();
179 let a = PathBuf::from("/a.jpg");
180 let b = PathBuf::from("/b.jpg");
181 index.put(&a, &record(Some("/img/1"))).unwrap();
182 index.put(&b, &record(Some("/img/1"))).unwrap();
183 assert_eq!(index.refs("/img/1").unwrap(), 2);
184
185 index.put(&a, &record(Some("/img/1"))).unwrap();
187 assert_eq!(index.refs("/img/1").unwrap(), 2);
188
189 index.put(&a, &record(Some("/img/2"))).unwrap();
191 assert_eq!(index.refs("/img/1").unwrap(), 1);
192 assert_eq!(index.refs("/img/2").unwrap(), 1);
193
194 index.put(&b, &record(None)).unwrap();
195 assert_eq!(index.refs("/img/1").unwrap(), 0);
196 }
197
198 #[test]
199 fn matches_on_size_and_mtime() {
200 let rec = record(Some("/img/1"));
201 let mut file = ScannedFile {
202 path: PathBuf::from("/a.jpg"),
203 size: 10,
204 mtime_ns: 20,
205 live_photo_of: None,
206 };
207 assert!(rec.matches(&file));
208 file.mtime_ns = 21;
209 assert!(!rec.matches(&file));
210 }
211
212 #[test]
213 fn clearing_the_store_clears_the_index() {
214 let dir = TempDir::new().unwrap();
215 let store = HashStore::new(dir.path().to_str().unwrap()).unwrap();
216 let index = FileIndex::open(&store).unwrap();
217 index
218 .put(Path::new("/a.jpg"), &record(Some("/img/1")))
219 .unwrap();
220 store.clear().unwrap();
221 let index = FileIndex::open(&store).unwrap();
222 assert_eq!(index.get(Path::new("/a.jpg")).unwrap(), None);
223 assert_eq!(index.refs("/img/1").unwrap(), 0);
224 }
225}