diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 8580bd278..9ec76ca08 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 60 -const val STRAW_VERSION_NAME = "0.1.0-BT" +const val STRAW_VERSION_CODE = 61 +const val STRAW_VERSION_NAME = "0.1.0-BU" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index ceaf572b4..2b8416a5d 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -225,10 +225,16 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { thumbnail: thumbnail.clone(), duration_seconds: 0, view_count: 0, - // RSS gives ISO-8601 timestamps. We pass them - // through unchanged — newer-first sorting on - // raw ISO strings is correct. - upload_date_relative: published.clone(), + // RSS gives RFC3339 timestamps. Convert to + // the human-relative format Kotlin's + // recencyScore parser expects ("N units + // ago"). vc=56 was passing the raw ISO + // through, which broke the sort comparator + // — every item tied at MIN_VALUE so the + // feed order was effectively random; LTT + + // WTYP landed at top because they resolved + // first in the fan-out. Caught 2026-05-26. + upload_date_relative: iso_to_relative(&published), }); } in_entry = false; @@ -255,6 +261,83 @@ enum TextTarget { Published, } +/// Parse an RFC3339 timestamp (`2026-05-25T15:00:00+00:00`) into "N +/// units ago". Drops the timezone offset — YT RSS always serves UTC +/// and the granularity is days at most, so a ±14h skew doesn't matter +/// for the relative display. +/// +/// Falls back to the raw string if parsing fails. That keeps the UI +/// readable even on a malformed feed (rare). +fn iso_to_relative(iso: &str) -> String { + let secs = match parse_rfc3339_secs(iso) { + Some(s) => s, + None => return iso.to_string(), + }; + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + format_relative(now_secs.saturating_sub(secs)) +} + +fn parse_rfc3339_secs(s: &str) -> Option { + if s.len() < 19 { + return None; + } + let date = s.get(..10)?; + let time = s.get(11..19)?; + if !s.is_char_boundary(10) || s.as_bytes().get(10) != Some(&b'T') { + return None; + } + let mut date_parts = date.split('-'); + let y: i32 = date_parts.next()?.parse().ok()?; + let m: u32 = date_parts.next()?.parse().ok()?; + let d: u32 = date_parts.next()?.parse().ok()?; + let mut time_parts = time.split(':'); + let hh: u32 = time_parts.next()?.parse().ok()?; + let mm: u32 = time_parts.next()?.parse().ok()?; + let ss: u32 = time_parts.next()?.parse().ok()?; + if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 { + return None; + } + let days = civil_to_days(y, m, d); + Some(days * 86_400 + hh as i64 * 3_600 + mm as i64 * 60 + ss as i64) +} + +/// Howard Hinnant's days-since-1970-01-01 algorithm. Standard, +/// branch-free, handles negative years correctly. Source: chrono +/// proposal for C++20. +fn civil_to_days(y: i32, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y / 400 } else { (y - 399) / 400 }; + let yoe = (y - era * 400) as u32; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era as i64 * 146_097 + doe as i64 - 719_468 +} + +fn format_relative(age_secs: i64) -> String { + let s = age_secs.max(0); + fn unit(n: i64, name: &str) -> String { + format!("{} {}{} ago", n, name, if n == 1 { "" } else { "s" }) + } + if s < 60 { + unit(s, "second") + } else if s < 3_600 { + unit(s / 60, "minute") + } else if s < 86_400 { + unit(s / 3_600, "hour") + } else if s < 604_800 { + unit(s / 86_400, "day") + } else if s < 2_592_000 { + unit(s / 604_800, "week") + } else if s < 31_536_000 { + unit(s / 2_592_000, "month") + } else { + unit(s / 31_536_000, "year") + } +} + /// Strip the namespace prefix off an XML element name. YouTube's feed /// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only /// care about the local part — namespace-vs-local distinguishing