refac: rewrite a lil in rust

This commit is contained in:
2026-05-04 17:38:49 +09:30
parent 608fc96c4e
commit aa1e1a925e
42 changed files with 1367 additions and 103 deletions
+17
View File
@@ -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"
+45
View File
@@ -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()
}
+48
View File
@@ -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)
}
+15
View File
@@ -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('"', "\\\"")
}
+46
View File
@@ -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 0100).
#[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)
}
+28
View File
@@ -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
}
+140
View File
@@ -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);
}
}
+28
View File
@@ -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}}}"#
))
}
+29
View File
@@ -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
}
+18
View File
@@ -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")
}
+62
View File
@@ -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,
}
}
+83
View File
@@ -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 `startend` / `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")
}
+9
View File
@@ -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")
}