Files
vtuber-awards/Backend/Common/SeasonMappings.cs
T
AzuTear 6b3b0360e7
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s
Add winner archive, host image upload and live demo data
Deliver the demo-ready feature set and seed data so the live site can be
presented end to end:

- Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager
  and public archive surface, backed by AddArchivedWinners migration.
- Host presentation: host image upload and artist name on SiteSettings with
  public image endpoint and supporting migrations.
- Clip submissions: idempotent table-ensure migration plus current-season
  demo clips for review workflows.
- Demo seed data: sponsors, share links and 2025 archived winners, with a
  guarded RemoveDemoSeasons cleanup; all seeds guard against real data.
- EnsureRuntimeSchemaParity migration to align runtime schema defensively.
- Admin/home UI refinements; remove unused team role permissions modal and
  dead share-quick-links code.

All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT)
and skip when real season data is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:47:52 +02:00

264 lines
10 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using Backend.Contracts;
using Backend.Domain;
using System.Net;
namespace Backend.Common;
public static class SeasonMappings
{
private static readonly Regex HtmlBreakRegex = new(@"<\s*br\s*/?>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex HtmlListItemOpenRegex = new(@"<\s*li\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex HtmlBlockCloseRegex = new(@"</\s*(p|div|li|ul|ol|h1|h2|h3|h4|h5|h6)\s*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex HtmlTagRegex = new(@"<[^>]*>", RegexOptions.Compiled);
private static readonly Regex MultiNewlineRegex = new(@"\n{3,}", RegexOptions.Compiled);
public static bool IsSeasonScheduleValid(
DateOnly nominationStartsAt,
DateOnly nominationEndsAt,
DateOnly votingStartsAt,
DateOnly votingEndsAt,
DateOnly reviewStartsAt,
DateOnly reviewEndsAt,
DateOnly showDate)
{
return nominationStartsAt <= nominationEndsAt
&& nominationEndsAt <= votingStartsAt
&& votingStartsAt <= votingEndsAt
&& votingEndsAt <= reviewStartsAt
&& reviewStartsAt <= reviewEndsAt
&& reviewEndsAt <= showDate;
}
public static string BuildProfileUrl(string platform, string channelSlug)
{
var normalizedPlatform = platform.Trim().ToLowerInvariant();
var platformKey = new string(normalizedPlatform.Where(char.IsLetterOrDigit).ToArray());
var slug = channelSlug.Trim();
var cleanSlug = slug.TrimStart('@');
if (slug.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
slug.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return slug;
}
if (string.IsNullOrWhiteSpace(cleanSlug))
{
return "#";
}
if (string.IsNullOrWhiteSpace(platformKey))
{
return cleanSlug.Contains('.') ? $"https://{cleanSlug}" : "#";
}
return platformKey switch
{
"artstation" => $"https://www.artstation.com/{cleanSlug}",
"bilibili" => $"https://space.bilibili.com/{cleanSlug}",
"bluesky" => $"https://bsky.app/profile/{cleanSlug}",
"booth" => $"https://{cleanSlug}.booth.pm",
"cake" => $"https://cake.gg/@{cleanSlug}",
"discord" => cleanSlug.Contains("discord.", StringComparison.OrdinalIgnoreCase) ? $"https://{cleanSlug}" : $"https://discord.gg/{cleanSlug}",
"deviantart" => $"https://www.deviantart.com/{cleanSlug}",
"facebook" => $"https://facebook.com/{cleanSlug}",
"fanbox" => $"https://{cleanSlug}.fanbox.cc",
"github" => $"https://github.com/{cleanSlug}",
"instagram" => $"https://instagram.com/{cleanSlug}",
"kick" => $"https://kick.com/{cleanSlug}",
"kofi" or "ko-fi" => $"https://ko-fi.com/{cleanSlug}",
"linktree" => $"https://linktr.ee/{cleanSlug}",
"mastodon" => cleanSlug.Contains('@') ? $"https://{cleanSlug.Split('@').Last()}/@{cleanSlug.Split('@').First()}" : $"https://mastodon.social/@{cleanSlug}",
"patreon" => $"https://patreon.com/{cleanSlug}",
"picarto" or "picartotv" => $"https://picarto.tv/{cleanSlug}",
"pinterest" => $"https://pinterest.com/{cleanSlug}",
"pixiv" => $"https://www.pixiv.net/users/{cleanSlug}",
"reddit" => $"https://reddit.com/user/{cleanSlug}",
"skeb" => $"https://skeb.jp/@{cleanSlug}",
"soundcloud" => $"https://soundcloud.com/{cleanSlug}",
"spotify" => $"https://open.spotify.com/user/{cleanSlug}",
"telegram" => $"https://t.me/{cleanSlug}",
"threads" => $"https://threads.net/@{cleanSlug}",
"tiktok" => $"https://tiktok.com/@{cleanSlug}",
"trovo" => $"https://trovo.live/s/{cleanSlug}",
"twitch" => $"https://twitch.tv/{cleanSlug}",
"tumblr" => $"https://{cleanSlug}.tumblr.com",
"vimeo" => $"https://vimeo.com/{cleanSlug}",
"website" or "link" => cleanSlug.Contains('.') ? $"https://{cleanSlug}" : $"https://{cleanSlug}.com",
"youtube" => cleanSlug.StartsWith("@", StringComparison.Ordinal) ? $"https://youtube.com/{cleanSlug}" : $"https://youtube.com/@{cleanSlug}",
"x" or "twitter" => $"https://x.com/{cleanSlug}",
_ => $"https://{platformKey}.com/{cleanSlug}",
};
}
public static (string Platform, string Slug) InferProfileMetadataFromUrl(string? value, string fallbackName = "")
{
var trimmed = value?.Trim() ?? string.Empty;
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
{
return ("Profil", fallbackName.Trim());
}
var host = uri.Host.Trim().ToLowerInvariant();
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var platform = host switch
{
var item when item.Contains("twitch.tv", StringComparison.Ordinal) => "Twitch",
var item when item.Contains("youtube.com", StringComparison.Ordinal) || item.Contains("youtu.be", StringComparison.Ordinal) => "YouTube",
var item when item.Contains("x.com", StringComparison.Ordinal) || item.Contains("twitter.com", StringComparison.Ordinal) => "X",
var item when item.Contains("instagram.com", StringComparison.Ordinal) => "Instagram",
var item when item.Contains("discord.gg", StringComparison.Ordinal) || item.Contains("discord.com", StringComparison.Ordinal) => "Discord",
var item when item.Contains("kick.com", StringComparison.Ordinal) => "Kick",
var item when item.Contains("cake.gg", StringComparison.Ordinal) => "Cake",
_ => "Profil",
};
var slug = segments.LastOrDefault() ?? string.Empty;
if (string.Equals(platform, "YouTube", StringComparison.Ordinal) && segments.Length > 0)
{
slug = segments.FirstOrDefault(segment => segment.StartsWith('@')) ?? slug;
}
slug = Uri.UnescapeDataString(slug).Trim().Trim('/');
if (slug.StartsWith('@') && !string.Equals(platform, "YouTube", StringComparison.Ordinal))
{
slug = slug[1..];
}
if (string.IsNullOrWhiteSpace(slug))
{
slug = fallbackName.Trim();
}
return (platform, slug);
}
public static string NormalizeSeasonStreamUrl(string? value)
{
var trimmed = value?.Trim() ?? string.Empty;
return string.IsNullOrWhiteSpace(trimmed) ? "https://twitch.tv/jayuhime" : trimmed;
}
public static string NormalizePlainTextContent(string? value)
{
var trimmed = value?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(trimmed))
{
return string.Empty;
}
var withBreakHints = HtmlBreakRegex.Replace(trimmed, "\n");
withBreakHints = HtmlListItemOpenRegex.Replace(withBreakHints, "- ");
withBreakHints = HtmlBlockCloseRegex.Replace(withBreakHints, "\n");
withBreakHints = HtmlTagRegex.Replace(withBreakHints, " ");
withBreakHints = WebUtility.HtmlDecode(withBreakHints).Replace("\r\n", "\n").Replace('\r', '\n');
var normalizedLines = withBreakHints
.Split('\n')
.Select(line => line.Trim())
.ToArray();
return MultiNewlineRegex.Replace(string.Join('\n', normalizedLines), "\n\n").Trim();
}
public static string NormalizePhaseKey(string? currentPhase)
{
var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty;
if (value.Contains("abgeschlossen") || value.Contains("archiv") || value.Contains("complete") || value.Contains("ended"))
{
return "completed";
}
if (value.Contains("show"))
{
return "show";
}
if (value.Contains("aufbereit") || value.Contains("vorbereit") || value.Contains("pause") || value.Contains("review") || value.Contains("auswert"))
{
return "preparation";
}
if (value.Contains("vot"))
{
return "voting";
}
if (value.Contains("nomin"))
{
return "nomination";
}
return "nomination";
}
public static string ResolveTimelineState(string itemKey, string currentPhaseKey)
{
string[] phaseOrder = ["nomination", "voting", "preparation", "show"];
if (string.Equals(currentPhaseKey, "completed", StringComparison.OrdinalIgnoreCase))
{
return phaseOrder.Contains(itemKey) ? "done" : "upcoming";
}
var itemIndex = Array.IndexOf(phaseOrder, itemKey);
var currentIndex = Array.IndexOf(phaseOrder, currentPhaseKey);
if (itemIndex < 0)
{
return "upcoming";
}
if (currentIndex < 0)
{
currentIndex = 0;
}
if (itemIndex < currentIndex)
{
return "done";
}
if (itemIndex == currentIndex)
{
return "active";
}
return "upcoming";
}
public static T[] DeserializeSiteArray<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<T[]>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
}
catch
{
return [];
}
}
public static PublicSocialLinkDto[] ReadSocialLinks(SiteSettings settings) =>
DeserializeSiteArray<PublicSocialLinkDto>(settings.SocialLinksJson);
public static FaqItemDto[] ReadFaqItems(SiteSettings settings) =>
DeserializeSiteArray<FaqItemDto>(settings.FaqJson);
public static FooterLinkDto[] BuildFooterLinks(SiteSettings settings) =>
[
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
new FooterLinkDto("sponsors", "Sponsoren & Partner", string.Empty, settings.SponsorsContent),
new FooterLinkDto("showacts", "Showacts", settings.ShowactsUrl, settings.ShowactsContent),
];
}