diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-06-06 15:45:11 +0200 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-06-06 15:45:11 +0200 |
commit | a6baea06697f6c76c695dc4198099deb8ba916e0 (patch) | |
tree | 476a3865f6b4bef04751ba20534813a58892811b /src/web/generate/mod.rs | |
parent | chore: Initial commit (diff) | |
download | back-a6baea06697f6c76c695dc4198099deb8ba916e0.zip |
feat(treewide): Prepare for first release
This commit contains many changes, as they were developed alongside `git-bug-rs` and unfortunately not separately committed. A toplevel summary would include: - Appropriate redirects, - The templating moved to `vy` (as this works with rustfmt formatting), - Search support (via `git-bug-rs`), - And better layout in the link section.
Diffstat (limited to 'src/web/generate/mod.rs')
-rw-r--r-- | src/web/generate/mod.rs | 522 |
1 files changed, 391 insertions, 131 deletions
diff --git a/src/web/generate/mod.rs b/src/web/generate/mod.rs index 06bab17..e68bddf 100644 --- a/src/web/generate/mod.rs +++ b/src/web/generate/mod.rs @@ -1,144 +1,353 @@ -// Back - An extremely simple git bug visualization system. Inspired by TVL's -// panettone. -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This file is part of Back. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/agpl.txt>. - use std::{fs, path::Path}; -use gix::hash::Prefix; -use log::info; -use rinja::Template; -use url::Url; - -use crate::{ - config::BackConfig, - error, - git_bug::{ - dag::issues_from_repository, - issue::{CollapsedIssue, Status}, +use git_bug::{ + entities::{ + identity::Identity, + issue::{ + Issue, data::status::Status, query::MatchKeyValue, snapshot::timeline::IssueTimeline, + }, + }, + query::{Matcher, Query}, + replica::{ + Replica, + entity::{ + Entity, + id::prefix::IdPrefix, + snapshot::{ + Snapshot, + timeline::{Timeline, history_step::HistoryStep}, + }, + }, }, }; +use log::info; +use templates::to_markdown; +use vy::{IntoHtml, a, div, footer, form, h1, header, input, li, main, nav, ol, p, span}; -#[derive(Template)] -#[template(path = "./issues.html")] -struct IssuesTemplate { - wanted_status: Status, - counter_status: Status, - issues: Vec<CollapsedIssue>, +use crate::{config::BackConfig, error, web::generate::templates::make_page}; - /// The path to the repository - repo_path: String, +pub(crate) mod templates; - /// The URL to `back`'s source code - source_code_repository_url: Url, +fn get_other_status(current: Status) -> Status { + match current { + Status::Open => Status::Closed, + Status::Closed => Status::Open, + } } -pub fn issues( + +fn set_query_status( + mut root_matcher: Matcher<Snapshot<Issue>>, + status: Status, +) -> Matcher<Snapshot<Issue>> { + fn change_status(matcher: &mut Matcher<Snapshot<Issue>>, status: Status) { + match matcher { + Matcher::Or { lhs, rhs } | Matcher::And { lhs, rhs } => { + change_status(lhs, status); + change_status(rhs, status); + } + Matcher::Match { key_value } => { + if let MatchKeyValue::Status(found_status) = key_value { + *found_status = status; + } + } + } + } + + change_status(&mut root_matcher, status); + root_matcher +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn issues( config: &BackConfig, - wanted_status: Status, - counter_status: Status, repo_path: &Path, + replica: &Replica, + query: &Query<Snapshot<Issue>>, ) -> error::Result<String> { - let repository = config - .repositories()? - .get(repo_path)? - .open(&config.scan_path)?; - - let mut issue_list = issues_from_repository(&repository.to_thread_local())? + let mut issue_list = replica + .get_all_with_query::<Issue>(query)? + .collect::<Result<Result<Vec<Issue>, _>, _>>()?? .into_iter() - .map(|issue| issue.collapse()) - .filter(|issue| issue.status == wanted_status) - .collect::<Vec<CollapsedIssue>>(); + .map(|i| i.snapshot()) + .map(|i| -> error::Result<_> { + let author = i.author(); + Ok((i, author.resolve(replica)?.snapshot())) + }) + .collect::<error::Result<Vec<_>>>()?; // Sort by date descending. // SAFETY: // The time stamp is only used for sorting, so a malicious attacker could only affect the issue // sorting. - issue_list.sort_by_key(|issue| unsafe { issue.timestamp.to_unsafe() }); - issue_list.reverse(); - - Ok(IssuesTemplate { - wanted_status, - counter_status, - source_code_repository_url: config.source_code_repository_url.clone(), - issues: issue_list, - repo_path: repo_path.display().to_string(), - } - .render() - .expect("This should always work")) -} + issue_list.sort_by_key(|(issue, _)| unsafe { + issue + .timeline() + .history() + .first() + .expect("Is some") + .at() + .to_unsafe() + }); + + let root_matcher = query.as_matcher().unwrap_or(&Matcher::Match { + key_value: MatchKeyValue::Status(Status::Open), + }); + + let status = { + fn find_status(matcher: &Matcher<Snapshot<Issue>>) -> Option<Status> { + fn merge(status1: Option<Status>, status2: Option<Status>) -> Option<Status> { + match (status1, status2) { + (None, None) => None, + (None, Some(b)) => Some(b), + #[allow(clippy::match_same_arms)] + (Some(a), None) => Some(a), + // TODO(@bpeetz): Should we warn the user somehow? <2025-05-28> + (Some(a), Some(_)) => Some(a), + } + } -use crate::git_bug::format::HtmlString; -#[derive(Template)] -#[template(path = "./issue.html")] -struct IssueTemplate { - issue: CollapsedIssue, + match matcher { + Matcher::Or { lhs, rhs } | Matcher::And { lhs, rhs } => { + merge(find_status(lhs), find_status(rhs)) + } + Matcher::Match { key_value } => match key_value { + MatchKeyValue::Status(status) => Some(*status), + _ => None, + }, + } + } - /// The path to the repository - repo_path: String, + find_status(root_matcher) + }; - /// The URL to `back`'s source code - source_code_repository_url: Url, + // TODO(@bpeetz): Be less verbose. <2025-06-06> + let query_string = query.to_string(); + + Ok(make_page( + ( + header!(h1!(( + status.map_or(String::new(), |a| a.to_string()), + " Issues" + ))), + main!( + div!( + class = "issue-links", + if let Some(status) = status { + a!( + href = format!( + "/{}/issues/?query={}", + repo_path.display(), + Query::from_matcher(set_query_status( + root_matcher.clone(), + get_other_status(status) + )) + .to_string() + ), + "View ", + get_other_status(status).to_string(), + " issues" + ) + }, + a!(href = "/", "View repos",), + form!( + class = "issue-search", + method = "get", + input!( + name = "query", + value = &query_string, + title = "Issue search query", + "type" = "search", + placeholder = "status:open title:\"Test\"", + size = query_string.chars().count().clamp(20, 40), + ), + input!( + class = "sr-only", + "type" = "submit", + value = "Search Issues" + ) + ) + ), + ol!( + class = "issue-list", + issue_list.iter().map(|(issue, identity)| { + li!(a!( + href = format!("/{}/issue/{}", repo_path.display(), issue.id()), + p!(span!( + class = "issue-subject", + to_markdown(issue.title(), true) + )), + span!( + class = "issue-number", + issue.id().as_id().shorten().to_string() + ), + " ", + display_issue_open(identity, issue.timeline()), + if !issue.comments().is_empty() { + span!( + class = "comment-count", + " - ", + issue.comments().len(), + " ", + { + if issue.comments().len() > 1 { + "comments" + } else { + "comment" + } + } + ) + } + )) + }) + ) + ), + footer!(nav!(a!( + href = config.source_code_repository_url.to_string(), + "Source Code" + ))), + ), + "issue list page", + None, + ) + .into_string()) } -pub fn issue(config: &BackConfig, repo_path: &Path, prefix: Prefix) -> error::Result<String> { - let repository = config + +pub(crate) fn issue( + config: &BackConfig, + repo_path: &Path, + prefix: IdPrefix, +) -> error::Result<String> { + let replica = config .repositories()? .get(repo_path)? - .open(&config.scan_path)? - .to_thread_local(); + .open(&config.scan_path)?; - let maybe_issue = issues_from_repository(&repository)? - .into_iter() - .map(|val| val.collapse()) - .find(|issue| issue.id.to_string().starts_with(&prefix.to_string())); - - match maybe_issue { - Some(issue) => Ok(IssueTemplate { - issue, - repo_path: repo_path.display().to_string(), - source_code_repository_url: config.source_code_repository_url.clone(), - } - .render() - .expect("This should always work")), - None => Err(error::Error::IssuesPrefixMissing { prefix }), - } -} + let issue = replica + .get(prefix.resolve::<Issue>(replica.repo()).map_err(|err| { + error::Error::IssuesPrefixMissing { + prefix: Box::new(prefix), + err, + } + })?)? + .snapshot(); + + let identity = issue.author().resolve(&replica)?.snapshot(); -#[derive(Template)] -#[template(path = "./repos.html")] -struct ReposTemplate { - repos: Vec<RepoValue>, + Ok(make_page( + div!( + class = "content", + nav!( + a!( + href = format!("/{}/issues/?query=status:open", repo_path.display()), + "Open Issues" + ), + a!( + href = format!("/{}/issues/?query=status:closed", repo_path.display()), + "Closed Issues" + ), + ), + header!( + h1!(to_markdown(issue.title(), true)), + div!( + class = "issue-number", + issue.id().as_id().shorten().to_string() + ) + ), + main!( + div!( + class = "issue-info", + display_issue_open(&identity, issue.timeline()) + ), + to_markdown(issue.body(), false), + if !issue.comments().is_empty() { + ol!( + class = "issue-history", + issue + .comments() + .into_iter() + .map(|comment| { + Ok(li!( + class = "comment", + id = comment.combined_id.shorten().to_string(), + to_markdown(&comment.message, false), + p!( + class = "comment-info", + span!( + class = "user-name", + comment + .author + .resolve(&replica)? + .snapshot() + .name() + .to_owned(), + " at ", + comment.timestamp.to_string() + ) + ) + )) + }) + .collect::<error::Result<Vec<_>>>()? + ) + } + ), + footer!(nav!(a!( + href = config.source_code_repository_url.to_string(), + "Source code" + ),)) + ), + "issue detail page", + Some(format!("{} | Back", issue.title()).as_str()), + ) + .into_string()) +} - /// The URL to `back`'s source code - source_code_repository_url: Url, +fn display_issue_open( + identity: &Snapshot<Identity>, + issue_timeline: &IssueTimeline, +) -> impl IntoHtml { + span!( + class = "created-by-at", + "Opened by ", + span!(class = "user-name", identity.name()), + " ", + identity + .email() + .map(|email| span!(class = "user-email", "<", email, "> ",)), + "at ", + span!(class = "timestamp", { + { + issue_timeline + .history() + .last() + .expect("Exists") + .at() + .to_string() + } + }) + ) } + struct RepoValue { description: String, owner: String, path: String, } -pub fn repos(config: &BackConfig) -> error::Result<String> { + +pub(crate) fn repos(config: &BackConfig) -> error::Result<String> { let repos: Vec<RepoValue> = config .repositories()? .iter() .filter_map(|raw_repo| match raw_repo.open(&config.scan_path) { - Ok(repo) => { - let repo = repo.to_thread_local(); - let git_config = repo.config_snapshot(); + Ok(replica) => { + let git_config = replica.repo().config_snapshot(); let path = raw_repo.path().to_string_lossy().to_string(); let owner = git_config .string("cgit.owner") - .map(|v| v.to_string()) - .unwrap_or("<No owner>".to_owned()); + .map_or("<No owner>".to_owned(), |v| v.to_string()); - let description = fs::read_to_string(repo.git_dir().join("description")) + let description = fs::read_to_string(replica.repo().git_dir().join("description")) .unwrap_or("<No description>".to_owned()); Some(RepoValue { @@ -157,73 +366,124 @@ pub fn repos(config: &BackConfig) -> error::Result<String> { }) .collect(); - Ok(ReposTemplate { - repos, - source_code_repository_url: config.source_code_repository_url.clone(), - } - .render() - .expect("this should work")) + Ok(make_page( + div!( + class = "content", + header!(h1!("Repositiories")), + main!( + div!( + class = "issue-links", + // TODO(@bpeetz): Add a search. <2025-05-21> + ), + if !repos.is_empty() { + ol!( + class = "issue-list", + repos.into_iter().map(|repo| { + li!(a!( + href = format!("/{}/issues/?query=status:open", repo.path), + p!(span!(class = "issue-subject", repo.path)), + span!( + class = "created-by-at", + span!(class = "timestamp", repo.description), + " - ", + span!(class = "user-name", repo.owner) + ) + )) + }) + ) + } + ), + footer!(nav!(a!( + href = config.source_code_repository_url.to_string(), + "Source Code" + ))), + ), + "repository listing page", + Some("Repos | Back"), + ) + .into_string()) } -pub fn feed(config: &BackConfig, repo_path: &Path) -> error::Result<String> { +pub(crate) fn feed(config: &BackConfig, repo_path: &Path) -> error::Result<String> { use rss::{ChannelBuilder, Item, ItemBuilder}; - let repository = config + let replica = config .repositories()? .get(repo_path)? - .open(&config.scan_path)? - .to_thread_local(); + .open(&config.scan_path)?; - let issues: Vec<CollapsedIssue> = issues_from_repository(&repository)? + let issues: Vec<Snapshot<Issue>> = replica + .get_all::<Issue>()? + .collect::<Result<Result<Vec<Issue>, _>, _>>()?? .into_iter() - .map(|issue| issue.collapse()) - .collect(); + .map(|i| i.snapshot()) + .collect::<Vec<_>>(); // Collect all Items as rss items let mut items: Vec<Item> = issues .iter() - .map(|issue| { - ItemBuilder::default() - .title(issue.title.to_string()) - .author(issue.author.to_string()) - .description(issue.message.to_string()) - .pub_date(issue.timestamp.to_string()) + .map(|issue| -> error::Result<_> { + Ok(ItemBuilder::default() + .title(issue.title().to_string()) + .author( + replica + .get(issue.author().id())? + .snapshot() + .name() + .to_owned(), + ) + .description(issue.body().to_string()) + .pub_date( + issue + .timeline() + .history() + .last() + .expect("Exists") + .at() + .to_string(), + ) .link(format!( "/{}/{}/issue/{}", repo_path.display(), &config.root_url, - issue.id + issue.id() )) - .build() + .build()) }) - .collect(); + .collect::<error::Result<Vec<Item>>>()?; // Append all comments after converting them to rss items items.extend( issues .iter() - .filter(|issue| !issue.comments.is_empty()) + .filter(|issue| !issue.comments().is_empty()) .flat_map(|issue| { issue - .comments + .comments() .iter() - .map(|comment| { - ItemBuilder::default() - .title(issue.title.to_string()) - .author(comment.author.to_string()) + .map(|comment| -> error::Result<_> { + Ok(ItemBuilder::default() + .title(issue.title().to_string()) + .author( + replica + .get(comment.author.id())? + .snapshot() + .name() + .to_owned(), + ) .description(comment.message.to_string()) .pub_date(comment.timestamp.to_string()) .link(format!( "/{}/{}/issue/{}", repo_path.display(), &config.root_url, - issue.id + comment.combined_id.shorten() )) - .build() + .build()) }) - .collect::<Vec<Item>>() + .collect::<Vec<_>>() }) - .collect::<Vec<Item>>(), + .collect::<error::Result<Vec<Item>>>()?, ); let channel = ChannelBuilder::default() |