新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
This commit is contained in:
lincube
2026-03-06 08:53:45 +08:00
parent 5d35e0d21c
commit de40471af6
37 changed files with 2949 additions and 142 deletions

View File

@@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
{
if (string.IsNullOrWhiteSpace(inputPath))
{
inputPath = ResolveImportedSchedulePathFromAppSettings();
inputPath = ResolveImportedSchedulePathFromComponentSettings();
}
var source = ResolveSource(inputPath, profileFileName, warnings);
@@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
return result;
}
private static string? ResolveImportedSchedulePathFromAppSettings()
private static string? ResolveImportedSchedulePathFromComponentSettings()
{
try
{
var snapshot = new AppSettingsService().Load();
var snapshot = new ComponentSettingsService().Load();
if (snapshot.ImportedClassSchedules.Count == 0)
{
return null;

View File

@@ -0,0 +1,403 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class ComponentSettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static readonly int[] SupportedCnrIntervals = [5, 10, 40, 60, 720, 1440];
private static readonly int[] SupportedDailyWordIntervals = [30, 60, 180, 360, 720, 1440];
private static readonly int[] SupportedBilibiliHotSearchIntervals = [5, 10, 15, 30, 60, 180];
private static string? _cachedPath;
private static ComponentSettingsSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
public ComponentSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public ComponentSettingsSnapshot Load()
{
try
{
lock (CacheGate)
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
ComponentSettingsSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = migratedSnapshot;
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new ComponentSettingsSnapshot();
}
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
}
catch
{
return new ComponentSettingsSnapshot();
}
}
public void Save(ComponentSettingsSnapshot snapshot)
{
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private ComponentSettingsSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch
{
return new ComponentSettingsSnapshot();
}
}
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
{
snapshot = new ComponentSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new ComponentSettingsSnapshot
{
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes
};
return true;
}
catch
{
return false;
}
}
private DateTime PersistSnapshotToDisk(ComponentSettingsSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
normalized.ActiveImportedClassScheduleId,
normalized.ImportedClassSchedules);
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
{
normalized.StudyEnvironmentShowDisplayDb = true;
}
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
.ToList();
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
return normalized;
}
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
{
if (schedules is null || schedules.Count == 0)
{
return [];
}
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
var id = schedule.Id?.Trim() ?? string.Empty;
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
{
continue;
}
if (!seenIds.Add(id))
{
continue;
}
result.Add(new ImportedClassScheduleSnapshot
{
Id = id,
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
FilePath = filePath
});
}
return result;
}
private static string NormalizeActiveScheduleId(
string? activeScheduleId,
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
{
var activeId = activeScheduleId?.Trim() ?? string.Empty;
if (schedules.Count == 0)
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(activeId))
{
return schedules[0].Id;
}
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
? activeId
: schedules[0].Id;
}
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
{
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
? "China Standard Time"
: timeZoneId.Trim();
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
}
private static int NormalizeCnrInterval(int minutes)
{
if (minutes <= 0)
{
return 60;
}
if (SupportedCnrIntervals.Contains(minutes))
{
return minutes;
}
return SupportedCnrIntervals
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(60);
}
private static int NormalizeDailyWordInterval(int minutes)
{
if (minutes <= 0)
{
return 360;
}
if (SupportedDailyWordIntervals.Contains(minutes))
{
return minutes;
}
return SupportedDailyWordIntervals
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(360);
}
private static int NormalizeBilibiliHotSearchInterval(int minutes)
{
if (minutes <= 0)
{
return 15;
}
if (SupportedBilibiliHotSearchIntervals.Contains(minutes))
{
return minutes;
}
return SupportedBilibiliHotSearchIntervals
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(15);
}
private void UpdateCache(ComponentSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class LegacyComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
public string? ActiveImportedClassScheduleId { get; set; }
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string>? WorldClockTimeZoneIds { get; set; }
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
}
}

View File

@@ -29,6 +29,11 @@ public sealed record DailyWordQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record Stcn24ForumPostsQuery(
string? Locale = null,
int? ItemCount = null,
bool ForceRefresh = false);
public sealed record ExchangeRateQuery(
string? BaseCurrency = null,
string? TargetCurrency = null,
@@ -84,6 +89,13 @@ public sealed record RecommendationApiOptions
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
public string SmartTeachForumApiTemplate { get; init; } =
"https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user";
public string SmartTeachForumBaseUrl { get; init; } = "https://forum.smart-teach.cn";
public string SmartTeachStcnKeyword { get; init; } = "STCN";
public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
@@ -226,6 +238,8 @@ public sealed record RecommendationApiOptions
public int DefaultDailyNewsCount { get; init; } = 2;
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
public int DefaultStcn24ForumPostCount { get; init; } = 4;
}
public interface IRecommendationInfoService
@@ -250,6 +264,10 @@ public interface IRecommendationInfoService
DailyWordQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
Stcn24ForumPostsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
ExchangeRateQuery query,
CancellationToken cancellationToken = default);

View File

@@ -36,6 +36,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record BilibiliHotSearchCacheEntry(BilibiliHotSearchSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record DailyWordCacheEntry(DailyWordSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record Stcn24ForumPostsCacheEntry(Stcn24ForumPostsSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ExchangeRateTableCacheEntry(
string BaseCurrency,
Dictionary<string, decimal> Rates,
@@ -52,7 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
private readonly RecommendationApiOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly object _cacheGate = new();
private readonly Dictionary<string, DailyArtworkCacheEntry> _dailyArtworkCacheBySource =
new(StringComparer.OrdinalIgnoreCase);
@@ -60,6 +61,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
private DailyNewsCacheEntry? _dailyNewsCache;
private BilibiliHotSearchCacheEntry? _bilibiliHotSearchCache;
private DailyWordCacheEntry? _dailyWordCache;
private Stcn24ForumPostsCacheEntry? _stcn24ForumPostsCache;
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
new(StringComparer.OrdinalIgnoreCase);
private int _dailyNewsRotationCursor;
@@ -106,6 +108,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_dailyNewsCache = null;
_bilibiliHotSearchCache = null;
_dailyWordCache = null;
_stcn24ForumPostsCache = null;
_exchangeRateCacheByBaseCurrency.Clear();
}
}
@@ -340,6 +343,53 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
lastError?.Message ?? "No available daily word from Youdao.");
}
public async Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
Stcn24ForumPostsQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new Stcn24ForumPostsQuery();
var targetCount = normalizedQuery.ItemCount.HasValue
? Math.Clamp(normalizedQuery.ItemCount.Value, 1, 12)
: Math.Clamp(_options.DefaultStcn24ForumPostCount, 1, 12);
if (!normalizedQuery.ForceRefresh &&
TryGetStcn24ForumPostsFromCache(out var cached) &&
cached.Items.Count >= targetCount)
{
var projectedSnapshot = cached with
{
Items = cached.Items.Take(targetCount).ToArray()
};
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Ok(projectedSnapshot);
}
try
{
var snapshot = await FetchStcn24ForumPostsSnapshotAsync(targetCount, cancellationToken);
if (snapshot.Items.Count == 0)
{
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Fail(
"upstream_empty_result",
"No STCN forum posts were returned.");
}
SetStcn24ForumPostsCache(snapshot);
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult<Stcn24ForumPostsSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
ExchangeRateQuery query,
CancellationToken cancellationToken = default)
@@ -762,6 +812,141 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
FetchedAt: DateTimeOffset.UtcNow);
}
private async Task<Stcn24ForumPostsSnapshot> FetchStcn24ForumPostsSnapshotAsync(
int targetCount,
CancellationToken cancellationToken)
{
var safeCount = Math.Clamp(targetCount, 1, 12);
var requestCount = Math.Clamp(Math.Max(safeCount * 3, 12), safeCount, 40);
var keyword = NormalizeInlineText(_options.SmartTeachStcnKeyword);
if (string.IsNullOrWhiteSpace(keyword))
{
keyword = "STCN";
}
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.SmartTeachForumApiTemplate,
Uri.EscapeDataString(keyword),
requestCount);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/vnd.api+json, application/json;q=0.9, */*;q=0.8");
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("Forum discussion list is missing.");
}
var usersById = new Dictionary<string, (string? DisplayName, string? AvatarUrl)>(StringComparer.OrdinalIgnoreCase);
if (root.TryGetProperty("included", out var includedArray) && includedArray.ValueKind == JsonValueKind.Array)
{
foreach (var entity in includedArray.EnumerateArray())
{
if (entity.ValueKind != JsonValueKind.Object)
{
continue;
}
var entityType = ReadString(entity, "type");
if (!string.Equals(entityType, "users", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var userId = ReadString(entity, "id");
if (string.IsNullOrWhiteSpace(userId))
{
continue;
}
var displayName = NormalizeInlineText(
ReadString(entity, "attributes", "displayName") ??
ReadString(entity, "attributes", "username"));
var avatarUrl = ResolveSmartTeachForumUrl(
ReadString(entity, "attributes", "avatarUrl"),
_options.SmartTeachForumBaseUrl);
usersById[userId.Trim()] = (
string.IsNullOrWhiteSpace(displayName) ? null : displayName,
avatarUrl);
}
}
var items = new List<Stcn24ForumPostItemSnapshot>(safeCount);
foreach (var discussionNode in dataArray.EnumerateArray())
{
if (discussionNode.ValueKind != JsonValueKind.Object || IsSmartTeachPinnedDiscussion(discussionNode))
{
continue;
}
var discussionId = ReadString(discussionNode, "id")?.Trim();
if (string.IsNullOrWhiteSpace(discussionId))
{
continue;
}
var title = NormalizeInlineText(ReadString(discussionNode, "attributes", "title"));
if (string.IsNullOrWhiteSpace(title))
{
continue;
}
var slug = NormalizeInlineText(ReadString(discussionNode, "attributes", "slug"));
var shareUrl = ResolveSmartTeachForumUrl(
ReadString(discussionNode, "attributes", "shareUrl"),
_options.SmartTeachForumBaseUrl);
var targetUrl = !string.IsNullOrWhiteSpace(shareUrl)
? shareUrl
: BuildSmartTeachDiscussionUrl(_options.SmartTeachForumBaseUrl, discussionId, slug);
if (string.IsNullOrWhiteSpace(targetUrl))
{
continue;
}
var authorId = ReadString(discussionNode, "relationships", "user", "data", "id");
string? authorDisplayName = null;
string? authorAvatarUrl = null;
if (!string.IsNullOrWhiteSpace(authorId) &&
usersById.TryGetValue(authorId.Trim(), out var userInfo))
{
authorDisplayName = userInfo.DisplayName;
authorAvatarUrl = userInfo.AvatarUrl;
}
var createdAtText = ReadString(discussionNode, "attributes", "createdAt");
var createdAt = TryParseDateTimeOffset(createdAtText);
items.Add(new Stcn24ForumPostItemSnapshot(
Title: title,
Url: targetUrl,
AuthorDisplayName: authorDisplayName,
AuthorAvatarUrl: authorAvatarUrl,
CreatedAt: createdAt));
if (items.Count >= safeCount)
{
break;
}
}
return new Stcn24ForumPostsSnapshot(
Provider: "SmartTeachForum",
Source: "智教联盟论坛 STCN",
Items: items,
FetchedAt: DateTimeOffset.UtcNow);
}
private async Task<string?> TryFetchBilibiliSearchPlaceholderAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.BilibiliSearchDefaultApiUrl))
@@ -864,6 +1049,31 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
return selection;
}
private bool TryGetStcn24ForumPostsFromCache(out Stcn24ForumPostsSnapshot snapshot)
{
lock (_cacheGate)
{
if (_stcn24ForumPostsCache is not null && _stcn24ForumPostsCache.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _stcn24ForumPostsCache.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetStcn24ForumPostsCache(Stcn24ForumPostsSnapshot snapshot)
{
lock (_cacheGate)
{
_stcn24ForumPostsCache = new Stcn24ForumPostsCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
}
private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot)
{
lock (_cacheGate)
@@ -1942,6 +2152,84 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
: null;
}
private static bool IsSmartTeachPinnedDiscussion(JsonElement discussionNode)
{
return ReadBoolean(discussionNode, "attributes", "isStickiest") ||
ReadBoolean(discussionNode, "attributes", "isSticky") ||
ReadBoolean(discussionNode, "attributes", "isTagSticky") ||
ReadBoolean(discussionNode, "attributes", "front") ||
ReadBoolean(discussionNode, "attributes", "frontpage");
}
private static string? ResolveSmartTeachForumUrl(string? rawUrl, string? baseUrl)
{
var normalizedAbsolute = NormalizeHttpUrl(rawUrl);
if (!string.IsNullOrWhiteSpace(normalizedAbsolute))
{
return normalizedAbsolute;
}
if (string.IsNullOrWhiteSpace(rawUrl) || string.IsNullOrWhiteSpace(baseUrl))
{
return null;
}
if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri))
{
return null;
}
var normalized = ResolveAbsoluteUrl(rawUrl, baseUri);
return NormalizeHttpUrl(normalized);
}
private static string? BuildSmartTeachDiscussionUrl(string? baseUrl, string discussionId, string slug)
{
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(discussionId))
{
return null;
}
if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var baseUri))
{
return null;
}
var normalizedId = discussionId.Trim();
var normalizedSlug = slug.Trim();
string path;
if (string.IsNullOrWhiteSpace(normalizedSlug))
{
path = $"/d/{normalizedId}";
}
else if (normalizedSlug.StartsWith($"{normalizedId}-", StringComparison.OrdinalIgnoreCase))
{
path = $"/d/{normalizedSlug}";
}
else
{
path = $"/d/{normalizedId}-{normalizedSlug}";
}
return new Uri(baseUri, path).ToString();
}
private static DateTimeOffset? TryParseDateTimeOffset(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
return DateTimeOffset.TryParse(
rawValue,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var value)
? value
: null;
}
private static string NormalizeInlineText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
@@ -1972,6 +2260,30 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
};
}
private static bool ReadBoolean(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return false;
}
var value = target.Value;
switch (value.ValueKind)
{
case JsonValueKind.True:
return true;
case JsonValueKind.False:
return false;
case JsonValueKind.String:
return bool.TryParse(value.GetString(), out var boolValue) && boolValue;
case JsonValueKind.Number:
return value.TryGetInt32(out var intValue) && intValue != 0;
default:
return false;
}
}
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
{
var current = node;
@@ -2010,7 +2322,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
try
{
var snapshot = _appSettingsService.Load();
var snapshot = _componentSettingsService.Load();
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
}
catch