diff options
author | 2024-09-17 04:44:56 +0200 | |
---|---|---|
committer | 2024-09-17 04:44:56 +0200 | |
commit | 547e509683007b9e0c149d847ac685f3aa770de8 (patch) | |
tree | 5cb2e9b75c736d116079165d359115bf23eb3bac /examples/changelog | |
parent | 8fb939b5a920e0cd836dbdd24c948f8f2512fc7e (diff) | |
download | iced-547e509683007b9e0c149d847ac685f3aa770de8.tar.gz iced-547e509683007b9e0c149d847ac685f3aa770de8.tar.bz2 iced-547e509683007b9e0c149d847ac685f3aa770de8.zip |
Implement a `changelog-generator` tool and example
Diffstat (limited to 'examples/changelog')
-rw-r--r-- | examples/changelog/Cargo.toml | 23 | ||||
-rw-r--r-- | examples/changelog/fonts/changelog-icons.ttf | bin | 0 -> 5764 bytes | |||
-rw-r--r-- | examples/changelog/src/changelog.rs | 354 | ||||
-rw-r--r-- | examples/changelog/src/icon.rs | 10 | ||||
-rw-r--r-- | examples/changelog/src/main.rs | 368 |
5 files changed, 755 insertions, 0 deletions
diff --git a/examples/changelog/Cargo.toml b/examples/changelog/Cargo.toml new file mode 100644 index 00000000..6f914bce --- /dev/null +++ b/examples/changelog/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "changelog" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"] +edition = "2021" +publish = false + +[dependencies] +iced.workspace = true +iced.features = ["tokio", "markdown", "highlighter", "debug"] + +log.workspace = true +thiserror.workspace = true +tokio.features = ["fs", "process"] +tokio.workspace = true + +serde = "1" +webbrowser = "1" + +[dependencies.reqwest] +version = "0.12" +default-features = false +features = ["json", "rustls-tls"] diff --git a/examples/changelog/fonts/changelog-icons.ttf b/examples/changelog/fonts/changelog-icons.ttf Binary files differnew file mode 100644 index 00000000..a0f32553 --- /dev/null +++ b/examples/changelog/fonts/changelog-icons.ttf diff --git a/examples/changelog/src/changelog.rs b/examples/changelog/src/changelog.rs new file mode 100644 index 00000000..39cbb42c --- /dev/null +++ b/examples/changelog/src/changelog.rs @@ -0,0 +1,354 @@ +use serde::Deserialize; +use tokio::fs; +use tokio::process; + +use std::env; +use std::fmt; +use std::io; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct Changelog { + ids: Vec<u64>, + added: Vec<String>, + changed: Vec<String>, + fixed: Vec<String>, + removed: Vec<String>, + authors: Vec<String>, +} + +impl Changelog { + pub fn new() -> Self { + Self { + ids: Vec::new(), + added: Vec::new(), + changed: Vec::new(), + fixed: Vec::new(), + removed: Vec::new(), + authors: Vec::new(), + } + } + + pub async fn list() -> Result<(Self, Vec<Candidate>), Error> { + let mut changelog = Self::new(); + + { + let markdown = fs::read_to_string("CHANGELOG.md").await?; + + if let Some(unreleased) = markdown.split("\n## ").nth(1) { + let sections = unreleased.split("\n\n"); + + for section in sections { + if section.starts_with("Many thanks to...") { + for author in section.lines().skip(1) { + let author = author.trim_start_matches("- @"); + + if author.is_empty() { + continue; + } + + changelog.authors.push(author.to_owned()); + } + + continue; + } + + let Some((_, rest)) = section.split_once("### ") else { + continue; + }; + + let Some((name, rest)) = rest.split_once("\n") else { + continue; + }; + + let category = match name { + "Added" => Category::Added, + "Fixed" => Category::Fixed, + "Changed" => Category::Changed, + "Removed" => Category::Removed, + _ => continue, + }; + + for entry in rest.lines() { + let Some((_, id)) = entry.split_once('#') else { + continue; + }; + + let Some((id, _)) = id.split_once(']') else { + continue; + }; + + let Ok(id): Result<u64, _> = id.parse() else { + continue; + }; + + changelog.ids.push(id); + + let target = match category { + Category::Added => &mut changelog.added, + Category::Changed => &mut changelog.added, + Category::Fixed => &mut changelog.fixed, + Category::Removed => &mut changelog.removed, + }; + + target.push(entry.to_owned()); + } + } + } + } + + let mut candidates = Candidate::list().await?; + + for reviewed_entry in changelog.entries() { + candidates.retain(|candidate| candidate.id != reviewed_entry); + } + + Ok((changelog, candidates)) + } + + pub fn len(&self) -> usize { + self.ids.len() + } + + pub fn entries(&self) -> impl Iterator<Item = u64> + '_ { + self.ids.iter().copied() + } + + pub fn push(&mut self, entry: Entry) { + self.ids.push(entry.id); + + let item = format!( + "- {title}. [#{id}](https://github.com/iced-rs/iced/pull/{id})", + title = entry.title, + id = entry.id + ); + + let target = match entry.category { + Category::Added => &mut self.added, + Category::Changed => &mut self.added, + Category::Fixed => &mut self.fixed, + Category::Removed => &mut self.removed, + }; + + target.push(item); + + if !self.authors.contains(&entry.author) { + self.authors.push(entry.author); + self.authors.sort_by_key(|author| author.to_lowercase()); + } + } +} + +impl fmt::Display for Changelog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn section(category: Category, entries: &[String]) -> String { + if entries.is_empty() { + return String::new(); + } + + format!("### {category}\n{list}\n", list = entries.join("\n")) + } + + fn thank_you<'a>(authors: impl IntoIterator<Item = &'a str>) -> String { + let mut list = String::new(); + + for author in authors { + list.push_str(&format!("- @{author}\n")); + } + + format!("Many thanks to...\n{list}") + } + + let changelog = [ + section(Category::Added, &self.added), + section(Category::Changed, &self.changed), + section(Category::Fixed, &self.fixed), + section(Category::Removed, &self.removed), + thank_you(self.authors.iter().map(String::as_str)), + ] + .into_iter() + .filter(|section| !section.is_empty()) + .collect::<Vec<String>>() + .join("\n"); + + f.write_str(&changelog) + } +} + +#[derive(Debug, Clone)] +pub struct Entry { + pub id: u64, + pub title: String, + pub category: Category, + pub author: String, +} + +impl Entry { + pub fn new( + title: &str, + category: Category, + pull_request: &PullRequest, + ) -> Option<Self> { + let title = title.strip_suffix(".").unwrap_or(title); + + if title.is_empty() { + return None; + }; + + Some(Self { + id: pull_request.id, + title: title.to_owned(), + category, + author: pull_request.author.clone(), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Category { + Added, + Changed, + Fixed, + Removed, +} + +impl Category { + pub const ALL: &'static [Self] = + &[Self::Added, Self::Changed, Self::Fixed, Self::Removed]; + + pub fn guess(label: &str) -> Option<Self> { + Some(match label { + "feature" | "addition" => Self::Added, + "change" => Self::Changed, + "bug" | "fix" => Self::Fixed, + _ => None?, + }) + } +} + +impl fmt::Display for Category { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Category::Added => "Added", + Category::Changed => "Changed", + Category::Fixed => "Fixed", + Category::Removed => "Removed", + }) + } +} + +#[derive(Debug, Clone)] +pub struct Candidate { + pub id: u64, +} + +#[derive(Debug, Clone)] +pub struct PullRequest { + pub id: u64, + pub title: String, + pub description: String, + pub labels: Vec<String>, + pub author: String, +} + +impl Candidate { + pub async fn list() -> Result<Vec<Candidate>, Error> { + let output = process::Command::new("git") + .args([ + "log", + "--oneline", + "--grep", + "#[0-9]*", + "origin/latest..HEAD", + ]) + .output() + .await?; + + let log = String::from_utf8_lossy(&output.stdout); + + Ok(log + .lines() + .filter(|title| !title.is_empty()) + .filter_map(|title| { + let (_, pull_request) = title.split_once("#")?; + let (pull_request, _) = pull_request.split_once([')', ' '])?; + + Some(Candidate { + id: pull_request.parse().ok()?, + }) + }) + .collect()) + } + + pub async fn fetch(self) -> Result<PullRequest, Error> { + let request = reqwest::Client::new() + .request( + reqwest::Method::GET, + format!( + "https://api.github.com/repos/iced-rs/iced/pulls/{}", + self.id + ), + ) + .header("User-Agent", "iced changelog generator") + .header( + "Authorization", + format!( + "Bearer {}", + env::var("GITHUB_TOKEN") + .map_err(|_| Error::GitHubTokenNotFound)? + ), + ); + + #[derive(Deserialize)] + struct Schema { + title: String, + body: String, + user: User, + labels: Vec<Label>, + } + + #[derive(Deserialize)] + struct User { + login: String, + } + + #[derive(Deserialize)] + struct Label { + name: String, + } + + let schema: Schema = request.send().await?.json().await?; + + Ok(PullRequest { + id: self.id, + title: schema.title, + description: schema.body, + labels: schema.labels.into_iter().map(|label| label.name).collect(), + author: schema.user.login, + }) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + #[error("io operation failed: {0}")] + IOFailed(Arc<io::Error>), + + #[error("http request failed: {0}")] + RequestFailed(Arc<reqwest::Error>), + + #[error("no GITHUB_TOKEN variable was set")] + GitHubTokenNotFound, +} + +impl From<io::Error> for Error { + fn from(error: io::Error) -> Self { + Error::IOFailed(Arc::new(error)) + } +} + +impl From<reqwest::Error> for Error { + fn from(error: reqwest::Error) -> Self { + Error::RequestFailed(Arc::new(error)) + } +} diff --git a/examples/changelog/src/icon.rs b/examples/changelog/src/icon.rs new file mode 100644 index 00000000..dd82e5b9 --- /dev/null +++ b/examples/changelog/src/icon.rs @@ -0,0 +1,10 @@ +use iced::widget::{text, Text}; +use iced::Font; + +pub const FONT_BYTES: &[u8] = include_bytes!("../fonts/changelog-icons.ttf"); + +const FONT: Font = Font::with_name("changelog-icons"); + +pub fn copy() -> Text<'static> { + text('\u{e800}').font(FONT) +} diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs new file mode 100644 index 00000000..73da0f2c --- /dev/null +++ b/examples/changelog/src/main.rs @@ -0,0 +1,368 @@ +mod changelog; +mod icon; + +use crate::changelog::Changelog; + +use iced::clipboard; +use iced::font; +use iced::widget::{ + button, center, column, container, markdown, pick_list, progress_bar, + rich_text, row, scrollable, span, stack, text, text_input, +}; +use iced::{Element, Fill, FillPortion, Font, Task, Theme}; + +pub fn main() -> iced::Result { + iced::application("Changelog Generator", Generator::update, Generator::view) + .font(icon::FONT_BYTES) + .theme(Generator::theme) + .run_with(Generator::new) +} + +enum Generator { + Loading, + Empty, + Reviewing { + changelog: Changelog, + pending: Vec<changelog::Candidate>, + state: State, + preview: Vec<markdown::Item>, + }, +} + +enum State { + Loading(changelog::Candidate), + Loaded { + pull_request: changelog::PullRequest, + description: Vec<markdown::Item>, + title: String, + category: changelog::Category, + }, +} + +#[derive(Debug, Clone)] +enum Message { + ChangelogListed( + Result<(Changelog, Vec<changelog::Candidate>), changelog::Error>, + ), + PullRequestFetched(Result<changelog::PullRequest, changelog::Error>), + UrlClicked(markdown::Url), + TitleChanged(String), + CategorySelected(changelog::Category), + Next, + OpenPullRequest(u64), + CopyPreview, +} + +impl Generator { + fn new() -> (Self, Task<Message>) { + ( + Self::Loading, + Task::perform(Changelog::list(), Message::ChangelogListed), + ) + } + + fn update(&mut self, message: Message) -> Task<Message> { + match message { + Message::ChangelogListed(Ok((changelog, mut pending))) => { + if let Some(candidate) = pending.pop() { + let preview = + markdown::parse(&changelog.to_string()).collect(); + + *self = Self::Reviewing { + changelog, + pending, + state: State::Loading(candidate.clone()), + preview, + }; + + Task::perform( + candidate.fetch(), + Message::PullRequestFetched, + ) + } else { + *self = Self::Empty; + + Task::none() + } + } + Message::PullRequestFetched(Ok(pull_request)) => { + let Self::Reviewing { state, .. } = self else { + return Task::none(); + }; + + let description = + markdown::parse(&pull_request.description).collect(); + + *state = State::Loaded { + title: pull_request.title.clone(), + category: pull_request + .labels + .iter() + .map(String::as_str) + .filter_map(changelog::Category::guess) + .next() + .unwrap_or(changelog::Category::Added), + pull_request, + description, + }; + + Task::none() + } + Message::ChangelogListed(Err(error)) + | Message::PullRequestFetched(Err(error)) => { + log::error!("{error}"); + + Task::none() + } + Message::UrlClicked(url) => { + let _ = webbrowser::open(url.as_str()); + + Task::none() + } + Message::TitleChanged(new_title) => { + let Self::Reviewing { state, .. } = self else { + return Task::none(); + }; + + let State::Loaded { title, .. } = state else { + return Task::none(); + }; + + *title = new_title; + + Task::none() + } + Message::CategorySelected(new_category) => { + let Self::Reviewing { state, .. } = self else { + return Task::none(); + }; + + let State::Loaded { category, .. } = state else { + return Task::none(); + }; + + *category = new_category; + + Task::none() + } + Message::Next => { + let Self::Reviewing { + changelog, + pending, + state, + preview, + .. + } = self + else { + return Task::none(); + }; + + let State::Loaded { + title, + category, + pull_request, + .. + } = state + else { + return Task::none(); + }; + + if let Some(entry) = + changelog::Entry::new(title, *category, pull_request) + { + changelog.push(entry); + + *preview = + markdown::parse(&changelog.to_string()).collect(); + + if let Some(candidate) = pending.pop() { + *state = State::Loading(candidate.clone()); + + Task::perform( + candidate.fetch(), + Message::PullRequestFetched, + ) + } else { + // TODO: We are done! + Task::none() + } + } else { + Task::none() + } + } + Message::OpenPullRequest(id) => { + let _ = webbrowser::open(&format!( + "https://github.com/iced-rs/iced/pull/{id}" + )); + + Task::none() + } + Message::CopyPreview => { + let Self::Reviewing { changelog, .. } = self else { + return Task::none(); + }; + + clipboard::write(changelog.to_string()) + } + } + } + + fn view(&self) -> Element<Message> { + match self { + Self::Loading => center("Loading...").into(), + Self::Empty => center("No changes found!").into(), + Self::Reviewing { + changelog, + pending, + state, + preview, + } => { + let progress = { + let total = pending.len() + changelog.len(); + + let bar = progress_bar( + 0.0..=1.0, + changelog.len() as f32 / total as f32, + ) + .style(progress_bar::secondary); + + let label = text!( + "{amount_reviewed} / {total}", + amount_reviewed = changelog.len() + ) + .font(Font::MONOSPACE) + .size(12); + + stack![bar, center(label)] + }; + + let form: Element<_> = match state { + State::Loading(candidate) => { + text!("Loading #{}...", candidate.id).into() + } + State::Loaded { + pull_request, + description, + title, + category, + } => { + let details = { + let title = rich_text![ + span(&pull_request.title).size(24).link( + Message::OpenPullRequest(pull_request.id) + ), + span(format!(" by {}", pull_request.author)) + .font(Font { + style: font::Style::Italic, + ..Font::default() + }), + ] + .font(Font::MONOSPACE); + + let description = markdown::view( + description, + markdown::Settings::default(), + markdown::Style::from_palette( + self.theme().palette(), + ), + ) + .map(Message::UrlClicked); + + let labels = + row(pull_request.labels.iter().map(|label| { + container( + text(label) + .size(10) + .font(Font::MONOSPACE), + ) + .padding(5) + .style(container::rounded_box) + .into() + })) + .spacing(10) + .wrap(); + + column![ + title, + labels, + scrollable(description) + .spacing(10) + .width(Fill) + .height(Fill) + ] + .spacing(10) + }; + + let title = text_input( + "Type a changelog entry title...", + title, + ) + .on_input(Message::TitleChanged); + + let category = pick_list( + changelog::Category::ALL, + Some(category), + Message::CategorySelected, + ); + + let next = button("Next →") + .on_press(Message::Next) + .style(button::success); + + column![ + details, + row![title, category, next].spacing(10) + ] + .spacing(10) + .into() + } + }; + + let preview: Element<_> = if preview.is_empty() { + center( + container( + text("The changelog is empty... so far!").size(12), + ) + .padding(10) + .style(container::rounded_box), + ) + .into() + } else { + let content = container( + scrollable( + markdown::view( + preview, + markdown::Settings::with_text_size(12), + markdown::Style::from_palette( + self.theme().palette(), + ), + ) + .map(Message::UrlClicked), + ) + .spacing(10), + ) + .width(Fill) + .padding(10) + .style(container::rounded_box); + + let copy = button(icon::copy().size(12)) + .on_press(Message::CopyPreview) + .style(button::text); + + center(stack![content, container(copy).align_right(Fill)]) + .into() + }; + + let review = column![container(form).height(Fill), progress] + .spacing(10) + .width(FillPortion(2)); + + row![review, preview].spacing(10).padding(10).into() + } + } + } + + fn theme(&self) -> Theme { + Theme::TokyoNightStorm + } +} |