From 7efe1fe0939c95f6302f04dd6ca9d27ab81f6488 Mon Sep 17 00:00:00 2001 From: Bryan Jonker Date: Tue, 9 Jun 2026 17:04:03 -0500 Subject: [PATCH 1/2] Add coursera import --- .../CourseImport/ScheduleTranslator.cs | 3 +- .../CourseraImport/CourseraCourse.cs | 18 +++ .../CourseraImport/CourseraGenerator.cs | 106 ++++++++++++++++++ .../CourseraImport/CourseraImportManager.cs | 39 +++++++ .../PageList/PageGroup.cs | 3 +- .../ProgramInformationV2.Data.csproj | 1 + ProgramInformationV2.Search/Models/Course.cs | 7 +- .../Models/ExtensionTypes.cs | 2 + .../Pages/Course/CourseraImport.razor | 56 +++++++++ .../Pages/Course/CourseraImport.razor.cs | 102 +++++++++++++++++ ProgramInformationV2/Program.cs | 3 + 11 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 ProgramInformationV2.Data/CourseraImport/CourseraCourse.cs create mode 100644 ProgramInformationV2.Data/CourseraImport/CourseraGenerator.cs create mode 100644 ProgramInformationV2.Data/CourseraImport/CourseraImportManager.cs create mode 100644 ProgramInformationV2/Components/Pages/Course/CourseraImport.razor create mode 100644 ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs diff --git a/ProgramInformationV2.Data/CourseImport/ScheduleTranslator.cs b/ProgramInformationV2.Data/CourseImport/ScheduleTranslator.cs index deceb0f..a2b948a 100644 --- a/ProgramInformationV2.Data/CourseImport/ScheduleTranslator.cs +++ b/ProgramInformationV2.Data/CourseImport/ScheduleTranslator.cs @@ -19,7 +19,8 @@ public static Course Translate(ScheduleCourse scheduleCourse, string source, boo Description = string.Empty, Information = string.Empty, ScheduleInformation = string.Empty, - Prerequisite = string.Empty + Prerequisite = string.Empty, + PlatformType = PlatformTypes.Campus }; course = course.AddDescription(scheduleCourse.Description); diff --git a/ProgramInformationV2.Data/CourseraImport/CourseraCourse.cs b/ProgramInformationV2.Data/CourseraImport/CourseraCourse.cs new file mode 100644 index 0000000..19483a4 --- /dev/null +++ b/ProgramInformationV2.Data/CourseraImport/CourseraCourse.cs @@ -0,0 +1,18 @@ +namespace ProgramInformationV2.Data.CourseraImport { + public class CourseraCourse { + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public string Url { get; set; } = ""; + public string ImageUrl { get; set; } = ""; + public string ImageAltText { get; set; } = ""; + + public bool IsCourseFree { get; set; } + + public bool IsCreditEligible { get; set; } + + public string Id { get; set; } = ""; + public List Skills { get; set; } = []; + public List Instructors { get; set; } = []; + + } +} diff --git a/ProgramInformationV2.Data/CourseraImport/CourseraGenerator.cs b/ProgramInformationV2.Data/CourseraImport/CourseraGenerator.cs new file mode 100644 index 0000000..8f43bef --- /dev/null +++ b/ProgramInformationV2.Data/CourseraImport/CourseraGenerator.cs @@ -0,0 +1,106 @@ +using System.Text; +using System.Text.Json; + +namespace ProgramInformationV2.Data.CourseraImport { + public class CourseraGenerator { + public async Task GetCourse(string id) { + var url = "https://www.coursera.org/graphql-gateway?opname=Search"; + var payload = """ + [{ + "operationName": "Search", + "variables": { + "requests": [ + { + "entityType": "PRODUCTS", + "limit": 10000, + "enableAutoAppliedFilters": false, + "requestOrigin": { + "pageType": "EQP", + "segmentType": "CONSUMER" + }, + "facetFilters":[["partners:University of Illinois Urbana-Champaign"],["productTypeDescription:Courses"]], + "maxValuesPerFacet":2000, + "cursor": "0", + "query": "" + } + ] + }, + "query": "query Search($requests: [Search_Request!]!) {\n SearchResult {\n search(requests: $requests) {\n ...SearchResult\n __typename\n }\n __typename\n }\n}\n\nfragment SearchResult on Search_Result {\n elements {\n ...SearchHit\n __typename\n }\n }\n\nfragment SearchHit on Search_Hit {\n ...SearchArticleHit\n ...SearchProductHit\n ...SearchSuggestionHit\n __typename\n}\n\nfragment SearchArticleHit on Search_ArticleHit {\n aeName\n careerField\n category\n createdByName\n firstPublishedAt\n id\n internalContentEpic\n internalProductLine\n internalTargetKw\n introduction\n islocalized\n lastPublishedAt\n localizedCountryCd\n localizedLanguageCd\n name\n subcategory\n topics\n url\n skill: skills\n __typename\n}\n\nfragment SearchProductHit on Search_ProductHit {\n avgProductRating\n cobrandingEnabled\n completions\n duration\n id\n imageUrl\n isCourseFree\n isCreditEligible\n isNewContent\n isPartOfCourseraPlus\n name\n numProductRatings\n parentCourseName\n parentLessonName\n partnerLogos\n partners\n productCard {\n ...SearchProductCard\n __typename\n }\n productDifficultyLevel\n productDuration\n productType\n skills\n url\n videosInLesson\n translatedName\n translatedSkills\n translatedParentCourseName\n translatedParentLessonName\n tagline\n fullyTranslatedLanguages\n subtitlesOnlyLanguages\n __typename\n}\n\nfragment SearchSuggestionHit on Search_SuggestionHit {\n id\n name\n score\n __typename\n}\n\nfragment SearchProductCard on ProductCard_ProductCard {\n id\n canonicalType\n marketingProductType\n badges\n productTypeAttributes {\n ... on ProductCard_Specialization {\n ...SearchProductCardSpecialization\n __typename\n }\n ... on ProductCard_Course {\n ...SearchProductCardCourse\n __typename\n }\n ... on ProductCard_Clip {\n ...SearchProductCardClip\n __typename\n }\n ... on ProductCard_Degree {\n ...SearchProductCardDegree\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment SearchProductCardSpecialization on ProductCard_Specialization {\n isPathwayContent\n __typename\n}\n\nfragment SearchProductCardCourse on ProductCard_Course {\n isPathwayContent\n rating\n reviewCount\n __typename\n}\n\nfragment SearchProductCardClip on ProductCard_Clip {\n canonical {\n id\n __typename\n }\n __typename\n}\n\nfragment SearchProductCardDegree on ProductCard_Degree {\n canonical {\n id\n __typename\n }\n __typename\n}\n" + }] + """; + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + request.Headers.Add("apollographql-client-name", "seo-entity-page"); + request.Headers.Add("apollographql-client-version", "3741b28900f73cb04ed39fe2210b2b9774b1d446"); + request.Headers.Add("operation-name", "Search"); + + using var httpClient = new HttpClient(); + var response = await httpClient.SendAsync(request); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var json = doc.RootElement.EnumerateArray().First(); + var items = json.GetProperty("data").GetProperty("SearchResult").GetProperty("search").EnumerateArray().First(); + var element = items.GetProperty("elements"); + var item = element.EnumerateArray().FirstOrDefault(e => e.GetProperty("id").ToString() == id); + if (item.GetProperty("id").ToString() == id) { + return new CourseraCourse() { + Id = item.GetProperty("id").ToString(), + Title = item.GetProperty("name").ToString(), + Url = item.GetProperty("url").ToString(), + ImageUrl = item.GetProperty("imageUrl").ToString(), + Skills = [.. item.GetProperty("skills").EnumerateArray().Select(s => s.ToString())], + IsCourseFree = item.GetProperty("isCourseFree").GetBoolean(), + IsCreditEligible = item.GetProperty("isCreditEligible").GetBoolean(), + }; + } + return new CourseraCourse(); + } + + public async Task> GetCourses() { + var url = "https://www.coursera.org/graphql-gateway?opname=Search"; + var payload = """ + [{ + "operationName": "Search", + "variables": { + "requests": [ + { + "entityType": "PRODUCTS", + "limit": 10000, + "enableAutoAppliedFilters": false, + "requestOrigin": { + "pageType": "EQP", + "segmentType": "CONSUMER" + }, + "facetFilters":[["partners:University of Illinois Urbana-Champaign"],["productTypeDescription:Courses"]], + "maxValuesPerFacet":2000, + "cursor": "0", + "query": "" + } + ] + }, + "query": "query Search($requests: [Search_Request!]!) {\n SearchResult {\n search(requests: $requests) {\n ...SearchResult\n __typename\n }\n __typename\n }\n}\n\nfragment SearchResult on Search_Result {\n elements {\n ...SearchHit\n __typename\n }\n }\n\nfragment SearchHit on Search_Hit {\n ...SearchArticleHit\n ...SearchProductHit\n ...SearchSuggestionHit\n __typename\n}\n\nfragment SearchArticleHit on Search_ArticleHit {\n aeName\n careerField\n category\n createdByName\n firstPublishedAt\n id\n internalContentEpic\n internalProductLine\n internalTargetKw\n introduction\n islocalized\n lastPublishedAt\n localizedCountryCd\n localizedLanguageCd\n name\n subcategory\n topics\n url\n skill: skills\n __typename\n}\n\nfragment SearchProductHit on Search_ProductHit {\n avgProductRating\n cobrandingEnabled\n completions\n duration\n id\n imageUrl\n isCourseFree\n isCreditEligible\n isNewContent\n isPartOfCourseraPlus\n name\n numProductRatings\n parentCourseName\n parentLessonName\n partnerLogos\n partners\n productCard {\n ...SearchProductCard\n __typename\n }\n productDifficultyLevel\n productDuration\n productType\n skills\n url\n videosInLesson\n translatedName\n translatedSkills\n translatedParentCourseName\n translatedParentLessonName\n tagline\n fullyTranslatedLanguages\n subtitlesOnlyLanguages\n __typename\n}\n\nfragment SearchSuggestionHit on Search_SuggestionHit {\n id\n name\n score\n __typename\n}\n\nfragment SearchProductCard on ProductCard_ProductCard {\n id\n canonicalType\n marketingProductType\n badges\n productTypeAttributes {\n ... on ProductCard_Specialization {\n ...SearchProductCardSpecialization\n __typename\n }\n ... on ProductCard_Course {\n ...SearchProductCardCourse\n __typename\n }\n ... on ProductCard_Clip {\n ...SearchProductCardClip\n __typename\n }\n ... on ProductCard_Degree {\n ...SearchProductCardDegree\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment SearchProductCardSpecialization on ProductCard_Specialization {\n isPathwayContent\n __typename\n}\n\nfragment SearchProductCardCourse on ProductCard_Course {\n isPathwayContent\n rating\n reviewCount\n __typename\n}\n\nfragment SearchProductCardClip on ProductCard_Clip {\n canonical {\n id\n __typename\n }\n __typename\n}\n\nfragment SearchProductCardDegree on ProductCard_Degree {\n canonical {\n id\n __typename\n }\n __typename\n}\n" + }] + """; + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + request.Headers.Add("apollographql-client-name", "seo-entity-page"); + request.Headers.Add("apollographql-client-version", "3741b28900f73cb04ed39fe2210b2b9774b1d446"); + request.Headers.Add("operation-name", "Search"); + + using var httpClient = new HttpClient(); + var response = await httpClient.SendAsync(request); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var json = doc.RootElement.EnumerateArray().First(); + var items = json.GetProperty("data").GetProperty("SearchResult").GetProperty("search").EnumerateArray().First(); + var element = items.GetProperty("elements"); + var returnValue = new Dictionary(); + foreach (var item in element.EnumerateArray()) { + returnValue.Add(item.GetProperty("id").ToString(), item.GetProperty("name").ToString()); + } + return returnValue; + } + } +} diff --git a/ProgramInformationV2.Data/CourseraImport/CourseraImportManager.cs b/ProgramInformationV2.Data/CourseraImport/CourseraImportManager.cs new file mode 100644 index 0000000..85ce3d9 --- /dev/null +++ b/ProgramInformationV2.Data/CourseraImport/CourseraImportManager.cs @@ -0,0 +1,39 @@ +using ProgramInformationV2.Search.Models; + +namespace ProgramInformationV2.Data.CourseraImport { + public class CourseraImportManager(CourseraGenerator courseraGenerator) { + private readonly CourseraGenerator _courseraGenerator = courseraGenerator; + + public async Task> GetCourses(string s) { + return (await _courseraGenerator.GetCourses()).Where(c => c.Value.ToLowerInvariant().Contains(s.ToLowerInvariant()) || s == "").OrderBy(d => d.Value).ToDictionary(d => d.Key, d => d.Value); + } + + public async Task GetCourse(string source, string id) { + var courseraCourse = await _courseraGenerator.GetCourse(id); + var course = new Course { + Source = source, + Title = courseraCourse.Title, + Url = "https://www.coursera.com" + courseraCourse.Url, + Id = source + "-" + courseraCourse.Id, + PlatformType = PlatformTypes.Coursera, + ImageUrl = courseraCourse.ImageUrl, + SkillList = courseraCourse.Skills, + IsActive = true, + CourseTitle = courseraCourse.Title, + Sections = [ + new Section { + BeginDate = DateTime.MinValue, + EndDate = DateTime.MaxValue, + IsActive = true, + Term = Terms.Ongoing, + FormatType = FormatType.Online, + SectionCode = courseraCourse.Id + } + ] + }; + course.CleanHtmlFields(); + return course; + } + + } +} diff --git a/ProgramInformationV2.Data/PageList/PageGroup.cs b/ProgramInformationV2.Data/PageList/PageGroup.cs index 4f2671d..6c2a00a 100644 --- a/ProgramInformationV2.Data/PageList/PageGroup.cs +++ b/ProgramInformationV2.Data/PageList/PageGroup.cs @@ -130,7 +130,8 @@ public static class PageGroup { }, { SidebarEnum.Courses, [ new("Courses", "/courses"), - new("Import Courses", "/courses/import") + new("Import Campus Courses", "/courses/import"), + new("Import Coursera Courses", "/courses/courseraimport") ] }, { SidebarEnum.Course, [ diff --git a/ProgramInformationV2.Data/ProgramInformationV2.Data.csproj b/ProgramInformationV2.Data/ProgramInformationV2.Data.csproj index 7d67f7c..66f9f91 100644 --- a/ProgramInformationV2.Data/ProgramInformationV2.Data.csproj +++ b/ProgramInformationV2.Data/ProgramInformationV2.Data.csproj @@ -11,6 +11,7 @@ + diff --git a/ProgramInformationV2.Search/Models/Course.cs b/ProgramInformationV2.Search/Models/Course.cs index 0cac398..2b5a742 100644 --- a/ProgramInformationV2.Search/Models/Course.cs +++ b/ProgramInformationV2.Search/Models/Course.cs @@ -1,5 +1,5 @@ -using System.Text.Json; -using OpenSearch.Client; +using OpenSearch.Client; +using System.Text.Json; namespace ProgramInformationV2.Search.Models { @@ -55,6 +55,9 @@ public Course() { public string Length { get; set; } = ""; public int MaximumCreditHours { get; set; } public int MinimumCreditHours { get; set; } + + public PlatformTypes PlatformType { get; set; } + public string Prerequisite { get; set; } = ""; [Keyword] diff --git a/ProgramInformationV2.Search/Models/ExtensionTypes.cs b/ProgramInformationV2.Search/Models/ExtensionTypes.cs index 284de6c..0b708fc 100644 --- a/ProgramInformationV2.Search/Models/ExtensionTypes.cs +++ b/ProgramInformationV2.Search/Models/ExtensionTypes.cs @@ -9,6 +9,8 @@ public enum Terms { None, Fall, Spring, Summer, Summer1, Summer2, Winter, Ongoin public enum UrlTypes { Programs, Courses, RequirementSets } + public enum PlatformTypes { None, Campus, Coursera, Custom, Moodle } + public enum NoteTemplateTypes { Programs = 1, Credentials = 2, Courses = 3 } public static class ExtensionTypes { diff --git a/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor b/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor new file mode 100644 index 0000000..3e7f85d --- /dev/null +++ b/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor @@ -0,0 +1,56 @@ +@page "/courses/courseraimport" +@layout SidebarLayout + +Import a Coursera Course + + +

Import a Coursera Course

+ @if (_useCourses.HasValue && _useCourses.Value) + { +

Note that this will overwrite any changes you made to the existing item.

+
+ + +
+ + @if (ListOfCourseraCourses != null && ListOfCourseraCoursesSelected != null) + { +
+
+ + +
+ + +
+
+
+
+
+ + +
+ + +
+
+
+ + } + } +
\ No newline at end of file diff --git a/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs b/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs new file mode 100644 index 0000000..87e3847 --- /dev/null +++ b/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Components; +using ProgramInformationV2.Components.Layout; +using ProgramInformationV2.Data.CourseraImport; +using ProgramInformationV2.Data.DataHelpers; +using ProgramInformationV2.Data.PageList; +using ProgramInformationV2.Search.Setters; + +namespace ProgramInformationV2.Components.Pages.Course { + public partial class CourseraImport { + private string _sourceCode = ""; + private bool? _useCourses = true; + public string SearchTerm { get; set; } = ""; + public string ListOfCourseraCourseId { get; set; } = ""; + + public string ListOfCourseraCourseIdSelected { get; set; } = ""; + [CascadingParameter] + public SidebarLayout Layout { get; set; } = default!; + + [Inject] + protected CourseSetter CourseSetter { get; set; } = default!; + + [Inject] + protected CourseraImportManager CourseraImportManager { get; set; } = default!; + + [Inject] + protected NavigationManager NavigationManager { get; set; } = default!; + + [Inject] + protected SourceHelper SourceHelper { get; set; } = default!; + + public Dictionary ListOfCourseraCourses { get; set; } = default!; + + public Dictionary ListOfCourseraCoursesSelected { get; set; } = default!; + + protected override async Task OnInitializedAsync() { + Layout.SetSidebar(SidebarEnum.Courses, "Courses"); + _sourceCode = await Layout.CheckSource(); + _useCourses = await SourceHelper.DoesSourceUseItem(_sourceCode, Data.DataModels.CategoryType.Course); + ListOfCourseraCoursesSelected = new Dictionary(); + await Search(); + await base.OnInitializedAsync(); + } + + + protected async Task Transfer() { + if (ListOfCourseraCourses.ContainsKey(ListOfCourseraCourseId)) { + ListOfCourseraCoursesSelected.TryAdd(ListOfCourseraCourseId, ListOfCourseraCourses[ListOfCourseraCourseId]); + ListOfCourseraCourses.Remove(ListOfCourseraCourseId); + ListOfCourseraCourseId = ListOfCourseraCourses.FirstOrDefault().Key ?? ""; + ListOfCourseraCoursesSelected = ListOfCourseraCoursesSelected.OrderBy(c => c.Value).ToDictionary(); + } + StateHasChanged(); + return true; + } + + protected async Task Remove() { + if (ListOfCourseraCoursesSelected.ContainsKey(ListOfCourseraCourseIdSelected)) { + ListOfCourseraCourses.TryAdd(ListOfCourseraCourseIdSelected, ListOfCourseraCoursesSelected[ListOfCourseraCourseIdSelected]); + ListOfCourseraCoursesSelected.Remove(ListOfCourseraCourseIdSelected); + ListOfCourseraCourseIdSelected = ListOfCourseraCoursesSelected.FirstOrDefault().Key ?? ""; + ListOfCourseraCourses = ListOfCourseraCourses.OrderBy(c => c.Value).ToDictionary(); + } + StateHasChanged(); + return true; + } + + protected async Task TransferAll() { + foreach (var item in ListOfCourseraCourses) { + ListOfCourseraCoursesSelected.TryAdd(item.Key, item.Value); + } + ListOfCourseraCoursesSelected = ListOfCourseraCoursesSelected.OrderBy(c => c.Value).ToDictionary(); + ListOfCourseraCourses.Clear(); + StateHasChanged(); + return true; + } + + protected async Task RemoveAll() { + foreach (var item in ListOfCourseraCoursesSelected) { + ListOfCourseraCourses.TryAdd(item.Key, item.Value); + } + ListOfCourseraCourses = ListOfCourseraCourses.OrderBy(c => c.Value).ToDictionary(); + ListOfCourseraCoursesSelected.Clear(); + StateHasChanged(); + return true; + } + + protected async Task Search() { + ListOfCourseraCourses = await CourseraImportManager.GetCourses(SearchTerm); + StateHasChanged(); + } + + protected async Task SendImport() { + await Layout.AddMessage("Starting to add courses - please wait"); + foreach (var course in ListOfCourseraCoursesSelected) { + var newCourse = await CourseraImportManager.GetCourse(_sourceCode, course.Key); + if (await CourseSetter.SetCourse(newCourse) != "") { + await Layout.AddMessage("Course added: " + newCourse.Title); + } + } + } + } +} diff --git a/ProgramInformationV2/Program.cs b/ProgramInformationV2/Program.cs index 06a4aa0..d3f5831 100644 --- a/ProgramInformationV2/Program.cs +++ b/ProgramInformationV2/Program.cs @@ -6,6 +6,7 @@ using ProgramInformationV2.Components; using ProgramInformationV2.Data.Cache; using ProgramInformationV2.Data.CourseImport; +using ProgramInformationV2.Data.CourseraImport; using ProgramInformationV2.Data.DataContext; using ProgramInformationV2.Data.DataHelpers; using ProgramInformationV2.Data.FieldList; @@ -58,6 +59,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(b => OpenSearchFactory.CreateClient(builder.Configuration["SearchUrl"], builder.Configuration["AccessKey"], builder.Configuration["SecretKey"], bool.Parse(builder.Configuration["SearchDebug"] ?? "false"))); builder.Services.AddSingleton(b => OpenSearchFactory.CreateLowLevelClient(builder.Configuration["SearchUrl"], builder.Configuration["AccessKey"], builder.Configuration["SecretKey"], bool.Parse(builder.Configuration["SearchDebug"] ?? "false"))); From 76dec12a1b8a8343137cd167d7d53b00ad6ced12 Mon Sep 17 00:00:00 2001 From: Bryan Jonker Date: Tue, 9 Jun 2026 17:12:35 -0500 Subject: [PATCH 2/2] Add user interface --- .../Components/Pages/Course/CourseraImport.razor.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs b/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs index 87e3847..3c32b1a 100644 --- a/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs +++ b/ProgramInformationV2/Components/Pages/Course/CourseraImport.razor.cs @@ -91,12 +91,18 @@ protected async Task Search() { protected async Task SendImport() { await Layout.AddMessage("Starting to add courses - please wait"); + var success = 0; + var failedTitles = new List(); foreach (var course in ListOfCourseraCoursesSelected) { var newCourse = await CourseraImportManager.GetCourse(_sourceCode, course.Key); if (await CourseSetter.SetCourse(newCourse) != "") { await Layout.AddMessage("Course added: " + newCourse.Title); + success++; + } else { + failedTitles.Add(string.IsNullOrWhiteSpace(newCourse.Title) ? "unknown course" : newCourse.Title); } } + await Layout.AddMessage($"Total courses added: {success}. Failed items: {(failedTitles.Count == 0 ? "none" : string.Join(", ", failedTitles))}"); } } }