Merge PR #2502: LRC-LIB-IMPORT: month names in date templates (%B January, %

# Conflicts:
#	docs/whats-new.md
This commit is contained in:
francoisgn
2026-10-22 22:31:04 +03:00
4 changed files with 27 additions and 4 deletions
+18 -2
View File
@@ -4,7 +4,7 @@
//! Template tokens (shared with import renaming and export file naming): `{name}` (current name
//! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` /
//! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date;
//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
//! `%Y %y %m %B %b %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`,
//! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as
//! typed. Characters that aren't allowed in file names become `-`. The original extension is always
//! kept.
@@ -25,6 +25,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use lightcraft_catalog::dates::MONTHS;
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde::Serialize;
@@ -57,9 +58,12 @@ fn sanitize(s: &str) -> String {
t.trim().trim_matches('.').trim().to_string()
}
/// `%Y%m%d`-style formatting of an ISO local time.
/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name
/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month
/// that doesn't parse gives nothing.
fn format_date(iso: &str, fmt: &str) -> String {
let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string();
let month = iso.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or("");
let mut out = String::new();
let mut it = fmt.chars();
while let Some(c) = it.next() {
@@ -71,6 +75,8 @@ fn format_date(iso: &str, fmt: &str) -> String {
Some('Y') => out.push_str(&part(0, 4)),
Some('y') => out.push_str(&part(2, 4)),
Some('m') => out.push_str(&part(5, 7)),
Some('B') => out.push_str(month),
Some('b') => out.push_str(month.get(..3).unwrap_or(month)),
Some('d') => out.push_str(&part(8, 10)),
Some('H') => out.push_str(&part(11, 13)),
Some('M') => out.push_str(&part(14, 16)),
@@ -120,6 +126,8 @@ pub const DATE_DIRECTIVES: &[(&str, &str)] = &[
("%Y", "year, 4 digits"),
("%y", "year, 2 digits"),
("%m", "month 01–12"),
("%B", "month name (January)"),
("%b", "short month name (Jan)"),
("%d", "day 01–31"),
("%H", "hour 00–23"),
("%M", "minute"),
@@ -1670,6 +1678,8 @@ pub(crate) mod tests {
let mut p = sample_photo();
assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]);
assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too");
assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]);
assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]);
// metadata can't add levels or climb out
p.meta.camera = "../../etc/x".into();
p.meta.title = "..".into();
@@ -1711,6 +1721,12 @@ pub(crate) mod tests {
let v = format_date("2026-01-14T05:58:48", d);
assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive");
}
assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep");
assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026");
// a month that isn't 01–12 gives no name instead of panicking
for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] {
assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}");
}
assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]);
assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty());
let json = token_help_json();
+1 -1
View File
@@ -170,7 +170,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now |
| `run_command {command, params?}` | Run any command by id |
| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) |
| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`) or `{date:%Y}/{date:%B}` (→ `2026/January`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) |
| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) |
| `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were |
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |
+1 -1
View File
@@ -698,7 +698,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import |
| LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 or `{date:%Y}/{date:%B}` → 2026/January, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import |
| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO |
| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | |
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) |
+7
View File
@@ -115,6 +115,13 @@
`photo.setCaptureTime` counts those photos as `unreadable`. Set date & time still repairs such a photo selected on
its own; with other photos selected it says why it can't move them by the same amount. When the dialog can't make a
change (a time that isn't a date, a shift past year 9999), it now says why and stays open, instead of closing.
## 2026-10-21
### Month names in folder and file name templates
- Date templates take the month by name: `%B` gives January, `%b` gives Jan. Import ▸ Folders ▸ Custom template…
`{date:%Y}/{date:%B}` files photos into 2026/January, 2026/February…, the layout many existing libraries already
use. The names are English in every interface language, so folders don't change when the language does. Rename
Photos and export file names take them too.
## 2026-10-19