diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e954fe..b67efce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +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.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/ConvertApi.csproj b/ConvertApi/ConvertApi.csproj
index d95c0aa..fc2d2fb 100644
--- a/ConvertApi/ConvertApi.csproj
+++ b/ConvertApi/ConvertApi.csproj
@@ -2,8 +2,8 @@
true
- 3.1.6
- Baltsoft
+ 3.1.14-dev
+ ConvertApi
ConvertApi
https://www.convertapi.com
https://github.com/ConvertAPI/convertapi-dotnet
@@ -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.14.0
+ 3.1.14.0
+ Development snapshot (-dev prerelease). Changelog will evolve during the development cycle.
netstandard2.0;netcoreapp3.1
- 3.1.6
+ 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
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
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/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..cee12fd
--- /dev/null
+++ b/ConvertApi/Model/ConverterDtos.cs
@@ -0,0 +1,219 @@
+using System.Collections.Generic;
+
+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; }
+
+ ///
+ /// Category of the tag
+ ///
+ public string Category { get; set; }
+ }
+}
diff --git a/ConvertApi/Services/ConverterCatalog.cs b/ConvertApi/Services/ConverterCatalog.cs
new file mode 100644
index 0000000..309c703
--- /dev/null
+++ b/ConvertApi/Services/ConverterCatalog.cs
@@ -0,0 +1,700 @@
+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.Helpers;
+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 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()
+ {
+ 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("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)
+ };
+
+ list.Add(dto);
+ }
+
+ return list;
+ }
+
+ private static List CollectTags(OpenApiDocument doc, OpenApiOperation op, OpenApiPathItem item)
+ {
+ var set = new HashSet(StringComparer.OrdinalIgnoreCase);
+ // 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"),
+ 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),
+ 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.Info.Extensions.TryGetValue("x-ca-converter-tags", out var ext))
+ {
+ IEnumerable items = null;
+ if (ext is OpenApiArray arr)
+ items = arr;
+ else if (ext is OpenApiObject obj)
+ items = obj.Values;
+
+ if (items != null)
+ {
+ 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"),
+ Category = GetStringProperty(obj, "category")
+ };
+ 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(string key, IDictionary opExt, IDictionary pathExt, IEnumerable defaultTo = null)
+ {
+ var str = GetExtensionString(opExt, key) ?? GetExtensionString(pathExt, key);
+ 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))
+ .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..3536733
--- /dev/null
+++ b/Examples/ConverterCatalogDemo/Program.cs
@@ -0,0 +1,147 @@
+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)}");
+ Console.WriteLine($"Source extensions: {string.Join(",", c.SourceExtensions)} -> Destination extensions: {string.Join(",", c.DestinationExtensions)}");
+ }
+ 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) + "…";
+ }
+}
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
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()}");