boomack-cli 0.1.0

CLI client for Boomack
use std::path::Path;
use clap::{Args, ArgGroup};
use http_types::url::Url;
use http_types::mime::Mime;
use http_types::headers::{HeaderValues, ToHeaderValues};
use serde::Serialize;
use boomack::client::json::*;
use boomack::client::display::{
    DisplayParameters,
    display_text_request,
    display_url_request,
    display_file_request,
    display_stdin_request,
};
use super::structs::*;
use super::{
    CliConfig,
    parse_location,
    file_url, has_local_host,
    source_auto_content_uf,
    run_request,
};

#[derive(Args, Serialize)]
#[clap(group(ArgGroup::new("input").args(&["text-input", "url-input", "file-input"])))]
pub struct DisplaySubCommandArguments {

    #[clap(short = 'l', long = "location",
           help = "Panel and slot: E. g. 'my-panel/slot-a'")]
    pub location: Option<String>,

    #[clap(help = "Auto content: URL, filename, or text")]
    pub auto_content: Option<String>,

    #[clap(id = "text-input", short = 's', long = "string",
           help = "Text content")]
    pub text_input: Option<String>,

    #[clap(id = "url-input", short = 'u', long = "url",
           help = "URL content")]
    pub url_input: Option<String>,

    #[clap(id = "file-input", short = 'f', long = "file", value_name = "PATH",
           help = "File content")]
    pub file_input: Option<String>,

    #[clap(long = "no-file-url",
           help = "Prevents passing a file:// URL in case a file is displayed and the server is localhost",
           takes_value = false)]
    pub no_file_url: bool,

    #[clap(short = 't', long = "type", value_name = "MIME_TYPE",
           help = "MIME type of the content")]
    pub content_type: Option<String>,

    #[clap(short = 'x', long = "extend", value_name = "POSITION",
           help = "Position where to add to existing content in the target slot",
           possible_values = ["start", "end"])]
    pub extend: Option<String>,

    #[clap(long = "prepend",
           help = "Same as --extend start",
           takes_value = false)]
    pub prepend: bool,

    #[clap(long = "append",
           help = "Same as --extend end",
           takes_value = false)]
    pub append: bool,

    #[clap(short = 'c', long ="cache", value_name = "MODE",
           help = "Specifies the intended caching on the server",
           possible_values = ["auto", "memory", "file"])]
    pub cache: Option<String>,

    #[clap(short = 'e', long = "embed",
           help = "A switch to state the intention, \
                   that the media content should be embedded \
                   into the HTML and not referenced as a resource",
           takes_value = false)]
    pub embed : bool,

    #[clap(short = 'p', long = "presets", value_name = "PRESET",
           help = "One or more IDs of presets to apply",
           takes_value = true, multiple_values = true, multiple_occurrences = true)]
    pub presets: Vec<String>,

    #[clap(short = 'o', long = "options", value_name="OPTION",
           help = "Options as key=value pair(s) or as a YAML map",
           takes_value = true, multiple_values = true, multiple_occurrences = true)]
    pub options: Vec<String>,

    // #[clap(short = 'r', long = "raw",
    //        help = "Send content directly as JSON request. Ignores all options but --string and --file.",
    //        takes_value = false)]
    // pub raw: bool,

    #[clap(long = "debug",
           help = "Display debug information instead of the actual media item",
           takes_value = false)]
    pub debug: bool,
}

impl DisplaySubCommandArguments {
    pub fn get_display_command(&self) -> DisplayCommand {
        DisplayCommand {
            location: self.location.as_deref(),
            auto_content: self.auto_content.as_deref(),
            text_input: self.text_input.as_deref(),
            url_input: self.url_input.as_deref(),
            file_input: self.file_input.as_deref(),
            no_file_url: self.no_file_url,
            content_type: self.content_type.as_deref(),
            extend: self.extend.as_deref(),
            prepend: self.prepend,
            append: self.append,
            cache: self.cache.as_deref(),
            embed: self.embed,
            presets: self.presets.iter().map(|x| x.as_ref()).collect(),
            options: self.options.iter().map(|x| x.as_ref()).collect(),
            debug: self.debug,
        }
    }
}

pub struct DisplayCommand<'l> {
    pub location: Option<&'l str>,
    pub auto_content: Option<&'l str>,
    pub text_input: Option<&'l str>,
    pub url_input: Option<&'l str>,
    pub file_input: Option<&'l str>,
    pub no_file_url: bool,
    pub content_type: Option<&'l str>,
    pub extend: Option<&'l str>,
    pub prepend: bool,
    pub append: bool,
    pub cache: Option<&'l str>,
    pub embed : bool,
    pub presets: Vec<&'l str>,
    pub options: Vec<&'l str>,
    // pub raw: bool,
    pub debug: bool,
}

impl<'l> DisplayCommand<'l> {

    fn parse_options(&self, stdin_consumed: &mut bool) -> JsonMap {
        let option_layers = self.options.clone().into_iter()
            .map(|o| parse_structure(&o, true, stdin_consumed, parse_kvp_lines));
        merge_layers(option_layers)
    }

    fn apply_known_options(&self, options: &mut JsonMap) {
        if let Some(cache) = self.cache {
            set_json_str_value(options, "cache", cache);
        }
        if self.embed {
            set_json_bool_value(options, "embed", true);
        }

        let extend = if let Some(extend) = self.extend {
            Some(extend)
        } else if self.append {
            Some("end")
        } else if self.prepend {
            Some("start")
        } else {
            None
        };
        if let Some(extend) = extend {
            set_json_str_value(options, "extend", extend);
        }

        if self.debug {
            set_json_bool_value(options, "debug", true);
        }
    }

    pub fn build_options(&self, stdin_consumed: &mut bool) -> JsonMap {
        let mut options = self.parse_options(stdin_consumed);
        self.apply_known_options(&mut options);
        options
    }

    pub fn to_parameters(&'l self, options: JsonMap, default_content_type: Option<&'l str>) -> DisplayParameters<'l> {
        let (panel, slot) = parse_location(self.location.as_deref());
        DisplayParameters {
            panel,
            slot,
            content_type: self.content_type.or(default_content_type),
            presets: self.presets.clone(),
            options,
        }
    }
}

fn mime_from_extension(ext: &str) -> Option<Mime> {
    let ext_low = ext.to_lowercase();
    match ext_low.as_ref() {
        // text types
        "txt" => Some(Mime::from("text/plain")),
        "htm" | "html" => Some(Mime::from("text/html")),

        // data types
        "xml" | "xsd" | "xslt" => Some(Mime::from("text/xml")),
        "json" => Some(Mime::from("application/json")),
        "yaml" | "yml" => Some(Mime::from("application/yaml")),

        // programming languages
        "css" => Some(Mime::from("text/css")),
        "js" | "jsm" => Some(Mime::from("application/javascript")),
        "ts" => Some(Mime::from("application/typescript")),

        // image types
        "png" => Some(Mime::from("image/png")),
        "jpg" | "jpeg" => Some(Mime::from("image/jpeg")),
        "gif" => Some(Mime::from("image/gif")),
        _ => None
    }
}

fn derive_content_type_from_filename(filename: &str) -> Option<String> {
    Path::new(filename).extension()
        .and_then(|file_ext| mime_from_extension(&file_ext.to_string_lossy()))
        .map(|mime| String::from(HeaderValues::from_iter(mime.to_header_values().unwrap()).as_str()))
}

fn content_type_for_filename(cfg: &CliConfig, cmd: &DisplayCommand, filename: &str) -> Option<String> {
    match cmd.content_type {
        Some(content_type) => Some(content_type.into()),
        None => {
            let content_type = derive_content_type_from_filename(filename);
            if let Some(mime) = &content_type {
                trace!(cfg, "MIME type from file extension: {}", mime);
            }
            content_type
        }
    }
}

fn derive_content_type_from_url(url: &str) -> Option<String> {
    Url::parse(url).ok().and_then(|url| {
        url.path().split('.').last()
            .and_then(|path_ext| mime_from_extension(path_ext))
            .map(|mime| String::from(HeaderValues::from_iter(mime.to_header_values().unwrap()).as_str()))
    })
}

fn content_type_for_url(cfg: &CliConfig, cmd: &DisplayCommand, url: &str) -> Option<String> {
    match cmd.content_type {
        Some(content_type) => Some(content_type.into()),
        None => {
            let content_type = derive_content_type_from_url(url);
            if let Some(mime) = &content_type {
                trace!(cfg, "MIME type from file extension in URL path: {}", mime);
            }
            content_type
        },
    }
}

pub fn dispatch_display_command(cfg: &CliConfig, cmd: &DisplayCommand) -> i32 {
    let mut text_arg: Option<&str> = cmd.text_input.as_deref();
    let mut url_arg: Option<&str> = cmd.url_input.as_deref();
    let mut file_arg: Option<&str> = cmd.file_input.as_deref();

    if text_arg.is_none() && url_arg.is_none() && file_arg.is_none() {
        source_auto_content_uf(cmd.auto_content, &mut text_arg, &mut url_arg, &mut file_arg);
    }

    let mut stdin_consumed = false;
    let options = cmd.build_options(&mut stdin_consumed);

    if cfg.is_verbose() {
        trace!(cfg, "DISPLAY OPTIONS: {}", pprint_json(&options));
    }

    let request = if let Some(text_input) = text_arg {
        let params = cmd.to_parameters(options, Some("text/plain"));
        trace!(cfg, "Text request");
        display_text_request(&params, text_input)
    } else if let Some(file_input) = file_arg {
        let content_type = content_type_for_filename(cfg, cmd, file_input);
        let params = cmd.to_parameters(options, content_type.as_deref());
        if !cmd.no_file_url && has_local_host(&cfg.api.server.get_api_url()) {
            let file_url = file_url(file_input);
            trace!(cfg, "File request as file URL: {}", file_url);
            display_url_request(&params, &file_url)
        } else {
            trace!(cfg, "File request as stream: {}", file_input);
            display_file_request(&params, file_input)
        }
    } else if let Some(url_input) = url_arg {
        let content_type = content_type_for_url(cfg, cmd, url_input);
        let params = cmd.to_parameters(options, content_type.as_deref());
        trace!(cfg, "URL request: {}", url_input);
        display_url_request(&params, url_input)
    } else {
        let params = cmd.to_parameters(options, Some("application/octet-stream"));
        trace!(cfg, "Trying to read from STDIN");
        if stdin_consumed {
            panic!("Can not read display content from STDIN, because STDIN was already consumed by options!");
        }
        display_stdin_request(&params)
    };

    run_request(cfg, request)
}