app
Parts Search
Public Made by Adomby adom
One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.
master
John Lauer
v1.1.1: Quota guard, copy-only Say it phrases, neutral best-source language
8aa8b86
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
//! Persistent app settings: ~/.adom/parts-search-settings.json.
//!
//! Local-first truth (settings-layout skill): the file on this machine is
//! the store, the UI mirrors it optimistically, and every key is typed by
//! its default so an enum can never persist as a bool.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
/// Hits fetched per vendor per search (3, 5, or 10).
pub default_limit: u32,
/// Drop stock=0 hits before rendering.
pub in_stock_only: bool,
pub vendor_mouser: bool,
pub vendor_digikey: bool,
pub vendor_jlcpcb: bool,
/// "mouser" (Mouser-preferred) or
/// "cheapest" (lowest in-stock unit price wins across all vendors).
pub policy: String,
/// Render vendor product photos (fetched from the vendors' CDNs).
pub show_images: bool,
/// Paint a progress bar on the Hydrogen taskbar button while a search runs.
pub taskbar_progress: bool,
/// Which Adom Desktop box carries the taskbar button ("" = first connected).
pub taskbar_box: String,
/// Show the "Say it to your AI" strip at the top of the page. The X on
/// the strip turns this off; Settings turns it back on.
pub show_say_strip: bool,
}
impl Default for Settings {
fn default() -> Self {
Settings {
default_limit: 5,
in_stock_only: false,
vendor_mouser: true,
vendor_digikey: true,
vendor_jlcpcb: true,
policy: "mouser".into(),
show_images: true,
taskbar_progress: true,
taskbar_box: String::new(),
show_say_strip: true,
}
}
}
pub const KEYS: &[&str] = &[
"defaultLimit", "inStockOnly", "vendorMouser", "vendorDigikey", "vendorJlcpcb",
"policy", "showImages", "taskbarProgress", "taskbarBox", "showSayStrip",
];
pub fn adom_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home).join(".adom")
}
pub fn path() -> PathBuf { adom_dir().join("parts-search-settings.json") }
pub fn load() -> Settings {
let defaults = Settings::default();
let Ok(raw) = std::fs::read_to_string(path()) else { return defaults };
let Ok(v) = serde_json::from_str::<Value>(&raw) else { return defaults };
let mut s = defaults;
let _ = apply(&mut s, &v);
s
}
pub fn save(s: &Settings) -> Result<(), String> {
let dir = adom_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?;
let tmp = path().with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(s).unwrap_or_default())
.map_err(|e| format!("write {}: {}", tmp.display(), e))?;
std::fs::rename(&tmp, path()).map_err(|e| format!("rename: {}", e))
}
/// Apply a partial JSON patch, typing every key by its default. Unknown
/// keys and wrongly-typed values are rejected by name so the caller can
/// report them instead of silently coercing (the kicad "flash persisted
/// as True" bug).
pub fn apply(s: &mut Settings, patch: &Value) -> Result<Vec<String>, String> {
let Some(obj) = patch.as_object() else { return Err("settings patch must be a JSON object".into()) };
let mut changed = vec![];
for (k, v) in obj {
match k.as_str() {
"defaultLimit" => {
let n = v.as_u64().ok_or("defaultLimit must be a number")?;
if ![3, 5, 10].contains(&n) { return Err("defaultLimit must be 3, 5, or 10".into()); }
s.default_limit = n as u32;
}
"inStockOnly" => s.in_stock_only = v.as_bool().ok_or("inStockOnly must be true or false")?,
"vendorMouser" => s.vendor_mouser = v.as_bool().ok_or("vendorMouser must be true or false")?,
"vendorDigikey" => s.vendor_digikey = v.as_bool().ok_or("vendorDigikey must be true or false")?,
"vendorJlcpcb" => s.vendor_jlcpcb = v.as_bool().ok_or("vendorJlcpcb must be true or false")?,
"policy" => {
let p = v.as_str().ok_or("policy must be a string")?;
if p != "mouser" && p != "cheapest" { return Err("policy must be \"mouser\" or \"cheapest\"".into()); }
s.policy = p.to_string();
}
"showImages" => s.show_images = v.as_bool().ok_or("showImages must be true or false")?,
"taskbarProgress" => s.taskbar_progress = v.as_bool().ok_or("taskbarProgress must be true or false")?,
"taskbarBox" => s.taskbar_box = v.as_str().ok_or("taskbarBox must be a string")?.to_string(),
"showSayStrip" => s.show_say_strip = v.as_bool().ok_or("showSayStrip must be true or false")?,
other => return Err(format!("unknown setting \"{}\" (known: {})", other, KEYS.join(", "))),
}
changed.push(k.clone());
}
Ok(changed)
}
/// Parse a CLI string value into the JSON type the key expects.
pub fn parse_cli_value(key: &str, raw: &str) -> Result<Value, String> {
match key {
"defaultLimit" => raw.parse::<u64>().map(Value::from).map_err(|_| "expected a number (3, 5, or 10)".to_string()),
"policy" | "taskbarBox" => Ok(Value::String(raw.to_string())),
k if KEYS.contains(&k) => match raw.to_ascii_lowercase().as_str() {
"on" | "true" | "1" | "yes" => Ok(json!(true)),
"off" | "false" | "0" | "no" => Ok(json!(false)),
_ => Err("expected on or off".into()),
},
other => Err(format!("unknown setting \"{}\" (known: {})", other, KEYS.join(", "))),
}
}
pub fn as_json(s: &Settings) -> Value { serde_json::to_value(s).unwrap_or(json!({})) }
pub fn defaults_json() -> Value { as_json(&Settings::default()) }