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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
17
PROGRESS.md
17
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
|
**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
|
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
|
Green`. The Python had the same wart.
|
||||||
rather than deleting them). 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:**
|
**Worth knowing, not a bug:**
|
||||||
|
|
||||||
|
|||||||
@@ -7,33 +7,56 @@ use tokio::io::AsyncWriteExt;
|
|||||||
|
|
||||||
use crate::config::{Config, Feed as FeedCfg, Organize};
|
use crate::config::{Config, Feed as FeedCfg, Organize};
|
||||||
|
|
||||||
/// Characters the original's stringCleaning() stripped, plus the control range and the
|
/// Forbidden characters that were separating words: they become "-" so the words stay
|
||||||
/// trailing dots/spaces it left in. A real length cap is new -- the Python had none.
|
/// apart. The original's stringCleaning() deleted them, turning "Show | Series" into
|
||||||
const FORBIDDEN: &[char] = &['/', '\\', '?', '*', ':', '<', '>', '|', '"', '\''];
|
/// "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;
|
const MAX_NAME_BYTES: usize = 255;
|
||||||
|
|
||||||
/// Keeps UTF-8: the original transliterated to ASCII via latin1_to_ascii because 2004
|
/// Keeps UTF-8: the original transliterated to ASCII via latin1_to_ascii because 2004
|
||||||
/// filesystems demanded it. Ours do not.
|
/// filesystems demanded it. Ours do not.
|
||||||
pub fn sanitize(name: &str) -> String {
|
pub fn sanitize(name: &str) -> String {
|
||||||
let stripped: String = name
|
let mapped: String = name
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_control() { ' ' } else { c })
|
.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();
|
.collect();
|
||||||
|
|
||||||
// Stripping a separator leaves a gap: "Show | Series | Delta" would otherwise become
|
// Collapse each run of dashes and spaces into one thing. A run containing a dash
|
||||||
// "Show Series Delta". Collapse runs of whitespace rather than leaving the seams.
|
// becomes " - " when it also had whitespace ("Show | Series" -> "Show - Series",
|
||||||
let mut out = String::with_capacity(stripped.len());
|
// "Ep 12: One" -> "Ep 12 - One") and a bare "-" when it did not ("AC/DC" -> "AC-DC").
|
||||||
for c in stripped.chars() {
|
// A run of plain whitespace collapses to a single space.
|
||||||
if c.is_whitespace() {
|
let mut out = String::with_capacity(mapped.len());
|
||||||
if !out.ends_with(' ') {
|
let mut chars = mapped.chars().peekable();
|
||||||
out.push(' ');
|
while let Some(c) = chars.next() {
|
||||||
}
|
if !(c == '-' || c.is_whitespace()) {
|
||||||
} else {
|
|
||||||
out.push(c);
|
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 {
|
if out.len() > MAX_NAME_BYTES {
|
||||||
// Truncate on a char boundary, keeping the extension if there is a plausible one.
|
// Truncate on a char boundary, keeping the extension if there is a plausible one.
|
||||||
@@ -278,21 +301,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_strips_path_and_control_characters() {
|
fn sanitize_strips_path_and_control_characters() {
|
||||||
assert_eq!(sanitize("../../etc/passwd"), "etcpasswd");
|
assert_eq!(sanitize("../../etc/passwd"), "etc-passwd");
|
||||||
assert_eq!(sanitize("Ep 12: The \"Best\" One?"), "Ep 12 The Best One");
|
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("bad\u{0}name\u{7}.mp3"), "bad name .mp3");
|
||||||
assert_eq!(sanitize(" spaced.mp3 "), "spaced.mp3");
|
assert_eq!(sanitize(" spaced.mp3 "), "spaced.mp3");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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.
|
// A real Patreon feed title; the pipes are forbidden characters.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sanitize("Get in the Trunk | Anthology Series | Delta Green"),
|
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("Ep 12: The One"), "Ep 12 - The One");
|
||||||
assert_eq!(sanitize("a b"), "a b");
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user