apodclient 0.1.0

Download NASA APOD images
Documentation
// Copyright 2019 Sebastian Wiesner <sebastian@swsnr.de>

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at

// 	http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#![deny(warnings, clippy::all)]

use apodclient::*;
use chrono::{Local, NaiveDate};
use clap::{
    app_from_crate, crate_authors, crate_description, crate_name, crate_version, value_t_or_exit,
    AppSettings, Arg, SubCommand,
};
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use std::str::FromStr;

#[derive(Debug)]
struct Settings<'a> {
    directory: &'a Path,
}

fn apod_file_for_date(directory: &Path, date: NaiveDate) -> std::io::Result<Option<PathBuf>> {
    let date_marker = format!("{}-", date.format("%Y-%m-%d"));
    match std::fs::read_dir(directory) {
        Ok(entries) => {
            for entry_result in entries {
                let entry = entry_result?;
                let filename = entry.file_name();
                if filename
                    .as_os_str()
                    .to_string_lossy()
                    .starts_with(&date_marker)
                {
                    return Ok(Some(entry.path()));
                }
            }
            Ok(None)
        }
        Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e),
    }
}

fn download_filename(apod: &APOD) -> String {
    let basename = apod
        .download_url()
        .path_segments()
        .and_then(Iterator::last)
        .unwrap();
    format!("{}-{}", apod.date.format("%Y-%m-%d"), basename)
}

fn cmd_download(settings: Settings, date: NaiveDate) -> Result<(), Box<Error>> {
    if let Some(file) = apod_file_for_date(settings.directory, date)? {
        println!("Already downloaded at {}", file.to_string_lossy());
        Ok(())
    } else {
        let client = make_client();
        let apod = client.get(date)?;
        let client = make_client();
        if apod.media_type == "image" {
            std::fs::create_dir_all(settings.directory)?;
            let target = settings.directory.join(download_filename(&apod));
            let mut sink = File::create(&target)?;
            client.copy_to(&apod, &mut sink)?;
            let meta_target = target.with_extension("md");
            let mut meta_sink = File::create(meta_target)?;
            writeln!(
                &mut meta_sink,
                "# {}\n\nCopyright {}\n\n{}",
                apod.title,
                apod.copyright
                    .as_ref()
                    .map_or("public domain", String::as_str),
                apod.explanation
            )?;
            // TODO: Show desktop notification after download with image info, etc.
            println!(
                "APOD {} downloaded to {}",
                apod.date,
                target.to_string_lossy()
            );
            Ok(())
        } else {
            use std::io::{Error, ErrorKind};
            Err(Error::new(
                ErrorKind::InvalidData,
                format!(
                    "Media type {} of APOD at {} not supported",
                    apod.media_type, apod.date
                ),
            )
            .into())
        }
    }
}

fn cmd_title(_settings: Settings, date: NaiveDate) -> Result<(), Box<Error>> {
    let apod = make_client().get(date)?;
    println!("APOD {}: {}", apod.date, apod.title);
    Ok(())
}

fn cmd_browse(_settings: Settings, date: NaiveDate) -> Result<(), Box<Error>> {
    open::that(apod_url(date).as_str())?;
    Ok(())
}

fn make_client() -> APODClient {
    // TODO: Load API key from keyring/keychain
    APODClient::new("DEMO_KEY".to_string())
}

fn default_apod_directory() -> Option<PathBuf> {
    dirs::picture_dir()
        .or_else(dirs::home_dir)
        .map(|d| d.join("APOD"))
}

enum DateSpec {
    Today,
    Day(NaiveDate),
}

impl From<DateSpec> for NaiveDate {
    fn from(spec: DateSpec) -> Self {
        match spec {
            DateSpec::Today => Local::today().naive_utc(),
            DateSpec::Day(date) => date,
        }
    }
}

impl FromStr for DateSpec {
    type Err = chrono::ParseError;

    fn from_str(s: &str) -> Result<DateSpec, Self::Err> {
        match s {
            "today" => Ok(DateSpec::Today),
            _ => NaiveDate::from_str(s).map(DateSpec::Day),
        }
    }
}

fn main() -> Result<(), Box<Error>> {
    let default_dir = default_apod_directory().unwrap().into_os_string();
    let date_arg = Arg::with_name("date")
        .help("The date to download, in ISO format")
        .value_name("YYYY-MM-DD")
        .required(true)
        .default_value("today");
    let matches = app_from_crate!()
        .setting(AppSettings::SubcommandRequiredElseHelp)
        .arg(
            Arg::with_name("directory")
                .short("d")
                .long("directory")
                .help("Directory to write images to")
                .value_name("DIRECTORY")
                .empty_values(false)
                .default_value_os(&default_dir)
                .takes_value(true),
        )
        .subcommand(
            SubCommand::with_name("download")
                .about("Download the image of the given day")
                .arg(date_arg.clone()),
        )
        .subcommand(
            SubCommand::with_name("title")
                .about("Show the title of the image at the given day")
                .arg(date_arg.clone()),
        )
        .subcommand(
            SubCommand::with_name("browse")
                .about("Open the APOD site for the given day in your browser")
                .arg(date_arg),
        )
        .get_matches();

    let settings = Settings {
        directory: Path::new(matches.value_of("directory").unwrap()),
    };
    match matches.subcommand() {
        ("download", Some(dl_matches)) => {
            let datespec = value_t_or_exit!(dl_matches.value_of("date"), DateSpec);
            cmd_download(settings, datespec.into())
        }
        ("title", Some(title_matches)) => {
            let datespec = value_t_or_exit!(title_matches.value_of("date"), DateSpec);
            cmd_title(settings, datespec.into())
        }
        ("browse", Some(browse_matches)) => {
            let datespec = value_t_or_exit!(browse_matches.value_of("date"), DateSpec);
            cmd_browse(settings, datespec.into())
        }
        (s, _) => panic!("Unexpected subcommand {}", s),
    }
}