glowpub 0.5.0

A glowfic to epub converter.
Documentation
mod cover;

pub mod epub;
pub mod html;
pub mod transform;

use std::collections::HashMap;

use crate::{
    Post, Reply,
    types::{BoardInPost, Character, Icon, User},
};

use super::Thread;

const STYLE: &str = include_str!("book.css");

#[derive(Debug, Clone, Copy, Default)]
pub struct Options {
    pub text_to_speech: bool,
    pub flatten_details: bool,
    pub jpeg: bool,
    pub resize_icons: Option<u32>,
}

fn raw_title_page(post: &Post, reply_count: usize) -> String {
    let Post {
        authors,
        board,
        description,
        id,
        status,
        subject,
        ..
    } = post;

    let author_names = author_names(authors);
    let author_ids: Vec<u64> = authors.iter().map(|user| user.id).collect();

    let BoardInPost {
        id: board_id,
        name: board_name,
    } = board;

    let description = description
        .as_ref()
        .map(|v| format!(r##"<div class="description">{v}</div>"##))
        .unwrap_or_default();

    format!(
        r##"

    <div class="title-page">
        <h1 post-id="{id}">{subject}</h1>
        <h2 author-ids="{author_ids:?}">by {author_names}</h2>
        <h3 board-id="{board_id}">in {board_name}</h3>
        <p>[Status: <a href="https://glowfic.com/posts/{id}" rel="noopener noreferrer">{status}</a>]</p>
        <p>[{reply_count} replies]</p>
        {description}
    </div>

    "##
    )
}

pub fn raw_content_page(content_blocks: &[String]) -> String {
    let content: String = content_blocks
        .iter()
        .map(String::as_ref)
        .collect::<Vec<_>>()
        .join("<hr>");

    format!(
        r##"

        <div class="content">
            {content}
        </div>

        "##
    )
}

impl Thread {
    fn content_blocks(&self, options: Options) -> Vec<String> {
        std::iter::once(self.post.content_block(options))
            .chain(
                self.replies
                    .iter()
                    .map(|reply| reply.content_block(options)),
            )
            .collect()
    }
}
impl Post {
    fn content_block(&self, options: Options) -> String {
        content_block(
            None,
            &None,
            &self.character,
            &self
                .character
                .as_ref()
                .map(|character| character.name.clone()),
            &self.icon,
            &self.content,
            options,
        )
    }
}
impl Reply {
    fn content_block(&self, options: Options) -> String {
        content_block(
            Some(self.id),
            &Some(self.user.clone()),
            &self.character,
            &self.character_name,
            &self.icon,
            &self.content,
            options,
        )
    }
}

fn content_block(
    reply_id: Option<u64>,
    author: &Option<User>,
    character: &Option<Character>,
    character_name: &Option<String>,
    icon: &Option<Icon>,
    content: &str,
    options: Options,
) -> String {
    let caption = match character {
        Some(Character {
            id: character_id,
            name: character_default_name,
            screenname,
        }) => {
            let screenname = screenname
                .as_ref()
                .map(|n| format!("({n})"))
                .map(|n| transform::escape_html(&n))
                .unwrap_or_default();
            let character_name =
                transform::escape_html(character_name.as_ref().unwrap_or(character_default_name));

            match author {
                Some(User {
                    id: user_id,
                    username,
                }) => {
                    let username = transform::escape_html(username);
                    let author_line = if options.text_to_speech {
                        format!(r##"{username} <br>as {character_name}"##)
                    } else {
                        format!(r##"{username} <br>as {character_name} <br>{screenname}"##)
                    };

                    format!(
                        r##"
                    <span author-id="{user_id}" author-name="{username}" character-id="{character_id}" character-name="{character_name}" class="icon-caption">
                    {author_line}
                    </span>
                    "##
                    )
                }
                None => {
                    let author_line = if options.text_to_speech {
                        character_name.clone()
                    } else {
                        format!(r##"{character_name} <br>{screenname}"##)
                    };

                    format!(
                        r##"
                    <span character-id="{character_id}" character-name="{character_name}" class="icon-caption">
                    {author_line}
                    </span>
                    "##
                    )
                }
            }
        }
        None => match author {
            Some(User {
                id: user_id,
                username,
            }) => {
                let username = transform::escape_html(username);
                format!(
                    r##"<span author-id="{user_id}" author-name="{username}" class="icon-caption">{username}</span>"##
                )
            }

            None => "".to_string(),
        },
    };
    let image = icon
        .as_ref()
        .and_then(|Icon { id, keyword, url }| {
            let keyword = keyword
                .as_deref()
                .map(transform::escape_html)
                .map(|keyword| format!(r#" alt="{keyword}""#))
                .unwrap_or_default();
            let url = url.as_ref()?;
            let url = transform::escape_html(url);
            Some(format!(
                r##"<img src="{url}"{keyword} icon-id="{id}" class="icon">"##
            ))
        })
        .unwrap_or_default();

    let reply_id = reply_id
        .map(|id| format!(r##" reply-id="{id}""##))
        .unwrap_or_default();

    format!(
        r##"

    <div class="content-block"{reply_id}>
        <div class="character">
            {image}
            {caption}
        </div>
        {content}
    </div>

    "##
    )
}

fn raw_copyright_page(post: &Post) -> String {
    let Post {
        authors,
        board,
        id,
        subject,
        ..
    } = post;

    let author_names = author_names(authors);
    let author_ids: Vec<u64> = authors.iter().map(|user| user.id).collect();

    let BoardInPost {
        id: board_id,
        name: board_name,
    } = board;

    format!(
        r##"

    <div class="copyright-page">
        <h3>This was</h3>
        <h1 post-id="{id}">{subject}</h1>
        <h2 author-ids="{author_ids:?}">by {author_names}</h2>
        <h3 board-id="{board_id}">in {board_name}</h3>

        © {author_names}
    </div>

    "##
    )
}

fn author_names(authors: &[User]) -> String {
    let usernames: Vec<_> = authors
        .iter()
        .map(|a| transform::escape_html(&a.username))
        .collect();

    match &*usernames {
        [] => String::new(),
        [username] => username.to_string(),
        [one, two] => format!("{one} &#38; {two}"),
        [leading @ .., last] => {
            let leading = leading.join(", ");
            format!("{leading}, &#38; {last}")
        }
    }
}

fn process_content(content: &str, options: Options, url_map: &HashMap<String, String>) -> String {
    let content = transform::repair_and_sanitize(content);
    let content = transform::decode_named_entities(content);
    let content =
        transform::edit_image_urls(&content, |url| url_map.get(&url).cloned().unwrap_or(url));

    if options.flatten_details {
        transform::flatten_details(&content)
    } else {
        content
    }
}