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