Skip to main content

smugmug_cli/dated/
plan.rs

1//! Where a dated file goes: `<root>/YYYY/MM/YYYY-MM-DD`, each day an album
2//! series (`2014-07-12`, `2014-07-12 (2)`, ...) so no album passes
3//! SmugMug's limit. Year and month folders and day albums are created only
4//! when a file needs uploading into them, and each folder is listed at most
5//! once per run.
6//!
7//! Each day also keeps the file names its albums hold, so a file is never
8//! uploaded over a different photo of the same name (two cameras both
9//! producing `IMG_0001.JPG` on one day): the newcomer is renamed
10//! `IMG_0001~<md5 prefix>.JPG` instead. A file whose name and content are
11//! already there is linked to that image rather than uploaded again.
12
13use anyhow::{Context, Result};
14use chrono::{Datelike, NaiveDate};
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use tokio::sync::{Mutex, OnceCell};
18
19use crate::api::SmugMugClient;
20use crate::api::albums::{Album, ChildNode};
21use crate::api::images::AlbumImage;
22use crate::uploader::album_series::{AlbumSeries, AlbumSeriesBackend, MAX_ALBUM_IMAGES};
23
24/// The SmugMug calls placing files by date needs.
25// Only implemented and used inside this crate with concrete types, so the
26// Send-bound caveat of async fns in public traits doesn't matter here.
27#[allow(async_fn_in_trait)]
28pub trait Smug {
29    async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>>;
30    /// Create a private folder listed by name, ascending; returns its node
31    /// URI.
32    async fn create_folder(&self, parent_node_uri: &str, name: &str) -> Result<String>;
33    /// Create a private album.
34    async fn create_album(&self, parent_node_uri: &str, name: &str) -> Result<Album>;
35    async fn image_count(&self, album: &Album) -> Result<u64>;
36    async fn album_images(&self, album_key: &str) -> Result<Vec<AlbumImage>>;
37}
38
39impl Smug for SmugMugClient {
40    async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>> {
41        self.list_children(node_uri).await
42    }
43    async fn create_folder(&self, parent_node_uri: &str, name: &str) -> Result<String> {
44        SmugMugClient::create_sorted_folder(self, parent_node_uri, name, Some("Private")).await
45    }
46    async fn create_album(&self, parent_node_uri: &str, name: &str) -> Result<Album> {
47        SmugMugClient::create_album(self, name, Some(parent_node_uri), "Private").await
48    }
49    async fn image_count(&self, album: &Album) -> Result<u64> {
50        Ok(self
51            .get_album(&album.album_key)
52            .await?
53            .image_count
54            .unwrap_or(0))
55    }
56    async fn album_images(&self, album_key: &str) -> Result<Vec<AlbumImage>> {
57        self.list_album_images(album_key).await
58    }
59}
60
61/// Name of the album for `date` (the first of its series).
62pub fn day_album_name(date: NaiveDate) -> String {
63    date.format("%Y-%m-%d").to_string()
64}
65
66fn month_path(date: NaiveDate) -> [String; 2] {
67    [
68        format!("{:04}", date.year()),
69        format!("{:02}", date.month()),
70    ]
71}
72
73/// Folders found or created so far, and folder listings.
74#[derive(Default)]
75struct Folders {
76    /// "2014" or "2014/07" → node URI, or `None` when known not to exist.
77    known: HashMap<String, Option<String>>,
78    listings: HashMap<String, Arc<Vec<ChildNode>>>,
79}
80
81struct Inner<T> {
82    smug: Arc<T>,
83    /// Node URI of the root folder; `None` if it doesn't exist (dry run).
84    root: Option<String>,
85    dry_run: bool,
86    folders: Mutex<Folders>,
87}
88
89impl<T: Smug> Inner<T> {
90    async fn listing(&self, folders: &mut Folders, node_uri: &str) -> Result<Arc<Vec<ChildNode>>> {
91        if let Some(listing) = folders.listings.get(node_uri) {
92            return Ok(listing.clone());
93        }
94        let listing = Arc::new(
95            self.smug
96                .children(node_uri)
97                .await
98                .with_context(|| format!("Failed to list folder {}", node_uri))?,
99        );
100        folders
101            .listings
102            .insert(node_uri.to_string(), listing.clone());
103        Ok(listing)
104    }
105
106    /// Node URI of the folder at `path` under the root, creating missing
107    /// folders if `create` (and this isn't a dry run).
108    async fn folder(&self, path: &[String], create: bool) -> Result<Option<String>> {
109        let Some(root) = &self.root else {
110            return Ok(None);
111        };
112        let mut folders = self.folders.lock().await;
113        let mut parent = root.clone();
114        let mut key = String::new();
115        for part in path {
116            if !key.is_empty() {
117                key.push('/');
118            }
119            key.push_str(part);
120
121            let known = folders.known.get(&key).cloned();
122            let found = match known {
123                Some(Some(uri)) => Some(uri),
124                Some(None) if !create => return Ok(None),
125                _ => self
126                    .listing(&mut folders, &parent)
127                    .await?
128                    .iter()
129                    .find(|c| c.node_type == "Folder" && c.name == *part)
130                    .map(|c| c.uri.clone()),
131            };
132            let uri = match found {
133                Some(uri) => uri,
134                None if create && !self.dry_run => {
135                    let uri = self
136                        .smug
137                        .create_folder(&parent, part)
138                        .await
139                        .with_context(|| format!("Failed to create folder {}", key))?;
140                    folders.listings.insert(uri.clone(), Arc::new(Vec::new()));
141                    uri
142                }
143                None => {
144                    folders.known.insert(key, None);
145                    return Ok(None);
146                }
147            };
148            folders.known.insert(key.clone(), Some(uri.clone()));
149            parent = uri;
150        }
151        Ok(Some(parent))
152    }
153
154    async fn albums_in(&self, folder_uri: &str) -> Result<Vec<Album>> {
155        let mut folders = self.folders.lock().await;
156        Ok(self
157            .listing(&mut folders, folder_uri)
158            .await?
159            .iter()
160            .filter_map(ChildNode::album)
161            .collect())
162    }
163}
164
165/// The albums of one day's series, as `AlbumSeries` sees them.
166pub struct DayBackend<T> {
167    inner: Arc<Inner<T>>,
168    date: NaiveDate,
169}
170
171impl<T: Smug> AlbumSeriesBackend for DayBackend<T> {
172    async fn find_album(&self, name: &str) -> Result<Option<Album>> {
173        let Some(month) = self.inner.folder(&month_path(self.date), false).await? else {
174            return Ok(None);
175        };
176        Ok(self
177            .inner
178            .albums_in(&month)
179            .await?
180            .into_iter()
181            .find(|a| a.name == name))
182    }
183
184    async fn image_count(&self, album: &Album) -> Result<u64> {
185        self.inner.smug.image_count(album).await
186    }
187
188    async fn create_album(&self, name: &str) -> Result<Album> {
189        let month = self
190            .inner
191            .folder(&month_path(self.date), true)
192            .await?
193            .context("The day's folder couldn't be created")?;
194        self.inner.smug.create_album(&month, name).await
195    }
196}
197
198/// An image already in a day's albums, by file name.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct NamedImage {
201    /// Lowercase hex MD5 of its content, if known.
202    pub md5: Option<String>,
203    /// Album image URI; empty while an upload of this name is in progress.
204    pub uri: String,
205    pub album_key: String,
206}
207
208/// What to call a file in its day's albums.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum NameCheck {
211    /// Upload it under this name (its own, or renamed to avoid a different
212    /// photo with its name), now reserved for it.
213    Upload(String),
214    /// The same content is already there under this name.
215    Present(NamedImage),
216}
217
218/// One day's album series and the file names in it.
219pub struct Day<T> {
220    pub series: AlbumSeries<DayBackend<T>>,
221    smug: Arc<T>,
222    names: OnceCell<std::sync::Mutex<HashMap<String, NamedImage>>>,
223}
224
225/// `IMG_0001.JPG` → `IMG_0001~1a2b3c4d.JPG`.
226pub fn renamed(file_name: &str, md5: &str) -> String {
227    let tag = &md5[..md5.len().min(8)];
228    match file_name.rsplit_once('.') {
229        Some((stem, ext)) if !stem.is_empty() => format!("{}~{}.{}", stem, tag, ext),
230        _ => format!("{}~{}", file_name, tag),
231    }
232}
233
234impl<T: Smug> Day<T> {
235    async fn names(&self) -> Result<&std::sync::Mutex<HashMap<String, NamedImage>>> {
236        self.names
237            .get_or_try_init(|| async {
238                let mut names = HashMap::new();
239                for existing in self.series.existing_albums().await {
240                    let key = existing.album.album_key;
241                    for image in
242                        self.smug.album_images(&key).await.with_context(|| {
243                            format!("Failed to list album {}", existing.album.name)
244                        })?
245                    {
246                        names.insert(
247                            image.file_name,
248                            NamedImage {
249                                md5: image.archived_md5.map(|m| m.to_lowercase()),
250                                uri: image.uri,
251                                album_key: key.clone(),
252                            },
253                        );
254                    }
255                }
256                Ok::<_, anyhow::Error>(std::sync::Mutex::new(names))
257            })
258            .await
259    }
260
261    /// Decide the name for a file called `file_name` with content `md5`,
262    /// reserving it (see `NameCheck`).
263    pub async fn check_name(&self, file_name: &str, md5: &str) -> Result<NameCheck> {
264        let names = self.names().await?;
265        let mut names = names.lock().unwrap();
266        let alternative = renamed(file_name, md5);
267        for candidate in [file_name, alternative.as_str()] {
268            match names.get(candidate) {
269                None => {
270                    names.insert(
271                        candidate.to_string(),
272                        NamedImage {
273                            md5: Some(md5.to_string()),
274                            uri: String::new(),
275                            album_key: String::new(),
276                        },
277                    );
278                    return Ok(NameCheck::Upload(candidate.to_string()));
279                }
280                Some(existing)
281                    if existing.md5.as_deref() == Some(md5) && !existing.uri.is_empty() =>
282                {
283                    return Ok(NameCheck::Present(existing.clone()));
284                }
285                Some(_) => {}
286            }
287        }
288        // Both taken by other content: extremely unlikely, but stay safe.
289        let mut n = 2;
290        loop {
291            let candidate = renamed(file_name, &format!("{}-{}", md5, n));
292            if !names.contains_key(&candidate) {
293                names.insert(
294                    candidate.clone(),
295                    NamedImage {
296                        md5: Some(md5.to_string()),
297                        uri: String::new(),
298                        album_key: String::new(),
299                    },
300                );
301                return Ok(NameCheck::Upload(candidate));
302            }
303            n += 1;
304        }
305    }
306
307    /// Record the image a reserved name was uploaded as.
308    pub async fn name_uploaded(&self, name: &str, uri: &str, album_key: &str) {
309        if let Some(names) = self.names.get()
310            && let Some(entry) = names.lock().unwrap().get_mut(name)
311        {
312            entry.uri = uri.to_string();
313            entry.album_key = album_key.to_string();
314        }
315    }
316
317    /// Give back a name whose upload failed.
318    pub async fn release_name(&self, name: &str) {
319        if let Some(names) = self.names.get() {
320            let mut names = names.lock().unwrap();
321            if names.get(name).is_some_and(|e| e.uri.is_empty()) {
322                names.remove(name);
323            }
324        }
325    }
326
327    /// Keys of the day's albums (existing and created by this run).
328    pub async fn album_keys(&self) -> HashSet<String> {
329        self.series
330            .albums()
331            .await
332            .into_iter()
333            .map(|a| a.album.album_key)
334            .filter(|k| !k.is_empty())
335            .collect()
336    }
337}
338
339/// A day's series, looked up by the first file that needs it.
340type DayCell<T> = Arc<OnceCell<Arc<Day<T>>>>;
341
342/// Day album series under one root folder, loaded as files need them.
343pub struct DayAlbums<T> {
344    inner: Arc<Inner<T>>,
345    days: std::sync::Mutex<HashMap<NaiveDate, DayCell<T>>>,
346}
347
348impl<T: Smug> DayAlbums<T> {
349    /// `root` is the root folder's node URI, or `None` if it doesn't exist
350    /// (only on a dry run).
351    pub fn new(smug: Arc<T>, root: Option<String>, dry_run: bool) -> Self {
352        DayAlbums {
353            inner: Arc::new(Inner {
354                smug,
355                root,
356                dry_run,
357                folders: Mutex::new(Folders::default()),
358            }),
359            days: std::sync::Mutex::new(HashMap::new()),
360        }
361    }
362
363    /// The album series for `date`, looking up its existing albums the
364    /// first time.
365    pub async fn day(&self, date: NaiveDate) -> Result<Arc<Day<T>>> {
366        let cell = self
367            .days
368            .lock()
369            .unwrap()
370            .entry(date)
371            .or_insert_with(|| Arc::new(OnceCell::new()))
372            .clone();
373        cell.get_or_try_init(|| async {
374            let backend = DayBackend {
375                inner: self.inner.clone(),
376                date,
377            };
378            let series = AlbumSeries::load(
379                backend,
380                &day_album_name(date),
381                MAX_ALBUM_IMAGES,
382                self.inner.dry_run,
383            )
384            .await
385            .with_context(|| format!("Failed to look up the albums for {}", date))?;
386            Ok::<_, anyhow::Error>(Arc::new(Day {
387                series,
388                smug: self.inner.smug.clone(),
389                names: OnceCell::new(),
390            }))
391        })
392        .await
393        .cloned()
394    }
395
396    /// Every day loaded so far.
397    pub fn loaded_days(&self) -> Vec<Arc<Day<T>>> {
398        self.days
399            .lock()
400            .unwrap()
401            .values()
402            .filter_map(|cell| cell.get().cloned())
403            .collect()
404    }
405}
406
407#[cfg(test)]
408pub(crate) mod tests {
409    use super::*;
410    use std::sync::Mutex as StdMutex;
411
412    /// In-memory SmugMug folder tree.
413    #[derive(Default)]
414    pub struct FakeSmug {
415        /// node URI → children
416        pub nodes: StdMutex<HashMap<String, Vec<ChildNode>>>,
417        /// album key → images
418        pub images: StdMutex<HashMap<String, Vec<AlbumImage>>>,
419        pub calls: StdMutex<Vec<String>>,
420        /// File names whose upload SmugMug refuses (413).
421        pub refuse: StdMutex<HashSet<String>>,
422        next: StdMutex<u32>,
423    }
424
425    impl FakeSmug {
426        pub fn id(&self) -> u32 {
427            let mut n = self.next.lock().unwrap();
428            *n += 1;
429            *n
430        }
431
432        pub fn with_root() -> (Arc<Self>, String) {
433            let fake = Arc::new(FakeSmug::default());
434            fake.nodes
435                .lock()
436                .unwrap()
437                .insert("/node/root".into(), Vec::new());
438            (fake, "/node/root".into())
439        }
440
441        pub fn add_folder(&self, parent: &str, name: &str) -> String {
442            let uri = format!("/node/f{}", self.id());
443            self.nodes
444                .lock()
445                .unwrap()
446                .get_mut(parent)
447                .unwrap()
448                .push(ChildNode {
449                    name: name.into(),
450                    node_type: "Folder".into(),
451                    uri: uri.clone(),
452                    node_id: String::new(),
453                    url_name: String::new(),
454                    web_uri: None,
455                    uris: None,
456                    sort_method: None,
457                    sort_direction: None,
458                });
459            self.nodes.lock().unwrap().insert(uri.clone(), Vec::new());
460            uri
461        }
462
463        pub fn add_album(&self, parent: &str, name: &str, images: &[(&str, &str)]) -> Album {
464            let id = self.id();
465            let key = format!("A{}", id);
466            let album_uri = format!("/api/v2/album/{}", key);
467            self.nodes
468                .lock()
469                .unwrap()
470                .get_mut(parent)
471                .unwrap()
472                .push(ChildNode {
473                    name: name.into(),
474                    node_type: "Album".into(),
475                    uri: format!("/node/a{}", id),
476                    node_id: format!("a{}", id),
477                    url_name: name.into(),
478                    web_uri: None,
479                    uris: Some(crate::api::albums::ChildNodeUris {
480                        album: Some(crate::api::albums::UriRef {
481                            uri: album_uri.clone(),
482                        }),
483                    }),
484                    sort_method: None,
485                    sort_direction: None,
486                });
487            let images = images
488                .iter()
489                .map(|(file, md5)| AlbumImage {
490                    image_key: format!("{}-{}", key, file),
491                    file_name: file.to_string(),
492                    archived_uri: String::new(),
493                    file_size: 1,
494                    format: "JPG".into(),
495                    uri: format!("{}/image/{}", album_uri, file),
496                    title: None,
497                    archived_md5: Some(md5.to_string()),
498                })
499                .collect();
500            self.images.lock().unwrap().insert(key.clone(), images);
501            Album {
502                album_key: key,
503                name: name.into(),
504                url_name: name.into(),
505                node_id: format!("a{}", id),
506                uri: album_uri,
507                web_uri: None,
508                uris: None,
509                image_count: None,
510            }
511        }
512
513        pub fn calls(&self, prefix: &str) -> usize {
514            self.calls
515                .lock()
516                .unwrap()
517                .iter()
518                .filter(|c| c.starts_with(prefix))
519                .count()
520        }
521    }
522
523    impl Smug for FakeSmug {
524        async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>> {
525            self.calls
526                .lock()
527                .unwrap()
528                .push(format!("children {}", node_uri));
529            self.nodes
530                .lock()
531                .unwrap()
532                .get(node_uri)
533                .cloned()
534                .context("no such node")
535        }
536        async fn create_folder(&self, parent: &str, name: &str) -> Result<String> {
537            self.calls
538                .lock()
539                .unwrap()
540                .push(format!("create_folder {}", name));
541            Ok(self.add_folder(parent, name))
542        }
543        async fn create_album(&self, parent: &str, name: &str) -> Result<Album> {
544            self.calls
545                .lock()
546                .unwrap()
547                .push(format!("create_album {}", name));
548            Ok(self.add_album(parent, name, &[]))
549        }
550        async fn image_count(&self, album: &Album) -> Result<u64> {
551            Ok(self
552                .images
553                .lock()
554                .unwrap()
555                .get(&album.album_key)
556                .map_or(0, |i| i.len() as u64))
557        }
558        async fn album_images(&self, album_key: &str) -> Result<Vec<AlbumImage>> {
559            self.calls
560                .lock()
561                .unwrap()
562                .push(format!("album_images {}", album_key));
563            Ok(self
564                .images
565                .lock()
566                .unwrap()
567                .get(album_key)
568                .cloned()
569                .unwrap_or_default())
570        }
571    }
572
573    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
574        NaiveDate::from_ymd_opt(y, m, day).unwrap()
575    }
576
577    #[tokio::test]
578    async fn finds_existing_day_albums_without_creating_anything() {
579        let (fake, root) = FakeSmug::with_root();
580        let year = fake.add_folder(&root, "2014");
581        let month = fake.add_folder(&year, "07");
582        let album = fake.add_album(&month, "2014-07-12", &[("a.jpg", "aa")]);
583
584        let days = DayAlbums::new(fake.clone(), Some(root), false);
585        let day = days.day(d(2014, 7, 12)).await.unwrap();
586        let existing = day.series.existing_albums().await;
587        assert_eq!(existing.len(), 1);
588        assert_eq!(existing[0].album.album_key, album.album_key);
589        assert_eq!(fake.calls("create"), 0);
590
591        // A second day in the same month reuses the folder listings.
592        let before = fake.calls("children");
593        days.day(d(2014, 7, 13)).await.unwrap();
594        assert_eq!(fake.calls("children"), before);
595    }
596
597    #[tokio::test]
598    async fn creates_year_month_and_day_only_when_claimed() {
599        let (fake, root) = FakeSmug::with_root();
600        let days = DayAlbums::new(fake.clone(), Some(root.clone()), false);
601        let day = days.day(d(2020, 1, 5)).await.unwrap();
602        assert_eq!(fake.calls("create"), 0);
603
604        let album = day.series.claim().await.unwrap();
605        assert_eq!(album.name, "2020-01-05");
606        assert_eq!(fake.calls("create_folder"), 2);
607        assert_eq!(fake.calls("create_album"), 1);
608
609        // Another day that month reuses the new folders.
610        let other = days.day(d(2020, 1, 6)).await.unwrap();
611        other.series.claim().await.unwrap();
612        assert_eq!(fake.calls("create_folder"), 2);
613
614        let nodes = fake.nodes.lock().unwrap();
615        let year = nodes[&root].iter().find(|c| c.name == "2020").unwrap();
616        let month = nodes[&year.uri].iter().find(|c| c.name == "01").unwrap();
617        let names: Vec<_> = nodes[&month.uri].iter().map(|c| c.name.clone()).collect();
618        assert_eq!(names, vec!["2020-01-05", "2020-01-06"]);
619    }
620
621    #[tokio::test]
622    async fn dry_run_without_root_uses_placeholders() {
623        let (fake, _) = FakeSmug::with_root();
624        let days = DayAlbums::new(fake.clone(), None, true);
625        let day = days.day(d(2020, 1, 5)).await.unwrap();
626        let album = day.series.claim().await.unwrap();
627        assert_eq!(album.name, "2020-01-05");
628        assert!(album.album_key.is_empty());
629        assert!(fake.calls.lock().unwrap().is_empty());
630    }
631
632    #[tokio::test]
633    async fn dry_run_creates_nothing() {
634        let (fake, root) = FakeSmug::with_root();
635        let days = DayAlbums::new(fake.clone(), Some(root), true);
636        let day = days.day(d(2020, 1, 5)).await.unwrap();
637        day.series.claim().await.unwrap();
638        assert_eq!(fake.calls("create"), 0);
639    }
640
641    #[tokio::test]
642    async fn names_link_same_content_and_rename_different_content() {
643        let (fake, root) = FakeSmug::with_root();
644        let year = fake.add_folder(&root, "2014");
645        let month = fake.add_folder(&year, "07");
646        fake.add_album(&month, "2014-07-12", &[("IMG_0001.JPG", "aaaa1111")]);
647
648        let days = DayAlbums::new(fake.clone(), Some(root), false);
649        let day = days.day(d(2014, 7, 12)).await.unwrap();
650
651        match day.check_name("IMG_0001.JPG", "aaaa1111").await.unwrap() {
652            NameCheck::Present(image) => assert!(image.uri.ends_with("IMG_0001.JPG")),
653            other => panic!("{:?}", other),
654        }
655        assert_eq!(
656            day.check_name("IMG_0001.JPG", "bbbb2222cccc")
657                .await
658                .unwrap(),
659            NameCheck::Upload("IMG_0001~bbbb2222.JPG".into())
660        );
661        assert_eq!(
662            day.check_name("IMG_0002.JPG", "dddd").await.unwrap(),
663            NameCheck::Upload("IMG_0002.JPG".into())
664        );
665        // Reserved: another file with that name but other content is renamed.
666        assert_eq!(
667            day.check_name("IMG_0002.JPG", "eeee").await.unwrap(),
668            NameCheck::Upload("IMG_0002~eeee.JPG".into())
669        );
670        // A failed upload frees its name.
671        day.release_name("IMG_0002.JPG").await;
672        assert_eq!(
673            day.check_name("IMG_0002.JPG", "ffff").await.unwrap(),
674            NameCheck::Upload("IMG_0002.JPG".into())
675        );
676        // An uploaded name is recognized afterwards.
677        day.name_uploaded("IMG_0002.JPG", "/img/2", "A9").await;
678        assert_eq!(
679            day.check_name("IMG_0002.JPG", "ffff").await.unwrap(),
680            NameCheck::Present(NamedImage {
681                md5: Some("ffff".into()),
682                uri: "/img/2".into(),
683                album_key: "A9".into()
684            })
685        );
686        assert_eq!(fake.calls("album_images"), 1);
687    }
688
689    #[test]
690    fn renaming() {
691        assert_eq!(
692            renamed("IMG_0001.JPG", "0123456789"),
693            "IMG_0001~01234567.JPG"
694        );
695        assert_eq!(renamed("noext", "0123456789"), "noext~01234567");
696        assert_eq!(renamed(".hidden", "ab"), ".hidden~ab");
697    }
698}