Skip to main content

falcon_library/
rename.rs

1//! Batch renaming, worked out the way Eagle's Rename dialog does: either a
2//! name template with counters, dates and the original name, or a find
3//! and replace (plain text or `/regex/flags`), then an optional change of
4//! case. Only names are computed here; callers save them.
5
6use chrono::{DateTime, Local, TimeZone};
7use regex::{Regex, RegexBuilder};
8
9/// How the new names are made.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Method {
12    /// A template. Tokens (any case): `%N` to `%NNNNNNNNN` a counter padded
13    /// to that many digits, `*` the original name, `%D` / `%DD` / `%DDD` /
14    /// `%DHM` / `%DHMS` today's date, `%M…` the modification date and
15    /// `%B…` the creation date in the same five forms, `%T` the tags.
16    /// Each token is replaced once, at its first place, as in Eagle.
17    Format { template: String, start: u32 },
18    /// Every `find` becomes `with`. A `find` written `/pattern/flags` is a
19    /// regular expression: only its first match is replaced unless the
20    /// flags hold `g`, and `i` ignores case.
21    Replace { find: String, with: String },
22}
23
24/// The case change applied to every new name.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum TextCase {
27    #[default]
28    Keep,
29    Upper,
30    Lower,
31    /// Each word with a capital first letter and the rest lower case.
32    Capitalize,
33}
34
35/// What a template can draw on for one thing being renamed.
36#[derive(Debug, Clone, Default)]
37pub struct Subject<'a> {
38    pub name: &'a str,
39    /// Modification and creation times, ms since the epoch.
40    pub modified: u64,
41    pub created: u64,
42    pub tags: &'a [String],
43    /// Names of the folders it is in (empty for a folder).
44    pub folders: &'a [String],
45}
46
47/// A batch rename: the method and the case change.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Rename {
50    pub method: Method,
51    pub case: TextCase,
52}
53
54/// Longest name Eagle's dialog produces.
55const MAX_CHARS: usize = 255;
56
57impl Rename {
58    /// Whether the dialog can run: a template, or something to find.
59    pub fn is_ready(&self) -> bool {
60        match &self.method {
61            Method::Format { template, .. } => !template.is_empty(),
62            Method::Replace { find, .. } => !find.is_empty(),
63        }
64    }
65
66    /// The new name of the `index`th subject (from 0), with `now` for the
67    /// date tokens. An empty result keeps the original name. Names are cut
68    /// to 255 characters.
69    pub fn apply(&self, s: &Subject, index: usize, now: DateTime<Local>) -> String {
70        let name = match &self.method {
71            Method::Format { template, start } => format_name(template, s, *start as usize + index, now),
72            Method::Replace { find, with } => replace_name(s.name, find, with),
73        };
74        // Eagle falls back to the original before changing the case. (A
75        // replace that empties the name would leave it empty in Eagle.)
76        let name = if name.is_empty() { s.name.to_string() } else { name };
77        change_case(&name, self.case).chars().take(MAX_CHARS).collect()
78    }
79}
80
81/// `/pattern/flags` as a regex and whether it replaces every match.
82fn parse_regex(find: &str) -> Option<(Regex, bool)> {
83    let rest = find.strip_prefix('/')?;
84    let end = rest.rfind('/')?;
85    let (pattern, flags) = (&rest[..end], &rest[end + 1..]);
86    if pattern.is_empty() || !flags.chars().all(|c| c.is_ascii_lowercase()) {
87        return None;
88    }
89    let re = RegexBuilder::new(pattern).case_insensitive(flags.contains('i')).multi_line(flags.contains('m')).build().ok()?;
90    Some((re, flags.contains('g')))
91}
92
93fn replace_name(name: &str, find: &str, with: &str) -> String {
94    match parse_regex(find) {
95        Some((re, true)) => re.replace_all(name, with).into_owned(),
96        Some((re, false)) => re.replace(name, with).into_owned(),
97        None if find.is_empty() => name.to_string(),
98        None => name.replace(find, with),
99    }
100}
101
102/// Replaces the first case-insensitive `%` + `letters` (with at least one
103/// more of the last letter when `more`), consuming the longest run in
104/// that case.
105fn replace_token(text: &mut String, letters: &str, more: bool, with: &str) {
106    let lower = text.to_ascii_lowercase();
107    let want = format!("%{}", letters.to_ascii_lowercase());
108    let mut from = 0;
109    while let Some(hit) = lower[from..].find(&want) {
110        let at = from + hit;
111        let mut end = at + want.len();
112        if more {
113            let last = want.as_bytes()[want.len() - 1];
114            let extra = lower.as_bytes()[end..].iter().take_while(|&&b| b == last).count();
115            if extra == 0 {
116                from = at + 1;
117                continue;
118            }
119            end += extra;
120        }
121        text.replace_range(at..end, with);
122        return;
123    }
124}
125
126fn format_name(template: &str, s: &Subject, n: usize, now: DateTime<Local>) -> String {
127    let mut out = template.to_string();
128    let pad = |w: usize| format!("{n:0w$}");
129    // Counters, longest first; nine or more letters pad to nine.
130    let counters: [(&str, bool, usize); 10] = [
131        ("NNNNNNNN", true, 9),
132        ("NNNNNNNNN", false, 8),
133        ("NNNNNNNN", false, 8),
134        ("NNNNNNN", false, 7),
135        ("NNNNNN", false, 6),
136        ("NNNNN", false, 5),
137        ("NNNN", false, 4),
138        ("NNN", false, 3),
139        ("NN", false, 2),
140        ("N", false, 1),
141    ];
142    // Eagle runs each counter pattern twice (upper and lower case); with
143    // case-insensitive matching that replaces up to two tokens of a length.
144    for (letters, more, width) in counters {
145        for _ in 0..2 {
146            replace_token(&mut out, letters, more, &pad(width));
147        }
148    }
149    let local = |ms: u64| Local.timestamp_millis_opt(ms as i64).single().unwrap_or(now);
150    let dates = [("D", now, '-'), ("M", local(s.modified), '_'), ("B", local(s.created), '_')];
151    for (letter, t, sep) in dates {
152        let ymd = format!("%Y{sep}%m{sep}%d");
153        let forms = [
154            (format!("{letter}{letter}"), true, t.format("%b %-d, %Y").to_string()),
155            (format!("{letter}{letter}{letter}"), false, t.format("%b %-d, %Y").to_string()),
156            (format!("{letter}{letter}"), false, t.format(&format!("%m{sep}%d")).to_string()),
157            (format!("{letter}HMS"), false, t.format(&format!("{ymd} %H_%M_%S")).to_string()),
158            (format!("{letter}HM"), false, t.format(&format!("{ymd} %H_%M")).to_string()),
159            (letter.to_string(), false, t.format(&ymd).to_string()),
160        ];
161        for _ in 0..2 {
162            for (letters, more, value) in &forms {
163                replace_token(&mut out, letters, *more, value);
164            }
165        }
166    }
167    if let Some(at) = out.find('*') {
168        out.replace_range(at..at + 1, s.name);
169    }
170    // Eagle looks for an upper-case %T or %F before replacing either case.
171    for (token, list) in [("%T", s.tags), ("%F", s.folders)] {
172        if out.contains(token) {
173            let mut sorted = list.to_vec();
174            sorted.sort();
175            replace_token(&mut out, &token[1..], false, &sorted.join("-"));
176        }
177    }
178    out
179}
180
181fn change_case(name: &str, case: TextCase) -> String {
182    match case {
183        TextCase::Keep => name.to_string(),
184        TextCase::Upper => name.to_uppercase(),
185        TextCase::Lower => name.to_lowercase(),
186        TextCase::Capitalize => {
187            // A word starts at an ASCII letter, digit or underscore and runs
188            // to the next space, as JavaScript's `\w\S*`.
189            let mut out = String::with_capacity(name.len());
190            let mut in_word = false;
191            for c in name.chars() {
192                if c.is_whitespace() {
193                    in_word = false;
194                    out.push(c);
195                } else if in_word {
196                    out.extend(c.to_lowercase());
197                } else if c.is_ascii_alphanumeric() || c == '_' {
198                    in_word = true;
199                    out.push(c.to_ascii_uppercase());
200                } else {
201                    out.push(c);
202                }
203            }
204            out
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn now() -> DateTime<Local> {
214        Local.with_ymd_and_hms(2026, 3, 7, 9, 5, 2).unwrap()
215    }
216
217    fn fmt(template: &str, start: u32) -> Rename {
218        Rename { method: Method::Format { template: template.into(), start }, case: TextCase::Keep }
219    }
220
221    fn subject(name: &str) -> Subject<'_> {
222        Subject { name, ..Default::default() }
223    }
224
225    #[test]
226    fn counters_pad_and_count_from_the_start() {
227        let r = fmt("Shot %NNN", 1);
228        assert_eq!(r.apply(&subject("a"), 0, now()), "Shot 001");
229        assert_eq!(r.apply(&subject("a"), 41, now()), "Shot 042");
230        assert_eq!(fmt("%n-%NN", 7).apply(&subject("a"), 2, now()), "9-09");
231        assert_eq!(fmt("x%NNNNNNNNNNNN", 0).apply(&subject("a"), 5, now()), "x000000005");
232        // Past the padding the number is written in full.
233        assert_eq!(fmt("%N", 1).apply(&subject("a"), 11, now()), "12");
234    }
235
236    #[test]
237    fn original_name_dates_and_tags() {
238        let tags = vec!["sky".to_string(), "blue".to_string()];
239        let modified = Local.with_ymd_and_hms(2024, 12, 31, 23, 59, 58).unwrap().timestamp_millis() as u64;
240        let s = Subject { name: "Logo", modified, created: modified, tags: &tags, folders: &[] };
241        assert_eq!(fmt("* (%D)", 1).apply(&s, 0, now()), "Logo (2026-03-07)");
242        assert_eq!(fmt("%dd %DHM", 1).apply(&s, 0, now()), "03-07 2026-03-07 09_05");
243        assert_eq!(fmt("%DHMS", 1).apply(&s, 0, now()), "2026-03-07 09_05_02");
244        assert_eq!(fmt("%DDD", 1).apply(&s, 0, now()), "Mar 7, 2026");
245        assert_eq!(fmt("%M / %bb / %MHMS", 1).apply(&s, 0, now()), "2024_12_31 / 12_31 / 2024_12_31 23_59_58");
246        assert_eq!(fmt("* %T", 1).apply(&s, 0, now()), "Logo blue-sky");
247        // A lower-case %t is left as typed, as in Eagle.
248        assert_eq!(fmt("* %t", 1).apply(&s, 0, now()), "Logo %t");
249        // A folder has no folders of its own.
250        assert_eq!(fmt("*%F", 1).apply(&s, 0, now()), "Logo");
251        // Only the first * is the name.
252        assert_eq!(fmt("**", 1).apply(&s, 0, now()), "Logo*");
253        // Tokens in the original name are not expanded.
254        assert_eq!(fmt("* %N", 1).apply(&subject("%D"), 0, now()), "%D 1");
255    }
256
257    #[test]
258    fn find_and_replace_plain_and_regex() {
259        let r = |find: &str, with: &str| Rename { method: Method::Replace { find: find.into(), with: with.into() }, case: TextCase::Keep };
260        assert_eq!(r("a", "o").apply(&subject("banana"), 0, now()), "bonono");
261        assert_eq!(r("/a/", "o").apply(&subject("banana"), 0, now()), "bonana");
262        assert_eq!(r("/A/gi", "o").apply(&subject("banana"), 0, now()), "bonono");
263        assert_eq!(r("/(\\d+)/g", "#$1").apply(&subject("v1 v22"), 0, now()), "v#1 v#22");
264        // Not a valid regex: plain text.
265        assert_eq!(r("/(/", "x").apply(&subject("a/(/b"), 0, now()), "axb");
266        // Replacing everything away keeps the name.
267        assert_eq!(r("banana", "").apply(&subject("banana"), 0, now()), "banana");
268        assert!(!r("", "x").is_ready());
269        assert!(!fmt("", 1).is_ready());
270    }
271
272    #[test]
273    fn text_case_and_length() {
274        let cased = |case| Rename { method: Method::Format { template: "*".into(), start: 1 }, case };
275        let s = subject("hello wORLD (draft) élan");
276        assert_eq!(cased(TextCase::Upper).apply(&s, 0, now()), "HELLO WORLD (DRAFT) ÉLAN");
277        assert_eq!(cased(TextCase::Lower).apply(&s, 0, now()), "hello world (draft) élan");
278        assert_eq!(cased(TextCase::Capitalize).apply(&s, 0, now()), "Hello World (Draft) éLan");
279        let long = "x".repeat(300);
280        assert_eq!(cased(TextCase::Keep).apply(&subject(&long), 0, now()).chars().count(), 255);
281    }
282}