Sam Jeston
History
173 lines·
4.3 KB
·Rust
| 1 | //! Small formatting helpers called from templates. |
| 2 | |
| 3 | use chrono::{DateTime, Utc}; |
| 4 | |
| 5 | use crate::db::now; |
| 6 | |
| 7 | /// Lets helpers accept numbers by value or by (nested) reference, since |
| 8 | /// templates pass fields by reference but method results by value. |
| 9 | pub trait Num<T: Copy> { |
| 10 | fn value(&self) -> T; |
| 11 | } |
| 12 | |
| 13 | impl Num<i64> for i64 { |
| 14 | fn value(&self) -> i64 { |
| 15 | *self |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | impl Num<usize> for usize { |
| 20 | fn value(&self) -> usize { |
| 21 | *self |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | impl<T: Copy, N: Num<T> + ?Sized> Num<T> for &N { |
| 26 | fn value(&self) -> T { |
| 27 | (**self).value() |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | pub fn icon(name: &str) -> String { |
| 32 | crate::icons::icon(name) |
| 33 | } |
| 34 | |
| 35 | /// "3 days ago" style relative time. |
| 36 | pub fn ago(ts: impl Num<i64>) -> String { |
| 37 | let delta = now() - ts.value(); |
| 38 | if delta < 45 { |
| 39 | return "just now".into(); |
| 40 | } |
| 41 | let (n, unit) = match delta { |
| 42 | d if d < 3600 => ((d + 30) / 60, "minute"), |
| 43 | d if d < 86400 => ((d + 1800) / 3600, "hour"), |
| 44 | d if d < 30 * 86400 => ((d + 43200) / 86400, "day"), |
| 45 | d if d < 365 * 86400 => (d / (30 * 86400), "month"), |
| 46 | d => (d / (365 * 86400), "year"), |
| 47 | }; |
| 48 | let n = n.max(1); |
| 49 | format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) |
| 50 | } |
| 51 | |
| 52 | fn datetime(ts: i64) -> DateTime<Utc> { |
| 53 | DateTime::from_timestamp(ts, 0).unwrap_or_default() |
| 54 | } |
| 55 | |
| 56 | /// "Oct 10, 2026" |
| 57 | pub fn date(ts: impl Num<i64>) -> String { |
| 58 | datetime(ts.value()).format("%b %-d, %Y").to_string() |
| 59 | } |
| 60 | |
| 61 | /// "Oct 10, 2026, 14:05 UTC" |
| 62 | pub fn datetime_full(ts: impl Num<i64>) -> String { |
| 63 | datetime(ts.value()) |
| 64 | .format("%b %-d, %Y, %H:%M UTC") |
| 65 | .to_string() |
| 66 | } |
| 67 | |
| 68 | pub fn short_sha(sha: &str) -> &str { |
| 69 | sha.get(..7).unwrap_or(sha) |
| 70 | } |
| 71 | |
| 72 | pub fn bytes(n: impl Num<usize>) -> String { |
| 73 | let n = n.value() as f64; |
| 74 | if n < 1024.0 { |
| 75 | format!("{n} Bytes") |
| 76 | } else if n < 1024.0 * 1024.0 { |
| 77 | format!("{:.1} KB", n / 1024.0) |
| 78 | } else { |
| 79 | format!("{:.1} MB", n / (1024.0 * 1024.0)) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | pub fn plural(n: impl Num<usize>, word: &str) -> String { |
| 84 | let n = n.value(); |
| 85 | if n == 1 { |
| 86 | format!("1 {word}") |
| 87 | } else if word.ends_with("ch") || word.ends_with('s') || word.ends_with('x') { |
| 88 | format!("{n} {word}es") |
| 89 | } else { |
| 90 | format!("{n} {word}s") |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// A coloured initial badge used in place of profile pictures. |
| 95 | pub fn avatar(name: &str, size: u32) -> String { |
| 96 | let hue = name |
| 97 | .bytes() |
| 98 | .fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32)) |
| 99 | % 360; |
| 100 | let initial = name |
| 101 | .chars() |
| 102 | .find(|c| c.is_alphanumeric()) |
| 103 | .map(|c| c.to_uppercase().to_string()) |
| 104 | .unwrap_or_else(|| "?".into()); |
| 105 | let font = (size as f32 * 0.45).round() as u32; |
| 106 | format!( |
| 107 | r#"<span class="avatar" style="--hue:{hue};width:{size}px;height:{size}px;font-size:{font}px" aria-hidden="true">{}</span>"#, |
| 108 | crate::highlight::escape(&initial) |
| 109 | ) |
| 110 | } |
| 111 | |
| 112 | /// Percent-encodes a value for use in a query string. |
| 113 | pub fn urlencode(s: &str) -> String { |
| 114 | let mut out = String::with_capacity(s.len()); |
| 115 | for b in s.bytes() { |
| 116 | match b { |
| 117 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { |
| 118 | out.push(b as char) |
| 119 | } |
| 120 | _ => out.push_str(&format!("%{b:02X}")), |
| 121 | } |
| 122 | } |
| 123 | out |
| 124 | } |
| 125 | |
| 126 | /// Percent-encodes a repository path or ref for use in a URL path, keeping `/`. |
| 127 | pub fn urlpath(s: &str) -> String { |
| 128 | let mut out = String::with_capacity(s.len()); |
| 129 | for b in s.bytes() { |
| 130 | match b { |
| 131 | b'A'..=b'Z' |
| 132 | | b'a'..=b'z' |
| 133 | | b'0'..=b'9' |
| 134 | | b'-' |
| 135 | | b'_' |
| 136 | | b'.' |
| 137 | | b'~' |
| 138 | | b'/' |
| 139 | | b'@' |
| 140 | | b'+' |
| 141 | | b'!' |
| 142 | | b'$' |
| 143 | | b'(' |
| 144 | | b')' |
| 145 | | b',' |
| 146 | | b';' |
| 147 | | b'=' |
| 148 | | b':' => out.push(b as char), |
| 149 | _ => out.push_str(&format!("%{b:02X}")), |
| 150 | } |
| 151 | } |
| 152 | out |
| 153 | } |
| 154 | |
| 155 | #[cfg(test)] |
| 156 | mod tests { |
| 157 | use super::*; |
| 158 | |
| 159 | #[test] |
| 160 | fn relative_times() { |
| 161 | let n = now(); |
| 162 | assert_eq!(ago(n), "just now"); |
| 163 | assert_eq!(ago(n - 60), "1 minute ago"); |
| 164 | assert_eq!(ago(n - 2 * 3600), "2 hours ago"); |
| 165 | assert_eq!(ago(n - 3 * 86400), "3 days ago"); |
| 166 | } |
| 167 | |
| 168 | #[test] |
| 169 | fn url_encoding() { |
| 170 | assert_eq!(urlpath("src/a b#c.rs"), "src/a%20b%23c.rs"); |
| 171 | assert_eq!(urlencode("/a/b?c"), "%2Fa%2Fb%3Fc"); |
| 172 | } |
| 173 | } |