From 0ef73464b821c415a3ebe62a42dfc73d4d714a89 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Thu, 9 Apr 2026 02:33:05 +0200 Subject: [PATCH 1/8] Extend and tags to allow different outputs like ISO-codes or names in different languages --- Source/DataLayer/EfClasses/Book.cs | 2 +- Source/DataLayer/MockLibraryBook.cs | 4 +- Source/FileLiberator/UtilityExtensions.cs | 4 +- .../NamingTemplate/CommonFormatters.cs | 40 ++++-- .../ConditionalTagCollection[TClass].cs | 2 +- .../PropertyTagCollection[TClass].cs | 2 +- .../Controls/ThemePreviewControl.axaml.cs | 2 +- .../Templates/CultureInfoDto.cs | 86 +++++++++++++ .../Templates/LibraryBookDto.cs | 4 +- .../Templates/RegionInfoDto.cs | 96 ++++++++++++++ .../Templates/TemplateEditor[T].cs | 4 +- .../Templates/TemplateTags.cs | 2 + .../Templates/Templates.cs | 8 +- .../TemplatesTests.cs | 119 +++++++++++++++++- docs/features/naming-templates.md | 6 +- 15 files changed, 348 insertions(+), 33 deletions(-) create mode 100644 Source/LibationFileManager/Templates/CultureInfoDto.cs create mode 100644 Source/LibationFileManager/Templates/RegionInfoDto.cs diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 57566f3c..52801dbd 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -256,7 +256,7 @@ public class Book IsAbridged |= isAbridged; IsSpatial = isSpatial ?? IsSpatial; DatePublished = datePublished ?? DatePublished; - Language = language?.FirstCharToUpper() ?? Language; + Language = language?.Trim().FirstCharToUpper() ?? Language; } public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}"; diff --git a/Source/DataLayer/MockLibraryBook.cs b/Source/DataLayer/MockLibraryBook.cs index 26b4cfe7..e37f91b5 100644 --- a/Source/DataLayer/MockLibraryBook.cs +++ b/Source/DataLayer/MockLibraryBook.cs @@ -73,7 +73,7 @@ public class MockLibraryBook : LibraryBook public static MockLibraryBook CreateBook( string account = "someone@email.co", - bool absetFromLastScan = false, + bool absentFromLastScan = false, DateTime? dateAdded = null, DateTime? datePublished = null, DateTime? includedUntil = null, @@ -120,7 +120,7 @@ public class MockLibraryBook : LibraryBook includedUntil, isAudiblePlus) { - AbsentFromLastScan = absetFromLastScan + AbsentFromLastScan = absentFromLastScan }; } diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 5c534a36..29f5f050 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -59,7 +59,7 @@ public static class UtilityExtensions Title = libraryBook.Book.Title, Subtitle = libraryBook.Book.Subtitle, TitleWithSubtitle = libraryBook.Book.TitleWithSubtitle, - Locale = libraryBook.Book.Locale, + Locale = new RegionInfoDto(libraryBook.Book.Locale), YearPublished = libraryBook.Book.DatePublished?.Year, DatePublished = libraryBook.Book.DatePublished, @@ -72,7 +72,7 @@ public static class UtilityExtensions IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), LengthInMinutes = TimeSpan.FromMinutes(libraryBook.Book.LengthInMinutes), - Language = libraryBook.Book.Language?.Trim(), + Language = libraryBook.Book.Language is null ? null : new CultureInfoDto(libraryBook.Book.Language), Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate, diff --git a/Source/FileManager/NamingTemplate/CommonFormatters.cs b/Source/FileManager/NamingTemplate/CommonFormatters.cs index 5fea2f7b..ef4d8efc 100644 --- a/Source/FileManager/NamingTemplate/CommonFormatters.cs +++ b/Source/FileManager/NamingTemplate/CommonFormatters.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; +using System.Threading; namespace FileManager.NamingTemplate; @@ -32,6 +33,9 @@ public static partial class CommonFormatters public static string StringFormatter(ITemplateTag _, string? value, string? formatString, CultureInfo? culture) => _StringFormatter(value, formatString, culture); + public static string _StringFormatter(string? value, string? formatString, IFormatProvider? provider) + => _StringFormatter(value, formatString, GetCultureInfo(provider)); + private static string _StringFormatter(string? value, string? formatString, CultureInfo? culture) { if (string.IsNullOrEmpty(value)) return string.Empty; @@ -61,24 +65,36 @@ public static partial class CommonFormatters if (string.IsNullOrWhiteSpace(templateString)) return ""; // is this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider - var culture = provider as CultureInfo ?? provider?.GetFormat(typeof(CultureInfo)) as CultureInfo; - return CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().Replace(templateString, GetValueForMatchingTag), ""); + var culture = GetCultureInfo(provider); + var oldUiCulture = Thread.CurrentThread.CurrentUICulture; + var result = CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().Replace(templateString, GetValueForMatchingTag), ""); + Thread.CurrentThread.CurrentUICulture = oldUiCulture; + return result; string GetValueForMatchingTag(Match m) { var tag = m.Groups["tag"].Value; if (!replacements.TryGetValue(tag, out var getter)) return m.Value; + var lang = m.Groups["lang"].ValueOrNull(); + var cultureToUse = lang is null ? culture : CultureInfo.GetCultureInfo(lang); + Thread.CurrentThread.CurrentUICulture = cultureToUse ?? oldUiCulture; + var value = getter(toFormat); var format = m.Groups["format"].ValueOrNull(); return value switch { - IFormattable formattable => formattable.ToString(format, provider), - _ => _StringFormatter(value?.ToString(), format, culture), + IFormattable formattable => formattable.ToString(format, cultureToUse), + _ => _StringFormatter(value?.ToString(), format, cultureToUse), }; } } + private static CultureInfo? GetCultureInfo(IFormatProvider? provider) + { + return provider as CultureInfo ?? provider?.GetFormat(typeof(CultureInfo)) as CultureInfo; + } + // Matches runs of spaces followed by a space as well as runs of spaces at the beginning or the end of a string (does NOT touch tabs/newlines). [GeneratedRegex(@"^ +| +(?=$| )")] private static partial Regex CollapseSpacesAndTrimRegex(); @@ -87,8 +103,8 @@ public static partial class CommonFormatters // The tagname may be followed by an optional format specifier separated by a colon. // All other parts of the template string are left untouched as well as the braces where the tagname is unknown. // TemplateStringFormatter will use a dictionary to lookup the tagname and the corresponding value getter. - [GeneratedRegex("""\{(?[A-Z]+|#)(?::(?(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)] - private static partial Regex TagFormatRegex(); + [GeneratedRegex("""\{(?[A-Z0-9]+|#)(?:@(?[a-z-]+))?(?::(?(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)] + public static partial Regex TagFormatRegex(); public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture) => value?.ToString(formatString, culture) ?? ""; @@ -100,12 +116,10 @@ public static partial class CommonFormatters { culture ??= CultureInfo.CurrentCulture; if (!int.TryParse(formatString, out var numDigits) || numDigits <= 0) return value.ToString(formatString, culture); - //Zero-pad the integer part - var strValue = value.ToString(culture); - var decIndex = culture.CompareInfo.IndexOf(strValue, culture.NumberFormat.NumberDecimalSeparator); - var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex); - return new string('0', zeroPad) + strValue; + //Zero-pad the integer part + formatString = new string('0', numDigits) + ".################"; + return value.ToString(formatString, culture); } public static string MinutesFormatter(ITemplateTag templateTag, TimeSpan value, string? formatString, CultureInfo? culture) @@ -163,7 +177,7 @@ public static partial class CommonFormatters [GeneratedRegex(""" (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # (?<=\G(?: # We lookbehind up to the start or the end of the last match for a number format. - \\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + \\. # - '\' escapes always the next character. Especially further '\' and the closing ']' | '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character | . # - match any character. This will not catch the number format at first. Because ... @@ -172,7 +186,7 @@ public static partial class CommonFormatters (?:\#[\#,.]*)? # - For grouping a number format may start with `#` and grouping hints `,` or even a decimal point `.`. D # - At least one unescaped, unquoted uppercase D must be included in the format to indicate that this is a total days format. (?:(?: # - Before further D's, there may be any combination of escaped characters and quoted strings. - \\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + \\. # - '\' escapes always the next character. Especially further '\' and the closing ']' | '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character )* [\#,.%‰D]+ # After escaped characters and quoted strings, there needs to be at least one more real number format character (which may be D as well). diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 3e3bdb5f..8ed37a69 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -116,7 +116,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) (? end with a whitepace. Otherwise "" would be matchable. (?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. So match whitespace first (? # - capture inner part as - (?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + (?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']' |[^\\\]])* ) # - match any character except '\' and ']'. Check may end in whitespace! \])? # - closing the check part )? # end of optional property and check part diff --git a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs index 2d43957c..81edd5ba 100644 --- a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs @@ -194,7 +194,7 @@ public class PropertyTagCollection : TagCollection {TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#' (?:\s* # optional whitespace \[ (? # optional format details enclosed in '[' and ']'. Capture inner part as . - (?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + (?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']' |'(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character |"(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character |[^\\\]])* ) # - match any character except '\' and ']'. Format may end in whitespace! diff --git a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs index 37107296..7e70931d 100644 --- a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs @@ -53,7 +53,7 @@ public partial class ThemePreviewControl : UserControl { yield return MockLibraryBook.CreateBook(title: "Some Book 1", subtitle: "The Theming", dateAdded: System.DateTime.Now.AddDays(4)).WithBookStatus(LiberatedStatus.Liberated); yield return MockLibraryBook.CreateBook(title: "Some Book 2", dateAdded: System.DateTime.Now.AddDays(3)).WithBookStatus(LiberatedStatus.PartialDownload); - yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absetFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated); + yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absentFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated); yield return MockLibraryBook.CreateBook(title: "Some Book 4", dateAdded: System.DateTime.Now.AddDays(1)).WithBookStatus(LiberatedStatus.Error); yield return MockLibraryBook.CreateBook(title: "Some Series", subtitle: "", contentType: ContentType.Parent).AddSeries("Some Series", 0); yield return MockLibraryBook.CreateBook(title: "Some Episode", subtitle: "Episode 1", contentType: ContentType.Episode).AddSeries("Some Series", 1); diff --git a/Source/LibationFileManager/Templates/CultureInfoDto.cs b/Source/LibationFileManager/Templates/CultureInfoDto.cs new file mode 100644 index 00000000..0b06b9cd --- /dev/null +++ b/Source/LibationFileManager/Templates/CultureInfoDto.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using FileManager.NamingTemplate; + +namespace LibationFileManager.Templates; + +public record CultureInfoDto : IFormattable +{ + private CultureInfo? Value { get; } + private string DefaultFormat { get; } + public string Original { get; } + + public static CultureInfoDto OfCurrentUi() + { + return new CultureInfoDto(CultureInfo.DefaultThreadCurrentUICulture ?? CultureInfo.CurrentUICulture, CultureInfo.CurrentUICulture.Name, "{N}"); + } + + public static CultureInfoDto OfCurrentOs() + { + return new CultureInfoDto(CultureInfo.DefaultThreadCurrentCulture ?? CultureInfo.CurrentCulture, CultureInfo.CurrentCulture.Name, "{N}"); + } + + public CultureInfoDto(string hint) : this(hint, "{O}") + { + } + + public CultureInfoDto(string hint, string defaultFormat) : this(GetCulture(hint), hint, defaultFormat) + { + } + + public CultureInfoDto(CultureInfo value, string hint, string defaultFormat) + { + Original = hint; + DefaultFormat = defaultFormat; + Value = value; + } + + private static CultureInfo? GetCulture(string input) + { + return + GetCulture(input, CultureTypes.NeutralCultures) ?? + GetCulture(input, CultureTypes.SpecificCultures); + } + + private static CultureInfo? GetCulture(string input, CultureTypes types) + { + var cultures = CultureInfo.GetCultures(types); + return Match(cultures, input, c => c.Name) ?? + Match(cultures, input, c => c.TwoLetterISOLanguageName) ?? + Match(cultures, input, c => c.ThreeLetterISOLanguageName) ?? + Match(cultures, input, c => c.EnglishName); + } + + private static CultureInfo? Match(IEnumerable cultures, string input, Func selector, StringComparison cmp = StringComparison.OrdinalIgnoreCase) + { + return cultures.FirstOrDefault(c => string.Equals(selector(c), input, cmp)); + } + + private static readonly Dictionary> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) + { + { "ID", dto => dto.Value?.Name }, + { "I", dto => dto.Value?.TwoLetterISOLanguageName }, + { "I2", dto => dto.Value?.TwoLetterISOLanguageName }, + { "I3", dto => dto.Value?.ThreeLetterISOLanguageName }, + { "W", dto => dto.Value?.ThreeLetterWindowsLanguageName }, + { "E", dto => dto.Value?.EnglishName }, + { "N", dto => dto.Value?.NativeName }, + { "O", dto => dto.Original }, + { "D", dto => dto.Value?.DisplayName }, // localized + }; + + public override string ToString() => ToString(DefaultFormat, CultureInfo.CurrentCulture); + + public string ToString(string? format, IFormatProvider? provider) + { + if (string.IsNullOrWhiteSpace(format)) format = DefaultFormat; + return format switch + { + _ when CommonFormatters.TagFormatRegex().IsMatch(format) => CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements), + _ when format == DefaultFormat => CommonFormatters._StringFormatter(Original, format, provider), + _ => CommonFormatters._StringFormatter(CommonFormatters.TemplateStringFormatter(this, DefaultFormat, provider, FormatReplacements), format, provider) + }; + } +} \ No newline at end of file diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index 29b98b01..7645089c 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -10,7 +10,7 @@ public class BookDto public string? Title { get; set; } public string? Subtitle { get; set; } public string? TitleWithSubtitle { get; set; } - public string? Locale { get; set; } + public RegionInfoDto? Locale { get; set; } public int? YearPublished { get; set; } public IEnumerable? Authors { get; set; } @@ -34,7 +34,7 @@ public class BookDto public string? Codec { get; set; } public DateTime FileDate { get; set; } = DateTime.Now; public DateTime? DatePublished { get; set; } - public string? Language { get; set; } + public CultureInfoDto? Language { get; set; } public string? LibationVersion { get; set; } public string? FileVersion { get; set; } } diff --git a/Source/LibationFileManager/Templates/RegionInfoDto.cs b/Source/LibationFileManager/Templates/RegionInfoDto.cs new file mode 100644 index 00000000..a2f8699e --- /dev/null +++ b/Source/LibationFileManager/Templates/RegionInfoDto.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using FileManager.NamingTemplate; + +namespace LibationFileManager.Templates; + +public partial record RegionInfoDto : IFormattable +{ + private RegionInfo? Value { get; } + private CultureInfo? Culture { get; } + private string DefaultFormat { get; } + public string Original { get; } + + public RegionInfoDto(string hint) : this(hint, "{O}") + { + } + + public RegionInfoDto(string hint, string defaultFormat) : this(GetRegion(hint), hint, defaultFormat) + { + } + + public RegionInfoDto(RegionInfo value, string hint, string defaultFormat) + { + Original = hint; + DefaultFormat = defaultFormat; + Value = value; + Culture = GetCultureInfo(value); + } + + private static RegionInfo? GetRegion(string input) + { + if (input.StartsWith("pre-amazon - ", StringComparison.OrdinalIgnoreCase)) input = input.Substring(13); + if (string.Equals(input, "uk", StringComparison.OrdinalIgnoreCase)) return new RegionInfo("GB"); + try + { + return new RegionInfo(input.ToUpperInvariant()); + } + catch + { + return CultureInfo.GetCultures(CultureTypes.SpecificCultures) + .Select(c => new RegionInfo(c.Name)) + .FirstOrDefault(r => + string.Equals(r.EnglishName, input, StringComparison.OrdinalIgnoreCase)); + } + } + + private string? GetLocalizedRegionName() + { + return Culture is null ? null : GetCountryName(Culture.DisplayName) ?? GetCountryName(Culture.EnglishName); + } + + private static string? GetCountryName(string localized) + { + return ExtractRegionName().Match(localized).Groups["displayName"].ValueOrNull(); + } + + [GeneratedRegex(@"\((?.+)\)")] + private static partial Regex ExtractRegionName(); + + private static CultureInfo GetCultureInfo(RegionInfo region) + { + // find culture for region + return CultureInfo.GetCultures(CultureTypes.SpecificCultures) + .First(c => Equals(new RegionInfo(c.Name), region)); + } + + + private static readonly Dictionary> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) + { + { "ID", dto => dto.Value?.Name }, + { "I", dto => dto.Value?.TwoLetterISORegionName }, + { "I2", dto => dto.Value?.TwoLetterISORegionName }, + { "I3", dto => dto.Value?.ThreeLetterISORegionName }, + { "W", dto => dto.Value?.ThreeLetterWindowsRegionName }, + { "E", dto => dto.Value?.EnglishName }, + { "N", dto => dto.Value?.NativeName }, + { "O", dto => dto.Original }, + { "D", dto => dto.GetLocalizedRegionName() }, // localized + }; + + public override string ToString() => ToString(DefaultFormat, CultureInfo.CurrentCulture); + + public string ToString(string? format, IFormatProvider? provider) + { + if (string.IsNullOrWhiteSpace(format)) format = DefaultFormat; + return format switch + { + _ when CommonFormatters.TagFormatRegex().IsMatch(format) => CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements), + _ when format == DefaultFormat => CommonFormatters._StringFormatter(Original, format, provider), + _ => CommonFormatters._StringFormatter(CommonFormatters.TemplateStringFormatter(this, DefaultFormat, provider, FormatReplacements), format, provider) + }; + } +} \ No newline at end of file diff --git a/Source/LibationFileManager/Templates/TemplateEditor[T].cs b/Source/LibationFileManager/Templates/TemplateEditor[T].cs index bb1b435f..9205b36c 100644 --- a/Source/LibationFileManager/Templates/TemplateEditor[T].cs +++ b/Source/LibationFileManager/Templates/TemplateEditor[T].cs @@ -63,7 +63,7 @@ public class TemplateEditor : ITemplateEditor where T : Templates, ITemplate, Title = "A Study in Scarlet", TitleWithSubtitle = "A Study in Scarlet: A Sherlock Holmes Novel", Subtitle = "A Sherlock Holmes Novel", - Locale = "us", + Locale = new RegionInfoDto("us"), YearPublished = 2017, Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [new("Stephen Fry", null)], @@ -74,7 +74,7 @@ public class TemplateEditor : ITemplateEditor where T : Templates, ITemplate, BitRate = 128, SampleRate = 44100, Channels = 2, - Language = "English" + Language = new CultureInfoDto("English"), }; private static readonly MultiConvertFileProperties DefaultMultipartProperties diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index 5c6cdf68..85a8cdd1 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -49,6 +49,8 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags YearPublished { get; } = new("year", "Year published"); public static TemplateTags Language { get; } = new("language", "Book's language"); public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG"); + public static TemplateTags UI { get; } = new("ui", "UI language"); + public static TemplateTags OS { get; } = new("os", "OS language"); public static TemplateTags FileDate { get; } = new("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"", ""); public static TemplateTags DatePublished { get; } = new("pub date", "Publication date. e.g. yyyy-MM-dd", $"", ""); diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 186dcfb4..3dfb9f69 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -278,12 +278,14 @@ public abstract class Templates { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter }, { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, CommonFormatters.FormattableFormatter }, - { TemplateTags.Language, lb => lb.Language }, + { TemplateTags.Language, lb => lb.Language, CommonFormatters.FormattableFormatter }, //Don't allow formatting of LanguageShort - { TemplateTags.LanguageShort, lb => lb.Language, CommonFormatters.LanguageShortFormatter }, + { TemplateTags.LanguageShort, lb => lb.Language?.Original, CommonFormatters.LanguageShortFormatter }, + { TemplateTags.UI, _ => CultureInfoDto.OfCurrentUi(), CommonFormatters.FormattableFormatter }, + { TemplateTags.OS, _ => CultureInfoDto.OfCurrentOs(), CommonFormatters.FormattableFormatter }, { TemplateTags.Account, lb => lb.Account }, { TemplateTags.AccountNickname, lb => lb.AccountNickname }, - { TemplateTags.Locale, lb => lb.Locale }, + { TemplateTags.Locale, lb => lb.Locale, CommonFormatters.FormattableFormatter }, { TemplateTags.YearPublished, lb => lb.YearPublished }, { TemplateTags.DatePublished, lb => lb.DatePublished }, { TemplateTags.DateAdded, lb => lb.DateAdded }, diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index ee97d4bb..449ebe84 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using static TemplatesTests.Shared; [assembly: Parallelize] @@ -43,7 +44,7 @@ namespace TemplatesTests FileDate = new DateTime(2023, 1, 28, 0, 0, 0), AudibleProductId = "asin", Title = "A Study in Scarlet: A Sherlock Holmes Novel", - Locale = "us", + Locale = new RegionInfoDto("us"), YearPublished = null, // explicitly null Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [], // explicitly empty list @@ -51,7 +52,7 @@ namespace TemplatesTests BitRate = 128, SampleRate = 44100, Channels = 2, - Language = "English", + Language = new CultureInfoDto("English"), Subtitle = "An Audible Original Drama", TitleWithSubtitle = "A Study in Scarlet: An Audible Original Drama", Codec = @"AAC[LC]\MP3", // special chars added @@ -761,6 +762,120 @@ namespace TemplatesTests .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }) .Should().Be(expected); } + + [TestMethod] + [DataRow("English", "", "English")] + [DataRow("English", "", "ENGL")] + [DataRow("English", "", "ENG")] + [DataRow("English", "", "ENG")] + [DataRow("English", "", "N:en, 2:en, 3:eng, W:ENU, D:inglés, E:English, N:English, O:English")] + [DataRow("en", "", "N:en, 2:en, 3:eng, W:ENU, D:inglés, E:English, N:English, O:en")] + [DataRow("fr", "", "N:fr, 2:fr, 3:fra, W:FRA, D:francés, E:French, N:français, O:fr")] + [DataRow("fr-ca", "", + "N:fr-CA, 2:fr, 3:fra, W:FRC, D:francés (Canadá), E:French (Canada), N:français (Canada), O:fr-ca")] + [DataRow("Any", "", + "N:es-ES, 2:es, 3:spa, W:ESN, D:español (España), E:Spanish (Spain), N:español (España), O:es-ES")] + [DataRow("Any", "", + "N:sv-SE, 2:sv, 3:swe, W:SVE, D:sueco (Suecia), E:Swedish (Sweden), N:svenska (Sverige), O:sv-SE")] + // different localizations + [DataRow("fr", "", "D:Französisch, E:French, N:français, O:fr")] + [DataRow("fr", "", "D:francuski")] + [DataRow("fr", "", "D:francese")] + public void Language_test(string language, string template, string expected) + { + var bookDto = Shared.GetLibraryBook(); + bookDto.Language = new CultureInfoDto(language); + + var result = ""; + + var old = Thread.CurrentThread.CurrentCulture; + var oldUi = Thread.CurrentThread.CurrentUICulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("sv-SE"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("es-ES"); + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + result = fileTemplate + .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }); + } + finally + { + Thread.CurrentThread.CurrentCulture = old; + Thread.CurrentThread.CurrentUICulture = oldUi; + } + + result.Should().Be(expected); + } + + [TestMethod] + // test known locales + [DataRow("us", "", + "N:US, 2:US, 3:USA, W:USA, D:Estados Unidos, E:United States, N:United States, O:us")] + [DataRow("uk", "", + "N:GB, 2:GB, 3:GBR, W:GBR, D:Reino Unido, E:United Kingdom, N:United Kingdom, O:uk")] + [DataRow("canada", "", + "N:CA, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, N:Canada, O:canada")] + [DataRow("germany", "", + "N:DE, 2:DE, 3:DEU, W:DEU, D:Alemania, E:Germany, N:Deutschland, O:germany")] + [DataRow("france", "", + "N:FR, 2:FR, 3:FRA, W:FRA, D:Francia, E:France, N:Frañs, O:france")] + [DataRow("australia", "", + "N:AU, 2:AU, 3:AUS, W:AUS, D:Australia, E:Australia, N:Australia, O:australia")] + [DataRow("japan", "", + "N:JP, 2:JP, 3:JPN, W:JPN, D:Japón, E:Japan, N:日本, O:japan")] + [DataRow("india", "", + "N:IN, 2:IN, 3:IND, W:IND, D:India, E:India, N:ভাৰত, O:india")] + [DataRow("spain", "", + "N:ES, 2:ES, 3:ESP, W:ESP, D:España, E:Spain, N:España, O:spain")] + [DataRow("italy", "", + "N:IT, 2:IT, 3:ITA, W:ITA, D:Italia, E:Italy, N:Itàlia, O:italy")] + [DataRow("brazil", "", + "N:BR, 2:BR, 3:BRA, W:BRA, D:Brasil, E:Brazil, N:Brasil, O:brazil")] + // test historical locales + [DataRow("pre-amazon - us", "", "N:US, O:pre-amazon - us")] + [DataRow("pre-amazon - uk", "", "N:GB, O:pre-amazon - uk")] + [DataRow("pre-amazon - germany", "", "N:DE, O:pre-amazon - germany")] + // test upcoming locales + [DataRow("be", "", "N:BE, E:Belgium, O:be")] + [DataRow("nl", "", "N:NL, E:Netherlands, O:nl")] + [DataRow("se", "", "N:SE, E:Sweden, O:se")] + [DataRow("pl", "", "N:PL, E:Poland, O:pl")] + [DataRow("ie", "", "N:IE, E:Ireland, O:ie")] + [DataRow("sg", "", "N:SG, E:Singapore, O:sg")] + [DataRow("za", "", "N:ZA, E:South Africa, O:za")] + [DataRow("tr", "", "N:TR, E:Turkey, O:tr")] + [DataRow("ae", "", "N:AE, E:United Arab Emirates, O:ae")] + [DataRow("sa", "", "N:SA, E:Saudi Arabia, O:sa")] + [DataRow("eg", "", "N:EG, E:Egypt, O:eg")] + // different localizations + [DataRow("fr", "", "D:Frankreich, E:France, N:France, O:fr")] + [DataRow("fr", "", "D:Francja")] + [DataRow("fr", "", "D:Francia")] + public void Region_test(string country, string template, string expected) + { + var bookDto = Shared.GetLibraryBook(); + bookDto.Locale = new RegionInfoDto(country); + + var result = ""; + + var old = Thread.CurrentThread.CurrentCulture; + var oldUi = Thread.CurrentThread.CurrentUICulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("es-ES"); + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + result = fileTemplate + .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }); + } + finally + { + Thread.CurrentThread.CurrentCulture = old; + Thread.CurrentThread.CurrentUICulture = oldUi; + } + + result.Should().Be(expected); + } } } diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 6917ec06..275b8ed5 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -37,9 +37,9 @@ These tags will be replaced in the template with the audiobook's values. | \ | Audible account nickname of this book | [Text](#text-formatters) | | \ | Tag(s) | [Text List](#text-list-formatters) | | \ | First tag | [Text](#text-formatters) | -| \ | Region/country | [Text](#text-formatters) | +| \ | Region/country | [Region](#region-formatters) | | \ | Year published | [Number](#number-formatters) | -| \ | Book's language | [Text](#text-formatters) | +| \ | Book's language | [Language](#language-formatters) | | \ **†** | Book's language abbreviated. Eg: ENG | Text | | \ | File creation date/time. | [DateTime](#date-formatters) | | \ | Audiobook publication date | [DateTime](#date-formatters) | @@ -185,7 +185,7 @@ Here, a number format is inserted for the desired part in accordance with [Micro |-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------|-------------------| | D | A number format with "D" instead of "0". Using this will output the total number of days and reduce the amount of minutes avalable for "H" and "M". | \ | 02 | | H | A number format with "H" instead of "0". Using this will output the total number of hours and reduce the amount of minutes available for "M". | \ | 62 | -| M | A number format with "H" instead of "0". Using this will output the total number of minutes. | \ | 3,762 | +| M | A number format with "M" instead of "0". Using this will output the total number of minutes. | \ | 3,762 | | D H M | A combination of the above. | \ | 02days 882minutes | ### Number Formatters From b4f48cb54cadf71764b47a1aa9a443441facae0f Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Thu, 9 Apr 2026 02:33:05 +0200 Subject: [PATCH 2/8] Extend and tags to allow different outputs like ISO-codes or names in different languages --- Source/DataLayer/EfClasses/Book.cs | 2 +- Source/DataLayer/MockLibraryBook.cs | 4 +- Source/FileLiberator/UtilityExtensions.cs | 4 +- .../NamingTemplate/CommonFormatters.cs | 40 ++++-- .../ConditionalTagCollection[TClass].cs | 2 +- .../PropertyTagCollection[TClass].cs | 2 +- .../Controls/ThemePreviewControl.axaml.cs | 2 +- .../Templates/CultureInfoDto.cs | 86 +++++++++++++ .../Templates/LibraryBookDto.cs | 4 +- .../Templates/RegionInfoDto.cs | 96 ++++++++++++++ .../Templates/TemplateEditor[T].cs | 4 +- .../Templates/TemplateTags.cs | 2 + .../Templates/Templates.cs | 8 +- .../TemplatesTests.cs | 119 +++++++++++++++++- docs/features/naming-templates.md | 6 +- 15 files changed, 348 insertions(+), 33 deletions(-) create mode 100644 Source/LibationFileManager/Templates/CultureInfoDto.cs create mode 100644 Source/LibationFileManager/Templates/RegionInfoDto.cs diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 57566f3c..52801dbd 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -256,7 +256,7 @@ public class Book IsAbridged |= isAbridged; IsSpatial = isSpatial ?? IsSpatial; DatePublished = datePublished ?? DatePublished; - Language = language?.FirstCharToUpper() ?? Language; + Language = language?.Trim().FirstCharToUpper() ?? Language; } public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}"; diff --git a/Source/DataLayer/MockLibraryBook.cs b/Source/DataLayer/MockLibraryBook.cs index 26b4cfe7..e37f91b5 100644 --- a/Source/DataLayer/MockLibraryBook.cs +++ b/Source/DataLayer/MockLibraryBook.cs @@ -73,7 +73,7 @@ public class MockLibraryBook : LibraryBook public static MockLibraryBook CreateBook( string account = "someone@email.co", - bool absetFromLastScan = false, + bool absentFromLastScan = false, DateTime? dateAdded = null, DateTime? datePublished = null, DateTime? includedUntil = null, @@ -120,7 +120,7 @@ public class MockLibraryBook : LibraryBook includedUntil, isAudiblePlus) { - AbsentFromLastScan = absetFromLastScan + AbsentFromLastScan = absentFromLastScan }; } diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 5c534a36..29f5f050 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -59,7 +59,7 @@ public static class UtilityExtensions Title = libraryBook.Book.Title, Subtitle = libraryBook.Book.Subtitle, TitleWithSubtitle = libraryBook.Book.TitleWithSubtitle, - Locale = libraryBook.Book.Locale, + Locale = new RegionInfoDto(libraryBook.Book.Locale), YearPublished = libraryBook.Book.DatePublished?.Year, DatePublished = libraryBook.Book.DatePublished, @@ -72,7 +72,7 @@ public static class UtilityExtensions IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), LengthInMinutes = TimeSpan.FromMinutes(libraryBook.Book.LengthInMinutes), - Language = libraryBook.Book.Language?.Trim(), + Language = libraryBook.Book.Language is null ? null : new CultureInfoDto(libraryBook.Book.Language), Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate, diff --git a/Source/FileManager/NamingTemplate/CommonFormatters.cs b/Source/FileManager/NamingTemplate/CommonFormatters.cs index 5fea2f7b..ef4d8efc 100644 --- a/Source/FileManager/NamingTemplate/CommonFormatters.cs +++ b/Source/FileManager/NamingTemplate/CommonFormatters.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; +using System.Threading; namespace FileManager.NamingTemplate; @@ -32,6 +33,9 @@ public static partial class CommonFormatters public static string StringFormatter(ITemplateTag _, string? value, string? formatString, CultureInfo? culture) => _StringFormatter(value, formatString, culture); + public static string _StringFormatter(string? value, string? formatString, IFormatProvider? provider) + => _StringFormatter(value, formatString, GetCultureInfo(provider)); + private static string _StringFormatter(string? value, string? formatString, CultureInfo? culture) { if (string.IsNullOrEmpty(value)) return string.Empty; @@ -61,24 +65,36 @@ public static partial class CommonFormatters if (string.IsNullOrWhiteSpace(templateString)) return ""; // is this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider - var culture = provider as CultureInfo ?? provider?.GetFormat(typeof(CultureInfo)) as CultureInfo; - return CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().Replace(templateString, GetValueForMatchingTag), ""); + var culture = GetCultureInfo(provider); + var oldUiCulture = Thread.CurrentThread.CurrentUICulture; + var result = CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().Replace(templateString, GetValueForMatchingTag), ""); + Thread.CurrentThread.CurrentUICulture = oldUiCulture; + return result; string GetValueForMatchingTag(Match m) { var tag = m.Groups["tag"].Value; if (!replacements.TryGetValue(tag, out var getter)) return m.Value; + var lang = m.Groups["lang"].ValueOrNull(); + var cultureToUse = lang is null ? culture : CultureInfo.GetCultureInfo(lang); + Thread.CurrentThread.CurrentUICulture = cultureToUse ?? oldUiCulture; + var value = getter(toFormat); var format = m.Groups["format"].ValueOrNull(); return value switch { - IFormattable formattable => formattable.ToString(format, provider), - _ => _StringFormatter(value?.ToString(), format, culture), + IFormattable formattable => formattable.ToString(format, cultureToUse), + _ => _StringFormatter(value?.ToString(), format, cultureToUse), }; } } + private static CultureInfo? GetCultureInfo(IFormatProvider? provider) + { + return provider as CultureInfo ?? provider?.GetFormat(typeof(CultureInfo)) as CultureInfo; + } + // Matches runs of spaces followed by a space as well as runs of spaces at the beginning or the end of a string (does NOT touch tabs/newlines). [GeneratedRegex(@"^ +| +(?=$| )")] private static partial Regex CollapseSpacesAndTrimRegex(); @@ -87,8 +103,8 @@ public static partial class CommonFormatters // The tagname may be followed by an optional format specifier separated by a colon. // All other parts of the template string are left untouched as well as the braces where the tagname is unknown. // TemplateStringFormatter will use a dictionary to lookup the tagname and the corresponding value getter. - [GeneratedRegex("""\{(?[A-Z]+|#)(?::(?(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)] - private static partial Regex TagFormatRegex(); + [GeneratedRegex("""\{(?[A-Z0-9]+|#)(?:@(?[a-z-]+))?(?::(?(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)] + public static partial Regex TagFormatRegex(); public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture) => value?.ToString(formatString, culture) ?? ""; @@ -100,12 +116,10 @@ public static partial class CommonFormatters { culture ??= CultureInfo.CurrentCulture; if (!int.TryParse(formatString, out var numDigits) || numDigits <= 0) return value.ToString(formatString, culture); - //Zero-pad the integer part - var strValue = value.ToString(culture); - var decIndex = culture.CompareInfo.IndexOf(strValue, culture.NumberFormat.NumberDecimalSeparator); - var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex); - return new string('0', zeroPad) + strValue; + //Zero-pad the integer part + formatString = new string('0', numDigits) + ".################"; + return value.ToString(formatString, culture); } public static string MinutesFormatter(ITemplateTag templateTag, TimeSpan value, string? formatString, CultureInfo? culture) @@ -163,7 +177,7 @@ public static partial class CommonFormatters [GeneratedRegex(""" (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # (?<=\G(?: # We lookbehind up to the start or the end of the last match for a number format. - \\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + \\. # - '\' escapes always the next character. Especially further '\' and the closing ']' | '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character | . # - match any character. This will not catch the number format at first. Because ... @@ -172,7 +186,7 @@ public static partial class CommonFormatters (?:\#[\#,.]*)? # - For grouping a number format may start with `#` and grouping hints `,` or even a decimal point `.`. D # - At least one unescaped, unquoted uppercase D must be included in the format to indicate that this is a total days format. (?:(?: # - Before further D's, there may be any combination of escaped characters and quoted strings. - \\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + \\. # - '\' escapes always the next character. Especially further '\' and the closing ']' | '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character )* [\#,.%‰D]+ # After escaped characters and quoted strings, there needs to be at least one more real number format character (which may be D as well). diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 3e3bdb5f..8ed37a69 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -116,7 +116,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) (? end with a whitepace. Otherwise "" would be matchable. (?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. So match whitespace first (? # - capture inner part as - (?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + (?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']' |[^\\\]])* ) # - match any character except '\' and ']'. Check may end in whitespace! \])? # - closing the check part )? # end of optional property and check part diff --git a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs index 2d43957c..81edd5ba 100644 --- a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs @@ -194,7 +194,7 @@ public class PropertyTagCollection : TagCollection {TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#' (?:\s* # optional whitespace \[ (? # optional format details enclosed in '[' and ']'. Capture inner part as . - (?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + (?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']' |'(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character |"(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character |[^\\\]])* ) # - match any character except '\' and ']'. Format may end in whitespace! diff --git a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs index 37107296..7e70931d 100644 --- a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs @@ -53,7 +53,7 @@ public partial class ThemePreviewControl : UserControl { yield return MockLibraryBook.CreateBook(title: "Some Book 1", subtitle: "The Theming", dateAdded: System.DateTime.Now.AddDays(4)).WithBookStatus(LiberatedStatus.Liberated); yield return MockLibraryBook.CreateBook(title: "Some Book 2", dateAdded: System.DateTime.Now.AddDays(3)).WithBookStatus(LiberatedStatus.PartialDownload); - yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absetFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated); + yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absentFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated); yield return MockLibraryBook.CreateBook(title: "Some Book 4", dateAdded: System.DateTime.Now.AddDays(1)).WithBookStatus(LiberatedStatus.Error); yield return MockLibraryBook.CreateBook(title: "Some Series", subtitle: "", contentType: ContentType.Parent).AddSeries("Some Series", 0); yield return MockLibraryBook.CreateBook(title: "Some Episode", subtitle: "Episode 1", contentType: ContentType.Episode).AddSeries("Some Series", 1); diff --git a/Source/LibationFileManager/Templates/CultureInfoDto.cs b/Source/LibationFileManager/Templates/CultureInfoDto.cs new file mode 100644 index 00000000..0b06b9cd --- /dev/null +++ b/Source/LibationFileManager/Templates/CultureInfoDto.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using FileManager.NamingTemplate; + +namespace LibationFileManager.Templates; + +public record CultureInfoDto : IFormattable +{ + private CultureInfo? Value { get; } + private string DefaultFormat { get; } + public string Original { get; } + + public static CultureInfoDto OfCurrentUi() + { + return new CultureInfoDto(CultureInfo.DefaultThreadCurrentUICulture ?? CultureInfo.CurrentUICulture, CultureInfo.CurrentUICulture.Name, "{N}"); + } + + public static CultureInfoDto OfCurrentOs() + { + return new CultureInfoDto(CultureInfo.DefaultThreadCurrentCulture ?? CultureInfo.CurrentCulture, CultureInfo.CurrentCulture.Name, "{N}"); + } + + public CultureInfoDto(string hint) : this(hint, "{O}") + { + } + + public CultureInfoDto(string hint, string defaultFormat) : this(GetCulture(hint), hint, defaultFormat) + { + } + + public CultureInfoDto(CultureInfo value, string hint, string defaultFormat) + { + Original = hint; + DefaultFormat = defaultFormat; + Value = value; + } + + private static CultureInfo? GetCulture(string input) + { + return + GetCulture(input, CultureTypes.NeutralCultures) ?? + GetCulture(input, CultureTypes.SpecificCultures); + } + + private static CultureInfo? GetCulture(string input, CultureTypes types) + { + var cultures = CultureInfo.GetCultures(types); + return Match(cultures, input, c => c.Name) ?? + Match(cultures, input, c => c.TwoLetterISOLanguageName) ?? + Match(cultures, input, c => c.ThreeLetterISOLanguageName) ?? + Match(cultures, input, c => c.EnglishName); + } + + private static CultureInfo? Match(IEnumerable cultures, string input, Func selector, StringComparison cmp = StringComparison.OrdinalIgnoreCase) + { + return cultures.FirstOrDefault(c => string.Equals(selector(c), input, cmp)); + } + + private static readonly Dictionary> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) + { + { "ID", dto => dto.Value?.Name }, + { "I", dto => dto.Value?.TwoLetterISOLanguageName }, + { "I2", dto => dto.Value?.TwoLetterISOLanguageName }, + { "I3", dto => dto.Value?.ThreeLetterISOLanguageName }, + { "W", dto => dto.Value?.ThreeLetterWindowsLanguageName }, + { "E", dto => dto.Value?.EnglishName }, + { "N", dto => dto.Value?.NativeName }, + { "O", dto => dto.Original }, + { "D", dto => dto.Value?.DisplayName }, // localized + }; + + public override string ToString() => ToString(DefaultFormat, CultureInfo.CurrentCulture); + + public string ToString(string? format, IFormatProvider? provider) + { + if (string.IsNullOrWhiteSpace(format)) format = DefaultFormat; + return format switch + { + _ when CommonFormatters.TagFormatRegex().IsMatch(format) => CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements), + _ when format == DefaultFormat => CommonFormatters._StringFormatter(Original, format, provider), + _ => CommonFormatters._StringFormatter(CommonFormatters.TemplateStringFormatter(this, DefaultFormat, provider, FormatReplacements), format, provider) + }; + } +} \ No newline at end of file diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index 29b98b01..7645089c 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -10,7 +10,7 @@ public class BookDto public string? Title { get; set; } public string? Subtitle { get; set; } public string? TitleWithSubtitle { get; set; } - public string? Locale { get; set; } + public RegionInfoDto? Locale { get; set; } public int? YearPublished { get; set; } public IEnumerable? Authors { get; set; } @@ -34,7 +34,7 @@ public class BookDto public string? Codec { get; set; } public DateTime FileDate { get; set; } = DateTime.Now; public DateTime? DatePublished { get; set; } - public string? Language { get; set; } + public CultureInfoDto? Language { get; set; } public string? LibationVersion { get; set; } public string? FileVersion { get; set; } } diff --git a/Source/LibationFileManager/Templates/RegionInfoDto.cs b/Source/LibationFileManager/Templates/RegionInfoDto.cs new file mode 100644 index 00000000..a2f8699e --- /dev/null +++ b/Source/LibationFileManager/Templates/RegionInfoDto.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using FileManager.NamingTemplate; + +namespace LibationFileManager.Templates; + +public partial record RegionInfoDto : IFormattable +{ + private RegionInfo? Value { get; } + private CultureInfo? Culture { get; } + private string DefaultFormat { get; } + public string Original { get; } + + public RegionInfoDto(string hint) : this(hint, "{O}") + { + } + + public RegionInfoDto(string hint, string defaultFormat) : this(GetRegion(hint), hint, defaultFormat) + { + } + + public RegionInfoDto(RegionInfo value, string hint, string defaultFormat) + { + Original = hint; + DefaultFormat = defaultFormat; + Value = value; + Culture = GetCultureInfo(value); + } + + private static RegionInfo? GetRegion(string input) + { + if (input.StartsWith("pre-amazon - ", StringComparison.OrdinalIgnoreCase)) input = input.Substring(13); + if (string.Equals(input, "uk", StringComparison.OrdinalIgnoreCase)) return new RegionInfo("GB"); + try + { + return new RegionInfo(input.ToUpperInvariant()); + } + catch + { + return CultureInfo.GetCultures(CultureTypes.SpecificCultures) + .Select(c => new RegionInfo(c.Name)) + .FirstOrDefault(r => + string.Equals(r.EnglishName, input, StringComparison.OrdinalIgnoreCase)); + } + } + + private string? GetLocalizedRegionName() + { + return Culture is null ? null : GetCountryName(Culture.DisplayName) ?? GetCountryName(Culture.EnglishName); + } + + private static string? GetCountryName(string localized) + { + return ExtractRegionName().Match(localized).Groups["displayName"].ValueOrNull(); + } + + [GeneratedRegex(@"\((?.+)\)")] + private static partial Regex ExtractRegionName(); + + private static CultureInfo GetCultureInfo(RegionInfo region) + { + // find culture for region + return CultureInfo.GetCultures(CultureTypes.SpecificCultures) + .First(c => Equals(new RegionInfo(c.Name), region)); + } + + + private static readonly Dictionary> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) + { + { "ID", dto => dto.Value?.Name }, + { "I", dto => dto.Value?.TwoLetterISORegionName }, + { "I2", dto => dto.Value?.TwoLetterISORegionName }, + { "I3", dto => dto.Value?.ThreeLetterISORegionName }, + { "W", dto => dto.Value?.ThreeLetterWindowsRegionName }, + { "E", dto => dto.Value?.EnglishName }, + { "N", dto => dto.Value?.NativeName }, + { "O", dto => dto.Original }, + { "D", dto => dto.GetLocalizedRegionName() }, // localized + }; + + public override string ToString() => ToString(DefaultFormat, CultureInfo.CurrentCulture); + + public string ToString(string? format, IFormatProvider? provider) + { + if (string.IsNullOrWhiteSpace(format)) format = DefaultFormat; + return format switch + { + _ when CommonFormatters.TagFormatRegex().IsMatch(format) => CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements), + _ when format == DefaultFormat => CommonFormatters._StringFormatter(Original, format, provider), + _ => CommonFormatters._StringFormatter(CommonFormatters.TemplateStringFormatter(this, DefaultFormat, provider, FormatReplacements), format, provider) + }; + } +} \ No newline at end of file diff --git a/Source/LibationFileManager/Templates/TemplateEditor[T].cs b/Source/LibationFileManager/Templates/TemplateEditor[T].cs index bb1b435f..9205b36c 100644 --- a/Source/LibationFileManager/Templates/TemplateEditor[T].cs +++ b/Source/LibationFileManager/Templates/TemplateEditor[T].cs @@ -63,7 +63,7 @@ public class TemplateEditor : ITemplateEditor where T : Templates, ITemplate, Title = "A Study in Scarlet", TitleWithSubtitle = "A Study in Scarlet: A Sherlock Holmes Novel", Subtitle = "A Sherlock Holmes Novel", - Locale = "us", + Locale = new RegionInfoDto("us"), YearPublished = 2017, Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [new("Stephen Fry", null)], @@ -74,7 +74,7 @@ public class TemplateEditor : ITemplateEditor where T : Templates, ITemplate, BitRate = 128, SampleRate = 44100, Channels = 2, - Language = "English" + Language = new CultureInfoDto("English"), }; private static readonly MultiConvertFileProperties DefaultMultipartProperties diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index 5c6cdf68..85a8cdd1 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -49,6 +49,8 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags YearPublished { get; } = new("year", "Year published"); public static TemplateTags Language { get; } = new("language", "Book's language"); public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG"); + public static TemplateTags UI { get; } = new("ui", "UI language"); + public static TemplateTags OS { get; } = new("os", "OS language"); public static TemplateTags FileDate { get; } = new("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"", ""); public static TemplateTags DatePublished { get; } = new("pub date", "Publication date. e.g. yyyy-MM-dd", $"", ""); diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 186dcfb4..3dfb9f69 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -278,12 +278,14 @@ public abstract class Templates { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter }, { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, CommonFormatters.FormattableFormatter }, - { TemplateTags.Language, lb => lb.Language }, + { TemplateTags.Language, lb => lb.Language, CommonFormatters.FormattableFormatter }, //Don't allow formatting of LanguageShort - { TemplateTags.LanguageShort, lb => lb.Language, CommonFormatters.LanguageShortFormatter }, + { TemplateTags.LanguageShort, lb => lb.Language?.Original, CommonFormatters.LanguageShortFormatter }, + { TemplateTags.UI, _ => CultureInfoDto.OfCurrentUi(), CommonFormatters.FormattableFormatter }, + { TemplateTags.OS, _ => CultureInfoDto.OfCurrentOs(), CommonFormatters.FormattableFormatter }, { TemplateTags.Account, lb => lb.Account }, { TemplateTags.AccountNickname, lb => lb.AccountNickname }, - { TemplateTags.Locale, lb => lb.Locale }, + { TemplateTags.Locale, lb => lb.Locale, CommonFormatters.FormattableFormatter }, { TemplateTags.YearPublished, lb => lb.YearPublished }, { TemplateTags.DatePublished, lb => lb.DatePublished }, { TemplateTags.DateAdded, lb => lb.DateAdded }, diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index ee97d4bb..6388d968 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using static TemplatesTests.Shared; [assembly: Parallelize] @@ -43,7 +44,7 @@ namespace TemplatesTests FileDate = new DateTime(2023, 1, 28, 0, 0, 0), AudibleProductId = "asin", Title = "A Study in Scarlet: A Sherlock Holmes Novel", - Locale = "us", + Locale = new RegionInfoDto("us"), YearPublished = null, // explicitly null Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [], // explicitly empty list @@ -51,7 +52,7 @@ namespace TemplatesTests BitRate = 128, SampleRate = 44100, Channels = 2, - Language = "English", + Language = new CultureInfoDto("English"), Subtitle = "An Audible Original Drama", TitleWithSubtitle = "A Study in Scarlet: An Audible Original Drama", Codec = @"AAC[LC]\MP3", // special chars added @@ -761,6 +762,120 @@ namespace TemplatesTests .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }) .Should().Be(expected); } + + [TestMethod] + [DataRow("English", "", "English")] + [DataRow("English", "", "ENGL")] + [DataRow("English", "", "ENG")] + [DataRow("English", "", "ENG")] + [DataRow("English", "", "ID:en, 2:en, 3:eng, W:ENU, D:inglés, E:English, N:English, O:English")] + [DataRow("en", "", "ID:en, 2:en, 3:eng, W:ENU, D:inglés, E:English, N:English, O:en")] + [DataRow("fr", "", "ID:fr, 2:fr, 3:fra, W:FRA, D:francés, E:French, N:français, O:fr")] + [DataRow("fr-ca", "", + "ID:fr-CA, 2:fr, 3:fra, W:FRC, D:francés (Canadá), E:French (Canada), N:français (Canada), O:fr-ca")] + [DataRow("Any", "", + "ID:es-ES, 2:es, 3:spa, W:ESN, D:español (España), E:Spanish (Spain), N:español (España), O:es-ES")] + [DataRow("Any", "", + "ID:sv-SE, 2:sv, 3:swe, W:SVE, D:sueco (Suecia), E:Swedish (Sweden), N:svenska (Sverige), O:sv-SE")] + // different localizations + [DataRow("fr", "", "D:Französisch, E:French, N:français, O:fr")] + [DataRow("fr", "", "D:francuski")] + [DataRow("fr", "", "D:francese")] + public void Language_test(string language, string template, string expected) + { + var bookDto = Shared.GetLibraryBook(); + bookDto.Language = new CultureInfoDto(language); + + var result = ""; + + var old = Thread.CurrentThread.CurrentCulture; + var oldUi = Thread.CurrentThread.CurrentUICulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("sv-SE"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("es-ES"); + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + result = fileTemplate + .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }); + } + finally + { + Thread.CurrentThread.CurrentCulture = old; + Thread.CurrentThread.CurrentUICulture = oldUi; + } + + result.Should().Be(expected); + } + + [TestMethod] + // test known locales + [DataRow("us", "", + "ID:US, 2:US, 3:USA, W:USA, D:Estados Unidos, E:United States, N:United States, O:us")] + [DataRow("uk", "", + "ID:GB, 2:GB, 3:GBR, W:GBR, D:Reino Unido, E:United Kingdom, N:United Kingdom, O:uk")] + [DataRow("canada", "", + "ID:CA, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, N:Canada, O:canada")] + [DataRow("germany", "", + "ID:DE, 2:DE, 3:DEU, W:DEU, D:Alemania, E:Germany, N:Deutschland, O:germany")] + [DataRow("france", "", + "ID:FR, 2:FR, 3:FRA, W:FRA, D:Francia, E:France, N:Frañs, O:france")] + [DataRow("australia", "", + "ID:AU, 2:AU, 3:AUS, W:AUS, D:Australia, E:Australia, N:Australia, O:australia")] + [DataRow("japan", "", + "ID:JP, 2:JP, 3:JPN, W:JPN, D:Japón, E:Japan, N:日本, O:japan")] + [DataRow("india", "", + "ID:IN, 2:IN, 3:IND, W:IND, D:India, E:India, N:ভাৰত, O:india")] + [DataRow("spain", "", + "ID:ES, 2:ES, 3:ESP, W:ESP, D:España, E:Spain, N:España, O:spain")] + [DataRow("italy", "", + "ID:IT, 2:IT, 3:ITA, W:ITA, D:Italia, E:Italy, N:Itàlia, O:italy")] + [DataRow("brazil", "", + "ID:BR, 2:BR, 3:BRA, W:BRA, D:Brasil, E:Brazil, N:Brasil, O:brazil")] + // test historical locales + [DataRow("pre-amazon - us", "", "ID:US, O:pre-amazon - us")] + [DataRow("pre-amazon - uk", "", "ID:GB, O:pre-amazon - uk")] + [DataRow("pre-amazon - germany", "", "ID:DE, O:pre-amazon - germany")] + // test upcoming locales + [DataRow("be", "", "ID:BE, E:Belgium, O:be")] + [DataRow("nl", "", "ID:NL, E:Netherlands, O:nl")] + [DataRow("se", "", "ID:SE, E:Sweden, O:se")] + [DataRow("pl", "", "ID:PL, E:Poland, O:pl")] + [DataRow("ie", "", "ID:IE, E:Ireland, O:ie")] + [DataRow("sg", "", "ID:SG, E:Singapore, O:sg")] + [DataRow("za", "", "ID:ZA, E:South Africa, O:za")] + [DataRow("tr", "", "ID:TR, E:Turkey, O:tr")] + [DataRow("ae", "", "ID:AE, E:United Arab Emirates, O:ae")] + [DataRow("sa", "", "ID:SA, E:Saudi Arabia, O:sa")] + [DataRow("eg", "", "ID:EG, E:Egypt, O:eg")] + // different localizations + [DataRow("fr", "", "D:Frankreich, E:France, N:France, O:fr")] + [DataRow("fr", "", "D:Francja")] + [DataRow("fr", "", "D:Francia")] + public void Region_test(string country, string template, string expected) + { + var bookDto = Shared.GetLibraryBook(); + bookDto.Locale = new RegionInfoDto(country); + + var result = ""; + + var old = Thread.CurrentThread.CurrentCulture; + var oldUi = Thread.CurrentThread.CurrentUICulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("es-ES"); + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + result = fileTemplate + .GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }); + } + finally + { + Thread.CurrentThread.CurrentCulture = old; + Thread.CurrentThread.CurrentUICulture = oldUi; + } + + result.Should().Be(expected); + } } } diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 6917ec06..275b8ed5 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -37,9 +37,9 @@ These tags will be replaced in the template with the audiobook's values. | \ | Audible account nickname of this book | [Text](#text-formatters) | | \ | Tag(s) | [Text List](#text-list-formatters) | | \ | First tag | [Text](#text-formatters) | -| \ | Region/country | [Text](#text-formatters) | +| \ | Region/country | [Region](#region-formatters) | | \ | Year published | [Number](#number-formatters) | -| \ | Book's language | [Text](#text-formatters) | +| \ | Book's language | [Language](#language-formatters) | | \ **†** | Book's language abbreviated. Eg: ENG | Text | | \ | File creation date/time. | [DateTime](#date-formatters) | | \ | Audiobook publication date | [DateTime](#date-formatters) | @@ -185,7 +185,7 @@ Here, a number format is inserted for the desired part in accordance with [Micro |-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------|-------------------| | D | A number format with "D" instead of "0". Using this will output the total number of days and reduce the amount of minutes avalable for "H" and "M". | \ | 02 | | H | A number format with "H" instead of "0". Using this will output the total number of hours and reduce the amount of minutes available for "M". | \ | 62 | -| M | A number format with "H" instead of "0". Using this will output the total number of minutes. | \ | 3,762 | +| M | A number format with "M" instead of "0". Using this will output the total number of minutes. | \ | 3,762 | | D H M | A combination of the above. | \ | 02days 882minutes | ### Number Formatters From 54765fc1818ad93439a7d87caf3c8ac28a7e469d Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Fri, 10 Apr 2026 10:14:19 +0200 Subject: [PATCH 3/8] Fixed cross-platform problems on region tests --- .../_Tests/LibationFileManager.Tests/TemplatesTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 6388d968..4cf0d0eb 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -813,8 +813,10 @@ namespace TemplatesTests "ID:US, 2:US, 3:USA, W:USA, D:Estados Unidos, E:United States, N:United States, O:us")] [DataRow("uk", "", "ID:GB, 2:GB, 3:GBR, W:GBR, D:Reino Unido, E:United Kingdom, N:United Kingdom, O:uk")] - [DataRow("canada", "", - "ID:CA, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, N:Canada, O:canada")] + // Skip NativeName: on Linux/ICU the native name for Canada is returned as the Inuktitut form 'ᑲᓇᑕ', while Windows returns 'Canada'. + // Because this value depends on the OS/globalization provider, it cannot be used for stable cross-platform tests. + [DataRow("canada", "", + "ID:CA, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, N:---, O:canada")] [DataRow("germany", "", "ID:DE, 2:DE, 3:DEU, W:DEU, D:Alemania, E:Germany, N:Deutschland, O:germany")] [DataRow("france", "", @@ -843,7 +845,9 @@ namespace TemplatesTests [DataRow("ie", "", "ID:IE, E:Ireland, O:ie")] [DataRow("sg", "", "ID:SG, E:Singapore, O:sg")] [DataRow("za", "", "ID:ZA, E:South Africa, O:za")] - [DataRow("tr", "", "ID:TR, E:Turkey, O:tr")] + // Skip EnglishName: the official English name of Turkey changed to 'Türkiye', and the returned value now depends on + // the OS/globalization provider (Windows-NLS vs. ICU). Tests would not be stable. + [DataRow("tr", "", "ID:TR, E:---, O:tr")] [DataRow("ae", "", "ID:AE, E:United Arab Emirates, O:ae")] [DataRow("sa", "", "ID:SA, E:Saudi Arabia, O:sa")] [DataRow("eg", "", "ID:EG, E:Egypt, O:eg")] From f4e61d0445d11c1a7096fd099f3ec8190f48225f Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Fri, 10 Apr 2026 11:44:28 +0200 Subject: [PATCH 4/8] hardend an commented unit tests for region output --- .../TemplatesTests.cs | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 4cf0d0eb..68106541 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -808,35 +808,39 @@ namespace TemplatesTests } [TestMethod] + // Audible does not provide a consistent or authoritative region code for its storefronts. + // In most cases, the storefront region can be inferred from the EnglishName of a matching RegionInfo entry. However, + // the US and UK storefronts do not follow this pattern, and the three historical “pre‑Amazon” storefront identifiers require + // separate interpretation to remain globally usable for all users. + // To ensure robustness, the tests attempt to cover all known Audible storefronts explicitly. + + // Skipping of NativeName: its output is influenced by external standards bodies and evolving globalization data (NLS vs. ICU), + // not solely by the OSPlatform. + // Because .NET provides no stability guarantees for NativeName across platforms or ICU/NLS versions, we do not include + // platform-specific tests here—unlike path-related differences, which are defined and testable. + // test known locales [DataRow("us", "", "ID:US, 2:US, 3:USA, W:USA, D:Estados Unidos, E:United States, N:United States, O:us")] [DataRow("uk", "", "ID:GB, 2:GB, 3:GBR, W:GBR, D:Reino Unido, E:United Kingdom, N:United Kingdom, O:uk")] - // Skip NativeName: on Linux/ICU the native name for Canada is returned as the Inuktitut form 'ᑲᓇᑕ', while Windows returns 'Canada'. - // Because this value depends on the OS/globalization provider, it cannot be used for stable cross-platform tests. - [DataRow("canada", "", - "ID:CA, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, N:---, O:canada")] [DataRow("germany", "", "ID:DE, 2:DE, 3:DEU, W:DEU, D:Alemania, E:Germany, N:Deutschland, O:germany")] - [DataRow("france", "", - "ID:FR, 2:FR, 3:FRA, W:FRA, D:Francia, E:France, N:Frañs, O:france")] - [DataRow("australia", "", - "ID:AU, 2:AU, 3:AUS, W:AUS, D:Australia, E:Australia, N:Australia, O:australia")] - [DataRow("japan", "", - "ID:JP, 2:JP, 3:JPN, W:JPN, D:Japón, E:Japan, N:日本, O:japan")] - [DataRow("india", "", - "ID:IN, 2:IN, 3:IND, W:IND, D:India, E:India, N:ভাৰত, O:india")] - [DataRow("spain", "", - "ID:ES, 2:ES, 3:ESP, W:ESP, D:España, E:Spain, N:España, O:spain")] - [DataRow("italy", "", - "ID:IT, 2:IT, 3:ITA, W:ITA, D:Italia, E:Italy, N:Itàlia, O:italy")] - [DataRow("brazil", "", - "ID:BR, 2:BR, 3:BRA, W:BRA, D:Brasil, E:Brazil, N:Brasil, O:brazil")] + // Skip NativeName (see above) + [DataRow("france", "", "ID:FR, 2:FR, 3:FRA, W:FRA, D:Francia, E:France, O:france")] + [DataRow("australia", "", "ID:AU, 2:AU, 3:AUS, W:AUS, D:Australia, E:Australia, O:australia")] + [DataRow("india", "", "ID:IN, 2:IN, 3:IND, W:IND, D:India, E:India, O:india")] + [DataRow("spain", "", "ID:ES, 2:ES, 3:ESP, W:ESP, D:España, E:Spain, O:spain")] + [DataRow("italy", "", "ID:IT, 2:IT, 3:ITA, W:ITA, D:Italia, E:Italy, O:italy")] + [DataRow("canada", "", "ID:CA, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, O:canada")] + [DataRow("japan", "", "ID:JP, 2:JP, 3:JPN, W:JPN, D:Japón, E:Japan, O:japan")] + [DataRow("brazil", "", "ID:BR, 2:BR, 3:BRA, W:BRA, D:Brasil, E:Brazil, O:brazil")] + // test historical locales [DataRow("pre-amazon - us", "", "ID:US, O:pre-amazon - us")] [DataRow("pre-amazon - uk", "", "ID:GB, O:pre-amazon - uk")] [DataRow("pre-amazon - germany", "", "ID:DE, O:pre-amazon - germany")] + // test upcoming locales [DataRow("be", "", "ID:BE, E:Belgium, O:be")] [DataRow("nl", "", "ID:NL, E:Netherlands, O:nl")] @@ -845,13 +849,15 @@ namespace TemplatesTests [DataRow("ie", "", "ID:IE, E:Ireland, O:ie")] [DataRow("sg", "", "ID:SG, E:Singapore, O:sg")] [DataRow("za", "", "ID:ZA, E:South Africa, O:za")] - // Skip EnglishName: the official English name of Turkey changed to 'Türkiye', and the returned value now depends on - // the OS/globalization provider (Windows-NLS vs. ICU). Tests would not be stable. - [DataRow("tr", "", "ID:TR, E:---, O:tr")] [DataRow("ae", "", "ID:AE, E:United Arab Emirates, O:ae")] [DataRow("sa", "", "ID:SA, E:Saudi Arabia, O:sa")] [DataRow("eg", "", "ID:EG, E:Egypt, O:eg")] - // different localizations + // Skip EnglishName: the official English name of Turkey changed to 'Türkiye', and the returned value now depends on + // the OS/globalization provider (Windows-NLS vs. ICU). Tests would not be stable. + // A future lookup may still need to account for whichever English name Audible chooses to use. + [DataRow("tr", "", "ID:TR, E:---, O:tr")] + + // test some different localizations - should change only D(isplayNames) [DataRow("fr", "", "D:Frankreich, E:France, N:France, O:fr")] [DataRow("fr", "", "D:Francja")] [DataRow("fr", "", "D:Francia")] From 6c552623d9a21819cee3556ee610cf262949f2ce Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Sat, 11 Apr 2026 01:59:49 +0200 Subject: [PATCH 5/8] Adding documentation for region and language tags --- docs/features/naming-templates.md | 36 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 275b8ed5..2e447932 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -106,7 +106,7 @@ And this example will customize the title based on whether the book has a subtit ## Tag Formatters -**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type. +**Text**, **Name**, **Series**, **Number**, **TimeSpan**, **DateTime**, **Region**, **Language** and their **List** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type. ### Text Formatters @@ -220,15 +220,35 @@ You can use custom formatters to construct customized DateTime string. For more |dd|2-digit day of the month|\|2023-02-14| |HH
mm|The hour, using a 24-hour clock from 00 to 23
The minute, from 00 through 59.|\|14:45| +### Region Formatters + +You can specify which part of a region you are interested in. + +| Formatter | Description | Example Usage | Example Result | +|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|--------------------| +| \{O \| I \| I2 \| I3 \| E \| N \| W \| ID\} | Formats the region using
the region part tags.
\{O:[Text_Formatter](#text-formatters)\} = Region as provided by audible
\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I3:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{E:[Text_Formatter](#text-formatters)\} = English name
\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependant
\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code
\{ID:[Text_Formatter](#text-formatters)\} = Lang code

Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.

Default is \{O\} | `` | US (United States) | +| \{D\} **†** | Display name interpreted by the current language settings.
To ensure output in a specific language the lang-code to use might be spcified with a leading '@'.
Formatter part is also optional and introduced by the colon.
\{D@LANG:[Text_Formatter](#text-formatters)\} | `` | ESTADOS UNIDOS | + +**†** LANG may be any code from the [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) standard like `es` for Spanish, `en` for English, `de` for German, etc. or even a [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag) like 'fr-CA'. + +### Language Formatters + +You can specify which part of a language you are interested in. + +| Formatter | Description | Example Usage | Example Result | +|---------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|----------------| +| \{O \| I \| I2 \| I3 \| E \| N \| W \| ID\} | Formats the language using
the language part tags.
\{O:[Text_Formatter](#text-formatters)\} = Language as provided by audible
\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I3:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{E:[Text_Formatter](#text-formatters)\} = English name
\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependant
\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code
\{ID:[Text_Formatter](#text-formatters)\} = Lang code

Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.

Default is \{O\} | `` | fra (French) | +| \{D\} | Display name interpreted by the current language settings.
To ensure output in a specific language the lang-code to use might be spcified with a leading '@'.
Formatter part is also optional and introduced by the colon.
\{D@LANG:[Text_Formatter](#text-formatters)\} | `` | francés | + ### Checks -| Check-Pattern | Description | Example | -| --------------- | ------------------------------------------------------------------------------- | --------------------------------------- | -| =STRING **†** | Matches if one item is equal to STRING (case ignored) | \ | -| !=STRING **†** | Matches if one item is not equal to STRING (case ignored) | \ | -| ~STRING **†** | Matches if one items is matched by the regular expression STRING (case ignored) | \ | -| #=NUMBER **‡** | Matches if the number value is equal to NUMBER | \ | -| #!=NUMBER **‡** | Matches if the number value is not equal to NUMBER | \ | +| Check-Pattern | Description | Example | +| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------ | +| =STRING **†** | Matches if one item is equal to STRING (case ignored) | \ | +| !=STRING **†** | Matches if one item is not equal to STRING (case ignored) | \ | +| ~STRING **†** | Matches if one items is matched by the regular expression STRING (case ignored) | \ | +| #=NUMBER **‡** | Matches if the number value is equal to NUMBER | \ | +| #!=NUMBER **‡** | Matches if the number value is not equal to NUMBER | \ | | #\>=NUMBER **‡** | Matches if the number value is greater than or equal to NUMBER | \=128]-\> | | #\>NUMBER **‡** | Matches if the number value is greater than NUMBER | \30]-\> | | #\<=NUMBER **‡** | Matches if the number value is less than or equal to NUMBER | \ | From 5fd8ac481b4ef9a6a558f985da9f111c3063f34e Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Tue, 14 Apr 2026 01:14:45 +0200 Subject: [PATCH 6/8] review fixes --- .../Templates/CultureInfoDto.cs | 2 +- .../Templates/RegionInfoDto.cs | 8 ++++---- docs/features/naming-templates.md | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Source/LibationFileManager/Templates/CultureInfoDto.cs b/Source/LibationFileManager/Templates/CultureInfoDto.cs index 0b06b9cd..27d7d56c 100644 --- a/Source/LibationFileManager/Templates/CultureInfoDto.cs +++ b/Source/LibationFileManager/Templates/CultureInfoDto.cs @@ -30,7 +30,7 @@ public record CultureInfoDto : IFormattable { } - public CultureInfoDto(CultureInfo value, string hint, string defaultFormat) + public CultureInfoDto(CultureInfo? value, string hint, string defaultFormat) { Original = hint; DefaultFormat = defaultFormat; diff --git a/Source/LibationFileManager/Templates/RegionInfoDto.cs b/Source/LibationFileManager/Templates/RegionInfoDto.cs index a2f8699e..3a64b9d7 100644 --- a/Source/LibationFileManager/Templates/RegionInfoDto.cs +++ b/Source/LibationFileManager/Templates/RegionInfoDto.cs @@ -22,12 +22,12 @@ public partial record RegionInfoDto : IFormattable { } - public RegionInfoDto(RegionInfo value, string hint, string defaultFormat) + public RegionInfoDto(RegionInfo? value, string hint, string defaultFormat) { Original = hint; DefaultFormat = defaultFormat; Value = value; - Culture = GetCultureInfo(value); + Culture = value is null ? null : GetCultureInfo(value); } private static RegionInfo? GetRegion(string input) @@ -60,11 +60,11 @@ public partial record RegionInfoDto : IFormattable [GeneratedRegex(@"\((?.+)\)")] private static partial Regex ExtractRegionName(); - private static CultureInfo GetCultureInfo(RegionInfo region) + private static CultureInfo? GetCultureInfo(RegionInfo region) { // find culture for region return CultureInfo.GetCultures(CultureTypes.SpecificCultures) - .First(c => Equals(new RegionInfo(c.Name), region)); + .FirstOrDefault(c => Equals(new RegionInfo(c.Name), region)); } diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 2e447932..c3338738 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -224,10 +224,10 @@ You can use custom formatters to construct customized DateTime string. For more You can specify which part of a region you are interested in. -| Formatter | Description | Example Usage | Example Result | -|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|--------------------| -| \{O \| I \| I2 \| I3 \| E \| N \| W \| ID\} | Formats the region using
the region part tags.
\{O:[Text_Formatter](#text-formatters)\} = Region as provided by audible
\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I3:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{E:[Text_Formatter](#text-formatters)\} = English name
\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependant
\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code
\{ID:[Text_Formatter](#text-formatters)\} = Lang code

Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.

Default is \{O\} | `` | US (United States) | -| \{D\} **†** | Display name interpreted by the current language settings.
To ensure output in a specific language the lang-code to use might be spcified with a leading '@'.
Formatter part is also optional and introduced by the colon.
\{D@LANG:[Text_Formatter](#text-formatters)\} | `` | ESTADOS UNIDOS | +| Formatter | Description | Example Usage | Example Result | +|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|--------------------| +| \{O \| I \| I2 \| I3 \| E \| N \| W \| ID\} | Formats the region using
the region part tags.
\{O:[Text_Formatter](#text-formatters)\} = Region as provided by audible
\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I3:[Text_Formatter](#text-formatters)\} = Three letter ISO code
\{E:[Text_Formatter](#text-formatters)\} = English name
\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependent
\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code
\{ID:[Text_Formatter](#text-formatters)\} = Region code

Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.

Default is \{O\} | `` | US (United States) | +| \{D\} **†** | Display name interpreted by the current language settings.
To ensure output in a specific language the lang-code to use might be specified with a leading '@'.
Formatter part is also optional and introduced by the colon.
\{D@LANG:[Text_Formatter](#text-formatters)\} | `` | ESTADOS UNIDOS | **†** LANG may be any code from the [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) standard like `es` for Spanish, `en` for English, `de` for German, etc. or even a [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag) like 'fr-CA'. @@ -235,10 +235,10 @@ You can specify which part of a region you are interested in. You can specify which part of a language you are interested in. -| Formatter | Description | Example Usage | Example Result | -|---------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|----------------| -| \{O \| I \| I2 \| I3 \| E \| N \| W \| ID\} | Formats the language using
the language part tags.
\{O:[Text_Formatter](#text-formatters)\} = Language as provided by audible
\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I3:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{E:[Text_Formatter](#text-formatters)\} = English name
\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependant
\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code
\{ID:[Text_Formatter](#text-formatters)\} = Lang code

Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.

Default is \{O\} | `` | fra (French) | -| \{D\} | Display name interpreted by the current language settings.
To ensure output in a specific language the lang-code to use might be spcified with a leading '@'.
Formatter part is also optional and introduced by the colon.
\{D@LANG:[Text_Formatter](#text-formatters)\} | `` | francés | +| Formatter | Description | Example Usage | Example Result | +|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|----------------| +| \{O \| I \| I2 \| I3 \| E \| N \| W \| ID\} | Formats the language using
the language part tags.
\{O:[Text_Formatter](#text-formatters)\} = Language as provided by audible
\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code
\{I3:[Text_Formatter](#text-formatters)\} = Three letter ISO code
\{E:[Text_Formatter](#text-formatters)\} = English name
\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependent
\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code
\{ID:[Text_Formatter](#text-formatters)\} = Lang code

Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.

Default is \{O\} | `` | fra (French) | +| \{D\} | Display name interpreted by the current language settings.
To ensure output in a specific language the lang-code to use might be specified with a leading '@'.
Formatter part is also optional and introduced by the colon.
\{D@LANG:[Text_Formatter](#text-formatters)\} | `` | francés | ### Checks From 8bad7d90c3ce145aa8f0b6c7edeea59497565a1b Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Tue, 14 Apr 2026 03:04:26 +0200 Subject: [PATCH 7/8] List os and ui tag in documentation --- docs/features/naming-templates.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index c3338738..7ceabced 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -13,7 +13,7 @@ These are the naming template tags currently supported by Libation. These tags will be replaced in the template with the audiobook's values. | Tag | Description | Type | -| ------------------------ | -------------------------------------------------------------- | -------------------------------------- | +|--------------------------|----------------------------------------------------------------| -------------------------------------- | | \ **†** | Audible book ID (ASIN) | Text | | \ | Full title with subtitle | [Text](#text-formatters) | | \ | Title. Stop at first colon | [Text](#text-formatters) | @@ -41,6 +41,8 @@ These tags will be replaced in the template with the audiobook's values. | \<year\> | Year published | [Number](#number-formatters) | | \<language\> | Book's language | [Language](#language-formatters) | | \<language short\> **†** | Book's language abbreviated. Eg: ENG | Text | +| \<os\> | Language currently set in the operating system | [Language](#language-formatters) | +| \<ui\> | User interface language | [Language](#language-formatters) | | \<file date\> | File creation date/time. | [DateTime](#date-formatters) | | \<pub date\> | Audiobook publication date | [DateTime](#date-formatters) | | \<date added\> | Date the book added to your Audible account | [DateTime](#date-formatters) | From a4bc39ee6bb88f010730d1ccf119021880127cf1 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Wed, 15 Apr 2026 00:45:55 +0200 Subject: [PATCH 8/8] switched from RegionInfo to AudibleApi.Locale --- Source/FileLiberator/UtilityExtensions.cs | 2 +- .../LibationFileManager.csproj | 1 + .../Templates/LibraryBookDto.cs | 2 +- .../{RegionInfoDto.cs => LocaleDto.cs} | 61 +++++++++++++----- .../Templates/TemplateEditor[T].cs | 2 +- .../TemplatesTests.cs | 63 ++++++++++--------- docs/features/naming-templates.md | 8 +-- 7 files changed, 85 insertions(+), 54 deletions(-) rename Source/LibationFileManager/Templates/{RegionInfoDto.cs => LocaleDto.cs} (59%) diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 29f5f050..5c23f477 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -59,7 +59,7 @@ public static class UtilityExtensions Title = libraryBook.Book.Title, Subtitle = libraryBook.Book.Subtitle, TitleWithSubtitle = libraryBook.Book.TitleWithSubtitle, - Locale = new RegionInfoDto(libraryBook.Book.Locale), + Locale = new LocaleDto(libraryBook.Book.Locale), YearPublished = libraryBook.Book.DatePublished?.Year, DatePublished = libraryBook.Book.DatePublished, diff --git a/Source/LibationFileManager/LibationFileManager.csproj b/Source/LibationFileManager/LibationFileManager.csproj index 1049f7da..4ef6419c 100644 --- a/Source/LibationFileManager/LibationFileManager.csproj +++ b/Source/LibationFileManager/LibationFileManager.csproj @@ -6,6 +6,7 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="AudibleApi" Version="10.1.4.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" /> <PackageReference Include="NameParserSharp" Version="1.5.0" /> <PackageReference Include="Serilog.Exceptions" Version="8.4.0" /> diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index 7645089c..fda444b2 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -10,7 +10,7 @@ public class BookDto public string? Title { get; set; } public string? Subtitle { get; set; } public string? TitleWithSubtitle { get; set; } - public RegionInfoDto? Locale { get; set; } + public LocaleDto? Locale { get; set; } public int? YearPublished { get; set; } public IEnumerable<ContributorDto>? Authors { get; set; } diff --git a/Source/LibationFileManager/Templates/RegionInfoDto.cs b/Source/LibationFileManager/Templates/LocaleDto.cs similarity index 59% rename from Source/LibationFileManager/Templates/RegionInfoDto.cs rename to Source/LibationFileManager/Templates/LocaleDto.cs index 3a64b9d7..6e8da8f8 100644 --- a/Source/LibationFileManager/Templates/RegionInfoDto.cs +++ b/Source/LibationFileManager/Templates/LocaleDto.cs @@ -3,47 +3,74 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using AudibleApi; using FileManager.NamingTemplate; namespace LibationFileManager.Templates; -public partial record RegionInfoDto : IFormattable +public partial record LocaleDto : IFormattable { + public string Original { get; } + public Locale Locale { get; set; } private RegionInfo? Value { get; } private CultureInfo? Culture { get; } private string DefaultFormat { get; } - public string Original { get; } - public RegionInfoDto(string hint) : this(hint, "{O}") + + public LocaleDto(string hint) : this(hint, "{O}") { } - public RegionInfoDto(string hint, string defaultFormat) : this(GetRegion(hint), hint, defaultFormat) + public LocaleDto(string hint, string defaultFormat) : this(Localization.Get(hint), hint, defaultFormat) { } - public RegionInfoDto(RegionInfo? value, string hint, string defaultFormat) + public LocaleDto(Locale locale, string hint, string defaultFormat) { + var (regionInfo, cultureInfo) = GetRegion(locale.Language, locale.CountryCode, hint); + Original = hint; + Locale = locale; + Value = regionInfo; + Culture = cultureInfo ?? (regionInfo is null ? null : GetCultureInfo(regionInfo)); DefaultFormat = defaultFormat; - Value = value; - Culture = value is null ? null : GetCultureInfo(value); } - private static RegionInfo? GetRegion(string input) + private static (RegionInfo?, CultureInfo?) GetRegion(string language, string countrcode, string input) { - if (input.StartsWith("pre-amazon - ", StringComparison.OrdinalIgnoreCase)) input = input.Substring(13); - if (string.Equals(input, "uk", StringComparison.OrdinalIgnoreCase)) return new RegionInfo("GB"); + CultureInfo? culture = null; + if (language != string.Empty) + try + { + culture = CultureInfo.GetCultureInfo(language.Length == 2 ? $"{language}-{countrcode}" : language); + } + catch + { + // ignored + } + try { - return new RegionInfo(input.ToUpperInvariant()); + return (new RegionInfo(countrcode), culture); + } + catch + { + // ignored + } + + if (culture is not null) + return (new RegionInfo(culture.Name), culture); + + try + { + return (new RegionInfo(input), culture); } catch { return CultureInfo.GetCultures(CultureTypes.SpecificCultures) - .Select(c => new RegionInfo(c.Name)) - .FirstOrDefault(r => - string.Equals(r.EnglishName, input, StringComparison.OrdinalIgnoreCase)); + .Select(c => (new RegionInfo(c.Name), c)) + .FirstOrDefault(rAndC => + string.Equals(rAndC.Item1.EnglishName, input, StringComparison.OrdinalIgnoreCase)); } } @@ -68,9 +95,9 @@ public partial record RegionInfoDto : IFormattable } - private static readonly Dictionary<string, Func<RegionInfoDto, object?>> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary<string, Func<LocaleDto, object?>> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) { - { "ID", dto => dto.Value?.Name }, + { "ID", dto => dto.Locale?.MarketPlaceId }, { "I", dto => dto.Value?.TwoLetterISORegionName }, { "I2", dto => dto.Value?.TwoLetterISORegionName }, { "I3", dto => dto.Value?.ThreeLetterISORegionName }, @@ -78,6 +105,8 @@ public partial record RegionInfoDto : IFormattable { "E", dto => dto.Value?.EnglishName }, { "N", dto => dto.Value?.NativeName }, { "O", dto => dto.Original }, + { "T", dto => dto.Locale.TopDomain }, + { "L", dto => dto.Culture?.Name }, { "D", dto => dto.GetLocalizedRegionName() }, // localized }; diff --git a/Source/LibationFileManager/Templates/TemplateEditor[T].cs b/Source/LibationFileManager/Templates/TemplateEditor[T].cs index 9205b36c..2ea0f04a 100644 --- a/Source/LibationFileManager/Templates/TemplateEditor[T].cs +++ b/Source/LibationFileManager/Templates/TemplateEditor[T].cs @@ -63,7 +63,7 @@ public class TemplateEditor<T> : ITemplateEditor where T : Templates, ITemplate, Title = "A Study in Scarlet", TitleWithSubtitle = "A Study in Scarlet: A Sherlock Holmes Novel", Subtitle = "A Sherlock Holmes Novel", - Locale = new RegionInfoDto("us"), + Locale = new LocaleDto("us"), YearPublished = 2017, Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [new("Stephen Fry", null)], diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 68106541..c06540f0 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -44,7 +44,7 @@ namespace TemplatesTests FileDate = new DateTime(2023, 1, 28, 0, 0, 0), AudibleProductId = "asin", Title = "A Study in Scarlet: A Sherlock Holmes Novel", - Locale = new RegionInfoDto("us"), + Locale = new LocaleDto("us"), YearPublished = null, // explicitly null Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [], // explicitly empty list @@ -820,51 +820,52 @@ namespace TemplatesTests // platform-specific tests here—unlike path-related differences, which are defined and testable. // test known locales - [DataRow("us", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, N:{N}, O:{O}]>", - "ID:US, 2:US, 3:USA, W:USA, D:Estados Unidos, E:United States, N:United States, O:us")] - [DataRow("uk", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, N:{N}, O:{O}]>", - "ID:GB, 2:GB, 3:GBR, W:GBR, D:Reino Unido, E:United Kingdom, N:United Kingdom, O:uk")] - [DataRow("germany", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, N:{N}, O:{O}]>", - "ID:DE, 2:DE, 3:DEU, W:DEU, D:Alemania, E:Germany, N:Deutschland, O:germany")] + [DataRow("us", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, N:{N}, O:{O}, T:.{T}, L:{L}]>", + "ID:AF2M0KC94RCEA, 2:US, 3:USA, W:USA, D:Estados Unidos, E:United States, N:United States, O:us, T:.com, L:en-US")] + [DataRow("uk", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, N:{N}, O:{O}, T:.{T}, L:{L}]>", + "ID:A2I9A3Q2GNFNGQ, 2:GB, 3:GBR, W:GBR, D:Reino Unido, E:United Kingdom, N:United Kingdom, O:uk, T:.co.uk, L:en-GB")] + [DataRow("germany", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, N:{N}, O:{O}, T:.{T}, L:{L}]>", + "ID:AN7V1F1VY261K, 2:DE, 3:DEU, W:DEU, D:Alemania, E:Germany, N:Deutschland, O:germany, T:.de, L:de-DE")] // Skip NativeName (see above) - [DataRow("france", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:FR, 2:FR, 3:FRA, W:FRA, D:Francia, E:France, O:france")] - [DataRow("australia", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:AU, 2:AU, 3:AUS, W:AUS, D:Australia, E:Australia, O:australia")] - [DataRow("india", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:IN, 2:IN, 3:IND, W:IND, D:India, E:India, O:india")] - [DataRow("spain", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:ES, 2:ES, 3:ESP, W:ESP, D:España, E:Spain, O:spain")] - [DataRow("italy", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:IT, 2:IT, 3:ITA, W:ITA, D:Italia, E:Italy, O:italy")] - [DataRow("canada", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:CA, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, O:canada")] - [DataRow("japan", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:JP, 2:JP, 3:JPN, W:JPN, D:Japón, E:Japan, O:japan")] - [DataRow("brazil", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}]>", "ID:BR, 2:BR, 3:BRA, W:BRA, D:Brasil, E:Brazil, O:brazil")] + [DataRow("france", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:A2728XDNODOQ8T, 2:FR, 3:FRA, W:FRA, D:Francia, E:France, O:france, T:.fr, L:fr-FR")] + [DataRow("australia", + "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:AN7EY7DTAW63G, 2:AU, 3:AUS, W:AUS, D:Australia, E:Australia, O:australia, T:.com.au, L:en-AU")] + [DataRow("india", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:AJO3FBRUE6J4S, 2:IN, 3:IND, W:IND, D:India, E:India, O:india, T:.in, L:en-IN")] + [DataRow("spain", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:ALMIKO4SZCSAR, 2:ES, 3:ESP, W:ESP, D:España, E:Spain, O:spain, T:.es, L:es-ES")] + [DataRow("italy", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:A2N7FU2W2BU2ZC, 2:IT, 3:ITA, W:ITA, D:Italia, E:Italy, O:italy, T:.it, L:it-IT")] + [DataRow("canada", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:A2CQZ5RBY40XE, 2:CA, 3:CAN, W:CAN, D:Canadá, E:Canada, O:canada, T:.ca, L:en-CA")] + [DataRow("japan", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:A1QAP3MOU4173J, 2:JP, 3:JPN, W:JPN, D:Japón, E:Japan, O:japan, T:.co.jp, L:ja-JP")] + [DataRow("brazil", "<locale[ID:{ID}, 2:{I}, 3:{I3}, W:{W}, D:{D}, E:{E}, O:{O}, T:.{T}, L:{L}]>", "ID:A10J1VAYUDTYRN, 2:BR, 3:BRA, W:BRA, D:Brasil, E:Brazil, O:brazil, T:.com.br, L:pt-BR")] // test historical locales - [DataRow("pre-amazon - us", "<locale[ID:{ID}, O:{O}]>", "ID:US, O:pre-amazon - us")] - [DataRow("pre-amazon - uk", "<locale[ID:{ID}, O:{O}]>", "ID:GB, O:pre-amazon - uk")] - [DataRow("pre-amazon - germany", "<locale[ID:{ID}, O:{O}]>", "ID:DE, O:pre-amazon - germany")] + [DataRow("pre-amazon - us", "<locale[ID:{ID}, O:{O}, T:.{T}, L:{L}]>", "ID:AF2M0KC94RCEA, O:pre-amazon - us, T:.com, L:en-US")] + [DataRow("pre-amazon - uk", "<locale[ID:{ID}, O:{O}, T:.{T}, L:{L}]>", "ID:A2I9A3Q2GNFNGQ, O:pre-amazon - uk, T:.co.uk, L:en-GB")] + [DataRow("pre-amazon - germany", "<locale[ID:{ID}, O:{O}, T:.{T}, L:{L}]>", "ID:AN7V1F1VY261K, O:pre-amazon - germany, T:.de, L:de-DE")] // test upcoming locales - [DataRow("be", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:BE, E:Belgium, O:be")] - [DataRow("nl", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:NL, E:Netherlands, O:nl")] - [DataRow("se", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:SE, E:Sweden, O:se")] - [DataRow("pl", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:PL, E:Poland, O:pl")] - [DataRow("ie", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:IE, E:Ireland, O:ie")] - [DataRow("sg", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:SG, E:Singapore, O:sg")] - [DataRow("za", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:ZA, E:South Africa, O:za")] - [DataRow("ae", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:AE, E:United Arab Emirates, O:ae")] - [DataRow("sa", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:SA, E:Saudi Arabia, O:sa")] - [DataRow("eg", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:EG, E:Egypt, O:eg")] + [DataRow("be", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Belgium, O:be")] + [DataRow("nl", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Netherlands, O:nl")] + [DataRow("se", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Sweden, O:se")] + [DataRow("pl", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Poland, O:pl")] + [DataRow("ie", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Ireland, O:ie")] + [DataRow("sg", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Singapore, O:sg")] + [DataRow("za", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:South Africa, O:za")] + [DataRow("ae", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:United Arab Emirates, O:ae")] + [DataRow("sa", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Saudi Arabia, O:sa")] + [DataRow("eg", "<locale[ID:{ID}, E:{E}, O:{O}]>", "ID:, E:Egypt, O:eg")] // Skip EnglishName: the official English name of Turkey changed to 'Türkiye', and the returned value now depends on // the OS/globalization provider (Windows-NLS vs. ICU). Tests would not be stable. // A future lookup may still need to account for whichever English name Audible chooses to use. - [DataRow("tr", "<locale[ID:{ID}, E:---, O:{O}]>", "ID:TR, E:---, O:tr")] + [DataRow("tr", "<locale[ID:{ID}, E:---, O:{O}]>", "ID:, E:---, O:tr")] // test some different localizations - should change only D(isplayNames) [DataRow("fr", "<locale[D:{D@de-DE}, E:{E@de-DE}, N:{N@de-DE}, O:{O@de-DE}]>", "D:Frankreich, E:France, N:France, O:fr")] [DataRow("fr", "<locale[D:{D@pl}]>", "D:Francja")] [DataRow("fr", "<locale[D:{D@it}]>", "D:Francia")] - public void Region_test(string country, string template, string expected) + public void Locale_test(string country, string template, string expected) { var bookDto = Shared.GetLibraryBook(); - bookDto.Locale = new RegionInfoDto(country); + bookDto.Locale = new LocaleDto(country); var result = ""; diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 7ceabced..27ac4a63 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -226,10 +226,10 @@ You can use custom formatters to construct customized DateTime string. For more You can specify which part of a region you are interested in. -| Formatter | Description | Example Usage | Example Result | -|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|--------------------| -| \{O \| I \| I2 \| I3 \| E \| N \| W \| ID\} | Formats the region using<br>the region part tags.<br>\{O:[Text_Formatter](#text-formatters)\} = Region as provided by audible<br>\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code<br>\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code<br>\{I3:[Text_Formatter](#text-formatters)\} = Three letter ISO code<br>\{E:[Text_Formatter](#text-formatters)\} = English name<br>\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependent<br>\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code<br>\{ID:[Text_Formatter](#text-formatters)\} = Region code<br><br>Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.<br><br>Default is \{O\} | `<locale[{I} ({E})]>` | US (United States) | -| \{D\} **†** | Display name interpreted by the current language settings.<br>To ensure output in a specific language the lang-code to use might be specified with a leading '@'.<br>Formatter part is also optional and introduced by the colon.<br>\{D@LANG:[Text_Formatter](#text-formatters)\} | `<locale[{D@es:u}]>` | ESTADOS UNIDOS | +| Formatter | Description | Example Usage | Example Result | +|-------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------|---------------------------------------| +| \{O \| I \| I2 \| I3 \| E \| N \| W \| L \| T \| ID\} | Formats the region using<br>the region part tags.<br>\{O:[Text_Formatter](#text-formatters)\} = Region as used in Libation<br>\{I:[Text_Formatter](#text-formatters)\} = Two letter ISO code<br>\{I2:[Text_Formatter](#text-formatters)\} = Two letter ISO code<br>\{I3:[Text_Formatter](#text-formatters)\} = Three letter ISO code<br>\{E:[Text_Formatter](#text-formatters)\} = English name<br>\{N:[Text_Formatter](#text-formatters)\} = Native name - OS dependent<br>\{W:[Text_Formatter](#number-formatters)\} = Unique Windows code<br>\{L:[Text_Formatter](#text-formatters)\} = Lang code used for this region/store<br>\{T:[Text_Formatter](#number-formatters)\} = TLD under which the audible store is hosted<br>\{ID:[Text_Formatter](#text-formatters)\} = Region code<br> <br><br>Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.<br><br>Default is \{O\} | `<locale[{I} ({E})]>`<hr>`www.audible.<locale[{T}]>` | US (United States)<hr>www.audible.com | +| \{D\} **†** | Display name interpreted by the current language settings.<br>To ensure output in a specific language the lang-code to use might be specified with a leading '@'.<br>Formatter part is also optional and introduced by the colon.<br>\{D@LANG:[Text_Formatter](#text-formatters)\} | `<locale[{D@es:u}]>` | ESTADOS UNIDOS | **†** LANG may be any code from the [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) standard like `es` for Spanish, `en` for English, `de` for German, etc. or even a [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag) like 'fr-CA'.