Files
portal/src/trainer/card.rs
T
Bendik LynghaugandClaude Opus 5 44c7ee6870
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m9s
Publish release / publish (push) Successful in 1m52s
Render an Apple configuration profile from the server's PACC document
iOS is the one client none of the mail autoconfiguration reaches: Apple
Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so
adding an account means the device probing for ports - and a probe that
meets a filtered port waits out a TCP timeout and reports the account as
unverifiable, which the person reads as a wrong password.

A profile states the hosts and the ports instead, and carries mail,
calendars and contacts in one install. It invents none of them: site.yaml
names a PACC document - the standard defined to be fetched from the mail
domain itself - and every host, port and collection path comes from
there, so the server is described in one place and the profile follows it.

The document holds no address, username or password. iOS asks for those
at install, which means one profile serves everyone with an account and
it can be fetched by anyone: it says no more than PACC already says in
public. Identifiers are derived from the site and the payload rather than
drawn at random, because iOS replaces a profile whose ids it knows and
stacks a duplicate when they differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:18:46 +02:00

129 lines
4.4 KiB
Rust

//! An alternative as the visitor meets it, in text.
//!
//! Portal renders a card: the heading, the description, a button, one
//! or two encouragements, and for each section an icon, a title, a
//! line of guidance and the fields with their labels and placeholders.
//! A visitor is oriented by all of it, not the heading alone. The
//! answering visitor and `rate` both read this rendering, so the
//! scored page is the served page; the gutted twin keeps the card's
//! shape and none of its words.
use serde_json::Value;
pub fn s(v: &Value) -> &str {
v.as_str().unwrap_or_default()
}
/// One option on a page. `full` is the card as rendered; otherwise the
/// heading alone, which is what a visitor who reads nothing else sees.
pub fn alternative(alt: &Value, full: bool) -> String {
let mut out = format!("- {}", s(&alt["name"]));
if !full {
out.push('\n');
return out;
}
let d = s(&alt["description"]).trim();
if !d.is_empty() {
out.push_str(&format!(": {d}"));
}
out.push('\n');
if let Some(button) = alt["consequence"]
.as_array()
.and_then(|c| c.first())
.and_then(Value::as_str)
{
out.push_str(&format!(" Button: {button}\n"));
}
for e in alt["encouragements"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
out.push_str(&format!(" {e}\n"));
}
for f in alt["features"].as_array().into_iter().flatten() {
let (name, desc, icon) = (
s(&f["name"]).trim(),
s(&f["description"]).trim(),
s(&f["icon"]).trim(),
);
let mut line = String::from(" ");
if !icon.is_empty() {
line.push_str(&format!(
"[icon {}] ",
icon.rsplit(':').next().unwrap_or(icon)
));
}
if !name.is_empty() {
line.push_str(name);
}
if !desc.is_empty() {
line.push_str(&format!(
"{}{desc}",
if name.is_empty() { "" } else { ": " }
));
}
if !line.trim().is_empty() {
out.push_str(line.trim_end());
out.push('\n');
}
let fields: Vec<String> = f["requirements"]
.as_array()
.into_iter()
.flatten()
.map(|r| {
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
let hint = s(&r["placeholder"]).trim();
let opt = if r["optional"] == true {
" (optional)"
} else {
""
};
if hint.is_empty() {
format!("{label}{opt}")
} else {
format!("{label}{opt} e.g. {hint}")
}
})
.collect();
if !fields.is_empty() {
out.push_str(&format!(" Asks for: {}\n", fields.join("; ")));
}
}
out
}
/// The page around its options: what it asks and says of itself.
pub fn page_head(page: &Value) -> String {
let mut out = format!("{}\n", s(&page["name"]));
let d = s(&page["description"]).trim();
if !d.is_empty() {
out.push_str(&format!("{d}\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_card_reads_as_the_visitor_sees_it_and_a_heading_alone_is_a_heading_alone() {
let alt = json!({
"name": "Get a price", "description": "For owners.",
"consequence": ["Send it"], "encouragements": ["We answer within two days."],
"features": [{"name": "Your brief", "icon": "lucide:file-text", "description": "A few lines are enough.",
"requirements": [{"name": "email", "label": "Email", "placeholder": "you@example.no"},
{"name": "address", "label": "Address", "optional": true}]}]
});
let full = alternative(&alt, true);
assert!(full.starts_with("- Get a price: For owners.\n"));
assert!(full.contains("Button: Send it") && full.contains("We answer within two days."));
assert!(full.contains("[icon file-text] Your brief: A few lines are enough."));
assert!(full.contains("Asks for: Email e.g. you@example.no; Address (optional)"));
assert_eq!(alternative(&alt, false), "- Get a price\n");
}
}