diff --git a/OrchardCore.sln b/OrchardCore.sln index 5fd2aa48730..012a41a30d5 100644 --- a/OrchardCore.sln +++ b/OrchardCore.sln @@ -505,7 +505,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms.Abstraction EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms", "src\OrchardCore.Modules\OrchardCore.Sms\OrchardCore.Sms.csproj", "{CBF6DB53-FD0C-47F8-9E60-A1D247ACFD05}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Sms.Core", "src\OrchardCore\OrchardCore.Sms.Core\OrchardCore.Sms.Core.csproj", "{20356393-B16D-466C-8203-877A534E287D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms.Core", "src\OrchardCore\OrchardCore.Sms.Core\OrchardCore.Sms.Core.csproj", "{20356393-B16D-466C-8203-877A534E287D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Search.AzureAI", "src\OrchardCore.Modules\OrchardCore.Search.AzureAI\OrchardCore.Search.AzureAI.csproj", "{5527BACF-FA5D-4617-978B-7EDE8942E6F6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Search.AzureAI.Core", "src\OrchardCore\OrchardCore.Search.AzureAI.Core\OrchardCore.Search.AzureAI.Core.csproj", "{E9428DE8-5D81-4359-BF84-31435041FF1A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -1335,6 +1339,14 @@ Global {20356393-B16D-466C-8203-877A534E287D}.Debug|Any CPU.Build.0 = Debug|Any CPU {20356393-B16D-466C-8203-877A534E287D}.Release|Any CPU.ActiveCfg = Release|Any CPU {20356393-B16D-466C-8203-877A534E287D}.Release|Any CPU.Build.0 = Release|Any CPU + {5527BACF-FA5D-4617-978B-7EDE8942E6F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5527BACF-FA5D-4617-978B-7EDE8942E6F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5527BACF-FA5D-4617-978B-7EDE8942E6F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5527BACF-FA5D-4617-978B-7EDE8942E6F6}.Release|Any CPU.Build.0 = Release|Any CPU + {E9428DE8-5D81-4359-BF84-31435041FF1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9428DE8-5D81-4359-BF84-31435041FF1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9428DE8-5D81-4359-BF84-31435041FF1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9428DE8-5D81-4359-BF84-31435041FF1A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1566,6 +1578,8 @@ Global {2D93F509-1FB3-4E22-92F0-588D0EFBA921} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {CBF6DB53-FD0C-47F8-9E60-A1D247ACFD05} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} {20356393-B16D-466C-8203-877A534E287D} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {5527BACF-FA5D-4617-978B-7EDE8942E6F6} = {90030E85-0C4F-456F-B879-443E8A3F220D} + {E9428DE8-5D81-4359-BF84-31435041FF1A} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {46A1D25A-78D1-4476-9CBF-25B75E296341} diff --git a/src/OrchardCore.Build/Dependencies.props b/src/OrchardCore.Build/Dependencies.props index 11af59e2834..2a6a9c7aa26 100644 --- a/src/OrchardCore.Build/Dependencies.props +++ b/src/OrchardCore.Build/Dependencies.props @@ -15,6 +15,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/src/OrchardCore.Cms.Web/appsettings.json b/src/OrchardCore.Cms.Web/appsettings.json index d4ebad437e2..5d2534a599a 100644 --- a/src/OrchardCore.Cms.Web/appsettings.json +++ b/src/OrchardCore.Cms.Web/appsettings.json @@ -133,6 +133,22 @@ // "MaxPagedCount": 500 // } //}, + // Provides Azure AI Search Connection + //"OrchardCore_AzureAISearch": { + // "Endpoint": "", + // "IndexesPrefix": "", + // "Credential": { + // "Key": "" + // } + //}, + // "Url": "http://localhost", + // "Ports": [ 9200 ], + // "Username": "admin", + // "Password": "admin", + // "CloudId": "Orchard_Core_deployment:ZWFzdHVzMi5henVyZS5lbGFzdGljLWNsb3VkLmNvbTo0NDMkNmMxZGQ4YzAzN2=", + // "CertificateFingerprint": "75:21:E7:92:8F:D5:7A:27:06:38:8E:A4:35:FE:F5:17:D7:37:F4:DF:F0:9A:D2:C0:C4:B6:FF:EE:D1:EA:2B:A7", + // "EnableApiVersioningHeader": false + //}, // Provides Elasticsearch Connection //"OrchardCore_Elasticsearch": { // "ConnectionType": "SingleNodeConnectionPool", @@ -143,7 +159,7 @@ // "CloudId": "Orchard_Core_deployment:ZWFzdHVzMi5henVyZS5lbGFzdGljLWNsb3VkLmNvbTo0NDMkNmMxZGQ4YzAzN2=", // "CertificateFingerprint": "75:21:E7:92:8F:D5:7A:27:06:38:8E:A4:35:FE:F5:17:D7:37:F4:DF:F0:9A:D2:C0:C4:B6:FF:EE:D1:EA:2B:A7", // "EnableApiVersioningHeader": false - //} + //}, // WARNING: AutoSetup section given as an example for Development only, for Production use "Environment Variables" instead //"OrchardCore_AutoSetup": { // "AutoSetupPath": "", diff --git a/src/OrchardCore.Modules/OrchardCore.ContentFields/Indexing/NumericFieldIndexHandler.cs b/src/OrchardCore.Modules/OrchardCore.ContentFields/Indexing/NumericFieldIndexHandler.cs index 7a4de334bbd..1f9e38ce499 100644 --- a/src/OrchardCore.Modules/OrchardCore.ContentFields/Indexing/NumericFieldIndexHandler.cs +++ b/src/OrchardCore.Modules/OrchardCore.ContentFields/Indexing/NumericFieldIndexHandler.cs @@ -12,19 +12,18 @@ public override Task BuildIndexAsync(NumericField field, BuildFieldIndexContext var settings = context.ContentPartFieldDefinition.GetSettings(); var options = context.Settings.ToOptions(); - if (settings.Scale == 0) + var isInteger = settings.Scale == 0; + + foreach (var key in context.Keys) { - foreach (var key in context.Keys) + if (isInteger) { context.DocumentIndex.Set(key, (int?)field.Value, options); + + continue; } - } - else - { - foreach (var key in context.Keys) - { - context.DocumentIndex.Set(key, field.Value, options); - } + + context.DocumentIndex.Set(key, field.Value, options); } return Task.CompletedTask; diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Indexing/MediaFieldIndexHandler.cs b/src/OrchardCore.Modules/OrchardCore.Media/Indexing/MediaFieldIndexHandler.cs index 9d93e3b27eb..1b33c847bdf 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media/Indexing/MediaFieldIndexHandler.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media/Indexing/MediaFieldIndexHandler.cs @@ -34,7 +34,7 @@ public async override Task BuildIndexAsync(MediaField field, BuildFieldIndexCont var options = context.Settings.ToOptions(); var settings = context.ContentPartFieldDefinition.GetSettings(); - if (field.Paths?.Length == 0) + if (field.Paths?.Length is null || field.Paths.Length == 0) { foreach (var key in context.Keys) { diff --git a/src/OrchardCore.Modules/OrchardCore.Notifications/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Notifications/Controllers/AdminController.cs index f1b26cea7ef..8a8a0ed69b2 100644 --- a/src/OrchardCore.Modules/OrchardCore.Notifications/Controllers/AdminController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Notifications/Controllers/AdminController.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.Extensions; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Notify; using OrchardCore.Entities; @@ -106,19 +107,11 @@ public async Task List( new(S["Remove"], nameof(NotificationBulkAction.Remove)), ]; - var routeData = new RouteData(options.RouteValues); var pager = new Pager(pagerParameters, _pagerOptions.GetPageSize()); var queryResult = await _notificationsAdminListQueryService.QueryAsync(pager.Page, pager.PageSize, options, this); - dynamic pagerShape = await _shapeFactory.CreateAsync("Pager", Arguments.From(new - { - pager.Page, - pager.PageSize, - TotalItemCount = queryResult.TotalCount - })); - - pagerShape.RouteData(routeData); + dynamic pagerShape = await _shapeFactory.PagerAsync(pager, queryResult.TotalCount, options.RouteValues); var notificationSummaries = new List(); diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/AdminMenu.cs new file mode 100644 index 00000000000..0f93471593d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/AdminMenu.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace OrchardCore.Search.AzureAI; + +public class AdminMenu(IStringLocalizer stringLocalizer) : INavigationProvider +{ + protected readonly IStringLocalizer S = stringLocalizer; + + public Task BuildNavigationAsync(string name, NavigationBuilder builder) + { + if (!string.Equals(name, "admin", StringComparison.OrdinalIgnoreCase)) + { + return Task.CompletedTask; + } + + builder + .Add(S["Search"], NavigationConstants.AdminMenuSearchPosition, search => search + .AddClass("azure-ai-service") + .Id("azureaiservice") + .Add(S["Indexing"], S["Indexing"].PrefixPosition(), indexing => indexing + .Add(S["Azure AI Indices"], S["Azure AI Indices"].PrefixPosition(), indexes => indexes + .Action("Index", "Admin", new { area = "OrchardCore.Search.AzureAI" }) + .Permission(AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes) + .LocalNav() + ) + ) + ); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Controllers/AdminController.cs new file mode 100644 index 00000000000..996e53b7e65 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Controllers/AdminController.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.BackgroundJobs; +using OrchardCore.ContentManagement; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.Extensions; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Indexing; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Search.AzureAI.ViewModels; +using OrchardCore.Settings; + +namespace OrchardCore.Search.AzureAI.Controllers; + +public class AdminController : Controller +{ + private readonly ISiteService _siteService; + private readonly IAuthorizationService _authorizationService; + private readonly AzureAISearchIndexManager _indexManager; + private readonly AzureAISearchIndexSettingsService _indexSettingsService; + private readonly IContentManager _contentManager; + private readonly IShapeFactory _shapeFactory; + private readonly AzureAIIndexDocumentManager _azureAIIndexDocumentManager; + private readonly AzureAISearchDefaultOptions _azureAIOptions; + private readonly INotifier _notifier; + private readonly IEnumerable _contentItemIndexHandlers; + private readonly ILogger _logger; + + protected readonly IStringLocalizer S; + protected readonly IHtmlLocalizer H; + + public AdminController( + ISiteService siteService, + IAuthorizationService authorizationService, + AzureAISearchIndexManager indexManager, + AzureAISearchIndexSettingsService indexSettingsService, + IContentManager contentManager, + IShapeFactory shapeFactory, + AzureAIIndexDocumentManager azureAIIndexDocumentManager, + IOptions azureAIOptions, + INotifier notifier, + IEnumerable contentItemIndexHandlers, + ILogger logger, + IStringLocalizer stringLocalizer, + IHtmlLocalizer htmlLocalizer + ) + { + _siteService = siteService; + _authorizationService = authorizationService; + _indexManager = indexManager; + _indexSettingsService = indexSettingsService; + _contentManager = contentManager; + _shapeFactory = shapeFactory; + _azureAIIndexDocumentManager = azureAIIndexDocumentManager; + _azureAIOptions = azureAIOptions.Value; + _notifier = notifier; + _contentItemIndexHandlers = contentItemIndexHandlers; + _logger = logger; + S = stringLocalizer; + H = htmlLocalizer; + } + + public async Task Index(AzureAIIndexOptions options, PagerParameters pagerParameters) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + var indexes = (await _indexSettingsService.GetSettingsAsync()) + .Select(i => new IndexViewModel { Name = i.IndexName }) + .ToList(); + + var totalIndexes = indexes.Count; + + if (!string.IsNullOrWhiteSpace(options.Search)) + { + indexes = indexes.Where(q => q.Name.Contains(options.Search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + var siteSettings = await _siteService.GetSiteSettingsAsync(); + var pager = new Pager(pagerParameters, siteSettings.PageSize); + + indexes = indexes + .Skip(pager.GetStartIndex()) + .Take(pager.PageSize).ToList(); + + // Maintain previous route data when generating page links.S + RouteValueDictionary routeValues = null; + + if (!string.IsNullOrWhiteSpace(options.Search)) + { + routeValues = []; + routeValues.TryAdd("Options.Search", options.Search); + } + + var model = new AdminIndexViewModel + { + Indexes = indexes, + Options = options, + Pager = await _shapeFactory.PagerAsync(pager, totalIndexes, routeValues) + }; + + model.Options.ContentsBulkAction = + [ + new SelectListItem(S["Delete"], nameof(AzureAISearchIndexBulkAction.Remove)), + ]; + + return View(model); + } + + [HttpPost, ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + public ActionResult IndexFilterPOST(AdminIndexViewModel model) + => RedirectToAction(nameof(Index), + new RouteValueDictionary + { + { "Options.Search", model.Options.Search } + }); + + + [HttpPost, ActionName(nameof(Index))] + [FormValueRequired("submit.BulkAction")] + public async Task IndexPost(AzureAIIndexOptions options, IEnumerable itemIds) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + if (itemIds?.Count() > 0) + { + var indexSettings = await _indexSettingsService.GetSettingsAsync(); + var checkedContentItems = indexSettings.Where(x => itemIds.Contains(x.IndexName)); + + switch (options.BulkAction) + { + case AzureAISearchIndexBulkAction.None: + break; + case AzureAISearchIndexBulkAction.Remove: + foreach (var item in checkedContentItems) + { + await _indexManager.DeleteAsync(item.IndexName); + } + + await _notifier.SuccessAsync(H["Indices successfully removed."]); + + break; + default: + throw new ArgumentOutOfRangeException(options.BulkAction.ToString(), "Unknown bulk action"); + } + } + + return RedirectToAction(nameof(Index)); + } + + public async Task Create() + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + var model = new AzureAISettingsViewModel + { + AnalyzerName = AzureAISearchDefaultOptions.DefaultAnalyzer + }; + + PopulateMenuOptions(model); + + return View(model); + } + + [HttpPost, ActionName(nameof(Create))] + public async Task CreatePost(AzureAISettingsViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + if (ModelState.IsValid && await _indexManager.ExistsAsync(model.IndexName)) + { + ModelState.AddModelError(nameof(AzureAISettingsViewModel.IndexName), S["An index named {0} already exist in Azure AI Search server.", model.IndexName]); + } + + if (ModelState.IsValid) + { + try + { + var settings = new AzureAISearchIndexSettings + { + IndexName = model.IndexName, + IndexFullName = _indexManager.GetFullIndexName(model.IndexName), + AnalyzerName = model.AnalyzerName, + QueryAnalyzerName = model.AnalyzerName, + IndexLatest = model.IndexLatest, + IndexedContentTypes = model.IndexedContentTypes, + Culture = model.Culture ?? string.Empty, + }; + + if (string.IsNullOrEmpty(settings.AnalyzerName)) + { + settings.AnalyzerName = AzureAISearchDefaultOptions.DefaultAnalyzer; + } + + if (string.IsNullOrEmpty(settings.QueryAnalyzerName)) + { + settings.QueryAnalyzerName = settings.AnalyzerName; + } + + settings.IndexMappings = await _azureAIIndexDocumentManager.GetMappingsAsync(settings.IndexedContentTypes); + await _indexManager.CreateAsync(settings); + await _indexSettingsService.UpdateAsync(settings); + await AsyncContentItemsAsync(settings.IndexName); + await _notifier.SuccessAsync(H["Index {0} created successfully.", model.IndexName]); + + return RedirectToAction(nameof(Index)); + } + catch (Exception e) + { + await _notifier.ErrorAsync(H["An error occurred while creating the index."]); + _logger.LogError(e, "An error occurred while creating an index {indexName}.", _indexManager.GetFullIndexName(model.IndexName)); + } + } + + PopulateMenuOptions(model); + + return View(model); + } + + public async Task Edit(string indexName) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + var settings = await _indexSettingsService.GetAsync(indexName); + + if (settings == null) + { + return NotFound(); + } + + var model = new AzureAISettingsViewModel + { + IndexName = settings.IndexName, + AnalyzerName = settings.AnalyzerName, + IndexLatest = settings.IndexLatest, + Culture = settings.Culture, + IndexedContentTypes = settings.IndexedContentTypes, + }; + + if (string.IsNullOrEmpty(model.AnalyzerName)) + { + model.AnalyzerName = AzureAISearchDefaultOptions.DefaultAnalyzer; + } + + if (string.IsNullOrEmpty(settings.QueryAnalyzerName)) + { + settings.QueryAnalyzerName = model.AnalyzerName; + } + + PopulateMenuOptions(model); + + return View(model); + } + + [HttpPost, ActionName(nameof(Edit))] + public async Task EditPost(AzureAISettingsViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + if (ModelState.IsValid && !await _indexManager.ExistsAsync(model.IndexName)) + { + ModelState.AddModelError(nameof(AzureAISettingsViewModel.IndexName), S["The index named {0} doesn't exist in Azure AI Search server.", model.IndexName]); + } + + if (ModelState.IsValid) + { + var settings = await _indexSettingsService.GetAsync(model.IndexName); + + if (settings == null) + { + return NotFound(); + } + + try + { + settings.AnalyzerName = model.AnalyzerName; + settings.QueryAnalyzerName = model.AnalyzerName; + settings.IndexLatest = model.IndexLatest; + settings.IndexedContentTypes = model.IndexedContentTypes; + settings.Culture = model.Culture ?? string.Empty; + + if (string.IsNullOrEmpty(settings.AnalyzerName)) + { + settings.AnalyzerName = AzureAISearchDefaultOptions.DefaultAnalyzer; + } + + if (string.IsNullOrEmpty(settings.QueryAnalyzerName)) + { + settings.QueryAnalyzerName = settings.AnalyzerName; + } + + settings.IndexMappings = await _azureAIIndexDocumentManager.GetMappingsAsync(settings.IndexedContentTypes); + + if (!await _indexManager.CreateAsync(settings)) + { + await _notifier.ErrorAsync(H["An error occurred while creating the index."]); + } + else + { + await _indexSettingsService.UpdateAsync(settings); + + await _notifier.SuccessAsync(H["Index {0} created successfully.", model.IndexName]); + + await AsyncContentItemsAsync(settings.IndexName); + + return RedirectToAction(nameof(Index)); + } + } + catch (Exception e) + { + await _notifier.ErrorAsync(H["An error occurred while updating the index."]); + + _logger.LogError(e, "An error occurred while updating an index {indexName}.", _indexManager.GetFullIndexName(model.IndexName)); + } + } + + PopulateMenuOptions(model); + + return View(model); + } + + [HttpPost] + public async Task Delete(string indexName) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + var exists = await _indexManager.ExistsAsync(indexName); + + if (!exists) + { + // At this point we know that the index does not exists on remote server. Let's delete it locally. + await _indexSettingsService.DeleteAsync(indexName); + + await _notifier.SuccessAsync(H["Index {0} deleted successfully.", indexName]); + } + else if (await _indexManager.DeleteAsync(indexName)) + { + await _indexSettingsService.DeleteAsync(indexName); + + await _notifier.SuccessAsync(H["Index {0} deleted successfully.", indexName]); + } + else + { + await _notifier.ErrorAsync(H["An error occurred while deleting the {0} index.", indexName]); + } + + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + public async Task Rebuild(string indexName) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + var settings = await _indexSettingsService.GetAsync(indexName); + + if (settings == null) + { + return NotFound(); + } + + if (!await _indexManager.ExistsAsync(indexName)) + { + return NotFound(); + } + + settings.IndexMappings = await _azureAIIndexDocumentManager.GetMappingsAsync(settings.IndexedContentTypes); + await _indexSettingsService.UpdateAsync(settings); + await _indexManager.RebuildAsync(settings); + await AsyncContentItemsAsync(settings.IndexName); + await _notifier.SuccessAsync(H["Index {0} rebuilt successfully.", indexName]); + + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + public async Task Reset(string indexName) + { + if (!await _authorizationService.AuthorizeAsync(User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return Forbid(); + } + + var settings = await _indexSettingsService.GetAsync(indexName); + + if (settings == null) + { + return NotFound(); + } + + if (!await _indexManager.ExistsAsync(indexName)) + { + return NotFound(); + } + + settings.SetLastTaskId(0); + settings.IndexMappings = await _azureAIIndexDocumentManager.GetMappingsAsync(settings.IndexedContentTypes); + await _indexSettingsService.UpdateAsync(settings); + await AsyncContentItemsAsync(settings.IndexName); + await _notifier.SuccessAsync(H["Index {0} reset successfully.", indexName]); + + return RedirectToAction(nameof(Index)); + } + + private static Task AsyncContentItemsAsync(string indexName) + => HttpBackgroundJob.ExecuteAfterEndOfRequestAsync("sync-content-items-azure-ai-" + indexName, async (scope) => + { + var indexingService = scope.ServiceProvider.GetRequiredService(); + await indexingService.ProcessContentItemsAsync(indexName); + }); + + private void PopulateMenuOptions(AzureAISettingsViewModel model) + { + model.Cultures = CultureInfo.GetCultures(CultureTypes.AllCultures) + .Select(x => new SelectListItem { Text = $"{x.Name} ({x.DisplayName})", Value = x.Name }); + + model.Analyzers = _azureAIOptions.Analyzers + .Select(x => new SelectListItem { Text = x, Value = x }); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexDeploymentStepDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexDeploymentStepDriver.cs new file mode 100644 index 00000000000..66657d250f1 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexDeploymentStepDriver.cs @@ -0,0 +1,52 @@ +using System.Linq; +using System.Threading.Tasks; +using OrchardCore.Deployment; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Search.AzureAI.Deployment; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Search.AzureAI.ViewModels; + +namespace OrchardCore.Search.AzureAI.Drivers; + +public class AzureAISearchIndexDeploymentStepDriver : DisplayDriver +{ + private readonly AzureAISearchIndexSettingsService _indexSettingsService; + + public AzureAISearchIndexDeploymentStepDriver(AzureAISearchIndexSettingsService indexSettingsService) + { + _indexSettingsService = indexSettingsService; + } + + public override IDisplayResult Display(AzureAISearchIndexDeploymentStep step) + => Combine( + View("AzureAISearchIndexDeploymentStep_Fields_Summary", step).Location("Summary", "Content"), + View("AzureAISearchIndexDeploymentStep_Fields_Thumbnail", step).Location("Thumbnail", "Content") + ); + + + public override IDisplayResult Edit(AzureAISearchIndexDeploymentStep step) + => Initialize("AzureAISearchIndexDeploymentStep_Fields_Edit", async model => + { + model.IncludeAll = step.IncludeAll; + model.IndexNames = step.IndexNames; + model.AllIndexNames = (await _indexSettingsService.GetSettingsAsync()).Select(x => x.IndexName).ToArray(); + }).Location("Content"); + + + public override async Task UpdateAsync(AzureAISearchIndexDeploymentStep step, IUpdateModel updater) + { + step.IndexNames = []; + + await updater.TryUpdateModelAsync(step, Prefix, p => p.IncludeAll, p => p.IndexNames); + + if (step.IncludeAll) + { + // clear index names if the user select include all. + step.IndexNames = []; + } + + return Edit(step); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexRebuildDeploymentStepDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexRebuildDeploymentStepDriver.cs new file mode 100644 index 00000000000..055bef504d6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexRebuildDeploymentStepDriver.cs @@ -0,0 +1,46 @@ +using System.Linq; +using System.Threading.Tasks; +using OrchardCore.Deployment; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Search.AzureAI.Deployment; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Search.AzureAI.ViewModels; + +namespace OrchardCore.Search.AzureAI.Drivers; + +public class AzureAISearchIndexRebuildDeploymentStepDriver(AzureAISearchIndexSettingsService indexSettingsService) + : DisplayDriver +{ + private readonly AzureAISearchIndexSettingsService _indexSettingsService = indexSettingsService; + + public override IDisplayResult Display(AzureAISearchIndexRebuildDeploymentStep step) + => Combine( + View("AzureAISearchIndexRebuildDeploymentStep_Fields_Summary", step).Location("Summary", "Content"), + View("AzureAISearchIndexRebuildDeploymentStep_Fields_Thumbnail", step).Location("Thumbnail", "Content") + ); + + public override IDisplayResult Edit(AzureAISearchIndexRebuildDeploymentStep step) + => Initialize("AzureAISearchIndexRebuildDeploymentStep_Fields_Edit", async model => + { + model.IncludeAll = step.IncludeAll; + model.IndexNames = step.Indices; + model.AllIndexNames = (await _indexSettingsService.GetSettingsAsync()).Select(x => x.IndexName).ToArray(); + }).Location("Content"); + + public override async Task UpdateAsync(AzureAISearchIndexRebuildDeploymentStep step, IUpdateModel updater) + { + step.Indices = []; + + await updater.TryUpdateModelAsync(step, Prefix, p => p.IncludeAll, p => p.Indices); + + if (step.IncludeAll) + { + // Clear index names if the user select include all. + step.Indices = []; + } + + return Edit(step); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexResetDeploymentStepDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexResetDeploymentStepDriver.cs new file mode 100644 index 00000000000..c7163acb0d9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchIndexResetDeploymentStepDriver.cs @@ -0,0 +1,46 @@ +using System.Linq; +using System.Threading.Tasks; +using OrchardCore.Deployment; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Search.AzureAI.Deployment; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Search.AzureAI.ViewModels; + +namespace OrchardCore.Search.AzureAI.Drivers; + +public class AzureAISearchIndexResetDeploymentStepDriver(AzureAISearchIndexSettingsService indexSettingsService) + : DisplayDriver +{ + private readonly AzureAISearchIndexSettingsService _indexSettingsService = indexSettingsService; + + public override IDisplayResult Display(AzureAISearchIndexResetDeploymentStep step) + => Combine( + View("AzureAISearchIndexResetDeploymentStep_Fields_Summary", step).Location("Summary", "Content"), + View("AzureAISearchIndexResetDeploymentStep_Fields_Thumbnail", step).Location("Thumbnail", "Content") + ); + + public override IDisplayResult Edit(AzureAISearchIndexResetDeploymentStep step) + => Initialize("AzureAISearchIndexResetDeploymentStep_Fields_Edit", async model => + { + model.IncludeAll = step.IncludeAll; + model.IndexNames = step.Indices; + model.AllIndexNames = (await _indexSettingsService.GetSettingsAsync()).Select(x => x.IndexName).ToArray(); + }).Location("Content"); + + public override async Task UpdateAsync(AzureAISearchIndexResetDeploymentStep step, IUpdateModel updater) + { + step.Indices = []; + + await updater.TryUpdateModelAsync(step, Prefix, p => p.IncludeAll, p => p.Indices); + + if (step.IncludeAll) + { + // Clear index names if the user select include all. + step.Indices = []; + } + + return Edit(step); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchSettingsDeploymentStepDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchSettingsDeploymentStepDriver.cs new file mode 100644 index 00000000000..8df641b0e58 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchSettingsDeploymentStepDriver.cs @@ -0,0 +1,20 @@ +using OrchardCore.Deployment; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Search.AzureAI.Deployment; + +namespace OrchardCore.Search.AzureAI.Drivers; + +public class AzureAISearchSettingsDeploymentStepDriver : DisplayDriver +{ + public override IDisplayResult Display(AzureAISearchSettingsDeploymentStep step) + => Combine( + View("AzureAISearchSettingsDeploymentStep_Fields_Summary", step).Location("Summary", "Content"), + View("AzureAISearchSettingsDeploymentStep_Fields_Thumbnail", step).Location("Thumbnail", "Content") + ); + + public override IDisplayResult Edit(AzureAISearchSettingsDeploymentStep step) + { + return View("AzureAISearchSettingsDeploymentStep_Fields_Edit", step).Location("Content"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchSettingsDisplayDriver.cs new file mode 100644 index 00000000000..e2f6a57d583 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchSettingsDisplayDriver.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Modules; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Search.AzureAI.ViewModels; +using OrchardCore.Settings; + +namespace OrchardCore.Search.AzureAI.Drivers; + +public class AzureAISearchSettingsDisplayDriver : SectionDisplayDriver +{ + private static readonly char[] _separator = [',', ' ']; + + private readonly AzureAISearchIndexSettingsService _indexSettingsService; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + + protected readonly IStringLocalizer S; + + public AzureAISearchSettingsDisplayDriver( + AzureAISearchIndexSettingsService indexSettingsService, + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IStringLocalizer stringLocalizer + ) + { + _indexSettingsService = indexSettingsService; + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + S = stringLocalizer; + } + + public override IDisplayResult Edit(AzureAISearchSettings settings) + => Initialize("AzureAISearchSettings_Edit", async model => + { + model.SearchIndex = settings.SearchIndex; + model.SearchFields = string.Join(", ", settings.DefaultSearchFields ?? []); + model.SearchIndexes = (await _indexSettingsService.GetSettingsAsync()) + .Select(x => new SelectListItem(x.IndexName, x.IndexName)) + .ToList(); + }).Location("Content:2#Azure AI Search;5") + .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + .Prefix(Prefix) + .OnGroup(SearchConstants.SearchSettingsGroupId); + + public override async Task UpdateAsync(AzureAISearchSettings section, BuildEditorContext context) + { + if (!SearchConstants.SearchSettingsGroupId.EqualsOrdinalIgnoreCase(context.GroupId)) + { + return null; + } + + if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return null; + } + + var model = new AzureAISearchSettingsViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (string.IsNullOrEmpty(model.SearchIndex)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SearchIndex), S["Search Index is required."]); + } + else + { + var indexes = await _indexSettingsService.GetSettingsAsync(); + + if (!indexes.Any(index => index.IndexName == model.SearchIndex)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SearchIndex), S["Invalid Search Index value."]); + } + } + + section.SearchIndex = model.SearchIndex; + section.DefaultSearchFields = model.SearchFields?.Split(_separator, StringSplitOptions.RemoveEmptyEntries); + + return await EditAsync(section, context); + } + + protected override void BuildPrefix(ISite model, string htmlFieldPrefix) + { + Prefix = typeof(AzureAISearchSettings).Name; + + if (!string.IsNullOrEmpty(htmlFieldPrefix)) + { + Prefix = htmlFieldPrefix + "." + Prefix; + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/ContentPartFieldIndexSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/ContentPartFieldIndexSettingsDisplayDriver.cs new file mode 100644 index 00000000000..789c2773c1f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/ContentPartFieldIndexSettingsDisplayDriver.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.ContentManagement.Metadata.Models; +using OrchardCore.ContentTypes.Editors; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI.Drivers; + +public class ContentPartFieldIndexSettingsDisplayDriver(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor) + : ContentPartFieldDefinitionDisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + private readonly IAuthorizationService _authorizationService = authorizationService; + + public override async Task EditAsync(ContentPartFieldDefinition contentPartFieldDefinition, IUpdateModel updater) + { + if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return null; + } + + return Initialize("AzureAISearchContentIndexSettings_Edit", model => contentPartFieldDefinition.GetSettings()) + .Location("Content:10"); + } + + public override async Task UpdateAsync(ContentPartFieldDefinition contentPartFieldDefinition, UpdatePartFieldEditorContext context) + { + if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return null; + } + + var model = new AzureAISearchContentIndexSettings(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + context.Builder.WithSettings(model); + + return await EditAsync(contentPartFieldDefinition, context.Updater); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/ContentTypePartIndexSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/ContentTypePartIndexSettingsDisplayDriver.cs new file mode 100644 index 00000000000..774a97df642 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/ContentTypePartIndexSettingsDisplayDriver.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.ContentManagement.Metadata.Models; +using OrchardCore.ContentTypes.Editors; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI.Drivers; + +public class ContentTypePartIndexSettingsDisplayDriver(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor) + : ContentTypePartDefinitionDisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + private readonly IAuthorizationService _authorizationService = authorizationService; + + public override async Task EditAsync(ContentTypePartDefinition contentTypePartDefinition, IUpdateModel updater) + { + if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return null; + } + + return Initialize("AzureAISearchContentIndexSettings_Edit", model => contentTypePartDefinition.GetSettings()) + .Location("Content:10"); + } + + public override async Task UpdateAsync(ContentTypePartDefinition contentTypePartDefinition, UpdateTypePartEditorContext context) + { + if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)) + { + return null; + } + + var model = new AzureAISearchContentIndexSettings(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + context.Builder.WithSettings(model); + + return await EditAsync(contentTypePartDefinition, context.Updater); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Manifest.cs new file mode 100644 index 00000000000..d3b68441882 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Manifest.cs @@ -0,0 +1,19 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Azure AI Search", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion +)] + +[assembly: Feature( + Id = "OrchardCore.Search.AzureAI", + Name = "Azure AI Search", + Description = "Provides Azure AI Search services for managing indexes and facilitating search scenarios within indexes.", + Dependencies = + [ + "OrchardCore.Indexing", + ], + Category = "Search" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/OrchardCore.Search.AzureAI.csproj b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/OrchardCore.Search.AzureAI.csproj new file mode 100644 index 00000000000..601c7dd8ae8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/OrchardCore.Search.AzureAI.csproj @@ -0,0 +1,33 @@ + + + + true + + OrchardCore Azure AI Search + + $(OCCMSDescription) + + Creates Azure AI Search indexes to support search scenarios, introduces a preconfigured container-enabled content type. + + $(PackageTags) OrchardCoreCMS + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Startup.cs new file mode 100644 index 00000000000..b48bcb8e198 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Startup.cs @@ -0,0 +1,138 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.ContentTypes.Editors; +using OrchardCore.Deployment; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Navigation; +using OrchardCore.Search.Abstractions; +using OrchardCore.Search.AzureAI.Controllers; +using OrchardCore.Search.AzureAI.Deployment; +using OrchardCore.Search.AzureAI.Drivers; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Settings; + +namespace OrchardCore.Search.AzureAI; + +public class Startup(ILogger logger, IShellConfiguration shellConfiguration, IOptions adminOptions) + : StartupBase +{ + private readonly ILogger _logger = logger; + private readonly IShellConfiguration _shellConfiguration = shellConfiguration; + private readonly AdminOptions _adminOptions = adminOptions.Value; + + public override void ConfigureServices(IServiceCollection services) + { + if (!services.TryAddAzureAISearchServices(_shellConfiguration, _logger)) + { + return; + } + + services.AddScoped(); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + var options = serviceProvider.GetRequiredService>().Value; + + if (!options.IsConfigurationExists()) + { + return; + } + + var adminControllerName = typeof(AdminController).ControllerName(); + + routes.MapAreaControllerRoute( + name: "AzureAISearch.Index", + areaName: "OrchardCore.Search.AzureAI", + pattern: _adminOptions.AdminUrlPrefix + "/azure-search/Index", + defaults: new { controller = adminControllerName, action = nameof(AdminController.Index) } + ); + + routes.MapAreaControllerRoute( + name: "AzureAISearch.Create", + areaName: "OrchardCore.Search.AzureAI", + pattern: _adminOptions.AdminUrlPrefix + "/azure-search/Create", + defaults: new { controller = adminControllerName, action = nameof(AdminController.Create) } + ); + + routes.MapAreaControllerRoute( + name: "AzureAISearch.Edit", + areaName: "OrchardCore.Search.AzureAI", + pattern: _adminOptions.AdminUrlPrefix + "/azure-search/Edit/{indexName}", + defaults: new { controller = adminControllerName, action = nameof(AdminController.Edit) } + ); + + routes.MapAreaControllerRoute( + name: "AzureAISearch.Delete", + areaName: "OrchardCore.Search.AzureAI", + pattern: _adminOptions.AdminUrlPrefix + "/azure-search/Delete/{indexName}", + defaults: new { controller = adminControllerName, action = nameof(AdminController.Delete) } + ); + + routes.MapAreaControllerRoute( + name: "AzureAISearch.Reset", + areaName: "OrchardCore.Search.AzureAI", + pattern: _adminOptions.AdminUrlPrefix + "/azure-search/Reset/{indexName}", + defaults: new { controller = adminControllerName, action = nameof(AdminController.Reset) } + ); + + routes.MapAreaControllerRoute( + name: "AzureAISearch.Rebuild", + areaName: "OrchardCore.Search.AzureAI", + pattern: _adminOptions.AdminUrlPrefix + "/azure-search/Rebuild/{indexName}", + defaults: new { controller = adminControllerName, action = nameof(AdminController.Rebuild) } + ); + } +} + +[RequireFeatures("OrchardCore.Search")] +public class SearchStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped, AzureAISearchSettingsDisplayDriver>(); + services.AddScoped(); + } +} + +[RequireFeatures("OrchardCore.ContentTypes")] +public class ContentTypesStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } +} + +[RequireFeatures("OrchardCore.Deployment")] +public class DeploymentStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(new DeploymentStepFactory()); + services.AddScoped, AzureAISearchIndexDeploymentStepDriver>(); + + services.AddTransient(); + services.AddSingleton(new DeploymentStepFactory()); + services.AddScoped, AzureAISearchSettingsDeploymentStepDriver>(); + + services.AddTransient(); + services.AddSingleton(new DeploymentStepFactory()); + services.AddScoped, AzureAISearchIndexRebuildDeploymentStepDriver>(); + + services.AddTransient(); + services.AddSingleton(new DeploymentStepFactory()); + services.AddScoped, AzureAISearchIndexResetDeploymentStepDriver>(); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AdminIndexViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AdminIndexViewModel.cs new file mode 100644 index 00000000000..aa65eed4fbe --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AdminIndexViewModel.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class AdminIndexViewModel +{ + [BindNever] + public IEnumerable Indexes { get; set; } + + public AzureAIIndexOptions Options { get; set; } = new(); + + [BindNever] + public dynamic Pager { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAIIndexOptions.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAIIndexOptions.cs new file mode 100644 index 00000000000..ab323f4b475 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAIIndexOptions.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class AzureAIIndexOptions +{ + public AzureAISearchIndexBulkAction BulkAction { get; set; } + + public string Search { get; set; } + + [BindNever] + public List ContentsBulkAction { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexBulkAction.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexBulkAction.cs new file mode 100644 index 00000000000..49a0cd91156 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexBulkAction.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Search.AzureAI.ViewModels; + +public enum AzureAISearchIndexBulkAction +{ + None, + Remove +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexDeploymentStepViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexDeploymentStepViewModel.cs new file mode 100644 index 00000000000..82e6df45cd6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexDeploymentStepViewModel.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class AzureAISearchIndexDeploymentStepViewModel +{ + public bool IncludeAll { get; set; } + + public string[] IndexNames { get; set; } + + public string[] AllIndexNames { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexRebuildDeploymentStepViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexRebuildDeploymentStepViewModel.cs new file mode 100644 index 00000000000..8c460abe0b2 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexRebuildDeploymentStepViewModel.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class AzureAISearchIndexRebuildDeploymentStepViewModel +{ + public bool IncludeAll { get; set; } + + public string[] IndexNames { get; set; } + + public string[] AllIndexNames { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexResetDeploymentStepViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexResetDeploymentStepViewModel.cs new file mode 100644 index 00000000000..4cb91a4ba76 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchIndexResetDeploymentStepViewModel.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class AzureAISearchIndexResetDeploymentStepViewModel +{ + public bool IncludeAll { get; set; } + + public string[] IndexNames { get; set; } + + public string[] AllIndexNames { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchSettingsViewModel.cs new file mode 100644 index 00000000000..aa7b5b59c1e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchSettingsViewModel.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class AzureAISearchSettingsViewModel +{ + public string SearchFields { get; set; } + + public string SearchIndex { get; set; } + + [BindNever] + public IList SearchIndexes { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISettingsViewModel.cs new file mode 100644 index 00000000000..fbcb8899e47 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISettingsViewModel.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class AzureAISettingsViewModel : IValidatableObject +{ + public string IndexName { get; set; } + + public string AnalyzerName { get; set; } + + public bool IndexLatest { get; set; } + + public string Culture { get; set; } + + public string[] IndexedContentTypes { get; set; } + + [BindNever] + public IEnumerable Analyzers { get; set; } + + [BindNever] + public IEnumerable Cultures { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + var S = validationContext.GetRequiredService>(); + + if (IndexedContentTypes == null || IndexedContentTypes.Length == 0) + { + yield return new ValidationResult(S["At least one content type is required."], [nameof(IndexedContentTypes)]); + } + + if (string.IsNullOrWhiteSpace(IndexName)) + { + yield return new ValidationResult(S["The index name is required."], [nameof(IndexName)]); + } + else if (!AzureAISearchIndexNamingHelper.TryGetSafeIndexName(IndexName, out var indexName) || indexName != IndexName) + { + yield return new ValidationResult(S["The index name contains forbidden characters."], [nameof(IndexName)]); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/IndexViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/IndexViewModel.cs new file mode 100644 index 00000000000..0a544cf38ee --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/IndexViewModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Search.AzureAI.ViewModels; + +public class IndexViewModel +{ + public string Name { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Create.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Create.cshtml new file mode 100644 index 00000000000..ca532023c43 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Create.cshtml @@ -0,0 +1,53 @@ +@using OrchardCore.Search.AzureAI.ViewModels + +@model AzureAISettingsViewModel + +

@RenderTitleSegments(T["Create Azure AI Search Index"])

+
+
+
+ + + @T["Should be all lowercase. The shell name will be automatically prepended."]@T["See documentation"]. + +
+ +
+ + + +
+ +
+ + + @T["The content culture that it will index."] + +
+ +
+ + @T["The content types to index. Choose at least one."] + + @await Component.InvokeAsync("SelectContentTypes", new { htmlName = Html.NameFor(m => m.IndexedContentTypes) }) + + +
+ +
+ +
+ + + @T["Check to index draft if it exists, otherwise only the published version is indexed."] +
+ +
+ +
+ + @T["Cancel"] +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Edit.cshtml new file mode 100644 index 00000000000..9f4a12222bf --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Edit.cshtml @@ -0,0 +1,55 @@ +@using OrchardCore.Search.AzureAI.ViewModels + +@model AzureAISettingsViewModel + + +

@RenderTitleSegments(T["Edit Azure AI Search Index"])

+ +
+
+
+ + + @T["Should be all lowercase. The shell name will be automatically prepended."]@T["See documentation"]. + +
+ +
+ + + +
+ +
+ + + @T["The content culture that it will index."] + +
+ +
+ + @T["The content types to index. Choose at least one."] + + @await Component.InvokeAsync("SelectContentTypes", new { selectedContentTypes = Model.IndexedContentTypes, htmlName = Html.NameFor(m => m.IndexedContentTypes) }) + + +
+ +
+ +
+ + + @T["Check to index draft if it exists, otherwise only the published version is indexed."] +
+ +
+ +
+ + @T["Cancel"] +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Index.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Index.cshtml new file mode 100644 index 00000000000..41a19e6f17d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Admin/Index.cshtml @@ -0,0 +1,158 @@ +@using OrchardCore.Search.AzureAI.ViewModels + +@model AdminIndexViewModel +@{ + int startIndex = (Model.Pager.Page - 1) * (Model.Pager.PageSize) + 1; + int endIndex = startIndex + Model.Indexes.Count() - 1; +} + +

@RenderTitleSegments(T["Azure AI Search Indices"])

+ +@* the form is necessary to generate and antiforgery token for the delete action *@ +
+ + + + +
+
+
+
+ +
+ +
+
+
+
    + @if (Model.Indexes.Any()) + { +
  • +
    +
    +
    + + + + +
    +
    + +
    +
  • + @foreach (var entry in Model.Indexes) + { +
  • +
    +
    +
    + + +
    +
    +
    @entry.Name
    +
    +
    + + +
    +
  • + } + } + else + { +
  • + +
  • + } +
+
+ +@await DisplayAsync(Model.Pager) + + diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchContentIndexSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchContentIndexSettings.Edit.cshtml new file mode 100644 index 00000000000..908275d1c76 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchContentIndexSettings.Edit.cshtml @@ -0,0 +1,13 @@ +@using OrchardCore.Search.AzureAI.Models + +@model AzureAISearchContentIndexSettings + +

@T["Azure AI Search Index Settings"]

+ +
+
+ + + @T["Check to include the value of this element in the index."] +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchSettings.Edit.cshtml new file mode 100644 index 00000000000..864e433973e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchSettings.Edit.cshtml @@ -0,0 +1,28 @@ +@using OrchardCore.Search.AzureAI +@using OrchardCore.Search.AzureAI.Models +@using OrchardCore.Search.AzureAI.ViewModels + +@model AzureAISearchSettingsViewModel + +@if (Model.SearchIndexes == null || Model.SearchIndexes?.Count == 0) +{ +
@T["No indices exist! Please create at least one index to configure Azure AI Search service."]
+ + return; +} + +
+ + + + @T["The default index to use for the search page."] +
+ +
+ + + + @T["A comma separated list of fields to use during search. Leave blank to search all fields."] +
diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Edit.cshtml new file mode 100644 index 00000000000..954e5b47c3c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Edit.cshtml @@ -0,0 +1,63 @@ +@using OrchardCore.Search.AzureAI.ViewModels + +@model AzureAISearchIndexDeploymentStepViewModel + +@{ + var indexNames = Model.IndexNames; + var allIndexNames = Model.AllIndexNames; +} + +
@T["Azure AI Search Indexes"]
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ @T["The search indexes to add as part of the plan."] +
+
+
+
+
    + @foreach (var indexName in allIndexNames) + { + var checkd = indexNames?.Contains(indexName); + +
  • +
    + +
    +
  • + } +
+
+
+
+ + diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Summary.cshtml new file mode 100644 index 00000000000..76bb37d7ff2 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Summary.cshtml @@ -0,0 +1,27 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Search.AzureAI.Deployment + +@model ShapeViewModel + +@{ + var includeAll = Model.Value.IncludeAll; + var indexNames = Model.Value.IndexNames; +} + +
@T["Azure AI Search Indexes"]
+ +@if (includeAll) +{ + @T["All"] +} +else if (indexNames?.Length > 0) +{ + foreach (var indexName in indexNames) + { + @indexName + } +} +else +{ + @T["No index selected."] +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..19c03f65901 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexDeploymentStep.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model dynamic + +

@T["Azure AI Search Indexes"]

+

@T["Exports all or specified search indexes."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Edit.cshtml new file mode 100644 index 00000000000..60692a9530a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Edit.cshtml @@ -0,0 +1,53 @@ +@using OrchardCore.Search.AzureAI.ViewModels + +@model AzureAISearchIndexRebuildDeploymentStepViewModel + +
@T["Rebuild Azure AI Search Indices"]
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ @T["The Azure AI Search indices to rebuild as part of the plan."] +
+
+
+
+
    + @foreach (var indexName in Model.AllIndexNames) + { + var isChecked = Model.IndexNames?.Contains(indexName); + +
  • +
    + +
    +
  • + } +
+
+
+
+ + diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Summary.cshtml new file mode 100644 index 00000000000..56b7df0d6d7 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Summary.cshtml @@ -0,0 +1,27 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Search.AzureAI.Deployment + +@model ShapeViewModel + +@{ + var includeAll = Model.Value.IncludeAll; + var indices = Model.Value.Indices; +} + +
@T["Rebuild Azure AI Search Indices"]
+ +@if (includeAll) +{ + @T["All"] +} +else if (indices?.Length > 0) +{ + foreach (var indexName in indices) + { + @indexName + } +} +else +{ + @T["No index selected."] +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..5f4caa88cb9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexRebuildDeploymentStep.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model dynamic + +

@T["Rebuild Azure AI Search Indices"]

+

@T["Rebuild all or specified Azure AI Search indices."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Edit.cshtml new file mode 100644 index 00000000000..42bc52d7257 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Edit.cshtml @@ -0,0 +1,53 @@ +@using OrchardCore.Search.AzureAI.ViewModels + +@model AzureAISearchIndexResetDeploymentStepViewModel + +
@T["Reset Azure AI Search Indices"]
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ @T["The Azure AI Search Indices to reset as part of the plan."] +
+
+
+
+
    + @foreach (var indexName in Model.AllIndexNames) + { + var isChecked = Model.IndexNames?.Contains(indexName); + +
  • +
    + +
    +
  • + } +
+
+
+
+ + diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Summary.cshtml new file mode 100644 index 00000000000..729d19b99bd --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Summary.cshtml @@ -0,0 +1,27 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Search.AzureAI.Deployment + +@model ShapeViewModel + +@{ + var includeAll = Model.Value.IncludeAll; + var indices = Model.Value.Indices; +} + +
@T["Reset Azure AI Search Indices"]
+ +@if (includeAll) +{ + @T["All"] +} +else if (indices?.Length > 0) +{ + foreach (var indexName in indices) + { + @indexName + } +} +else +{ + @T["No index selected."] +} diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..a4208808ffe --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchIndexResetDeploymentStep.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model dynamic + +

@T["Reset Azure AI Indices"]

+

@T["Reset all or specified Azure AI Search indices."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Edit.cshtml new file mode 100644 index 00000000000..2cbd2f838bc --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Edit.cshtml @@ -0,0 +1,3 @@ +@model dynamic + +
@T["Azure AI Search Settings"]
diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Summary.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Summary.cshtml new file mode 100644 index 00000000000..2f9066b5f1e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Summary.cshtml @@ -0,0 +1,5 @@ +@model dynamic + +
@T["Azure AI Search Settings"]
+ +@T["Adds Azure AI Search settings to the plan."] diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..38381fa9a9d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/Items/AzureAISearchSettingsDeploymentStep.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +@model dynamic + +

@T["Azure AI Search Settings"]

+

@T["Exports Azure AI Search settings."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/_ViewImports.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..cd513344b82 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/_ViewImports.cshtml @@ -0,0 +1,7 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement +@using Microsoft.Extensions.Localization +@using Microsoft.AspNetCore.Mvc.Localization +@using OrchardCore.ContentManagement.Display diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/AdminMenu.cs index be180cbc52d..b30226ed9b2 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/AdminMenu.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/AdminMenu.cs @@ -2,46 +2,39 @@ using System.Threading.Tasks; using Microsoft.Extensions.Localization; using OrchardCore.Navigation; -using OrchardCore.Search.Elasticsearch.Drivers; -namespace OrchardCore.Search.Elasticsearch +namespace OrchardCore.Search.Elasticsearch; + +public class AdminMenu(IStringLocalizer localizer) : INavigationProvider { - public class AdminMenu : INavigationProvider - { - protected readonly IStringLocalizer S; + protected readonly IStringLocalizer S = localizer; - public AdminMenu(IStringLocalizer localizer) + public Task BuildNavigationAsync(string name, NavigationBuilder builder) + { + if (!string.Equals(name, "admin", StringComparison.OrdinalIgnoreCase)) { - S = localizer; + return Task.CompletedTask; } - public Task BuildNavigationAsync(string name, NavigationBuilder builder) - { - if (!string.Equals(name, "admin", StringComparison.OrdinalIgnoreCase)) - { - return Task.CompletedTask; - } - - builder - .Add(S["Search"], "7", search => search - .AddClass("elasticsearch").Id("Elasticsearch") - .Add(S["Indexing"], S["Indexing"].PrefixPosition(), import => import - .Add(S["Elasticsearch Indices"], S["Elasticsearch Indices"].PrefixPosition(), indexes => indexes - .Action("Index", "Admin", new { area = "OrchardCore.Search.Elasticsearch" }) - .Permission(Permissions.ManageElasticIndexes) - .LocalNav()) - .Add(S["Run Elasticsearch Query"], S["Run Elasticsearch Query"].PrefixPosition(), queries => queries - .Action("Query", "Admin", new { area = "OrchardCore.Search.Elasticsearch" }) - .Permission(Permissions.ManageElasticIndexes) - .LocalNav())) - .Add(S["Settings"], settings => settings - .Add(S["Elasticsearch"], S["Elasticsearch"].PrefixPosition(), entry => entry - .Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = ElasticSettingsDisplayDriver.GroupId }) - .Permission(Permissions.ManageElasticIndexes) - .LocalNav() - ))); + builder + .Add(S["Search"], NavigationConstants.AdminMenuSearchPosition, search => search + .AddClass("elasticsearch").Id("Elasticsearch") + .Add(S["Indexing"], S["Indexing"].PrefixPosition(), import => import + .Add(S["Elasticsearch Indices"], S["Elasticsearch Indices"].PrefixPosition(), indexes => indexes + .Action("Index", "Admin", new { area = "OrchardCore.Search.Elasticsearch" }) + .Permission(Permissions.ManageElasticIndexes) + .LocalNav() + ) + ) + .Add(S["Queries"], S["Queries"].PrefixPosition(), import => import + .Add(S["Run Elasticsearch Query"], S["Run Elasticsearch Query"].PrefixPosition(), queries => queries + .Action("Query", "Admin", new { area = "OrchardCore.Search.Elasticsearch" }) + .Permission(Permissions.ManageElasticIndexes) + .LocalNav() + ) + ) + ); - return Task.CompletedTask; - } + return Task.CompletedTask; } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs index dac5b29ebe9..1bd65044e39 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs @@ -11,6 +11,7 @@ using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; +using OrchardCore.Modules; using OrchardCore.Mvc.ModelBinding; using OrchardCore.Search.Elasticsearch.Core.Models; using OrchardCore.Search.Elasticsearch.Core.Services; @@ -21,8 +22,6 @@ namespace OrchardCore.Search.Elasticsearch.Drivers; public class ElasticSettingsDisplayDriver : SectionDisplayDriver { - public const string GroupId = "elasticsearch"; - private static readonly char[] _separator = [',', ' ']; private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { @@ -62,13 +61,14 @@ public override IDisplayResult Edit(ElasticSettings settings) new(S["Query String Query"], ElasticSettings.QueryStringSearchType), new(S["Custom Query"], ElasticSettings.CustomSearchType), ]; - }).Location("Content:2") + }).Location("Content:2#Elasticsearch;10") .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, Permissions.ManageElasticIndexes)) - .OnGroup(GroupId); + .Prefix(Prefix) + .OnGroup(SearchConstants.SearchSettingsGroupId); public override async Task UpdateAsync(ElasticSettings section, BuildEditorContext context) { - if (!string.Equals(GroupId, context.GroupId, StringComparison.OrdinalIgnoreCase)) + if (!SearchConstants.SearchSettingsGroupId.EqualsOrdinalIgnoreCase(context.GroupId)) { return null; } @@ -116,4 +116,14 @@ public override async Task UpdateAsync(ElasticSettings section, return await EditAsync(section, context); } + + protected override void BuildPrefix(ISite model, string htmlFieldPrefix) + { + Prefix = typeof(ElasticSettings).Name; + + if (!string.IsNullOrEmpty(htmlFieldPrefix)) + { + Prefix = htmlFieldPrefix + "." + Prefix; + } + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Startup.cs index 57f2601fbb3..764e350df0b 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Startup.cs @@ -115,10 +115,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddElasticServices(); services.AddScoped(); services.AddScoped(); - services.AddScoped, ElasticSettingsDisplayDriver>(); services.AddScoped, ElasticQueryDisplayDriver>(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); } @@ -263,6 +260,7 @@ public class SearchStartup : StartupBase public override void ConfigureServices(IServiceCollection services) { services.AddScoped(); + services.AddScoped, ElasticSettingsDisplayDriver>(); } } @@ -308,4 +306,14 @@ public override void ConfigureServices(IServiceCollection services) services.AddShapeAttributes(); } } + + [RequireFeatures("OrchardCore.ContentTypes")] + public class ContentTypesStartup : StartupBase + { + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticContentIndexSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticContentIndexSettings.Edit.cshtml index 4f56c038bd2..27d7597ee24 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticContentIndexSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticContentIndexSettings.Edit.cshtml @@ -1,10 +1,10 @@ @model ElasticContentIndexSettingsViewModel -

Elasticsearch @T["Index settings"]

+

@T["Elasticsearch Index settings"]

- + @T["Check to include the value of this element in the index."]
diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Lucene/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Search.Lucene/AdminMenu.cs index 82be44b0d40..acac7e5b1f7 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Lucene/AdminMenu.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Lucene/AdminMenu.cs @@ -2,46 +2,39 @@ using System.Threading.Tasks; using Microsoft.Extensions.Localization; using OrchardCore.Navigation; -using OrchardCore.Search.Lucene.Drivers; -namespace OrchardCore.Search.Lucene +namespace OrchardCore.Search.Lucene; + +public class AdminMenu(IStringLocalizer localizer) : INavigationProvider { - public class AdminMenu : INavigationProvider - { - protected readonly IStringLocalizer S; + protected readonly IStringLocalizer S = localizer; - public AdminMenu(IStringLocalizer localizer) + public Task BuildNavigationAsync(string name, NavigationBuilder builder) + { + if (!string.Equals(name, "admin", StringComparison.OrdinalIgnoreCase)) { - S = localizer; + return Task.CompletedTask; } - public Task BuildNavigationAsync(string name, NavigationBuilder builder) - { - if (!string.Equals(name, "admin", StringComparison.OrdinalIgnoreCase)) - { - return Task.CompletedTask; - } - - builder - .Add(S["Search"], NavigationConstants.AdminMenuSearchPosition, search => search - .AddClass("search").Id("search") - .Add(S["Indexing"], S["Indexing"].PrefixPosition(), import => import - .Add(S["Lucene Indices"], S["Lucene Indices"].PrefixPosition(), indexes => indexes - .Action("Index", "Admin", new { area = "OrchardCore.Search.Lucene" }) - .Permission(Permissions.ManageLuceneIndexes) - .LocalNav()) - .Add(S["Run Lucene Query"], S["Run Lucene Query"].PrefixPosition(), queries => queries - .Action("Query", "Admin", new { area = "OrchardCore.Search.Lucene" }) - .Permission(Permissions.ManageLuceneIndexes) - .LocalNav())) - .Add(S["Settings"], settings => settings - .Add(S["Lucene"], S["Lucene"].PrefixPosition(), entry => entry - .Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = LuceneSettingsDisplayDriver.GroupId }) - .Permission(Permissions.ManageLuceneIndexes) - .LocalNav() - ))); + builder + .Add(S["Search"], NavigationConstants.AdminMenuSearchPosition, search => search + .AddClass("search").Id("search") + .Add(S["Indexing"], S["Indexing"].PrefixPosition(), import => import + .Add(S["Lucene Indices"], S["Lucene Indices"].PrefixPosition(), indexes => indexes + .Action("Index", "Admin", new { area = "OrchardCore.Search.Lucene" }) + .Permission(Permissions.ManageLuceneIndexes) + .LocalNav() + ) + ) + .Add(S["Queries"], S["Queries"].PrefixPosition(), import => import + .Add(S["Run Lucene Query"], S["Run Lucene Query"].PrefixPosition(), queries => queries + .Action("Query", "Admin", new { area = "OrchardCore.Search.Lucene" }) + .Permission(Permissions.ManageLuceneIndexes) + .LocalNav() + ) + ) + ); - return Task.CompletedTask; - } + return Task.CompletedTask; } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Drivers/LuceneSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Drivers/LuceneSettingsDisplayDriver.cs index 54fb3e9af86..dfa9902e907 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Drivers/LuceneSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Drivers/LuceneSettingsDisplayDriver.cs @@ -6,29 +6,23 @@ using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; +using OrchardCore.Modules; using OrchardCore.Search.Lucene.Model; using OrchardCore.Search.Lucene.ViewModels; using OrchardCore.Settings; namespace OrchardCore.Search.Lucene.Drivers { - public class LuceneSettingsDisplayDriver : SectionDisplayDriver + public class LuceneSettingsDisplayDriver( + LuceneIndexSettingsService luceneIndexSettingsService, + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService + ) : SectionDisplayDriver { - public const string GroupId = "lucene"; - private readonly LuceneIndexSettingsService _luceneIndexSettingsService; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IAuthorizationService _authorizationService; - - public LuceneSettingsDisplayDriver( - LuceneIndexSettingsService luceneIndexSettingsService, - IHttpContextAccessor httpContextAccessor, - IAuthorizationService authorizationService - ) - { - _luceneIndexSettingsService = luceneIndexSettingsService; - _httpContextAccessor = httpContextAccessor; - _authorizationService = authorizationService; - } + private readonly LuceneIndexSettingsService _luceneIndexSettingsService = luceneIndexSettingsService; + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + private readonly IAuthorizationService _authorizationService = authorizationService; + private static readonly char[] _separator = [',', ' ']; public override async Task EditAsync(LuceneSettings settings, BuildEditorContext context) { @@ -42,14 +36,21 @@ public override async Task EditAsync(LuceneSettings settings, Bu return Initialize("LuceneSettings_Edit", async model => { model.SearchIndex = settings.SearchIndex; - model.SearchFields = string.Join(", ", settings.DefaultSearchFields ?? Array.Empty()); + model.SearchFields = string.Join(", ", settings.DefaultSearchFields ?? []); model.SearchIndexes = (await _luceneIndexSettingsService.GetSettingsAsync()).Select(x => x.IndexName); model.AllowLuceneQueriesInSearch = settings.AllowLuceneQueriesInSearch; - }).Location("Content:2").OnGroup(GroupId); + }).Location("Content:2#Lucene;15") + .Prefix(Prefix) + .OnGroup(SearchConstants.SearchSettingsGroupId); } public override async Task UpdateAsync(LuceneSettings section, BuildEditorContext context) { + if (!SearchConstants.SearchSettingsGroupId.EqualsOrdinalIgnoreCase(context.GroupId)) + { + return null; + } + var user = _httpContextAccessor.HttpContext?.User; if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageLuceneIndexes)) @@ -57,18 +58,25 @@ public override async Task UpdateAsync(LuceneSettings section, B return null; } - if (context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) - { - var model = new LuceneSettingsViewModel(); + var model = new LuceneSettingsViewModel(); - await context.Updater.TryUpdateModelAsync(model, Prefix); + await context.Updater.TryUpdateModelAsync(model, Prefix); - section.SearchIndex = model.SearchIndex; - section.DefaultSearchFields = model.SearchFields?.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries); - section.AllowLuceneQueriesInSearch = model.AllowLuceneQueriesInSearch; - } + section.SearchIndex = model.SearchIndex; + section.DefaultSearchFields = model.SearchFields?.Split(_separator, StringSplitOptions.RemoveEmptyEntries); + section.AllowLuceneQueriesInSearch = model.AllowLuceneQueriesInSearch; return await EditAsync(section, context); } + + protected override void BuildPrefix(ISite model, string htmlFieldPrefix) + { + Prefix = typeof(LuceneSettings).Name; + + if (!string.IsNullOrEmpty(htmlFieldPrefix)) + { + Prefix = htmlFieldPrefix + "." + Prefix; + } + } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Startup.cs index 8d156f0ec1d..0561c8461d7 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Lucene/Startup.cs @@ -69,10 +69,7 @@ public override void ConfigureServices(IServiceCollection services) o.Analyzers.Add(new LuceneAnalyzer(LuceneSettings.StandardAnalyzer, new StandardAnalyzer(LuceneSettings.DefaultVersion)))); - services.AddScoped, LuceneSettingsDisplayDriver>(); services.AddScoped, LuceneQueryDisplayDriver>(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddLuceneQueries(); @@ -133,6 +130,7 @@ public class SearchStartup : StartupBase public override void ConfigureServices(IServiceCollection services) { services.AddScoped(); + services.AddScoped, LuceneSettingsDisplayDriver>(); } } @@ -178,4 +176,14 @@ public override void ConfigureServices(IServiceCollection services) services.AddShapeAttributes(); } } + + [RequireFeatures("OrchardCore.ContentTypes")] + public class ContentTypesStartup : StartupBase + { + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Search/AdminMenu.cs index 3e24843a234..cabc55e26ae 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search/AdminMenu.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search/AdminMenu.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.Localization; using OrchardCore.Navigation; -using OrchardCore.Search.Drivers; namespace OrchardCore.Search { @@ -25,12 +24,12 @@ public Task BuildNavigationAsync(string name, NavigationBuilder builder) builder .Add(S["Search"], NavigationConstants.AdminMenuSearchPosition, search => search .AddClass("search").Id("search") - .Add(S["Settings"], settings => settings - .Add(S["Search"], S["Search"].PrefixPosition(), entry => entry - .Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = SearchSettingsDisplayDriver.GroupId }) - .Permission(Permissions.ManageSearchSettings) - .LocalNav() - ))); + .Add(S["Settings"], S["Settings"].PrefixPosition(), settings => settings + .Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = SearchConstants.SearchSettingsGroupId }) + .Permission(Permissions.ManageSearchSettings) + .LocalNav() + ) + ); return Task.CompletedTask; } diff --git a/src/OrchardCore.Modules/OrchardCore.Search/Drivers/SearchSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search/Drivers/SearchSettingsDisplayDriver.cs index ce5e9085d17..d26a5b8edcd 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search/Drivers/SearchSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search/Drivers/SearchSettingsDisplayDriver.cs @@ -8,6 +8,7 @@ using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; +using OrchardCore.Modules; using OrchardCore.Search.Abstractions; using OrchardCore.Search.Models; using OrchardCore.Search.ViewModels; @@ -17,7 +18,9 @@ namespace OrchardCore.Search.Drivers { public class SearchSettingsDisplayDriver : SectionDisplayDriver { - public const string GroupId = "search"; + [Obsolete("This property should not be used. Instead use SearchConstants.SearchSettingsGroupId.")] + public const string GroupId = SearchConstants.SearchSettingsGroupId; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly IAuthorizationService _authorizationService; private readonly IServiceProvider _serviceProvider; @@ -50,11 +53,16 @@ public override async Task EditAsync(SearchSettings settings, Bu model.Placeholder = settings.Placeholder; model.PageTitle = settings.PageTitle; model.ProviderName = settings.ProviderName; - }).Location("Content:2").OnGroup(GroupId); + }).Location("Content:2").OnGroup(SearchConstants.SearchSettingsGroupId); } public override async Task UpdateAsync(SearchSettings section, BuildEditorContext context) { + if (!SearchConstants.SearchSettingsGroupId.EqualsOrdinalIgnoreCase(context.GroupId)) + { + return null; + } + var user = _httpContextAccessor.HttpContext?.User; if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageSearchSettings)) @@ -62,16 +70,13 @@ public override async Task UpdateAsync(SearchSettings section, B return null; } - if (context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) - { - var model = new SearchSettingsViewModel(); + var model = new SearchSettingsViewModel(); - if (await context.Updater.TryUpdateModelAsync(model, Prefix)) - { - section.ProviderName = model.ProviderName; - section.Placeholder = model.Placeholder; - section.PageTitle = model.PageTitle; - } + if (await context.Updater.TryUpdateModelAsync(model, Prefix)) + { + section.ProviderName = model.ProviderName; + section.Placeholder = model.Placeholder; + section.PageTitle = model.PageTitle; } return await EditAsync(section, context); diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj index 4aebb037984..8ec835476c2 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj @@ -3,9 +3,11 @@ Target for Modular OrchardCore CMS Application - $(OCCMSDescription) + + $(OCCMSDescription) - Converts the application into a modular OrchardCore CMS application with TheAdmin theme but without any front-end Themes. + Converts the application into a modular OrchardCore CMS application with TheAdmin theme but without any front-end Themes. + $(PackageTags) OrchardCoreCMS CMS @@ -98,8 +100,9 @@ - + + diff --git a/src/OrchardCore/OrchardCore.Indexing.Abstractions/DocumentIndex.cs b/src/OrchardCore/OrchardCore.Indexing.Abstractions/DocumentIndex.cs index 1735745285a..bc759112f0f 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Abstractions/DocumentIndex.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Abstractions/DocumentIndex.cs @@ -3,92 +3,77 @@ using Microsoft.AspNetCore.Html; -namespace OrchardCore.Indexing +namespace OrchardCore.Indexing; + +public class DocumentIndex(string contentItemId, string contentItemVersionId) { - public class DocumentIndex + public List Entries { get; } = []; + + public void Set(string name, string value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.Text, options)); + } + + public void Set(string name, IHtmlContent value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.Text, options)); + } + + public void Set(string name, DateTimeOffset? value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.DateTime, options)); + } + + public void Set(string name, int? value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.Integer, options)); + } + + public void Set(string name, bool? value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.Boolean, options)); + } + + public void Set(string name, double? value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.Number, options)); + } + + public void Set(string name, decimal? value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.Number, options)); + } + + public void Set(string name, GeoPoint value, DocumentIndexOptions options) + { + Entries.Add(new DocumentIndexEntry(name, value, Types.GeoPoint, options)); + } + + public string ContentItemId { get; } = contentItemId; + + public string ContentItemVersionId { get; } = contentItemVersionId; + + public enum Types + { + Integer, + Text, + DateTime, + Boolean, + Number, + GeoPoint + } + + public class GeoPoint + { + public decimal Longitude; + public decimal Latitude; + } + + public class DocumentIndexEntry(string name, object value, Types type, DocumentIndexOptions options) { - public DocumentIndex(string contentItemId, string contentItemVersionId) - { - ContentItemId = contentItemId; - ContentItemVersionId = contentItemVersionId; - } - - public List Entries { get; } = new List(); - - public void Set(string name, string value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.Text, options)); - } - - public void Set(string name, IHtmlContent value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.Text, options)); - } - - public void Set(string name, DateTimeOffset? value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.DateTime, options)); - } - - public void Set(string name, int? value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.Integer, options)); - } - - public void Set(string name, bool? value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.Boolean, options)); - } - - public void Set(string name, double? value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.Number, options)); - } - - public void Set(string name, decimal? value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.Number, options)); - } - - public void Set(string name, GeoPoint value, DocumentIndexOptions options) - { - Entries.Add(new DocumentIndexEntry(name, value, Types.GeoPoint, options)); - } - - public string ContentItemId { get; } - - public string ContentItemVersionId { get; } - - public enum Types - { - Integer, - Text, - DateTime, - Boolean, - Number, - GeoPoint - } - - public class GeoPoint - { - public decimal Longitude; - public decimal Latitude; - } - - public class DocumentIndexEntry - { - public DocumentIndexEntry(string name, object value, Types type, DocumentIndexOptions options) - { - Name = name; - Value = value; - Type = type; - Options = options; - } - - public string Name { get; } - public object Value { get; } - public Types Type { get; } - public DocumentIndexOptions Options { get; } - } + public string Name { get; } = name; + public object Value { get; } = value; + public Types Type { get; } = type; + public DocumentIndexOptions Options { get; } = options; } } diff --git a/src/OrchardCore/OrchardCore.Navigation.Core/OrchardCore.Navigation.Core.csproj b/src/OrchardCore/OrchardCore.Navigation.Core/OrchardCore.Navigation.Core.csproj index 8e08bd71cd1..db2cbe9d09c 100644 --- a/src/OrchardCore/OrchardCore.Navigation.Core/OrchardCore.Navigation.Core.csproj +++ b/src/OrchardCore/OrchardCore.Navigation.Core/OrchardCore.Navigation.Core.csproj @@ -3,9 +3,11 @@ OrchardCore Navigation Core - $(OCFrameworkDescription) + + $(OCFrameworkDescription) - Core Implementation for OrchardCore Navigation. + Core Implementation for OrchardCore Navigation. + $(PackageTags) OrchardCoreFramework @@ -15,6 +17,7 @@ + diff --git a/src/OrchardCore/OrchardCore.Navigation.Core/ShapeFactoryExtensions.cs b/src/OrchardCore/OrchardCore.Navigation.Core/ShapeFactoryExtensions.cs new file mode 100644 index 00000000000..7da9bb39906 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Navigation.Core/ShapeFactoryExtensions.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using OrchardCore.Navigation; + +namespace OrchardCore.DisplayManagement.Extensions; + +public static class ShapeFactoryExtensions +{ + public static ValueTask PagerAsync(this IShapeFactory _shapeFactory, Pager pager, int totalItemCount) + => _shapeFactory.CreateAsync("Pager", Arguments.From(new + { + pager.Page, + pager.PageSize, + TotalItemCount = totalItemCount, + })); + + public static async ValueTask PagerAsync(this IShapeFactory _shapeFactory, Pager pager, int totalItemCount, RouteData routeData) + { + dynamic pagerShape = await _shapeFactory.PagerAsync(pager, totalItemCount); + + if (routeData != null) + { + pagerShape.RouteData(routeData); + } + + return pagerShape; + } + + public static ValueTask PagerAsync(this IShapeFactory _shapeFactory, Pager pager, int totalItemCount, RouteValueDictionary routeValues) + => _shapeFactory.PagerAsync(pager, totalItemCount, routeValues == null ? null : new RouteData(routeValues)); +} diff --git a/src/OrchardCore/OrchardCore.Search.Abstractions/SearchConstants.cs b/src/OrchardCore/OrchardCore.Search.Abstractions/SearchConstants.cs new file mode 100644 index 00000000000..00263214ab5 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.Abstractions/SearchConstants.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Search; + +public class SearchConstants +{ + public const string SearchSettingsGroupId = "search"; +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexCreateContext.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexCreateContext.cs new file mode 100644 index 00000000000..ae86f124de3 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexCreateContext.cs @@ -0,0 +1,19 @@ +using System; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI; + +public class AzureAISearchIndexCreateContext +{ + public AzureAISearchIndexSettings Settings { get; } + + public string IndexFullName { get; } + + public AzureAISearchIndexCreateContext(AzureAISearchIndexSettings settings, string indexFullName) + { + ArgumentNullException.ThrowIfNull(nameof(settings)); + + Settings = settings; + IndexFullName = indexFullName; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexNamingHelper.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexNamingHelper.cs new file mode 100644 index 00000000000..6b254787cd8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexNamingHelper.cs @@ -0,0 +1,101 @@ +using System; + +namespace OrchardCore.Search.AzureAI; + +public class AzureAISearchIndexNamingHelper +{ + /// + /// Makes sure that the index names are compliant with Azure AI Search specifications. + /// . + /// + public static bool TryGetSafeIndexName(string indexName, out string safeName) + { + if (!TryGetSafePrefix(indexName, out var safePrefix) || safePrefix.Length < 2) + { + safeName = null; + + return false; + } + + if (safePrefix.Length > 128) + { + safeName = safePrefix[..128]; + } + else + { + safeName = safePrefix; + } + + return true; + } + + public static bool TryGetSafeFieldName(string fieldName, out string safeName) + { + if (string.IsNullOrEmpty(fieldName)) + { + safeName = null; + + return false; + } + + if (fieldName.StartsWith("azureSearch")) + { + fieldName = fieldName[11..]; + } + + while (fieldName.Length > 0 && !char.IsLetter(fieldName[0])) + { + fieldName = fieldName.Remove(0, 1); + } + + fieldName = fieldName.Replace(".", "__"); + + var validChars = Array.FindAll(fieldName.ToCharArray(), c => char.IsLetterOrDigit(c) || c == '_'); + + if (validChars.Length > 128) + { + safeName = new string(validChars[..128]); + + return true; + } + + if (validChars.Length > 0) + { + safeName = new string(validChars); + + return true; + } + + safeName = null; + + return false; + } + + public static bool TryGetSafePrefix(string indexName, out string safePrefix) + { + if (string.IsNullOrWhiteSpace(indexName)) + { + safePrefix = null; + + return false; + } + + indexName = indexName.ToLowerInvariant(); + + while (indexName.Length > 0 && !char.IsLetterOrDigit(indexName[0])) + { + indexName = indexName.Remove(0, 1); + } + + var validChars = Array.FindAll(indexName.ToCharArray(), c => char.IsLetterOrDigit(c) || c == '-'); + + safePrefix = new string(validChars); + + while (safePrefix.Contains("--")) + { + safePrefix = safePrefix.Replace("--", "-"); + } + + return true; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexPermissionHelper.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexPermissionHelper.cs new file mode 100644 index 00000000000..4bebbe67c74 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexPermissionHelper.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Search.AzureAI; + +public class AzureAISearchIndexPermissionHelper +{ + public static readonly Permission ManageAzureAISearchIndexes = new("ManageAzureAISearchIndexes", "Manage Azure AI Search Indexes"); + + private static readonly Permission _indexPermissionTemplate = new("QueryAzureAISearchIndex_{0}", "Query Azure AI Search '{0}' Index", new[] { ManageAzureAISearchIndexes }); + + private static readonly Dictionary _permissions = []; + + public static Permission GetPermission(string indexName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(indexName, nameof(indexName)); + + if (!_permissions.TryGetValue(indexName, out var permission)) + { + permission = new Permission( + string.Format(_indexPermissionTemplate.Name, indexName), + string.Format(_indexPermissionTemplate.Description, indexName), + _indexPermissionTemplate.ImpliedBy + ); + + _permissions.Add(indexName, permission); + } + + return permission; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexRebuildContext.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexRebuildContext.cs new file mode 100644 index 00000000000..18fdb89ce30 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexRebuildContext.cs @@ -0,0 +1,19 @@ +using System; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI; + +public class AzureAISearchIndexRebuildContext +{ + public AzureAISearchIndexSettings Settings { get; } + + public string IndexFullName { get; } + + public AzureAISearchIndexRebuildContext(AzureAISearchIndexSettings settings, string indexFullName) + { + ArgumentNullException.ThrowIfNull(nameof(settings)); + + Settings = settings; + IndexFullName = indexFullName; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexRemoveContext.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexRemoveContext.cs new file mode 100644 index 00000000000..fb7b1334ebf --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/AzureAISearchIndexRemoveContext.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Search.AzureAI; + +public class AzureAISearchIndexRemoveContext(string indexName, string indexFullName) +{ + public string IndexName { get; } = indexName; + + public string IndexFullName { get; } = indexFullName; +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexDeploymentSource.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexDeploymentSource.cs new file mode 100644 index 00000000000..7e2324c9145 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexDeploymentSource.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OrchardCore.Deployment; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Services; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchIndexDeploymentSource(AzureAISearchIndexSettingsService indexSettingsService) : IDeploymentSource +{ + private readonly AzureAISearchIndexSettingsService _indexSettingsService = indexSettingsService; + + public async Task ProcessDeploymentStepAsync(DeploymentStep step, DeploymentPlanResult result) + { + if (step is not AzureAISearchIndexDeploymentStep settingsStep) + { + return; + } + + var indexSettings = await _indexSettingsService.GetSettingsAsync(); + + var data = new JArray(); + var indicesToAdd = settingsStep.IncludeAll ? indexSettings.Select(x => x.IndexName).ToArray() : settingsStep.IndexNames; + + foreach (var index in indexSettings) + { + if (indicesToAdd.Contains(index.IndexName)) + { + var indexSettingsDict = new Dictionary + { + { index.IndexName, index }, + }; + + data.Add(JObject.FromObject(indexSettingsDict)); + } + } + + result.Steps.Add(new JObject( + new JProperty("name", nameof(AzureAISearchIndexSettings)), + new JProperty("Indices", data) + )); + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexDeploymentStep.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexDeploymentStep.cs new file mode 100644 index 00000000000..0f03364f129 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexDeploymentStep.cs @@ -0,0 +1,15 @@ +using OrchardCore.Deployment; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchIndexDeploymentStep : DeploymentStep +{ + public AzureAISearchIndexDeploymentStep() + { + Name = "AzureAISearchIndexSettings"; + } + + public bool IncludeAll { get; set; } = true; + + public string[] IndexNames { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexRebuildDeploymentSource.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexRebuildDeploymentSource.cs new file mode 100644 index 00000000000..9f7ce3ea7ed --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexRebuildDeploymentSource.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OrchardCore.Deployment; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchIndexRebuildDeploymentSource : IDeploymentSource +{ + public const string Name = "azureai-index-rebuild"; + + public Task ProcessDeploymentStepAsync(DeploymentStep step, DeploymentPlanResult result) + { + if (step is not AzureAISearchIndexRebuildDeploymentStep rebuildStep) + { + return Task.CompletedTask; + } + + var indicesToRebuild = rebuildStep.IncludeAll ? [] : rebuildStep.Indices; + + result.Steps.Add(new JObject( + new JProperty("name", Name), + new JProperty("includeAll", rebuildStep.IncludeAll), + new JProperty("Indices", new JArray(indicesToRebuild)) + )); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexRebuildDeploymentStep.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexRebuildDeploymentStep.cs new file mode 100644 index 00000000000..d092f1f6f85 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexRebuildDeploymentStep.cs @@ -0,0 +1,15 @@ +using OrchardCore.Deployment; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchIndexRebuildDeploymentStep : DeploymentStep +{ + public AzureAISearchIndexRebuildDeploymentStep() + { + Name = "AzureAISearchIndexRebuild"; + } + + public bool IncludeAll { get; set; } + + public string[] Indices { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexResetDeploymentSource.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexResetDeploymentSource.cs new file mode 100644 index 00000000000..1c2f2f4f50e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexResetDeploymentSource.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OrchardCore.Deployment; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchIndexResetDeploymentSource : IDeploymentSource +{ + public const string Name = "azureai-index-reset"; + + public Task ProcessDeploymentStepAsync(DeploymentStep step, DeploymentPlanResult result) + { + if (step is not AzureAISearchIndexResetDeploymentStep resetStep) + { + return Task.CompletedTask; + } + + var indicesToReset = resetStep.IncludeAll ? [] : resetStep.Indices; + + result.Steps.Add(new JObject( + new JProperty("name", Name), + new JProperty("includeAll", resetStep.IncludeAll), + new JProperty("Indices", new JArray(indicesToReset)) + )); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexResetDeploymentStep.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexResetDeploymentStep.cs new file mode 100644 index 00000000000..0f26d9bd3c5 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchIndexResetDeploymentStep.cs @@ -0,0 +1,15 @@ +using OrchardCore.Deployment; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchIndexResetDeploymentStep : DeploymentStep +{ + public AzureAISearchIndexResetDeploymentStep() + { + Name = "AzureAISearchIndexReset"; + } + + public bool IncludeAll { get; set; } = false; + + public string[] Indices { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchSettingsDeploymentSource.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchSettingsDeploymentSource.cs new file mode 100644 index 00000000000..f4c5fbfa20a --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchSettingsDeploymentSource.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OrchardCore.Deployment; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Settings; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchSettingsDeploymentSource(ISiteService siteService) : IDeploymentSource +{ + private readonly ISiteService _siteService = siteService; + + public async Task ProcessDeploymentStepAsync(DeploymentStep step, DeploymentPlanResult result) + { + var settingsStep = step as AzureAISearchSettingsDeploymentStep; + + if (settingsStep == null) + { + return; + } + + var site = await _siteService.GetSiteSettingsAsync(); + + var settings = site.As(); + + result.Steps.Add(new JObject( + new JProperty("name", "Settings"), + new JProperty(nameof(AzureAISearchSettings), JObject.FromObject(settings)) + )); + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchSettingsDeploymentStep.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchSettingsDeploymentStep.cs new file mode 100644 index 00000000000..fd72e12b3e9 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Deployment/AzureAISearchSettingsDeploymentStep.cs @@ -0,0 +1,12 @@ +using OrchardCore.Deployment; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI.Deployment; + +public class AzureAISearchSettingsDeploymentStep : DeploymentStep +{ + public AzureAISearchSettingsDeploymentStep() + { + Name = nameof(AzureAISearchSettings); + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Extensions/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000000..00dde35c723 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Recipes; +using OrchardCore.Search.AzureAI.Handlers; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Recipes; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Search.AzureAI; + +public static class ServiceCollectionExtensions +{ + public static bool TryAddAzureAISearchServices(this IServiceCollection services, IShellConfiguration configuration, ILogger logger) + { + var section = configuration.GetSection("OrchardCore_AzureAISearch"); + + var options = section.Get(); + + if (string.IsNullOrWhiteSpace(options?.Endpoint) || string.IsNullOrWhiteSpace(options?.Credential?.Key)) + { + logger.LogError("Azure AI Search module is enabled. However, the connection settings are not provided in configuration file.."); + + return false; + } + + services.Configure(opts => + { + opts.Endpoint = options.Endpoint; + opts.Credential = options.Credential; + opts.IndexesPrefix = options.IndexesPrefix; + opts.Analyzers = options.Analyzers == null || options.Analyzers.Length == 0 + ? AzureAISearchDefaultOptions.DefaultAnalyzers + : options.Analyzers; + opts.SetConfigurationExists(true); + }); + + services.AddAzureClients(builder => + { + builder.AddSearchIndexClient(section); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddRecipeExecutionStep(); + services.AddRecipeExecutionStep(); + services.AddRecipeExecutionStep(); + + return true; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchAuthorizationHandler.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchAuthorizationHandler.cs new file mode 100644 index 00000000000..5a309222cc5 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchAuthorizationHandler.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Search.Abstractions; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Security; +using OrchardCore.Settings; + +namespace OrchardCore.Search.AzureAI.Handlers; + +public class AzureAISearchAuthorizationHandler(IServiceProvider serviceProvider) : AuthorizationHandler +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + + private IAuthorizationService _authorizationService; + private ISiteService _siteService; + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement) + { + if (context.HasSucceeded) + { + // This handler is not revoking any pre-existing grants. + return; + } + + if (context.Resource is not SearchPermissionParameters parameters) + { + return; + } + + if (AzureAISearchService.Key != parameters.ServiceName) + { + // Only validate if Azure AI Search is requested. + return; + } + + var indexName = await GetIndexNameAsync(parameters); + + if (!string.IsNullOrEmpty(indexName)) + { + _authorizationService ??= _serviceProvider?.GetService(); + + var permission = AzureAISearchIndexPermissionHelper.GetPermission(indexName); + + if (await _authorizationService.AuthorizeAsync(context.User, permission)) + { + context.Succeed(requirement); + + return; + } + } + } + + private async Task GetIndexNameAsync(SearchPermissionParameters parameters) + { + if (!string.IsNullOrWhiteSpace(parameters.IndexName)) + { + return parameters.IndexName.Trim(); + } + + _siteService ??= _serviceProvider.GetRequiredService(); + var siteSettings = await _siteService.GetSiteSettingsAsync(); + + return siteSettings.As().SearchIndex; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexingContentHandler.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexingContentHandler.cs new file mode 100644 index 00000000000..519bad65a26 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexingContentHandler.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.ContentLocalization; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.ContentPreview; +using OrchardCore.Environment.Shell.Scope; +using OrchardCore.Indexing; +using OrchardCore.Modules; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Services; + +namespace OrchardCore.Search.AzureAI.Handlers; + +public class AzureAISearchIndexingContentHandler(IHttpContextAccessor httpContextAccessor) : ContentHandlerBase +{ + private readonly List _contexts = []; + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + + public override Task PublishedAsync(PublishContentContext context) => AddContextAsync(context); + public override Task CreatedAsync(CreateContentContext context) => AddContextAsync(context); + public override Task UpdatedAsync(UpdateContentContext context) => AddContextAsync(context); + public override Task RemovedAsync(RemoveContentContext context) => AddContextAsync(context); + public override Task UnpublishedAsync(PublishContentContext context) => AddContextAsync(context); + + private Task AddContextAsync(ContentContextBase context) + { + // Do not index a preview content item. + if (_httpContextAccessor.HttpContext?.Features.Get()?.Previewing == true) + { + return Task.CompletedTask; + } + + if (context.ContentItem.Id == 0) + { + // Ignore that case, when Update is called on a content item which has not been created yet. + return Task.CompletedTask; + } + + if (_contexts.Count == 0) + { + var contexts = _contexts; + + // Using a local variable prevents the lambda from holding a ref on this scoped service. + ShellScope.AddDeferredTask(scope => IndexingAsync(scope, contexts)); + } + + _contexts.Add(context); + + return Task.CompletedTask; + } + + private static async Task IndexingAsync(ShellScope scope, IEnumerable contexts) + { + var services = scope.ServiceProvider; + var contentManager = services.GetRequiredService(); + var contentItemIndexHandlers = services.GetServices(); + + var indexSettingsService = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + var indexDocumentManager = services.GetRequiredService(); + + // Multiple items may have been updated in the same scope, e.g through a recipe. + var contextsGroupById = contexts.GroupBy(c => c.ContentItem.ContentItemId); + + // Get all contexts for each content item id. + foreach (var ContextsById in contextsGroupById) + { + // Only process the last context. + var context = ContextsById.Last(); + + ContentItem published = null, latest = null; + bool publishedLoaded = false, latestLoaded = false; + + foreach (var indexSettings in await indexSettingsService.GetSettingsAsync()) + { + var cultureAspect = await contentManager.PopulateAspectAsync(context.ContentItem); + var culture = cultureAspect.HasCulture ? cultureAspect.Culture.Name : null; + var ignoreIndexedCulture = indexSettings.Culture != "any" && culture != indexSettings.Culture; + + if (indexSettings.IndexedContentTypes.Contains(context.ContentItem.ContentType) && !ignoreIndexedCulture) + { + if (!indexSettings.IndexLatest && !publishedLoaded) + { + publishedLoaded = true; + published = await contentManager.GetAsync(context.ContentItem.ContentItemId, VersionOptions.Published); + } + + if (indexSettings.IndexLatest && !latestLoaded) + { + latestLoaded = true; + latest = await contentManager.GetAsync(context.ContentItem.ContentItemId, VersionOptions.Latest); + } + + var contentItem = !indexSettings.IndexLatest ? published : latest; + + if (contentItem == null) + { + await indexDocumentManager.DeleteDocumentsAsync(indexSettings.IndexName, new string[] { context.ContentItem.ContentItemId }); + } + else + { + var index = new DocumentIndex(contentItem.ContentItemId, contentItem.ContentItemVersionId); + var buildIndexContext = new BuildIndexContext(index, contentItem, [contentItem.ContentType], new AzureAISearchContentIndexSettings()); + await contentItemIndexHandlers.InvokeAsync(x => x.BuildIndexAsync(buildIndexContext), logger); + await indexDocumentManager.MergeOrUploadDocumentsAsync(indexSettings.IndexName, new DocumentIndex[] { buildIndexContext.DocumentIndex }, indexSettings); + } + } + } + } + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/IAzureAISearchDocumentEvents.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/IAzureAISearchDocumentEvents.cs new file mode 100644 index 00000000000..9b768facb2a --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/IAzureAISearchDocumentEvents.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.Search.Documents.Models; + +namespace OrchardCore.Search.AzureAI; + +public interface IAzureAISearchDocumentEvents +{ + Task UploadingAsync(IEnumerable docs); + + Task MergingOrUploadingAsync(IEnumerable docs); +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/IAzureAISearchIndexEvents.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/IAzureAISearchIndexEvents.cs new file mode 100644 index 00000000000..8076ca1b51e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/IAzureAISearchIndexEvents.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; + +namespace OrchardCore.Search.AzureAI; + +public interface IAzureAISearchIndexEvents +{ + /// + /// This event is invoked before removing an existing that already exists. + /// + /// + /// + Task RemovingAsync(AzureAISearchIndexRemoveContext context); + + /// + /// This event is invoked after the index is removed. + /// + /// + /// + Task RemovedAsync(AzureAISearchIndexRemoveContext context); + + /// + /// This event is invoked before the index is rebuilt. + /// If the rebuild deletes the index and create a new one, other events like Removing, Removed, Creating, or Created should not be invoked. + /// + /// + /// + Task RebuildingAsync(AzureAISearchIndexRebuildContext context); + + Task RebuiltAsync(AzureAISearchIndexRebuildContext context); + + Task CreatingAsync(AzureAISearchIndexCreateContext context); + + Task CreatedAsync(AzureAISearchIndexCreateContext context); +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchContentIndexSettings.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchContentIndexSettings.cs new file mode 100644 index 00000000000..16d6cab9b28 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchContentIndexSettings.cs @@ -0,0 +1,15 @@ +using OrchardCore.Indexing; + +namespace OrchardCore.Search.AzureAI.Models; + +public class AzureAISearchContentIndexSettings : IContentIndexSettings +{ + public bool Included { get; set; } + + public DocumentIndexOptions ToOptions() + { + var options = DocumentIndexOptions.None; + + return options; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultOptions.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultOptions.cs new file mode 100644 index 00000000000..8ec1a2c2b17 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultOptions.cs @@ -0,0 +1,122 @@ +using Azure; +using Azure.Search.Documents.Indexes.Models; + +namespace OrchardCore.Search.AzureAI.Models; + +public class AzureAISearchDefaultOptions +{ + public const string DefaultAnalyzer = LexicalAnalyzerName.Values.StandardLucene; + + public readonly static string[] DefaultAnalyzers = + [ + LexicalAnalyzerName.Values.ArMicrosoft, + LexicalAnalyzerName.Values.ArLucene, + LexicalAnalyzerName.Values.HyLucene, + LexicalAnalyzerName.Values.BnMicrosoft, + LexicalAnalyzerName.Values.EuLucene, + LexicalAnalyzerName.Values.BgMicrosoft, + LexicalAnalyzerName.Values.BgLucene, + LexicalAnalyzerName.Values.CaMicrosoft, + LexicalAnalyzerName.Values.CaLucene, + LexicalAnalyzerName.Values.ZhHansMicrosoft, + LexicalAnalyzerName.Values.ZhHansLucene, + LexicalAnalyzerName.Values.ZhHantMicrosoft, + LexicalAnalyzerName.Values.ZhHantLucene, + LexicalAnalyzerName.Values.HrMicrosoft, + LexicalAnalyzerName.Values.CsMicrosoft, + LexicalAnalyzerName.Values.CsLucene, + LexicalAnalyzerName.Values.DaMicrosoft, + LexicalAnalyzerName.Values.DaLucene, + LexicalAnalyzerName.Values.NlMicrosoft, + LexicalAnalyzerName.Values.NlLucene, + LexicalAnalyzerName.Values.EnMicrosoft, + LexicalAnalyzerName.Values.EnLucene, + LexicalAnalyzerName.Values.EtMicrosoft, + LexicalAnalyzerName.Values.FiMicrosoft, + LexicalAnalyzerName.Values.FiLucene, + LexicalAnalyzerName.Values.FrMicrosoft, + LexicalAnalyzerName.Values.FrLucene, + LexicalAnalyzerName.Values.GlLucene, + LexicalAnalyzerName.Values.DeMicrosoft, + LexicalAnalyzerName.Values.DeLucene, + LexicalAnalyzerName.Values.ElMicrosoft, + LexicalAnalyzerName.Values.ElLucene, + LexicalAnalyzerName.Values.GuMicrosoft, + LexicalAnalyzerName.Values.HeMicrosoft, + LexicalAnalyzerName.Values.HiMicrosoft, + LexicalAnalyzerName.Values.HiLucene, + LexicalAnalyzerName.Values.HuMicrosoft, + LexicalAnalyzerName.Values.HuLucene, + LexicalAnalyzerName.Values.IsMicrosoft, + LexicalAnalyzerName.Values.IdMicrosoft, + LexicalAnalyzerName.Values.GaLucene, + LexicalAnalyzerName.Values.ItMicrosoft, + LexicalAnalyzerName.Values.ItLucene, + LexicalAnalyzerName.Values.JaMicrosoft, + LexicalAnalyzerName.Values.JaLucene, + LexicalAnalyzerName.Values.KnMicrosoft, + LexicalAnalyzerName.Values.KoMicrosoft, + LexicalAnalyzerName.Values.KoLucene, + LexicalAnalyzerName.Values.LvMicrosoft, + LexicalAnalyzerName.Values.LvLucene, + LexicalAnalyzerName.Values.LtMicrosoft, + LexicalAnalyzerName.Values.MlMicrosoft, + LexicalAnalyzerName.Values.MsMicrosoft, + LexicalAnalyzerName.Values.MrMicrosoft, + LexicalAnalyzerName.Values.NbMicrosoft, + LexicalAnalyzerName.Values.NoLucene, + LexicalAnalyzerName.Values.FaLucene, + LexicalAnalyzerName.Values.PlMicrosoft, + LexicalAnalyzerName.Values.PlLucene, + LexicalAnalyzerName.Values.PtBrMicrosoft, + LexicalAnalyzerName.Values.PtBrLucene, + LexicalAnalyzerName.Values.PtPtMicrosoft, + LexicalAnalyzerName.Values.PtPtLucene, + LexicalAnalyzerName.Values.PaMicrosoft, + LexicalAnalyzerName.Values.RoMicrosoft, + LexicalAnalyzerName.Values.RoLucene, + LexicalAnalyzerName.Values.RuMicrosoft, + LexicalAnalyzerName.Values.RuLucene, + LexicalAnalyzerName.Values.SrCyrillicMicrosoft, + LexicalAnalyzerName.Values.SrLatinMicrosoft, + LexicalAnalyzerName.Values.SkMicrosoft, + LexicalAnalyzerName.Values.SlMicrosoft, + LexicalAnalyzerName.Values.EsMicrosoft, + LexicalAnalyzerName.Values.EsLucene, + LexicalAnalyzerName.Values.SvMicrosoft, + LexicalAnalyzerName.Values.SvLucene, + LexicalAnalyzerName.Values.TaMicrosoft, + LexicalAnalyzerName.Values.TeMicrosoft, + LexicalAnalyzerName.Values.ThMicrosoft, + LexicalAnalyzerName.Values.ThLucene, + LexicalAnalyzerName.Values.TrMicrosoft, + LexicalAnalyzerName.Values.TrLucene, + LexicalAnalyzerName.Values.UkMicrosoft, + LexicalAnalyzerName.Values.UrMicrosoft, + LexicalAnalyzerName.Values.ViMicrosoft, + LexicalAnalyzerName.Values.StandardLucene, + LexicalAnalyzerName.Values.StandardAsciiFoldingLucene, + LexicalAnalyzerName.Values.Keyword, + LexicalAnalyzerName.Values.Pattern, + LexicalAnalyzerName.Values.Simple, + LexicalAnalyzerName.Values.Stop, + LexicalAnalyzerName.Values.Whitespace, + ]; + + public string Endpoint { get; set; } + + public AzureKeyCredential Credential { get; set; } + + // Environment prefix for all of the indexes. + public string IndexesPrefix { get; set; } + + public string[] Analyzers { get; set; } + + private bool _configurationExists; + + public void SetConfigurationExists(bool configurationExists) + => _configurationExists = configurationExists; + + public bool IsConfigurationExists() + => _configurationExists; +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexMap.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexMap.cs new file mode 100644 index 00000000000..5337fabdc93 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexMap.cs @@ -0,0 +1,35 @@ +using System; +using OrchardCore.Indexing; +using static OrchardCore.Indexing.DocumentIndex; + +namespace OrchardCore.Search.AzureAI.Models; + +public class AzureAISearchIndexMap +{ + public string IndexingKey { get; set; } + + public string AzureFieldKey { get; set; } + + public Types Type { get; set; } + + public DocumentIndexOptions Options { get; set; } + + public AzureAISearchIndexMap() + { + + } + + public AzureAISearchIndexMap(string azureFieldKey, Types type) + { + ArgumentException.ThrowIfNullOrEmpty(azureFieldKey, nameof(azureFieldKey)); + + AzureFieldKey = azureFieldKey; + Type = type; + } + + public AzureAISearchIndexMap(string azureFieldKey, Types type, DocumentIndexOptions options) + : this(azureFieldKey, type) + { + Options = options; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexSettings.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexSettings.cs new file mode 100644 index 00000000000..4f516c54195 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexSettings.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace OrchardCore.Search.AzureAI.Models; + +public class AzureAISearchIndexSettings +{ + [JsonIgnore] + public string IndexName { get; set; } + + [JsonIgnore] + public string IndexFullName { get; set; } + + public string AnalyzerName { get; set; } + + public string QueryAnalyzerName { get; set; } + + public bool IndexLatest { get; set; } + + public string[] IndexedContentTypes { get; set; } + + public string Culture { get; set; } + + public IList IndexMappings { get; set; } + + private long _lastTaskId = 0; + + public long GetLastTaskId() + => _lastTaskId; + + public void SetLastTaskId(long lastTaskId) + => _lastTaskId = lastTaskId; + + // The dictionary key should be indexingKey Not AzureFieldKey. + public Dictionary GetMaps() + => IndexMappings?.ToDictionary(x => x.IndexingKey) ?? []; +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexSettingsDocument.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexSettingsDocument.cs new file mode 100644 index 00000000000..59eb201a6fb --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchIndexSettingsDocument.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using OrchardCore.Data.Documents; + +namespace OrchardCore.Search.AzureAI.Models; + +public class AzureAISearchIndexSettingsDocument : Document +{ + public Dictionary IndexSettings { get; set; } = []; +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchSettings.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchSettings.cs new file mode 100644 index 00000000000..fda7892534d --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchSettings.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Search.AzureAI.Models; + +public class AzureAISearchSettings +{ + public string[] DefaultSearchFields { get; set; } + + public string SearchIndex { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/OrchardCore.Search.AzureAI.Core.csproj b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/OrchardCore.Search.AzureAI.Core.csproj new file mode 100644 index 00000000000..fb201fa05b5 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/OrchardCore.Search.AzureAI.Core.csproj @@ -0,0 +1,37 @@ + + + + OrchardCore.Search.AzureAI + + OrchardCore Azure AI Search Core + + $(OCCMSDescription) + + Core Implementation for Azure AI Search module. + + $(PackageTags) OrchardCoreCMS + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Permissions.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Permissions.cs new file mode 100644 index 00000000000..21e7204c7c4 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Permissions.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using OrchardCore.Search.AzureAI.Services; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Search.AzureAI; + +public class Permissions(AzureAISearchIndexSettingsService indexSettingsService) : IPermissionProvider +{ + private readonly AzureAISearchIndexSettingsService _indexSettingsService = indexSettingsService; + + public async Task> GetPermissionsAsync() + { + var permissions = new List() + { + AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes, + }; + + var indexSettings = await _indexSettingsService.GetSettingsAsync(); + + foreach (var index in indexSettings) + { + permissions.Add(AzureAISearchIndexPermissionHelper.GetPermission(index.IndexName)); + } + + return permissions; + } + + public IEnumerable GetDefaultStereotypes() + => _defaultStereotypes; + + private static readonly IEnumerable _defaultStereotypes = new[] + { + new PermissionStereotype + { + Name = "Administrator", + Permissions = new[] + { + AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes, + }, + }, + }; +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexRebuildStep.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexRebuildStep.cs new file mode 100644 index 00000000000..faf6228e43c --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexRebuildStep.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.BackgroundJobs; +using OrchardCore.Recipes.Models; +using OrchardCore.Recipes.Services; +using OrchardCore.Search.AzureAI.Deployment; +using OrchardCore.Search.AzureAI.Services; + +namespace OrchardCore.Search.AzureAI.Recipes; + +public class AzureAISearchIndexRebuildStep : IRecipeStepHandler +{ + public async Task ExecuteAsync(RecipeExecutionContext context) + { + if (!string.Equals(context.Name, AzureAISearchIndexRebuildDeploymentSource.Name, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var model = context.Step.ToObject(); + + if (model == null) + { + return; + } + + if (!model.IncludeAll && (model.Indices == null || model.Indices.Length == 0)) + { + return; + } + + await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync(AzureAISearchIndexRebuildDeploymentSource.Name, async scope => + { + var searchIndexingService = scope.ServiceProvider.GetService(); + var indexSettingsService = scope.ServiceProvider.GetService(); + var indexDocumentManager = scope.ServiceProvider.GetRequiredService(); + var indexManager = scope.ServiceProvider.GetRequiredService(); + + var indexSettings = model.IncludeAll + ? await indexSettingsService.GetSettingsAsync() + : (await indexSettingsService.GetSettingsAsync()).Where(x => model.Indices.Contains(x.IndexName, StringComparer.OrdinalIgnoreCase)); + + foreach (var settings in indexSettings) + { + settings.SetLastTaskId(0); + settings.IndexMappings = await indexDocumentManager.GetMappingsAsync(settings.IndexedContentTypes); + await indexSettingsService.UpdateAsync(settings); + + await indexManager.RebuildAsync(settings); + } + + await searchIndexingService.ProcessContentItemsAsync(indexSettings.Select(settings => settings.IndexName).ToArray()); + }); + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexResetStep.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexResetStep.cs new file mode 100644 index 00000000000..a9c63bdacec --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexResetStep.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.BackgroundJobs; +using OrchardCore.Recipes.Models; +using OrchardCore.Recipes.Services; +using OrchardCore.Search.AzureAI.Deployment; +using OrchardCore.Search.AzureAI.Services; + +namespace OrchardCore.Search.AzureAI.Recipes; + +public class AzureAISearchIndexResetStep : IRecipeStepHandler +{ + public async Task ExecuteAsync(RecipeExecutionContext context) + { + if (!string.Equals(context.Name, AzureAISearchIndexResetDeploymentSource.Name, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var model = context.Step.ToObject(); + + if (model == null) + { + return; + } + + if (!model.IncludeAll && (model.Indices == null || model.Indices.Length == 0)) + { + return; + } + + await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync(AzureAISearchIndexRebuildDeploymentSource.Name, async scope => + { + var searchIndexingService = scope.ServiceProvider.GetService(); + var indexSettingsService = scope.ServiceProvider.GetService(); + var indexManager = scope.ServiceProvider.GetRequiredService(); + var indexDocumentManager = scope.ServiceProvider.GetRequiredService(); + + var indexSettings = model.IncludeAll + ? await indexSettingsService.GetSettingsAsync() + : (await indexSettingsService.GetSettingsAsync()).Where(x => model.Indices.Contains(x.IndexName, StringComparer.OrdinalIgnoreCase)); + + foreach (var settings in indexSettings) + { + settings.SetLastTaskId(0); + settings.IndexMappings = await indexDocumentManager.GetMappingsAsync(settings.IndexedContentTypes); + + if (!await indexManager.ExistsAsync(settings.IndexName)) + { + settings.IndexFullName = indexManager.GetFullIndexName(settings.IndexName); + + await indexManager.CreateAsync(settings); + } + + await indexSettingsService.UpdateAsync(settings); + } + + await searchIndexingService.ProcessContentItemsAsync(indexSettings.Select(settings => settings.IndexName).ToArray()); + }); + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexSettingsStep.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexSettingsStep.cs new file mode 100644 index 00000000000..fc86ec48e3f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Recipes/AzureAISearchIndexSettingsStep.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OrchardCore.Recipes.Models; +using OrchardCore.Recipes.Services; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Search.AzureAI.Services; + +namespace OrchardCore.Search.AzureAI.Recipes; + +public class AzureAISearchIndexSettingsStep( + AzureAISearchIndexManager indexManager, + ILogger logger + ) : IRecipeStepHandler +{ + private readonly AzureAISearchIndexManager _indexManager = indexManager; + private readonly ILogger _logger = logger; + + public async Task ExecuteAsync(RecipeExecutionContext context) + { + if (!string.Equals(context.Name, nameof(AzureAISearchIndexSettings), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (context.Step["Indices"] is null) + { + return; + } + + foreach (var index in context.Step["Indices"]) + { + var indexSettings = index.ToObject(); + + if (!AzureAISearchIndexNamingHelper.TryGetSafeIndexName(indexSettings.IndexName, out var indexName)) + { + _logger.LogError("Invalid index name was provided in the recipe step. IndexName: {indexName}.", indexSettings.IndexName); + + continue; + } + + indexSettings.IndexName = indexName; + + if (!await _indexManager.ExistsAsync(indexSettings.IndexName)) + { + if (string.IsNullOrWhiteSpace(indexSettings.AnalyzerName)) + { + indexSettings.AnalyzerName = AzureAISearchDefaultOptions.DefaultAnalyzer; + } + + if (string.IsNullOrEmpty(indexSettings.QueryAnalyzerName)) + { + indexSettings.QueryAnalyzerName = indexSettings.AnalyzerName; + } + + if (indexSettings.IndexedContentTypes == null || indexSettings.IndexedContentTypes.Length == 0) + { + _logger.LogError("No {fieldName} were provided in the recipe step. IndexName: {indexName}.", nameof(indexSettings.IndexedContentTypes), indexSettings.IndexName); + + continue; + } + + await _indexManager.CreateAsync(indexSettings); + } + } + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexDocumentManager.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexDocumentManager.cs new file mode 100644 index 00000000000..bdee7a23ee2 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexDocumentManager.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.Search.Documents; +using Azure.Search.Documents.Models; +using Microsoft.Extensions.Logging; +using OrchardCore.ContentManagement; +using OrchardCore.Contents.Indexing; +using OrchardCore.Indexing; +using OrchardCore.Modules; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI.Services; + +public class AzureAIIndexDocumentManager( + SearchClientFactory searchClientFactory, + AzureAISearchIndexManager indexManager, + IIndexingTaskManager indexingTaskManager, + IContentManager contentManager, + IEnumerable documentEvents, + IEnumerable contentItemIndexHandlers, + ILogger logger) +{ + private readonly SearchClientFactory _searchClientFactory = searchClientFactory; + private readonly AzureAISearchIndexManager _indexManager = indexManager; + private readonly IIndexingTaskManager _indexingTaskManager = indexingTaskManager; + private readonly IContentManager _contentManager = contentManager; + private readonly IEnumerable _documentEvents = documentEvents; + private readonly IEnumerable _contentItemIndexHandlers = contentItemIndexHandlers; + private readonly ILogger _logger = logger; + + public async Task> SearchAsync(string indexName, string searchText, SearchOptions searchOptions = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(searchText, nameof(searchText)); + + var client = GetSearchClient(indexName); + + var searchResult = await client.SearchAsync(searchText, searchOptions); + var docs = new List(); + await foreach (var doc in searchResult.Value.GetResultsAsync()) + { + docs.Add(doc.Document); + } + + return docs; + } + + public async Task SearchAsync(string indexName, string searchText, Action action, SearchOptions searchOptions = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(searchText, nameof(searchText)); + ArgumentNullException.ThrowIfNull(action); + + var client = GetSearchClient(indexName); + + var searchResult = await client.SearchAsync(searchText, searchOptions); + var counter = 0L; + + await foreach (var doc in searchResult.Value.GetResultsAsync()) + { + action(doc.Document); + counter++; + } + + return searchResult.Value.TotalCount ?? counter; + } + + public async Task DeleteDocumentsAsync(string indexName, IEnumerable contentItemIds) + { + ArgumentNullException.ThrowIfNull(contentItemIds, nameof(contentItemIds)); + + try + { + var client = GetSearchClient(indexName); + + await client.DeleteDocumentsAsync(IndexingConstants.ContentItemIdKey, contentItemIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to delete documents from Azure AI Search Settings"); + } + } + + public async Task DeleteAllDocumentsAsync(string indexName) + { + var contentItemIds = new List(); + + try + { + var searchOptions = new SearchOptions(); + searchOptions.Select.Add(IndexingConstants.ContentItemIdKey); + + // Match-all documents. + var totalRecords = SearchAsync(indexName, "*", (doc) => + { + if (doc.TryGetValue(IndexingConstants.ContentItemIdKey, out var contentItemId)) + { + contentItemIds.Add(contentItemId.ToString()); + } + }, searchOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to search documents using Azure AI Search"); + } + + if (contentItemIds.Count == 0) + { + return; + } + + await DeleteDocumentsAsync(indexName, contentItemIds); + } + + public async Task MergeOrUploadDocumentsAsync(string indexName, IList indexDocuments, AzureAISearchIndexSettings indexSettings) + { + ArgumentNullException.ThrowIfNull(indexDocuments, nameof(indexDocuments)); + ArgumentNullException.ThrowIfNull(indexSettings, nameof(indexSettings)); + + if (indexDocuments.Count == 0) + { + return true; + } + + try + { + var client = GetSearchClient(indexName); + + // The dictionary key should be indexingKey Not AzureFieldKey. + var maps = indexSettings.GetMaps(); + + var pages = indexDocuments.PagesOf(32000); + + foreach (var page in pages) + { + var docs = CreateSearchDocuments(page, maps); + + await _documentEvents.InvokeAsync(handler => handler.MergingOrUploadingAsync(docs), _logger); + + await client.MergeOrUploadDocumentsAsync(docs); + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to delete documents from Azure AI Search Settings"); + } + + return false; + } + + public async Task UploadDocumentsAsync(string indexName, IEnumerable indexDocuments, AzureAISearchIndexSettings indexSettings) + { + ArgumentNullException.ThrowIfNull(indexDocuments, nameof(indexDocuments)); + ArgumentNullException.ThrowIfNull(indexSettings, nameof(indexSettings)); + + try + { + var client = GetSearchClient(indexName); + + var maps = indexSettings.GetMaps(); + + var pages = indexDocuments.PagesOf(32000); + + foreach (var page in pages) + { + var docs = CreateSearchDocuments(page, maps); + + await _documentEvents.InvokeAsync(handler => handler.UploadingAsync(docs), _logger); + + await client.UploadDocumentsAsync(docs); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to delete documents from Azure AI Search Settings"); + } + } + + public async Task> GetMappingsAsync(string[] indexedContentTypes) + { + ArgumentNullException.ThrowIfNull(indexedContentTypes, nameof(indexedContentTypes)); + + var mapping = new List(); + + foreach (var contentType in indexedContentTypes) + { + var contentItem = await _contentManager.NewAsync(contentType); + var index = new DocumentIndex(contentItem.ContentItemId, contentItem.ContentItemVersionId); + var buildIndexContext = new BuildIndexContext(index, contentItem, [contentType], new AzureAISearchContentIndexSettings()); + await _contentItemIndexHandlers.InvokeAsync(x => x.BuildIndexAsync(buildIndexContext), _logger); + + foreach (var entry in index.Entries) + { + if (!AzureAISearchIndexNamingHelper.TryGetSafeFieldName(entry.Name, out var fieldKey)) + { + continue; + } + + mapping.Add(new AzureAISearchIndexMap(fieldKey, entry.Type, entry.Options) + { + IndexingKey = entry.Name, + }); + } + } + + return mapping; + } + + private IEnumerable CreateSearchDocuments(IEnumerable indexDocuments, Dictionary mappings) + { + foreach (var indexDocument in indexDocuments) + { + yield return CreateSearchDocument(indexDocument, mappings); + } + } + + private SearchDocument CreateSearchDocument(DocumentIndex documentIndex, Dictionary mappings) + { + var doc = new SearchDocument() + { + { IndexingConstants.ContentItemIdKey, documentIndex.ContentItemId }, + { IndexingConstants.ContentItemVersionIdKey, documentIndex.ContentItemVersionId }, + }; + + foreach (var entry in documentIndex.Entries) + { + if (!mappings.TryGetValue(entry.Name, out var map)) + { + continue; + } + + switch (entry.Type) + { + case DocumentIndex.Types.Boolean: + if (entry.Value is bool boolValue) + { + doc.TryAdd(map.AzureFieldKey, boolValue); + } + break; + + case DocumentIndex.Types.DateTime: + + if (entry.Value is DateTimeOffset offsetValue) + { + doc.TryAdd(map.AzureFieldKey, offsetValue); + } + else if (entry.Value is DateTime dateTimeValue) + { + doc.TryAdd(map.AzureFieldKey, dateTimeValue.ToUniversalTime()); + } + + break; + + case DocumentIndex.Types.Integer: + if (entry.Value != null && long.TryParse(entry.Value.ToString(), out var value)) + { + doc.TryAdd(map.AzureFieldKey, value); + } + + break; + + case DocumentIndex.Types.Number: + if (entry.Value != null) + { + doc.TryAdd(map.AzureFieldKey, Convert.ToDouble(entry.Value)); + } + break; + + case DocumentIndex.Types.GeoPoint: + if (entry.Value != null) + { + doc.TryAdd(map.AzureFieldKey, entry.Value); + } + break; + + case DocumentIndex.Types.Text: + if (entry.Value != null) + { + var stringValue = Convert.ToString(entry.Value); + + if (!string.IsNullOrEmpty(stringValue) && stringValue != "NULL") + { + // Only full-test and display-text and keyword fields contains single string. All others, support a collection of strings. + if (map.AzureFieldKey == AzureAISearchIndexManager.FullTextKey + || map.AzureFieldKey == AzureAISearchIndexManager.DisplayTextAnalyzedKey + || entry.Options.HasFlag(DocumentIndexOptions.Keyword)) + { + doc.TryAdd(map.AzureFieldKey, stringValue); + } + else + { + if (!doc.TryGetValue(map.AzureFieldKey, out var obj) || obj is not List existingValue) + { + existingValue = []; + } + + existingValue.Add(stringValue); + + doc[map.AzureFieldKey] = existingValue; + } + } + } + break; + } + } + + return doc; + } + + private SearchClient GetSearchClient(string indexName) + { + var fullIndexName = _indexManager.GetFullIndexName(indexName); + + var client = _searchClientFactory.Create(fullIndexName) ?? throw new Exception("Endpoint is missing from Azure AI Search Settings"); + + return client; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexManager.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexManager.cs new file mode 100644 index 00000000000..bd71068f16b --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexManager.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Azure; +using Azure.Search.Documents.Indexes; +using Azure.Search.Documents.Indexes.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Contents.Indexing; +using OrchardCore.Environment.Shell; +using OrchardCore.Modules; +using OrchardCore.Search.AzureAI.Models; +using static OrchardCore.Indexing.DocumentIndex; + +namespace OrchardCore.Search.AzureAI.Services; + +public class AzureAISearchIndexManager( + SearchIndexClient client, + ILogger logger, + IOptions azureAIOptions, + IEnumerable indexEvents, + IMemoryCache memoryCache, + ShellSettings shellSettings) +{ + public const string OwnerKey = "Content__ContentItem__Owner"; + public const string AuthorKey = "Content__ContentItem__Author"; + public const string FullTextKey = "Content__ContentItem__FullText"; + public const string DisplayTextAnalyzedKey = "Content__ContentItem__DisplayText__Analyzed"; + + private const string _prefixCacheKey = "AzureAISearchIndexesPrefix"; + + private readonly SearchIndexClient _client = client; + private readonly ILogger _logger = logger; + private readonly IEnumerable _indexEvents = indexEvents; + private readonly IMemoryCache _memoryCache = memoryCache; + private readonly ShellSettings _shellSettings = shellSettings; + private readonly AzureAISearchDefaultOptions _azureAIOptions = azureAIOptions.Value; + + public async Task CreateAsync(AzureAISearchIndexSettings settings) + { + if (await ExistsAsync(settings.IndexName)) + { + return true; + } + + try + { + var context = new AzureAISearchIndexCreateContext(settings, GetFullIndexName(settings.IndexName)); + + await _indexEvents.InvokeAsync((handler, ctx) => handler.CreatingAsync(ctx), context, _logger); + + var searchIndex = GetSearchIndex(context.IndexFullName, settings); + + var response = await _client.CreateIndexAsync(searchIndex); + + await _indexEvents.InvokeAsync((handler, ctx) => handler.CreatedAsync(ctx), context, _logger); + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to create index in Azure AI Search."); + } + + return false; + } + + public async Task ExistsAsync(string indexName) + => await GetAsync(indexName) != null; + + public async Task GetAsync(string indexName) + { + try + { + var indexFullName = GetFullIndexName(indexName); + var response = await _client.GetIndexAsync(indexFullName); + + return response?.Value; + } + catch (RequestFailedException e) + { + if (e.Status != 404) + { + _logger.LogError(e, "Unable to get index from Azure AI Search."); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to get index from Azure AI Search."); + } + + return null; + } + + public async Task DeleteAsync(string indexName) + { + if (!await ExistsAsync(indexName)) + { + return false; + } + + try + { + var context = new AzureAISearchIndexRemoveContext(indexName, GetFullIndexName(indexName)); + + await _indexEvents.InvokeAsync((handler, ctx) => handler.RemovingAsync(ctx), context, _logger); + + var response = await _client.DeleteIndexAsync(context.IndexFullName); + + await _indexEvents.InvokeAsync((handler, ctx) => handler.RemovedAsync(ctx), context, _logger); + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to delete index from Azure AI Search."); + } + + return false; + } + + public async Task RebuildAsync(AzureAISearchIndexSettings settings) + { + try + { + var context = new AzureAISearchIndexRebuildContext(settings, GetFullIndexName(settings.IndexName)); + + await _indexEvents.InvokeAsync((handler, ctx) => handler.RebuildingAsync(ctx), context, _logger); + + if (await ExistsAsync(settings.IndexName)) + { + await _client.DeleteIndexAsync(context.IndexFullName); + } + + var searchIndex = GetSearchIndex(context.IndexFullName, settings); + + var response = await _client.CreateIndexAsync(searchIndex); + + await _indexEvents.InvokeAsync((handler, ctx) => handler.RebuiltAsync(ctx), context, _logger); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to update Azure AI Search index."); + } + } + + public string GetFullIndexName(string indexName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(indexName); + + return GetIndexPrefix() + '-' + indexName; + } + + private string GetIndexPrefix() + { + if (!_memoryCache.TryGetValue(_prefixCacheKey, out var value)) + { + var builder = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(_azureAIOptions.IndexesPrefix)) + { + builder.Append(_azureAIOptions.IndexesPrefix.ToLowerInvariant()); + builder.Append('-'); + } + + builder.Append(_shellSettings.Name.ToLowerInvariant()); + + if (AzureAISearchIndexNamingHelper.TryGetSafePrefix(builder.ToString(), out var safePrefix)) + { + value = safePrefix; + _memoryCache.Set(_prefixCacheKey, safePrefix); + } + } + + return value ?? string.Empty; + } + + private static SearchIndex GetSearchIndex(string fullIndexName, AzureAISearchIndexSettings settings) + { + var searchFields = new List() + { + new SimpleField(IndexingConstants.ContentItemIdKey, SearchFieldDataType.String) + { + IsKey = true, + IsFilterable = true, + IsSortable = true, + }, + new SimpleField(IndexingConstants.ContentItemVersionIdKey, SearchFieldDataType.String) + { + IsFilterable = true, + IsSortable = true, + }, + new SimpleField(OwnerKey, SearchFieldDataType.String) + { + IsFilterable = true, + IsSortable = true, + }, + new SearchableField(DisplayTextAnalyzedKey) + { + AnalyzerName = settings.AnalyzerName, + }, + new SearchableField(FullTextKey) + { + AnalyzerName = settings.AnalyzerName, + }, + }; + + foreach (var indexMap in settings.IndexMappings) + { + if (!AzureAISearchIndexNamingHelper.TryGetSafeFieldName(indexMap.AzureFieldKey, out var safeFieldName)) + { + continue; + } + + if (searchFields.Exists(x => x.Name.EqualsOrdinalIgnoreCase(safeFieldName))) + { + continue; + } + + if (indexMap.Options.HasFlag(Indexing.DocumentIndexOptions.Keyword)) + { + searchFields.Add(new SimpleField(safeFieldName, GetFieldType(indexMap.Type)) + { + IsFilterable = true, + IsSortable = true, + }); + + continue; + } + + searchFields.Add(new SearchableField(safeFieldName, true) + { + AnalyzerName = settings.AnalyzerName, + }); + } + + var searchIndex = new SearchIndex(fullIndexName) + { + Fields = searchFields, + Suggesters = + { + new SearchSuggester("sg", FullTextKey), + }, + }; + + return searchIndex; + } + + private static SearchFieldDataType GetFieldType(Types type) + => type switch + { + Types.Boolean => SearchFieldDataType.Boolean, + Types.DateTime => SearchFieldDataType.DateTimeOffset, + Types.Number => SearchFieldDataType.Double, + Types.Integer => SearchFieldDataType.Int64, + Types.GeoPoint => SearchFieldDataType.GeographyPoint, + _ => SearchFieldDataType.String, + }; +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexSettingsService.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexSettingsService.cs new file mode 100644 index 00000000000..59c578a313e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexSettingsService.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Documents; +using OrchardCore.Environment.Shell.Scope; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI.Services; + +public class AzureAISearchIndexSettingsService +{ + /// + /// Loads the index settings document from the store for updating and that should not be cached. + /// + public Task LoadDocumentAsync() + => DocumentManager.GetOrCreateMutableAsync(); + + /// + /// Gets the index settings document from the cache for sharing and that should not be updated. + /// + public async Task GetDocumentAsync() + { + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + foreach (var name in document.IndexSettings.Keys) + { + document.IndexSettings[name].IndexName = name; + } + + return document; + } + + public async Task> GetSettingsAsync() + => (await GetDocumentAsync()).IndexSettings.Values; + + public async Task GetAsync(string indexName) + { + var document = await GetDocumentAsync(); + + if (document.IndexSettings.TryGetValue(indexName, out var settings)) + { + return settings; + } + + return null; + } + + public async Task UpdateAsync(AzureAISearchIndexSettings settings) + { + var document = await LoadDocumentAsync(); + document.IndexSettings[settings.IndexName] = settings; + await DocumentManager.UpdateAsync(document); + } + + public async Task DeleteAsync(string indexName) + { + var document = await LoadDocumentAsync(); + document.IndexSettings.Remove(indexName); + await DocumentManager.UpdateAsync(document); + } + + private static IDocumentManager DocumentManager + => ShellScope.Services.GetRequiredService>(); +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexingService.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexingService.cs new file mode 100644 index 00000000000..e9f43a2edcf --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchIndexingService.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OrchardCore.ContentLocalization; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Records; +using OrchardCore.Indexing; +using OrchardCore.Modules; +using OrchardCore.Search.AzureAI.Models; +using YesSql; +using YesSql.Services; + +namespace OrchardCore.Search.AzureAI.Services; + +public class AzureAISearchIndexingService +{ + private const int _batchSize = 100; + + private readonly IIndexingTaskManager _indexingTaskManager; + private readonly AzureAISearchIndexSettingsService _azureAISearchIndexSettingsService; + private readonly AzureAIIndexDocumentManager _indexDocumentManager; + private readonly ISession _session; + private readonly IContentManager _contentManager; + private readonly IEnumerable _contentItemIndexHandlers; + private readonly ILogger _logger; + + public AzureAISearchIndexingService( + IIndexingTaskManager indexingTaskManager, + AzureAISearchIndexSettingsService azureAISearchIndexSettingsService, + AzureAIIndexDocumentManager indexDocumentManager, + ISession session, + IContentManager contentManager, + IEnumerable contentItemIndexHandlers, + ILogger logger) + { + _indexingTaskManager = indexingTaskManager; + _azureAISearchIndexSettingsService = azureAISearchIndexSettingsService; + _indexDocumentManager = indexDocumentManager; + _session = session; + _contentManager = contentManager; + _contentItemIndexHandlers = contentItemIndexHandlers; + _logger = logger; + } + + public async Task ProcessContentItemsAsync(params string[] indexNames) + { + var lastTaskId = long.MaxValue; + var indexSettings = new List(); + var indexesDocument = await _azureAISearchIndexSettingsService.LoadDocumentAsync(); + + if (indexNames == null || indexNames.Length == 0) + { + indexSettings = new List(indexesDocument.IndexSettings.Values); + } + else + { + indexSettings = indexesDocument.IndexSettings.Where(x => indexNames.Contains(x.Key, StringComparer.OrdinalIgnoreCase)) + .Select(x => x.Value) + .ToList(); + } + + if (indexSettings.Count == 0) + { + return; + } + + // Find the lowest task id to process. + foreach (var indexSetting in indexSettings) + { + var taskId = indexSetting.GetLastTaskId(); + lastTaskId = Math.Min(lastTaskId, taskId); + } + + if (indexSettings.Count == 0) + { + return; + } + + var tasks = new List(); + + var allContentTypes = indexSettings.SelectMany(x => x.IndexedContentTypes ?? []).Distinct().ToList(); + + while (tasks.Count <= _batchSize) + { + // Load the next batch of tasks. + tasks = (await _indexingTaskManager.GetIndexingTasksAsync(lastTaskId, _batchSize)).ToList(); + + if (tasks.Count == 0) + { + break; + } + + var updatedContentItemIds = tasks + .Where(x => x.Type == IndexingTaskTypes.Update) + .Select(x => x.ContentItemId) + .ToArray(); + + Dictionary allPublished = null; + Dictionary allLatest = null; + + // Group all DocumentIndex by index to batch update them. + var updatedDocumentsByIndex = indexSettings.ToDictionary(x => x.IndexName, b => new List()); + + var settingsByIndex = indexSettings.ToDictionary(x => x.IndexName); + + if (indexSettings.Any(x => !x.IndexLatest)) + { + var publishedContentItems = await _session.Query(index => index.Published && index.ContentType.IsIn(allContentTypes) && index.ContentItemId.IsIn(updatedContentItemIds)).ListAsync(); + allPublished = publishedContentItems.DistinctBy(x => x.ContentItemId) + .ToDictionary(k => k.ContentItemId); + + foreach (var publishedContentItem in publishedContentItems) + { + _session.Detach(publishedContentItem); + } + } + + if (indexSettings.Any(x => x.IndexLatest)) + { + var latestContentItems = await _session.Query(index => index.Latest && index.ContentType.IsIn(allContentTypes) && index.ContentItemId.IsIn(updatedContentItemIds)).ListAsync(); + allLatest = latestContentItems.DistinctBy(x => x.ContentItemId).ToDictionary(k => k.ContentItemId); + + foreach (var latestContentItem in latestContentItems) + { + _session.Detach(latestContentItem); + } + } + + foreach (var task in tasks) + { + if (task.Type == IndexingTaskTypes.Update) + { + BuildIndexContext publishedIndexContext = null, latestIndexContext = null; + + if (allPublished != null && allPublished.TryGetValue(task.ContentItemId, out var publishedContentItem)) + { + publishedIndexContext = new BuildIndexContext(new DocumentIndex(task.ContentItemId, publishedContentItem.ContentItemVersionId), publishedContentItem, [publishedContentItem.ContentType], new AzureAISearchContentIndexSettings()); + await _contentItemIndexHandlers.InvokeAsync(x => x.BuildIndexAsync(publishedIndexContext), _logger); + } + + if (allLatest != null && allLatest.TryGetValue(task.ContentItemId, out var latestContentItem)) + { + latestIndexContext = new BuildIndexContext(new DocumentIndex(task.ContentItemId, latestContentItem.ContentItemVersionId), latestContentItem, [latestContentItem.ContentType], new AzureAISearchContentIndexSettings()); + await _contentItemIndexHandlers.InvokeAsync(x => x.BuildIndexAsync(latestIndexContext), _logger); + } + + if (publishedIndexContext == null && latestIndexContext == null) + { + continue; + } + + // Update the document from the index if its lastIndexId is smaller than the current task id. + foreach (var settings in indexSettings) + { + if (settings.GetLastTaskId() >= task.Id) + { + continue; + } + + var context = !settings.IndexLatest ? publishedIndexContext : latestIndexContext; + + // We index only if we actually found a content item in the database. + if (context == null) + { + continue; + } + + // Ignore if the content item content type is not indexed in this index. + if (!settings.IndexedContentTypes.Contains(context.ContentItem.ContentType)) + { + continue; + } + + // Ignore if the culture is not indexed in this index. + var cultureAspect = await _contentManager.PopulateAspectAsync(context.ContentItem); + var culture = cultureAspect.HasCulture ? cultureAspect.Culture.Name : null; + var ignoreIndexedCulture = settings.Culture != "any" && culture != settings.Culture; + + if (ignoreIndexedCulture) + { + continue; + } + + updatedDocumentsByIndex[settings.IndexName].Add(context.DocumentIndex); + } + } + } + + lastTaskId = tasks.Last().Id; + + var resultTracker = new HashSet(); + // Send all the new documents to the index. + foreach (var index in updatedDocumentsByIndex) + { + if (index.Value.Count == 0) + { + continue; + } + + var settings = indexSettings.FirstOrDefault(x => x.IndexName == index.Key); + + if (settings == null) + { + continue; + } + + if (!await _indexDocumentManager.MergeOrUploadDocumentsAsync(index.Key, updatedDocumentsByIndex[index.Key], settings)) + { + // At this point we know something went wrong while trying update content items for this index. + resultTracker.Add(index.Key); + + continue; + } + + if (!resultTracker.Contains(index.Key)) + { + // We know none of the previous batches failed to update this index. + settings.SetLastTaskId(lastTaskId); + await _azureAISearchIndexSettingsService.UpdateAsync(settings); + } + } + } + } +} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchService.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchService.cs new file mode 100644 index 00000000000..f61b6d401e0 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchService.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using Azure.Search.Documents; +using Microsoft.Extensions.Logging; +using OrchardCore.Contents.Indexing; +using OrchardCore.Search.Abstractions; +using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Settings; + +namespace OrchardCore.Search.AzureAI.Services; + +public class AzureAISearchService( + ISiteService siteService, + AzureAIIndexDocumentManager indexDocumentManager, + AzureAISearchIndexSettingsService indexSettingsService, + ILogger logger + ) : ISearchService +{ + public const string Key = "Azure AI Search"; + + private readonly ISiteService _siteService = siteService; + private readonly AzureAIIndexDocumentManager _indexDocumentManager = indexDocumentManager; + private readonly AzureAISearchIndexSettingsService _indexSettingsService = indexSettingsService; + private readonly ILogger _logger = logger; + + public string Name => Key; + + public async Task SearchAsync(string indexName, string term, int start, int size) + { + var siteSettings = await _siteService.GetSiteSettingsAsync(); + var searchSettings = siteSettings.As(); + + var index = !string.IsNullOrWhiteSpace(indexName) ? indexName.Trim() : searchSettings.SearchIndex; + + var result = new SearchResult(); + if (string.IsNullOrEmpty(index)) + { + _logger.LogWarning("Azure AI Search: Couldn't execute search. No search provider settings was defined."); + + return result; + } + + var indexSettings = await _indexSettingsService.GetAsync(index); + result.Latest = indexSettings.IndexLatest; + + try + { + result.ContentItemIds = []; + + var searchOptions = new SearchOptions() + { + Skip = start, + Size = size, + }; + + searchOptions.Select.Add(IndexingConstants.ContentItemIdKey); + + if (searchSettings.DefaultSearchFields?.Length > 0) + { + foreach (var field in searchSettings.DefaultSearchFields) + { + searchOptions.SearchFields.Add(field); + } + } + + await _indexDocumentManager.SearchAsync(index, term, (doc) => + { + if (doc.TryGetValue(IndexingConstants.ContentItemIdKey, out var contentItemId)) + { + result.ContentItemIds.Add(contentItemId.ToString()); + } + }, searchOptions); + + result.Success = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Azure AI Search: Couldn't execute search due to an exception."); + } + + return result; + } +} + diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/SearchClientFactory.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/SearchClientFactory.cs new file mode 100644 index 00000000000..01e24e1356f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/SearchClientFactory.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Concurrent; +using Azure.Search.Documents; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Search.AzureAI.Models; + +namespace OrchardCore.Search.AzureAI.Services; + +public class SearchClientFactory( + IOptions azureAIOptions, + ILogger logger) +{ + private readonly ConcurrentDictionary _clients = []; + private readonly AzureAISearchDefaultOptions _azureAIOptions = azureAIOptions.Value; + private readonly ILogger _logger = logger; + + public SearchClient Create(string indexFullName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(indexFullName, nameof(indexFullName)); + + if (!_clients.TryGetValue(indexFullName, out var client)) + { + if (!Uri.TryCreate(_azureAIOptions.Endpoint, UriKind.Absolute, out var endpoint)) + { + _logger.LogError("Endpoint is missing from Azure AI Options."); + + return null; + } + + client = new SearchClient(endpoint, indexFullName, _azureAIOptions.Credential); + + _clients.TryAdd(indexFullName, client); + } + + return client; + } +} diff --git a/src/docs/OrchardCore.Docs.csproj b/src/docs/OrchardCore.Docs.csproj index cc04c70dd0c..8720ce5cbf5 100644 --- a/src/docs/OrchardCore.Docs.csproj +++ b/src/docs/OrchardCore.Docs.csproj @@ -7,4 +7,8 @@ false + + + + diff --git a/src/docs/reference/README.md b/src/docs/reference/README.md index 40803748154..7dc3372e4b3 100644 --- a/src/docs/reference/README.md +++ b/src/docs/reference/README.md @@ -135,6 +135,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance. ### Search, Indexing, Querying +- [Azure AI Search](modules/AzureAISearch/README.md) - [SQL](modules/SQLIndexing/README.md) - [Lucene](modules/Lucene/README.md) - [Elasticsearch](modules/Elasticsearch/README.md) diff --git a/src/docs/reference/modules/AzureAISearch/README.md b/src/docs/reference/modules/AzureAISearch/README.md new file mode 100644 index 00000000000..60d0237e5b0 --- /dev/null +++ b/src/docs/reference/modules/AzureAISearch/README.md @@ -0,0 +1,99 @@ +# Azure AI Search (`OrchardCore.Search.AzureAI`) + +The Azure AI Search module allows you to manage Azure AI Search indices. + +Before enabling the service, you'll need to configure the connection to the server. You can do that by adding the following into `appsettings.json` file + +``` +{ + "OrchardCore":{ + "OrchardCore_AzureAISearch":{ + "Endpoint":"https://[search service name].search.windows.net", + "IndexesPrefix":"", + "Credential":{ + "Key":"the server key goes here" + } + } + } +} +``` + +Then navigate to `Search` > `Indexing` > `Azure AI Indices` to add an index. + +![image](images/management.gif) + +## Recipes + +### Reset Azure AI Search Index Step + +The `Reset Index Step` resets an Azure AI Search index. Restarts the indexing process from the beginning in order to update current content items. It doesn't delete existing entries from the index. + +```json +{ + "steps":[ + { + "name":"azureai-index-reset", + "Indices":[ + "IndexName1", + "IndexName2" + ] + } + ] +} +``` + +To reset all indices: + +```json +{ + "steps":[ + { + "name":"azureai-index-reset", + "IncludeAll":true + } + ] +} +``` + +### Rebuild Elasticsearch Index Step + +The `Rebuild Index Step` rebuilds an Elasticsearch index. It deletes and recreates the full index content. + +```json +{ + "steps":[ + { + "name":"azureai-index-rebuild", + "Indices":[ + "IndexName1", + "IndexName2" + ] + } + ] +} +``` + +To rebuild all indices: + +```json +{ + "steps":[ + { + "name":"azureai-index-rebuild", + "IncludeAll":true + } + ] +} +``` + +## Search Module (`OrchardCore.Search`) + +When the Search module is enabled along with Azure AI Search, you'll be able to use run the frontend site search against your Azure AI Search indices. + +To configure the frontend site search settings, navigate to `Search` > `Settings`. On the `Content` tab, change the default search provider to `Azure AI Search`. Then click on the `Azure AI Search` tab select the default search index to use. + +### Using the Search Feature to Perform Full-Text Search +![image](images/frontend-search.gif) + +### Frontend Search Settings +![image](images/settings.gif) diff --git a/src/docs/reference/modules/AzureAISearch/images/frontend-search.gif b/src/docs/reference/modules/AzureAISearch/images/frontend-search.gif new file mode 100644 index 00000000000..ef7a0eec76a Binary files /dev/null and b/src/docs/reference/modules/AzureAISearch/images/frontend-search.gif differ diff --git a/src/docs/reference/modules/AzureAISearch/images/management.gif b/src/docs/reference/modules/AzureAISearch/images/management.gif new file mode 100644 index 00000000000..12fa564a31b Binary files /dev/null and b/src/docs/reference/modules/AzureAISearch/images/management.gif differ diff --git a/src/docs/reference/modules/AzureAISearch/images/settings.gif b/src/docs/reference/modules/AzureAISearch/images/settings.gif new file mode 100644 index 00000000000..ee1c1314dfb Binary files /dev/null and b/src/docs/reference/modules/AzureAISearch/images/settings.gif differ diff --git a/src/docs/releases/1.9.0.md b/src/docs/releases/1.9.0.md new file mode 100644 index 00000000000..98db1a2110f --- /dev/null +++ b/src/docs/releases/1.9.0.md @@ -0,0 +1,7 @@ +# Orchard Core 1.9.0 + +Release date: Not yet released + +### Azure AI Search module + +Introducing a new "Azure AI Search" module, designed to empower you in the administration of Azure AI Search indices. When enabling with the "Search" module, it facilitates frontend full-text search capabilities through Azure AI Search. For more info read the [Azure AI Search](../reference/modules/AzureAISearch/README.md) docs. diff --git a/src/docs/resources/libraries/README.md b/src/docs/resources/libraries/README.md index 448904e062e..f0122ad905f 100644 --- a/src/docs/resources/libraries/README.md +++ b/src/docs/resources/libraries/README.md @@ -32,6 +32,8 @@ The below table lists the different .NET libraries used in Orchard Core: | [MiniProfiler](https://github.com/MiniProfiler/dotnet) | A simple but effective mini-profiler for ASP.NET (and Core) websites | 4.3.8 | [MIT](https://github.com/MiniProfiler/dotnet/blob/main/LICENSE.txt) | | [NCrontab](https://github.com/atifaziz/NCrontab) | Crontab for .NET | 3.3.3 | [Apache-2.0](https://github.com/atifaziz/NCrontab/blob/master/COPYING.txt) | | [NEST](https://github.com/elastic/elasticsearch-net) | .NET Library for Elasticsearch | 7.17.5 | [Apache-2.0](https://github.com/elastic/elasticsearch-net/blob/main/LICENSE.txt) | +| [Azure.Search.Documents](https://github.com/Azure/azure-sdk-for-net/blob/Azure.Search.Documents_11.5.1/sdk/search/Azure.Search.Documents/README.md) | Azure AI Search client library for .NET | 11.5.1 | [MIT](https://github.com/AzureAD/microsoft-identity-web/blob/master/LICENSE) | +| [Microsoft.Extensions.Azure](https://github.com/Azure/azure-sdk-for-net/blob/Microsoft.Extensions.Azure_1.7.1/sdk/extensions/Microsoft.Extensions.Azure/README.md) | Azure client library integration for ASP.NET Core | 1.7.1 | [MIT](https://github.com/AzureAD/microsoft-identity-web/blob/master/LICENSE) | | [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json) | Json.NET is a popular high-performance JSON framework for .NET | 13.0.3 | [MIT](https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md) | | [NJsonSchema](https://github.com/RicoSuter/NJsonSchema) | JSON Schema reader, generator and validator for .NET | 10.9.0 | [MIT](https://github.com/RicoSuter/NJsonSchema/blob/master/LICENSE.md) | | [NLog.Web.AspNetCore](https://github.com/NLog/NLog.Web/tree/master/src/NLog.Web.AspNetCore) | NLog integration for ASP.NET. | 5.3.8 | [BSD-3-Clause](https://github.com/NLog/NLog.Web/blob/master/LICENSE) | diff --git a/src/docs/resources/owners/README.md b/src/docs/resources/owners/README.md index b51247b3f99..38ae7414eb0 100644 --- a/src/docs/resources/owners/README.md +++ b/src/docs/resources/owners/README.md @@ -21,3 +21,4 @@ The below table lists the different code/features areas and their owners: | [Sébastien Ros](https://github.com/sebastienros) | YesSql, Fluid, Jint, Shortcodes | | [Jasmin Savard](https://github.com/skrypt) | Lucene, Indexing, Admin, UI | | [Bertrand Le Roy](https://github.com/bleroy) | Commerce module | +| [Mike Alhayek](https://github.com/MikeAlhayek) | Azure AI Search, Notifications, SMS | diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Search.AzureAI/NamingTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Search.AzureAI/NamingTests.cs new file mode 100644 index 00000000000..3452a75cbf7 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Search.AzureAI/NamingTests.cs @@ -0,0 +1,98 @@ +using OrchardCore.Search.AzureAI; + +namespace OrchardCore.Tests.Modules.OrchardCore.Search.AzureAI; + +public class NamingTests +{ + private static string _maxName = new('a', 128); + private static string _overMaxLength = new('a', 129); + + [Fact] + public void CreateLengthSafeIndexName() + { + + + var isValid1 = AzureAISearchIndexNamingHelper.TryGetSafeIndexName(_maxName, out var result1); + + Assert.True(isValid1); + + Assert.Equal(_maxName, result1); + + var isValid2 = AzureAISearchIndexNamingHelper.TryGetSafeIndexName(_overMaxLength, out var result2); + + Assert.True(isValid2); + + Assert.Equal(_maxName, result2); + } + + [Theory] + [InlineData("IndexName_1", "indexname1")] + [InlineData("Index-Name-1", "index-name-1")] + [InlineData("123Index-Name-1", "123index-name-1")] + [InlineData("_index-Name-1", "index-name-1")] + [InlineData("123_-", "123-")] + [InlineData("1234t", "1234t")] + [InlineData("a", null)] + [InlineData("___%^&", null)] + public void CreateSafeIndexName(string indexName, string expectedName) + { + var valid = AzureAISearchIndexNamingHelper.TryGetSafeIndexName(indexName, out var result); + + if (expectedName != null) + { + Assert.True(valid); + } + else + { + Assert.False(valid); + } + + Assert.Equal(expectedName, result); + } + + [Fact] + public void CreateLengthSafeFieldName() + { + var isValid1 = AzureAISearchIndexNamingHelper.TryGetSafeFieldName(_maxName, out var result1); + + Assert.True(isValid1); + + Assert.Equal(_maxName, result1); + + var isValid2 = AzureAISearchIndexNamingHelper.TryGetSafeFieldName(_overMaxLength, out var result2); + + Assert.True(isValid2); + + Assert.Equal(_maxName, result2); + } + + [Theory] + [InlineData("FieldName_1", "FieldName_1")] + [InlineData("Field-Name-1", "FieldName1")] + [InlineData("123Field-Name-1", "FieldName1")] + [InlineData("_field-Name-1", "fieldName1")] + [InlineData("a__B", "a__B")] + [InlineData("a.b", "a__b")] + [InlineData("a.b.c.d", "a__b__c__d")] + [InlineData("a", "a")] + [InlineData("1", null)] + [InlineData("-", null)] + [InlineData("a1", "a1")] + [InlineData("azureSearchFieldName", "FieldName")] + [InlineData("azureSearch_FieldName", "FieldName")] + public void CreateSafeFieldName(string indexName, string expectedName) + { + var valid = AzureAISearchIndexNamingHelper.TryGetSafeFieldName(indexName, out var result); + + if (expectedName != null) + { + Assert.True(valid); + } + else + { + Assert.False(valid); + } + + Assert.Equal(expectedName, result); + } +}