From ca9eaf06622c97ac23af1c2cba37c34d51e6a9ea Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 02:12:57 +0100 Subject: [PATCH 01/14] feat: voice note config fields --- src/config.rs | 32 ++++++++++++++++++++++++++++++++ src/render.rs | 3 +++ src/web.rs | 3 +++ 3 files changed, 38 insertions(+) diff --git a/src/config.rs b/src/config.rs index e29259d..80fc2e2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,9 @@ pub struct Config { pub label_drawing: String, pub canvas_width: u32, pub canvas_height: u32, + pub enable_voice_notes: bool, + pub label_voice_note: String, + pub voice_note_max_duration: u32, pub template: Option, pub success_template: Option, pub separator: String, @@ -49,6 +52,12 @@ impl Config { self.canvas_width as usize * self.canvas_height as usize * 4 } + /// Maximum voice note file size: duration * 10KB. + /// Generous cap — real WebM/Opus clips are much smaller. + pub fn max_voice_note_bytes(&self) -> usize { + self.voice_note_max_duration as usize * 10 * 1024 + } + pub fn from_env() -> Result { Ok(Config { port: env::var("BOOK_PORT") @@ -115,6 +124,15 @@ impl Config { .unwrap_or_else(|_| "200".into()) .parse() .map_err(|_| "BOOK_CANVAS_HEIGHT must be a number")?, + enable_voice_notes: env::var("BOOK_ENABLE_VOICE_NOTES") + .map(|v| v != "false") + .unwrap_or(false), + label_voice_note: env::var("BOOK_LABEL_VOICE_NOTE") + .unwrap_or_else(|_| "Voice note (optional):".into()), + voice_note_max_duration: env::var("BOOK_VOICE_NOTE_MAX_DURATION") + .unwrap_or_else(|_| "20".into()) + .parse() + .map_err(|_| "BOOK_VOICE_NOTE_MAX_DURATION must be a number")?, separator: env::var("BOOK_SEPARATOR") .unwrap_or_else(|_| "------------------------------------------------------------".into()), template: env::var("BOOK_TEMPLATE").ok().map(|path| { @@ -286,6 +304,20 @@ mod tests { assert_eq!(config.label_drawing, "Draw (optional):"); } + #[test] + fn test_enable_voice_notes_default() { + let _lock = ENV_LOCK.lock().unwrap(); + env::remove_var("BOOK_ENABLE_VOICE_NOTES"); + env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); + env::remove_var("BOOK_TELEGRAM_CHAT_ID"); + + let config = Config::from_env().unwrap(); + assert!(!config.enable_voice_notes); + assert_eq!(config.voice_note_max_duration, 20); + assert_eq!(config.max_voice_note_bytes(), 20 * 10 * 1024); + assert_eq!(config.label_voice_note, "Voice note (optional):"); + } + #[test] fn test_success_template_default() { let _lock = ENV_LOCK.lock().unwrap(); diff --git a/src/render.rs b/src/render.rs index 428ef9b..440d7ca 100644 --- a/src/render.rs +++ b/src/render.rs @@ -237,6 +237,9 @@ mod tests { label_drawing: "Draw (optional):".into(), canvas_width: 400, canvas_height: 200, + enable_voice_notes: false, + label_voice_note: "Voice note (optional):".into(), + voice_note_max_duration: 20, template: None, success_template: None, separator: "---".into(), diff --git a/src/web.rs b/src/web.rs index 72abfac..e8e2bb7 100644 --- a/src/web.rs +++ b/src/web.rs @@ -265,6 +265,9 @@ mod tests { label_drawing: "Draw (optional):".into(), canvas_width: 400, canvas_height: 200, + enable_voice_notes: false, + label_voice_note: "Voice note (optional):".into(), + voice_note_max_duration: 20, template: None, success_template: None, separator: "---".into(), From d66e004b0d2f49976a92fec193c83ee7cbb4ce3c Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 02:14:57 +0100 Subject: [PATCH 02/14] feat: voice_note field in entry frontmatter --- src/entries.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ src/render.rs | 1 + src/web.rs | 1 + 3 files changed, 44 insertions(+) diff --git a/src/entries.rs b/src/entries.rs index e9af7a8..4c071bf 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -17,6 +17,8 @@ pub struct EntryMeta { pub website: String, #[serde(default)] pub drawing: String, + #[serde(default)] + pub voice_note: String, pub status: Status, } @@ -225,4 +227,44 @@ Hello!"#; let reparsed = Entry::parse("test", &serialized).unwrap(); assert_eq!(reparsed.meta.drawing, "abc123.png"); } + + #[test] + fn test_parse_entry_with_voice_note() { + let contents = r#"+++ +name = "alice" +date = "2026-04-10" +status = "approved" +voice_note = "1744300800_abcd1234.webm" ++++ +Hello!"#; + let entry = Entry::parse("test", contents).unwrap(); + assert_eq!(entry.meta.voice_note, "1744300800_abcd1234.webm"); + } + + #[test] + fn test_parse_entry_without_voice_note() { + let contents = r#"+++ +name = "bob" +date = "2026-04-10" +status = "pending" ++++ +Hi!"#; + let entry = Entry::parse("test", contents).unwrap(); + assert_eq!(entry.meta.voice_note, ""); + } + + #[test] + fn test_roundtrip_with_voice_note() { + let contents = r#"+++ +name = "alice" +date = "2026-04-10" +status = "approved" +voice_note = "1744300800_abcd1234.webm" ++++ +Hello!"#; + let entry = Entry::parse("test", contents).unwrap(); + let serialized = entry.to_file_contents(); + let reparsed = Entry::parse("test", &serialized).unwrap(); + assert_eq!(reparsed.meta.voice_note, "1744300800_abcd1234.webm"); + } } diff --git a/src/render.rs b/src/render.rs index 440d7ca..44ee119 100644 --- a/src/render.rs +++ b/src/render.rs @@ -262,6 +262,7 @@ mod tests { date: date.into(), website: String::new(), drawing: String::new(), + voice_note: String::new(), status: Status::Approved, }, body: body.into(), diff --git a/src/web.rs b/src/web.rs index e8e2bb7..f673b82 100644 --- a/src/web.rs +++ b/src/web.rs @@ -212,6 +212,7 @@ async fn submit( date, website, drawing: drawing_filename, + voice_note: String::new(), status: Status::Pending, }, body: message, From 54764935c22b289e09bfc2b577e0e15dcd7af619 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 02:17:05 +0100 Subject: [PATCH 03/14] refactor: extend channel to carry voice note bytes --- src/main.rs | 2 +- src/telegram.rs | 4 ++-- src/web.rs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 969ffe7..36956c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ async fn main() { std::fs::create_dir_all(&entries_dir).ok(); - let (tx, rx) = tokio::sync::mpsc::channel::<(entries::Entry, Option>)>(32); + let (tx, rx) = tokio::sync::mpsc::channel::<(entries::Entry, Option>, Option>)>(32); // Spawn telegram tasks if configured match (&config.telegram_bot_token, config.telegram_chat_id) { diff --git a/src/telegram.rs b/src/telegram.rs index 2001824..ad4dd9d 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -18,8 +18,8 @@ async fn notify(bot: &Bot, chat_id: ChatId, entry: &Entry) { } /// Listen for new entries on the channel and send Telegram notifications. -pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entry, Option>)>) { - while let Some((entry, drawing_bytes)) = rx.recv().await { +pub async fn notification_task(bot: Bot, chat_id: ChatId, mut rx: Receiver<(Entry, Option>, Option>)>) { + while let Some((entry, drawing_bytes, _voice_bytes)) = rx.recv().await { notify(&bot, chat_id, &entry).await; if let Some(bytes) = drawing_bytes { if let Err(e) = bot.send_photo( diff --git a/src/web.rs b/src/web.rs index f673b82..ed2ef9c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -18,7 +18,7 @@ use crate::render::{self, DEFAULT_TEMPLATE, render_error_page, render_success_pa pub struct AppState { pub config: Config, - pub tx: tokio::sync::mpsc::Sender<(Entry, Option>)>, + pub tx: tokio::sync::mpsc::Sender<(Entry, Option>, Option>)>, } #[derive(Deserialize)] @@ -228,7 +228,7 @@ async fn submit( } // Notify telegram task - let _ = state.tx.send((entry, drawing_bytes)).await; + let _ = state.tx.send((entry, drawing_bytes, None)).await; Html(render_success_page(&state.config)) } @@ -283,7 +283,7 @@ mod tests { } } - fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver<(Entry, Option>)>) { + fn test_app(config: Config) -> (Router, tokio::sync::mpsc::Receiver<(Entry, Option>, Option>)>) { let (tx, rx) = tokio::sync::mpsc::channel(32); let state = Arc::new(AppState { config, tx }); (router(state), rx) From 68080a1455fa9c06d8a137a48108364066b76bad Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 02:22:25 +0100 Subject: [PATCH 04/14] feat: voice note and collapsible form CSS --- templates/default.css | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/templates/default.css b/templates/default.css index d3503ca..e239e32 100644 --- a/templates/default.css +++ b/templates/default.css @@ -15,7 +15,10 @@ .guestbook-textarea { box-sizing: border-box; } -.guestbook-button {} +.guestbook-button { + display: block; + margin-top: 1em; +} /* Drawings */ .guestbook-canvas { @@ -29,6 +32,9 @@ .guestbook-canvas-tools a { cursor: pointer; } +.guestbook-drawing-inline a { + cursor: pointer; +} .guestbook-swatch { display: inline-block; width: 0.85em; @@ -51,6 +57,26 @@ max-width: 100%; } +/* Voice notes */ +.guestbook-voice-record.recording { + color: red; +} +.guestbook-voice-timer { + font-variant-numeric: tabular-nums; +} +.guestbook-voice-playback:empty { + display: none; +} +.guestbook-voice-playback { + display: block; + white-space: normal; +} +audio { + display: block; + margin-top: 0.6em; + height: 2em; +} + /* Entries */ .entry-header {} .entry-date {} From ab6dd05b6e54ca52ea10724e05bf07e7ed261f49 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 10 Apr 2026 02:24:55 +0100 Subject: [PATCH 05/14] refactor: collapsible drawing form with add/discard UX --- src/render.rs | 111 +++++++++++++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 46 deletions(-) diff --git a/src/render.rs b/src/render.rs index 44ee119..28aacc6 100644 --- a/src/render.rs +++ b/src/render.rs @@ -41,46 +41,67 @@ pub fn render_form(config: &Config) -> String { let drawing_section = if config.enable_drawings { format!( - r##" - - | undo | reset"##, - label = config.label_drawing, w = config.canvas_width, h = config.canvas_height, ) @@ -97,8 +118,7 @@ pub fn render_form(config: &Config) -> String { {captcha_section} -{drawing_section} - +{drawing_section} "#, prompt = config.form_prompt, label_name = config.label_name, @@ -430,21 +450,20 @@ mod tests { } #[test] - fn test_render_form_shows_canvas_when_drawings_enabled() { + fn test_render_form_shows_drawing_toggle_when_enabled() { let mut config = test_config(); config.enable_drawings = true; let form = render_form(&config); - assert!(form.contains(" Date: Fri, 10 Apr 2026 02:27:55 +0100 Subject: [PATCH 06/14] feat: voice note recorder in form --- src/render.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/render.rs b/src/render.rs index 28aacc6..55c0451 100644 --- a/src/render.rs +++ b/src/render.rs @@ -109,6 +109,70 @@ pub fn render_form(config: &Config) -> String { String::new() }; + let voice_note_section = if config.enable_voice_notes { + format!( + r##"add a voice note "##, + max_dur = config.voice_note_max_duration, + ) + } else { + String::new() + }; + format!( r#"{prompt}
@@ -118,7 +182,7 @@ pub fn render_form(config: &Config) -> String { {captcha_section} -{drawing_section} +{drawing_section}{voice_note_section}
"#, prompt = config.form_prompt, label_name = config.label_name, @@ -128,6 +192,7 @@ pub fn render_form(config: &Config) -> String { th = config.textarea_height, captcha_section = captcha_section, drawing_section = drawing_section, + voice_note_section = voice_note_section, button = config.button_text, ) } @@ -527,4 +592,22 @@ mod tests { assert!(html.contains("back")); assert!(html.contains("