diff --git a/src/settings-ui/Settings.UI/Converters/DateTimeOffsetToStringConverter.cs b/src/settings-ui/Settings.UI/Converters/DateTimeOffsetToStringConverter.cs new file mode 100644 index 0000000000..a766f33364 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/DateTimeOffsetToStringConverter.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public sealed partial class DateTimeOffsetToStringConverter : IValueConverter + { + /// + /// Gets or sets default .NET date format string. Can be overridden in XAML via ConverterParameter. + /// + public string Format { get; set; } = "MMMM yyyy"; + + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is null) + { + return string.Empty; + } + + var culture = GetCulture(language); + var format = parameter as string ?? Format; + + if (value is DateTimeOffset dto) + { + return dto.ToString(format, culture); + } + + if (value is DateTime dt) + { + return dt.ToString(format, culture); + } + + if (value is string s) + { + // Try to parse strings robustly using the culture; assume unspecified is universal to avoid local offset surprises + if (DateTimeOffset.TryParse(s, culture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedDto)) + { + return parsedDto.ToString(format, culture); + } + + if (DateTime.TryParse(s, culture, DateTimeStyles.AssumeLocal, out var parsedDt)) + { + return parsedDt.ToString(format, culture); + } + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + + private static CultureInfo GetCulture(string language) + { + try + { + if (!string.IsNullOrWhiteSpace(language)) + { + return new CultureInfo(language); + } + } + catch + { + // ignore and fall back + } + + // Prefer UI culture for display + return CultureInfo.CurrentUICulture; + } + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/ReleaseNotesItem.cs b/src/settings-ui/Settings.UI/Helpers/ReleaseNotesItem.cs new file mode 100644 index 0000000000..e8701d0496 --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/ReleaseNotesItem.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public sealed class ReleaseNotesItem + { + public string Title { get; set; } + + public string Markdown { get; set; } + + public DateTimeOffset PublishedDate { get; set; } + + public string VersionGroup { get; set; } + + public string HeaderImageUri { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index eac4332e27..1ad80fc6e4 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -26,6 +26,8 @@ + + @@ -156,7 +158,16 @@ Always - + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + MSBuild:Compile diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotePage.xaml new file mode 100644 index 0000000000..5c7e85c557 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotePage.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotePage.xaml.cs new file mode 100644 index 0000000000..c9836da129 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotePage.xaml.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.UI.Xaml.Navigation; +using Windows.Foundation; +using Windows.Foundation.Collections; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + public sealed partial class ReleaseNotePage : Page + { + public ReleaseNotePage() + { + InitializeComponent(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + if (e.Parameter is ReleaseNotesItem item) + { + MarkdownBlock.Text = item.Markdown ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(item.HeaderImageUri) && + Uri.TryCreate(item.HeaderImageUri, UriKind.Absolute, out var uri)) + { + HeaderImage.Source = new BitmapImage(uri); + HeaderImage.Visibility = Visibility.Visible; + } + else + { + HeaderImage.Source = null; + HeaderImage.Visibility = Visibility.Collapsed; + } + } + + base.OnNavigatedTo(e); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotesPage.xaml new file mode 100644 index 0000000000..05d9e1aad4 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotesPage.xaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotesPage.xaml.cs new file mode 100644 index 0000000000..2f73a52bac --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ReleaseNotesPage.xaml.cs @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Controls; +using global::PowerToys.GPOWrapper; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.OOBE.Enums; +using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + public sealed partial class ReleaseNotesPage : Page + { + public ReleaseNotesPage() + { + InitializeComponent(); + } + + // Your original regex constants + private const string RemoveInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+## Highlights"; + private const string RemoveHotFixInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+$"; + private const RegexOptions RemoveInstallerHashesRegexOptions = + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + + // Image extraction regexes (Markdown image + HTML ) + private static readonly Regex MdImageRegex = + new( + @"!\[(?:[^\]]*)\]\((?[^)\s]+)(?:\s+""[^""]*"")?\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex HtmlImageRegex = + new( + @"]*\s+src\s*=\s*[""'](?[^""']+)[""'][^>]*>", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // PR URL normalization: + // 1) Markdown links whose *text* is the full PR URL -> make text "#12345" + private static readonly Regex MdLinkWithPrUrlTextRegex = + new( + @"\[(?https?://github\.com/microsoft/PowerToys/pull/(?\d+))\]\(\k\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // 2) Bare PR URLs -> turn into "[#12345](url)" + private static readonly Regex BarePrUrlRegex = + new( + @"(?https?://github\.com/microsoft/PowerToys/pull/(?\d+))(?!\))", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public ObservableCollection ReleaseItems { get; } = new(); + + // Fetch, group (by Major.Minor), clean, extract first image, and build items + private async Task> GetGroupedReleaseNotesAsync() + { + // Fetch GitHub releases using system proxy & user-agent + using var proxyClientHandler = new HttpClientHandler + { + DefaultProxyCredentials = CredentialCache.DefaultCredentials, + Proxy = WebRequest.GetSystemWebProxy(), + PreAuthenticate = true, + }; + + using var client = new HttpClient(proxyClientHandler); + client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "PowerToys"); + + string json = await client.GetStringAsync("https://api.github.com/repos/microsoft/PowerToys/releases"); + + // NOTE: PowerToysReleaseInfo + SourceGenerationContextContext are assumed to exist in your project + IList releases = + JsonSerializer.Deserialize>( + json, SourceGenerationContextContext.Default.IListPowerToysReleaseInfo)!; + + // Prepare hash-removal regexes + var removeHashRegex = new Regex(RemoveInstallerHashesRegex, RemoveInstallerHashesRegexOptions); + var removeHotfixHashRegex = new Regex(RemoveHotFixInstallerHashesRegex, RemoveInstallerHashesRegexOptions); + + // Parse versions; keep ones that contain x.y.z (handles "v0.93.2" etc.) + var parsed = releases + .Select(r => new + { + Release = r, + Version = TryParseSemVer(r.TagName ?? r.Name, out var v) ? v : null, + }) + .Where(x => x.Version is not null) + .ToList(); + + // Group by Major.Minor (e.g., "0.93"), order groups by newest published date + var groups = parsed + .GroupBy(x => $"{x.Version!.Major}.{x.Version!.Minor}") + .OrderByDescending(g => g.Max(x => x.Release.PublishedDate)) + .ToList(); + + var items = new List(); + + foreach (var g in groups) + { + // Order subreleases by version (patch desc), then date desc + var ordered = g.OrderByDescending(x => x.Version) + .ThenByDescending(x => x.Release.PublishedDate) + .ToList(); + + // Title is the highest patch tag (e.g., "0.93.2"), trimmed of any leading 'v' + var top = ordered.First(); + var title = TrimLeadingV(top.Release.TagName ?? top.Release.Name); + + var sb = new StringBuilder(); + int counter = 0; + string headerImage = null; + + for (int i = 0; i < ordered.Count; i++) + { + var r = ordered[i].Release; + + // Clean installer hash sections + var cleaned = removeHashRegex.Replace(r.ReleaseNotes ?? string.Empty, "\r\n### Highlights"); + cleaned = cleaned.Replace("[github-current-release-work]", $"[github-current-release-work{++counter}]"); + cleaned = removeHotfixHashRegex.Replace(cleaned, string.Empty); + + // Capture & remove FIRST image across the whole group (only once) + if (headerImage is null) + { + var (withoutFirstImage, foundUrl) = RemoveFirstImageAndGetUrl(cleaned); + if (!string.IsNullOrWhiteSpace(foundUrl)) + { + headerImage = foundUrl; + cleaned = withoutFirstImage; + } + } + + // Normalize PR links to show "#12345" like GitHub + cleaned = NormalizeGitHubPrLinks(cleaned); + + if (i > 0) + { + // Horizontal rule between subreleases within the same group + sb.AppendLine("\r\n---\r\n"); + } + + // Keep a per-subrelease header for context (optional) + var header = $"# {TrimLeadingV(r.TagName ?? r.Name)}"; + sb.AppendLine(header); + sb.AppendLine(cleaned); + } + + items.Add(new ReleaseNotesItem + { + Title = title, + VersionGroup = g.Key, + PublishedDate = ordered.Max(x => x.Release.PublishedDate), + Markdown = sb.ToString(), + HeaderImageUri = headerImage, + }); + } + + return items; + } + + // Turn "https://github.com/microsoft/PowerToys/pull/41853" into "[#41853](...)". + // Also, if the markdown link text equals the full URL, rewrite it to "#41853". + private static string NormalizeGitHubPrLinks(string markdown) + { + if (string.IsNullOrEmpty(markdown)) + { + return markdown; + } + + // Case 1: [https://github.com/.../pull/12345](https://github.com/.../pull/12345) + markdown = MdLinkWithPrUrlTextRegex.Replace( + markdown, + m => + { + var id = m.Groups["id"].Value; + var url = m.Groups["url"].Value; + return $"[#{id}]({url})"; + }); + + // Case 2: bare https://github.com/.../pull/12345 (not already inside link markup) + markdown = BarePrUrlRegex.Replace( + markdown, + m => + { + var id = m.Groups["id"].Value; + var url = m.Groups["url"].Value; + return $"[#{id}]({url})"; + }); + + return markdown; + } + + // Extract and remove the first image (Markdown or HTML) from a markdown string + private static (string Cleaned, string Url) RemoveFirstImageAndGetUrl(string markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + { + return (markdown, null); + } + + // Markdown image first: ![alt](url "title") + var m = MdImageRegex.Match(markdown); + if (m.Success) + { + var url = m.Groups["url"].Value.Trim(); + var cleaned = MdImageRegex.Replace(markdown, string.Empty, 1); + cleaned = CollapseExtraBlankLines(cleaned); + return (cleaned, url); + } + + // Fallback: HTML + m = HtmlImageRegex.Match(markdown); + if (m.Success) + { + var url = m.Groups["url"].Value.Trim(); + var cleaned = HtmlImageRegex.Replace(markdown, string.Empty, 1); + cleaned = CollapseExtraBlankLines(cleaned); + return (cleaned, url); + } + + return (markdown, null); + } + + private static string CollapseExtraBlankLines(string s) + { + s = s.Trim(); + s = Regex.Replace(s, @"(\r?\n){3,}", "\r\n\r\n"); + return s; + } + + // Try to parse the first x.y.z version found in a string (supports leading 'v') + private static bool TryParseSemVer(string s, out Version v) + { + v = null; + if (string.IsNullOrWhiteSpace(s)) + { + return false; + } + + var m = Regex.Match(s, @"(? + string.IsNullOrEmpty(s) ? s : (s.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? s[1..] : s); + + private async void Grid_Loaded(object sender, RoutedEventArgs e) + { + if (ReleaseItems.Count == 0) + { + var items = await GetGroupedReleaseNotesAsync(); + foreach (var item in items) + { + ReleaseItems.Add(item); + } + + if (ReleaseItems.Count > 0 && ReleasesList is not null) + { + ReleasesList.SelectedIndex = 0; + } + } + } + + private void ReleasesList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ReleasesList.SelectedItem is ReleaseNotesItem item) + { + ContentFrame.Navigate(typeof(ReleaseNotePage), item); + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml new file mode 100644 index 0000000000..82b4170618 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs new file mode 100644 index 0000000000..924914aa04 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Controls; +using global::PowerToys.GPOWrapper; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.OOBE.Enums; +using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using WinUIEx; + +namespace Microsoft.PowerToys.Settings.UI.SettingsXAML +{ + public sealed partial class ScoobeWindow : WindowEx + { + public ScoobeWindow() + { + InitializeComponent(); + this.ExtendsContentIntoTitleBar = true; + SetTitleBar(titleBar); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs index 06f7e222ce..62419505f8 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs @@ -14,6 +14,7 @@ using Microsoft.PowerToys.Settings.UI.Controls; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.SettingsXAML; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; @@ -373,7 +374,9 @@ namespace Microsoft.PowerToys.Settings.UI.Views private void WhatIsNewItem_Tapped(object sender, TappedRoutedEventArgs e) { - OpenWhatIsNewWindowCallback(); + // OpenWhatIsNewWindowCallback(); + ScoobeWindow window = new ScoobeWindow(); + window.Activate(); } private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)