Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions GenHub/GenHub.Core/Constants/ImageCacheConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace GenHub.Core.Constants;

/// <summary>
/// Constants for image downloading, validation, and caching.
/// </summary>
public static class ImageCacheConstants
{
/// <summary>
/// Maximum allowed image download payload in bytes (15 MB).
/// </summary>
public const long MaxImageDownloadSizeBytes = 15L * 1024 * 1024;

/// <summary>
/// Maximum number of bitmap entries stored in the memory LRU cache.
/// </summary>
public const int MaxMemoryCacheEntries = 200;

/// <summary>
/// Maximum disk cache size in bytes (250 MB).
/// </summary>
public const long MaxDiskCacheSizeBytes = 250L * 1024 * 1024;

/// <summary>
/// Time-to-live for disk-cached images in days.
/// </summary>
public const int DiskCacheTtlDays = 30;

/// <summary>
/// Default HTTP timeout in seconds for downloading images.
/// </summary>
public const int DefaultTimeoutSeconds = 30;

/// <summary>
/// Maximum allowed HTTP redirects when downloading images.
/// </summary>
public const int MaxRedirects = 5;
}
45 changes: 45 additions & 0 deletions GenHub/GenHub.Core/Constants/UiConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ public static class UiConstants
/// </summary>
public const double DefaultProfileSettingsHeight = 700;

/// <summary>
/// Default width for the profile settings sidebar in pixels.
/// </summary>
public const double DefaultProfileSettingsSidebarWidth = 190;

/// <summary>
/// Minimum width for the profile settings sidebar (shows icons only) in pixels.
/// </summary>
public const double MinProfileSettingsSidebarWidth = 68;

/// <summary>
/// Maximum width for the profile settings sidebar in pixels.
/// </summary>
public const double MaxProfileSettingsSidebarWidth = 300;

// Status colors

/// <summary>
Expand All @@ -37,6 +52,36 @@ public static class UiConstants
/// </summary>
public const string StatusErrorColor = "#F44336";

/// <summary>
/// color used for downloaded status indicator.
/// </summary>
public const string StatusDownloadedColor = "#4CAF50";

/// <summary>
/// color used for not downloaded status indicator.
/// </summary>
public const string StatusNotDownloadedColor = "#B388FF";

/// <summary>
/// color used for update available status indicator.
/// </summary>
public const string StatusUpdateAvailableColor = "#FFB74D";

/// <summary>
/// svg path data for transparent checkmark icon.
/// </summary>
public const string TransparentCheckmarkIconPath = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z";

/// <summary>
/// svg path data for detailed download arrow icon into tray.
/// </summary>
public const string DownloadArrowIconPath = "M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z";

/// <summary>
/// svg path data for update sync icon.
/// </summary>
public const string UpdateSyncIconPath = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z";

/// <summary>
/// Default theme color for Generals content.
/// </summary>
Expand Down
145 changes: 145 additions & 0 deletions GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System;
using System.Net;
using System.Text.RegularExpressions;

namespace GenHub.Core.Helpers;

/// <summary>
/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities,
/// and normalizing text descriptions for display across the application.
/// </summary>
public static partial class HtmlTextHelper
{
/// <summary>
/// Converts an HTML snippet or formatted description into clean, normalized plain text:
/// - Replaces &lt;br&gt; and block element closures (&lt;/p&gt;, &lt;/div&gt;, etc.) with line breaks.
/// - Strips all remaining HTML tags.
/// - Decodes HTML entities (e.g., &amp;amp;, &amp;quot;, &amp;gt;, &amp;nbsp;).
/// - Normalizes whitespace and excessive blank lines.
/// - Uses the platform newline format.
/// </summary>
/// <param name="html">The raw HTML or formatted text string to normalize.</param>
/// <returns>Normalized plain text, or empty string if input is null or whitespace.</returns>
public static string NormalizeHtml(string? html)
{
if (string.IsNullOrWhiteSpace(html))
{
return string.Empty;
}

// 0. Remove script and style elements along with their contents
var text = ScriptTagRegex().Replace(html, string.Empty);
text = StyleTagRegex().Replace(text, string.Empty);

// 1. Convert <br> tags to newline
text = BrTagRegex().Replace(text, "\n");

// 2. Convert paragraph closing tags to double newline for paragraph separation
text = ParagraphCloseTagRegex().Replace(text, "\n\n");

// 3. Convert other block-level closing tags and <hr> tags to newline
text = BlockCloseTagRegex().Replace(text, "\n");

// 4. Strip all remaining HTML/XML tags
text = HtmlTagRegex().Replace(text, string.Empty);

// 5. Decode HTML entities (&nbsp;, &gt;, &quot;, &#39;, numeric entities, etc.)
text = WebUtility.HtmlDecode(text);

// 6. Normalize non-breaking spaces and line endings
text = text.Replace('\u00A0', ' ')
.Replace("\r\n", "\n")
.Replace('\r', '\n');

// 7. Clean trailing whitespace on lines and collapse excess blank lines
text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n");
text = ExcessBlankLinesRegex().Replace(text, "\n\n");

// 8. Trim and unify with environment newline
text = text.Trim();
text = text.Replace("\n", Environment.NewLine);

return text;
}

/// <summary>
/// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags,
/// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis.
/// </summary>
/// <param name="htmlOrText">The input HTML or text string.</param>
/// <param name="maxLength">Optional maximum character length including ellipsis.</param>
/// <returns>A single-line plain text summary.</returns>
public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null)
{
if (string.IsNullOrWhiteSpace(htmlOrText))
{
return string.Empty;
}

// Strip HTML if tags exist, decode entities, and normalize
var text = NormalizeHtml(htmlOrText);

// Collapse all newlines, tabs, and multiple spaces into a single space
text = MultiWhitespaceRegex().Replace(text, " ").Trim();

if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value)
{
return TruncateWithEllipsis(text, maxLength.Value);
}

return text;
}

/// <summary>
/// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated.
/// </summary>
/// <param name="text">The text to truncate.</param>
/// <param name="maxLength">The maximum allowed length (including the ellipsis).</param>
/// <returns>The truncated text with an ellipsis if it exceeded maxLength, or the original text.</returns>
public static string TruncateWithEllipsis(string? text, int maxLength)
{
if (string.IsNullOrWhiteSpace(text) || maxLength <= 0)
{
return string.Empty;
}

if (text.Length <= maxLength)
{
return text;
}

if (maxLength <= 3)
{
return text[..maxLength];
}

return string.Concat(text.AsSpan(0, maxLength - 3), "...");
}

[GeneratedRegex(@"<script\b[^>]*>[\s\S]*?</script>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ScriptTagRegex();

[GeneratedRegex(@"<style\b[^>]*>[\s\S]*?</style>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex StyleTagRegex();

[GeneratedRegex(@"<br\s*/?>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BrTagRegex();

[GeneratedRegex(@"</p\s*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ParagraphCloseTagRegex();

[GeneratedRegex(@"</?(?:div|li|h[1-6]|tr|section|article|blockquote|header|footer|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BlockCloseTagRegex();

[GeneratedRegex(@"</?[A-Za-z][^>]*>", RegexOptions.CultureInvariant)]
private static partial Regex HtmlTagRegex();

[GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)]
private static partial Regex TrailingWhitespaceBeforeNewlineRegex();

[GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)]
private static partial Regex ExcessBlankLinesRegex();

[GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
private static partial Regex MultiWhitespaceRegex();
}
23 changes: 23 additions & 0 deletions GenHub/GenHub.Core/Models/Enums/ContentState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace GenHub.Core.Models.Enums;

/// <summary>
/// Content state for UI display - determines which button to show.
/// </summary>
public enum ContentState
{
/// <summary>
/// Content has not been downloaded yet. Show "Download" button.
/// </summary>
NotDownloaded,

/// <summary>
/// Content exists locally but a newer version is available (same publisher+name, newer date).
/// Show "Update" button.
/// </summary>
UpdateAvailable,

/// <summary>
/// Content is downloaded and up-to-date. Show "Add to Profile" dropdown.
/// </summary>
Downloaded,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using System.Globalization;
using GenHub.Infrastructure.Converters;
using Xunit;

namespace GenHub.Tests.Core.Converters;

/// <summary>
/// Unit tests for <see cref="StripHtmlConverter"/>.
/// </summary>
public sealed class StripHtmlConverterTests
{
private readonly StripHtmlConverter _converter = new();

/// <summary>
/// Verifies Convert strips HTML tags and normalizes text.
/// </summary>
[Fact]
public void Convert_WithHtmlMarkup_StripsTags()
{
var input = "<p>Test <b>content</b> with <a href=\"#\">links</a>.</p>";
var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture);

Assert.Equal("Test content with links.", result);
}

/// <summary>
/// Verifies Convert with integer parameter truncates and single-lines text.
/// </summary>
[Fact]
public void Convert_WithMaxLenParameter_CleansToSingleLineAndTruncates()
{
var input = "<p>First line</p>\n\n<p>Second line with a lot of details here.</p>";
var result = _converter.Convert(input, typeof(string), 25, CultureInfo.InvariantCulture);

Assert.Equal("First line Second line...", result);
}

/// <summary>
/// Verifies Convert with string parameter parses integer and truncates.
/// </summary>
[Fact]
public void Convert_WithStringParameter_ParsesAndTruncates()
{
var input = "<p>First line</p>\n\n<p>Second line with a lot of details here.</p>";
var result = _converter.Convert(input, typeof(string), "25", CultureInfo.InvariantCulture);

Assert.Equal("First line Second line...", result);
}

/// <summary>
/// Verifies Convert handles non-string input by returning value untouched.
/// </summary>
[Fact]
public void Convert_WithScriptAndStyleTags_StripsContents()
{
var input = "<style>.hide { display: none; }</style><p>Hello World</p><script>alert('bad');</script>";
var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture);

Assert.Equal("Hello World", result);
}

/// <summary>
/// Verifies Convert handles non-string input by returning value untouched.
/// </summary>
[Fact]
public void Convert_NonStringValue_ReturnsOriginalValue()
{
var result = _converter.Convert(42, typeof(int), null, CultureInfo.InvariantCulture);
Assert.Equal(42, result);
}

/// <summary>
/// Verifies ConvertBack throws NotSupportedException.
/// </summary>
[Fact]
public void ConvertBack_ThrowsNotSupportedException()
{
Assert.Throws<NotSupportedException>(() =>
_converter.ConvertBack("test", typeof(string), null, CultureInfo.InvariantCulture));
}
}
Loading
Loading