Skip to main content

smugmug_cli/dated/
index.rs

1//! What each local path was last seen as and uploaded to, so an unchanged
2//! file (same size and modification time) is skipped without being read.
3//!
4//! Lives in the cache database next to the content-hash store: the `files`
5//! tree maps a path to its `FileRecord`, and the `refs` tree counts the
6//! paths pointing at each SmugMug image, so an image shared by identical
7//! copies of a file is never overwritten when one copy is edited.
8
9use 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/// One local file as of its last successful upload (or permanent failure).
19/// Field names are short: there's one record per file in the library.
20#[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    /// SHA-256 of the file (for a RAW: of the RAW file, not the JPEG).
27    #[serde(rename = "h")]
28    pub sha256: String,
29    /// MD5 of what was uploaded: the file, or the JPEG rendered from a RAW.
30    #[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    /// The SmugMug (album) image this file is, once uploaded or linked.
37    #[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    /// Why SmugMug refused this file, if it did (e.g. too big). Not retried
42    /// until the file changes.
43    #[serde(rename = "f", default, skip_serializing_if = "Option::is_none")]
44    pub failed: Option<String>,
45}
46
47impl FileRecord {
48    /// The file on disk is the one this record describes (by size and
49    /// modification time; contents aren't read).
50    pub fn matches(&self, file: &ScannedFile) -> bool {
51        self.size == file.size && self.mtime_ns == file.mtime_ns
52    }
53
54    /// Nothing left to do for this file while it's unchanged.
55    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    /// Record `path` as `record`, moving its image reference from whatever
90    /// it pointed at before.
91    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    /// How many indexed paths point at `image_uri`.
110    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    /// Number of indexed files.
133    #[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        // Re-recording the same image doesn't double count.
186        index.put(&a, &record(Some("/img/1"))).unwrap();
187        assert_eq!(index.refs("/img/1").unwrap(), 2);
188
189        // Moving a path to another image moves its reference.
190        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}