use anyhow::{anyhow, Result}; use askama::Template; use chrono::offset::Utc; use comrak::{ adapters::{HeadingAdapter, HeadingMeta}, markdown_to_html_with_plugins, ComrakOptions, ComrakPlugins, }; use git2::{BlameHunk, Repository}; use slug; use std::path::Path; use std::str; use crate::file; const MINUTE: i64 = 60; // e.g. 60 seconds const TWO_MINUTES: i64 = MINUTE * 2; const HOUR: i64 = MINUTE * 60; const TWO_HOURS: i64 = HOUR * 2; // 7_200 const DAY: i64 = HOUR * 24; // 86_400 const TWO_DAYS: i64 = DAY * 2; // 172_800 const WEEK: i64 = HOUR * 24; // 604_800 const TWO_WEEKS: i64 = HOUR * 24; // 1_209_600 const MONTH: i64 = DAY * 30; // 2_592_000 const TWO_MONTH: i64 = MONTH * 2; // 5_184_000 const YEAR: i64 = MONTH * 12; // 31_104_000 const TWO_YEAR: i64 = YEAR * 2; // 62_208_000 fn timestamp_to_human_relative_time(time: i64) -> String { let now = Utc::now().timestamp(); let diff = now - time; match diff { i64::MIN..=-1 => String::from("Timezone issue or in the future"), 0..=59 => String::from("Seconds ago"), MINUTE..=119 => String::from("1 minute ago"), TWO_MINUTES..=3_599 => format!("{} minutes ago", diff / MINUTE), HOUR..=7_199 => String::from("1 hour ago"), TWO_HOURS..=86_399 => format!("{} hours ago", diff / HOUR), DAY..=171_999 => String::from("Yesterday"), TWO_DAYS..=604_799 => format!("{} days ago", diff / DAY), WEEK..=1_209_599 => String::from("last week"), TWO_WEEKS..=2_591_999 => format!("{} weeks ago", diff / WEEK), MONTH..=5_183_999 => String::from("one month ago"), TWO_MONTH..=31_103_999 => format!("{} months ago", diff / MONTH), YEAR..=62_207_999 => String::from("1 year ago"), TWO_YEAR..=i64::MAX => format!("{} years ago", diff / YEAR), } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Template)] #[template(path = "repo_tree.html")] struct FileTree { name: String, // in seconds last_touched: String, view_link: String, is_dir: bool, path: String, children: Box<Vec<FileTree>>, } #[derive(Template)] #[template(path = "repo_index.html")] struct RepoIndexHtml<'a, 'b, 'c> { repo_name: String, repo_tree: &'a FileTree, repo_owner: &'c str, readme: Option<String>, description: &'b str, clone_with: &'b str, } #[derive(Template)] #[template(path = "repo_folder.html")] struct RepoFolderHtml<'a, 'b> { repo_name: String, repo_tree: &'a FileTree, folder: &'b str, breadcrumbs: Vec<file::Breadcrumb>, } impl FileTree { fn insert(&mut self, new_entry_parent_name: &str, entry: FileTree) -> Result<()> { if !self.is_dir { return Err(anyhow!("can not insert entry into file")); } if new_entry_parent_name == self.path { self.children.push(entry); return Ok(()); } else if new_entry_parent_name.starts_with(&self.path) { for folder_content in self.children.iter_mut() { if folder_content .insert(new_entry_parent_name, entry.clone()) .is_ok() { return Ok(()); }; } } Err(anyhow!("could not insert")) } } struct CustomHeadingAdapter; impl HeadingAdapter for CustomHeadingAdapter { fn enter(&self, heading: &HeadingMeta) -> String { let id = slug::slugify(&heading.content); format!("<h{} id=\"{}\">", heading.level, id) } fn exit(&self, heading: &HeadingMeta) -> String { let id = slug::slugify(&heading.content); format!( "<a class=\"ReadmeLink\" href=\"#{}\" title=\"link to {}\">📎</a></h{}>", id, heading.content, heading.level ) } } fn get_latest_commit_time_for_file(repo: &Repository, file: &str) -> i64 { let blame = repo.blame_file(Path::new(file), None); match blame { Ok(blame) => blame .iter() .reduce(|acc: BlameHunk, curr| { curr.final_commit_id(); let curr_when = curr.final_signature().when(); if acc.final_signature().when() > curr_when { acc } else { curr } }) .unwrap() .final_signature() .when() .seconds(), _ => -1, } } fn get_file_tree( repo: &Repository, repo_owner: &str, repo_name: &str, folder: &str, max_depth: usize, ) -> Result<FileTree> { let tree = if folder == "" { let head = repo.head()?; head.peel_to_tree()? } else { repo.revparse_single(&format!("HEAD:{}", folder))? .peel_to_tree()? }; let mut render_entries = FileTree { path: if folder == "" { folder.to_string() } else { format!("{}/", folder) }, name: String::from(""), last_touched: String::from(""), view_link: String::from("/"), is_dir: true, children: Box::new(vec![]), }; tree.walk( git2::TreeWalkMode::PreOrder, |relative_parent_directory, entry| { let parent_directory = if folder == "" { relative_parent_directory.to_string() } else { format!("{}/{}", folder, relative_parent_directory) }; let kind = match entry.kind() { Some(x) => x, _ => return git2::TreeWalkResult::Skip, }; if kind != git2::ObjectType::Tree && kind != git2::ObjectType::Blob { return git2::TreeWalkResult::Skip; }; let entry_name = entry.name().unwrap_or("Name not understood"); let mut last_touched = String::from(""); let is_dir = kind == git2::ObjectType::Tree; if !is_dir { let commit_time = get_latest_commit_time_for_file( repo, &format!("{}{}", parent_directory, entry_name), ); last_touched = timestamp_to_human_relative_time(commit_time); } let file_path = format!( "{}{}{}", parent_directory, entry_name, if is_dir { "/" } else { "" } ); let view_link = format!("/~{}/{}/tree/{}", repo_owner, repo_name, file_path); let new_entry = FileTree { path: file_path, name: entry_name.to_string(), last_touched, view_link, is_dir: kind == git2::ObjectType::Tree, children: Box::new(vec![]), }; if let Err(err) = render_entries.insert(&parent_directory, new_entry) { println!( "error inserting file, this should never happen {} {:?}", parent_directory, err ); }; let depth = parent_directory.split('/').collect::<Vec<_>>().len(); if depth >= max_depth { git2::TreeWalkResult::Skip } else { git2::TreeWalkResult::Ok } }, )?; Ok(render_entries) } pub fn render_repo_index( repo: &Repository, repo_owner: &str, repo_name: &str, repo_config: Option<&crate::RepoConfig>, folder_depth: usize, ) -> Result<String> { let entries = get_file_tree(repo, repo_owner, repo_name, "", folder_depth)?; let mut readme = None; if let Ok(obj) = repo.revparse_single("HEAD:README.md") { if obj.kind() == Some(git2::ObjectType::Blob) { if let Some(blob) = obj.as_blob() { if let Ok(blob_str) = str::from_utf8(blob.content()) { let adapter = CustomHeadingAdapter; let mut plugins = ComrakPlugins::default(); plugins.render.heading_adapter = Some(&adapter); readme = Some(markdown_to_html_with_plugins( blob_str, &ComrakOptions::default(), &plugins, )) } } } } else { println!("no rev parse") } Ok(RepoIndexHtml { repo_name: repo_name.clone().to_string(), repo_tree: &entries, repo_owner, readme, description: &repo_config .and_then(|config| Some(&config.description)) .unwrap_or(&"".to_string()), clone_with: &repo_config .and_then(|config| config.clone_with.as_ref()) .unwrap_or(&"".to_string()), } .render()?) } pub fn render_repo_folder( repo: &Repository, repo_owner: &str, repo_name: &str, folder_depth: usize, folder: &str, ) -> Result<String> { let entries = get_file_tree(repo, repo_owner, repo_name, folder, folder_depth)?; Ok(RepoFolderHtml { repo_name: repo_name.clone().to_string(), repo_tree: &entries, folder, breadcrumbs: file::get_file_breadcrumbs(repo_owner, repo_name, &entries.path), } .render()?) }