Skip to main content

max / balanced_breakfast

Remove Supernote push, whose library is decommissioned supernote-push was decommissioned on 2026-08-04 when the reMarkable displaced the Supernote as the daily e-ink device, and is archived today. Leaving the feature in place would point a shipping app at an archived repo, which is the loose wire the archiving is meant to remove. Wider than the filed scope, because the feature was wired further in than the task recorded. Removed: the `supernote` feature and both dependency entries, commands/supernote.rs, the push_item_to_supernote handler, the supernoteEligible DTO field and its two cfg'd predicates, and the frontend button plus its API binding. Two corrections to the record while here. The feature was described as off by default and unused; bento.toml turned it on for every release, so this is a user-facing removal and gets a changelog entry rather than a silent one. And bb-pdf existed only to render articles for it, so src-tauri's now unused dependency on it is dropped; the crate itself stays in the workspace pending a decision on its fate.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 18:30 UTC
Signed with PGP, not checked
Commit: 0371ddfad9f4ed8779015caea9695e163cd5118b
Parent: 0d6d35a
11 files changed, +9 insertions, -294 deletions
@@ -4,6 +4,11 @@
4 4
5 5 Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 6
7 + ## [Unreleased]
8 +
9 + ### Removed
10 + - Supernote push. The `supernote` build feature, the "Send to Supernote" button, and the `supernote-push` dependency are all gone. The library behind it was decommissioned when the reMarkable displaced the Supernote as the daily e-ink device. Releases built before this carried the feature on; releases after it will not.
11 +
7 12 ## [0.3.0] — 2026-03-28
8 13
9 14 First beta-ready release.
M Cargo.lock +4 -51
@@ -440,7 +440,6 @@
440 440 "bb-db",
441 441 "bb-feed",
442 442 "bb-interface",
443 - "bb-pdf",
444 443 "chrono",
445 444 "futures",
446 445 "makeover",
@@ -455,7 +454,6 @@
455 454 "serde_json",
456 455 "sha2 0.11.0",
457 456 "sqlx",
458 - "supernote-push",
459 457 "synckit-client",
460 458 "tagtree",
461 459 "tauri",
@@ -2663,16 +2661,6 @@
2663 2661 "icu_properties",
2664 2662 ]
2665 2663
2666 - [[package]]
2667 - name = "if-addrs"
2668 - version = "0.15.0"
2669 - source = "registry+https://github.com/rust-lang/crates.io-index"
2670 - checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
2671 - dependencies = [
2672 - "libc",
2673 - "windows-sys 0.61.2",
2674 - ]
2675 -
2676 2664 [[package]]
2677 2665 name = "include_dir"
2678 2666 version = "0.7.4"
@@ -3199,21 +3187,6 @@
3199 3187 "digest 0.11.3",
3200 3188 ]
3201 3189
3202 - [[package]]
3203 - name = "mdns-sd"
3204 - version = "0.20.2"
3205 - source = "registry+https://github.com/rust-lang/crates.io-index"
3206 - checksum = "f18d8ec9d1869796fb2910d95f4d957072df0b6a22e247a1d760d8b4c805e17a"
3207 - dependencies = [
3208 - "fastrand",
3209 - "flume",
3210 - "if-addrs",
3211 - "log",
3212 - "mio",
3213 - "socket-pktinfo",
3214 - "socket2",
3215 - ]
3216 -
3217 3190 [[package]]
3218 3191 name = "memchr"
3219 3192 version = "2.8.0"
@@ -3258,7 +3231,6 @@
3258 3231 checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
3259 3232 dependencies = [
3260 3233 "libc",
3261 - "log",
3262 3234 "wasi 0.11.1+wasi-snapshot-preview1",
3263 3235 "windows-sys 0.61.2",
3264 3236 ]
@@ -5389,17 +5361,6 @@
5389 5361 "version_check",
5390 5362 ]
5391 5363
5392 - [[package]]
5393 - name = "socket-pktinfo"
5394 - version = "0.4.0"
5395 - source = "registry+https://github.com/rust-lang/crates.io-index"
5396 - checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
5397 - dependencies = [
5398 - "libc",
5399 - "socket2",
5400 - "windows-sys 0.61.2",
5401 - ]
5402 -
5403 5364 [[package]]
5404 5365 name = "socket2"
5405 5366 version = "0.6.2"
@@ -5748,18 +5709,6 @@
5748 5709 source = "registry+https://github.com/rust-lang/crates.io-index"
5749 5710 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
5750 5711
5751 - [[package]]
5752 - name = "supernote-push"
5753 - version = "0.1.0"
5754 - dependencies = [
5755 - "mdns-sd",
5756 - "percent-encoding",
5757 - "serde",
5758 - "serde_json",
5759 - "thiserror 2.0.18",
5760 - "ureq",
5761 - ]
5762 -
5763 5712 [[package]]
5764 5713 name = "swift-rs"
5765 5714 version = "1.0.7"
@@ -8220,6 +8169,10 @@
8220 8169 "winnow 1.0.4",
8221 8170 ]
8222 8171
8172 + [[patch.unused]]
8173 + name = "supernote-push"
8174 + version = "0.1.0"
8175 +
8223 8176 [[patch.unused]]
8224 8177 name = "kberg"
8225 8178 version = "0.1.0"
M Cargo.toml -1
@@ -67,7 +67,6 @@
67 67 bb-pdf = { path = "crates/bb-pdf" }
68 68
69 69 # External sibling crates (shared under ~/Code/Libraries)
70 - supernote-push = { git = "https://makenot.work/git/max/supernote-push.git" }
71 70 synckit-client = { git = "https://makenot.work/git/max/synckit.git" }
72 71 synckit-config = { git = "https://makenot.work/git/max/synckit.git" }
73 72 tauri = "2.10.2"
M bento.toml -5
@@ -9,11 +9,6 @@
9 9 # Cargo.toml, whose [workspace.package] version tracks the library crates.
10 10 version_path = "src-tauri/tauri.conf.json"
11 11
12 - # LAN push to a Ratta Supernote. Off in a default build (most readers do not
13 - # own one, and it pulls in an HTTP client and an mDNS responder); on in every
14 - # release. Recipes read this via feature_flags().
15 - features = ["supernote"]
16 -
17 12 # macOS/iOS omitted until BB gains Developer ID signing + notarization infra;
18 13 # only linux + windows recipes exist today.
19 14 targets = ["linux/x86_64", "linux/aarch64", "windows/x86_64"]
@@ -25,10 +25,6 @@
25 25 bb-core.workspace = true
26 26 bb-feed.workspace = true
27 27 bb-db.workspace = true
28 - bb-pdf.workspace = true
29 -
30 - # Supernote push (LAN e-ink upload)
31 - supernote-push = { workspace = true, optional = true }
32 28
33 29 # Tauri
34 30 tauri = { version = "2.10.2", features = [] }
@@ -110,10 +106,6 @@
110 106 [features]
111 107 default = ["custom-protocol"]
112 108 custom-protocol = ["tauri/custom-protocol"]
113 - # Push an article to a Ratta Supernote over the LAN. Off by default: most
114 - # readers do not own one, and enabling it pulls an HTTP client and an mDNS
115 - # responder in for a device they will never point at.
116 - supernote = ["dep:supernote-push"]
117 109
118 110 [lints]
119 111 workspace = true
@@ -137,8 +137,6 @@
137 137 commands::is_bookmarked,
138 138 commands::get_bookmark_count,
139 139 commands::export_bookmark_html,
140 - #[cfg(feature = "supernote")]
141 - commands::push_item_to_supernote,
142 140 ])
143 141 }
144 142
@@ -69,13 +69,6 @@
69 69 * @returns {Promise<number>} Count of items marked read.
70 70 */
71 71 markAllRead: (sourceId) => invoke('mark_all_read', { sourceId: sourceId || null }),
72 - /**
73 - * Render the item as PDF and push it to the configured Supernote.
74 - * Fails if the item has no eligible body or Supernote is unconfigured.
75 - * @param {string} id - Item external_id.
76 - * @returns {Promise<void>}
77 - */
78 - pushToSupernote: (id) => invoke('push_item_to_supernote', { id }),
79 72 },
80 73
81 74 // Plugins: Rhai busser plugins
@@ -132,14 +132,6 @@
132 132 actions.appendChild(openBtn);
133 133 }
134 134
135 - if (item.supernoteEligible) {
136 - const snBtn = document.createElement('button');
137 - snBtn.className = 'button';
138 - snBtn.textContent = 'Send to Supernote';
139 - snBtn.addEventListener('click', pushToSupernote);
140 - actions.appendChild(snBtn);
141 - }
142 -
143 135 // Plugin-declared custom action buttons
144 136 if (item.actions && item.actions.length > 0) {
145 137 for (const action of item.actions) {
@@ -234,22 +226,6 @@
234 226 }
235 227 }
236 228
237 - /**
238 - * Push the current item to the configured Supernote as a PDF.
239 - * Surfaces backend errors (missing config, empty body, unreachable device)
240 - * via a toast.
241 - */
242 - async function pushToSupernote() {
243 - if (!currentItem) return;
244 - BB.ui.showToast('Rendering and pushing to Supernote...');
245 - try {
246 - await BB.api.items.pushToSupernote(currentItem.id);
247 - BB.ui.showToast('Sent to Supernote', 'success');
248 - } catch (err) {
249 - BB.ui.showToast('Supernote push failed: ' + getErrorMessage(err), 'error');
250 - }
251 - }
252 -
253 229 /**
254 230 * Open the current item's URL in the system browser via Tauri shell API.
255 231 * No-op if no item is selected or the item has no URL.
@@ -99,11 +99,6 @@
99 99 pub is_read: bool,
100 100 /// Whether the user has starred/favourited this item.
101 101 pub is_starred: bool,
102 - /// Whether the body is worth converting to PDF and pushing to a Supernote.
103 - /// Frontend uses this to gate the "Send to Supernote" button. Always false
104 - /// when the `supernote` feature is off, so the button stays hidden without
105 - /// the frontend needing to know how the app was built.
106 - pub supernote_eligible: bool,
107 102 }
108 103
109 104 /// Paginated list of item summaries.
@@ -181,22 +176,6 @@
181 176 }
182 177
183 178 /// Convert a database feed item to a full detail response.
184 - /// Whether an item is worth pushing to a Supernote.
185 - ///
186 - /// Defined in both builds so `ItemDetailResponse` keeps one shape: without the
187 - /// `supernote` feature this is always false, the frontend's eligibility check
188 - /// fails, and the button never renders. Gating the field itself would change
189 - /// the JSON the frontend parses depending on how the app was compiled.
190 - #[cfg(feature = "supernote")]
191 - fn supernote_eligible(body: Option<&str>) -> bool {
192 - crate::commands::supernote::is_eligible(body)
193 - }
194 -
195 - #[cfg(not(feature = "supernote"))]
196 - fn supernote_eligible(_body: Option<&str>) -> bool {
197 - false
198 - }
199 -
200 179 fn item_to_detail(item: &bb_db::DbFeedItem) -> ItemDetailResponse {
201 180 ItemDetailResponse {
202 181 id: item.id.to_string(),
@@ -227,7 +206,6 @@
227 206 .collect(),
228 207 is_read: item.is_read,
229 208 is_starred: item.is_starred,
230 - supernote_eligible: supernote_eligible(item.body.as_deref()),
231 209 }
232 210 }
233 211
@@ -8,8 +8,6 @@
8 8 pub mod plugins;
9 9 pub mod query_feeds;
10 10 pub mod sources;
11 - #[cfg(feature = "supernote")]
12 - pub mod supernote;
13 11 pub mod sync;
14 12 pub mod themes;
15 13
@@ -21,7 +19,5 @@
21 19 pub use plugins::*;
22 20 pub use query_feeds::*;
23 21 pub use sources::*;
24 - #[cfg(feature = "supernote")]
25 - pub use supernote::*;
26 22 pub use sync::*;
27 23 pub use themes::*;
@@ -1,170 +1,0 @@
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.is_some_and(|b| b.trim().chars().count() >= MIN_BODY_CHARS)
46 - }
47 -
48 - fn load(data_dir: &Path) -> Result<SupernoteFile, ApiError> {
49 - let path = data_dir.join(CONFIG_FILE);
50 - let raw = std::fs::read_to_string(&path).map_err(|_| {
51 - ApiError::bad_request(format!(
52 - "Supernote not configured. Create {} with `host = \"<ip>\"`.",
53 - path.display()
54 - ))
55 - })?;
56 - toml::from_str::<SupernoteFile>(&raw)
57 - .map_err(|e| ApiError::bad_request(format!("Malformed {CONFIG_FILE}: {e}")))
58 - }
59 -
60 - /// Render the item's HTML body to PDF and upload to the configured Supernote.
61 - #[tauri::command]
62 - #[instrument(skip_all)]
63 - pub async fn push_item_to_supernote(
64 - state: State<'_, Arc<AppState>>,
65 - id: String,
66 - ) -> Result<(), ApiError> {
67 - let item_id: ItemId = id
68 - .parse()
69 - .map_err(|_| ApiError::bad_request("Invalid item ID"))?;
70 - let db = state.orchestrator.database();
71 - let item = db
72 - .items()
73 - .get(item_id)
74 - .await?
75 - .ok_or_else(|| ApiError::not_found(format!("Item {id} not found")))?;
76 -
77 - let body = item
78 - .body
79 - .clone()
80 - .ok_or_else(|| ApiError::bad_request("Item has no body content to push"))?;
81 - if !is_eligible(Some(&body)) {
82 - return Err(ApiError::bad_request(
83 - "Item body is too short to be worth pushing",
84 - ));
85 - }
86 -
87 - let cfg = load(&state.data_dir)?;
88 - let remote_dir = cfg
89 - .remote_dir
90 - .unwrap_or_else(|| DEFAULT_REMOTE_DIR.to_string());
91 - let title = item.title.clone().unwrap_or_else(|| item.bite_text.clone());
92 - let url = item.url.clone();
93 - let filename = pdf_filename(&title, &item.published_at);
94 -
95 - let bytes =
96 - tokio::task::spawn_blocking(move || bb_pdf::render_article(&body, &title, url.as_deref()))
97 - .await
98 - .map_err(|e| ApiError::internal(format!("Render task join error: {e}")))?
99 - .map_err(|e| ApiError::internal(format!("PDF render failed: {e}")))?;
100 -
101 - let host = cfg.host.clone();
102 - let passcode = cfg.passcode.clone();
103 - tokio::task::spawn_blocking(move || {
104 - let mut sn = supernote_push::Supernote::at(&host);
105 - if let Some(pc) = passcode {
106 - sn = sn.with_passcode(pc);
107 - }
108 - sn.push_pdf(&remote_dir, &filename, &bytes)
109 - })
110 - .await
111 - .map_err(|e| ApiError::internal(format!("Push task join error: {e}")))?
112 - .map_err(|e| ApiError::plugin(format!("Supernote push failed: {e}")))?;
113 -
114 - Ok(())
115 - }
116 -
117 - fn pdf_filename(title: &str, published_at: &str) -> String {
118 - let date = published_at
119 - .split_whitespace()
120 - .next()
121 - .unwrap_or(published_at);
122 - let slug: String = title
123 - .chars()
124 - .map(|c| {
125 - if c.is_ascii_alphanumeric() {
126 - c.to_ascii_lowercase()
127 - } else {
128 - '-'
129 - }
130 - })
131 - .collect();
132 - let slug: String = slug
133 - .split('-')
134 - .filter(|s| !s.is_empty())
135 - .collect::<Vec<_>>()
136 - .join("-");
137 - let slug = if slug.is_empty() { "article" } else { &slug };
138 - let slug: String = slug.chars().take(60).collect();
139 - format!("{date}_{slug}.pdf")
140 - }
141 -
142 - #[cfg(test)]
143 - mod tests {
144 - use super::*;
145 -
146 - #[test]
147 - fn eligibility_rejects_short_and_missing() {
148 - assert!(!is_eligible(None));
149 - assert!(!is_eligible(Some("")));
150 - assert!(!is_eligible(Some("short")));
151 - assert!(!is_eligible(Some(&"x".repeat(199))));
152 - }
153 -
154 - #[test]
155 - fn eligibility_accepts_prose() {
156 - assert!(is_eligible(Some(&"lorem ipsum ".repeat(50))));
157 - }
158 -
159 - #[test]
160 - fn filename_is_sluggy_and_dated() {
161 - let f = pdf_filename("Why E-Ink Rewards Slow Reading!", "2026-07-19 08:00:00");
162 - assert_eq!(f, "2026-07-19_why-e-ink-rewards-slow-reading.pdf");
163 - }
164 -
165 - #[test]
166 - fn filename_falls_back_when_title_is_symbols() {
167 - let f = pdf_filename("///", "2026-07-19");
168 - assert_eq!(f, "2026-07-19_article.pdf");
169 - }
170 - }