From ed47e456d4bbfcf27f3b01ee5f0ab5a0ed7522e3 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 00:40:17 +0000 Subject: [PATCH] Turn filename separators into dashes rather than dropping them Splits the forbidden set: / \ | : were separating words, so they become "-"; ? * < > " ' just go. Runs of dashes and spaces collapse to " - " when the run held whitespace and to a bare "-" when it did not, so "Show | Series" reads "Show - Series" while "AC/DC" stays "AC-DC". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe --- PROGRESS.md | 17 +++++++++++-- src/download.rs | 68 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index bab3615..e5e85fe 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -41,8 +41,21 @@ episode -- whole-percent throttling behaving exactly as intended -- then `downlo **Fixed: stripped separators left doubled spaces.** The feed title separates words with `|`, a forbidden filename character, so the folder came out `Get in the Trunk Anthology Series Delta -Green`. `sanitize()` now collapses runs of whitespace (and maps control characters to a space -rather than deleting them). The Python had the same wart. +Green`. The Python had the same wart. + +Forbidden characters are now split in two. Separators (`/ \\ | :`) become `-`; the rest +(`? * < > " '`) are simply dropped. A run of dashes and spaces then collapses to `" - "` when the +run contained whitespace and to a bare `-` when it did not, so: + +| input | output | +|---|---| +| `Get in the Trunk \| Anthology Series \| Delta Green` | `Get in the Trunk - Anthology Series - Delta Green` | +| `Ep 12: The One` | `Ep 12 - The One` | +| `AC/DC` | `AC-DC` | +| `well-known.mp3` | `well-known.mp3` | +| `../../etc/passwd` | `etc-passwd` | + +Leading dashes and dots are trimmed too -- a filename starting with `-` trips up CLI tools. **Worth knowing, not a bug:** diff --git a/src/download.rs b/src/download.rs index 3b3ea51..40cdd59 100644 --- a/src/download.rs +++ b/src/download.rs @@ -7,33 +7,56 @@ use tokio::io::AsyncWriteExt; use crate::config::{Config, Feed as FeedCfg, Organize}; -/// Characters the original's stringCleaning() stripped, plus the control range and the -/// trailing dots/spaces it left in. A real length cap is new -- the Python had none. -const FORBIDDEN: &[char] = &['/', '\\', '?', '*', ':', '<', '>', '|', '"', '\'']; +/// Forbidden characters that were separating words: they become "-" so the words stay +/// apart. The original's stringCleaning() deleted them, turning "Show | Series" into +/// "Show Series". +const SEPARATORS: &[char] = &['/', '\\', '|', ':']; +/// Forbidden characters that were never separators: they just go. +const STRIPPED: &[char] = &['?', '*', '<', '>', '"', '\'']; +/// A real length cap is new -- the Python had none. const MAX_NAME_BYTES: usize = 255; /// Keeps UTF-8: the original transliterated to ASCII via latin1_to_ascii because 2004 /// filesystems demanded it. Ours do not. pub fn sanitize(name: &str) -> String { - let stripped: String = name + let mapped: String = name .chars() .map(|c| if c.is_control() { ' ' } else { c }) - .filter(|c| !FORBIDDEN.contains(c)) + .filter(|c| !STRIPPED.contains(c)) + .map(|c| if SEPARATORS.contains(&c) { '-' } else { c }) .collect(); - // Stripping a separator leaves a gap: "Show | Series | Delta" would otherwise become - // "Show Series Delta". Collapse runs of whitespace rather than leaving the seams. - let mut out = String::with_capacity(stripped.len()); - for c in stripped.chars() { - if c.is_whitespace() { - if !out.ends_with(' ') { - out.push(' '); - } - } else { + // Collapse each run of dashes and spaces into one thing. A run containing a dash + // becomes " - " when it also had whitespace ("Show | Series" -> "Show - Series", + // "Ep 12: One" -> "Ep 12 - One") and a bare "-" when it did not ("AC/DC" -> "AC-DC"). + // A run of plain whitespace collapses to a single space. + let mut out = String::with_capacity(mapped.len()); + let mut chars = mapped.chars().peekable(); + while let Some(c) = chars.next() { + if !(c == '-' || c.is_whitespace()) { out.push(c); + continue; + } + let mut has_dash = c == '-'; + let mut has_space = c.is_whitespace(); + while let Some(&next) = chars.peek() { + if next == '-' { + has_dash = true; + } else if next.is_whitespace() { + has_space = true; + } else { + break; + } + chars.next(); + } + match (has_dash, has_space) { + (true, true) => out.push_str(" - "), + (true, false) => out.push('-'), + _ => out.push(' '), } } - out = out.trim().trim_matches('.').trim().to_owned(); + // Leading/trailing separators and dots are noise, and a leading "-" trips up CLI tools. + out = out.trim().trim_matches(|c| c == '.' || c == '-').trim().to_owned(); if out.len() > MAX_NAME_BYTES { // Truncate on a char boundary, keeping the extension if there is a plausible one. @@ -278,21 +301,24 @@ mod tests { #[test] fn sanitize_strips_path_and_control_characters() { - assert_eq!(sanitize("../../etc/passwd"), "etcpasswd"); - assert_eq!(sanitize("Ep 12: The \"Best\" One?"), "Ep 12 The Best One"); + assert_eq!(sanitize("../../etc/passwd"), "etc-passwd"); + assert_eq!(sanitize("Ep 12: The \"Best\" One?"), "Ep 12 - The Best One"); assert_eq!(sanitize("bad\u{0}name\u{7}.mp3"), "bad name .mp3"); assert_eq!(sanitize(" spaced.mp3 "), "spaced.mp3"); } #[test] - fn sanitize_collapses_the_gaps_left_by_stripped_separators() { + fn sanitize_turns_separators_into_dashes() { // A real Patreon feed title; the pipes are forbidden characters. assert_eq!( sanitize("Get in the Trunk | Anthology Series | Delta Green"), - "Get in the Trunk Anthology Series Delta Green" + "Get in the Trunk - Anthology Series - Delta Green" ); - assert_eq!(sanitize("Ep 12: The One"), "Ep 12 The One"); - assert_eq!(sanitize("a b"), "a b"); + assert_eq!(sanitize("Ep 12: The One"), "Ep 12 - The One"); + assert_eq!(sanitize("a b"), "a b", "plain whitespace stays whitespace"); + assert_eq!(sanitize("AC/DC"), "AC-DC", "no spaces around it, so no spaces added"); + assert_eq!(sanitize("well-known.mp3"), "well-known.mp3", "existing dashes survive"); + assert_eq!(sanitize("Show -- Thing"), "Show - Thing"); } #[test]