use std::{ env, fmt::Display, fs::{self, File}, io::{BufReader, Write}, mem, path::PathBuf, process::{Command, Stdio}, }; use anyhow::Context; use chrono::{Local, TimeZone}; use chrono_humanize::{Accuracy, HumanTime, Tense}; use info_json::{Comment, InfoJson, Parent}; use regex::Regex; mod info_json; fn get_runtime_path(component: &'static str) -> anyhow::Result { let out: PathBuf = format!( "{}/{}", env::var("XDG_RUNTIME_DIR").expect("This should always exist"), component ) .into(); fs::create_dir_all(out.parent().expect("Parent should exist"))?; Ok(out) } const STATUS_PATH: &str = "ytcc/running"; pub fn status_path() -> anyhow::Result { get_runtime_path(STATUS_PATH) } #[derive(Debug, Clone)] pub struct CommentExt { pub value: Comment, pub replies: Vec, } #[derive(Debug, Default)] pub struct Comments { vec: Vec, } impl Comments { pub fn new() -> Self { Self::default() } pub fn push(&mut self, value: CommentExt) { self.vec.push(value); } pub fn get_mut(&mut self, key: &str) -> Option<&mut CommentExt> { self.vec.iter_mut().filter(|c| c.value.id.id == key).last() } pub fn insert(&mut self, key: &str, value: CommentExt) { let parent = self .vec .iter_mut() .filter(|c| c.value.id.id == key) .last() .expect("One of these should exist"); parent.push_reply(value); } } impl CommentExt { pub fn push_reply(&mut self, value: CommentExt) { self.replies.push(value) } pub fn get_mut_reply(&mut self, key: &str) -> Option<&mut CommentExt> { self.replies .iter_mut() .filter(|c| c.value.id.id == key) .last() } } impl From for CommentExt { fn from(value: Comment) -> Self { Self { replies: vec![], value, } } } impl Display for Comments { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { macro_rules! c { ($color:expr, $write:ident) => { $write.write_str(concat!("\x1b[", $color, "m"))? }; } fn format( comment: &CommentExt, f: &mut std::fmt::Formatter<'_>, ident_count: u32, ) -> std::fmt::Result { let ident = &(0..ident_count).map(|_| " ").collect::(); let value = &comment.value; f.write_str(&ident)?; if value.author_is_uploader { c!("91;1", f); } else { c!("35", f); } f.write_str(&value.author)?; c!("0", f); if value.edited || value.is_favorited { f.write_str("[")?; if value.edited { f.write_str("")?; } if value.edited && value.is_favorited { f.write_str(" ")?; } if value.is_favorited { f.write_str("")?; } f.write_str("]")?; } c!("36;1", f); write!( f, " {}", HumanTime::from( Local .timestamp_opt(value.timestamp, 0) .single() .expect("This should be valid") ) .to_text_en(Accuracy::Rough, Tense::Past) )?; c!("0", f); // c!("31;1", f); // f.write_fmt(format_args!(" [{}]", comment.value.like_count))?; // c!("0", f); f.write_str(":\n")?; f.write_str(&ident)?; f.write_str(&value.text.replace('\n', &format!("\n{}", ident)))?; f.write_str("\n")?; if !comment.replies.is_empty() { let mut children = comment.replies.clone(); children.sort_by(|a, b| a.value.timestamp.cmp(&b.value.timestamp)); for child in children { format(&child, f, ident_count + 4)?; } } else { f.write_str("\n")?; } Ok(()) } if !&self.vec.is_empty() { let mut children = self.vec.clone(); children.sort_by(|a, b| b.value.like_count.cmp(&a.value.like_count)); for child in children { format(&child, f, 0)? } } Ok(()) } } fn main() -> anyhow::Result<()> { cli_log::init_cli_log!(); let args: Option = env::args().skip(1).last(); let mut info_json: InfoJson = { let status_path = if let Some(arg) = args { PathBuf::from(arg) } else { status_path().context("Failed to get status path")? }; let reader = BufReader::new(File::open(&status_path).with_context(|| { format!("Failed to open status file at {}", status_path.display()) })?); serde_json::from_reader(reader)? }; let base_comments = mem::take(&mut info_json.comments); drop(info_json); let mut comments = Comments::new(); base_comments.into_iter().for_each(|c| { if let Parent::Id(id) = &c.parent { comments.insert(&(id.clone()), CommentExt::from(c)); } else { comments.push(CommentExt::from(c)); } }); comments.vec.iter_mut().for_each(|comment| { let replies = mem::take(&mut comment.replies); let mut output_replies: Vec = vec![]; let re = Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").unwrap(); for reply in replies { if let Some(replyee_match) = re.captures(&reply.value.text){ let full_match = replyee_match.get(0).expect("This always exists"); let text = reply. value. text[0..full_match.start()] .to_owned() + &reply .value .text[full_match.end()..]; let text: &str = &text.trim().trim_matches('\u{200b}'); let replyee = replyee_match.get(1).expect("This should exist").as_str(); if let Some(parent) = output_replies .iter_mut() .rev() .flat_map(|com| &mut com.replies) .flat_map(|com| &mut com.replies) .flat_map(|com| &mut com.replies) .filter(|com| com.value.author == replyee) .last() { parent.replies.push(CommentExt::from(Comment { text: text.to_owned(), ..reply.value })) } else if let Some(parent) = output_replies .iter_mut() .rev() .flat_map(|com| &mut com.replies) .flat_map(|com| &mut com.replies) .filter(|com| com.value.author == replyee) .last() { parent.replies.push(CommentExt::from(Comment { text: text.to_owned(), ..reply.value })) } else if let Some(parent) = output_replies .iter_mut() .rev() .flat_map(|com| &mut com.replies) .filter(|com| com.value.author == replyee) .last() { parent.replies.push(CommentExt::from(Comment { text: text.to_owned(), ..reply.value })) } else if let Some(parent) = output_replies.iter_mut() .rev() .filter(|com| com.value.author == replyee) .last() { parent.replies.push(CommentExt::from(Comment { text: text.to_owned(), ..reply.value })) } else { eprintln!( "Failed to find a parent for ('{}') both directly and via replies! The reply text was:\n'{}'\n", replyee, reply.value.text ); output_replies.push(reply); } } else { output_replies.push(reply); } } comment.replies = output_replies; }); let mut less = Command::new("less") .args(["--raw-control-chars"]) .stdin(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .expect("Should work"); let mut child = Command::new("fmt") .args(["--uniform-spacing", "--split-only", "--width=90"]) .stdin(Stdio::piped()) .stderr(Stdio::inherit()) .stdout(less.stdin.take().expect("Should be open")) .spawn() .expect("Failed to spawn child process"); let mut stdin = child.stdin.take().expect("Failed to open stdin"); std::thread::spawn(move || { stdin .write_all(comments.to_string().as_bytes()) .expect("Failed to write to stdin"); }); let _ = less.wait().expect("Failed to read stdout"); Ok(()) } #[cfg(test)] mod test { #[test] fn test_string_replacement() { let s = "A \n\nB\n\nC".to_owned(); assert_eq!("A \n \n B\n \n C", s.replace('\n', "\n ")) } }