use std::path::{Path, PathBuf};
use std::process::exit;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{sleep, spawn};
use std::time::{Duration, SystemTime};
use clap::{Args, ArgGroup};
use http_types::url::Url;
use http_types::mime::Mime;
use http_types::headers::{HeaderValues, ToHeaderValues};
use serde::Serialize;
use notify::{Watcher, RecommendedWatcher, RecursiveMode, EventKind};
use boomack::client::api::ClientRequest;
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",
num_args = 0)]
pub no_file_url: bool,
#[clap(id = "watch", short = 'w', long = "watch",
help = "Watch the file and display content on change",
requires = "file-input",
num_args = 0)]
pub watch: 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",
value_parser = ["start", "end"])]
pub extend: Option<String>,
#[clap(long = "prepend",
help = "Same as --extend start",
num_args = 0)]
pub prepend: bool,
#[clap(long = "append",
help = "Same as --extend end",
num_args = 0)]
pub append: bool,
#[clap(short = 'c', long ="cache", value_name = "MODE",
help = "Specifies the intended caching on the server",
value_parser = ["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",
num_args = 0)]
pub embed : bool,
#[clap(long = "title", value_name = "TITLE",
help = "A title for the content")]
pub title: Option<String>,
#[clap(long = "debug",
help = "Display debug information instead of the actual media item",
num_args = 0)]
pub debug: bool,
#[clap(short = 'o', long = "options", value_name="OPTION",
help = "Display options as key=value pair(s), or a YAML/JSON map, or a filename, or STDIN",
num_args = 1..)]
pub options: Vec<String>,
#[clap(short = 'p', long = "presets", value_name = "PRESET",
help = "One or more IDs of presets to apply",
num_args = 1..)]
pub presets: Vec<String>,
}
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,
watch: self.watch,
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,
title: self.title.as_deref(),
debug: self.debug,
options: self.options.iter().map(|x| x.as_ref()).collect(),
presets: self.presets.iter().map(|x| x.as_ref()).collect(),
}
}
}
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 watch: 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 title: Option<&'l str>,
pub presets: Vec<&'l str>,
pub options: Vec<&'l str>,
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),
title: self.title,
presets: self.presets.clone(),
options,
}
}
}
fn mime_from_extension(ext: &str) -> Option<Mime> {
let ext_low = ext.to_lowercase();
match ext_low.as_ref() {
"txt" => Some(Mime::from("text/plain")),
"htm" | "html" => Some(Mime::from("text/html")),
"md" => Some(Mime::from("text/markdown")),
"xml" | "xsd" | "xslt" => Some(Mime::from("text/xml")),
"json" => Some(Mime::from("application/json")),
"yaml" | "yml" => Some(Mime::from("application/yaml")),
"csv" => Some(Mime::from("text/csv")),
"css" => Some(Mime::from("text/css")),
"js" | "jsm" => Some(Mime::from("application/javascript")),
"ts" => Some(Mime::from("application/typescript")),
"png" => Some(Mime::from("image/png")),
"jpg" | "jpeg" => Some(Mime::from("image/jpeg")),
"gif" => Some(Mime::from("image/gif")),
"svg" => Some(Mime::from("image/svg+xml")),
_ => None
}
}
fn derive_content_type_from_filename(cfg: &CliConfig, filename: &str) -> Option<String> {
let path = Path::new(filename);
let configured_media_type =
path.file_name()
.and_then(|filename|
cfg.api.client.lookup_media_type(&filename.to_string_lossy()));
if configured_media_type.is_some() {
return configured_media_type
}
let guessed_media_type =
path.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()));
guessed_media_type
}
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(cfg, filename);
if let Some(mime) = &content_type {
trace!(cfg, "MIME type from filename: {}", 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);
}
if let Some(file_input) = file_arg {
if file_input == "-" {
file_arg = Option::None;
}
}
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 mut file_path: String = String::from("");
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(¶ms, text_input)
} else if let Some(file_input) = file_arg {
file_path = file_input.to_string();
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(¶ms, &file_url)
} else {
trace!(cfg, "File request as stream: {}", file_input);
display_file_request(¶ms, file_input)
}
} else if let Some(url_input) = url_arg {
if let Ok(url) = Url::from_str(url_input) {
if url.scheme() != "http" &&
url.scheme() != "https" &&
url.scheme() != "file" {
eprintln!("Invalid protocol in URL. Allowed: http, https, file.");
exit(1);
}
} else {
eprintln!("Invalid URL: {}", url_input);
exit(1)
}
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(¶ms, url_input)
} else {
let params = cmd.to_parameters(options, Some("application/octet-stream"));
trace!(cfg, "Trying to read from STDIN");
if stdin_consumed {
eprintln!("Can not read display content from STDIN, because STDIN was already consumed by options!");
exit(1);
}
display_stdin_request(¶ms)
};
fn equal_path(a: &PathBuf, b: &PathBuf) -> bool {
if let Ok(a) = a.canonicalize() {
if let Ok(b) = b.canonicalize() {
return a == b;
}
}
false
}
if cmd.watch { if let Some(file_input) = file_arg {
let first_result = run_request(cfg, request.clone());
if first_result != 0 {
return first_result;
}
let local_cfg = cfg.clone();
let test_path = PathBuf::from_str(&file_path).unwrap();
let busy = Arc::new(AtomicBool::new(false));
let skipped = Arc::new(AtomicBool::new(false));
let t = Arc::new(Mutex::new(SystemTime::now()));
let mut watcher = Box::new(
RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
if (matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)))
&& event.paths.iter().any(|p| equal_path(p, &test_path))
{
let ct = SystemTime::now();
{
let mut t = t.lock().unwrap();
if ct.duration_since(*t).unwrap() < Duration::from_millis(100) {
return;
}
*t = ct;
}
trace!(&local_cfg, "File was {}", match event.kind {
EventKind::Create(_) => "created",
EventKind::Modify(_) => "modified",
_ => "<unexpected event>",
});
let worker = DisplayWorkerContext::new(
local_cfg.clone(), request.clone(),
busy.clone(), skipped.clone()
);
worker.spawn();
}
}
},
notify::Config::default())
.unwrap());
let file_path = Path::new(file_input);
let dir_path = file_path.parent()
.expect("Can not watch file without parent directory");
watcher.watch(dir_path, RecursiveMode::NonRecursive).unwrap();
println!("Watching...");
loop { sleep(Duration::from_millis(100)); }
} }
run_request(cfg, request)
}
#[derive(Clone)]
struct DisplayWorkerContext {
pub cfg: CliConfig,
pub busy: Arc<AtomicBool>,
pub skipped: Arc<AtomicBool>,
pub request: ClientRequest,
}
impl DisplayWorkerContext {
pub fn new(cfg: CliConfig, request: ClientRequest, busy: Arc<AtomicBool>, skipped: Arc<AtomicBool>) -> DisplayWorkerContext {
DisplayWorkerContext {
cfg,
request,
busy,
skipped,
}
}
fn run(&self) {
if matches!(self.busy.compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire),
Err(_)) {
self.skipped.store(true, Ordering::Relaxed);
return;
}
trace!(&self.cfg, "Sending request after filesystem event");
run_request(&self.cfg, self.request.clone());
self.busy.store(false, Ordering::Release);
if matches!(self.skipped.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed),
Ok(_)) {
self.spawn();
}
}
pub fn spawn(&self) {
let local_ctx = self.clone();
spawn(move || local_ctx.run());
}
}