From d9e3c59d69930fa9debe712eb810129adf985579 Mon Sep 17 00:00:00 2001
From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com>
Date: Tue, 14 Jul 2026 06:21:37 +0200
Subject: [PATCH] more flex and filters
---
Client/ListModifiers.cs | 248 ++++++++++++++++++++++
Client/SdkRequestExtensions.cs | 7 +-
Controllers/ContactsController.cs | 20 +-
Controllers/IssuedInvoicesController.cs | 20 +-
Controllers/MailController.cs | 47 ++++
Controllers/ReceivedInvoicesController.cs | 17 +-
Controllers/RegistersController.cs | 36 +++-
Controllers/ReportsController.cs | 38 ++++
Program.cs | 2 +
Services/ContactsService.cs | 4 +-
Services/IssuedInvoicesService.cs | 4 +-
Services/MailService.cs | 30 +++
Services/ReceivedInvoicesService.cs | 4 +-
Services/RegistersService.cs | 12 +-
Services/ReportsService.cs | 31 +++
15 files changed, 488 insertions(+), 32 deletions(-)
create mode 100644 Client/ListModifiers.cs
create mode 100644 Controllers/MailController.cs
create mode 100644 Controllers/ReportsController.cs
create mode 100644 Services/MailService.cs
create mode 100644 Services/ReportsService.cs
diff --git a/Client/ListModifiers.cs b/Client/ListModifiers.cs
new file mode 100644
index 0000000..50dd063
--- /dev/null
+++ b/Client/ListModifiers.cs
@@ -0,0 +1,248 @@
+using System.Collections;
+using System.Globalization;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using IdokladSdk.Clients;
+using IdokladSdk.Requests.Core;
+using IdokladSdk.Requests.Core.Modifiers.Filters.Common;
+using IdokladSdk.Requests.Core.Modifiers.Sort.Common;
+using IdokladSdk.Response;
+
+namespace Idoklad.Client;
+
+///
+/// Translates iDoklad-style filter/sort query strings into the SDK's strongly-typed
+/// /
+/// modifiers, so the REST
+/// list endpoints expose the full server-side filtering the SDK supports (operators
+/// eq/neq/gt/gte/lt/lte/ct/nct, AND/OR combining, and multi-column sort) instead of only paging.
+///
+/// Filter format (identical to the public iDoklad API): one or more
+/// (Property~operator~value) conditions joined by , — e.g.
+/// (DateOfTaxing~gte~2024-01-01),(IsPaid~eq~false). Parentheses are optional for a single
+/// condition. The filtertype query parameter (and | or, default and)
+/// decides how multiple conditions combine.
+///
+/// Operators: eq (=), neq (≠), gt (>), gte (≥),
+/// lt (<), lte (≤), ct (contains), nct (not contains). The available
+/// operators per field follow the SDK filter type (e.g. dates support the compare operators,
+/// text fields support contains).
+///
+/// Sort format: Property~asc|desc, multiple joined by , — e.g.
+/// DateOfIssue~desc,Id~asc. Direction defaults to ascending.
+///
+public static class ListModifiers
+{
+ private static readonly Regex GroupRegex = new(@"\(([^()]*)\)", RegexOptions.Compiled);
+
+ private static readonly Dictionary OperatorMethods = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["eq"] = "IsEqual",
+ ["neq"] = "IsNotEqual",
+ ["gt"] = "IsGreaterThan",
+ ["gte"] = "IsGreaterThanOrEqual",
+ ["lt"] = "IsLowerThan",
+ ["lte"] = "IsLowerThanOrEqual",
+ ["ct"] = "Contains",
+ ["nct"] = "NotContains",
+ };
+
+ ///
+ /// Applies paging plus optional / to a list
+ /// request and returns the unwrapped page. Shared by every list endpoint.
+ ///
+ public static async Task> GetPageAsync(
+ this BaseList list,
+ int page,
+ int pageSize,
+ string? filter,
+ string? filterType,
+ string? sort,
+ CancellationToken ct)
+ where TList : BaseList
+ where TClient : BaseClient
+ where TFilter : new()
+ where TSort : new()
+ where TGetModel : new()
+ {
+ BaseList built = list;
+
+ if (!string.IsNullOrWhiteSpace(filter))
+ {
+ var useOr = IsOr(filterType);
+ built = built.Filter(f => BuildFilter(f!, filter, useOr));
+ }
+
+ if (!string.IsNullOrWhiteSpace(sort))
+ {
+ built = built.Sort(BuildSort(sort));
+ }
+
+ return (await built.Page(page).PageSize(pageSize).GetAsync(ct)).Unwrap();
+ }
+
+ /// True when the filtertype query value requests OR combining (default AND).
+ public static bool IsOr(string? filterType)
+ => string.Equals(filterType, "or", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Builds a combined from an iDoklad-style filter string
+ /// against the SDK filter object (passed in by the SDK at call time).
+ /// Conditions are combined with OR when is true, otherwise AND.
+ ///
+ public static FilterExpressionBase BuildFilter(object filter, string filterString, bool useOr)
+ {
+ FilterExpressionBase? combined = null;
+
+ foreach (var group in ExtractConditions(filterString))
+ {
+ var expression = BuildCondition(filter, group);
+ combined = combined is null
+ ? expression
+ : (useOr ? combined | expression : combined & expression);
+ }
+
+ if (combined is null)
+ {
+ throw new ArgumentException($"Filtr '{filterString}' neobsahuje žádnou platnou podmínku.");
+ }
+
+ return combined;
+ }
+
+ /// Builds the SDK sort selectors from a Field~asc|desc,... string.
+ public static Func[] BuildSort(string sort)
+ {
+ var parts = sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var selectors = new List>();
+
+ foreach (var part in parts)
+ {
+ var segments = part.Split('~', StringSplitOptions.TrimEntries);
+ var field = segments[0];
+ var descending = segments.Length > 1 && segments[1].Equals("desc", StringComparison.OrdinalIgnoreCase);
+
+ selectors.Add(sortObj =>
+ {
+ var property = typeof(TSort).GetProperty(field, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
+ ?? throw new ArgumentException($"Řazení podle pole '{field}' není u této agendy podporováno.");
+ var item = (SortItem)property.GetValue(sortObj)!;
+ return descending ? item.Desc() : item.Asc();
+ });
+ }
+
+ return selectors.ToArray();
+ }
+
+ /// Splits the filter string into individual Name~op~value conditions.
+ private static IEnumerable ExtractConditions(string filterString)
+ {
+ filterString = filterString.Trim();
+
+ if (filterString.Contains('('))
+ {
+ return GroupRegex.Matches(filterString)
+ .Select(m => m.Groups[1].Value.Trim())
+ .Where(s => s.Length > 0);
+ }
+
+ return filterString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+
+ private static FilterExpressionBase BuildCondition(object filter, string condition)
+ {
+ var firstTilde = condition.IndexOf('~');
+ var secondTilde = firstTilde < 0 ? -1 : condition.IndexOf('~', firstTilde + 1);
+ if (firstTilde < 0 || secondTilde < 0)
+ {
+ throw new ArgumentException($"Neplatná filtrační podmínka '{condition}'. Očekává se tvar 'Pole~operátor~hodnota'.");
+ }
+
+ var name = condition[..firstTilde].Trim();
+ var op = condition[(firstTilde + 1)..secondTilde].Trim();
+ var rawValue = condition[(secondTilde + 1)..].Trim();
+
+ if (!OperatorMethods.TryGetValue(op, out var methodName))
+ {
+ throw new ArgumentException($"Neznámý filtrační operátor '{op}'. Povolené: eq, neq, gt, gte, lt, lte, ct, nct.");
+ }
+
+ var property = filter.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
+ ?? throw new ArgumentException($"Filtrování podle pole '{name}' není u této agendy podporováno.");
+
+ var item = property.GetValue(filter)
+ ?? throw new ArgumentException($"Filtrační pole '{name}' není dostupné.");
+
+ var method = FindOperatorMethod(item.GetType(), methodName)
+ ?? throw new ArgumentException($"Operátor '{op}' není pro pole '{name}' podporován.");
+
+ var targetType = method.GetParameters()[0].ParameterType;
+ var value = ConvertValue(rawValue, targetType, name);
+
+ try
+ {
+ return (FilterExpressionBase)method.Invoke(item, new[] { value })!;
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException is not null)
+ {
+ throw new ArgumentException($"Filtr '{name}~{op}~{rawValue}' se nepodařilo sestavit: {ex.InnerException.Message}");
+ }
+ }
+
+ private static MethodInfo? FindOperatorMethod(Type itemType, string methodName)
+ {
+ var candidates = itemType
+ .GetMethods(BindingFlags.Public | BindingFlags.Instance)
+ .Where(m => m.Name == methodName && m.GetParameters().Length == 1)
+ .ToList();
+
+ if (candidates.Count <= 1)
+ {
+ return candidates.FirstOrDefault();
+ }
+
+ // Some filter items (e.g. the Id filter) overload Contains with a scalar and a collection —
+ // prefer the scalar overload for a single query value.
+ return candidates.FirstOrDefault(m =>
+ {
+ var pt = m.GetParameters()[0].ParameterType;
+ return pt == typeof(string) || !typeof(IEnumerable).IsAssignableFrom(pt);
+ }) ?? candidates[0];
+ }
+
+ private static object ConvertValue(string raw, Type target, string fieldName)
+ {
+ var type = Nullable.GetUnderlyingType(target) ?? target;
+
+ try
+ {
+ if (type == typeof(string))
+ {
+ return raw;
+ }
+ if (type.IsEnum)
+ {
+ return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric)
+ ? Enum.ToObject(type, numeric)
+ : Enum.Parse(type, raw, ignoreCase: true);
+ }
+ if (type == typeof(DateTime))
+ {
+ return DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None);
+ }
+ if (type == typeof(bool))
+ {
+ return bool.Parse(raw);
+ }
+ if (type == typeof(Guid))
+ {
+ return Guid.Parse(raw);
+ }
+ return Convert.ChangeType(raw, type, CultureInfo.InvariantCulture);
+ }
+ catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException or ArgumentException)
+ {
+ throw new ArgumentException($"Hodnotu '{raw}' filtru '{fieldName}' nelze převést na typ {type.Name}.");
+ }
+ }
+}
diff --git a/Client/SdkRequestExtensions.cs b/Client/SdkRequestExtensions.cs
index 279e350..0a3156b 100644
--- a/Client/SdkRequestExtensions.cs
+++ b/Client/SdkRequestExtensions.cs
@@ -14,13 +14,16 @@ public static class SdkRequestExtensions
this BaseList list,
int page,
int pageSize,
- CancellationToken ct)
+ CancellationToken ct,
+ string? filter = null,
+ string? filterType = null,
+ string? sort = null)
where TList : BaseList
where TClient : BaseClient
where TFilter : new()
where TSort : new()
where TGetModel : new()
- => (await list.Page(page).PageSize(pageSize).GetAsync(ct)).Unwrap();
+ => await list.GetPageAsync(page, pageSize, filter, filterType, sort, ct);
public static async Task