max / balanced_breakfast
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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. |
| @@ -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" |
| @@ -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" |
| @@ -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 | - | } |