//! Push an article to a Ratta Supernote via the local Browse & Access API. //! //! Config lives at `/supernote.toml`: //! //! ```toml //! host = "192.168.0.18" //! remote_dir = "Document/balanced_breakfast" //! # passcode = "..." //! ``` //! //! Missing / unparseable file yields a `bad_request` from the push command, //! so the frontend can surface a helpful message directing the user to the //! config path. use std::path::Path; use std::sync::Arc; use bb_db::ItemId; use serde::Deserialize; use tauri::State; use tracing::instrument; use crate::commands::error::ApiError; use crate::state::AppState; const CONFIG_FILE: &str = "supernote.toml"; const DEFAULT_REMOTE_DIR: &str = "Document/balanced_breakfast"; const MIN_BODY_CHARS: usize = 200; #[derive(Deserialize)] struct SupernoteFile { host: String, #[serde(default)] remote_dir: Option, #[serde(default)] passcode: Option, } /// True iff the item body is worth converting to PDF and pushing. /// /// The bar is intentionally low: any non-empty body over `MIN_BODY_CHARS` /// characters qualifies. Short bodies (bare HN titles, RSS-summary blurbs) /// aren't worth a page turn. pub fn is_eligible(body: Option<&str>) -> bool { body.map(|b| b.trim().chars().count() >= MIN_BODY_CHARS) .unwrap_or(false) } fn load(data_dir: &Path) -> Result { let path = data_dir.join(CONFIG_FILE); let raw = std::fs::read_to_string(&path).map_err(|_| { ApiError::bad_request(format!( "Supernote not configured. Create {} with `host = \"\"`.", path.display() )) })?; toml::from_str::(&raw) .map_err(|e| ApiError::bad_request(format!("Malformed {CONFIG_FILE}: {e}"))) } /// Render the item's HTML body to PDF and upload to the configured Supernote. #[tauri::command] #[instrument(skip_all)] pub async fn push_item_to_supernote( state: State<'_, Arc>, id: String, ) -> Result<(), ApiError> { let item_id: ItemId = id .parse() .map_err(|_| ApiError::bad_request("Invalid item ID"))?; let db = state.orchestrator.database(); let item = db .items() .get(item_id) .await? .ok_or_else(|| ApiError::not_found(format!("Item {} not found", id)))?; let body = item .body .clone() .ok_or_else(|| ApiError::bad_request("Item has no body content to push"))?; if !is_eligible(Some(&body)) { return Err(ApiError::bad_request( "Item body is too short to be worth pushing", )); } let cfg = load(&state.data_dir)?; let remote_dir = cfg .remote_dir .unwrap_or_else(|| DEFAULT_REMOTE_DIR.to_string()); let title = item.title.clone().unwrap_or_else(|| item.bite_text.clone()); let url = item.url.clone(); let filename = pdf_filename(&title, &item.published_at); let bytes = tokio::task::spawn_blocking(move || bb_pdf::render_article(&body, &title, url.as_deref())) .await .map_err(|e| ApiError::internal(format!("Render task join error: {e}")))? .map_err(|e| ApiError::internal(format!("PDF render failed: {e}")))?; let host = cfg.host.clone(); let passcode = cfg.passcode.clone(); tokio::task::spawn_blocking(move || { let mut sn = supernote_push::Supernote::at(&host); if let Some(pc) = passcode { sn = sn.with_passcode(pc); } sn.push_pdf(&remote_dir, &filename, &bytes) }) .await .map_err(|e| ApiError::internal(format!("Push task join error: {e}")))? .map_err(|e| ApiError::plugin(format!("Supernote push failed: {e}")))?; Ok(()) } fn pdf_filename(title: &str, published_at: &str) -> String { let date = published_at .split_whitespace() .next() .unwrap_or(published_at); let slug: String = title .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' } }) .collect(); let slug: String = slug .split('-') .filter(|s| !s.is_empty()) .collect::>() .join("-"); let slug = if slug.is_empty() { "article" } else { &slug }; let slug: String = slug.chars().take(60).collect(); format!("{date}_{slug}.pdf") } #[cfg(test)] mod tests { use super::*; #[test] fn eligibility_rejects_short_and_missing() { assert!(!is_eligible(None)); assert!(!is_eligible(Some(""))); assert!(!is_eligible(Some("short"))); assert!(!is_eligible(Some(&"x".repeat(199)))); } #[test] fn eligibility_accepts_prose() { assert!(is_eligible(Some(&"lorem ipsum ".repeat(50)))); } #[test] fn filename_is_sluggy_and_dated() { let f = pdf_filename("Why E-Ink Rewards Slow Reading!", "2026-07-19 08:00:00"); assert_eq!(f, "2026-07-19_why-e-ink-rewards-slow-reading.pdf"); } #[test] fn filename_falls_back_when_title_is_symbols() { let f = pdf_filename("///", "2026-07-19"); assert_eq!(f, "2026-07-19_article.pdf"); } }