fourchan-rs 0.1.0

Async 4chan JSON API client and type bindings
Documentation
use serde::{Deserialize, Serialize};

use crate::raw::RawPost;

/// A sanitized post. Attachment metadata is split out into [`Attachment`].
///
/// 4chan's raw post JSON mixes post metadata and attachment metadata into the
/// same object, with most attachment fields optional. We normalize that here so
/// `attachment.is_some()` is the single source of truth.
#[derive(Debug, Clone, Serialize)]
pub struct Post {
    /// Post number.
    pub no: u64,
    /// Reply-to. `0` for OP, OP number for replies.
    pub resto: u64,
    /// Unix timestamp of post creation.
    pub time: i64,
    /// Pre-formatted `MM/DD/YY(Day)HH:MM` timestamp.
    pub now: String,
    /// Poster name. Defaults to `"Anonymous"`.
    pub name: String,
    /// OP-only subject.
    pub sub: Option<String>,
    /// HTML-escaped comment body.
    pub com: Option<String>,
    /// Tripcode (`!tripcode` or `!!securetrip`).
    pub trip: Option<String>,
    /// 8-character poster ID, for boards that show one.
    pub id: Option<String>,
    /// `mod`, `admin`, `developer`, etc.
    pub capcode: Option<String>,
    /// ISO 3166-1 alpha-2 country code.
    pub country: Option<String>,
    pub country_name: Option<String>,
    pub board_flag: Option<String>,
    pub flag_name: Option<String>,
    /// 4-digit year the poster bought a 4chan Pass.
    pub since4pass: Option<u32>,

    // OP-only.
    pub sticky: bool,
    pub closed: bool,
    pub archived: bool,
    pub archived_on: Option<i64>,
    pub bumplimit: bool,
    pub imagelimit: bool,
    pub replies: Option<u32>,
    pub images: Option<u32>,
    pub unique_ips: Option<u32>,
    pub semantic_url: Option<String>,
    pub tag: Option<String>,

    pub attachment: Option<Attachment>,
}

/// File attachment metadata.
#[derive(Debug, Clone, Serialize)]
pub struct Attachment {
    pub id: u64,            /// Unixtime + microtime upload identifier; this is afterall the filename on 4chan CDN.
    pub filename: String,   /// Original filename the user uploaded (no extension).
    pub ext: String,        /// Extension, including the leading dot (e.g. `".jpg"`). 
    pub size: u64,          /// Size in bytes.
    pub md5: String,        /// 24-character base64-packed MD5.
    pub width: i32,
    pub height: i32,
    pub thumbnail_width: i32,
    pub thumbnail_height: i32,
    pub spoiler: bool,
    pub custom_spoiler: Option<u8>,
    pub deleted: bool,
    pub mobile_optimized: bool,
}

impl Attachment {
    /// Full-resolution URL on `i.4cdn.org`.
    pub fn url(&self, board: &str) -> String {
        format!("https://i.4cdn.org/{}/{}{}", board, self.id, self.ext)
    }

    /// Thumbnail URL. Always a compressed JPEG, regardless of the original extension.
    pub fn thumbnail_url(&self, board: &str) -> String {
        format!("https://i.4cdn.org/{}/{}s.jpg", board, self.id)
    }

    pub fn is_video(&self) -> bool {
        matches!(
            self.ext.to_lowercase().as_str(),
            ".webm" | ".mp4" | ".mov" | ".mkv"
        )
    }

    pub fn is_animated(&self) -> bool {
        self.is_video() || self.ext.eq_ignore_ascii_case(".gif")
    }

    /// True for still-image extensions. 
    pub fn is_image(&self) -> bool {
        matches!(
            self.ext.to_lowercase().as_str(),
            ".jpg" | ".jpeg" | ".png" | ".gif" | ".webp"
        )
    }
}

impl Post {
    /// True if this post is the thread OP. Replies carry `resto = OP number`.
    pub fn is_op(&self) -> bool {
        self.resto == 0
    }
}

impl Post {
    pub(crate) fn from_raw(raw: RawPost) -> Self {
        let attachment = raw.tim.and_then(|id| {
            let ext = raw.ext?;
            Some(Attachment {
                id,
                filename: raw.filename.unwrap_or_default(),
                ext,
                size: raw.fsize.unwrap_or(0),
                md5: raw.md5.unwrap_or_default(),
                width: raw.w.unwrap_or(0),
                height: raw.h.unwrap_or(0),
                thumbnail_width: raw.tn_w.unwrap_or(0),
                thumbnail_height: raw.tn_h.unwrap_or(0),
                spoiler: raw.spoiler.unwrap_or(0) == 1,
                custom_spoiler: raw.custom_spoiler,
                deleted: raw.filedeleted.unwrap_or(0) == 1,
                mobile_optimized: raw.m_img.unwrap_or(0) == 1,
            })
        });

        Post {
            no: raw.no,
            resto: raw.resto,
            time: raw.time,
            now: raw.now,
            name: raw.name.unwrap_or_else(|| "Anonymous".to_string()),
            sub: raw.sub,
            com: raw.com,
            trip: raw.trip,
            id: raw.id,
            capcode: raw.capcode,
            country: raw.country,
            country_name: raw.country_name,
            board_flag: raw.board_flag,
            flag_name: raw.flag_name,
            since4pass: raw.since4pass,
            sticky: raw.sticky.unwrap_or(0) == 1,
            closed: raw.closed.unwrap_or(0) == 1,
            archived: raw.archived.unwrap_or(0) == 1,
            archived_on: raw.archived_on,
            bumplimit: raw.bumplimit.unwrap_or(0) == 1,
            imagelimit: raw.imagelimit.unwrap_or(0) == 1,
            replies: raw.replies,
            images: raw.images,
            unique_ips: raw.unique_ips,
            semantic_url: raw.semantic_url,
            tag: raw.tag,
            attachment,
        }
    }
}

impl<'de> Deserialize<'de> for Post {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        RawPost::deserialize(deserializer).map(Post::from_raw)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn op_without_file_normalizes() {
        let json = r#"{"no":1,"resto":0,"time":0,"now":"x"}"#;
        let p: Post = serde_json::from_str(json).unwrap();
        assert!(p.is_op());
        assert_eq!(p.name, "Anonymous"); // defaulted anon
        assert!(p.attachment.is_none());
    }

    #[test]
    fn reply_attachment_urls_and_kind() {
        let json = r#"{"no":2,"resto":1,"time":0,"now":"x","tim":1234,"ext":".png",
                       "filename":"f","fsize":10,"md5":"abc","w":800,"h":600,
                       "tn_w":250,"tn_h":187}"#;
        let p: Post = serde_json::from_str(json).unwrap();
        assert!(!p.is_op());
        let a = p.attachment.as_ref().expect("attachment present when tim+ext set");
        assert_eq!(a.url("g"), "https://i.4cdn.org/g/1234.png");
        assert_eq!(a.thumbnail_url("g"), "https://i.4cdn.org/g/1234s.jpg");
        assert!(a.is_image());
        assert!(!a.is_video());
    }

    #[test]
    fn webm_is_video_not_image() {
        let json = r#"{"no":3,"resto":1,"time":0,"now":"x","tim":9,"ext":".webm"}"#;
        let p: Post = serde_json::from_str(json).unwrap();
        let a = p.attachment.unwrap();
        assert!(a.is_video());
        assert!(a.is_animated());
        assert!(!a.is_image());
    }
}