Skip to content
Merged
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
5 changes: 5 additions & 0 deletions SubathonManager.Core/Events/SettingsEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ namespace SubathonManager.Core.Events;
public static class SettingsEvents {
public static event Action<bool>? SettingsUnsavedChanges;
public static event Action? EventVisibilityChanged;
public static event Action<string>? PrimaryCurrencyChanged;
public static event Action? HotLinkToDevTunnelsRequested;
public static event Action<SubathonEventSource, string?>? HotLinkToSourceRequested;

public static void RaiseSettingsUnsavedChanges(bool hasPendingChanges) {
SettingsUnsavedChanges?.Invoke(hasPendingChanges);
}

public static void RaisePrimaryCurrencyChanged(string currency) {
PrimaryCurrencyChanged?.Invoke(currency);
}

public static void RaiseEventVisibilityChanged() {
EventVisibilityChanged?.Invoke();
}
Expand Down
32 changes: 32 additions & 0 deletions SubathonManager.Core/Utils.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
Expand Down Expand Up @@ -192,6 +193,37 @@ public static string EscapeCsv(string? value) {
return value;
}

public static string DescribeTokenPointRate(string? pointsPer100, string unitPlural, string? unitSingular = null) {
if (!decimal.TryParse(pointsPer100, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal points))
return "";
if (points < 0.0001m) return "";

if (points > 100m)
return $"= 1 {unitSingular ?? unitPlural.TrimEnd('s')} / {PointsLabel(points / 100m)}";

decimal needed = Math.Ceiling(100m / points);
string unit = needed == 1 ? unitSingular ?? unitPlural.TrimEnd('s') : unitPlural;
return $"= {needed.ToString("N0", CultureInfo.InvariantCulture)} {unit} / point";
}

public static string DescribeMoneyPointRate(string? pointsPerUnit, string? currency) {
if (!decimal.TryParse(pointsPerUnit, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal points))
return "";
if (points < 0.0001m) return "";

string unit = string.IsNullOrWhiteSpace(currency) ? "" : $" {currency.Trim()}";
if (points > 100m) return $"= 1{unit} / {PointsLabel(points)}";

decimal needed = Math.Ceiling(100m / points) / 100m;
return $"= {needed.ToString("#,##0.##", CultureInfo.InvariantCulture)}{unit} / point";
}

private static string PointsLabel(decimal points) {
return points == 1m
? "point"
: $"{points.ToString("#,##0.##", CultureInfo.InvariantCulture)} points";
}

public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, SubathonEventType? eventType) {
double modifier = 1;
if (!eventType.IsToken())
Expand Down
7 changes: 7 additions & 0 deletions SubathonManager.Data/DbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using SubathonManager.Core;
using SubathonManager.Core.Enums;
using SubathonManager.Core.Models;
using SubathonManager.Data.Widgets;

// ReSharper disable NullableWarningSuppressionIsUsed

Expand Down Expand Up @@ -67,6 +68,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) {
.HasForeignKey(w => w.RouteId)
.OnDelete(DeleteBehavior.Cascade); // deleting route deletes its widgets

modelBuilder.Entity<Widget>()
.Property(w => w.HtmlPath)
.HasConversion(
v => WidgetPathStore.ToStored(v),
v => WidgetPathStore.ToAbsolute(v));

modelBuilder.Entity<Widget>()
.HasMany(w => w.CssVariables)
.WithOne(cv => cv.Widget)
Expand Down
22 changes: 2 additions & 20 deletions SubathonManager.Data/Widgets/WidgetCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,29 +238,11 @@ public static async Task<bool> DeleteAsync(IDbContextFactory<AppDbContext> facto
}

public static string ToAbsolutePath(string storedPath) {
try {
return Path.IsPathRooted(storedPath)
? storedPath
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), storedPath));
}
catch {
return storedPath;
}
return WidgetPathStore.ToAbsolute(storedPath);
}

private static string ToStoredPath(string absolute) {
try {
string full = Path.GetFullPath(absolute);
string baseDir = Directory.GetCurrentDirectory();
string relative = Path.GetRelativePath(baseDir, full);

return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)
? full
: relative;
}
catch {
return absolute;
}
return WidgetPathStore.ToStored(absolute);
}

private static IEnumerable<string> EnumeratePackages(string root) {
Expand Down
77 changes: 77 additions & 0 deletions SubathonManager.Data/Widgets/WidgetPathRepair.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SubathonManager.Core.Interfaces;
using SubathonManager.Core.Models;

namespace SubathonManager.Data.Widgets;

public static class WidgetPathRepair {
private static readonly string[] Anchors = ["presets", "imports"];

public static int Run(IDbContextFactory<AppDbContext> factory, ILogger? logger = null) {
WidgetPackPaths.InvalidateResolveCache();
WidgetPackPaths.InvalidateVersionCache();

using AppDbContext db = factory.CreateDbContext();
List<Widget> widgets = db.Widgets.ToList();

var repaired = 0;
foreach (Widget widget in widgets) {
if (WidgetFiles.Current.Exists(widget.HtmlPath)) continue;
if (!TryRebase(widget.HtmlPath, out string rebased)) continue;

logger?.LogInformation("Repointed widget {Name} ({Id}) from {Old} to {New}",
widget.Name, widget.Id, widget.HtmlPath, rebased);

widget.HtmlPath = rebased;
repaired++;
}

if (repaired > 0) {
db.SaveChanges();
WidgetPackPaths.InvalidateResolveCache();
}

return repaired;
}

public static int Normalize(IDbContextFactory<AppDbContext> factory) {
using AppDbContext db = factory.CreateDbContext();
List<Widget> widgets = db.Widgets.ToList();

var rewritten = 0;
foreach (Widget widget in widgets) {
if (Path.IsPathRooted(WidgetPathStore.ToStored(widget.HtmlPath))) continue;

db.Entry(widget).Property(w => w.HtmlPath).IsModified = true;
rewritten++;
}

if (rewritten > 0) db.SaveChanges();
return rewritten;
}

public static bool TryRebase(string storedPath, out string repaired) {
repaired = string.Empty;
if (string.IsNullOrWhiteSpace(storedPath)) return false;

string[] parts = storedPath.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries);

for (var i = parts.Length - 2; i >= 0; i--) {
string segment = parts[i];
int anchor = Array.FindIndex(Anchors,
a => string.Equals(a, segment, StringComparison.OrdinalIgnoreCase));
if (anchor < 0) continue;

string[] segments = [Path.GetFullPath(Anchors[anchor]), .. parts[(i + 1)..]];
string candidate = Path.GetFullPath(Path.Combine(segments));

if (!WidgetFiles.Current.Exists(candidate)) continue;

repaired = candidate;
return true;
}

return false;
}
}
33 changes: 33 additions & 0 deletions SubathonManager.Data/Widgets/WidgetPathStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace SubathonManager.Data.Widgets;

public static class WidgetPathStore {
public static string ToStored(string? absolute) {
if (string.IsNullOrWhiteSpace(absolute)) return absolute ?? string.Empty;

try {
string full = Path.GetFullPath(absolute);
string baseDir = Directory.GetCurrentDirectory();
string relative = Path.GetRelativePath(baseDir, full);

return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)
? full
: relative;
}
catch {
return absolute;
}
}

public static string ToAbsolute(string? stored) {
if (string.IsNullOrWhiteSpace(stored)) return stored ?? string.Empty;

try {
return Path.IsPathRooted(stored)
? stored
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), stored));
}
catch {
return stored;
}
}
}
48 changes: 48 additions & 0 deletions SubathonManager.Tests/CoreUnitTests/UtilsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,54 @@ public void ParseDurationString() {
Assert.Equal(TimeSpan.FromSeconds(87), Utils.ParseDurationString("0:87")); // technically valid
}

[Fact]
public void DescribeTokenPointRate() {
Assert.Equal("= 295 bits / point", Utils.DescribeTokenPointRate("0.34", "bits"));
Assert.Equal("= 300 bits / point", Utils.DescribeTokenPointRate("0.334", "bits"));
Assert.Equal("= 200 bits / point", Utils.DescribeTokenPointRate("0.5", "bits"));
Assert.Equal("= 100 bits / point", Utils.DescribeTokenPointRate("1", "bits"));
Assert.Equal("= 10 beets / point", Utils.DescribeTokenPointRate("10", "beets"));
Assert.Equal("= 1 bit / point", Utils.DescribeTokenPointRate("100", "bits"));
Assert.Equal("= 1 kudos / point", Utils.DescribeTokenPointRate("100", "kudos", "kudos"));

Assert.Equal("= 1 bit / 2.5 points", Utils.DescribeTokenPointRate("250", "bits"));
Assert.Equal("= 1 bit / 200 points", Utils.DescribeTokenPointRate("20000", "bits"));
Assert.Equal("= 1 beet / 1.5 points", Utils.DescribeTokenPointRate("150", "beets"));
Assert.Equal("= 1 kudos / 12,345.67 points", Utils.DescribeTokenPointRate("1234567", "kudos", "kudos"));
Assert.Equal("= 10,000 tokens / point", Utils.DescribeTokenPointRate("0.01", "tokens"));

Assert.Equal("", Utils.DescribeTokenPointRate("0", "bits"));
Assert.Equal("", Utils.DescribeTokenPointRate("-5", "bits"));
Assert.Equal("", Utils.DescribeTokenPointRate("", "bits"));
Assert.Equal("", Utils.DescribeTokenPointRate(null, "bits"));
Assert.Equal("", Utils.DescribeTokenPointRate("abc", "bits"));
Assert.Equal("", Utils.DescribeTokenPointRate("0.00000000000000000000000000001", "bits"));
}

[Fact]
public void DescribeMoneyPointRate() {
Assert.Equal("= 2.95 USD / point", Utils.DescribeMoneyPointRate("0.34", "USD"));
Assert.Equal("= 0.34 CAD / point", Utils.DescribeMoneyPointRate("3", "CAD"));
Assert.Equal("= 0.5 EUR / point", Utils.DescribeMoneyPointRate("2", "EUR"));
Assert.Equal("= 1 USD / point", Utils.DescribeMoneyPointRate("1", "USD"));
Assert.Equal("= 0.01 USD / point", Utils.DescribeMoneyPointRate("100", "USD"));

Assert.Equal("= 1 USD / 20,000 points", Utils.DescribeMoneyPointRate("20000", "USD"));
Assert.Equal("= 1 CAD / 150.5 points", Utils.DescribeMoneyPointRate("150.5", "CAD"));
Assert.Equal("= 1 / 200 points", Utils.DescribeMoneyPointRate("200", ""));
Assert.Equal("= 10 JPY / point", Utils.DescribeMoneyPointRate("0.1", "JPY"));
Assert.Equal("= 1,000 USD / point", Utils.DescribeMoneyPointRate("0.001", "USD"));
Assert.Equal("= 2.95 / point", Utils.DescribeMoneyPointRate("0.34", ""));
Assert.Equal("= 3 / point", Utils.DescribeMoneyPointRate("0.334", ""));

Assert.Equal("", Utils.DescribeMoneyPointRate("0", "USD"));
Assert.Equal("", Utils.DescribeMoneyPointRate("-2", "USD"));
Assert.Equal("", Utils.DescribeMoneyPointRate("", "USD"));
Assert.Equal("", Utils.DescribeMoneyPointRate(null, "USD"));
Assert.Equal("", Utils.DescribeMoneyPointRate("abc", "USD"));
Assert.Equal("", Utils.DescribeMoneyPointRate("0.00000000000000000000000000001", "USD"));
}

[Fact]
public void GenerateHashFromString() {
Assert.Equal(Guid.Parse("bdc43e17-41dc-fb50-8c1c-54a72b1ec93e"),
Expand Down
Loading
Loading