use anyhow::{anyhow, Context, Result}; use chrono_humanize::HumanTime; use fatcat_openapi::models; #[allow(unused_imports)] use log::{self, debug, info}; use std::fs::File; use std::io::{self, BufRead, Write}; use std::path::PathBuf; use tabwriter::TabWriter; use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; use crate::{ entity_model_from_json_str, read_entity_file, ApiModelSer, EntityType, FatcatApiClient, Mutation, SearchEntityType, SearchResults, Specifier, }; // Want to show: // - whether api_token found // - configured api_host we are connecting to // - whether we can connect to remote host (eg, get most recent changelog) // - whether our auth is valid // - current active editgroup #[derive(Debug, PartialEq, Clone, serde::Serialize)] pub struct ClientStatus { pub has_api_token: bool, pub api_host: String, pub last_changelog: Option, pub account: Option, pub cli_api_version: String, } impl ClientStatus { pub fn generate(api_client: &mut FatcatApiClient) -> Result { let last_changelog = match api_client .rt .block_on(api_client.api.get_changelog(Some(1))) { Ok(fatcat_openapi::GetChangelogResponse::Success(entry_vec)) => { Some(entry_vec[0].index) } Ok(_) | Err(_) => None, }; let has_api_token = api_client.api_token.is_some(); let account: Option = if has_api_token && last_changelog.is_some() { match api_client .rt .block_on(api_client.api.auth_check(None)) .context("check auth token")? { fatcat_openapi::AuthCheckResponse::Success(_) => Ok(()), fatcat_openapi::AuthCheckResponse::Forbidden(err) => { Err(anyhow!("Forbidden ({}): {}", err.error, err.message)) } fatcat_openapi::AuthCheckResponse::NotAuthorized { body: err, .. } => { Err(anyhow!("Bad Request ({}): {}", err.error, err.message)) } resp => return Err(anyhow!("{:?}", resp)).context("auth check failed"), } .context("check auth token")?; match api_client .rt .block_on( api_client .api .get_editor(api_client.editor_id.as_ref().unwrap().to_string()), ) .context("fetching editor account info")? { fatcat_openapi::GetEditorResponse::Found(editor) => Some(editor), fatcat_openapi::GetEditorResponse::NotFound(err) => { return Err(anyhow!("Not Found: {}", err.message)) } resp => return Err(anyhow!("{:?}", resp)).context("editor fetch failed"), } } else { None }; Ok(ClientStatus { api_host: api_client.api_host.clone(), has_api_token, last_changelog, account, cli_api_version: fatcat_openapi::API_VERSION.to_string(), }) } pub fn pretty_print(self) -> Result<()> { let mut color_stdout = StandardStream::stdout(if atty::is(atty::Stream::Stdout) { ColorChoice::Auto } else { ColorChoice::Never }); let color_normal = ColorSpec::new(); let mut color_bold = ColorSpec::new(); color_bold.set_bold(true); let mut color_happy = ColorSpec::new(); color_happy.set_fg(Some(Color::Green)).set_bold(true); let mut color_sad = ColorSpec::new(); color_sad.set_fg(Some(Color::Red)).set_bold(true); color_stdout.set_color(&color_normal)?; write!(&mut color_stdout, "{:>16}: ", "API Version")?; color_stdout.set_color(&color_bold)?; writeln!(&mut color_stdout, "{} (local)", self.cli_api_version)?; color_stdout.set_color(&color_normal)?; write!(&mut color_stdout, "{:>16}: ", "API host")?; color_stdout.set_color(&color_bold)?; write!(&mut color_stdout, "{}", self.api_host)?; match self.last_changelog { Some(index) => { color_stdout.set_color(&color_happy)?; writeln!(&mut color_stdout, " [successfully connected]")?; color_stdout.set_color(&color_normal)?; write!(&mut color_stdout, "{:>16}: ", "Last changelog")?; color_stdout.set_color(&color_bold)?; writeln!(&mut color_stdout, "{}", index)?; } None => { color_stdout.set_color(&color_sad)?; writeln!(&mut color_stdout, " [Failed to connect]")?; } }; color_stdout.set_color(&color_normal)?; write!(&mut color_stdout, "{:>16}: ", "API auth token")?; if self.has_api_token { color_stdout.set_color(&color_happy)?; writeln!(&mut color_stdout, "[configured]")?; } else { color_stdout.set_color(&color_sad)?; writeln!(&mut color_stdout, "[not configured]")?; }; if let Some(editor) = self.account { color_stdout.set_color(&color_normal)?; write!(&mut color_stdout, "{:>16}: ", "Account")?; color_stdout.set_color(&color_bold)?; write!(&mut color_stdout, "{}", editor.username)?; if editor.is_bot == Some(true) { color_stdout .set_color(ColorSpec::new().set_fg(Some(Color::Blue)).set_bold(true))?; write!(&mut color_stdout, " [bot]")?; } if editor.is_admin == Some(true) { color_stdout .set_color(ColorSpec::new().set_fg(Some(Color::Magenta)).set_bold(true))?; write!(&mut color_stdout, " [admin]")?; } match editor.is_active { Some(true) => { color_stdout.set_color(&color_happy)?; writeln!(&mut color_stdout, " [active]")?; } Some(false) | None => { color_stdout.set_color(&color_sad)?; writeln!(&mut color_stdout, " [disabled]")?; } }; color_stdout.set_color(&color_normal)?; writeln!( &mut color_stdout, "{:>16} editor_{}", "", editor.editor_id.unwrap() )?; }; color_stdout.set_color(&color_normal)?; Ok(()) } } pub fn print_editgroups(eg_list: Vec, json: bool) -> Result<()> { if json { for eg in eg_list { writeln!(&mut std::io::stdout(), "{}", eg.to_json_string()?)?; } } else { let mut tw = TabWriter::new(std::io::stdout()); writeln!( tw, "editgroup_id\tchangelog_index\tcreated\tsubmitted\tdescription" )?; for eg in eg_list { writeln!( tw, "editgroup_{}\t{}\t{}\t{}\t{}", eg.editgroup_id.unwrap(), eg.changelog_index .map_or("-".to_string(), |v| v.to_string()), eg.created .map_or("-".to_string(), |v| HumanTime::from(v).to_string()), eg.submitted .map_or("-".to_string(), |v| HumanTime::from(v).to_string()), eg.description.unwrap_or_else(|| "-".to_string()) )?; } tw.flush()?; } Ok(()) } pub fn print_changelog_entries(entry_list: Vec, json: bool) -> Result<()> { if json { for entry in entry_list { writeln!(&mut std::io::stdout(), "{}", entry.to_json_string()?)?; } } else { let mut tw = TabWriter::new(std::io::stdout()); writeln!(tw, "index\ttimestamp\teditor\teditgroup_description")?; for entry in entry_list { writeln!( tw, "{}\t{}\t{}\t{}", entry.index, HumanTime::from(entry.timestamp), entry .editgroup .as_ref() .unwrap() .editor .as_ref() .map_or("-".to_string(), |v| v.username.to_string()), entry .editgroup .as_ref() .unwrap() .description .as_ref() .map_or("-".to_string(), |v| v.to_string()), )?; } tw.flush()?; } Ok(()) } pub fn print_entity_histories( history_list: Vec, json: bool, ) -> Result<()> { if json { for history in history_list { writeln!(&mut std::io::stdout(), "{}", history.to_json_string()?)?; } } else { let mut tw = TabWriter::new(std::io::stdout()); writeln!( tw, "changelog_index\ttype\ttimestamp\teditor\teditgroup_description" )?; for history in history_list { let state = match ( history.edit.revision, history.edit.prev_revision, history.edit.redirect_ident, ) { (Some(_), None, None) => "create", (Some(_), Some(_), None) => "update", (None, _, None) => "delete", (None, _, Some(_)) => "redirect", _ => "-", }; writeln!( tw, "{}\t{}\t{}\t{}\t{}", history.changelog_entry.index, state, HumanTime::from(history.changelog_entry.timestamp), history .editgroup .editor .map_or("-".to_string(), |v| v.username), history .editgroup .description .unwrap_or_else(|| "-".to_string()) )?; } tw.flush()?; } Ok(()) } pub fn print_search_table(results: SearchResults, entity_type: SearchEntityType) -> Result<()> { let mut tw = TabWriter::new(std::io::stdout()); match entity_type { SearchEntityType::Release => { writeln!(tw, "ident\ttype\tstage\tyear\tcontainer_name\ttitle")?; } SearchEntityType::Container => { writeln!(tw, "ident\tissnl\tname")?; } SearchEntityType::File => { writeln!(tw, "ident\tsha1\tsize_bytes\tmimetype")?; } SearchEntityType::Scholar => { writeln!(tw, "key\ttype\tstage\tyear\tcontainer_name\ttitle")?; } SearchEntityType::Reference | SearchEntityType::ReferenceIn | SearchEntityType::ReferenceOut => { writeln!(tw, "source\tkey\tprovenance\tstatus\ttarget")?; } } for hit in results { let hit = hit?; match entity_type { SearchEntityType::Release => { writeln!( tw, "release_{}\t{}\t{}\t{}\t{}\t{}", hit["ident"].as_str().unwrap_or("-"), hit["release_type"].as_str().unwrap_or("-"), hit["release_stage"].as_str().unwrap_or("-"), hit["release_year"] .as_u64() .map_or("-".to_string(), |v| v.to_string()), hit["container_name"].as_str().unwrap_or("-"), hit["title"].as_str().unwrap_or("-"), )?; } SearchEntityType::Container => { writeln!( tw, "container_{}\t{}\t{}", hit["ident"].as_str().unwrap_or("-"), hit["issnl"].as_str().unwrap_or("-"), hit["name"].as_str().unwrap_or("-"), )?; } SearchEntityType::File => { writeln!( tw, "file_{}\t{}\t{}\t{}", hit["ident"].as_str().unwrap_or("-"), hit["sha1"].as_str().unwrap_or("-"), hit["size_bytes"] .as_u64() .map_or("-".to_string(), |v| v.to_string()), hit["mimetype"].as_str().unwrap_or("-"), )?; } SearchEntityType::Scholar => { writeln!( tw, "{}\t{}\t{}\t{}\t{}\t{}", hit["key"].as_str().unwrap_or("-"), hit["biblio"]["release_type"].as_str().unwrap_or("-"), hit["biblio"]["release_stage"].as_str().unwrap_or("-"), hit["biblio"]["release_year"] .as_u64() .map_or("-".to_string(), |v| v.to_string()), hit["biblio"]["container_name"].as_str().unwrap_or("-"), hit["biblio"]["title"].as_str().unwrap_or("-"), )?; } SearchEntityType::Reference | SearchEntityType::ReferenceIn | SearchEntityType::ReferenceOut => { writeln!( tw, "{}\t{}\t{}\t{}\t{}", hit["source_release_ident"].as_str().unwrap_or("-"), hit["ref_key"].as_str().unwrap_or("-"), hit["match_provenance"].as_str().unwrap_or("-"), hit["match_status"].as_str().unwrap_or("-"), hit["target_release_ident"].as_str().unwrap_or("-"), )?; } } } tw.flush()?; Ok(()) } pub fn edit_entity_locally( api_client: &mut FatcatApiClient, specifier: Specifier, editgroup_id: String, json: bool, editing_command: String, ) -> Result { // TODO: fetch editgroup, check if this entity is already being updated in it. If so, // need to fetch that revision, do the edit, parse that synatx is good, then delete the // existing edit and update with the new one. let original_entity = specifier.get_from_api(api_client, None, None)?; let exact_specifier = original_entity.specifier(); let tmp_file = tempfile::Builder::new() .suffix(if json { ".json" } else { ".toml" }) .tempfile()?; if json { writeln!(&tmp_file, "{}", original_entity.to_json_string()?)? } else { writeln!(&tmp_file, "{}", original_entity.to_toml_string()?)? } let mut editor_cmd = std::process::Command::new(&editing_command) .arg(tmp_file.path()) .spawn() .expect("failed to execute process"); let cmd_status = editor_cmd.wait()?; if !cmd_status.success() { return Err(anyhow!( "editor ({}) exited with non-success status code ({}), bailing on edit", editing_command, cmd_status .code() .map(|v| v.to_string()) .unwrap_or_else(|| "N/A".to_string()) )); }; let json_str = read_entity_file(Some(tmp_file.path().to_path_buf()))?; // for whatever reason api_client's TCP connection is broken after spawning, so try a // dummy call, expected to fail, but connection should re-establish after this specifier .get_from_api(api_client, None, None) .context("re-fetch") .ok(); let ee = api_client .update_entity_from_json(exact_specifier, &json_str, editgroup_id) .context("updating after edit")?; Ok(ee) } pub enum BatchOp { Create, Update, Delete, } pub struct BatchGrouper { entity_type: EntityType, batch_size: u64, limit: Option, auto_accept: bool, editgroup_description: String, current_count: u64, current_editgroup_id: Option, total_count: u64, } // Note: should be possible to add support for the single-call batch create endpoint by storing // edits in a batch within this object. Might need to change control flow a bit. This optimization // was mostly intended for bootstrapping of tens of thousands of entities, so not including for // now. impl BatchGrouper { pub fn new( entity_type: EntityType, batch_size: u64, limit: Option, auto_accept: bool, editgroup_description: Option, ) -> Self { let editgroup_description = match editgroup_description { Some(ed) => ed, None => "part of a fatcat-cli batch operation".to_string(), }; BatchGrouper { entity_type, batch_size, limit, auto_accept, editgroup_description, current_count: 0, current_editgroup_id: None, total_count: 0, } } pub fn run( &mut self, api_client: &mut FatcatApiClient, input_path: Option, op: BatchOp, mutations: Option>, ) -> Result<()> { match input_path { None => { let stdin = io::stdin(); let stdin_lock = stdin.lock(); let lines = stdin_lock.lines(); for line in lines { let json_str = line?; match op { BatchOp::Create => self.push_create(api_client, &json_str)?, BatchOp::Update => self.push_update( api_client, &json_str, mutations.clone().unwrap_or_default(), )?, BatchOp::Delete => self.push_delete(api_client, &json_str)?, }; if let Some(limit) = self.limit { if self.total_count + self.current_count >= limit { break; } } } } Some(path) => { let input_file = File::open(path)?; let buffered = io::BufReader::new(input_file); let lines = buffered.lines(); for line in lines { let json_str = line?; match op { BatchOp::Create => self.push_create(api_client, &json_str)?, BatchOp::Update => self.push_update( api_client, &json_str, mutations.clone().unwrap_or_default(), )?, BatchOp::Delete => self.push_delete(api_client, &json_str)?, }; if let Some(limit) = self.limit { if self.total_count + self.current_count >= limit { break; } } } } } self.flush(api_client)?; Ok(()) } pub fn push_create( &mut self, api_client: &mut FatcatApiClient, json_str: &str, ) -> Result { let editgroup_id = self.increment_editgroup(api_client)?; api_client.create_entity_from_json(self.entity_type, json_str, editgroup_id) } pub fn push_update( &mut self, api_client: &mut FatcatApiClient, json_str: &str, mutations: Vec, ) -> Result { let obj: serde_json::Value = serde_json::from_str(json_str)?; let ident = obj["ident"] .as_str() .ok_or_else(|| anyhow!("expect entity JSON to have 'ident' field"))?; let editgroup_id = self.increment_editgroup(api_client)?; let mut entity = entity_model_from_json_str(self.entity_type, json_str)?; entity.mutate(mutations)?; api_client.update_entity_from_json( Specifier::from_ident(self.entity_type, ident.to_string()), &entity.to_json_string()?, editgroup_id, ) } pub fn push_delete( &mut self, api_client: &mut FatcatApiClient, json_str: &str, ) -> Result { let obj: serde_json::Value = serde_json::from_str(json_str)?; let ident = obj["ident"] .as_str() .ok_or_else(|| anyhow!("expect entity JSON to have 'ident' field"))?; let editgroup_id = self.increment_editgroup(api_client)?; api_client.delete_entity( Specifier::from_ident(self.entity_type, ident.to_string()), editgroup_id, ) } pub fn increment_editgroup(&mut self, api_client: &mut FatcatApiClient) -> Result { if self.current_count >= self.batch_size { self.flush(api_client)?; }; self.current_count += 1; if let Some(eg) = &self.current_editgroup_id { return Ok(eg.to_string()); } let eg = api_client.create_editgroup(Some(self.editgroup_description.clone()))?; self.current_editgroup_id = eg.editgroup_id; Ok(self.current_editgroup_id.as_ref().unwrap().to_string()) } pub fn flush(&mut self, api_client: &mut FatcatApiClient) -> Result<()> { if self.current_count > 0 && self.current_editgroup_id.is_some() { let eg_id = self.current_editgroup_id.clone().unwrap(); if self.auto_accept { api_client.accept_editgroup(eg_id.clone())?; // This doesn't return changelog entry, so just printing editgroup_id //writeln!(&mut std::io::stdout(), "{}", entry.to_json_string()?)?; println!("editgroup_{}", eg_id); } else { let _eg = api_client.update_editgroup_submit(eg_id.clone(), true)?; //writeln!(&mut std::io::stdout(), "{}", eg.to_json_string()?)?; println!("editgroup_{}", eg_id); } self.total_count += self.current_count; self.current_count = 0; self.current_editgroup_id = None; } Ok(()) } }