use crate::{path_or_stdin, BatchGrouper, BatchOp}; use anyhow::{anyhow, Context, Result}; use colored_json::to_colored_json_auto; use fatcat_cli::*; #[allow(unused_imports)] use log::{self, debug, info}; use std::io::Write; use std::path::PathBuf; use structopt::StructOpt; use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; #[derive(StructOpt)] #[structopt(rename_all = "kebab-case", about = "CLI interface to Fatcat API")] struct Opt { #[structopt( global = true, long = "--api-host", env = "FATCAT_API_HOST", default_value = "https://api.fatcat.wiki" )] api_host: String, // API auth tokens can be generated from the account page in the fatcat.wiki web interface #[structopt( global = true, long = "--api-token", env = "FATCAT_API_AUTH_TOKEN", hide_env_values = true )] api_token: Option, #[structopt( global = true, long = "--search-host", env = "FATCAT_SEARCH_HOST", default_value = "https://search.fatcat.wiki" )] search_host: String, /// Log more messages. Pass multiple times for ever more verbosity /// /// By default, it'll only report errors. Passing `-v` one time also prints /// warnings, `-vv` enables info logging, `-vvv` debug, and `-vvvv` trace. #[structopt(global = true, long, short = "v", parse(from_occurrences))] verbose: i8, #[structopt(long = "--shell-completions", hidden = true)] shell_completions: Option, #[structopt(long = "--meow", hidden = true)] meow: bool, #[structopt(subcommand)] cmd: Command, } #[derive(StructOpt)] enum EditgroupsCommand { /// Create a new editgroup Create { #[structopt(long, short)] description: String, }, /// Print editgroups for current user List { #[structopt(long = "--editor-id", short)] editor_id: Option, #[structopt(long, short = "-n", default_value = "20")] limit: i64, #[structopt(long)] json: bool, }, /// Print recent editgroups from any user which need review Reviewable { #[structopt(long, short = "-n", default_value = "20")] limit: i64, #[structopt(long)] json: bool, }, /// Accept (merge) a single editgroup Accept { #[structopt(env = "FATCAT_EDITGROUP", hide_env_values = true)] editgroup_id: EditgroupSpecifier, }, /// Submit a single editgroup for review Submit { #[structopt(env = "FATCAT_EDITGROUP", hide_env_values = true)] editgroup_id: EditgroupSpecifier, }, /// Un-submit a single editgroup (for more editing) Unsubmit { #[structopt(env = "FATCAT_EDITGROUP", hide_env_values = true)] editgroup_id: EditgroupSpecifier, }, } #[derive(StructOpt)] enum BatchCommand { /// Create new entities in batches Create { entity_type: EntityType, #[structopt(long, default_value = "50", global = true)] batch_size: u64, #[structopt(long, global = true)] auto_accept: bool, }, /// Update existing entities in batches Update { entity_type: EntityType, mutations: Vec, #[structopt(long, default_value = "50")] batch_size: u64, #[structopt(long)] auto_accept: bool, }, /// Delete entities in batches Delete { entity_type: EntityType, #[structopt(long, default_value = "50")] batch_size: u64, #[structopt(long)] auto_accept: bool, }, /// Download multiple files Download { #[structopt(long, short = "-o", parse(from_os_str))] output_dir: Option, #[structopt(long, short = "-j", default_value = "1")] jobs: u64, }, } #[derive(StructOpt)] enum Command { /// Fetch a single entity, by "ident" or external identifier Get { specifier: Specifier, #[structopt(long = "--expand")] expand: Option, #[structopt(long = "--hide")] hide: Option, #[allow(dead_code)] #[structopt(long)] json: bool, #[structopt(long)] toml: bool, }, /// Create a single new entity, from a file, in an existing editgroup Create { entity_type: EntityType, /// Input file, "-" for stdin. #[structopt(long = "--input-file", short = "-i", parse(from_os_str))] input_path: Option, #[structopt( long = "--editgroup-id", short, env = "FATCAT_EDITGROUP", hide_env_values = true )] editgroup_id: EditgroupSpecifier, }, /// Update an existing editgroup, either from file or updating specified fields Update { specifier: Specifier, /// Input file, "-" for stdin. #[structopt(long = "--input-file", short = "-i", parse(from_os_str))] input_path: Option, #[structopt( long = "--editgroup-id", short, env = "FATCAT_EDITGROUP", hide_env_values = true )] editgroup_id: EditgroupSpecifier, mutations: Vec, }, /// Delete a single entity Delete { specifier: Specifier, #[structopt( long = "--editgroup-id", short, env = "FATCAT_EDITGROUP", hide_env_values = true )] editgroup_id: EditgroupSpecifier, }, /// Use a text editor to update entity; fetches and uploads automatically Edit { specifier: Specifier, #[structopt( long = "--editgroup-id", short, env = "FATCAT_EDITGROUP", hide_env_values = true )] editgroup_id: EditgroupSpecifier, #[structopt(long)] json: bool, #[allow(dead_code)] #[structopt(long)] toml: bool, #[structopt(long = "--editing-command", env = "EDITOR")] editing_command: String, }, /// Fetch full-text file corresponding to an entity Download { specifier: Specifier, #[structopt(long = "--output-dir", short = "-o", parse(from_os_str))] output_path: Option, }, /// List edit history for a single entity History { specifier: Specifier, #[structopt(long, short = "-n", default_value = "100")] limit: u64, #[structopt(long)] json: bool, }, /// Query catalog index Search { entity_type: SearchEntityType, query: Vec, #[structopt(long = "--expand")] expand: Option, #[structopt(long = "--hide")] hide: Option, #[structopt(long = "--count")] count: bool, #[structopt(long, short = "-n", default_value = "20")] limit: i64, #[structopt(long = "--entity-json")] entity_json: bool, #[structopt(long = "--index-json")] index_json: bool, }, /// Sub-commands for managing editgroups Editgroups { #[structopt(subcommand)] cmd: EditgroupsCommand, }, /// List recent accepted edits to the catalog Changelog { #[structopt(long, short = "-n", default_value = "20")] limit: i64, /* TODO: follow (streaming) mode for changelog #[structopt(long, short = "-f")] follow: bool, */ #[structopt(long)] json: bool, }, /// Operations on multiple entities Batch { #[structopt(subcommand)] cmd: BatchCommand, /// Input file, "-" for stdin. #[structopt(long = "--input-file", short = "-i", parse(from_os_str))] input_path: Option, #[structopt(long, short = "-n")] limit: Option, }, /// Summarize connection and authentication with API Status { #[structopt(long)] json: bool, }, } fn main() -> Result<()> { let opt = Opt::from_args(); let log_level = match opt.verbose { std::i8::MIN..=-1 => "none", 0 => "error", 1 => "warn", 2 => "info", 3 => "debug", 4..=std::i8::MAX => "trace", }; // hyper logging is very verbose, so crank that down even if everything else is more verbose let log_filter = format!("{},hyper=error", log_level); env_logger::from_env(env_logger::Env::default().default_filter_or(log_filter)) .format_timestamp(None) .init(); debug!("Args parsed, starting up"); #[cfg(windows)] colored_json::enable_ansi_support(); if let Some(shell) = opt.shell_completions { Opt::clap().gen_completions_to("fatcat-cli", shell, &mut std::io::stdout()); std::process::exit(0); } if opt.meow { println!("meow meow"); std::process::exit(0); } if let Err(err) = run(opt) { // Be graceful about some errors if let Some(io_err) = err.root_cause().downcast_ref::() { if let std::io::ErrorKind::BrokenPipe = io_err.kind() { // presumably due to something like writing to stdout and piped to `head -n10` and // stdout was closed debug!("got BrokenPipe error, assuming stdout closed as expected and exiting with success"); std::process::exit(0); } } let mut color_stderr = StandardStream::stderr(if atty::is(atty::Stream::Stderr) { ColorChoice::Auto } else { ColorChoice::Never }); color_stderr.set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true))?; eprintln!("Error: {:?}", err); color_stderr.set_color(&ColorSpec::new())?; std::process::exit(1); } Ok(()) } fn run(opt: Opt) -> Result<()> { let mut api_client = FatcatApiClient::new(opt.api_host.clone(), opt.api_token.clone())?; match opt.cmd { Command::Get { specifier, expand, hide, json: _, toml, } => { let result = specifier.get_from_api(&mut api_client, expand, hide)?; if toml { writeln!(&mut std::io::stdout(), "{}", result.to_toml_string()?)? } else { // "if json" writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&result.to_json_value()?)? )? } } Command::Create { entity_type, input_path, editgroup_id, } => { let json_str = read_entity_file(input_path)?; let ee = api_client.create_entity_from_json( entity_type, &json_str, editgroup_id.into_string(), )?; writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&serde_json::to_value(&ee)?)? )? } Command::Update { specifier, input_path, editgroup_id, mutations, } => { let (json_str, exact_specifier): (String, Specifier) = match (&input_path, mutations.len()) { // input path or no mutations: read from path or stdin (Some(_), _) | (None, 0) => ( read_entity_file(input_path)?, specifier.into_entity_specifier(&mut api_client)?, ), // no input path *and* mutations: fetch from API (None, _) => { let mut entity = specifier.get_from_api(&mut api_client, None, None)?; entity.mutate(mutations)?; (entity.to_json_string()?, entity.specifier()) } }; let ee = api_client.update_entity_from_json( exact_specifier, &json_str, editgroup_id.into_string(), )?; writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&serde_json::to_value(&ee)?)? )? } Command::Edit { specifier, editgroup_id, json, toml: _, editing_command, } => { let ee = edit_entity_locally( &mut api_client, specifier, editgroup_id.into_string(), json, editing_command, )?; writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&serde_json::to_value(&ee)?)? )? } Command::Changelog { limit, json } => { let resp = api_client .rt .block_on(api_client.api.get_changelog(Some(limit))) .context("fetch recent changelogs")?; match resp { fatcat_openapi::GetChangelogResponse::Success(change_list) => { print_changelog_entries(change_list, json)?; } other => { return Err(anyhow!("{:?}", other)) .with_context(|| "failed to fetch changelogs".to_string()) } } } Command::Batch { cmd: BatchCommand::Create { entity_type, batch_size, auto_accept, }, input_path, limit, } => { let input_path = path_or_stdin(input_path); let mut batch = BatchGrouper::new(entity_type, batch_size, limit, auto_accept); batch.run(&mut api_client, input_path, BatchOp::Create, None)?; } Command::Batch { cmd: BatchCommand::Update { entity_type, mutations, batch_size, auto_accept, }, input_path, limit, } => { let input_path = path_or_stdin(input_path); let mut batch = BatchGrouper::new(entity_type, batch_size, limit, auto_accept); batch.run( &mut api_client, input_path, BatchOp::Update, Some(mutations), )?; } Command::Batch { cmd: BatchCommand::Delete { entity_type, batch_size, auto_accept, }, input_path, limit, } => { let input_path = path_or_stdin(input_path); let mut batch = BatchGrouper::new(entity_type, batch_size, limit, auto_accept); batch.run(&mut api_client, input_path, BatchOp::Delete, None)?; } Command::Batch { cmd: BatchCommand::Download { jobs, output_dir }, input_path, limit, } => { let input_path = path_or_stdin(input_path); if let Some(ref dir) = output_dir { if !dir.is_dir() { return Err(anyhow!("output directory doesn't exist")); } } if jobs == 0 { return Err(anyhow!("--jobs=0 not implemented")); } if jobs > 12 { return Err(anyhow!( "please don't download more than 12 parallel requests" )); } download_batch(input_path, output_dir, limit, jobs)?; } Command::Download { specifier, output_path, } => { // run lookups if necessary (inefficient) let specifier = match specifier { Specifier::ReleaseLookup(_, _) | Specifier::FileLookup(_, _) => { specifier.into_entity_specifier(&mut api_client)? } _ => specifier, }; if let Some(ref path) = output_path { if path.exists() { return Err(anyhow!("refusing to over-write output file")); } } let status = match specifier { Specifier::Release(ident) => { let result = api_client.rt.block_on(api_client.api.get_release( ident.clone(), Some("files".to_string()), Some("abstracts,refs".to_string()), ))?; let release_entity = match result { fatcat_openapi::GetReleaseResponse::FoundEntity(model) => Ok(model), resp => Err(anyhow!("{:?}", resp)) .with_context(|| format!("API GET failed: {:?}", ident)), }?; download_release(&release_entity, output_path, true) } Specifier::File(ident) => { let result = api_client.rt.block_on(api_client.api.get_file( ident.clone(), None, None, ))?; let file_entity = match result { fatcat_openapi::GetFileResponse::FoundEntity(model) => Ok(model), resp => Err(anyhow!("{:?}", resp)) .with_context(|| format!("API GET failed: {:?}", ident)), }?; download_file(&file_entity, &file_entity.specifier(), output_path, true) } other => Err(anyhow!("Don't know how to download: {:?}", other)), }?; if let Some(detail) = status.details() { println!("{}: {}", status, detail); } else { println!("{}", status); } } Command::Search { entity_type, query, limit, count, entity_json, index_json, expand, hide, } => { let limit: Option = match (count, limit) { (true, _) => Some(0), (false, l) if l <= 0 => None, (false, l) => Some(l as u64), }; let results = fatcat_cli::crude_search(&opt.search_host, entity_type, limit, query) .with_context(|| format!("searching for {:?}", entity_type))?; if count { println!("{}", results.count); } else { eprintln!("Got {} hits in {}ms", results.count, results.took_ms); if !(index_json || entity_json) { print_search_table(results, entity_type)?; } else { for hit in results { let hit = hit?; match (index_json, entity_json, entity_type) { (false, false, _) => unreachable!("case handled above"), (true, _, _) => { writeln!(&mut std::io::stdout(), "{}", hit.to_string())? } (false, true, SearchEntityType::Release) => { let specifier = Specifier::Release(hit["ident"].as_str().unwrap().to_string()); let entity = specifier.get_from_api( &mut api_client, expand.clone(), hide.clone(), )?; writeln!(&mut std::io::stdout(), "{}", entity.to_json_string()?)? } (false, true, SearchEntityType::Container) => { let specifier = Specifier::Container( hit["ident"].as_str().unwrap().to_string(), ); let entity = specifier.get_from_api( &mut api_client, expand.clone(), hide.clone(), )?; writeln!(&mut std::io::stdout(), "{}", entity.to_json_string()?)? } (false, true, SearchEntityType::File) => { let specifier = Specifier::File(hit["ident"].as_str().unwrap().to_string()); let entity = specifier.get_from_api( &mut api_client, expand.clone(), hide.clone(), )?; writeln!(&mut std::io::stdout(), "{}", entity.to_json_string()?)? } (false, true, SearchEntityType::Scholar) => { if !hit["biblio"]["release_ident"].is_string() { continue; } let specifier = Specifier::Release( hit["biblio"]["release_ident"].as_str().unwrap().to_string(), ); let entity = specifier.get_from_api( &mut api_client, expand.clone(), hide.clone(), )?; writeln!(&mut std::io::stdout(), "{}", entity.to_json_string()?)? } (false, true, SearchEntityType::Reference) => { return Err(anyhow!( "entity schema output not supported for references index" )); } (false, true, SearchEntityType::ReferenceIn) => { if !hit["source_release_ident"].is_string() { continue; } let specifier = Specifier::Release( hit["source_release_ident"].as_str().unwrap().to_string(), ); let entity = specifier.get_from_api( &mut api_client, expand.clone(), hide.clone(), )?; writeln!(&mut std::io::stdout(), "{}", entity.to_json_string()?)? } (false, true, SearchEntityType::ReferenceOut) => { if !hit["target_release_ident"].is_string() { continue; } let specifier = Specifier::Release( hit["target_release_ident"].as_str().unwrap().to_string(), ); let entity = specifier.get_from_api( &mut api_client, expand.clone(), hide.clone(), )?; writeln!(&mut std::io::stdout(), "{}", entity.to_json_string()?)? } } } } } } Command::Delete { specifier, editgroup_id, } => { let result = api_client .delete_entity(specifier.clone(), editgroup_id.into_string()) .with_context(|| format!("delete entity: {:?}", specifier))?; println!("{}", serde_json::to_string(&result)?); } Command::History { specifier, limit, json, } => { let specifier = specifier.into_entity_specifier(&mut api_client)?; let history_entries = specifier.get_history(&mut api_client, Some(limit))?; print_entity_histories(history_entries, json)?; } Command::Editgroups { cmd: EditgroupsCommand::List { editor_id, limit, json, }, } => { let editor_id = match editor_id.or(api_client.editor_id) { Some(eid) => eid, None => return Err(anyhow!("require either working auth token or --editor-id")), }; let result = api_client .rt .block_on(api_client.api.get_editor_editgroups( editor_id.clone(), Some(limit), None, None, )) .context("fetch editgroups")?; match result { fatcat_openapi::GetEditorEditgroupsResponse::Found(eg_list) => { print_editgroups(eg_list, json)?; } other => { return Err(anyhow!("{:?}", other)).with_context(|| { format!("failed to fetch editgroups for editor_{}", editor_id) }) } } } Command::Editgroups { cmd: EditgroupsCommand::Reviewable { limit, json }, } => { let result = api_client .rt .block_on(api_client.api.get_editgroups_reviewable( Some("editors".to_string()), Some(limit), None, None, )) .context("fetch reviewable editgroups")?; match result { fatcat_openapi::GetEditgroupsReviewableResponse::Found(eg_list) => { print_editgroups(eg_list, json)?; } other => { return Err(anyhow!("{:?}", other)) .context("failed to fetch reviewable editgroups") } } } Command::Editgroups { cmd: EditgroupsCommand::Create { description }, } => { let eg = api_client.create_editgroup(Some(description))?; writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&serde_json::to_value(&eg)?)? )? } Command::Editgroups { cmd: EditgroupsCommand::Accept { editgroup_id }, } => { let msg = api_client.accept_editgroup(editgroup_id.into_string())?; writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&serde_json::to_value(&msg)?)? )? } Command::Editgroups { cmd: EditgroupsCommand::Submit { editgroup_id }, } => { let msg = api_client.update_editgroup_submit(editgroup_id.into_string(), true)?; writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&serde_json::to_value(&msg)?)? )? } Command::Editgroups { cmd: EditgroupsCommand::Unsubmit { editgroup_id }, } => { let msg = api_client.update_editgroup_submit(editgroup_id.into_string(), false)?; writeln!( &mut std::io::stdout(), "{}", to_colored_json_auto(&serde_json::to_value(&msg)?)? )? } Command::Status { json } => { let status = ClientStatus::generate(&mut api_client)?; if json { println!("{}", serde_json::to_string(&status)?) } else { status.pretty_print()?; } } } Ok(()) }