1//! The archive: every request lmjtfy has had answered, and the questions 2//! people asked. These are the messages the Worker and the archive's Durable 3//! Object exchange. No I/O and no clock. 4//! 5//! The rule (the user, 2026-10-02): the same request is never sent to the 6//! same model twice. A request is the exact body, and it is kept with its 7//! response under the model it went to. Asking again returns that response. 8//! The archive is also what makes the call, so two visitors asking the same 9//! new thing at once share one request. 10#![forbid(unsafe_code)] 11 12use ask::{Sent, Wanted}; 13use serde::{Deserialize, Serialize}; 14 15pub mod event; 16pub use event::{Event, Origin, Seen as Report}; 17 18/// What the Worker asks the archive. 19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 20pub enum Ask { 21 /// Jev's answer to `wanted` about `input`. `request` is the body the 22 /// Worker prepared and shows on the page; the archive prepares it again 23 /// from `wanted`, and refuses if the two differ. 24 Jev { input: String, wanted: Wanted, request: String, #[serde(default)] pick: Pick }, 25 /// The LLM's reply to `request`, from `model`. 26 Llm { model: String, request: String, #[serde(default)] pick: Pick }, 27 /// `input` was asked and got `answers`, one per question Jev answered. 28 /// `llm` is whether an LLM had to be asked; `listed` is whether the 29 /// question may be shown on the public feed. `who` is the asking 30 /// browser for this question only (`Asker`): a browser that asked it 31 /// before is not counted again. `None` when the browser sent no id. 32 /// `browser` is that browser's id itself, kept beside `who` so the 33 /// owner can see whose row it is. 34 Asked { 35 input: String, 36 answers: Vec<Answer>, 37 llm: bool, 38 listed: bool, 39 #[serde(default)] 40 who: Option<Asker>, 41 #[serde(default)] 42 browser: String, 43 }, 44 /// Someone cloned or pulled `repo` (`lmjtfy.git`) through the site. 45 /// Counted, and told to every open page. 46 Fetched { repo: String, fetch: Fetch }, 47 /// `who` thinks Jev's answer to `input` right (`Up`) or wrong (`Down`). 48 /// The vote is for the answer as it is kept now; the same vote again 49 /// takes it back. Answered with the `Rating` after it. 50 Rate { 51 input: String, 52 who: Asker, 53 vote: Vote, 54 #[serde(default)] 55 browser: String, 56 }, 57 /// The votes on Jev's answer to `input` as it is kept now, and `who`'s. 58 Rating { input: String, who: Option<Asker> }, 59 /// `who` says more about the vote they have on Jev's answer to `input` 60 /// (`comment` is already `clean_comment`ed). Kept beside the vote, as a 61 /// row of its own that is never changed; answered with `Commented`. 62 Comment { 63 input: String, 64 who: Asker, 65 browser: String, 66 comment: String, 67 }, 68 /// What the home page shows from the archive. 69 Home, 70 /// The next `FEED` listed questions asked before `after`, newest first: 71 /// the feed scrolled to its end. 72 Older { after: Cursor }, 73 /// What Jev said to `input`, if it was ever asked and answered. Nothing 74 /// is sent to find out. 75 Answer { input: String }, 76 /// `input` was a question Jev cannot take (the gate said it is not one, 77 /// or no question could be made of it). Kept apart from what was 78 /// answered: it is not counted, listed or toasted, and exists so a link 79 /// to it can say so without asking anyone. 80 Declined { input: String }, 81 /// Whether `input` was declined and never answered. Sends nothing. 82 WasDeclined { input: String }, 83 /// The questions on the feed that what has been typed so far could be 84 /// the start of. It reads the archive and asks nobody anything. 85 Suggest { typed: String }, 86 /// Something happened, to be kept whole (`event.rs`). 87 Event(Box<Event>), 88} 89 90/// The archive object's name: there is one, for the whole site. A Worker 91/// that binds the archive (the site's, the admin's) reaches it by this. 92pub const OBJECT: &str = "everything"; 93 94/// The path of the archive's door for the owner's admin backend. The site's 95/// own Worker never sends a request there; the admin's Worker, which binds 96/// the same object from the owner's account, does. 97pub const ADMIN: &str = "/admin"; 98 99/// What the admin backend asks the archive. 100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 101pub enum Admin { 102 /// Rows of the archive's tables: one statement that only reads 103 /// (`reads_only`), with a value for each `?`. 104 Select { 105 sql: String, 106 #[serde(default)] 107 values: Vec<serde_json::Value>, 108 }, 109 /// The owner's say on whether `input` may be shown on the feed: yes, no, 110 /// or `None` to leave it to the rules again. Jev's own verdict 111 /// (`asked.listed`) is kept beside it, untouched. 112 Moderate { input: String, listed: Option<bool> }, 113 /// The name of a moment in the archive's last thirty days, to restore 114 /// to: `at_ms`, or now. Asking changes nothing. 115 Bookmark { at_ms: Option<f64> }, 116 /// Puts the whole archive back as it was at `bookmark`, losing all 117 /// that was kept since. Answered with the bookmark of the moment just 118 /// before, which undoes it. 119 Restore { bookmark: String }, 120} 121 122/// What the archive answers the admin backend. 123#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 124pub enum Answered { 125 Rows { 126 columns: Vec<String>, 127 rows: Vec<Vec<serde_json::Value>>, 128 /// How many rows SQLite read to answer, which is what Cloudflare 129 /// meters: a statement that returns ten rows may have read every 130 /// row of a table to find them. 131 #[serde(default)] 132 read: u64, 133 }, 134 Done, 135 Refused(String), 136 /// A moment in the archive's history, by name. 137 Bookmark(String), 138} 139 140/// Whether `sql` is one statement that can only read. The admin's door 141/// changes the archive through `Admin`'s own messages, never through SQL: 142/// the schema changes only by a migration, and a response that was kept is 143/// never edited. Text in quotes is not looked at, so a question that says 144/// "delete" can still be searched for; pass such text as a value. 145pub fn reads_only(sql: &str) -> bool { 146 let mut bare = String::new(); 147 let mut quoted = None; 148 for c in sql.chars() { 149 match quoted { 150 Some(quote) if c == quote => quoted = None, 151 Some(_) => {} 152 None if c == '\'' || c == '"' => quoted = Some(c), 153 None => bare.push(c.to_ascii_lowercase()), 154 } 155 } 156 let bare = bare.trim().trim_end_matches(';').trim_end(); 157 let words: Vec<&str> = bare.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').filter(|word| !word.is_empty()).collect(); 158 const WRITES: [&str; 12] = ["insert", "update", "delete", "drop", "alter", "create", "attach", "detach", "pragma", "vacuum", "reindex", "analyze"]; 159 // `replace` is a statement that writes (`REPLACE INTO`) and a function 160 // that does not (`replace(path, '/', ' ')`): only the function is 161 // followed by its arguments. 162 let replaces = bare.match_indices("replace").any(|(at, word)| { 163 let alone = !bare[..at].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_'); 164 let after = &bare[at + word.len()..]; 165 alone && !after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') && !after.trim_start().starts_with('(') 166 }); 167 quoted.is_none() && !bare.contains(';') && matches!(words.first(), Some(&"select") | Some(&"with")) && !replaces && !words.iter().any(|word| WRITES.contains(word)) 168} 169 170/// Which of a request's kept responses an ask wants. A request is sent 171/// again only when a visitor asks for that (`Fresh`, the page's ↻); every 172/// response it ever got is kept, numbered from 1, newest last. 173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 174pub enum Pick { 175 /// The newest kept, or sent if there is none. 176 #[default] 177 Latest, 178 /// Sent now, whatever is kept, and kept as the newest. 179 Fresh, 180 /// That one, if kept; the newest kept otherwise. 181 Version(u32), 182 /// The newest kept, and never sent: `Called::NotKept` if there is none. 183 /// What a page that is stepping through old versions uses for the calls 184 /// after the one it stepped, so looking costs nothing. 185 KeptOnly, 186} 187 188/// Which version of each call a page wants, by `call_id`: the page's 189/// `$pins` signal, `id:3;id:fresh`, later entries winning. A call it does 190/// not name gets the newest kept. 191#[derive(Clone, Debug, Default, PartialEq, Eq)] 192pub struct Pins(pub Vec<(String, Pick)>); 193 194impl Pins { 195 pub fn parse(text: &str) -> Pins { 196 let mut pins: Vec<(String, Pick)> = Vec::new(); 197 for entry in text.split(';') { 198 let Some((id, what)) = entry.trim().split_once(':') else { continue }; 199 if id.len() != 12 || !id.bytes().all(|b| b.is_ascii_hexdigit()) { 200 continue; 201 } 202 let pick = match what { 203 "fresh" => Pick::Fresh, 204 number => match number.parse::<u32>() { 205 Ok(version) if version > 0 => Pick::Version(version), 206 _ => continue, 207 }, 208 }; 209 pins.retain(|(seen, _)| seen != id); 210 pins.push((id.to_owned(), pick)); 211 } 212 Pins(pins) 213 } 214 215 /// Stepping through old versions, and asking nothing new: a version is 216 /// pinned and nothing is to be sent again. Then a call that is not pinned 217 /// is only read, never sent, so looking costs nothing. 218 pub fn browsing(&self) -> bool { 219 self.0.iter().any(|(_, pick)| matches!(pick, Pick::Version(_))) && !self.0.iter().any(|(_, pick)| *pick == Pick::Fresh) 220 } 221 222 /// What to ask the archive for, for the call `id`. 223 pub fn pick(&self, id: &str) -> Pick { 224 match self.0.iter().find(|(seen, _)| seen == id) { 225 Some((_, pick)) => *pick, 226 None if self.browsing() => Pick::KeptOnly, 227 None => Pick::Latest, 228 } 229 } 230} 231 232/// A call, as the page names it for ↻ and ◀ ▶: the first 12 hex digits of 233/// the SHA-256 of where it went and what was sent. 234pub fn call_id(sent_to: &str, request: &str) -> String { 235 use sha2::{Digest, Sha256}; 236 let digest = Sha256::new().chain_update(sent_to.as_bytes()).chain_update([0]).chain_update(request.as_bytes()).finalize(); 237 digest.iter().take(6).map(|byte| format!("{byte:02x}")).collect() 238} 239 240/// A response the archive holds, and the call that got it. 241#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 242pub struct Record { 243 /// The response body exactly as it arrived. 244 pub response: String, 245 pub request_id: Option<String>, 246 pub attempts: u32, 247 /// The round trip of the call that was sent, in milliseconds. 248 pub took_ms: f64, 249 /// When that call was answered, in Unix milliseconds. 250 pub answered_ms: f64, 251 /// Whether this ask is the one that sent it. 252 pub sent_now: bool, 253 /// Which of the request's kept responses this is, from 1, and how many 254 /// there are. 255 #[serde(default = "first")] 256 pub version: u32, 257 #[serde(default = "first")] 258 pub versions: u32, 259} 260 261fn first() -> u32 { 262 1 263} 264 265impl Record { 266 pub fn sent(&self) -> Sent { 267 if self.sent_now { Sent::Now } else { Sent::Before { at_ms: self.answered_ms } } 268 } 269 270 /// The same record, as someone who did not send it sees it. 271 pub fn kept(mut self) -> Self { 272 self.sent_now = false; 273 self 274 } 275} 276 277/// A browser, for one question: the hex SHA-256 of the browser's random id 278/// and the question. It says whether this browser asked this question 279/// before, and nothing else: two questions from one browser give unrelated 280/// values, and the id cannot be had back from one. 281#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 282pub struct Asker(pub String); 283 284/// A browser's view of Jev's answer. 285#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 286pub enum Vote { 287 Up, 288 Down, 289} 290 291/// The votes on one answer, and the asking browser's own, if it voted. 292#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] 293pub struct Rating { 294 pub up: u32, 295 pub down: u32, 296 pub mine: Option<Vote>, 297 /// Whether these are votes on Jev declining the question (up: right to 298 /// decline) and not on an answer (up: right). 299 #[serde(default)] 300 pub declined: bool, 301} 302 303/// The most a comment on a vote may be, in characters. 304pub const MOST_COMMENT: usize = 1000; 305 306/// Why a comment was not kept. 307#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 308pub enum Unsaid { 309 /// Nothing but whitespace. 310 Empty, 311 /// More than `MOST_COMMENT` characters. Refused, not cut: the visitor 312 /// keeps what they wrote and shortens it. 313 TooLong, 314 /// The browser has no vote on this answer to say more about (or no id). 315 NoVote, 316 /// Too many from this visitor this minute. 317 Limit, 318 /// The archive could not be asked or could not write it. 319 Failed, 320} 321 322/// A comment as it is kept: trimmed, line ends made `\n`, and every other 323/// control character dropped. It is the visitor's own words, so it stays 324/// untrusted wherever it is shown: this does not make it safe to put in a 325/// page, and nothing is done to it for that. 326pub fn clean_comment(raw: &str) -> Result<String, Unsaid> { 327 let text: String = raw.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| *c == '\n' || *c == '\t' || !c.is_control()).collect(); 328 let text = text.trim(); 329 match text.chars().count() { 330 0 => Err(Unsaid::Empty), 331 n if n > MOST_COMMENT => Err(Unsaid::TooLong), 332 _ => Ok(text.to_owned()), 333 } 334} 335 336/// Which shared budget refused a call. 337#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] 338pub enum Pot { 339 Jev, 340 Llm, 341} 342 343/// What became of a call. 344#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 345pub enum Called { 346 Answered(Record), 347 /// Sent, or not sendable, and there is no response. Nothing is kept, so 348 /// the same request may be sent again. 349 Failed { error: String, request_id: Option<String>, took_ms: f64 }, 350 /// Not sent: today's budget for it is used up. 351 Spent(Pot), 352 /// Not sent, because the ask was `Pick::KeptOnly` and nothing is kept. 353 NotKept, 354} 355 356/// What Jev said to one question, as it is kept for the feed and for link 357/// previews: the answer in a few words, and the numbers behind it. 358#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 359pub struct Answer { 360 /// What the page prints large: "No?", "Pepperoni.", "28%.". 361 pub headline: String, 362 pub detail: Detail, 363} 364 365/// The numbers behind an answer, by the type of question it answered. 366#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 367pub enum Detail { 368 /// Kept before details were. Only the headline is known. 369 Unknown, 370 /// A yes-or-no: the probability of yes. 371 Noul { p_yes: f64 }, 372 /// A how-likely, which Jev answers as a Noul about the thing itself: the 373 /// probability that it is so. 374 Chance { p: f64 }, 375 /// A pick: every option with its probability, most likely first. 376 Choice { confidence: f64, options: Vec<(String, f64)> }, 377 /// A how-much: every level with its probability, lowest first. 378 Score { score: f64, confidence: f64, levels: Vec<(String, f64)> }, 379} 380 381impl Answer { 382 /// The type and the numbers in one line, as the page prints them under 383 /// an answer. `None` when only the headline was kept. 384 pub fn note(&self) -> Option<String> { 385 Some(match &self.detail { 386 Detail::Unknown => return None, 387 Detail::Noul { p_yes } => format!("Noul · p(yes) = {p_yes:.2}"), 388 Detail::Chance { p } => format!("Noul · p = {p:.2} · Jev thinks that's {}", ask::likelihood(*p)), 389 Detail::Choice { confidence, .. } => format!("Choice · confidence {confidence:.2}"), 390 Detail::Score { score, confidence, levels } => { 391 format!("Score · {score:.1} of {} · confidence {confidence:.2}", levels.len().saturating_sub(1)) 392 } 393 }) 394 } 395 396 /// The `most` likeliest options or levels, likeliest first, for a line 397 /// of text. Empty for a Noul, whose one number is in the note. 398 pub fn spread(&self, most: usize) -> Vec<(&str, f64)> { 399 let mut spread: Vec<(&str, f64)> = match &self.detail { 400 Detail::Choice { options, .. } => options.iter().map(|(label, p)| (label.as_str(), *p)).collect(), 401 Detail::Score { levels, .. } => levels.iter().map(|(label, p)| (label.as_str(), *p)).collect(), 402 _ => Vec::new(), 403 }; 404 spread.sort_by(|a, b| b.1.total_cmp(&a.1)); 405 spread.truncate(most); 406 spread 407 } 408 409 /// Everything in one line: the headline, the note and the spread. 410 pub fn line(&self) -> String { 411 let mut parts = vec![self.headline.clone()]; 412 parts.extend(self.note()); 413 let spread: Vec<String> = self.spread(3).iter().map(|(label, p)| format!("{label} {:.0}%", p * 100.0)).collect(); 414 if !spread.is_empty() { 415 parts.push(spread.join(", ")); 416 } 417 parts.join(" · ") 418 } 419} 420 421/// A kept answer as it is stored: with its numbers, or, from before they 422/// were kept, as the headline alone. 423#[derive(Deserialize)] 424#[serde(untagged)] 425enum Stored { 426 Whole(Answer), 427 Headline(String), 428} 429 430/// Reads the answers a question was stored with, old form or new. 431pub fn stored(text: &str) -> Vec<Answer> { 432 let stored: Vec<Stored> = serde_json::from_str(text).unwrap_or_default(); 433 stored 434 .into_iter() 435 .map(|stored| match stored { 436 Stored::Whole(answer) => answer, 437 Stored::Headline(headline) => Answer { headline, detail: Detail::Unknown }, 438 }) 439 .collect() 440} 441 442/// A question somebody asked, and what Jev said. Nothing about who asked. 443#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 444pub struct Entry { 445 pub input: String, 446 pub answers: Vec<Answer>, 447 /// When it was last asked. 448 pub asked_ms: f64, 449 /// How many times it has been asked and answered. 450 pub times: u32, 451} 452 453impl Entry { 454 /// The answers in a few words each, as the feed prints them. 455 pub fn headlines(&self) -> String { 456 let headlines: Vec<&str> = self.answers.iter().map(|answer| answer.headline.as_str()).collect(); 457 headlines.join(" ") 458 } 459} 460 461/// How many questions are suggested for what has been typed, and how many 462/// characters must be typed before any are. 463pub const SUGGESTED: usize = 5; 464pub const SUGGEST_FROM: usize = 3; 465 466/// Every question the feed may show, ready to be matched against what a 467/// visitor is typing. Only those: a suggestion is shown to strangers, as 468/// the feed is. 469#[derive(Clone, Debug, Default, PartialEq)] 470pub struct Listed(Vec<(String, Entry)>); 471 472impl Listed { 473 pub fn new(entries: impl IntoIterator<Item = Entry>) -> Listed { 474 Listed(entries.into_iter().map(|entry| (entry.input.to_lowercase(), entry)).collect()) 475 } 476 477 /// The questions `typed` could be the start of, whatever its case: 478 /// those that begin with it, then those with a word that begins there, 479 /// the most asked first, and no more than `SUGGESTED`. Nothing until 480 /// `SUGGEST_FROM` characters are typed. 481 pub fn suggest(&self, typed: &str) -> Vec<Entry> { 482 let typed = typed.trim().to_lowercase(); 483 if typed.chars().count() < SUGGEST_FROM { 484 return Vec::new(); 485 } 486 let within = format!(" {typed}"); 487 let mut found: Vec<(u8, &Entry)> = self 488 .0 489 .iter() 490 .filter_map(|(lower, entry)| if lower.starts_with(&typed) { Some((0, entry)) } else if lower.contains(&within) { Some((1, entry)) } else { None }) 491 .collect(); 492 found.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.times.cmp(&a.1.times)).then(b.1.asked_ms.total_cmp(&a.1.asked_ms)).then(a.1.input.cmp(&b.1.input))); 493 found.into_iter().take(SUGGESTED).map(|(_, entry)| entry.clone()).collect() 494 } 495} 496 497/// How many questions the feed shows as asked lately, and as most asked. 498pub const FEED: u32 = 50; 499pub const MOST: u32 = 5; 500 501/// The archive in numbers. 502#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] 503pub struct Stats { 504 /// Different questions answered. 505 pub questions: u32, 506 /// Times a question was asked and answered, repeats included. 507 pub asks: u32, 508 /// Of the different questions, those Jev answered with no LLM. 509 pub no_llm: u32, 510 /// Requests that went out and were answered. 511 pub sent: u32, 512 /// Requests that did not go out, because their response was kept. 513 pub kept: u32, 514 /// Clones and pulls of the code through the site. 515 #[serde(default)] 516 pub clones: u32, 517 #[serde(default)] 518 pub pulls: u32, 519} 520 521impl Stats { 522 /// The share of questions answered with no LLM, as a whole percentage. 523 pub fn no_llm_percent(&self) -> Option<u32> { 524 (self.questions > 0).then(|| (f64::from(self.no_llm) * 100.0 / f64::from(self.questions)).round() as u32) 525 } 526} 527 528/// What the home page shows from the archive. The lists hold only questions 529/// that may be shown publicly; the numbers count every question. 530#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] 531pub struct Home { 532 /// Newest first. 533 pub lately: Vec<Entry>, 534 /// Asked more than once, most asked first. 535 pub most: Vec<Entry>, 536 pub stats: Stats, 537} 538 539/// Where the feed was scrolled to: the last question shown. Questions are 540/// listed newest first, ties broken by the question itself, so the next page 541/// starts strictly after this one and none is shown twice or skipped. 542#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 543pub struct Cursor { 544 pub asked_ms: f64, 545 pub input: String, 546} 547 548impl Entry { 549 /// Where the feed is once this entry is the last shown. 550 pub fn cursor(&self) -> Cursor { 551 Cursor { asked_ms: self.asked_ms, input: self.input.clone() } 552 } 553} 554 555/// What a fetch through the clone proxy was. Git's upload-pack request 556/// names the commits wanted; a pull also names the ones it has. 557#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] 558pub enum Fetch { 559 Clone, 560 Pull, 561} 562 563impl Fetch { 564 /// Its name in the archive's `counts`, for one repository. 565 pub fn counted(self, repo: &str) -> String { 566 match self { 567 Fetch::Clone => format!("clone:{repo}"), 568 Fetch::Pull => format!("pull:{repo}"), 569 } 570 } 571} 572 573/// What the archive pushes to every open page over its socket, as JSON: 574/// `{"online": 3}` or `{"toast": "<html>"}`. 575#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 576#[serde(rename_all = "lowercase")] 577pub enum Live { 578 /// Pages open now. 579 Online(u32), 580 /// Something happened, as markup to show for a moment. 581 Toast(String), 582 /// The build the site is now (`LMJTFY_BUILD`), told to each page as it 583 /// connects. A page built from another offers a reload: a deploy 584 /// restarts the object, every page reconnects, and each hears this. 585 Build(String), 586 /// Elements that changed, each with an `id`: a page that has an element 587 /// with that id replaces it. An element with `data-prepend="<id>"` 588 /// instead puts its children at the top of that element, each removing 589 /// any element with its id first: a question asked again moves to the 590 /// top of the feed, and what a page has scrolled in below stays. The 591 /// activity feeds stay current this way, with no refresh. 592 Patch(String), 593} 594 595/// Where an open page connected from, as Cloudflare placed the request: a 596/// country code and a city, either unknown. It is kept on the page's socket 597/// while it is open, for the online list. 598#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 599pub struct Place { 600 pub country: Option<String>, 601 pub city: Option<String>, 602} 603 604impl Place { 605 /// From what the Worker passed on, kept only if it looks like a place: 606 /// a country is two letters, a city a short line of text. 607 pub fn new(country: Option<&str>, city: Option<&str>) -> Place { 608 let country = country 609 .map(str::trim) 610 .filter(|code| code.len() == 2 && code.bytes().all(|b| b.is_ascii_alphabetic())) 611 .map(str::to_ascii_uppercase) 612 // Cloudflare's own codes for Tor and for unknown are not places. 613 .filter(|code| code != "T1" && code != "XX"); 614 let city = city 615 .map(str::trim) 616 .filter(|city| !city.is_empty() && city.chars().count() <= 64 && !city.chars().any(char::is_control)) 617 .map(str::to_owned); 618 Place { country, city } 619 } 620 621 /// The country's flag: its two letters as regional indicator symbols. 622 pub fn flag(&self) -> Option<String> { 623 let code = self.country.as_deref()?; 624 code.chars().map(|letter| char::from_u32(0x1F1E6 + (letter as u32 - 'A' as u32))).collect() 625 } 626 627 /// `Austin, US`, `US`, or `somewhere`. 628 pub fn label(&self) -> String { 629 match (&self.city, &self.country) { 630 (Some(city), Some(country)) => format!("{city}, {country}"), 631 (Some(city), None) => city.clone(), 632 (None, Some(country)) => country.clone(), 633 (None, None) => "somewhere".to_owned(), 634 } 635 } 636} 637 638/// A header value as the Worker percent-encoded it (`+` for a space), back 639/// to text. `None` if it is not UTF-8 or a `%` is not followed by two hex 640/// digits. 641pub fn percent_decoded(text: &str) -> Option<String> { 642 let mut bytes = Vec::with_capacity(text.len()); 643 let mut rest = text.bytes(); 644 while let Some(byte) = rest.next() { 645 match byte { 646 b'+' => bytes.push(b' '), 647 b'%' => { 648 let hex = [rest.next()?, rest.next()?]; 649 bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?); 650 } 651 byte => bytes.push(byte), 652 } 653 } 654 String::from_utf8(bytes).ok() 655} 656 657/// What the archive keeps on an open page's socket: where it is, and whether 658/// a Worker that passes places on sent it. A page that reconnects while a 659/// deploy is still reaching Cloudflare's edge can come through the Worker 660/// from before, which passes no place; `passed` is false for it, and the 661/// archive asks it to reconnect once the deploy has settled. 662#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] 663pub struct Seen { 664 pub place: Place, 665 pub passed: bool, 666 /// When it connected, in Unix milliseconds. 667 pub at_ms: f64, 668 /// The row of `events` that says it connected, which the row that says 669 /// it left copies who it was from. 670 #[serde(default)] 671 pub event: Option<f64>, 672} 673 674/// How long after connecting an unplaced page is asked to reconnect: long 675/// enough for a deploy to reach the whole edge. 676pub const REPLACE_AFTER_MS: f64 = 10_000.0; 677 678impl Seen { 679 /// Whether this page should reconnect to be placed: it came through a 680 /// Worker that passes no place, long enough ago. A socket with nothing 681 /// kept on it (`None`) was accepted by code from before places. 682 pub fn stale(seen: Option<&Seen>, now_ms: f64) -> bool { 683 seen.is_none_or(|seen| !seen.passed && now_ms - seen.at_ms >= REPLACE_AFTER_MS) 684 } 685} 686 687/// How many open pages are in each place, most first, then by name. 688pub fn places(open: impl IntoIterator<Item = Place>) -> Vec<(Place, u32)> { 689 let mut counted: std::collections::BTreeMap<Place, u32> = std::collections::BTreeMap::new(); 690 for place in open { 691 *counted.entry(place).or_default() += 1; 692 } 693 let mut counted: Vec<(Place, u32)> = counted.into_iter().collect(); 694 counted.sort_by(|(a, n), (b, m)| m.cmp(n).then_with(|| a.label().cmp(&b.label()))); 695 counted 696} 697 698/// What the archive says back. 699#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 700pub enum Told { 701 Called(Called), 702 Noted, 703 Home(Home), 704 Answer(Option<Entry>), 705 WasDeclined(bool), 706 Older(Vec<Entry>), 707 Suggested(Vec<Entry>), 708 /// `None` if the question was never answered, so there is nothing to 709 /// vote on. 710 Rating(Option<Rating>), 711 /// What became of a `Comment`. 712 Commented(Result<(), Unsaid>), 713} 714 715/// A moment as `YYYY-MM-DD HH:MM UTC`. 716pub fn when(unix_ms: f64) -> String { 717 let minutes = (unix_ms / 60_000.0).floor().max(0.0) as u64; 718 format!("{} {:02}:{:02} UTC", budget::date(budget::day(unix_ms)), minutes / 60 % 24, minutes % 60) 719} 720 721#[cfg(test)] 722mod tests;