From 2303dc44c8d4b2289eb1ae8864db0bdb17b826a6 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Wed, 21 Oct 2026 13:22:10 -0400 Subject: [PATCH] LRC-LIB-IMPORT: month names in date templates (%B January, %b Jan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folder templates (and rename / export names) can now give month folders by name: {date:%Y}/{date:%B} → 2026/January, {date:%b} → Jan. The names are English in every interface language so a library's folders don't change with the language; a month that doesn't parse gives nothing (a folder level becomes "unknown"). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/rename.rs | 20 ++++++++++++++++++-- docs/mcp.md | 2 +- docs/parity-checklist.md | 2 +- docs/whats-new.md | 8 ++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index f0d725793..c569a2e1f 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -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::().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(); diff --git a/docs/mcp.md b/docs/mcp.md index d99bbff87..0d1880850 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 02f5bf08f..b96ea4998 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -690,7 +690,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) | diff --git a/docs/whats-new.md b/docs/whats-new.md index cfeed33ed..bf69a9592 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 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 ### Hidden Local locations come back one at a time