rl_hours_tracker/
website_files.rs

1//! This module contains the functionality to generate the Html, CSS, and JavaScript for the
2//! Rocket League Hours Tracker website.
3use crate::IoResult;
4use build_html::{Container, ContainerType, Html, HtmlContainer, HtmlElement, HtmlPage, HtmlTag};
5use bytes::Bytes;
6use colour::{green, green_ln_bold, red};
7use log::{error, warn};
8use reqwest::{Client, Response};
9use std::{
10    error::Error as ErrorTrait,
11    fs::{write, File},
12    io::{self, Error, ErrorKind, Read, Write},
13    process,
14    slice::Iter,
15};
16use tokio::runtime::Runtime;
17use webbrowser;
18
19/// The Github repository and the `Url` to the files in the repository.
20#[derive(Debug, Clone)]
21pub struct Github<'a> {
22    owner: &'a str,
23    repo: &'a str,
24    branch: &'a str,
25    path: &'a str,
26    file: &'a str,
27    url: String,
28}
29
30impl<'a> Github<'a> {
31    /// Creates a new instance with empty strings.
32    pub fn new(
33        owner: &'a str,
34        repo: &'a str,
35        branch: &'a str,
36        path: &'a str,
37        file: &'a str,
38    ) -> Github<'a> {
39        Github {
40            owner,
41            repo,
42            branch,
43            path,
44            file,
45            url: String::new(),
46        }
47    }
48
49    /// Gets the built url of the GitHub instance.
50    pub fn get_url(&self) -> String {
51        self.url.clone()
52    }
53
54    /// Builds the `Url` for the raw contents of a file.
55    ///
56    /// This function should only be used for files on Github which can be opened in raw format.
57    ///
58    /// ## Usage
59    ///
60    /// ```
61    ///let mut github_repo = Github::new("OneilNvM", "rl-hours-tracker", "master", "src", "main.rs");
62    ///
63    /// // Example Output: "https://raw.githubusercontent.com/OneilNvM/rl-hours-tracker/refs/heads/master/src/main.rs"
64    /// github_repo.build_url();
65    /// ```
66    pub fn build_url(&mut self) {
67        let url = format!(
68            "https://raw.githubusercontent.com/{}/{}/refs/heads/{}/{}/{}",
69            self.owner, self.repo, self.branch, self.path, self.file
70        );
71        self.url = url;
72    }
73
74    /// Builds the `Url` for the blob of an image file.
75    ///
76    /// This function should only be used for image files in a Github repository.
77    ///
78    /// ## Usage
79    ///
80    /// ```
81    ///let mut github_repo = Github::new("OneilNvM", "rl-hours-tracker", "master", "images", "img.png");
82    ///
83    /// // Example Output: "https://github.com/OneilNvM/rl-hours-tracker/blob/master/images/img.png"
84    /// github_repo.build_image_url();
85    /// ```
86    pub fn build_image_url(&mut self) {
87        let url = format!(
88            "https://github.com/{}/{}/blob/{}/{}/{}",
89            self.owner, self.repo, self.branch, self.path, self.file
90        );
91        self.url = url;
92    }
93}
94
95/// This stores the file and image responses from the `GET` requests to GitHub
96#[derive(Debug, Clone)]
97pub struct GHResponse {
98    raw_url: Vec<String>,
99    image_url: Vec<Bytes>,
100}
101
102impl GHResponse {
103    /// Creates a new instance
104    pub fn new(raw_url: Vec<String>, image_url: Vec<Bytes>) -> GHResponse {
105        GHResponse { raw_url, image_url }
106    }
107}
108
109/// Sends a HTTP `GET` request and returns the response.
110///
111/// ## Usage
112///
113/// ```
114/// let response = send_request(url).await;
115///
116/// let text = response.text().await;
117/// ```
118pub async fn send_request(url: &String) -> Response {
119    // Construct a new client instance
120    let client = Client::new();
121
122    // Send the GET request
123    let request = client.get(url).send().await;
124
125    // Handle the request
126    match request {
127        Ok(response) => response,
128        Err(e) => {
129            error!("error sending get request for url: {url}\n{e}");
130            process::exit(1);
131        }
132    }
133}
134
135/// Handles the response received from [`send_request`].
136///
137/// This function specifically handles the Urls from the [`Github`] instance, which was created
138/// through [`Github::build_url`].
139pub async fn handle_response(urls: Vec<String>) -> Vec<String> {
140    let mut text_vec: Vec<String> = Vec::new();
141
142    // Loop through the Urls
143    for url in urls {
144        let response = send_request(&url).await;
145
146        let text = response.text().await;
147
148        // Handle the response text
149        match text {
150            Ok(result) => text_vec.push(result),
151            Err(e) => {
152                error!("error retrieving full response text: {e}");
153                process::exit(1);
154            }
155        }
156    }
157
158    text_vec
159}
160
161/// Handles the response received from [`send_request`].
162///
163/// This function specifically handles the urls from the [`Github`] instance, which was created
164/// through [`Github::build_image_url`].
165pub async fn handle_image_response(urls: Vec<String>) -> Vec<Bytes> {
166    let mut blob_vec: Vec<Bytes> = Vec::new();
167
168    // Loop through the Urls
169    for url in urls {
170        let response = send_request(&url).await;
171
172        let blob = response.bytes().await;
173
174        // Handle the response bytes
175        match blob {
176            Ok(result) => blob_vec.push(result),
177            Err(e) => {
178                error!("error retrieving response bytes: {e}");
179                process::exit(1);
180            }
181        }
182    }
183
184    blob_vec
185}
186
187/// Runs the asynchronous functions to completion and returns a [`GHResponse`] instance.
188///
189/// This creates a new [`Runtime`] instance and runs the async functions to completion with [`Runtime::block_on`].
190pub fn run_async_functions(
191    urls1: Vec<String>,
192    urls2: Vec<String>,
193) -> Result<GHResponse, Box<dyn ErrorTrait>> {
194    let rt = Runtime::new()?;
195
196    // Run the async functions
197    let result1 = rt.block_on(handle_response(urls1));
198    let result2 = rt.block_on(handle_image_response(urls2));
199
200    Ok(GHResponse::new(result1, result2))
201}
202
203/// This function is used to generate the necessary files for the Rocket League Hours Tracker website.
204/// It accepts a bool [`bool`] as an argument which determines whether the option to open the website
205/// in the browser should appear or not.
206///
207/// # Errors
208/// Returns an [`io::Error`] if there were any file operations which failed
209pub fn generate_website_files(boolean: bool) -> Result<(), Box<dyn ErrorTrait>> {
210    // Create Github instances for the website files
211    let mut github_main_css = Github::new(
212        "OneilNvM",
213        "rl-hours-tracker",
214        "master",
215        "website/css",
216        "main.css",
217    );
218    let mut github_home_css = Github::new(
219        "OneilNvM",
220        "rl-hours-tracker",
221        "master",
222        "website/css",
223        "home.css",
224    );
225    let mut github_animations_js = Github::new(
226        "OneilNvM",
227        "rl-hours-tracker",
228        "master",
229        "website/js",
230        "animations.js",
231    );
232    let mut github_grey_icon = Github::new(
233        "OneilNvM",
234        "rl-hours-tracker",
235        "master",
236        "website/images",
237        "rl-icon-grey.png",
238    );
239    let mut github_white_icon = Github::new(
240        "OneilNvM",
241        "rl-hours-tracker",
242        "master",
243        "website/images",
244        "rl-icon-white.png",
245    );
246
247    // Build the Urls for the Github instances
248    github_main_css.build_url();
249    github_home_css.build_url();
250    github_animations_js.build_url();
251    github_grey_icon.build_url();
252    github_white_icon.build_url();
253
254    let github_text_vec = vec![
255        github_main_css.url,
256        github_home_css.url,
257        github_animations_js.url,
258    ];
259
260    let github_blob_vec = vec![github_grey_icon.url, github_white_icon.url];
261
262    // Run asynchronous functions and return a GHResponse instance
263    let ghresponse = run_async_functions(github_text_vec, github_blob_vec)?;
264
265    let mut bytes_iter = ghresponse.image_url.iter();
266    let mut raw_iter = ghresponse.raw_url.iter();
267
268    // Write the image bytes
269    write(
270        "C:\\RLHoursFolder\\website\\images\\rl-icon-grey.png",
271        bytes_iter.next().unwrap(),
272    )
273    .unwrap_or_else(|e| warn!("failed to write rl-icon-grey.png: {e}"));
274    write(
275        "C:\\RLHoursFolder\\website\\images\\rl-icon-white.png",
276        bytes_iter.next().unwrap(),
277    )
278    .unwrap_or_else(|e| warn!("failed to write rl-icon-white.png: {e}"));
279
280    // Create the files for the website
281    create_website_files(&mut raw_iter, boolean)
282}
283
284fn create_website_files(
285    raw_iter: &mut Iter<'_, String>,
286    boolean: bool,
287) -> Result<(), Box<dyn ErrorTrait>> {
288    // Create and open files
289    let mut index = File::create("C:\\RLHoursFolder\\website\\pages\\index.html")?;
290    let main_styles = File::create("C:\\RLHoursFolder\\website\\css\\main.css");
291    let home_styles = File::create("C:\\RLHoursFolder\\website\\css\\home.css");
292    let animations_js = File::create("C:\\RLHoursFolder\\website\\js\\animations.js");
293    let mut hours_file = File::open("C:\\RLHoursFolder\\hours.txt");
294    let mut date_file = File::open("C:\\RLHoursFolder\\date.txt");
295
296    // Creates the main.css file
297    match main_styles {
298        Ok(mut ms_file) => {
299            // Writes the CSS content to the file
300            match ms_file.write_all(raw_iter.next().unwrap().as_bytes()) {
301                Ok(_) => (),
302                Err(e) => warn!("failed to write to main.css: {e}"),
303            }
304        }
305        Err(e) => warn!("failed to create main.css: {e}"),
306    }
307
308    // Creates the home.css file
309    match home_styles {
310        Ok(mut hs_file) => {
311            // Writes the CSS content to the file
312            match hs_file.write_all(raw_iter.next().unwrap().as_bytes()) {
313                Ok(_) => (),
314                Err(e) => warn!("failed to write to home.css: {e}"),
315            }
316        }
317        Err(e) => warn!("failed to create home.css: {e}"),
318    }
319
320    // Creates the animations.js file
321    match animations_js {
322        Ok(mut a_js_file) => {
323            // Writes the JavaScript content to the file
324            match a_js_file.write_all(raw_iter.next().unwrap().as_bytes()) {
325                Ok(_) => (),
326                Err(e) => warn!("failed to write to animations.js: {e}"),
327            }
328        }
329        Err(e) => warn!("failed to create animations.js: {e}"),
330    }
331
332    // Generate the website
333    let contents: String = generate_page(&mut hours_file, &mut date_file)?;
334
335    // Initialize the 'contents' variable with the Html
336    let page = contents.replace("<body>", "<body class=\"body adaptive\">");
337
338    // Writes the index.html file
339    index.write_all(page.as_bytes())?;
340
341    // Prompt the user with the option to open the website
342    if boolean {
343        let mut option = String::new();
344
345        print!("Open hours website in browser (");
346        green!("y");
347        print!(" / ");
348        red!("n");
349        print!("): ");
350        io::stdout()
351            .flush()
352            .unwrap_or_else(|_| println!("Open hours website in browser (y/n)?"));
353        io::stdin().read_line(&mut option).unwrap();
354
355        if option.trim().to_lowercase() == "y"
356            && webbrowser::open("C:\\RLHoursFolder\\website\\pages\\index.html").is_ok()
357        {
358            green_ln_bold!("OK\n");
359        }
360    }
361
362    Ok(())
363}
364
365/// This function generates the necessary Html for the website via the [`build_html`] library. The `hours_file` and `date_file`
366/// parameters are both mutable [`Result<File>`] references which provides us with a [`File`] if it is successful, or [`io::Error`] if
367/// it fails. This function then returns a [`Result<String>`] of the Html.
368///
369/// # Errors
370/// This function returns an [`io::Error`] if there were any errors during file operations.
371fn generate_page(
372    hours_file: &mut IoResult<File>,
373    date_file: &mut IoResult<File>,
374) -> IoResult<String> {
375    let mut page = HtmlPage::new()
376    .with_title("Rocket League Hours Tracker")
377    .with_meta(vec![("charset", "UTF-8")])
378    .with_meta(vec![("name", "viewport"), ("content", "width=device-width, initial-scale=1.0")])
379    .with_head_link("../css/main.css", "stylesheet")
380    .with_head_link("../css/home.css", "stylesheet")
381    .with_head_link("https://fonts.googleapis.com", "preconnect")
382    .with_head_link_attr("https://fonts.gstatic.com", "preconnect", [("crossorigin", "")])
383    .with_head_link("https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap", "stylesheet")
384    .with_head_link("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Oswald:wght@200..700&display=swap", "stylesheet")
385    .with_script_link("../js/animations.js");
386
387    page.add_container(
388        Container::new(ContainerType::Div)
389            .with_attributes(vec![("class", "animation-div adaptive")])
390            .with_raw(""),
391    );
392
393    let mut hrs_content = String::new();
394    let mut date_content = String::new();
395
396    if let Ok(ref mut hrs_file) = hours_file {
397        if hrs_file.read_to_string(&mut hrs_content).is_err() {
398            return Err(Error::new(
399                ErrorKind::InvalidData,
400                "The files contents are not valid UTF-8.",
401            ));
402        }
403    } else {
404        return Err(Error::new(ErrorKind::NotFound, "The file 'hours.txt' could not be opened. Either it does not exist or it is not in the 'RLHoursFolder' directory."));
405    }
406
407    if let Ok(ref mut dt_file) = date_file {
408        if dt_file.read_to_string(&mut date_content).is_err() {
409            return Err(Error::new(
410                ErrorKind::InvalidData,
411                "The files contents are not valid UTF-8.",
412            ));
413        }
414    } else {
415        return Err(Error::new(ErrorKind::NotFound, "The file 'hours.txt' could not be opened. Either it does not exist or it is not in the 'RLHoursFolder' directory."));
416    }
417
418    let mut hrs_lines: Vec<&str> = hrs_content.split("\n").collect();
419    let mut date_lines: Vec<&str> = date_content.split("\n").collect();
420
421    hrs_lines.pop();
422    date_lines.pop();
423
424    let main_heading_vec: Vec<&str> = hrs_lines.remove(0).split_whitespace().collect();
425
426    let main_heading = format!(
427        "{} {}<br>{} Tracker",
428        main_heading_vec[0], main_heading_vec[1], main_heading_vec[2]
429    );
430
431    page.add_container(
432        Container::new(ContainerType::Header)
433            .with_attributes(vec![("class", "header")])
434            .with_container(Container::new(ContainerType::Div).with_header_attr(
435                1,
436                main_heading,
437                vec![("class", "main-title bebas-neue-regular")],
438            )),
439    );
440
441    let nav_container = HtmlElement::new(HtmlTag::Div)
442        .with_attribute("class", "nav-container flex-column")
443        .with_container(
444            Container::new(ContainerType::Div)
445                .with_attributes(vec![("class", "your-hours-div nav-div")])
446                .with_link("#hours", "Your Hours"),
447        )
448        .with_container(
449            Container::new(ContainerType::Div)
450                .with_attributes(vec![("class", "date-and-times-div nav-div")])
451                .with_link("#dates", "Date And Times"),
452        );
453
454    page.add_container(
455        Container::new(ContainerType::Nav)
456            .with_attributes(vec![("class", "nav oswald-font-500")])
457            .with_html(nav_container),
458    );
459
460    let mut hours_div =
461        HtmlElement::new(HtmlTag::Div).with_attribute("class", "hours-div flex-column adaptive");
462
463    let mut dates_div = HtmlElement::new(HtmlTag::Div).with_attribute(
464        "class",
465        "dates-div flex-column flex-align-justify-center adaptive",
466    );
467
468    for line in hrs_lines {
469        hours_div.add_paragraph(line);
470    }
471
472    date_lines.reverse();
473
474    let mut counter: usize = 0;
475
476    if date_lines.len() >= 7 {
477        while counter <= 6 {
478            dates_div.add_paragraph(date_lines[counter]);
479
480            counter += 1;
481        }
482    } else {
483        for line in date_lines {
484            dates_div.add_paragraph(line);
485        }
486    }
487
488    let hours_div_container = HtmlElement::new(HtmlTag::Div)
489        .with_attribute("id", "hours")
490        .with_attribute("class", "hours-div-container color flex-column")
491        .with_header(2, "Your Hours Played")
492        .with_html(hours_div);
493
494    let dates_div_container = HtmlElement::new(HtmlTag::Div)
495        .with_attribute("id", "dates")
496        .with_attribute("class", "dates-div-container color flex-column")
497        .with_header(2, "Your time played<br>in the last 7 sessions")
498        .with_html(dates_div);
499
500    page.add_container(
501        Container::new(ContainerType::Main)
502            .with_attributes(vec![("class", "main flex-column color oswald-font-500")])
503            .with_html(hours_div_container)
504            .with_html(dates_div_container),
505    );
506
507    page.add_container(
508        Container::new(ContainerType::Footer)
509            .with_attributes(vec![("class", "footer flex-row oswald-font-700")])
510            .with_paragraph("&copy; OneilNvM 2024 ")
511            .with_link_attr(
512                "https://github.com/OneilNvM/rl-hours-tracker",
513                "Rocket League Hours Tracker Github",
514                [("target", "_blank")],
515            ),
516    );
517
518    Ok(page.to_html_string())
519}