mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
refac: rewrite a lil in rust
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "betterseqta-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["BetterSEQTA+"]
|
||||
description = "WebAssembly helpers for the BetterSEQTA+ browser extension"
|
||||
repository = "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
base64 = "0.22.1"
|
||||
percent-encoding = "2.3.1"
|
||||
regex = "1.11.1"
|
||||
csscolorparser = "0.7.2"
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Base64 and data-URL helpers.
|
||||
|
||||
use base64::Engine;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = encodeBase64)]
|
||||
pub fn encode_base64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = decodeBase64)]
|
||||
pub fn decode_base64(b64: &str) -> Vec<u8> {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.trim())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `data:{mime};base64,{payload}` (mirrors `readAsDataURL` output shape).
|
||||
#[wasm_bindgen(js_name = encodeDataUrl)]
|
||||
pub fn encode_data_url(mime: &str, bytes: &[u8]) -> String {
|
||||
let mime = mime.trim();
|
||||
let mime = if mime.is_empty() {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
mime
|
||||
};
|
||||
format!(
|
||||
"data:{};base64,{}",
|
||||
mime,
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
)
|
||||
}
|
||||
|
||||
/// Strips a leading `data:*;base64,` prefix; returns the original string when no prefix matches.
|
||||
#[wasm_bindgen(js_name = stripDataUrlBase64Payload)]
|
||||
pub fn strip_data_url_base64_payload(s: &str) -> String {
|
||||
let Some(rest) = s.strip_prefix("data:") else {
|
||||
return s.to_string();
|
||||
};
|
||||
let Some(i) = rest.find(";base64,") else {
|
||||
return s.to_string();
|
||||
};
|
||||
let after = &rest[i + ";base64,".len()..];
|
||||
after.to_string()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Engage hash routing (`getEngageRoutePage`).
|
||||
|
||||
use percent_encoding::percent_decode_str;
|
||||
use std::borrow::Cow;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors `getEngageRoutePage` using `window.location.hash` and `window.location.href` inputs.
|
||||
#[wasm_bindgen(js_name = parseEngageRoutePage)]
|
||||
pub fn parse_engage_route_page(hash: &str, full_href: &str) -> Option<String> {
|
||||
let hash = hash.strip_prefix('#').unwrap_or(hash);
|
||||
if !hash.is_empty() {
|
||||
let qs: Cow<'_, str> = if hash.starts_with('?') {
|
||||
Cow::Borrowed(hash)
|
||||
} else {
|
||||
Cow::Owned(format!("?{hash}"))
|
||||
};
|
||||
if let Some(seg) = parse_page_segment_from_query_string(qs.as_ref()) {
|
||||
return Some(seg);
|
||||
}
|
||||
}
|
||||
segment_from_href_split(full_href)
|
||||
}
|
||||
|
||||
fn parse_page_segment_from_query_string(qs: &str) -> Option<String> {
|
||||
let body = qs.strip_prefix('?').unwrap_or(qs);
|
||||
for pair in body.split('&') {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
let key = it.next()?;
|
||||
if key != "page" {
|
||||
continue;
|
||||
}
|
||||
let enc = it.next().unwrap_or("");
|
||||
let decoded = percent_decode_str(enc).decode_utf8_lossy();
|
||||
let page = decoded.as_ref();
|
||||
if let Some(rest) = page.strip_prefix('/') {
|
||||
let seg = rest.split('/').next().unwrap_or("");
|
||||
if !seg.is_empty() {
|
||||
return Some(seg.to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn segment_from_href_split(full_href: &str) -> Option<String> {
|
||||
full_href.split('/').nth(4).map(std::string::ToString::to_string)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! String escaping for injected scripts (PDF / Firefox workarounds).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = escapeJsSingleQuotedString)]
|
||||
pub fn escape_js_single_quoted_string(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
||||
}
|
||||
|
||||
/// `escJsSingleQuoted` plus double-quote escapes (used for some injected literals).
|
||||
#[wasm_bindgen(js_name = escapeJsForInlineScript)]
|
||||
pub fn escape_js_for_inline_script(s: &str) -> String {
|
||||
escape_js_single_quoted_string(s)
|
||||
.replace('"', "\\\"")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Grade parsing (`parseGrade` in `assessmentsAverage/utils.ts`).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn letter_grade_percent(s: &str) -> Option<f64> {
|
||||
Some(match s {
|
||||
"A+" => 100.0,
|
||||
"A" => 95.0,
|
||||
"A-" => 90.0,
|
||||
"B+" => 85.0,
|
||||
"B" => 80.0,
|
||||
"B-" => 75.0,
|
||||
"C+" => 70.0,
|
||||
"C" => 65.0,
|
||||
"C-" => 60.0,
|
||||
"D+" => 55.0,
|
||||
"D" => 50.0,
|
||||
"D-" => 45.0,
|
||||
"E+" => 40.0,
|
||||
"E" => 35.0,
|
||||
"E-" => 30.0,
|
||||
"F" => 0.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors `parseGrade` (numeric percent 0–100).
|
||||
#[wasm_bindgen(js_name = parseGradeToPercent)]
|
||||
pub fn parse_grade_to_percent(text: &str) -> f64 {
|
||||
let str = text.trim().to_ascii_uppercase();
|
||||
if str.contains('/') {
|
||||
let mut parts = str.split('/');
|
||||
let raw = parts.next().and_then(|p| p.parse::<f64>().ok());
|
||||
let max = parts.next().and_then(|p| p.parse::<f64>().ok());
|
||||
if let (Some(r), Some(m)) = (raw, max) {
|
||||
if m != 0.0 {
|
||||
return (r / m) * 100.0;
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
if str.contains('%') {
|
||||
return str.replace('%', "").parse::<f64>().unwrap_or(0.0);
|
||||
}
|
||||
letter_grade_percent(&str).unwrap_or(0.0)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Subject timetable colour hex validation (`getAdaptiveColour` in `adaptiveThemeColour.ts`).
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn re_hash_shorthand_or_full() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)^#([0-9a-f]{3}|[0-9a-f]{6})$").expect("hex # regex"))
|
||||
}
|
||||
|
||||
fn re_plain_six() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)^[0-9a-f]{6}$").expect("hex6 regex"))
|
||||
}
|
||||
|
||||
/// Returns `#rgb` / `#rrggbb` unchanged, or adds `#` to a bare 6-digit hex; otherwise `undefined`.
|
||||
#[wasm_bindgen(js_name = normalizeSeqtaSubjectHexColour)]
|
||||
pub fn normalize_seqta_subject_hex_colour(colour: &str) -> Option<String> {
|
||||
let c = colour.trim();
|
||||
if re_hash_shorthand_or_full().is_match(c) {
|
||||
return Some(c.to_string());
|
||||
}
|
||||
if re_plain_six().is_match(c) {
|
||||
return Some(format!("#{c}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! BetterSEQTA+ WebAssembly module (wasm-bindgen).
|
||||
//! Pure helpers mirrored from the TypeScript extension.
|
||||
|
||||
mod base64_api;
|
||||
mod engage;
|
||||
mod escape;
|
||||
mod grades;
|
||||
mod hex_colour;
|
||||
mod page_context;
|
||||
mod pdf_weight;
|
||||
mod seqta;
|
||||
mod threshold;
|
||||
mod time_format;
|
||||
mod timetable_nav;
|
||||
mod user_agent;
|
||||
|
||||
pub use base64_api::{
|
||||
decode_base64, encode_base64, encode_data_url, strip_data_url_base64_payload,
|
||||
};
|
||||
pub use engage::parse_engage_route_page;
|
||||
pub use escape::{escape_js_for_inline_script, escape_js_single_quoted_string};
|
||||
pub use grades::parse_grade_to_percent;
|
||||
pub use hex_colour::normalize_seqta_subject_hex_colour;
|
||||
pub use page_context::parse_seqta_courses_assessments_page_json;
|
||||
pub use pdf_weight::extract_weight_from_pdf_text;
|
||||
pub use seqta::{
|
||||
child_text_has_seqta_copyright, title_is_seqta_engage_only, title_is_seqta_learn_or_engage,
|
||||
};
|
||||
pub use threshold::color_css_threshold_distance;
|
||||
pub use time_format::{
|
||||
convert_to_12_hour_format, format_timetable_time_label, format_timetable_time_range,
|
||||
};
|
||||
pub use timetable_nav::location_hash_includes_timetable_page;
|
||||
pub use user_agent::is_firefox_user_agent;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = extensionWasmVersion)]
|
||||
pub fn extension_wasm_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twelve_hour_matches_ts_examples() {
|
||||
assert_eq!(convert_to_12_hour_format("13:05", false), "1:05pm");
|
||||
assert_eq!(convert_to_12_hour_format("0:30", false), "12:30am");
|
||||
assert_eq!(convert_to_12_hour_format("12:00", false), "12:00pm");
|
||||
assert_eq!(convert_to_12_hour_format("12:00", true), "12pm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seqta_strings() {
|
||||
assert!(child_text_has_seqta_copyright(
|
||||
"foo Copyright (c) SEQTA Software bar"
|
||||
));
|
||||
assert!(!child_text_has_seqta_copyright("other"));
|
||||
assert!(title_is_seqta_learn_or_engage("SEQTA Learn — Home"));
|
||||
assert!(title_is_seqta_engage_only("SEQTA Engage"));
|
||||
assert!(!title_is_seqta_engage_only("SEQTA Learn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engage_routes() {
|
||||
assert_eq!(
|
||||
parse_engage_route_page("#?page=/home/extra", "https://x.example/a/b/c/d/e"),
|
||||
Some("home".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_engage_route_page("#?page=%2Fhome%2Fextra", "https://x.example/a/b/c/d/e"),
|
||||
Some("home".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_engage_route_page("", "a/b/c/d/home/extra"),
|
||||
Some("home".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grades_and_weight() {
|
||||
assert_eq!(parse_grade_to_percent(" 12/20 "), 60.0);
|
||||
assert_eq!(parse_grade_to_percent("85%"), 85.0);
|
||||
assert_eq!(parse_grade_to_percent("A-"), 90.0);
|
||||
assert_eq!(
|
||||
extract_weight_from_pdf_text("foo Weight: 12.5 bar").as_deref(),
|
||||
Some("12.5")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firefox_ua() {
|
||||
assert!(is_firefox_user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; rv:109.0) Gecko/20100101 Firefox/115.0"
|
||||
));
|
||||
assert!(!is_firefox_user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timetable_hash() {
|
||||
assert!(location_hash_includes_timetable_page("#?page=/timetable"));
|
||||
assert!(!location_hash_includes_timetable_page("#?page=/home"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_context_json() {
|
||||
let j = parse_seqta_courses_assessments_page_json(
|
||||
"#?page=/courses/2023S/4804:11066",
|
||||
)
|
||||
.expect("json");
|
||||
assert!(j.contains("\"programme\":4804"));
|
||||
assert!(j.contains("\"metaclass\":11066"));
|
||||
let j2 = parse_seqta_courses_assessments_page_json("#?page=/courses/4804:11066")
|
||||
.expect("json2");
|
||||
assert!(j2.contains("4804"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_subject_colour() {
|
||||
assert_eq!(
|
||||
normalize_seqta_subject_hex_colour("#aBc").as_deref(),
|
||||
Some("#aBc")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_seqta_subject_hex_colour("aabbcc").as_deref(),
|
||||
Some("#aabbcc")
|
||||
);
|
||||
assert!(normalize_seqta_subject_hex_colour("gggggg").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_basic() {
|
||||
let t = color_css_threshold_distance("rgb(3,4,5)");
|
||||
assert!((t - (3f64 * 3.0 + 4.0 * 4.0 + 5.0 * 5.0).sqrt()).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! `#?page=/courses/...` and `#?page=/assessments/...` programme:metaclass parsing
|
||||
//! (mirrors `parsePageContext` in `adaptiveThemeColour.ts`).
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn page_ctx_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"[?&]page=/(courses|assessments)/(?:[^/]+/)?(\d+):(\d+)")
|
||||
.expect("page context regex")
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON `{"programme":n,"metaclass":m}` or `undefined` when the hash does not match.
|
||||
#[wasm_bindgen(js_name = parseSeqtaCoursesAssessmentsPageJson)]
|
||||
pub fn parse_seqta_courses_assessments_page_json(hash: &str) -> Option<String> {
|
||||
let cap = page_ctx_regex().captures(hash)?;
|
||||
let programme: i32 = cap.get(2)?.as_str().parse().ok()?;
|
||||
let metaclass: i32 = cap.get(3)?.as_str().parse().ok()?;
|
||||
if programme < 0 || metaclass < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
r#"{{"programme":{programme},"metaclass":{metaclass}}}"#
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! PDF text weight extraction (`/weight:\s*(\d+\.?\d*)/i` in assessments average).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Returns the first `weight:` numeric capture, or `undefined` when absent.
|
||||
#[wasm_bindgen(js_name = extractWeightFromPdfText)]
|
||||
pub fn extract_weight_from_pdf_text(text: &str) -> Option<String> {
|
||||
let lower: Vec<u8> = text.bytes().map(|b| b.to_ascii_lowercase()).collect();
|
||||
let needle = b"weight:";
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0usize;
|
||||
while i + needle.len() <= lower.len() {
|
||||
if lower[i..i + needle.len()] == needle[..] {
|
||||
let mut j = i + needle.len();
|
||||
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
let start = j;
|
||||
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b'.') {
|
||||
j += 1;
|
||||
}
|
||||
if j > start {
|
||||
return Some(text[start..j].to_string());
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! SEQTA page / title detection strings.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = childTextHasSeqtaCopyright)]
|
||||
pub fn child_text_has_seqta_copyright(text: &str) -> bool {
|
||||
text.contains("Copyright (c) SEQTA Software")
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = titleIsSeqtaLearnOrEngage)]
|
||||
pub fn title_is_seqta_learn_or_engage(title: &str) -> bool {
|
||||
title.contains("SEQTA Learn") || title.contains("SEQTA Engage")
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = titleIsSeqtaEngage)]
|
||||
pub fn title_is_seqta_engage_only(title: &str) -> bool {
|
||||
title.contains("SEQTA Engage")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! `GetThresholdOfColor` luminance distance (`sqrt(r²+g²+b²)`), including gradients.
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn rgba_capture_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)rgba?\(([^)]+)\)").expect("regex"))
|
||||
}
|
||||
|
||||
fn parse_js_int_channel(s: &str) -> f64 {
|
||||
s.trim().parse::<f64>().ok().map(|n| n.trunc()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn threshold_from_rgb_triplet(r: f64, g: f64, b: f64) -> f64 {
|
||||
(r * r + g * g + b * b).sqrt()
|
||||
}
|
||||
|
||||
fn gradient_average_threshold(color: &str) -> Option<f64> {
|
||||
let re = rgba_capture_regex();
|
||||
let mut sums = Vec::new();
|
||||
for cap in re.captures_iter(color) {
|
||||
let inner = cap.get(1)?.as_str();
|
||||
let parts: Vec<&str> = inner.split(',').collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let r = parse_js_int_channel(parts[0]);
|
||||
let g = parse_js_int_channel(parts[1]);
|
||||
let b = parse_js_int_channel(parts[2]);
|
||||
sums.push(threshold_from_rgb_triplet(r, g, b));
|
||||
}
|
||||
if sums.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sums.iter().sum::<f64>() / sums.len() as f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `sqrt(r²+g²+b²)` for a CSS color string, or **`-1`** when the Rust path
|
||||
/// cannot match the JS `color` package (caller should fall back to TypeScript).
|
||||
#[wasm_bindgen(js_name = colorCssThresholdDistance)]
|
||||
pub fn color_css_threshold_distance(color: &str) -> f64 {
|
||||
let color = color.trim();
|
||||
if color.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if color.contains("gradient") {
|
||||
return gradient_average_threshold(color).unwrap_or(-1.0);
|
||||
}
|
||||
match csscolorparser::parse(color) {
|
||||
Ok(c) => {
|
||||
let rgba = c.to_rgba8();
|
||||
let r = f64::from(rgba[0]);
|
||||
let g = f64::from(rgba[1]);
|
||||
let b = f64::from(rgba[2]);
|
||||
threshold_from_rgb_triplet(r, g, b)
|
||||
}
|
||||
Err(_) => -1.0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 12-hour timetable formatting (mirrors TS helpers around `convertTo12HourFormat`).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors JavaScript `Number(s.trim())` for a single split segment, including `"" -> 0`.
|
||||
fn js_number_from_split_segment(part: Option<&str>) -> f64 {
|
||||
let s = part.unwrap_or("");
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
trimmed.parse::<f64>().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Mirrors `n.toString()` for timetable paths.
|
||||
fn js_number_to_string(n: f64) -> String {
|
||||
if n.is_nan() {
|
||||
return "NaN".to_string();
|
||||
}
|
||||
if n == 0.0 && n.is_sign_negative() {
|
||||
return "0".to_string();
|
||||
}
|
||||
if (n - n.round()).abs() < f64::EPSILON {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 1:1 with `convertTo12HourFormat` in `src/seqta/utils/convertTo12HourFormat.ts`.
|
||||
#[wasm_bindgen(js_name = convertTo12HourFormat)]
|
||||
pub fn convert_to_12_hour_format(time: &str, no_minutes: bool) -> String {
|
||||
let parts: Vec<&str> = time.split(':').collect();
|
||||
let mut hours = js_number_from_split_segment(parts.first().copied());
|
||||
let minutes = js_number_from_split_segment(parts.get(1).copied());
|
||||
|
||||
let mut period = "am";
|
||||
if hours >= 12.0 {
|
||||
period = "pm";
|
||||
if hours > 12.0 {
|
||||
hours -= 12.0;
|
||||
}
|
||||
} else if hours == 0.0 {
|
||||
hours = 12.0;
|
||||
}
|
||||
|
||||
let mut hours_str = js_number_to_string(hours);
|
||||
if hours_str.len() == 2 && hours_str.starts_with('0') {
|
||||
hours_str = hours_str[1..].to_string();
|
||||
}
|
||||
|
||||
let minute_part = if no_minutes {
|
||||
String::new()
|
||||
} else {
|
||||
let m = js_number_to_string(minutes);
|
||||
let m = if m.len() >= 2 { m } else { format!("0{m}") };
|
||||
format!(":{m}")
|
||||
};
|
||||
|
||||
format!("{hours_str}{minute_part}{period}")
|
||||
}
|
||||
|
||||
/// `convertTo12HourFormat(...).toLowerCase().replace(" ", "")` from `updateTimetableTimes.ts`.
|
||||
#[wasm_bindgen(js_name = formatTimetableTimeLabel)]
|
||||
pub fn format_timetable_time_label(time: &str, no_minutes: bool) -> String {
|
||||
convert_to_12_hour_format(time, no_minutes)
|
||||
.to_lowercase()
|
||||
.replace(' ', "")
|
||||
}
|
||||
|
||||
/// Formats a `start–end` / `start-end` range label for timetable rows.
|
||||
#[wasm_bindgen(js_name = formatTimetableTimeRange)]
|
||||
pub fn format_timetable_time_range(original: &str) -> Option<String> {
|
||||
let mut parts = original
|
||||
.split(|c| c == '-' || c == '\u{2013}')
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty());
|
||||
let start = parts.next()?;
|
||||
let end = parts.next()?;
|
||||
let start12 = format_timetable_time_label(start, false);
|
||||
let end12 = format_timetable_time_label(end, false);
|
||||
Some(format!("{start12}\u{2013}{end12}"))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Timetable URL/hash checks.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = locationHashIncludesTimetablePage)]
|
||||
pub fn location_hash_includes_timetable_page(location_hash: &str) -> bool {
|
||||
location_hash.contains("page=/timetable")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! User-agent sniffing (`isFirefox` in assessments average).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = isFirefoxUserAgent)]
|
||||
pub fn is_firefox_user_agent(user_agent: &str) -> bool {
|
||||
let u = user_agent.to_ascii_lowercase();
|
||||
u.contains("firefox") && !u.contains("seamonkey") && !u.contains("waterfox")
|
||||
}
|
||||
Reference in New Issue
Block a user