diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/DateFormats.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/DateFormats.cs new file mode 100644 index 00000000..55a6f0d7 --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/DateFormats.cs @@ -0,0 +1,51 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +/// +/// Maps Dataverse date format strings to their codes +/// +public static class DateFormats +{ + /// Short date formats mapped to their dateformatcode. + public static readonly IReadOnlyDictionary Short = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["dd/MMMM/yy"] = 0, + ["M/d/yy"] = 1, + ["M/d/yyyy"] = 2, + ["MM/dd/yy"] = 3, + ["MM/dd/yyyy"] = 4, + ["yy/MM/dd"] = 5, + ["yyyy/MM/dd"] = 6, + }; + + /// Long date formats mapped to their longdateformatcode. + public static readonly IReadOnlyDictionary Long = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["d MMMM, yyyy"] = 0, + ["dddd, d MMMM, yyyy"] = 1, + ["dddd, MMMM d, yyyy"] = 2, + ["MMMM d, yyyy"] = 3, + }; + + /// + /// Turns user input into a format code. + /// + public static int ToCode(IReadOnlyDictionary formats, string input, string label) + { + if (string.IsNullOrWhiteSpace(input)) throw new ArgumentException($"{label} cannot be empty."); + + + if (int.TryParse(input, out var code)) return code; + + + if (formats.TryGetValue(input, out var mapped)) return mapped; + + var known = string.Join(Environment.NewLine, formats.OrderBy(pair => pair.Value).Select(pair => $" {pair.Value} {pair.Key}")); + throw new ArgumentException( + $"'{input}' is not a known {label}. Pass a code or one of:{Environment.NewLine}{known}"); + } + + /// + /// Returns the format string for a code, or null when the code is unknown. + /// + public static string? Describe(IReadOnlyDictionary formats, int? code) => code is { } codeValue ? formats.FirstOrDefault(pair => pair.Value == codeValue).Key : null; +} diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/IUserSettingsService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/IUserSettingsService.cs new file mode 100644 index 00000000..8ca7cc88 --- /dev/null +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/IUserSettingsService.cs @@ -0,0 +1,73 @@ +namespace TALXIS.CLI.Core.Contracts.Dataverse; + +/// +/// The connected user's personalization settings (timezone, locale, date/time +/// formats, currency) read from the Dataverse usersettings entity. +/// +public sealed record UserSettingsInfo( + Guid UserId, + string? FullName, + string? Email, + int? TimeZoneCode, + string? TimeZoneName, + int? LocaleId, + string? LocaleName, + int? ShortDateFormatCode, + string? ShortDateFormat, + int? LongDateFormatCode, + string? TimeFormat, + string? CurrencyCode, + string? CurrencyName); + +/// A Dataverse timezonedefinition row. +public sealed record TimezoneInfo(int Code, string Name, string? StandardName); + +/// A Dataverse transactioncurrency row. +public sealed record CurrencyInfo(Guid Id, string IsoCode, string Name, string? Symbol); + +/// +/// The subset of user settings to change. Null fields are left untouched. +/// +public sealed record UserSettingsUpdate( + int? TimeZoneCode = null, + int? LocaleId = null, + int? ShortDateFormatCode = null, + int? LongDateFormatCode = null, + string? TimeFormat = null, + Guid? CurrencyId = null) +{ + public bool IsEmpty => TimeZoneCode is null && LocaleId is null && ShortDateFormatCode is null + && LongDateFormatCode is null && TimeFormat is null && CurrencyId is null; +} + +/// +/// Reads and updates the connected user's personalization settings and looks +/// up available timezones and currencies. Keyed off the current user resolved +/// via WhoAmI, so it always targets the profile's own account. +/// +public interface IUserSettingsService +{ + /// Gets the connected user's current settings. + Task GetCurrentAsync(string? profileName, CancellationToken ct); + + /// Applies the non-null fields of to the connected user. + Task UpdateCurrentAsync(string? profileName, UserSettingsUpdate update, CancellationToken ct); + + /// Lists timezones, optionally filtered by a substring of the display or standard name. + Task> ListTimezonesAsync(string? profileName, string? filter, CancellationToken ct); + + /// + /// Resolves a timezone name (fuzzy) to its numeric code. Throws + /// when nothing matches or the query is ambiguous. + /// + Task ResolveTimezoneCodeAsync(string? profileName, string query, CancellationToken ct); + + /// Lists currencies enabled in the environment, optionally filtered by a substring of the ISO code or name. + Task> ListCurrenciesAsync(string? profileName, string? filter, CancellationToken ct); + + /// + /// Resolves a currency (ISO code or name, fuzzy) to its record id. Throws + /// when nothing matches or the query is ambiguous. + /// + Task ResolveCurrencyIdAsync(string? profileName, string query, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Environment/Currency/CurrencyCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyCliCommand.cs new file mode 100644 index 00000000..e1d6303f --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyCliCommand.cs @@ -0,0 +1,21 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Currency; + +/// +/// txc environment currency - look up currencies enabled in the +/// environment for use with user-settings set --currency. +/// +[CliCommand( + Name = "currency", + Description = "Look up currencies enabled in the environment.", + Children = new[] { typeof(CurrencyListCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class CurrencyCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Currency/CurrencyListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyListCliCommand.cs new file mode 100644 index 00000000..66c52b59 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Currency/CurrencyListCliCommand.cs @@ -0,0 +1,53 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Currency; + +/// +/// txc environment currency list - lists currencies enabled in the +/// environment (ISO code + name), optionally filtered. +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "Lists currencies enabled in the LIVE connected environment (ISO code + name). Requires an active profile." +)] +public class CurrencyListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(CurrencyListCliCommand)); + + [CliOption(Name = "--filter", Description = "Show only currencies whose ISO code or name contains this substring.", Required = false)] + public string? Filter { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var currencies = await service.ListCurrenciesAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(currencies, PrintTable); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteList; OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList currencies) + { + if (currencies.Count == 0) + { + OutputWriter.WriteLine("No currencies found."); + return; + } + + OutputWriter.WriteLine($"{"ISO",-5} | Name"); + OutputWriter.WriteLine(new string('-', 60)); + foreach (var currency in currencies) + { + OutputWriter.WriteLine($"{currency.IsoCode,-5} | {currency.Name}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 3e26fc7f..f22637c9 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand), typeof(UserSettings.UserSettingsCliCommand), typeof(Timezone.TimezoneCliCommand), typeof(Currency.CurrencyCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneCliCommand.cs new file mode 100644 index 00000000..4dc411db --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneCliCommand.cs @@ -0,0 +1,21 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.Timezone; + +/// +/// txc environment timezone - look up Dataverse timezones for use with +/// user-settings set --timezone. +/// +[CliCommand( + Name = "timezone", + Description = "Look up Dataverse timezones.", + Children = new[] { typeof(TimezoneListCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class TimezoneCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneListCliCommand.cs new file mode 100644 index 00000000..9f94df21 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/Timezone/TimezoneListCliCommand.cs @@ -0,0 +1,53 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.Timezone; + +/// +/// txc environment timezone list - lists available timezones and their +/// codes, optionally filtered by name. +/// +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "Lists Dataverse timezones (code + name) from the LIVE connected environment. Requires an active profile." +)] +public class TimezoneListCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(TimezoneListCliCommand)); + + [CliOption(Name = "--filter", Description = "Show only timezones whose name contains this substring.", Required = false)] + public string? Filter { get; set; } + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var timezones = await service.ListTimezonesAsync(Profile, Filter, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(timezones, PrintTable); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteList; OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void PrintTable(IReadOnlyList timezones) + { + if (timezones.Count == 0) + { + OutputWriter.WriteLine("No timezones found."); + return; + } + + OutputWriter.WriteLine($"{"Code",-5} | Name"); + OutputWriter.WriteLine(new string('-', 60)); + foreach (var timezone in timezones) + { + OutputWriter.WriteLine($"{timezone.Code,-5} | {timezone.Name}"); + } + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsCliCommand.cs b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsCliCommand.cs new file mode 100644 index 00000000..f6d23b73 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsCliCommand.cs @@ -0,0 +1,21 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Environment.UserSettings; + +/// +/// txc environment user-settings - view and change the connected +/// user's personalization settings (timezone, locale, date/time formats). +/// +[CliCommand( + Name = "user-settings", + Description = "View and change the connected user's settings (timezone, locale, date/time formats).", + Children = new[] { typeof(UserSettingsGetCliCommand), typeof(UserSettingsSetCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class UserSettingsCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsGetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsGetCliCommand.cs new file mode 100644 index 00000000..bed71355 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsGetCliCommand.cs @@ -0,0 +1,67 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.UserSettings; + +/// +/// txc environment user-settings get - shows the connected user's +/// timezone, locale, date/time formats, and currency. +/// +[CliReadOnly] +[CliCommand( + Name = "get", + Description = "Shows the connected user's settings (timezone, locale, date/time formats, currency) from the LIVE connected environment. Requires an active profile." +)] +public class UserSettingsGetCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserSettingsGetCliCommand)); + + protected override async Task ExecuteAsync() + { + var service = TxcServices.Get(); + var settings = await service.GetCurrentAsync(Profile, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteData(settings, Print); + return ExitSuccess; + } + + // Text-renderer callback invoked by OutputFormatter.WriteData; OutputWriter usage is intentional. +#pragma warning disable TXC003 + private static void Print(UserSettingsInfo userSettingsInfo) + { + var user = userSettingsInfo.FullName is { } name + ? (userSettingsInfo.Email is { } email ? $"{name} ({email})" : name) + : userSettingsInfo.UserId.ToString(); + var timezone = userSettingsInfo.TimeZoneCode is { } code + ? (userSettingsInfo.TimeZoneName is { } tzName ? $"{tzName} (code: {code})" : $"code {code}") + : "(not set)"; + var locale = userSettingsInfo.LocaleId is { } localeId + ? (userSettingsInfo.LocaleName is { } localeName ? $"{localeName} (code: {localeId})" : $"code {localeId}") + : "(not set)"; + var currency = userSettingsInfo.CurrencyCode is { } isoCode + ? (userSettingsInfo.CurrencyName is { } currencyName ? $"{isoCode} ({currencyName})" : isoCode) + : "(not set)"; + var shortDate = FormatCode(userSettingsInfo.ShortDateFormatCode, userSettingsInfo.ShortDateFormat ?? DateFormats.Describe(DateFormats.Short, userSettingsInfo.ShortDateFormatCode)); + var longDate = FormatCode(userSettingsInfo.LongDateFormatCode, DateFormats.Describe(DateFormats.Long, userSettingsInfo.LongDateFormatCode)); + + OutputWriter.WriteLine($"User: {user}"); + OutputWriter.WriteLine($"Timezone: {timezone}"); + OutputWriter.WriteLine($"Locale: {locale}"); + OutputWriter.WriteLine($"Short date: {shortDate}"); + OutputWriter.WriteLine($"Long date: {longDate}"); + OutputWriter.WriteLine($"Time format: {userSettingsInfo.TimeFormat ?? "(not set)"}"); + OutputWriter.WriteLine($"Currency: {currency}"); + } + + private static string FormatCode(int? code, string? description) + { + if (code is not { } codeValue) + return "(not set)"; + return description is { } format ? $"{format} (code: {codeValue})" : codeValue.ToString(); + } +#pragma warning restore TXC003 +} diff --git a/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsSetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsSetCliCommand.cs new file mode 100644 index 00000000..7df372f7 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/UserSettings/UserSettingsSetCliCommand.cs @@ -0,0 +1,84 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Environment.UserSettings; + +/// +/// txc environment user-settings set - updates one or more of the +/// connected user's settings. Timezone and currency accept a name (fuzzy) or +/// their code/ISO code. +/// +[CliIdempotent] +[CliCommand( + Name = "set", + Description = "Updates the connected user's settings (timezone, locale, date/time formats, currency) on the LIVE connected environment. Requires an active profile. Pass at least one setting." +)] +public class UserSettingsSetCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UserSettingsSetCliCommand)); + + [CliOption(Name = "--timezone", Description = "Timezone by numeric code (e.g. 95) or by city/region name, fuzzy-matched (e.g. 'Prague').", Required = false)] + public string? Timezone { get; set; } + + [CliOption(Name = "--locale", Description = "Locale ID / LCID (e.g. 1033 for en-US, 1029 for cs-CZ).", Required = false)] + public int? Locale { get; set; } + + [CliOption(Name = "--short-date-format", Description = "Short date format string (e.g. 'M/d/yyyy') or its numeric code. See options via 'user-settings get'.", Required = false)] + public string? ShortDateFormat { get; set; } + + [CliOption(Name = "--long-date-format", Description = "Long date format string (e.g. 'dddd, MMMM d, yyyy') or its numeric code. See options via 'user-settings get'.", Required = false)] + public string? LongDateFormat { get; set; } + + [CliOption(Name = "--time-format", Description = "Time format string (e.g. 'H:mm', 'h:mm tt').", Required = false)] + public string? TimeFormat { get; set; } + + [CliOption(Name = "--currency", Description = "Base currency by ISO code (e.g. 'CZK') or name, fuzzy-matched.", Required = false)] + public string? Currency { get; set; } + + protected override async Task ExecuteAsync() + { + if (Timezone is null && Locale is null && ShortDateFormat is null && LongDateFormat is null && TimeFormat is null && Currency is null) + { + Logger.LogError("Nothing to update. Pass at least one of --timezone, --locale, --short-date-format, --long-date-format, --time-format, --currency."); + return ExitValidationError; + } + + var service = TxcServices.Get(); + + int? timezoneCode = null; + if (Timezone is { } timezone) + { + timezoneCode = int.TryParse(timezone, out var code) + ? code + : await service.ResolveTimezoneCodeAsync(Profile, timezone, CancellationToken.None).ConfigureAwait(false); + } + + Guid? currencyId = null; + if (Currency is { } currency) + currencyId = await service.ResolveCurrencyIdAsync(Profile, currency, CancellationToken.None).ConfigureAwait(false); + + int? shortDateCode = ShortDateFormat is { } shortDateFormat + ? DateFormats.ToCode(DateFormats.Short, shortDateFormat, "short date format") + : null; + int? longDateCode = LongDateFormat is { } longDateFormat + ? DateFormats.ToCode(DateFormats.Long, longDateFormat, "long date format") + : null; + + var update = new UserSettingsUpdate( + TimeZoneCode: timezoneCode, + LocaleId: Locale, + ShortDateFormatCode: shortDateCode, + LongDateFormatCode: longDateCode, + TimeFormat: TimeFormat, + CurrencyId: currencyId); + + await service.UpdateCurrentAsync(Profile, update, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteResult("succeeded", "User settings updated."); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/CurrencyResolver.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/CurrencyResolver.cs new file mode 100644 index 00000000..2b4e3d74 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/CurrencyResolver.cs @@ -0,0 +1,47 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Data; + +/// +/// Pure name/ISO-code-to-currency matching so user-settings set --currency +/// can take "CZK" or "Czech Koruna" instead of a record id. +/// +internal static class CurrencyResolver +{ + /// + /// Finds the single currency matching . An exact + /// (case-insensitive) ISO code wins; otherwise a substring match on the + /// code, name, or symbol must be unique. Throws + /// on no match or ambiguity. + /// + public static CurrencyInfo Resolve(IReadOnlyList currencies, string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentException("Currency query cannot be empty."); + + var trimmedQuery = query.Trim(); + + var exactMatches = currencies + .Where(currency => string.Equals(currency.IsoCode, trimmedQuery, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (exactMatches.Count == 1) + return exactMatches[0]; + + var matches = currencies + .Where(currency => currency.IsoCode.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) + || currency.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) + || (currency.Symbol?.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + + if (matches.Count == 1) + return matches[0]; + + if (matches.Count == 0) + throw new ArgumentException( + $"No currency matches '{query}'. Run 'txc env currency list --filter {query}' to browse."); + + var sample = string.Join(Environment.NewLine, matches.Take(10).Select(currency => $" {currency.IsoCode,-5} {currency.Name}")); + throw new ArgumentException( + $"'{query}' matches {matches.Count} currencies. Use the exact ISO code:{Environment.NewLine}{sample}"); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs index d1eb017c..bf6ea5ae 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/DependencyInjection/DataverseDataServiceCollectionExtensions.cs @@ -15,6 +15,7 @@ public static IServiceCollection AddTxcDataverseData(this IServiceCollection ser services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); return services; } diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/TimezoneResolver.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/TimezoneResolver.cs new file mode 100644 index 00000000..c3691381 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/TimezoneResolver.cs @@ -0,0 +1,45 @@ +using TALXIS.CLI.Core.Contracts.Dataverse; + +namespace TALXIS.CLI.Platform.Dataverse.Data; + +/// +/// Pure name-to-timezone matching so user-settings set --timezone can +/// take a city or region name instead of a numeric code. +/// +internal static class TimezoneResolver +{ + /// + /// Finds the single timezone matching . An exact + /// (case-insensitive) name wins; otherwise a substring match must be unique. + /// Throws on no match or ambiguity. + /// + public static TimezoneInfo Resolve(IReadOnlyList timezones, string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentException("Timezone query cannot be empty."); + + var trimmedQuery = query.Trim(); + + var exactMatches = timezones + .Where(timezone => string.Equals(timezone.Name, trimmedQuery, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (exactMatches.Count == 1) + return exactMatches[0]; + + var matches = timezones + .Where(timezone => timezone.Name.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) + || (timezone.StandardName?.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + + if (matches.Count == 1) + return matches[0]; + + if (matches.Count == 0) + throw new ArgumentException( + $"No timezone matches '{query}'. Run 'txc env timezone list --filter {query}' to browse, or pass the numeric code to --timezone."); + + var sample = string.Join(Environment.NewLine, matches.Take(10).Select(timezone => $" {timezone.Code,-5} {timezone.Name}")); + throw new ArgumentException( + $"'{query}' matches {matches.Count} timezones. Narrow it down or pass the numeric code to --timezone:{Environment.NewLine}{sample}"); + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Data/UserSettingsService.cs b/src/TALXIS.CLI.Platform.Dataverse.Data/UserSettingsService.cs new file mode 100644 index 00000000..eb26476e --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Data/UserSettingsService.cs @@ -0,0 +1,198 @@ +using System.Globalization; +using Microsoft.Crm.Sdk.Messages; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Runtime; + +namespace TALXIS.CLI.Platform.Dataverse.Data; + +/// +/// Reads and writes the connected user's usersettings record and looks +/// up timezonedefinition rows via the ServiceClient SDK. +/// +internal sealed class UserSettingsService : IUserSettingsService +{ + public async Task GetCurrentAsync(string? profileName, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var userId = await WhoAmIAsync(conn, ct).ConfigureAwait(false); + + var settings = await conn.Client.RetrieveAsync( + "usersettings", userId, + new ColumnSet("timezonecode", "localeid", "dateformatcode", "dateformatstring", "longdateformatcode", "timeformatstring", "transactioncurrencyid"), + ct).ConfigureAwait(false); + + var user = await conn.Client.RetrieveAsync( + "systemuser", userId, + new ColumnSet("fullname", "internalemailaddress"), + ct).ConfigureAwait(false); + + var timezoneCode = settings.GetAttributeValue("timezonecode"); + var localeId = settings.GetAttributeValue("localeid"); + var currency = settings.GetAttributeValue("transactioncurrencyid") is { } currencyRef + ? await TryGetCurrencyAsync(conn, currencyRef.Id, ct).ConfigureAwait(false) + : null; + + return new UserSettingsInfo( + UserId: userId, + FullName: user.GetAttributeValue("fullname"), + Email: user.GetAttributeValue("internalemailaddress"), + TimeZoneCode: timezoneCode, + TimeZoneName: timezoneCode is { } code ? await TryGetTimezoneNameAsync(conn, code, ct).ConfigureAwait(false) : null, + LocaleId: localeId, + LocaleName: LocaleName(localeId), + ShortDateFormatCode: settings.GetAttributeValue("dateformatcode"), + ShortDateFormat: settings.GetAttributeValue("dateformatstring"), + LongDateFormatCode: settings.GetAttributeValue("longdateformatcode"), + TimeFormat: settings.GetAttributeValue("timeformatstring"), + CurrencyCode: currency?.IsoCode, + CurrencyName: currency?.Name); + } + + public async Task UpdateCurrentAsync(string? profileName, UserSettingsUpdate update, CancellationToken ct) + { + if (update.IsEmpty) + throw new ArgumentException("No settings to update."); + + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var userId = await WhoAmIAsync(conn, ct).ConfigureAwait(false); + + var entity = new Entity("usersettings", userId); + + if (update.TimeZoneCode is { } timeZoneCode) entity["timezonecode"] = timeZoneCode; + if (update.LocaleId is { } localeId) entity["localeid"] = localeId; + if (update.ShortDateFormatCode is { } shortDateCode) entity["dateformatcode"] = shortDateCode; + if (update.LongDateFormatCode is { } longDateCode) entity["longdateformatcode"] = longDateCode; + if (update.TimeFormat is { } timeFormat) entity["timeformatstring"] = timeFormat; + if (update.CurrencyId is { } currencyId) entity["transactioncurrencyid"] = new EntityReference("transactioncurrency", currencyId); + + await conn.Client.UpdateAsync(entity, ct).ConfigureAwait(false); + } + + public async Task> ListTimezonesAsync(string? profileName, string? filter, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var all = await QueryTimezonesAsync(conn, ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(filter)) + return all; + + return all + .Where(timezone => timezone.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) + || (timezone.StandardName?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + } + + public async Task ResolveTimezoneCodeAsync(string? profileName, string query, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var all = await QueryTimezonesAsync(conn, ct).ConfigureAwait(false); + return TimezoneResolver.Resolve(all, query).Code; + } + + public async Task> ListCurrenciesAsync(string? profileName, string? filter, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var all = await QueryCurrenciesAsync(conn, ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(filter)) + return all; + + return all + .Where(currency => currency.IsoCode.Contains(filter, StringComparison.OrdinalIgnoreCase) + || currency.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public async Task ResolveCurrencyIdAsync(string? profileName, string query, CancellationToken ct) + { + using var conn = await DataverseCommandBridge.ConnectAsync(profileName, ct).ConfigureAwait(false); + var all = await QueryCurrenciesAsync(conn, ct).ConfigureAwait(false); + return CurrencyResolver.Resolve(all, query).Id; + } + + private static async Task WhoAmIAsync(DataverseConnection conn, CancellationToken ct) + { + var response = (WhoAmIResponse)await conn.Client.ExecuteAsync(new WhoAmIRequest(), ct).ConfigureAwait(false); + return response.UserId; + } + + private static async Task> QueryTimezonesAsync(DataverseConnection conn, CancellationToken ct) + { + var query = new QueryExpression("timezonedefinition") + { + ColumnSet = new ColumnSet("timezonecode", "userinterfacename", "standardname"), + Orders = { new OrderExpression("userinterfacename", OrderType.Ascending) } + }; + + var result = await conn.Client.RetrieveMultipleAsync(query, ct).ConfigureAwait(false); + return result.Entities + .Select(entity => new TimezoneInfo( + entity.GetAttributeValue("timezonecode"), + entity.GetAttributeValue("userinterfacename") ?? string.Empty, + entity.GetAttributeValue("standardname"))) + .ToList(); + } + + private static async Task TryGetTimezoneNameAsync(DataverseConnection conn, int code, CancellationToken ct) + { + var query = new QueryExpression("timezonedefinition") + { + ColumnSet = new ColumnSet("userinterfacename"), + Criteria = { Conditions = { new ConditionExpression("timezonecode", ConditionOperator.Equal, code) } }, + TopCount = 1 + }; + + var result = await conn.Client.RetrieveMultipleAsync(query, ct).ConfigureAwait(false); + return result.Entities.Count > 0 + ? result.Entities[0].GetAttributeValue("userinterfacename") + : null; + } + + private static async Task> QueryCurrenciesAsync(DataverseConnection conn, CancellationToken ct) + { + var query = new QueryExpression("transactioncurrency") + { + ColumnSet = new ColumnSet("transactioncurrencyid", "isocurrencycode", "currencyname", "currencysymbol"), + Orders = { new OrderExpression("isocurrencycode", OrderType.Ascending) } + }; + + var result = await conn.Client.RetrieveMultipleAsync(query, ct).ConfigureAwait(false); + return result.Entities + .Select(entity => new CurrencyInfo( + entity.Id, + entity.GetAttributeValue("isocurrencycode") ?? string.Empty, + entity.GetAttributeValue("currencyname") ?? string.Empty, + entity.GetAttributeValue("currencysymbol"))) + .ToList(); + } + + private static async Task TryGetCurrencyAsync(DataverseConnection conn, Guid id, CancellationToken ct) + { + var currency = await conn.Client.RetrieveAsync( + "transactioncurrency", id, + new ColumnSet("isocurrencycode", "currencyname", "currencysymbol"), + ct).ConfigureAwait(false); + + return new CurrencyInfo( + id, + currency.GetAttributeValue("isocurrencycode") ?? string.Empty, + currency.GetAttributeValue("currencyname") ?? string.Empty, + currency.GetAttributeValue("currencysymbol")); + } + + private static string? LocaleName(int? localeId) + { + if (localeId is null) + return null; + try + { + return CultureInfo.GetCultureInfo(localeId.Value).Name; + } + catch (CultureNotFoundException) + { + return null; + } + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/CurrencyResolverTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/CurrencyResolverTests.cs new file mode 100644 index 00000000..6f50c819 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/CurrencyResolverTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Data; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +/// +/// Unit tests for ISO-code/name matching. +/// +public class CurrencyResolverTests +{ + private static readonly Guid CzkId = Guid.NewGuid(); + private static readonly Guid UsdId = Guid.NewGuid(); + private static readonly Guid CadId = Guid.NewGuid(); + + private static readonly IReadOnlyList Currencies = new[] + { + new CurrencyInfo(CzkId, "CZK", "Czech Koruna", "Kč"), + new CurrencyInfo(UsdId, "USD", "US Dollar", "$"), + new CurrencyInfo(CadId, "CAD", "Canadian Dollar", "$"), + }; + + [Fact] + public void Resolve_ExactIsoCode_ReturnsMatch() + { + var match = CurrencyResolver.Resolve(Currencies, "CZK"); + + Assert.Equal(CzkId, match.Id); + } + + [Fact] + public void Resolve_IsoCodeIsCaseInsensitive() + { + var match = CurrencyResolver.Resolve(Currencies, "czk"); + + Assert.Equal(CzkId, match.Id); + } + + [Fact] + public void Resolve_UniqueNameSubstring_ReturnsMatch() + { + var match = CurrencyResolver.Resolve(Currencies, "Koruna"); + + Assert.Equal(CzkId, match.Id); + } + + [Fact] + public void Resolve_NoMatch_ThrowsWithBrowseHint() + { + var ex = Assert.Throws(() => CurrencyResolver.Resolve(Currencies, "XYZ")); + + Assert.Contains("XYZ", ex.Message); + Assert.Contains("currency list", ex.Message); + } + + [Fact] + public void Resolve_Ambiguous_ThrowsAndListsCandidates() + { + var ex = Assert.Throws(() => CurrencyResolver.Resolve(Currencies, "Dollar")); + + Assert.Contains("matches 2 currencies", ex.Message); + Assert.Contains("USD", ex.Message); + Assert.Contains("CAD", ex.Message); + } + + [Fact] + public void Resolve_EmptyQuery_Throws() + { + Assert.Throws(() => CurrencyResolver.Resolve(Currencies, " ")); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DateFormatsTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DateFormatsTests.cs new file mode 100644 index 00000000..e9ca4f0b --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/DateFormatsTests.cs @@ -0,0 +1,63 @@ +using System; +using TALXIS.CLI.Core.Contracts.Dataverse; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +/// +/// Unit tests for code/format mapping. +/// +public class DateFormatsTests +{ + [Fact] + public void ResolveCode_FormatString_MapsToCode() + { + Assert.Equal(2, DateFormats.ToCode(DateFormats.Short, "M/d/yyyy", "short date format")); + Assert.Equal(1, DateFormats.ToCode(DateFormats.Long, "dddd, d MMMM, yyyy", "long date format")); + } + + [Fact] + public void ResolveCode_IsCaseInsensitive() + { + Assert.Equal(2, DateFormats.ToCode(DateFormats.Short, "m/d/YYYY", "short date format")); + } + + [Fact] + public void ResolveCode_NumericInput_PassesThrough() + { + Assert.Equal(5, DateFormats.ToCode(DateFormats.Short, "5", "short date format")); + // Codes outside the known set still pass through; the environment validates them. + Assert.Equal(42, DateFormats.ToCode(DateFormats.Long, "42", "long date format")); + } + + [Fact] + public void ResolveCode_UnknownFormat_ThrowsAndListsOptions() + { + var ex = Assert.Throws( + () => DateFormats.ToCode(DateFormats.Short, "dd/MMM/yy", "short date format")); + + Assert.Contains("dd/MMM/yy", ex.Message); + Assert.Contains("M/d/yyyy", ex.Message); + } + + [Fact] + public void ResolveCode_EmptyInput_Throws() + { + Assert.Throws(() => DateFormats.ToCode(DateFormats.Short, " ", "short date format")); + } + + [Fact] + public void Describe_InRange_ReturnsFormat() + { + Assert.Equal("M/d/yyyy", DateFormats.Describe(DateFormats.Short, 2)); + Assert.Equal("dddd, d MMMM, yyyy", DateFormats.Describe(DateFormats.Long, 1)); + } + + [Fact] + public void Describe_OutOfRangeOrNull_ReturnsNull() + { + Assert.Null(DateFormats.Describe(DateFormats.Short, 99)); + Assert.Null(DateFormats.Describe(DateFormats.Short, null)); + Assert.Null(DateFormats.Describe(DateFormats.Long, -1)); + } +} diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/TimezoneResolverTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/TimezoneResolverTests.cs new file mode 100644 index 00000000..f16d8b5b --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/TimezoneResolverTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using TALXIS.CLI.Core.Contracts.Dataverse; +using TALXIS.CLI.Platform.Dataverse.Data; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment.Platforms.Dataverse; + +/// +/// Unit tests for name-to-code matching. +/// +public class TimezoneResolverTests +{ + private static readonly IReadOnlyList Timezones = new[] + { + new TimezoneInfo(85, "(GMT) Dublin, Edinburgh, Lisbon, London", "GMT Standard Time"), + new TimezoneInfo(95, "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague", "Central Europe Standard Time"), + new TimezoneInfo(105, "(GMT-05:00) Eastern Time (US & Canada)", "Eastern Standard Time"), + new TimezoneInfo(110, "(GMT-06:00) Central Time (US & Canada)", "Central Standard Time"), + }; + + [Fact] + public void Resolve_UniqueSubstring_ReturnsMatch() + { + var match = TimezoneResolver.Resolve(Timezones, "Prague"); + + Assert.Equal(95, match.Code); + } + + [Fact] + public void Resolve_MatchesStandardName() + { + var match = TimezoneResolver.Resolve(Timezones, "Central Europe"); + + Assert.Equal(95, match.Code); + } + + [Fact] + public void Resolve_IsCaseInsensitive() + { + var match = TimezoneResolver.Resolve(Timezones, "prague"); + + Assert.Equal(95, match.Code); + } + + [Fact] + public void Resolve_ExactNameWinsOverSubstring() + { + var tzs = new[] + { + new TimezoneInfo(1, "Prague", null), + new TimezoneInfo(2, "Greater Prague Area", null), + }; + + var match = TimezoneResolver.Resolve(tzs, "Prague"); + + Assert.Equal(1, match.Code); + } + + [Fact] + public void Resolve_NoMatch_ThrowsWithBrowseHint() + { + var ex = Assert.Throws(() => TimezoneResolver.Resolve(Timezones, "Atlantis")); + + Assert.Contains("Atlantis", ex.Message); + Assert.Contains("timezone list", ex.Message); + } + + [Fact] + public void Resolve_Ambiguous_ThrowsAndListsCandidates() + { + var ex = Assert.Throws(() => TimezoneResolver.Resolve(Timezones, "Canada")); + + Assert.Contains("matches 2 timezones", ex.Message); + Assert.Contains("--timezone", ex.Message); + Assert.Contains("105", ex.Message); + Assert.Contains("110", ex.Message); + } + + [Fact] + public void Resolve_EmptyQuery_Throws() + { + Assert.Throws(() => TimezoneResolver.Resolve(Timezones, " ")); + } +}