From fd41110f2a4c8c2712550ee465d51c50296b40c7 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 25 Nov 2025 16:10:49 +0200 Subject: [PATCH 01/17] Add `ConverterCatalog` service and demo project for handling ConvertAPI converters - Introduced `ConverterCatalog` for fetching and parsing ConvertAPI's OpenAPI spec. - Added `IConverterCatalog` interface for defining catalog operations. - Implemented model classes for working with converters and tags. - Created a `ConverterCatalogDemo` project demonstrating usage of the new service. - Updated the solution to include the demo project. #66 --- ConvertApi.sln | 9 +- ConvertApi/Interface/IConverterCatalog.cs | 43 ++ ConvertApi/Model/ConverterDtos.cs | 59 +++ ConvertApi/Services/ConverterCatalog.cs | 475 ++++++++++++++++++ .../ConverterCatalogDemo.csproj | 14 + Examples/ConverterCatalogDemo/Program.cs | 146 ++++++ 6 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 ConvertApi/Interface/IConverterCatalog.cs create mode 100644 ConvertApi/Model/ConverterDtos.cs create mode 100644 ConvertApi/Services/ConverterCatalog.cs create mode 100644 Examples/ConverterCatalogDemo/ConverterCatalogDemo.csproj create mode 100644 Examples/ConverterCatalogDemo/Program.cs diff --git a/ConvertApi.sln b/ConvertApi.sln index 88edc02..f41db12 100644 --- a/ConvertApi.sln +++ b/ConvertApi.sln @@ -57,6 +57,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConvertPdfToPdfA", "Example EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UploadOnceReuseTwice", "Examples\UploadOnceReuseTwice\UploadOnceReuseTwice.csproj", "{BDF6CFA4-56C1-4F23-9D65-5D0B7A5C2E0A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConverterCatalogDemo", "Examples\ConverterCatalogDemo\ConverterCatalogDemo.csproj", "{DFEC8F03-E140-488A-B457-59715605F5CA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -147,6 +149,10 @@ Global {C3C2C6C0-9F6E-4B9F-A6A9-1C0D7E4CB8B1}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3C2C6C0-9F6E-4B9F-A6A9-1C0D7E4CB8B1}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3C2C6C0-9F6E-4B9F-A6A9-1C0D7E4CB8B1}.Release|Any CPU.Build.0 = Release|Any CPU + {DFEC8F03-E140-488A-B457-59715605F5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFEC8F03-E140-488A-B457-59715605F5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFEC8F03-E140-488A-B457-59715605F5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFEC8F03-E140-488A-B457-59715605F5CA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -173,7 +179,8 @@ Global {BDF6CFA4-56C1-4F23-9D65-5D0B7A5C2E0A} = {139F9822-4A90-475F-BAA5-F0471A85461B} {C3C2C6C0-9F6E-4B9F-A6A9-1C0D7E4CB8B1} = {139F9822-4A90-475F-BAA5-F0471A85461B} {5C7E3B2A-0D7B-4D3F-9C64-2EA2F2E0E5C1} = {139F9822-4A90-475F-BAA5-F0471A85461B} -EndGlobalSection + {DFEC8F03-E140-488A-B457-59715605F5CA} = {139F9822-4A90-475F-BAA5-F0471A85461B} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {337A0F6F-40A9-4993-BF66-05E4E374871F} EndGlobalSection diff --git a/ConvertApi/Interface/IConverterCatalog.cs b/ConvertApi/Interface/IConverterCatalog.cs new file mode 100644 index 0000000..f04e08e --- /dev/null +++ b/ConvertApi/Interface/IConverterCatalog.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ConvertApiDotNet.Model; + +namespace ConvertApiDotNet.Interface +{ + /// + /// Provides access to ConvertAPI converters and tags by parsing the OpenAPI specification. + /// + public interface IConverterCatalog + { + /// + /// Gets the complete list of available converters. + /// + List GetAllConverters(); + + /// + /// Gets a single converter definition by source and destination formats. + /// + ConverterDto GetConverter(string sourceFormat, string destinationFormat); + + /// + /// Gets converters filtered by the specified tags. + /// If tags is null or empty, returns all converters. + /// + List GetConvertersByTags(List tags = null); + + /// + /// Searches for converters by the provided search terms. + /// + List SearchConverters(string[] terms); + + /// + /// Gets the list of available tags. + /// + List GetTags(); + + /// + /// Reloads converter and tag information from the service and updates the cache. + /// + Task Reload(); + } +} diff --git a/ConvertApi/Model/ConverterDtos.cs b/ConvertApi/Model/ConverterDtos.cs new file mode 100644 index 0000000..3f7b66a --- /dev/null +++ b/ConvertApi/Model/ConverterDtos.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; + +namespace ConvertApiDotNet.Model +{ + public class ConverterDto + { + public string Summary { get; set; } + public string Description { get; set; } + public string Overview { get; set; } + public string MetaTitle { get; set; } + public string MetaDescription { get; set; } + public List Tags { get; set; } + public string SourceFormat { get; set; } + public string DestinationFormat { get; set; } + public List SourceExtensions { get; set; } + public List DestinationExtensions { get; set; } + public IEnumerable ConverterParameterGroups { get; set; } + } + + public class ConverterParameterGroupDto + { + public string Name { get; set; } + public IEnumerable ConverterParameters { get; set; } + } + + public class ConverterParameterDto + { + public string Name { get; set; } + public string Label { get; set; } + public string Description { get; set; } + public string GroupName { get; set; } + public string Type { get; set; } + public string Representation { get; set; } + public object Default { get; set; } + public Dictionary Values { get; set; } + public ConverterParameterRangeDto Range { get; set; } + public bool Required { get; set; } + public bool Featured { get; set; } + public bool Array { get; set; } + public string[] AllowedExtensions { get; set; } + } + + public class ConverterParameterRangeDto + { + public string From { get; set; } + public string To { get; set; } + } + + public class TagDto + { + public string Name { get; set; } + public string Summary { get; set; } + public string Description { get; set; } + public string PageTitle { get; set; } + public string FriendlyName { get; set; } + public string MetaTitle { get; set; } + public string MetaDescription { get; set; } + } +} diff --git a/ConvertApi/Services/ConverterCatalog.cs b/ConvertApi/Services/ConverterCatalog.cs new file mode 100644 index 0000000..3615ea8 --- /dev/null +++ b/ConvertApi/Services/ConverterCatalog.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using ConvertApiDotNet.Constants; +using ConvertApiDotNet.Interface; +using ConvertApiDotNet.Model; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Readers; + +namespace ConvertApiDotNet.Services +{ + /// + /// Default implementation that downloads and parses ConvertAPI OpenAPI spec and exposes a catalog of converters and tags. + /// + public class ConverterCatalog : IConverterCatalog + { + private readonly Uri _baseUri; + private readonly IConvertApiHttpClient _http; + + private readonly SemaphoreSlim _loadSync = new SemaphoreSlim(1, 1); + private volatile bool _loaded; + private List _converters = new List(); + private List _tags = new List(); + + public ConverterCatalog() + : this(new Uri(ConvertApi.ApiBaseUri), ConvertApi.GetClient()) + { + } + + public ConverterCatalog(Uri baseUri, IConvertApiHttpClient http) + { + _baseUri = baseUri ?? throw new ArgumentNullException(nameof(baseUri)); + _http = http ?? throw new ArgumentNullException(nameof(http)); + } + + public List GetAllConverters() + { + EnsureLoaded(); + return _converters; + } + + public ConverterDto GetConverter(string sourceFormat, string destinationFormat) + { + EnsureLoaded(); + if (string.IsNullOrWhiteSpace(sourceFormat) || string.IsNullOrWhiteSpace(destinationFormat)) + return null; + var src = TrimDot(sourceFormat); + var dst = TrimDot(destinationFormat); + return _converters.FirstOrDefault(c => + string.Equals(c.SourceFormat, src, StringComparison.OrdinalIgnoreCase) && + string.Equals(c.DestinationFormat, dst, StringComparison.OrdinalIgnoreCase)); + } + + public List GetConvertersByTags(List tags = null) + { + EnsureLoaded(); + if (tags == null || tags.Count == 0) + return _converters; + var wanted = tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList(); + if (wanted.Count == 0) return _converters; + return _converters.Where(c => ContainsAllTags(c.Tags, wanted)).ToList(); + } + + public List SearchConverters(string[] terms) + { + EnsureLoaded(); + if (terms == null || terms.Length == 0) return _converters; + var keys = terms.Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim()) + .ToArray(); + if (keys.Length == 0) return _converters; + + return _converters.Where(c => + keys.All(k => Matches(c, k))).ToList(); + } + + public List GetTags() + { + EnsureLoaded(); + return _tags; + } + + public async Task Reload() + { + await LoadInternal(force: true).ConfigureAwait(false); + } + + private void EnsureLoaded() + { + if (_loaded) return; + // Avoid deadlocks by waiting on background thread + LoadInternal(force: false).GetAwaiter().GetResult(); + } + + private async Task LoadInternal(bool force) + { + if (_loaded && !force) return; + await _loadSync.WaitAsync().ConfigureAwait(false); + try + { + if (_loaded && !force) return; + var doc = await TryFetchOpenApiAsync("info/openapi").ConfigureAwait(false) + ?? await TryFetchOpenApiAsync("info/openApi").ConfigureAwait(false); + if (doc == null) + throw new InvalidOperationException("OpenAPI document could not be retrieved."); + + var parsedConverters = ParseConverters(doc); + var parsedTags = ParseTags(doc); + + // Apply + _converters = parsedConverters + .OrderBy(c => c.SourceFormat, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.DestinationFormat, StringComparer.OrdinalIgnoreCase) + .ToList(); + _tags = parsedTags + .OrderBy(t => t.FriendlyName ?? t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + _loaded = true; + } + finally + { + _loadSync.Release(); + } + } + + private async Task TryFetchOpenApiAsync(string path) + { + var url = new UriBuilder(_baseUri) + { + Path = path + }; + try + { + var response = await _http.GetAsync(url.Uri, ConvertApiConstants.DownloadTimeout).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + return null; + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using (var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))) + { + var reader = new OpenApiStreamReader(); + return reader.Read(ms, out var _); + } + } + catch + { + return null; + } + } + + private static List ParseConverters(OpenApiDocument doc) + { + var list = new List(); + + foreach (var pathKvp in doc.Paths) + { + var path = pathKvp.Key; // e.g. /convert/pdf/to/docx + if (!path.StartsWith("/convert/", StringComparison.OrdinalIgnoreCase)) + continue; + + var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + // expected: ["convert", "{src}", "to", "{dst}"] + if (parts.Length < 4 || !string.Equals(parts[2], "to", StringComparison.OrdinalIgnoreCase)) + continue; + var src = TrimDot(parts[1]); + var dst = TrimDot(parts[3]); + + var item = pathKvp.Value; + if (item?.Operations == null) continue; + if (!item.Operations.TryGetValue(OperationType.Post, out var op)) + continue; + + var dto = new ConverterDto + { + SourceFormat = src, + DestinationFormat = dst, + Summary = item.Summary ?? op.Summary, + Description = item.Description ?? op.Description, + Overview = GetExtensionString(op.Extensions, "x-ca-overview") ?? GetExtensionString(item.Extensions, "x-ca-overview"), + MetaTitle = GetExtensionString(op.Extensions, "x-ca-meta-title") ?? GetExtensionString(item.Extensions, "x-ca-meta-title"), + MetaDescription = GetExtensionString(op.Extensions, "x-ca-meta-description") ?? GetExtensionString(item.Extensions, "x-ca-meta-description"), + Tags = CollectTags(doc, op, item), + SourceExtensions = ParseExtensionsFrom(op.Extensions, item.Extensions, defaultTo: new[] { "." + src }), + DestinationExtensions = new List { "." + dst }, + ConverterParameterGroups = ParseParameterGroups(op) + }; + + list.Add(dto); + } + + return list; + } + + private static List CollectTags(OpenApiDocument doc, OpenApiOperation op, OpenApiPathItem item) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + // From operation tag names + if (op?.Tags != null) + { + foreach (var t in op.Tags) + { + if (!string.IsNullOrWhiteSpace(t.Name)) set.Add(t.Name.Trim()); + } + } + // From custom extension array + foreach (var extDict in new[] { op?.Extensions, item?.Extensions }) + { + var ext = GetExtensionArray(extDict, "x-ca-tags"); + if (ext != null) + { + foreach (var v in ext) + { + if (!string.IsNullOrWhiteSpace(v)) set.Add(v); + } + } + } + return set.ToList(); + } + + private static IEnumerable ParseParameterGroups(OpenApiOperation op) + { + // For simplicity, we treat operation request body schema properties as a single group "General" + var groups = new List(); + var parameters = new List(); + + var content = op?.RequestBody?.Content; + if (content != null) + { + foreach (var kv in content) + { + var schema = kv.Value?.Schema; + if (schema == null) continue; + + var requiredList = schema.Required != null ? schema.Required.ToList() : new List(); + var required = new HashSet(requiredList, StringComparer.OrdinalIgnoreCase); + if (schema.Properties != null) + { + foreach (var p in schema.Properties) + { + var name = p.Key; + var s = p.Value; + + var cp = new ConverterParameterDto + { + Name = name, + Label = GetExtensionString(s.Extensions, "x-ca-label") ?? name, + Description = s.Description, + GroupName = GetExtensionString(s.Extensions, "x-ca-group") ?? "General", + Type = s.Type, + Representation = s.Format, + Default = s.Default is IOpenApiPrimitive prim ? GetPrimitiveValue(prim) : null, + Values = ToEnumDictionary(s.Enum), + Range = GetRange(s), + Required = required.Contains(name), + Featured = GetExtensionBool(s.Extensions, "x-ca-featured") ?? false, + Array = string.Equals(s.Type, "array", StringComparison.OrdinalIgnoreCase), + AllowedExtensions = GetAllowedExtensions(s) + }; + + parameters.Add(cp); + } + } + } + } + + // Group by GroupName to ConverterParameterGroupDto + foreach (var grp in parameters.GroupBy(p => p.GroupName ?? "General")) + { + groups.Add(new ConverterParameterGroupDto + { + Name = grp.Key, + ConverterParameters = grp.ToList() + }); + } + return groups; + } + + private static ConverterParameterRangeDto GetRange(OpenApiSchema s) + { + string from = null; + string to = null; + if (s.Minimum.HasValue) from = s.Minimum.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (s.Maximum.HasValue) to = s.Maximum.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (from == null && to == null) return null; + return new ConverterParameterRangeDto { From = from, To = to }; + } + + private static string[] GetAllowedExtensions(OpenApiSchema s) + { + // Use x-ca-allowed-extensions or x-ca-source-formats on the property + var formats = GetExtensionString(s.Extensions, "x-ca-allowed-extensions") + ?? GetExtensionString(s.Extensions, "x-ca-source-formats"); + if (string.IsNullOrWhiteSpace(formats)) return null; + return SplitExtensions(formats).ToArray(); + } + + private static Dictionary ToEnumDictionary(IList @enum) + { + if (@enum == null || @enum.Count == 0) return null; + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var e in @enum) + { + if (e is IOpenApiPrimitive p) + { + var v = GetPrimitiveValue(p)?.ToString(); + if (!string.IsNullOrWhiteSpace(v) && !dict.ContainsKey(v)) + dict[v] = v; + } + } + return dict.Count > 0 ? dict : null; + } + + private static object GetPrimitiveValue(IOpenApiPrimitive prim) + { + switch (prim) + { + case OpenApiString s: return s.Value; + case OpenApiBoolean b: return b.Value; + case OpenApiInteger i: return i.Value; + case OpenApiLong l: return l.Value; + case OpenApiFloat f: return f.Value; + case OpenApiDouble d: return d.Value; + default: return prim?.ToString(); + } + } + + private static List ParseTags(OpenApiDocument doc) + { + var list = new List(); + if (doc.Tags == null) return list; + foreach (var t in doc.Tags) + { + var tag = new TagDto + { + Name = t.Name, + Summary = GetExtensionString(t.Extensions, "x-summary"), + Description = t.Description, + PageTitle = GetExtensionString(t.Extensions, "x-ca-page-title"), + FriendlyName = GetExtensionString(t.Extensions, "x-ca-friendly-name"), + MetaTitle = GetExtensionString(t.Extensions, "x-ca-meta-title"), + MetaDescription = GetExtensionString(t.Extensions, "x-ca-meta-description") + }; + list.Add(tag); + } + return list; + } + + private static List ParseExtensionsFrom(IDictionary opExt, IDictionary pathExt, IEnumerable defaultTo = null) + { + var str = GetExtensionString(opExt, "x-ca-source-formats") ?? GetExtensionString(pathExt, "x-ca-source-formats"); + if (!string.IsNullOrWhiteSpace(str)) + { + return SplitExtensions(str).ToList(); + } + return defaultTo?.ToList() ?? new List(); + } + + private static IEnumerable SplitExtensions(string csv) + { + return (csv ?? string.Empty) + .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.StartsWith(".") ? s : "." + s) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static string GetExtensionString(IDictionary extensions, string key) + { + if (extensions == null) return null; + if (!extensions.TryGetValue(key, out var ext) || ext == null) return null; + if (ext is OpenApiString s) return s.Value; + if (ext is OpenApiArray arr) + { + try + { + var joined = string.Join(",", arr.Select(a => (a as OpenApiString)?.Value).Where(v => !string.IsNullOrWhiteSpace(v))); + return joined; + } + catch { return null; } + } + return null; + } + + private static IEnumerable GetExtensionArray(IDictionary extensions, string key) + { + if (extensions == null) return null; + if (!extensions.TryGetValue(key, out var ext) || ext == null) return null; + if (ext is OpenApiArray arr) + { + return arr.Select(a => (a as OpenApiString)?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .ToArray(); + } + if (ext is OpenApiString s) + { + return s.Value?.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(v => v.Trim()) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .ToArray(); + } + return null; + } + + private static bool? GetExtensionBool(IDictionary extensions, string key) + { + if (extensions == null) return null; + if (!extensions.TryGetValue(key, out var ext) || ext == null) return null; + if (ext is OpenApiBoolean b) return b.Value; + if (ext is OpenApiString s && bool.TryParse(s.Value, out var v)) return v; + return null; + } + + private static bool ContainsAllTags(List converterTags, List wanted) + { + if (wanted == null || wanted.Count == 0) return true; + if (converterTags == null || converterTags.Count == 0) return false; + var set = new HashSet(converterTags, StringComparer.OrdinalIgnoreCase); + foreach (var t in wanted) + { + if (!set.Contains(t)) return false; + } + return true; + } + + private static string TrimDot(string s) + { + if (string.IsNullOrWhiteSpace(s)) return s; + return s.Trim().TrimStart('.'); + } + + private static bool Matches(ConverterDto c, string term) + { + if (c == null || string.IsNullOrWhiteSpace(term)) return false; + var t = term.Trim(); + bool In(params string[] fields) + { + foreach (var f in fields) + { + if (!string.IsNullOrEmpty(f) && f.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + return false; + } + + if (In(c.SourceFormat, c.DestinationFormat, c.Summary, c.Description, c.Overview, c.MetaTitle, c.MetaDescription)) + return true; + + if (c.Tags != null && c.Tags.Any(tag => tag?.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0)) + return true; + + if (c.ConverterParameterGroups != null) + { + foreach (var g in c.ConverterParameterGroups) + { + if (In(g?.Name)) return true; + if (g?.ConverterParameters != null) + { + foreach (var p in g.ConverterParameters) + { + if (In(p?.Name, p?.Label, p?.Description, p?.GroupName, p?.Type, p?.Representation)) + return true; + } + } + } + } + return false; + } + } +} diff --git a/Examples/ConverterCatalogDemo/ConverterCatalogDemo.csproj b/Examples/ConverterCatalogDemo/ConverterCatalogDemo.csproj new file mode 100644 index 0000000..acd1fad --- /dev/null +++ b/Examples/ConverterCatalogDemo/ConverterCatalogDemo.csproj @@ -0,0 +1,14 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + diff --git a/Examples/ConverterCatalogDemo/Program.cs b/Examples/ConverterCatalogDemo/Program.cs new file mode 100644 index 0000000..503f518 --- /dev/null +++ b/Examples/ConverterCatalogDemo/Program.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ConvertApiDotNet.Interface; +using ConvertApiDotNet.Model; +using ConvertApiDotNet.Services; + +// Demo app to showcase IConverterCatalog methods backed by ConvertAPI OpenAPI spec + +internal class Program +{ + private static async Task Main(string[] args) + { + Console.WriteLine("=== ConvertAPI Converter Catalog Demo ==="); + Console.WriteLine($"Base URI: {ConvertApiDotNet.ConvertApi.ApiBaseUri}"); + Console.WriteLine(); + + var catalog = new ConverterCatalog(); + + try + { + Console.WriteLine("Loading catalog (OpenAPI fetch + parse)..."); + await catalog.Reload(); + Console.WriteLine("Loaded.\n"); + } + catch (Exception ex) + { + Console.WriteLine("Failed to load catalog: " + ex.Message); + Console.WriteLine("The demo will try to continue with lazy loading on each call.\n"); + } + + // 1) GetAllConverters + Console.WriteLine("--- GetAllConverters() ---"); + var all = SafeCall(() => catalog.GetAllConverters()); + Console.WriteLine($"Total converters: {all.Count}"); + PrintConverters(all.Take(10), title: "First 10 converters"); + + // 2) GetConverter(source, destination) + Console.WriteLine("\n--- GetConverter(src, dst) ---"); + var samples = new (string src, string dst)[] + { + ("pdf", "docx"), + ("docx", "pdf"), + ("pdf", "merge"), + }; + foreach (var (src, dst) in samples) + { + var c = SafeCall(() => catalog.GetConverter(src, dst)); + if (c != null) + { + Console.WriteLine($"Found: {c.SourceFormat} -> {c.DestinationFormat} | {Trim(c.Summary, 60)}"); + } + else + { + Console.WriteLine($"Not found: {src} -> {dst}"); + } + } + + // 3) GetConvertersByTags(tags) + Console.WriteLine("\n--- GetConvertersByTags([\"pdf\"]) ---"); + var pdfTagged = SafeCall(() => catalog.GetConvertersByTags(new List { "pdf" })); + Console.WriteLine($"Converters with tag 'pdf': {pdfTagged.Count}"); + PrintConverters(pdfTagged.Take(10)); + + // 4) SearchConverters(terms) + Console.WriteLine("\n--- SearchConverters([\"watermark\", \"pdf\"]) ---"); + var search = SafeCall(() => catalog.SearchConverters(new[] { "watermark", "pdf" })); + Console.WriteLine($"Search results: {search.Count}"); + PrintConverters(search.Take(10)); + + // 5) GetTags() + Console.WriteLine("\n--- GetTags() ---"); + var tags = SafeCall(() => catalog.GetTags()); + Console.WriteLine($"Total tags: {tags.Count}"); + foreach (var t in tags.Take(20)) + { + var label = string.IsNullOrWhiteSpace(t.FriendlyName) ? t.Name : $"{t.FriendlyName} ({t.Name})"; + Console.WriteLine(" - " + label); + } + if (tags.Count > 20) Console.WriteLine($" ... and {tags.Count - 20} more"); + + // 6) Reload() + Console.WriteLine("\n--- Reload() ---"); + try + { + await catalog.Reload(); + var after = catalog.GetAllConverters(); + Console.WriteLine($"Reloaded. Converters count now: {after.Count}"); + } + catch (Exception ex) + { + Console.WriteLine("Reload failed: " + ex.Message); + } + + Console.WriteLine("\nDemo finished."); + return 0; + } + + private static List SafeCall(Func> f) + { + try { return f() ?? new List(); } + catch (Exception ex) + { + Console.WriteLine("Call failed: " + ex.Message); + return new List(); + } + } + + private static ConverterDto SafeCall(Func f) + { + try { return f(); } + catch (Exception ex) + { + Console.WriteLine("Call failed: " + ex.Message); + return null; + } + } + + private static List SafeCall(Func> f) + { + try { return f() ?? new List(); } + catch (Exception ex) + { + Console.WriteLine("Call failed: " + ex.Message); + return new List(); + } + } + + private static void PrintConverters(IEnumerable items, string title = null) + { + if (!string.IsNullOrWhiteSpace(title)) + Console.WriteLine(title + ":"); + + foreach (var c in items) + { + Console.WriteLine($" - {c.SourceFormat} -> {c.DestinationFormat} | {Trim(c.Summary, 60)}"); + } + } + + private static string Trim(string value, int max) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + return value.Length <= max ? value : value.Substring(0, max - 1) + "…"; + } +} From 8a179bbe96dc85a1de6681639c011e4f40f57937 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 25 Nov 2025 16:43:31 +0200 Subject: [PATCH 02/17] Start 3.1.7-dev development cycle; update version and changelog --- CHANGELOG.md | 9 +++++++++ ConvertApi/ConvertApi.csproj | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e954fe..8bf0c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning. +## [3.1.7-dev] — 2025-11-25 + +### Added +- Add `ConverterCatalog` service and demo project for handling ConvertAPI converters. + +### Changed +- Start next development cycle as `-dev` prerelease: bump version to `3.1.7-dev` (assembly/file version `3.1.7.0`). +- This is a development snapshot; entries will accumulate here until the next stable release. + ## [3.1.6] — 2025-11-12 ### Added diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index d95c0aa..9579dc4 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,7 +2,7 @@ true - 3.1.6 + 3.1.7-dev Baltsoft ConvertApi https://www.convertapi.com @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.6.0 - 3.1.6.0 - New examples (UploadOnceReuseTwice, GetConverterInfoDemo, DeleteFiles); new APIs (GetConverterInfo, DeleteFilesAsync); make CopyToAsync awaitable; rename GetValueAsync to GetUploadedFileAsync (obsolete alias kept); improve ConvertApiFileParam validation; examples upgraded to .NET 6. + 3.1.7.0 + 3.1.7.0 + Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.6 + 3.1.7-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9 From 16524cc7952e81bebbe8b4c307a4341095447c0e Mon Sep 17 00:00:00 2001 From: Kostas Date: Wed, 26 Nov 2025 15:19:29 +0200 Subject: [PATCH 03/17] Fix Representation parameter #67 --- ConvertApi/Services/ConverterCatalog.cs | 57 +++++++++++++++---------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/ConvertApi/Services/ConverterCatalog.cs b/ConvertApi/Services/ConverterCatalog.cs index 3615ea8..253e126 100644 --- a/ConvertApi/Services/ConverterCatalog.cs +++ b/ConvertApi/Services/ConverterCatalog.cs @@ -199,14 +199,6 @@ private static List ParseConverters(OpenApiDocument doc) private static List CollectTags(OpenApiDocument doc, OpenApiOperation op, OpenApiPathItem item) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); - // From operation tag names - if (op?.Tags != null) - { - foreach (var t in op.Tags) - { - if (!string.IsNullOrWhiteSpace(t.Name)) set.Add(t.Name.Trim()); - } - } // From custom extension array foreach (var extDict in new[] { op?.Extensions, item?.Extensions }) { @@ -250,9 +242,9 @@ private static IEnumerable ParseParameterGroups(Open Name = name, Label = GetExtensionString(s.Extensions, "x-ca-label") ?? name, Description = s.Description, - GroupName = GetExtensionString(s.Extensions, "x-ca-group") ?? "General", + GroupName = GetExtensionString(s.Extensions, "x-ca-group"), Type = s.Type, - Representation = s.Format, + Representation = GetExtensionString(s.Extensions, "x-ca-representation"), Default = s.Default is IOpenApiPrimitive prim ? GetPrimitiveValue(prim) : null, Values = ToEnumDictionary(s.Enum), Range = GetRange(s), @@ -332,24 +324,45 @@ private static object GetPrimitiveValue(IOpenApiPrimitive prim) private static List ParseTags(OpenApiDocument doc) { var list = new List(); - if (doc.Tags == null) return list; - foreach (var t in doc.Tags) + if (doc.Info.Extensions.TryGetValue("x-ca-converter-tags", out var ext)) { - var tag = new TagDto + IEnumerable items = null; + if (ext is OpenApiArray arr) + items = arr; + else if (ext is OpenApiObject obj) + items = obj.Values; + + if (items != null) { - Name = t.Name, - Summary = GetExtensionString(t.Extensions, "x-summary"), - Description = t.Description, - PageTitle = GetExtensionString(t.Extensions, "x-ca-page-title"), - FriendlyName = GetExtensionString(t.Extensions, "x-ca-friendly-name"), - MetaTitle = GetExtensionString(t.Extensions, "x-ca-meta-title"), - MetaDescription = GetExtensionString(t.Extensions, "x-ca-meta-description") - }; - list.Add(tag); + foreach (var item in items) + { + if (item is OpenApiObject obj) + { + var tag = new TagDto + { + Name = GetStringProperty(obj, "name"), + Summary = GetStringProperty(obj, "summary"), + Description = GetStringProperty(obj, "description"), + PageTitle = GetStringProperty(obj, "pageTitle"), + FriendlyName = GetStringProperty(obj, "friendlyName"), + MetaTitle = GetStringProperty(obj, "metaTitle"), + MetaDescription = GetStringProperty(obj, "metaDescription") + }; + list.Add(tag); + } + } + } } return list; } + private static string GetStringProperty(OpenApiObject obj, string key) + { + if (obj.TryGetValue(key, out var val) && val is OpenApiString s) + return s.Value; + return null; + } + private static List ParseExtensionsFrom(IDictionary opExt, IDictionary pathExt, IEnumerable defaultTo = null) { var str = GetExtensionString(opExt, "x-ca-source-formats") ?? GetExtensionString(pathExt, "x-ca-source-formats"); From c610703715725a4530270c85afaacb43afd73ab3 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 26 Nov 2025 15:29:41 +0200 Subject: [PATCH 04/17] Downgrade target framework for GetConverterInfoDemo to .NET 6.0 --- Examples/GetConverterInfoDemo/GetConverterInfoDemo.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/GetConverterInfoDemo/GetConverterInfoDemo.csproj b/Examples/GetConverterInfoDemo/GetConverterInfoDemo.csproj index 4d97731..acd1fad 100644 --- a/Examples/GetConverterInfoDemo/GetConverterInfoDemo.csproj +++ b/Examples/GetConverterInfoDemo/GetConverterInfoDemo.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net6.0 enable enable From 59c6acfa6ead11333250985dc14a4c473525a890 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 26 Nov 2025 15:36:10 +0200 Subject: [PATCH 05/17] Start 3.1.8-dev development cycle; update version and changelog --- CHANGELOG.md | 9 +++++++++ ConvertApi/ConvertApi.csproj | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf0c8b..b67efce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning. +## [3.1.8-dev] — 2025-11-26 + +### Changed +- Start next development cycle as `-dev` prerelease: bump version to `3.1.8-dev` (assembly/file version `3.1.8.0`). + ## [3.1.7-dev] — 2025-11-25 ### Added - Add `ConverterCatalog` service and demo project for handling ConvertAPI converters. ### Changed +- Downgrade `GetConverterInfoDemo` target framework to .NET 6.0. - Start next development cycle as `-dev` prerelease: bump version to `3.1.7-dev` (assembly/file version `3.1.7.0`). - This is a development snapshot; entries will accumulate here until the next stable release. +### Fixed +- Fix `Representation` parameter handling in `ConverterCatalog` (#67). + ## [3.1.6] — 2025-11-12 ### Added diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index 9579dc4..1cd0ee5 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,8 +2,8 @@ true - 3.1.7-dev - Baltsoft + 3.1.8-dev + ConvertApi ConvertApi https://www.convertapi.com https://github.com/ConvertAPI/convertapi-dotnet @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.7.0 - 3.1.7.0 + 3.1.8.0 + 3.1.8.0 Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.7-dev + 3.1.8-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9 From ee5a352383a76982459aa788591f06672471c3cb Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 26 Nov 2025 15:40:38 +0200 Subject: [PATCH 06/17] build 3.1.9-dev --- ConvertApi/ConvertApi.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index 1cd0ee5..63da2fe 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,7 +2,7 @@ true - 3.1.8-dev + 3.1.9-dev ConvertApi ConvertApi https://www.convertapi.com @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.8.0 - 3.1.8.0 + 3.1.9.0 + 3.1.9.0 Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.8-dev + 3.1.9-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9 From 560e7f3181521a782056f7a2542507c92941eef4 Mon Sep 17 00:00:00 2001 From: Kostas Date: Thu, 27 Nov 2025 09:33:07 +0200 Subject: [PATCH 07/17] Fix Representation parameter #67 --- ConvertApi/Model/ConverterDtos.cs | 155 ++++++++++++++++++++++++ ConvertApi/Services/ConverterCatalog.cs | 1 + 2 files changed, 156 insertions(+) diff --git a/ConvertApi/Model/ConverterDtos.cs b/ConvertApi/Model/ConverterDtos.cs index 3f7b66a..53cc010 100644 --- a/ConvertApi/Model/ConverterDtos.cs +++ b/ConvertApi/Model/ConverterDtos.cs @@ -2,58 +2,213 @@ namespace ConvertApiDotNet.Model { + /// + /// Converter details + /// public class ConverterDto { + /// + /// Converter title + /// public string Summary { get; set; } + + /// + /// Short description of the converter + /// public string Description { get; set; } + + /// + /// Detailed description of the converter + /// public string Overview { get; set; } + + /// + /// Meta title for SEO purposes + /// public string MetaTitle { get; set; } + + /// + /// Meta description for SEO purposes + /// public string MetaDescription { get; set; } + + /// + /// List of tags associated with the converter + /// public List Tags { get; set; } + + /// + /// The endpoint source format + /// public string SourceFormat { get; set; } + + /// + /// The endpoint destination file format + /// public string DestinationFormat { get; set; } + + /// + /// List of supported source file extensions + /// public List SourceExtensions { get; set; } + + /// + /// List of supported destination file extensions + /// public List DestinationExtensions { get; set; } + + /// + /// Collection of converter parameter groups + /// public IEnumerable ConverterParameterGroups { get; set; } } + /// + /// Group of converter parameters + /// public class ConverterParameterGroupDto { + /// + /// Name of the parameter group + /// public string Name { get; set; } + + /// + /// Collection of parameters within the group + /// public IEnumerable ConverterParameters { get; set; } } + /// + /// Converter parameter details + /// public class ConverterParameterDto { + /// + /// Technical name of the parameter + /// public string Name { get; set; } + + /// + /// Display label for the parameter + /// public string Label { get; set; } + + /// + /// Description of what the parameter does + /// public string Description { get; set; } + + /// + /// Name of the group this parameter belongs to + /// public string GroupName { get; set; } + + /// + /// Data type of the parameter + /// public string Type { get; set; } + + /// + /// Internal type representation of the property (for example Color, Collection, etc.) + /// + public string XType { get; set; } + + /// + /// Representation of the parameter (e.g. text, select, checkbox) + /// public string Representation { get; set; } + + /// + /// Default value for the parameter + /// public object Default { get; set; } + + /// + /// Dictionary of possible values (key-value pairs) if applicable + /// public Dictionary Values { get; set; } + + /// + /// Range of allowed values for the parameter + /// public ConverterParameterRangeDto Range { get; set; } + + /// + /// Indicates if the parameter is mandatory + /// public bool Required { get; set; } + + /// + /// Indicates if the parameter is featured/important + /// public bool Featured { get; set; } + + /// + /// Indicates if the parameter accepts an array of values + /// public bool Array { get; set; } + + /// + /// Array of allowed file extensions for this parameter + /// public string[] AllowedExtensions { get; set; } } + /// + /// Range of allowed values + /// public class ConverterParameterRangeDto { + /// + /// Start value of the range + /// public string From { get; set; } + + /// + /// End value of the range + /// public string To { get; set; } } + /// + /// Tag details + /// public class TagDto { + /// + /// Name of the tag + /// public string Name { get; set; } + + /// + /// Summary description of the tag + /// public string Summary { get; set; } + + /// + /// Detailed description of the tag + /// public string Description { get; set; } + + /// + /// Title of the page associated with the tag + /// public string PageTitle { get; set; } + + /// + /// User-friendly name of the tag + /// public string FriendlyName { get; set; } + + /// + /// Meta title for the tag page + /// public string MetaTitle { get; set; } + + /// + /// Meta description for the tag page + /// public string MetaDescription { get; set; } } } diff --git a/ConvertApi/Services/ConverterCatalog.cs b/ConvertApi/Services/ConverterCatalog.cs index 253e126..28b13c5 100644 --- a/ConvertApi/Services/ConverterCatalog.cs +++ b/ConvertApi/Services/ConverterCatalog.cs @@ -244,6 +244,7 @@ private static IEnumerable ParseParameterGroups(Open Description = s.Description, GroupName = GetExtensionString(s.Extensions, "x-ca-group"), Type = s.Type, + XType = GetExtensionString(s.Extensions, "x-ca-type"), Representation = GetExtensionString(s.Extensions, "x-ca-representation"), Default = s.Default is IOpenApiPrimitive prim ? GetPrimitiveValue(prim) : null, Values = ToEnumDictionary(s.Enum), From 51a526e5089d9b3715aef430ed38e0cdfdb39368 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Thu, 27 Nov 2025 09:35:01 +0200 Subject: [PATCH 08/17] build 3.1.10-dev --- ConvertApi/ConvertApi.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index 63da2fe..367f97e 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,7 +2,7 @@ true - 3.1.9-dev + 3.1.10-dev ConvertApi ConvertApi https://www.convertapi.com @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.9.0 - 3.1.9.0 + 3.1.10.0 + 3.1.10.0 Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.9-dev + 3.1.10-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9 From 89b5c2cda9298da418afc6807f379bf140c36962 Mon Sep 17 00:00:00 2001 From: Kostas Date: Tue, 2 Dec 2025 13:07:29 +0200 Subject: [PATCH 09/17] Extend OpenAPI support #66 --- ConvertApi/Services/ConverterCatalog.cs | 9 ++++----- Examples/ConverterCatalogDemo/Program.cs | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ConvertApi/Services/ConverterCatalog.cs b/ConvertApi/Services/ConverterCatalog.cs index 28b13c5..d4d23b8 100644 --- a/ConvertApi/Services/ConverterCatalog.cs +++ b/ConvertApi/Services/ConverterCatalog.cs @@ -185,8 +185,8 @@ private static List ParseConverters(OpenApiDocument doc) MetaTitle = GetExtensionString(op.Extensions, "x-ca-meta-title") ?? GetExtensionString(item.Extensions, "x-ca-meta-title"), MetaDescription = GetExtensionString(op.Extensions, "x-ca-meta-description") ?? GetExtensionString(item.Extensions, "x-ca-meta-description"), Tags = CollectTags(doc, op, item), - SourceExtensions = ParseExtensionsFrom(op.Extensions, item.Extensions, defaultTo: new[] { "." + src }), - DestinationExtensions = new List { "." + dst }, + SourceExtensions = ParseExtensionsFrom("x-ca-source-formats", op.Extensions, item.Extensions, defaultTo: new[] { src }), + DestinationExtensions = ParseExtensionsFrom("x-ca-destination-formats", op.Extensions, item.Extensions, defaultTo: new[] { dst }), ConverterParameterGroups = ParseParameterGroups(op) }; @@ -364,9 +364,9 @@ private static string GetStringProperty(OpenApiObject obj, string key) return null; } - private static List ParseExtensionsFrom(IDictionary opExt, IDictionary pathExt, IEnumerable defaultTo = null) + private static List ParseExtensionsFrom(string key, IDictionary opExt, IDictionary pathExt, IEnumerable defaultTo = null) { - var str = GetExtensionString(opExt, "x-ca-source-formats") ?? GetExtensionString(pathExt, "x-ca-source-formats"); + var str = GetExtensionString(opExt, key) ?? GetExtensionString(pathExt, key); if (!string.IsNullOrWhiteSpace(str)) { return SplitExtensions(str).ToList(); @@ -380,7 +380,6 @@ private static IEnumerable SplitExtensions(string csv) .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) .Where(s => !string.IsNullOrWhiteSpace(s)) - .Select(s => s.StartsWith(".") ? s : "." + s) .Distinct(StringComparer.OrdinalIgnoreCase); } diff --git a/Examples/ConverterCatalogDemo/Program.cs b/Examples/ConverterCatalogDemo/Program.cs index 503f518..3536733 100644 --- a/Examples/ConverterCatalogDemo/Program.cs +++ b/Examples/ConverterCatalogDemo/Program.cs @@ -50,6 +50,7 @@ private static async Task Main(string[] args) if (c != null) { Console.WriteLine($"Found: {c.SourceFormat} -> {c.DestinationFormat} | {Trim(c.Summary, 60)}"); + Console.WriteLine($"Source extensions: {string.Join(",", c.SourceExtensions)} -> Destination extensions: {string.Join(",", c.DestinationExtensions)}"); } else { From a86c94f7fee2cf959a55df4e8af9df6380493a48 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 2 Dec 2025 13:34:02 +0200 Subject: [PATCH 10/17] build 3.1.11-dev --- ConvertApi/ConvertApi.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index 367f97e..23956d9 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,7 +2,7 @@ true - 3.1.10-dev + 3.1.11-dev ConvertApi ConvertApi https://www.convertapi.com @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.10.0 - 3.1.10.0 + 3.1.11.0 + 3.1.11.0 Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.10-dev + 3.1.11-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9 From 54290cd8e0c8247f38a86a2a210aa1cc3f371005 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 3 Dec 2025 10:03:29 +0200 Subject: [PATCH 11/17] Enhance `ConvertApiParam` to support FileId alongside local file paths. --- ConvertApi/ConvertApiParam.cs | 45 ++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/ConvertApi/ConvertApiParam.cs b/ConvertApi/ConvertApiParam.cs index 06ccad8..b796785 100644 --- a/ConvertApi/ConvertApiParam.cs +++ b/ConvertApi/ConvertApiParam.cs @@ -88,9 +88,9 @@ public ConvertApiFileParam(string name, Uri url) : base(name) } /// - /// Convert local file. + /// Convert a local file or reference an already uploaded file by its FileId. /// - /// Path to a local file. + /// Path to a local file or a 32-character lowercase FileId. public ConvertApiFileParam(string path) : this("file", path) { } @@ -99,15 +99,27 @@ public ConvertApiFileParam(string name, string path) : base(name) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException( - "Path must be a non-empty path to an existing local file. To use a FileId, pass new ConvertApiParam(\"FileId\", fileId).", + "Value must be a non-empty local file path or a 32-character lowercase FileId.", nameof(path)); - if (!File.Exists(path)) - throw new FileNotFoundException( - $"File not found at path '{path}'. To use a FileId, pass new ConvertApiParam(\"FileId\", fileId).", - path); + // Prefer an existing local file path + if (File.Exists(path)) + { + Tasks = Upload(new FileInfo(path)); + return; + } - Tasks = Upload(new FileInfo(path)); + // If it looks like a FileId, pass it as-is (no upload) + if (LooksLikeFileId(path)) + { + Value = new[] { path }; + return; + } + + // Neither a file on disk nor a valid FileId + throw new FileNotFoundException( + $"Value '{path}' is neither an existing local file nor a valid FileId (32 lowercase alphanumeric characters).", + path); } /// @@ -222,5 +234,22 @@ public async Task GetValueAsync() { return await GetUploadedFileAsync(); } + + private static bool LooksLikeFileId(string value) + { + if (string.IsNullOrEmpty(value) || value.Length != 32) + return false; + + for (int i = 0; i < value.Length; i++) + { + var ch = value[i]; + var isDigit = ch >= '0' && ch <= '9'; + var isLower = ch >= 'a' && ch <= 'z'; + if (!isDigit && !isLower) + return false; + } + + return true; + } } } \ No newline at end of file From 28bdcf0d145ff18d100d5e68fb7abf361a266497 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 3 Dec 2025 10:03:37 +0200 Subject: [PATCH 12/17] Update `Program.cs` to use relative path for test PDF file location in examples --- Examples/Workflow/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Examples/Workflow/Program.cs b/Examples/Workflow/Program.cs index 9d00f3b..5a22283 100644 --- a/Examples/Workflow/Program.cs +++ b/Examples/Workflow/Program.cs @@ -21,7 +21,8 @@ static async Task Main(string[] args) var convertApi = new ConvertApi("api_token"); Console.WriteLine("Converting PDF to JPG and compressing result files with ZIP"); - var fileName = Path.Combine(Path.GetTempPath(), "test.pdf"); + var examplesDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + var fileName = Path.Combine(examplesDir, "TestFiles", "test.pdf"); var firstTask = await convertApi.ConvertAsync("pdf", "jpg", new ConvertApiFileParam(fileName)); Console.WriteLine($"Conversions done. Cost: {firstTask.ConversionCost}. Total files created: {firstTask.FileCount()}"); From 69fc4eec1e59216d188c337d6f75eebdc31da526 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 3 Dec 2025 10:04:24 +0200 Subject: [PATCH 13/17] build 3.1.12-dev --- ConvertApi/ConvertApi.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index 23956d9..e0e76bd 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,7 +2,7 @@ true - 3.1.11-dev + 3.1.12-dev ConvertApi ConvertApi https://www.convertapi.com @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.11.0 - 3.1.11.0 + 3.1.12.0 + 3.1.12.0 Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.11-dev + 3.1.12-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9 From d728899065cf9950ba700bebac8cff9ea3834e4b Mon Sep 17 00:00:00 2001 From: Kostas Date: Mon, 5 Jan 2026 16:32:09 +0200 Subject: [PATCH 14/17] Update the SearchConverters() method #68 --- ConvertApi/Helpers/Helper.cs | 10 ++ ConvertApi/Services/ConverterCatalog.cs | 227 +++++++++++++++++++++++- 2 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 ConvertApi/Helpers/Helper.cs diff --git a/ConvertApi/Helpers/Helper.cs b/ConvertApi/Helpers/Helper.cs new file mode 100644 index 0000000..9f8a481 --- /dev/null +++ b/ConvertApi/Helpers/Helper.cs @@ -0,0 +1,10 @@ +using System; + +namespace ConvertApiDotNet.Helpers +{ + public static class Helper + { + public static bool ContainsIgnoreCase(string source, string token) => + source?.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0; + } +} \ No newline at end of file diff --git a/ConvertApi/Services/ConverterCatalog.cs b/ConvertApi/Services/ConverterCatalog.cs index d4d23b8..e66289e 100644 --- a/ConvertApi/Services/ConverterCatalog.cs +++ b/ConvertApi/Services/ConverterCatalog.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using ConvertApiDotNet.Constants; +using ConvertApiDotNet.Helpers; using ConvertApiDotNet.Interface; using ConvertApiDotNet.Model; using Microsoft.OpenApi.Any; @@ -70,14 +71,224 @@ public List GetConvertersByTags(List tags = null) public List SearchConverters(string[] terms) { EnsureLoaded(); - if (terms == null || terms.Length == 0) return _converters; - var keys = terms.Where(t => !string.IsNullOrWhiteSpace(t)) - .Select(t => t.Trim()) - .ToArray(); - if (keys.Length == 0) return _converters; - - return _converters.Where(c => - keys.All(k => Matches(c, k))).ToList(); + + if (terms == null || terms.Length == 0) + return new List(); + + var stop = new HashSet(StringComparer.OrdinalIgnoreCase) + { "to", "into", "in", "as", "from", "->", "-", "–", "—" }; + + var tokens = terms + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim().Trim('-', '>', '<', '.', ',', ';', ':', '/', '\\')) + .Where(t => t.Length > 0 && !stop.Contains(t)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (tokens.Length == 0) + return new List(); + + var allConverters = GetAllConverters(); + + var knownFormats = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var c in allConverters) + { + if (!string.IsNullOrEmpty(c.SourceFormat)) knownFormats.Add(c.SourceFormat); + if (!string.IsNullOrEmpty(c.DestinationFormat)) knownFormats.Add(c.DestinationFormat); + if (c.SourceExtensions != null) foreach (var e in c.SourceExtensions) knownFormats.Add(e.TrimStart('.')); + if (c.DestinationExtensions != null) foreach (var e in c.DestinationExtensions) knownFormats.Add(e.TrimStart('.')); + } + + var formatTokens = tokens.Where(t => knownFormats.Contains(t)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + bool SupportsFormat(string format, List extensions, string token) + { + if (string.Equals(format, token, StringComparison.OrdinalIgnoreCase)) return true; + if (extensions != null) + { + return extensions.Any(e => string.Equals(e.TrimStart('.'), token, StringComparison.OrdinalIgnoreCase)); + } + return false; + } + + bool MatchesToken(ConverterDto c, string t) + { + return SupportsFormat(c.SourceFormat, c.SourceExtensions, t) || + SupportsFormat(c.DestinationFormat, c.DestinationExtensions, t) || + (c.Summary != null && Helper.ContainsIgnoreCase(c.Summary, t)) || + (c.Tags != null && c.Tags.Any(tag => Helper.ContainsIgnoreCase(tag, t))) || + (c.Description != null && Helper.ContainsIgnoreCase(c.Description, t)) || + (c.Overview != null && Helper.ContainsIgnoreCase(c.Overview, t)); + } + + // Prioritize items that match ALL search terms + var candidates = allConverters.Where(c => tokens.All(t => MatchesToken(c, t))).ToList(); + + if (formatTokens.Length >= 2) + { + var src = formatTokens[0]; + var dst = formatTokens[1]; + + var pairMatches = allConverters.Where(c => + { + bool supportsSrc = SupportsFormat(c.SourceFormat, c.SourceExtensions, src); + bool supportsDst = SupportsFormat(c.DestinationFormat, c.DestinationExtensions, dst); + if (supportsSrc && supportsDst) return true; + + bool supportsRevSrc = SupportsFormat(c.SourceFormat, c.SourceExtensions, dst); + bool supportsRevDst = SupportsFormat(c.DestinationFormat, c.DestinationExtensions, src); + return supportsRevSrc && supportsRevDst; + }); + + foreach (var pm in pairMatches) + { + if (!candidates.Contains(pm)) candidates.Add(pm); + } + } + + // Fallback: if no matches for all terms, return items matching ANY term + if (candidates.Count == 0) + { + candidates = allConverters.Where(c => tokens.Any(t => MatchesToken(c, t))).ToList(); + } + + if (formatTokens.Length >= 2) + { + var src = formatTokens[0]; + var dst = formatTokens[1]; + + var virtuals = new List(); + + foreach (var c in candidates) + { + // Forward check (src -> dst) + bool supportsSrc = SupportsFormat(c.SourceFormat, c.SourceExtensions, src); + bool supportsDst = SupportsFormat(c.DestinationFormat, c.DestinationExtensions, dst); + + if (supportsSrc && supportsDst) + { + if (!string.Equals(c.SourceFormat, src, StringComparison.OrdinalIgnoreCase) || !string.Equals(c.DestinationFormat, dst, StringComparison.OrdinalIgnoreCase)) + { + virtuals.Add(CreateVirtual(c, src, dst)); + } + } + + // Reverse check (dst -> src) + bool supportsRevSrc = SupportsFormat(c.SourceFormat, c.SourceExtensions, dst); + bool supportsRevDst = SupportsFormat(c.DestinationFormat, c.DestinationExtensions, src); + + if (supportsRevSrc && supportsRevDst) + { + if (!string.Equals(c.SourceFormat, dst, StringComparison.OrdinalIgnoreCase) || !string.Equals(c.DestinationFormat, src, StringComparison.OrdinalIgnoreCase)) + { + virtuals.Add(CreateVirtual(c, dst, src)); + } + } + } + candidates.AddRange(virtuals); + } + + int Score(ConverterDto c) + { + int s = 0; + + // 0. Individual Token Scoring with Positional Weighting + for (int i = 0; i < formatTokens.Length; i++) + { + var t = formatTokens[i]; + bool isSrc = SupportsFormat(c.SourceFormat, c.SourceExtensions, t); + bool isDst = SupportsFormat(c.DestinationFormat, c.DestinationExtensions, t); + + if (isSrc) s += 3000; + if (isDst) s += 3000; + + // Positional Bonuses + if (i == 0) // First format term: Prefer Source + { + if (isSrc) s += 2000; + } + else if (i == 1) // Second format term: Prefer Destination + { + if (isDst) s += 2000; + } + } + + if (formatTokens.Length >= 2) + { + var t1 = formatTokens[0]; + var t2 = formatTokens[1]; + + // 1. SourceFormat & DestinationFormat (Highest Priority) + bool fwdFmt = string.Equals(c.SourceFormat, t1, StringComparison.OrdinalIgnoreCase) && string.Equals(c.DestinationFormat, t2, StringComparison.OrdinalIgnoreCase); + bool revFmt = string.Equals(c.SourceFormat, t2, StringComparison.OrdinalIgnoreCase) && string.Equals(c.DestinationFormat, t1, StringComparison.OrdinalIgnoreCase); + + if (fwdFmt) s += 100000; + if (revFmt) s += 50000; + + // 3. SourceExtensions & DestinationExtensions (Third Priority) + if (!fwdFmt && !revFmt) + { + bool fwdExt = SupportsFormat(c.SourceFormat, c.SourceExtensions, t1) && SupportsFormat(c.DestinationFormat, c.DestinationExtensions, t2); + bool revExt = SupportsFormat(c.SourceFormat, c.SourceExtensions, t2) && SupportsFormat(c.DestinationFormat, c.DestinationExtensions, t1); + + if (fwdExt) s += 40000; + if (revExt) s += 20000; + } + } + + // 2. Summary (Second Priority) + if (c.Summary != null) + { + int summaryHits = tokens.Count(t => Helper.ContainsIgnoreCase(c.Summary, t)); + s += summaryHits * 1000; + } + + // 4. Tags (Low Priority) + if (c.Tags != null) + { + int tagHits = tokens.Count(t => c.Tags.Any(tag => Helper.ContainsIgnoreCase(tag, t))); + s += tagHits * 10; + } + + // 5. Description (Lower Priority) + if (c.Description != null) + { + int descHits = tokens.Count(t => Helper.ContainsIgnoreCase(c.Description, t)); + s += descHits * 5; + } + + // 6. Overview (Lowest Priority) + if (c.Overview != null) + { + int ovHits = tokens.Count(t => Helper.ContainsIgnoreCase(c.Overview, t)); + s += ovHits * 2; + } + + return s; + } + + return candidates + .OrderByDescending(Score) + .ThenBy(c => c.Summary) + .GroupBy(c => new { S = c.SourceFormat?.ToLowerInvariant(), D = c.DestinationFormat?.ToLowerInvariant() }) + .Select(g => g.First()) + .Where(x=> GetConverter(x.SourceFormat, x.DestinationFormat) != null) + .ToList(); + } + + private ConverterDto CreateVirtual(ConverterDto c, string s, string d) + { + return new ConverterDto + { + SourceFormat = s.ToLowerInvariant(), + DestinationFormat = d.ToLowerInvariant(), + SourceExtensions = c.SourceExtensions, + DestinationExtensions = c.DestinationExtensions, + Summary = $"{s.ToUpper()} to {d.ToUpper()} API", + Description = c.Description, + Tags = c.Tags, + Overview = c.Overview + }; } public List GetTags() From 900105306643e48967d91654f79d09509990c94e Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 6 Jan 2026 10:23:05 +0200 Subject: [PATCH 15/17] build 3.1.13-dev --- ConvertApi/ConvertApi.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index e0e76bd..d169220 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,7 +2,7 @@ true - 3.1.12-dev + 3.1.13-dev ConvertApi ConvertApi https://www.convertapi.com @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.12.0 - 3.1.12.0 + 3.1.13.0 + 3.1.13.0 Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.12-dev + 3.1.13-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9 From abbd5440bbae07f4ef272a10da54d3ed9e46a757 Mon Sep 17 00:00:00 2001 From: Kostas Date: Tue, 6 Jan 2026 16:19:57 +0200 Subject: [PATCH 16/17] Parse tag category #71 --- ConvertApi/Model/ConverterDtos.cs | 5 +++++ ConvertApi/Services/ConverterCatalog.cs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ConvertApi/Model/ConverterDtos.cs b/ConvertApi/Model/ConverterDtos.cs index 53cc010..cee12fd 100644 --- a/ConvertApi/Model/ConverterDtos.cs +++ b/ConvertApi/Model/ConverterDtos.cs @@ -210,5 +210,10 @@ public class TagDto /// Meta description for the tag page /// public string MetaDescription { get; set; } + + /// + /// Category of the tag + /// + public string Category { get; set; } } } diff --git a/ConvertApi/Services/ConverterCatalog.cs b/ConvertApi/Services/ConverterCatalog.cs index d4d23b8..3dcd95b 100644 --- a/ConvertApi/Services/ConverterCatalog.cs +++ b/ConvertApi/Services/ConverterCatalog.cs @@ -347,7 +347,8 @@ private static List ParseTags(OpenApiDocument doc) PageTitle = GetStringProperty(obj, "pageTitle"), FriendlyName = GetStringProperty(obj, "friendlyName"), MetaTitle = GetStringProperty(obj, "metaTitle"), - MetaDescription = GetStringProperty(obj, "metaDescription") + MetaDescription = GetStringProperty(obj, "metaDescription"), + Category = GetStringProperty(obj, "category") }; list.Add(tag); } From 4039c79bdb0cc4fe0442ba30e8ab4f1244d8cf19 Mon Sep 17 00:00:00 2001 From: Kostas Date: Tue, 6 Jan 2026 16:35:52 +0200 Subject: [PATCH 17/17] build 3.1.14-dev --- ConvertApi/ConvertApi.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ConvertApi/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj index d169220..fc2d2fb 100644 --- a/ConvertApi/ConvertApi.csproj +++ b/ConvertApi/ConvertApi.csproj @@ -2,7 +2,7 @@ true - 3.1.13-dev + 3.1.14-dev ConvertApi ConvertApi https://www.convertapi.com @@ -17,11 +17,11 @@ ConvertApi ConvertApi ConvertApiDotNet - 3.1.13.0 - 3.1.13.0 + 3.1.14.0 + 3.1.14.0 Development snapshot (-dev prerelease). Changelog will evolve during the development cycle. netstandard2.0;netcoreapp3.1 - 3.1.13-dev + 3.1.14-dev ConvertAPI for .NET — one call to turn Office files into PDF or back again (DOCX ⇄ PDF, XLSX ⇄ PDF, PPTX ⇄ PDF). Convert HTML, images, ebooks, email, zip; merge, split, watermark, compress, OCR, redact, protect or repair PDF. Async, cross-platform, .NET Standard 2.0+ / .NET Core 5-9