Skip to main content

max / balanced_breakfast

5.1 KB · 172 lines History Blame Raw
1 //! Push an article to a Ratta Supernote via the local Browse & Access API.
2 //!
3 //! Config lives at `<app_data_dir>/supernote.toml`:
4 //!
5 //! ```toml
6 //! host = "192.168.0.18"
7 //! remote_dir = "Document/balanced_breakfast"
8 //! # passcode = "..."
9 //! ```
10 //!
11 //! Missing / unparseable file yields a `bad_request` from the push command,
12 //! so the frontend can surface a helpful message directing the user to the
13 //! config path.
14
15 use std::path::Path;
16 use std::sync::Arc;
17
18 use bb_db::ItemId;
19 use serde::Deserialize;
20 use tauri::State;
21 use tracing::instrument;
22
23 use crate::commands::error::ApiError;
24 use crate::state::AppState;
25
26 const CONFIG_FILE: &str = "supernote.toml";
27 const DEFAULT_REMOTE_DIR: &str = "Document/balanced_breakfast";
28 const MIN_BODY_CHARS: usize = 200;
29
30 #[derive(Deserialize)]
31 struct SupernoteFile {
32 host: String,
33 #[serde(default)]
34 remote_dir: Option<String>,
35 #[serde(default)]
36 passcode: Option<String>,
37 }
38
39 /// True iff the item body is worth converting to PDF and pushing.
40 ///
41 /// The bar is intentionally low: any non-empty body over `MIN_BODY_CHARS`
42 /// characters qualifies. Short bodies (bare HN titles, RSS-summary blurbs)
43 /// aren't worth a page turn.
44 pub fn is_eligible(body: Option<&str>) -> bool {
45 body.map(|b| b.trim().chars().count() >= MIN_BODY_CHARS)
46 .unwrap_or(false)
47 }
48
49 fn load(data_dir: &Path) -> Result<SupernoteFile, ApiError> {
50 let path = data_dir.join(CONFIG_FILE);
51 let raw = std::fs::read_to_string(&path).map_err(|_| {
52 ApiError::bad_request(format!(
53 "Supernote not configured. Create {} with `host = \"<ip>\"`.",
54 path.display()
55 ))
56 })?;
57 toml::from_str::<SupernoteFile>(&raw)
58 .map_err(|e| ApiError::bad_request(format!("Malformed {CONFIG_FILE}: {e}")))
59 }
60
61 /// Render the item's HTML body to PDF and upload to the configured Supernote.
62 #[tauri::command]
63 #[instrument(skip_all)]
64 pub async fn push_item_to_supernote(
65 state: State<'_, Arc<AppState>>,
66 id: String,
67 ) -> Result<(), ApiError> {
68 let item_id: ItemId = id
69 .parse()
70 .map_err(|_| ApiError::bad_request("Invalid item ID"))?;
71 let db = state.orchestrator.database();
72 let item = db
73 .items()
74 .get(item_id)
75 .await?
76 .ok_or_else(|| ApiError::not_found(format!("Item {} not found", id)))?;
77
78 let body = item
79 .body
80 .clone()
81 .ok_or_else(|| ApiError::bad_request("Item has no body content to push"))?;
82 if !is_eligible(Some(&body)) {
83 return Err(ApiError::bad_request(
84 "Item body is too short to be worth pushing",
85 ));
86 }
87
88 let cfg = load(&state.data_dir)?;
89 let remote_dir = cfg
90 .remote_dir
91 .unwrap_or_else(|| DEFAULT_REMOTE_DIR.to_string());
92 let title = item.title.clone().unwrap_or_else(|| item.bite_text.clone());
93 let url = item.url.clone();
94 let filename = pdf_filename(&title, &item.published_at);
95
96 let bytes =
97 tokio::task::spawn_blocking(move || bb_pdf::render_article(&body, &title, url.as_deref()))
98 .await
99 .map_err(|e| ApiError::internal(format!("Render task join error: {e}")))?
100 .map_err(|e| ApiError::internal(format!("PDF render failed: {e}")))?;
101
102 let host = cfg.host.clone();
103 let passcode = cfg.passcode.clone();
104 tokio::task::spawn_blocking(move || {
105 let mut sn = supernote_push::Supernote::at(&host);
106 if let Some(pc) = passcode {
107 sn = sn.with_passcode(pc);
108 }
109 sn.push_pdf(&remote_dir, &filename, &bytes)
110 })
111 .await
112 .map_err(|e| ApiError::internal(format!("Push task join error: {e}")))?
113 .map_err(|e| ApiError::plugin(format!("Supernote push failed: {e}")))?;
114
115 Ok(())
116 }
117
118 fn pdf_filename(title: &str, published_at: &str) -> String {
119 let date = published_at
120 .split_whitespace()
121 .next()
122 .unwrap_or(published_at);
123 let slug: String = title
124 .chars()
125 .map(|c| {
126 if c.is_ascii_alphanumeric() {
127 c.to_ascii_lowercase()
128 } else {
129 '-'
130 }
131 })
132 .collect();
133 let slug: String = slug
134 .split('-')
135 .filter(|s| !s.is_empty())
136 .collect::<Vec<_>>()
137 .join("-");
138 let slug = if slug.is_empty() { "article" } else { &slug };
139 let slug: String = slug.chars().take(60).collect();
140 format!("{date}_{slug}.pdf")
141 }
142
143 #[cfg(test)]
144 mod tests {
145 use super::*;
146
147 #[test]
148 fn eligibility_rejects_short_and_missing() {
149 assert!(!is_eligible(None));
150 assert!(!is_eligible(Some("")));
151 assert!(!is_eligible(Some("short")));
152 assert!(!is_eligible(Some(&"x".repeat(199))));
153 }
154
155 #[test]
156 fn eligibility_accepts_prose() {
157 assert!(is_eligible(Some(&"lorem ipsum ".repeat(50))));
158 }
159
160 #[test]
161 fn filename_is_sluggy_and_dated() {
162 let f = pdf_filename("Why E-Ink Rewards Slow Reading!", "2026-07-19 08:00:00");
163 assert_eq!(f, "2026-07-19_why-e-ink-rewards-slow-reading.pdf");
164 }
165
166 #[test]
167 fn filename_falls_back_when_title_is_symbols() {
168 let f = pdf_filename("///", "2026-07-19");
169 assert_eq!(f, "2026-07-19_article.pdf");
170 }
171 }
172