use std::sync::mpsc::Receiver;
use brep_render::engine_state::{EngineState, NoticeSeverity};
use eframe::egui;
use serde::{Deserialize, Serialize};
const STATUS_URL: &str = "https://next.brep.io/api/public/reports/status";
const FEATURE: &str = "Problem report status";
const FIRST_CHECK_SECS: f64 = 5.0;
const CHECK_EVERY_SECS: f64 = 3.0 * 60.0 * 60.0;
const MAX_KEPT: usize = 50;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Submitted {
pub id: String,
pub submitted_at: String,
pub summary: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub notified: Option<String>,
}
fn resolution_notice(report: &Submitted, status: &str) -> Option<String> {
let outcome = match status {
"fixed" => "was marked fixed. Thank you for reporting it!",
"wontfix" => "was closed as won't fix.",
"closed" => "was closed.",
_ => return None,
};
let date = report.submitted_at.get(..10).unwrap_or(&report.submitted_at);
let summary = if report.summary.is_empty() { "no description".to_string() } else { report.summary.clone() };
Some(format!("Your problem report from {date} (\u{201c}{summary}\u{201d}) {outcome}"))
}
pub fn apply(reports: &mut Vec<Submitted>, body: &str) -> Result<Vec<String>, String> {
#[derive(Deserialize)]
struct Row {
id: String,
status: String,
}
#[derive(Deserialize)]
struct Answer {
#[serde(default)]
reports: Vec<Row>,
#[serde(default)]
missing: Vec<String>,
}
let answer: Answer = serde_json::from_str(body).map_err(|e| format!("report status: {e}"))?;
reports.retain(|r| !answer.missing.contains(&r.id));
let mut notices = Vec::new();
for row in answer.reports {
let Some(report) = reports.iter_mut().find(|r| r.id == row.id) else { continue };
report.status = row.status.clone();
if report.notified.as_deref() == Some(row.status.as_str()) {
continue;
}
if let Some(notice) = resolution_notice(report, &row.status) {
notices.push(notice);
report.notified = Some(row.status);
}
}
Ok(notices)
}
pub fn summary_of(description: &str) -> String {
let line = description.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
let mut out: String = line.chars().take(80).collect();
if line.chars().count() > 80 {
out.push('\u{2026}');
}
out
}
pub fn id_of_reply(body: &[u8]) -> Option<String> {
let v: serde_json::Value = serde_json::from_slice(body).ok()?;
let id = v.get("id")?.as_str()?;
let ok = !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
ok.then(|| id.to_string())
}
fn rfc3339(secs: i64) -> String {
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
fn status_url(reports: &[Submitted]) -> String {
let ids: Vec<String> = reports.iter().map(|r| crate::http::query_component(&r.id)).collect();
format!("{STATUS_URL}?ids={}", ids.join(","))
}
#[cfg(not(target_arch = "wasm32"))]
mod saved {
use super::Submitted;
fn path() -> std::path::PathBuf {
crate::plm::config::app_config_dir().join("submitted-reports.json")
}
pub fn load() -> Vec<Submitted> {
super::parse_saved(&std::fs::read_to_string(path()).unwrap_or_default())
}
pub fn save(reports: &[Submitted]) {
let path = path();
let write = || -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, serde_json::to_vec_pretty(reports).unwrap_or_default())
};
if let Err(e) = write() {
log::warn!("could not save {}: {e}", path.display());
}
}
}
#[cfg(target_arch = "wasm32")]
mod saved {
use super::Submitted;
const KEY: &str = "brep-submitted-reports";
fn storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok().flatten()
}
pub fn load() -> Vec<Submitted> {
let text = storage().and_then(|s| s.get_item(KEY).ok().flatten()).unwrap_or_default();
super::parse_saved(&text)
}
pub fn save(reports: &[Submitted]) {
if let (Some(storage), Ok(text)) = (storage(), serde_json::to_string(reports)) {
let _ = storage.set_item(KEY, &text);
}
}
}
fn parse_saved(text: &str) -> Vec<Submitted> {
serde_json::from_str(text).unwrap_or_default()
}
pub struct ReportFollowup {
started: bool,
reports: Vec<Submitted>,
rx: Option<Receiver<Result<String, String>>>,
next_check: f64,
}
impl Default for ReportFollowup {
fn default() -> Self {
Self::new()
}
}
impl ReportFollowup {
pub fn new() -> Self {
ReportFollowup { started: false, reports: Vec::new(), rx: None, next_check: 0.0 }
}
pub fn start(&mut self, ctx: &egui::Context) {
self.started = true;
self.reports = saved::load();
self.next_check = ctx.input(|i| i.time) + FIRST_CHECK_SECS;
if !self.reports.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(FIRST_CHECK_SECS));
}
}
pub fn record(&mut self, id: String, summary: String) {
if !self.started || self.reports.iter().any(|r| r.id == id) {
return;
}
let submitted_at = rfc3339(crate::recovery::wall_clock() as i64);
self.reports.push(Submitted { id, submitted_at, summary, status: "new".into(), notified: None });
if self.reports.len() > MAX_KEPT {
let extra = self.reports.len() - MAX_KEPT;
self.reports.drain(..extra);
}
saved::save(&self.reports);
}
pub fn reports(&self) -> &[Submitted] {
&self.reports
}
pub fn tick(&mut self, ctx: &egui::Context, state: &mut EngineState) {
if !self.started {
return;
}
if let Some(rx) = &self.rx {
match rx.try_recv() {
Ok(Ok(body)) => {
self.rx = None;
match apply(&mut self.reports, &body) {
Ok(notices) => {
for notice in notices {
state.push_notice_as(NoticeSeverity::Info, notice);
}
saved::save(&self.reports);
}
Err(e) => log::info!("{e}"),
}
}
Ok(Err(e)) => {
self.rx = None;
log::info!("report status: {e}");
}
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.rx = None,
}
}
let now = ctx.input(|i| i.time);
if now < self.next_check {
return;
}
self.next_check = now + CHECK_EVERY_SECS;
ctx.request_repaint_after(std::time::Duration::from_secs_f64(CHECK_EVERY_SECS));
if self.reports.is_empty() || crate::offsite::refusal(FEATURE, STATUS_URL).is_some() {
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
ehttp::fetch(ehttp::Request::get(status_url(&self.reports)), move |result| {
let out = match result {
Ok(resp) if resp.ok => Ok(String::from_utf8_lossy(&resp.bytes).into_owned()),
Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
Err(e) => Err(e),
};
let _ = tx.send(out);
ctx.request_repaint();
});
self.rx = Some(rx);
}
}