This commit is contained in:
lincube
2026-07-11 16:11:41 +09:00
parent b5dab55fe2
commit a858860b3f
7 changed files with 1443 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.AirAppHost.RssReaderAirAppView">
<Grid ColumnDefinitions="250,*" Background="{DynamicResource ApplicationPageBackgroundThemeBrush}">
<Border Grid.Column="0" BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" BorderThickness="0,0,1,0" Padding="14">
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="AddButton" Content="Add source"/>
<Button x:Name="RefreshButton" Content="Refresh"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="4">
<Button x:Name="AllButton" HorizontalContentAlignment="Left" Content="All articles"/>
<Button x:Name="UnreadButton" HorizontalContentAlignment="Left" Content="Unread"/>
<Button x:Name="FavoritesButton" HorizontalContentAlignment="Left" Content="Favorites"/>
</StackPanel>
<Grid Grid.Row="2" RowDefinitions="*,Auto" RowSpacing="6">
<ListBox x:Name="SourcesList"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6">
<Button x:Name="EditSourceButton" Content="Edit" IsEnabled="False"/>
<Button x:Name="DeleteSourceButton" Content="Delete" IsEnabled="False"/>
</StackPanel>
</Grid>
<StackPanel Grid.Row="3" Spacing="6">
<Button x:Name="ImportButton" Content="Import OPML" HorizontalContentAlignment="Left"/>
<Button x:Name="ExportButton" Content="Export OPML" HorizontalContentAlignment="Left"/>
<Button x:Name="SettingsButton" Content="Settings" HorizontalContentAlignment="Left"/>
<TextBlock x:Name="StatusText" FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
</StackPanel>
</Grid>
</Border>
<Grid Grid.Column="1" ColumnDefinitions="330,*">
<Border BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" BorderThickness="0,0,1,0" Padding="12">
<Grid RowDefinitions="Auto,*" RowSpacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="ListTitle" Text="All articles" FontSize="20" FontWeight="SemiBold"/>
<Button x:Name="MarkAllReadButton" Grid.Column="1" Content="Mark all read"/>
</Grid>
<ListBox x:Name="EntriesList" Grid.Row="1">
<ListBox.ItemTemplate>
<DataTemplate x:CompileBindings="False">
<StackPanel Margin="4,8" Spacing="4">
<TextBlock Text="{Binding DisplayTitle}" FontWeight="{Binding Weight}" TextWrapping="Wrap" MaxLines="3"/>
<TextBlock Text="{Binding Metadata}" FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
<Grid Grid.Column="1" Margin="22" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
<TextBlock x:Name="ArticleTitle" Text="Select an article" FontSize="26" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock x:Name="ArticleMeta" Grid.Row="1" Opacity="0.6"/>
<ScrollViewer x:Name="ArticleTextScroller" Grid.Row="2">
<TextBlock x:Name="ArticleBody" Text="Your RSS articles will appear here." TextWrapping="Wrap" FontSize="15" LineHeight="24"/>
</ScrollViewer>
<Grid x:Name="ArticleHtmlHost" Grid.Row="2" IsVisible="False"/>
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8">
<Button x:Name="FavoriteButton" Content="☆ Favorite" IsEnabled="False"/>
<Button x:Name="OpenOriginalButton" Content="Open original" IsEnabled="False"/>
</StackPanel>
</Grid>
</Grid>
</Grid>
</UserControl>

View File

@@ -0,0 +1,318 @@
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.RssReader;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.AirAppHost;
public sealed partial class RssReaderAirAppView : UserControl, IDisposable
{
private readonly AirAppLaunchOptions _options;
private readonly RssReaderService _service = new();
private readonly LocalizationService _localization = new();
private readonly string _languageCode;
private string? _sourceId;
private bool _unreadOnly;
private bool _favoritesOnly;
private RssEntry? _selectedEntry;
private string? _lastHandledTargetEntryId;
private readonly DispatcherTimer _refreshTimer = new();
private NativeWebView? _articleWebView;
public RssReaderAirAppView() : this(AirAppLaunchOptions.Parse([])) { }
public RssReaderAirAppView(AirAppLaunchOptions options)
{
_options = options;
try
{
_languageCode = _localization.NormalizeLanguageCode(new AppSettingsService().Load().LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
InitializeComponent();
ApplyLocalization();
AddButton.Click += OnAdd;
RefreshButton.Click += OnRefresh;
AllButton.Click += (_, _) => SetFilter(null, false, false, L("rss.all_articles", "All articles"));
UnreadButton.Click += (_, _) => SetFilter(null, true, false, L("rss.unread", "Unread"));
FavoritesButton.Click += (_, _) => SetFilter(null, false, true, L("rss.favorites", "Favorites"));
SourcesList.SelectionChanged += OnSourceSelected;
EditSourceButton.Click += OnEditSource;
DeleteSourceButton.Click += OnDeleteSource;
EntriesList.SelectionChanged += OnEntrySelected;
MarkAllReadButton.Click += OnMarkAllRead;
FavoriteButton.Click += OnFavorite;
OpenOriginalButton.Click += OnOpenOriginal;
ImportButton.Click += OnImport;
ExportButton.Click += OnExport;
SettingsButton.Click += OnSettings;
_service.Changed += OnChanged;
_refreshTimer.Tick += async (_, _) => await RefreshAsync(force: false);
DetachedFromVisualTree += (_, _) => Dispose();
Reload();
ConfigureRefreshTimer();
_ = RefreshAsync(force: true);
}
public void Dispose()
{
_service.Changed -= OnChanged;
_refreshTimer.Stop();
if (_articleWebView is not null)
{
ArticleHtmlHost.Children.Remove(_articleWebView);
_articleWebView = null;
}
_service.Dispose();
}
private void OnChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Reload);
private void Reload()
{
var selectedSource = _sourceId;
SourcesList.ItemsSource = _service.GetSources().Select(source => new SourceItem(source)).ToArray();
var entries = _service.GetEntries(_sourceId, _unreadOnly, _favoritesOnly, 500);
EntriesList.ItemsSource = entries.Select(entry => new EntryItem(entry)).ToArray();
var targetEntryId = _options.TargetEntryId ?? _service.GetPendingEntryId();
if (!string.IsNullOrWhiteSpace(targetEntryId) && targetEntryId != _lastHandledTargetEntryId)
{
var target = entries.FirstOrDefault(entry => entry.Id == targetEntryId) ?? _service.GetEntry(targetEntryId);
if (target is not null) { _lastHandledTargetEntryId = targetEntryId; ShowEntry(target); }
}
StatusText.Text = string.Format(L("rss.status_counts", "{0} articles · {1} sources"), entries.Count, _service.GetSources().Count);
}
private void SetFilter(string? sourceId, bool unread, bool favorites, string title)
{
_sourceId = sourceId; _unreadOnly = unread; _favoritesOnly = favorites; ListTitle.Text = title; Reload();
}
private void OnSourceSelected(object? sender, SelectionChangedEventArgs e)
{
var hasSource = SourcesList.SelectedItem is SourceItem;
EditSourceButton.IsEnabled = hasSource;
DeleteSourceButton.IsEnabled = hasSource;
if (SourcesList.SelectedItem is SourceItem item) SetFilter(item.Source.Id, false, false, item.Source.Title);
}
private async void OnEditSource(object? sender, RoutedEventArgs e)
{
if (SourcesList.SelectedItem is not SourceItem item) return;
var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return;
var title = new TextBox { Text = item.Source.Title, Width = 390 };
var folder = new TextBox { Text = item.Source.Folder, Width = 390 };
var enabled = new CheckBox { Content = L("rss.enabled", "Enabled"), IsChecked = item.Source.IsEnabled };
var interval = new NumericUpDown { Minimum = 15, Maximum = 1440, Value = item.Source.RefreshIntervalMinutes ?? 30 };
var save = new Button { Content = L("rss.save", "Save"), HorizontalAlignment = HorizontalAlignment.Right };
var dialog = new Window { Title = L("rss.edit_source", "Edit RSS source"), Width = 460, Height = 360, CanResize = false };
save.Click += (_, _) =>
{
_service.UpdateSource(item.Source.Id, title.Text ?? item.Source.Title, folder.Text, enabled.IsChecked == true, (int)(interval.Value ?? 30));
dialog.Close();
};
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 10, Children = { new TextBlock { Text = L("rss.name", "Name") }, title, new TextBlock { Text = L("rss.folder", "Folder") }, folder, enabled, new TextBlock { Text = L("rss.refresh_interval_minutes", "Refresh interval (minutes)") }, interval, save } };
await dialog.ShowDialog(owner);
}
private async void OnDeleteSource(object? sender, RoutedEventArgs e)
{
if (SourcesList.SelectedItem is not SourceItem item) return;
var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return;
var preserve = new CheckBox { Content = L("rss.keep_favorites", "Keep favorited articles"), IsChecked = true };
var delete = new Button { Content = L("rss.delete", "Delete"), HorizontalAlignment = HorizontalAlignment.Right };
var cancel = new Button { Content = L("rss.cancel", "Cancel") };
var dialog = new Window { Title = L("rss.delete_source", "Delete RSS source"), Width = 420, Height = 220, CanResize = false };
delete.Click += (_, _) => { _service.DeleteSource(item.Source.Id, preserve.IsChecked == true); _sourceId = null; dialog.Close(); };
cancel.Click += (_, _) => dialog.Close();
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 14, Children = { new TextBlock { Text = string.Format(L("rss.delete_source_confirm", "Delete {0}?"), item.Source.Title), FontSize = 18, TextWrapping = TextWrapping.Wrap }, preserve, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { cancel, delete } } } };
await dialog.ShowDialog(owner);
}
private void OnEntrySelected(object? sender, SelectionChangedEventArgs e)
{
if (EntriesList.SelectedItem is EntryItem item) ShowEntry(item.Entry);
}
private void ShowEntry(RssEntry entry)
{
if (!entry.IsRead) _service.MarkRead(entry.Id);
_selectedEntry = entry with { IsRead = true };
ArticleTitle.Text = entry.Title;
ArticleMeta.Text = $"{entry.SourceTitle} · {entry.PublishedAt.ToLocalTime():g}{(string.IsNullOrWhiteSpace(entry.Author) ? string.Empty : " · " + entry.Author)}";
var html = string.IsNullOrWhiteSpace(entry.Content) ? entry.Summary : entry.Content;
ShowArticleContent(html);
FavoriteButton.IsEnabled = true;
FavoriteButton.Content = entry.IsFavorite ? $"★ {L("rss.favorited", "Favorited")}" : $"☆ {L("rss.favorite", "Favorite")}";
OpenOriginalButton.IsEnabled = !string.IsNullOrWhiteSpace(entry.Link);
}
private async void OnRefresh(object? sender, RoutedEventArgs e) => await RefreshAsync(force: true);
private async Task RefreshAsync(bool force)
{
RefreshButton.IsEnabled = false; StatusText.Text = L("rss.refreshing", "Refreshing…");
try { await _service.RefreshAllAsync(force); StatusText.Text = L("rss.updated", "Updated"); }
catch (Exception ex) { StatusText.Text = $"{L("rss.refresh_failed", "Refresh failed")}: {ex.Message}"; }
finally { RefreshButton.IsEnabled = true; Reload(); }
}
private void OnMarkAllRead(object? sender, RoutedEventArgs e) => _service.MarkAllRead(_sourceId);
private void OnFavorite(object? sender, RoutedEventArgs e)
{
if (_selectedEntry is null) return;
var favorite = !_selectedEntry.IsFavorite;
_service.SetFavorite(_selectedEntry.Id, favorite);
ShowEntry(_selectedEntry with { IsFavorite = favorite });
}
private void OnOpenOriginal(object? sender, RoutedEventArgs e)
{
if (!Uri.TryCreate(_selectedEntry?.Link, UriKind.Absolute, out var uri)) return;
Process.Start(new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true });
}
private void ShowArticleContent(string html)
{
var settings = _service.GetSettings();
var sanitized = RssReaderService.SanitizeHtml(html, settings.LoadRemoteImages);
ArticleBody.Text = RssReaderService.ToPlainText(sanitized);
try
{
var availability = WebView2RuntimeProbe.GetAvailability();
if (!availability.IsAvailable || string.IsNullOrWhiteSpace(sanitized))
{
ArticleHtmlHost.IsVisible = false;
ArticleTextScroller.IsVisible = true;
return;
}
_articleWebView ??= CreateArticleWebView();
var imagePolicy = settings.LoadRemoteImages ? "https: http: data:" : "data:";
var document = $$"""
<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src {{imagePolicy}}; style-src 'unsafe-inline'">
<style>body{font-family:system-ui,sans-serif;margin:24px;color:#202124;line-height:1.65;font-size:16px}img{max-width:100%;height:auto}a{color:#0067c0}pre{white-space:pre-wrap}</style>
</head><body>{{sanitized}}</body></html>
""";
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(document));
_articleWebView.Navigate(new Uri($"data:text/html;base64,{encoded}"));
ArticleTextScroller.IsVisible = false;
ArticleHtmlHost.IsVisible = true;
}
catch
{
ArticleHtmlHost.IsVisible = false;
ArticleTextScroller.IsVisible = true;
}
}
private NativeWebView CreateArticleWebView()
{
var webView = new NativeWebView();
ArticleHtmlHost.Children.Add(webView);
return webView;
}
private async void OnAdd(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var url = new TextBox { PlaceholderText = "https://example.com/feed.xml", Width = 430 };
var title = new TextBox { PlaceholderText = L("rss.optional_name", "Optional custom name"), Width = 430 };
var folder = new TextBox { PlaceholderText = L("rss.optional_folder", "Optional folder"), Width = 430 };
var message = new TextBlock { TextWrapping = TextWrapping.Wrap };
var add = new Button { Content = L("rss.probe_add", "Probe and add"), HorizontalAlignment = HorizontalAlignment.Right };
var dialog = new Window { Title = L("rss.add_source", "Add RSS source"), Width = 500, Height = 330, CanResize = false };
add.Click += async (_, _) =>
{
add.IsEnabled = false; message.Text = L("rss.checking_feed", "Checking feed…");
try
{
var probe = await _service.ProbeAsync(url.Text ?? string.Empty);
message.Text = $"{probe.Title} · {probe.Format}";
await _service.AddSourceAsync(url.Text ?? string.Empty, title.Text, folder.Text);
dialog.Close();
}
catch (Exception ex) { message.Text = ex.Message; add.IsEnabled = true; }
};
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 12, Children = { new TextBlock { Text = L("rss.feed_url", "Feed URL") }, url, new TextBlock { Text = L("rss.name", "Name") }, title, new TextBlock { Text = L("rss.folder", "Folder") }, folder, message, add } };
await dialog.ShowDialog(owner);
}
private async void OnImport(object? sender, RoutedEventArgs e)
{
var top = TopLevel.GetTopLevel(this); if (top is null) return;
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = L("rss.import_opml", "Import OPML"), AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("OPML") { Patterns = ["*.opml", "*.xml"] }] });
var path = files.FirstOrDefault()?.TryGetLocalPath(); if (path is null) return;
var result = await _service.ImportOpmlAsync(path); StatusText.Text = string.Format(L("rss.import_result", "Imported {0}, skipped {1}, failed {2}."), result.Added, result.Skipped, result.Failed);
}
private async void OnExport(object? sender, RoutedEventArgs e)
{
var top = TopLevel.GetTopLevel(this); if (top is null) return;
var file = await top.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = L("rss.export_opml", "Export OPML"), SuggestedFileName = "subscriptions.opml", FileTypeChoices = [new FilePickerFileType("OPML") { Patterns = ["*.opml"] }] });
var path = file?.TryGetLocalPath(); if (path is null) return; _service.ExportOpml(path); StatusText.Text = L("rss.exported", "OPML exported.");
}
private async void OnSettings(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return;
var settings = _service.GetSettings();
var interval = new ComboBox { ItemsSource = new[] { new Choice(15, string.Format(L("rss.minutes", "{0} minutes"), 15)), new Choice(30, string.Format(L("rss.minutes", "{0} minutes"), 30)), new Choice(60, string.Format(L("rss.minutes", "{0} minutes"), 60)), new Choice(0,L("rss.manual_only", "Manual only")) }, SelectedIndex = settings.RefreshIntervalMinutes switch { 15 => 0, 60 => 2, 0 => 3, _ => 1 } };
var images = new CheckBox { Content = L("rss.load_remote_images", "Load remote images"), IsChecked = settings.LoadRemoteImages };
var save = new Button { Content = L("rss.save", "Save"), HorizontalAlignment = HorizontalAlignment.Right };
var dialog = new Window { Title = L("rss.settings", "RSS settings"), Width = 420, Height = 240, CanResize = false };
save.Click += (_, _) => { var value = (interval.SelectedItem as Choice)?.Value ?? 30; _service.SaveSettings(settings with { RefreshIntervalMinutes = value, LoadRemoteImages = images.IsChecked == true }); ConfigureRefreshTimer(); dialog.Close(); };
dialog.Content = new StackPanel { Margin = new Avalonia.Thickness(24), Spacing = 14, Children = { new TextBlock { Text = L("rss.refresh_interval", "Refresh interval") }, interval, images, save } };
await dialog.ShowDialog(owner);
}
private sealed record SourceItem(RssSource Source) { public override string ToString() => string.IsNullOrWhiteSpace(Source.Folder) ? Source.Title : $"{Source.Folder} / {Source.Title}"; }
private void ConfigureRefreshTimer()
{
var minutes = _service.GetSettings().RefreshIntervalMinutes;
_refreshTimer.Stop();
if (minutes <= 0) return;
_refreshTimer.Interval = TimeSpan.FromMinutes(minutes);
_refreshTimer.Start();
}
private void ApplyLocalization()
{
AddButton.Content = L("rss.add_source", "Add source");
RefreshButton.Content = L("rss.refresh", "Refresh");
AllButton.Content = L("rss.all_articles", "All articles");
UnreadButton.Content = L("rss.unread", "Unread");
FavoritesButton.Content = L("rss.favorites", "Favorites");
EditSourceButton.Content = L("rss.edit", "Edit");
DeleteSourceButton.Content = L("rss.delete", "Delete");
ImportButton.Content = L("rss.import_opml", "Import OPML");
ExportButton.Content = L("rss.export_opml", "Export OPML");
SettingsButton.Content = L("rss.settings", "Settings");
ListTitle.Text = L("rss.all_articles", "All articles");
MarkAllReadButton.Content = L("rss.mark_all_read", "Mark all read");
ArticleTitle.Text = L("rss.select_article", "Select an article");
ArticleBody.Text = L("rss.article_placeholder", "Your RSS articles will appear here.");
FavoriteButton.Content = $"☆ {L("rss.favorite", "Favorite")}";
OpenOriginalButton.Content = L("rss.open_original", "Open original");
}
private string L(string key, string fallback) => _localization.GetString(_languageCode, key, fallback);
private sealed record EntryItem(RssEntry Entry)
{
public string DisplayTitle => (Entry.IsRead ? string.Empty : "● ") + Entry.Title;
public FontWeight Weight => Entry.IsRead ? FontWeight.Normal : FontWeight.SemiBold;
public string Metadata => $"{Entry.SourceTitle} · {Entry.PublishedAt.ToLocalTime():g}{(Entry.IsFavorite ? " · " : string.Empty)}";
}
private sealed record Choice(int Value, string Label) { public override string ToString() => Label; }
}

View File

@@ -0,0 +1,87 @@
using System.Text;
using System.Xml;
using System.Xml.Linq;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services.RssReader;
using Microsoft.Data.Sqlite;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class RssReaderServiceTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.RssTests", Guid.NewGuid().ToString("N"));
[Fact]
public void ComponentDefinition_UsesInfoCategoryAndEightByFourMinimum()
{
var registry = ComponentRegistry.CreateDefault();
Assert.True(registry.TryGetDefinition(BuiltInComponentIds.DesktopRssReader, out var definition));
Assert.Equal("Info", definition.Category);
Assert.Equal(8, definition.MinWidthCells);
Assert.Equal(4, definition.MinHeightCells);
Assert.Equal(DesktopComponentResizeMode.Free, definition.ResizeMode);
}
[Theory]
[InlineData("HTTPS://Example.COM:443/feed/#fragment", "https://example.com/feed")]
[InlineData("http://Example.com:80/rss", "http://example.com/rss")]
public void NormalizeFeedUrl_CanonicalizesAddress(string input, string expected)
{
Assert.Equal(expected, RssReaderService.NormalizeFeedUrl(input));
}
[Fact]
public void ParseProbe_ReadsRssAndAtom()
{
var rss = Encoding.UTF8.GetBytes("""<?xml version="1.0"?><rss version="2.0"><channel><title>News</title><link>https://example.com</link><description>Test</description><item><title>One</title><guid>1</guid></item></channel></rss>""");
var atom = Encoding.UTF8.GetBytes("""<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Atom News</title><id>x</id><updated>2026-01-01T00:00:00Z</updated><entry><title>One</title><id>1</id><updated>2026-01-01T00:00:00Z</updated></entry></feed>""");
Assert.Equal("News", RssReaderService.ParseProbe(rss, "https://example.com/rss").Title);
Assert.Equal("Atom", RssReaderService.ParseProbe(atom, "https://example.com/atom").Format);
}
[Fact]
public void ParseProbe_RejectsDtd()
{
var xml = Encoding.UTF8.GetBytes("""<?xml version="1.0"?><!DOCTYPE rss [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><rss version="2.0"><channel><title>&xxe;</title><description>x</description><link>https://example.com</link></channel></rss>""");
Assert.ThrowsAny<XmlException>(() => RssReaderService.ParseProbe(xml, "https://example.com/rss"));
}
[Fact]
public void SanitizeHtml_RemovesExecutableContentAndRemoteImages()
{
var sanitized = RssReaderService.SanitizeHtml("<script>alert(1)</script><p onclick=\"x()\">Hello</p><img src=\"https://x/img.png\"><a href=\"javascript:x()\">bad</a>", false);
Assert.DoesNotContain("script", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("onclick", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<img", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("javascript:", sanitized, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Settings_PersistAcrossServiceInstances()
{
Directory.CreateDirectory(_root);
var path = Path.Combine(_root, "rss.db");
using (var first = new RssReaderService(path)) first.SaveSettings(new RssReaderSettings(60, 45, true));
using var second = new RssReaderService(path);
Assert.Equal(new RssReaderSettings(60, 45, true), second.GetSettings());
}
[Fact]
public void ExportOpml_CreatesValidEmptyDocument()
{
Directory.CreateDirectory(_root);
using var service = new RssReaderService(Path.Combine(_root, "rss.db"));
var path = Path.Combine(_root, "feeds.opml");
service.ExportOpml(path);
var document = XDocument.Load(path);
Assert.Equal("opml", document.Root?.Name.LocalName);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
if (Directory.Exists(_root)) Directory.Delete(_root, true);
}
}

View File

@@ -0,0 +1,665 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.ServiceModel.Syndication;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services.RssReader;
public sealed record RssSource(
string Id,
string Title,
string FeedUrl,
string? SiteUrl,
string? Folder,
bool IsEnabled,
int? RefreshIntervalMinutes,
string? ETag,
DateTimeOffset? LastModified,
DateTimeOffset? LastRefresh,
string? LastError);
public sealed record RssEntry(
string Id,
string SourceId,
string SourceTitle,
string Title,
string? Link,
string? Author,
string Summary,
string Content,
DateTimeOffset PublishedAt,
bool IsRead,
bool IsFavorite);
public sealed record RssReaderSettings(int RefreshIntervalMinutes, int RetentionDays, bool LoadRemoteImages)
{
public static RssReaderSettings Default { get; } = new(30, 30, false);
}
public sealed record RssFeedProbe(string Title, string FeedUrl, string? SiteUrl, string Format);
public sealed record RssOpmlImportResult(int Added, int Skipped, int Failed);
public sealed class RssReaderService : IDisposable
{
private const int MaxResponseBytes = 5 * 1024 * 1024;
private const int MaxEntriesPerSource = 500;
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20);
private static readonly HttpClient SharedHttpClient = CreateHttpClient();
private readonly string _databasePath;
private readonly Timer _revisionTimer;
private long _lastRevision;
private bool _disposed;
public RssReaderService(string? databasePath = null)
{
_databasePath = databasePath ?? Path.Combine(
AppDataPathProvider.GetDataRoot(), "AirApps", "RssReader", "rss.db");
Directory.CreateDirectory(Path.GetDirectoryName(_databasePath)!);
InitializeDatabase();
_lastRevision = GetRevision();
_revisionTimer = new Timer(CheckRevision, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
public event EventHandler? Changed;
public string DatabasePath => _databasePath;
public IReadOnlyList<RssSource> GetSources()
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT id, title, feed_url, site_url, folder, enabled, refresh_interval,
etag, last_modified, last_refresh, last_error
FROM rss_sources ORDER BY COALESCE(folder, ''), title COLLATE NOCASE;
""";
using var reader = command.ExecuteReader();
var result = new List<RssSource>();
while (reader.Read())
{
result.Add(ReadSource(reader));
}
return result;
}
public IReadOnlyList<RssEntry> GetEntries(
string? sourceId = null,
bool unreadOnly = false,
bool favoritesOnly = false,
int limit = 100)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
var filters = new List<string>();
if (!string.IsNullOrWhiteSpace(sourceId))
{
filters.Add("e.source_id = $sourceId");
command.Parameters.AddWithValue("$sourceId", sourceId);
}
if (unreadOnly) filters.Add("e.is_read = 0");
if (favoritesOnly) filters.Add("e.is_favorite = 1");
command.Parameters.AddWithValue("$limit", Math.Clamp(limit, 1, 500));
command.CommandText = $"""
SELECT e.id, e.source_id, s.title, e.title, e.link, e.author,
e.summary, e.content, e.published_at, e.is_read, e.is_favorite
FROM rss_entries e JOIN rss_sources s ON s.id = e.source_id
{(filters.Count == 0 ? string.Empty : "WHERE " + string.Join(" AND ", filters))}
ORDER BY e.published_at DESC LIMIT $limit;
""";
using var reader = command.ExecuteReader();
var result = new List<RssEntry>();
while (reader.Read()) result.Add(ReadEntry(reader));
return result;
}
public RssEntry? GetEntry(string id)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT e.id, e.source_id, s.title, e.title, e.link, e.author,
e.summary, e.content, e.published_at, e.is_read, e.is_favorite
FROM rss_entries e JOIN rss_sources s ON s.id = e.source_id WHERE e.id = $id;
""";
command.Parameters.AddWithValue("$id", id);
using var reader = command.ExecuteReader();
return reader.Read() ? ReadEntry(reader) : null;
}
public async Task<RssFeedProbe> ProbeAsync(string feedUrl, CancellationToken cancellationToken = default)
{
var normalizedUrl = NormalizeFeedUrl(feedUrl);
var document = await DownloadFeedAsync(normalizedUrl, null, null, cancellationToken).ConfigureAwait(false);
if (document.NotModified || document.Bytes is null) throw new InvalidDataException("Feed returned no content.");
var parsed = ParseFeed(document.Bytes, normalizedUrl);
return new RssFeedProbe(parsed.Title, normalizedUrl, parsed.SiteUrl, parsed.Format);
}
public async Task<RssSource> AddSourceAsync(
string feedUrl,
string? title = null,
string? folder = null,
CancellationToken cancellationToken = default)
{
var normalizedUrl = NormalizeFeedUrl(feedUrl);
var document = await DownloadFeedAsync(normalizedUrl, null, null, cancellationToken).ConfigureAwait(false);
if (document.Bytes is null) throw new InvalidDataException("Feed returned no content.");
var parsed = ParseFeed(document.Bytes, normalizedUrl);
var sourceId = CreateStableId(normalizedUrl);
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO rss_sources(id, title, feed_url, site_url, folder, enabled, etag, last_modified, last_refresh)
VALUES($id, $title, $url, $site, $folder, 1, $etag, $modified, $refresh)
ON CONFLICT(feed_url) DO UPDATE SET
title = excluded.title, site_url = excluded.site_url,
folder = COALESCE(excluded.folder, rss_sources.folder), enabled = 1;
""";
command.Parameters.AddWithValue("$id", sourceId);
command.Parameters.AddWithValue("$title", string.IsNullOrWhiteSpace(title) ? parsed.Title : title.Trim());
command.Parameters.AddWithValue("$url", normalizedUrl);
command.Parameters.AddWithValue("$site", (object?)parsed.SiteUrl ?? DBNull.Value);
command.Parameters.AddWithValue("$folder", NormalizeOptional(folder));
command.Parameters.AddWithValue("$etag", (object?)document.ETag ?? DBNull.Value);
command.Parameters.AddWithValue("$modified", ToDb(document.LastModified));
command.Parameters.AddWithValue("$refresh", ToDb(DateTimeOffset.UtcNow));
command.ExecuteNonQuery();
}
UpsertEntries(connection, transaction, sourceId, normalizedUrl, parsed.Items);
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
return GetSources().Single(source => source.FeedUrl == normalizedUrl);
}
public void UpdateSource(string id, string title, string? folder, bool enabled, int? refreshIntervalMinutes)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
UPDATE rss_sources SET title=$title, folder=$folder, enabled=$enabled,
refresh_interval=$interval WHERE id=$id;
""";
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$title", string.IsNullOrWhiteSpace(title) ? "Untitled Feed" : title.Trim());
command.Parameters.AddWithValue("$folder", NormalizeOptional(folder));
command.Parameters.AddWithValue("$enabled", enabled ? 1 : 0);
command.Parameters.AddWithValue("$interval", refreshIntervalMinutes is null ? DBNull.Value : Math.Clamp(refreshIntervalMinutes.Value, 15, 1440));
command.ExecuteNonQuery();
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
}
public void DeleteSource(string id, bool preserveFavorites)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
if (preserveFavorites)
{
using var preserve = connection.CreateCommand();
preserve.Transaction = transaction;
preserve.CommandText = "DELETE FROM rss_entries WHERE source_id=$id AND is_favorite=0; UPDATE rss_sources SET enabled=0 WHERE id=$id;";
preserve.Parameters.AddWithValue("$id", id);
preserve.ExecuteNonQuery();
}
else
{
using var delete = connection.CreateCommand();
delete.Transaction = transaction;
delete.CommandText = "DELETE FROM rss_sources WHERE id=$id;";
delete.Parameters.AddWithValue("$id", id);
delete.ExecuteNonQuery();
}
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
}
public void MarkRead(string entryId, bool isRead = true) => UpdateEntryFlag(entryId, "is_read", isRead);
public void SetFavorite(string entryId, bool isFavorite) => UpdateEntryFlag(entryId, "is_favorite", isFavorite);
public void MarkAllRead(string? sourceId = null)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = sourceId is null
? "UPDATE rss_entries SET is_read=1 WHERE is_read=0;"
: "UPDATE rss_entries SET is_read=1 WHERE is_read=0 AND source_id=$sourceId;";
if (sourceId is not null) command.Parameters.AddWithValue("$sourceId", sourceId);
command.ExecuteNonQuery();
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
}
public RssReaderSettings GetSettings()
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = "SELECT value FROM rss_metadata WHERE key='settings';";
var value = command.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(value)) return RssReaderSettings.Default;
var parts = value.Split('|');
return new RssReaderSettings(
parts.Length > 0 && int.TryParse(parts[0], out var refresh) ? refresh : 30,
parts.Length > 1 && int.TryParse(parts[1], out var retention) ? retention : 30,
parts.Length > 2 && bool.TryParse(parts[2], out var images) && images);
}
public void SaveSettings(RssReaderSettings settings)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
SetMetadata(connection, transaction, "settings", $"{NormalizeRefresh(settings.RefreshIntervalMinutes)}|{Math.Clamp(settings.RetentionDays, 1, 365)}|{settings.LoadRemoteImages}");
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
}
public string? GetPendingEntryId()
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = "SELECT value FROM rss_metadata WHERE key='pending_entry';";
return command.ExecuteScalar() as string;
}
public void SetPendingEntryId(string? entryId)
{
if (string.IsNullOrWhiteSpace(entryId)) return;
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
SetMetadata(connection, transaction, "pending_entry", entryId.Trim());
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
}
public async Task RefreshAllAsync(bool force = true, CancellationToken cancellationToken = default)
{
var globalInterval = GetSettings().RefreshIntervalMinutes;
var now = DateTimeOffset.UtcNow;
var sources = GetSources().Where(source =>
{
if (!source.IsEnabled) return false;
if (force) return true;
var interval = source.RefreshIntervalMinutes ?? globalInterval;
return interval > 0 && (source.LastRefresh is null || now - source.LastRefresh >= TimeSpan.FromMinutes(interval));
}).ToArray();
using var gate = new SemaphoreSlim(4);
await Task.WhenAll(sources.Select(async source =>
{
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try { await RefreshSourceAsync(source.Id, cancellationToken).ConfigureAwait(false); }
finally { gate.Release(); }
})).ConfigureAwait(false);
Cleanup();
}
public async Task RefreshSourceAsync(string sourceId, CancellationToken cancellationToken = default)
{
var source = GetSources().FirstOrDefault(item => item.Id == sourceId)
?? throw new KeyNotFoundException("RSS source was not found.");
try
{
var document = await DownloadFeedAsync(source.FeedUrl, source.ETag, source.LastModified, cancellationToken).ConfigureAwait(false);
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
if (!document.NotModified && document.Bytes is not null)
{
var parsed = ParseFeed(document.Bytes, source.FeedUrl);
UpsertEntries(connection, transaction, source.Id, source.FeedUrl, parsed.Items);
}
using var update = connection.CreateCommand();
update.Transaction = transaction;
update.CommandText = "UPDATE rss_sources SET etag=$etag,last_modified=$modified,last_refresh=$refresh,last_error=NULL WHERE id=$id;";
update.Parameters.AddWithValue("$id", source.Id);
update.Parameters.AddWithValue("$etag", (object?)document.ETag ?? (object?)source.ETag ?? DBNull.Value);
update.Parameters.AddWithValue("$modified", ToDb(document.LastModified ?? source.LastModified));
update.Parameters.AddWithValue("$refresh", ToDb(DateTimeOffset.UtcNow));
update.ExecuteNonQuery();
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = "UPDATE rss_sources SET last_error=$error,last_refresh=$refresh WHERE id=$id;";
command.Parameters.AddWithValue("$id", source.Id);
command.Parameters.AddWithValue("$error", ex.Message);
command.Parameters.AddWithValue("$refresh", ToDb(DateTimeOffset.UtcNow));
command.ExecuteNonQuery();
}
}
public async Task<RssOpmlImportResult> ImportOpmlAsync(string path, CancellationToken cancellationToken = default)
{
var document = XDocument.Load(path, LoadOptions.None);
var outlines = document.Descendants("outline")
.Where(element => element.Attribute("xmlUrl") is not null)
.Select(element => new
{
Url = element.Attribute("xmlUrl")!.Value,
Title = element.Attribute("title")?.Value ?? element.Attribute("text")?.Value,
Folder = string.Join(" / ", element.Ancestors("outline").Reverse()
.Select(ancestor => ancestor.Attribute("text")?.Value)
.Where(value => !string.IsNullOrWhiteSpace(value)))
}).ToArray();
var existing = GetSources().Select(source => source.FeedUrl).ToHashSet(StringComparer.OrdinalIgnoreCase);
var added = 0; var skipped = 0; var failed = 0;
foreach (var outline in outlines)
{
try
{
var normalized = NormalizeFeedUrl(outline.Url);
if (!existing.Add(normalized)) { skipped++; continue; }
await AddSourceAsync(normalized, outline.Title, outline.Folder, cancellationToken).ConfigureAwait(false);
added++;
}
catch (Exception ex) when (ex is not OperationCanceledException) { failed++; }
}
return new RssOpmlImportResult(added, skipped, failed);
}
public void ExportOpml(string path)
{
var body = new XElement("body");
foreach (var group in GetSources().GroupBy(source => source.Folder ?? string.Empty))
{
var parent = string.IsNullOrWhiteSpace(group.Key) ? body : new XElement("outline", new XAttribute("text", group.Key));
foreach (var source in group)
{
parent.Add(new XElement("outline",
new XAttribute("type", "rss"), new XAttribute("text", source.Title),
new XAttribute("title", source.Title), new XAttribute("xmlUrl", source.FeedUrl),
source.SiteUrl is null ? null : new XAttribute("htmlUrl", source.SiteUrl)));
}
if (parent != body) body.Add(parent);
}
new XDocument(new XDeclaration("1.0", "utf-8", null),
new XElement("opml", new XAttribute("version", "2.0"),
new XElement("head", new XElement("title", "LanMountainDesktop RSS subscriptions")), body)).Save(path);
}
public static string NormalizeFeedUrl(string value)
{
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
throw new ArgumentException("RSS address must be an absolute HTTP or HTTPS URL.", nameof(value));
var builder = new UriBuilder(uri) { Fragment = string.Empty, Host = uri.Host.ToLowerInvariant() };
if ((builder.Scheme == "https" && builder.Port == 443) || (builder.Scheme == "http" && builder.Port == 80)) builder.Port = -1;
return builder.Uri.AbsoluteUri.TrimEnd('/');
}
internal static RssFeedProbe ParseProbe(byte[] bytes, string feedUrl)
{
var parsed = ParseFeed(bytes, NormalizeFeedUrl(feedUrl));
return new RssFeedProbe(parsed.Title, NormalizeFeedUrl(feedUrl), parsed.SiteUrl, parsed.Format);
}
public static string SanitizeHtml(string? html, bool loadRemoteImages)
{
if (string.IsNullOrWhiteSpace(html)) return string.Empty;
var value = Regex.Replace(html, @"<(script|style|iframe|object|embed|form)[^>]*>.*?</\1\s*>", string.Empty, RegexOptions.IgnoreCase | RegexOptions.Singleline);
value = Regex.Replace(value, """\s+on[a-z]+\s*=\s*(['"]).*?\1""", string.Empty, RegexOptions.IgnoreCase | RegexOptions.Singleline);
value = Regex.Replace(value, """\s+(src|href)\s*=\s*(['"])\s*javascript:.*?\2""", string.Empty, RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (!loadRemoteImages) value = Regex.Replace(value, @"<img\b[^>]*>", string.Empty, RegexOptions.IgnoreCase);
return value;
}
public static string ToPlainText(string? html)
{
if (string.IsNullOrWhiteSpace(html)) return string.Empty;
var withoutTags = Regex.Replace(html, "<[^>]+>", " ");
return Regex.Replace(WebUtility.HtmlDecode(withoutTags), @"\s+", " ").Trim();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_revisionTimer.Dispose();
}
private void InitializeDatabase()
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS rss_sources(
id TEXT PRIMARY KEY, title TEXT NOT NULL, feed_url TEXT NOT NULL UNIQUE,
site_url TEXT, folder TEXT, enabled INTEGER NOT NULL DEFAULT 1,
refresh_interval INTEGER, etag TEXT, last_modified TEXT,
last_refresh TEXT, last_error TEXT);
CREATE TABLE IF NOT EXISTS rss_entries(
id TEXT PRIMARY KEY, source_id TEXT NOT NULL REFERENCES rss_sources(id) ON DELETE CASCADE,
title TEXT NOT NULL, link TEXT, author TEXT, summary TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '', published_at TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0, is_favorite INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS ix_rss_entries_source_date ON rss_entries(source_id, published_at DESC);
CREATE INDEX IF NOT EXISTS ix_rss_entries_date ON rss_entries(published_at DESC);
CREATE TABLE IF NOT EXISTS rss_metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT OR IGNORE INTO rss_metadata(key,value) VALUES('revision','0');
""";
command.ExecuteNonQuery();
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = _databasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
Pooling = true
}.ToString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;";
command.ExecuteNonQuery();
return connection;
}
private static HttpClient CreateHttpClient()
{
var client = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All })
{ Timeout = RequestTimeout };
client.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-RssReader/1.0");
return client;
}
private static async Task<DownloadResult> DownloadFeedAsync(string url, string? etag, DateTimeOffset? modified, CancellationToken token)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrWhiteSpace(etag) && EntityTagHeaderValue.TryParse(etag, out var tag)) request.Headers.IfNoneMatch.Add(tag);
if (modified is not null) request.Headers.IfModifiedSince = modified;
using var response = await SharedHttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotModified) return new DownloadResult(null, etag, modified, true);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > MaxResponseBytes) throw new InvalidDataException("RSS response exceeds 5 MB.");
await using var stream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
using var memory = new MemoryStream();
var buffer = new byte[81920];
int read;
while ((read = await stream.ReadAsync(buffer, token).ConfigureAwait(false)) > 0)
{
if (memory.Length + read > MaxResponseBytes) throw new InvalidDataException("RSS response exceeds 5 MB.");
memory.Write(buffer, 0, read);
}
return new DownloadResult(memory.ToArray(), response.Headers.ETag?.ToString(), response.Content.Headers.LastModified, false);
}
private static ParsedFeed ParseFeed(byte[] bytes, string feedUrl)
{
var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = MaxResponseBytes };
using var stream = new MemoryStream(bytes);
using var reader = XmlReader.Create(stream, settings);
var feed = SyndicationFeed.Load(reader) ?? throw new InvalidDataException("Unsupported RSS or Atom document.");
var title = string.IsNullOrWhiteSpace(feed.Title?.Text) ? new Uri(feedUrl).Host : feed.Title.Text.Trim();
var siteUrl = ResolveLink(feed.Links.FirstOrDefault(link => link.RelationshipType is null or "alternate")?.Uri, feedUrl);
var items = feed.Items.Select(item =>
{
var link = ResolveLink(item.Links.FirstOrDefault(link => link.RelationshipType is null or "alternate")?.Uri, feedUrl);
var published = item.PublishDate != DateTimeOffset.MinValue ? item.PublishDate :
item.LastUpdatedTime != DateTimeOffset.MinValue ? item.LastUpdatedTime : DateTimeOffset.UtcNow;
var summary = item.Summary?.Text ?? string.Empty;
var content = item.Content is TextSyndicationContent textContent ? textContent.Text : summary;
var stable = !string.IsNullOrWhiteSpace(item.Id) ? item.Id : link ?? $"{item.Title?.Text}|{published:O}";
return new ParsedItem(CreateStableId(feedUrl + "|" + stable), item.Title?.Text?.Trim() ?? "Untitled", link,
item.Authors.FirstOrDefault()?.Name, summary, content, published);
}).ToArray();
var format = Encoding.UTF8.GetString(bytes.AsSpan(0, Math.Min(bytes.Length, 512))).Contains("<feed", StringComparison.OrdinalIgnoreCase) ? "Atom" : "RSS";
return new ParsedFeed(title, siteUrl, format, items);
}
private static void UpsertEntries(SqliteConnection connection, SqliteTransaction transaction, string sourceId, string feedUrl, IEnumerable<ParsedItem> items)
{
foreach (var item in items)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO rss_entries(id,source_id,title,link,author,summary,content,published_at)
VALUES($id,$source,$title,$link,$author,$summary,$content,$published)
ON CONFLICT(id) DO UPDATE SET title=excluded.title,link=excluded.link,author=excluded.author,
summary=excluded.summary,content=excluded.content,published_at=excluded.published_at;
""";
command.Parameters.AddWithValue("$id", item.Id);
command.Parameters.AddWithValue("$source", sourceId);
command.Parameters.AddWithValue("$title", item.Title);
command.Parameters.AddWithValue("$link", (object?)item.Link ?? DBNull.Value);
command.Parameters.AddWithValue("$author", (object?)item.Author ?? DBNull.Value);
command.Parameters.AddWithValue("$summary", item.Summary);
command.Parameters.AddWithValue("$content", item.Content);
command.Parameters.AddWithValue("$published", ToDb(item.PublishedAt));
command.ExecuteNonQuery();
}
}
private static string? ResolveLink(Uri? link, string feedUrl)
{
if (link is null) return null;
if (link.IsAbsoluteUri) return link.AbsoluteUri;
return Uri.TryCreate(new Uri(feedUrl), link, out var absolute) ? absolute.AbsoluteUri : null;
}
private void Cleanup()
{
var retention = GetSettings().RetentionDays;
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
DELETE FROM rss_entries WHERE is_favorite=0 AND published_at < $cutoff;
DELETE FROM rss_entries WHERE is_favorite=0 AND id IN (
SELECT id FROM (SELECT id, ROW_NUMBER() OVER(PARTITION BY source_id ORDER BY published_at DESC) row_number FROM rss_entries)
WHERE row_number > $limit);
""";
command.Parameters.AddWithValue("$cutoff", ToDb(DateTimeOffset.UtcNow.AddDays(-retention)));
command.Parameters.AddWithValue("$limit", MaxEntriesPerSource);
command.ExecuteNonQuery();
IncrementRevision(connection, transaction);
transaction.Commit();
}
private void UpdateEntryFlag(string entryId, string column, bool value)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"UPDATE rss_entries SET {column}=$value WHERE id=$id;";
command.Parameters.AddWithValue("$id", entryId);
command.Parameters.AddWithValue("$value", value ? 1 : 0);
command.ExecuteNonQuery();
IncrementRevision(connection, transaction);
transaction.Commit();
RaiseChanged();
}
private long GetRevision()
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = "SELECT value FROM rss_metadata WHERE key='revision';";
return long.TryParse(command.ExecuteScalar()?.ToString(), out var revision) ? revision : 0;
}
private void CheckRevision(object? state)
{
if (_disposed) return;
try
{
var revision = GetRevision();
if (revision == Interlocked.Read(ref _lastRevision)) return;
Interlocked.Exchange(ref _lastRevision, revision);
Changed?.Invoke(this, EventArgs.Empty);
}
catch { }
}
private void RaiseChanged()
{
_lastRevision = GetRevision();
Changed?.Invoke(this, EventArgs.Empty);
}
private static void IncrementRevision(SqliteConnection connection, SqliteTransaction transaction)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "UPDATE rss_metadata SET value=CAST(value AS INTEGER)+1 WHERE key='revision';";
command.ExecuteNonQuery();
}
private static void SetMetadata(SqliteConnection connection, SqliteTransaction transaction, string key, string value)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "INSERT INTO rss_metadata(key,value) VALUES($key,$value) ON CONFLICT(key) DO UPDATE SET value=excluded.value;";
command.Parameters.AddWithValue("$key", key);
command.Parameters.AddWithValue("$value", value);
command.ExecuteNonQuery();
}
private static RssSource ReadSource(SqliteDataReader reader) => new(
reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.IsDBNull(3) ? null : reader.GetString(3),
reader.IsDBNull(4) ? null : reader.GetString(4), reader.GetInt64(5) != 0,
reader.IsDBNull(6) ? null : reader.GetInt32(6), reader.IsDBNull(7) ? null : reader.GetString(7),
ParseDate(reader, 8), ParseDate(reader, 9), reader.IsDBNull(10) ? null : reader.GetString(10));
private static RssEntry ReadEntry(SqliteDataReader reader) => new(
reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3),
reader.IsDBNull(4) ? null : reader.GetString(4), reader.IsDBNull(5) ? null : reader.GetString(5),
reader.GetString(6), reader.GetString(7), DateTimeOffset.Parse(reader.GetString(8)), reader.GetInt64(9) != 0, reader.GetInt64(10) != 0);
private static DateTimeOffset? ParseDate(SqliteDataReader reader, int index) => reader.IsDBNull(index) ? null : DateTimeOffset.Parse(reader.GetString(index));
private static object NormalizeOptional(string? value) => string.IsNullOrWhiteSpace(value) ? DBNull.Value : value.Trim();
private static object ToDb(DateTimeOffset? value) => value is null ? DBNull.Value : value.Value.ToUniversalTime().ToString("O");
private static int NormalizeRefresh(int value) => value is 0 or 15 or 30 or 60 ? value : 30;
private static string CreateStableId(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private sealed record DownloadResult(byte[]? Bytes, string? ETag, DateTimeOffset? LastModified, bool NotModified);
private sealed record ParsedFeed(string Title, string? SiteUrl, string Format, IReadOnlyList<ParsedItem> Items);
private sealed record ParsedItem(string Id, string Title, string? Link, string? Author, string Summary, string Content, DateTimeOffset PublishedAt);
}

View File

@@ -0,0 +1,63 @@
using Avalonia.Controls;
using Avalonia.Layout;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.RssReader;
namespace LanMountainDesktop.Views.ComponentEditors;
public sealed class RssReaderComponentEditor : ComponentEditorViewBase, IDisposable
{
private readonly RssReaderService _service = new();
private readonly ComboBox _sourceCombo = new();
private readonly CheckBox _unreadFirst = new();
private readonly NumericUpDown _displayCount = new() { Minimum = 5, Maximum = 100, Increment = 5 };
public RssReaderComponentEditor(DesktopComponentEditorContext? context) : base(context)
{
var snapshot = LoadSnapshot();
var options = new List<SourceOption> { new(string.Empty, L("rss.all_sources", "All sources")) };
options.AddRange(_service.GetSources().Select(source => new SourceOption(source.Id, source.Title)));
_sourceCombo.ItemsSource = options;
_sourceCombo.SelectedItem = options.FirstOrDefault(option => option.Id == snapshot.RssReaderSourceId) ?? options[0];
_unreadFirst.IsChecked = snapshot.RssReaderUnreadFirst;
_displayCount.Value = Math.Clamp(snapshot.RssReaderDisplayCount, 5, 100);
_unreadFirst.Content = L("rss.unread_first", "Show unread entries first");
var save = new Button { Content = L("rss.save", "Save"), HorizontalAlignment = HorizontalAlignment.Right };
save.Click += (_, _) => Save();
Content = new StackPanel
{
Spacing = 14,
Margin = new Avalonia.Thickness(20),
Children =
{
new TextBlock { Text = L("component.rss_reader", "RSS Reader"), FontSize = 22, FontWeight = Avalonia.Media.FontWeight.SemiBold },
new TextBlock { Text = L("rss.instance_settings_description", "These display options apply only to this component instance."), TextWrapping = Avalonia.Media.TextWrapping.Wrap, Opacity = 0.7 },
new TextBlock { Text = L("rss.source", "Source") }, _sourceCombo,
_unreadFirst,
new TextBlock { Text = L("rss.entry_count", "Number of entries") }, _displayCount,
save
}
};
}
public void Dispose() => _service.Dispose();
private void Save()
{
var snapshot = LoadSnapshot();
snapshot.RssReaderSourceId = (_sourceCombo.SelectedItem as SourceOption)?.Id ?? string.Empty;
snapshot.RssReaderUnreadFirst = _unreadFirst.IsChecked == true;
snapshot.RssReaderDisplayCount = (int)(_displayCount.Value ?? 20);
SaveSnapshot(snapshot,
nameof(ComponentSettingsSnapshot.RssReaderSourceId),
nameof(ComponentSettingsSnapshot.RssReaderUnreadFirst),
nameof(ComponentSettingsSnapshot.RssReaderDisplayCount));
}
private sealed record SourceOption(string Id, string Title)
{
public override string ToString() => Title;
}
}

View File

@@ -0,0 +1,76 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Views.Components.RssReaderWidget">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="RssSurfaceBrush" Color="#FCFCFD"/>
<SolidColorBrush x:Key="RssSubtleBrush" Color="#F1F3F6"/>
<SolidColorBrush x:Key="RssBorderBrush" Color="#16000000"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="RssSurfaceBrush" Color="#1B2129"/>
<SolidColorBrush x:Key="RssSubtleBrush" Color="#2A313C"/>
<SolidColorBrush x:Key="RssBorderBrush" Color="#28FFFFFF"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Border x:Name="RootBorder"
Background="{DynamicResource RssSurfaceBrush}"
BorderBrush="{DynamicResource RssBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="18">
<Grid ColumnDefinitions="164,*" ColumnSpacing="18">
<Grid RowDefinitions="Auto,*" RowSpacing="12">
<StackPanel Spacing="7">
<TextBlock x:Name="TitleText" Text="RSS Reader" FontSize="21" FontWeight="SemiBold"/>
<TextBlock x:Name="UnreadText" Text="0 unread" FontSize="12" Opacity="0.66"/>
</StackPanel>
<StackPanel Grid.Row="1" VerticalAlignment="Bottom" Spacing="8">
<Button x:Name="RefreshButton" HorizontalContentAlignment="Left"
Background="{DynamicResource RssSubtleBrush}" BorderThickness="0">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowSync" FontSize="14"/>
<TextBlock x:Name="RefreshButtonText" Text="Refresh" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button x:Name="MarkAllReadButton" HorizontalContentAlignment="Left"
Background="{DynamicResource RssSubtleBrush}" BorderThickness="0">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="Checkmark" FontSize="14"/>
<TextBlock x:Name="MarkAllReadButtonText" Text="Mark all read" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button x:Name="OpenReaderButton" HorizontalContentAlignment="Left"
Background="{DynamicResource RssSubtleBrush}" BorderThickness="0">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Symbol="ArrowRight" FontSize="14"/>
<TextBlock x:Name="OpenReaderButtonText" Text="Open reader" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
<Grid Grid.Column="1" RowDefinitions="Auto,*" RowSpacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="LatestText" Text="Latest articles" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock x:Name="ErrorText" Grid.Column="1" IsVisible="False" Foreground="#D13438"
FontSize="11" TextTrimming="CharacterEllipsis" MaxWidth="240"/>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<WrapPanel x:Name="EntriesPanel" Orientation="Horizontal" ItemWidth="220" ItemHeight="88"/>
</ScrollViewer>
<Button x:Name="EmptyButton" Grid.Row="1" IsVisible="False" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Background="{DynamicResource RssSubtleBrush}" BorderThickness="0"
Content="Import OPML or add your first RSS source"/>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,170 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using System.Globalization;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.RssReader;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views.Components;
public sealed partial class RssReaderWidget : UserControl, IDesktopComponentWidget, IDesktopComponentLifecycleWidget
{
private readonly RssReaderService _service;
private readonly IComponentSettingsAccessor _settingsAccessor;
private readonly string? _placementId;
private readonly LocalizationService _localization = new();
private readonly string _languageCode;
private readonly DispatcherTimer _refreshTimer = new();
private bool _isRefreshing;
public RssReaderWidget(IComponentSettingsAccessor settingsAccessor, string? placementId)
{
_settingsAccessor = settingsAccessor;
_placementId = placementId;
_languageCode = _localization.NormalizeLanguageCode(HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode);
_service = new RssReaderService();
InitializeComponent();
ApplyLocalization();
OpenReaderButton.Click += OnOpen;
EmptyButton.Click += OnOpen;
RefreshButton.Click += OnRefresh;
MarkAllReadButton.Click += OnMarkAllRead;
_service.Changed += OnServiceChanged;
_refreshTimer.Tick += OnRefreshTimer;
AttachedToVisualTree += OnAttached;
DetachedFromVisualTree += OnDetached;
}
public void ApplyCellSize(double cellSize) { }
public void OnWidgetDestroyed() => DisposeResources();
private void OnAttached(object? sender, VisualTreeAttachmentEventArgs e)
{
Reload();
ConfigureTimer();
_ = RefreshAsync(force: true);
}
private void OnDetached(object? sender, VisualTreeAttachmentEventArgs e) => _refreshTimer.Stop();
private void ConfigureTimer()
{
var minutes = _service.GetSettings().RefreshIntervalMinutes;
if (minutes <= 0) { _refreshTimer.Stop(); return; }
_refreshTimer.Interval = TimeSpan.FromMinutes(minutes);
_refreshTimer.Start();
}
private async void OnRefreshTimer(object? sender, EventArgs e) => await RefreshAsync(force: false);
private async void OnRefresh(object? sender, RoutedEventArgs e) => await RefreshAsync(force: true);
private async Task RefreshAsync(bool force)
{
if (_isRefreshing) return;
_isRefreshing = true;
RefreshButton.IsEnabled = false;
ErrorText.IsVisible = false;
try { await _service.RefreshAllAsync(force); }
catch (Exception ex) { ErrorText.Text = $"{L("rss.refresh_failed", "Refresh failed")}: {ex.Message}"; ErrorText.IsVisible = true; }
finally { _isRefreshing = false; RefreshButton.IsEnabled = true; Reload(); }
}
private void OnMarkAllRead(object? sender, RoutedEventArgs e)
{
var settings = LoadSettings();
_service.MarkAllRead(string.IsNullOrWhiteSpace(settings.RssReaderSourceId) ? null : settings.RssReaderSourceId);
}
private void OnOpen(object? sender, RoutedEventArgs e) => OpenReader(null);
private void OnServiceChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Reload);
private void Reload()
{
var settings = LoadSettings();
var sourceId = string.IsNullOrWhiteSpace(settings.RssReaderSourceId) ? null : settings.RssReaderSourceId;
var entries = _service.GetEntries(sourceId, limit: Math.Clamp(settings.RssReaderDisplayCount, 5, 100));
if (settings.RssReaderUnreadFirst)
entries = entries.OrderBy(entry => entry.IsRead).ThenByDescending(entry => entry.PublishedAt).ToArray();
EntriesPanel.Children.Clear();
foreach (var entry in entries)
{
var button = new Button
{
HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Stretch,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(8),
Tag = entry.Id,
Content = new Grid
{
RowDefinitions = new RowDefinitions("Auto,Auto"),
Children =
{
new TextBlock { Text = (entry.IsRead ? string.Empty : "● ") + entry.Title, FontWeight = entry.IsRead ? FontWeight.Normal : FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap, MaxLines = 2 },
new TextBlock { Text = $"{entry.SourceTitle} · {FormatRelative(entry.PublishedAt)}{(entry.IsFavorite ? " · " : string.Empty)}", FontSize = 11, Opacity = 0.6, Margin = new Thickness(0,4,0,0), [Grid.RowProperty] = 1 }
}
}
};
button.Click += OnEntryClicked;
EntriesPanel.Children.Add(button);
}
var unread = _service.GetEntries(sourceId, unreadOnly: true, limit: 500).Count;
UnreadText.Text = string.Format(L("rss.unread_count", "{0} unread"), unread);
EmptyButton.IsVisible = _service.GetSources().Count == 0;
EntriesPanel.IsVisible = !EmptyButton.IsVisible;
}
private void OnEntryClicked(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: string id }) return;
_service.MarkRead(id);
OpenReader(id);
}
private void OpenReader(string? entryId) => AirAppLauncherServiceProvider.GetOrCreate()
.OpenRssReader(BuiltInComponentIds.DesktopRssReader, _placementId, entryId);
private ComponentSettingsSnapshot LoadSettings() =>
_settingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>() ?? new ComponentSettingsSnapshot();
private string FormatRelative(DateTimeOffset value)
{
var elapsed = DateTimeOffset.Now - value.ToLocalTime();
if (elapsed.TotalMinutes < 1) return L("rss.just_now", "Just now");
if (elapsed.TotalHours < 1)
return string.Format(L("rss.minutes_ago", "{0} min ago"), (int)elapsed.TotalMinutes);
if (elapsed.TotalDays < 1)
return string.Format(L("rss.hours_ago", "{0} hr ago"), (int)elapsed.TotalHours);
var culture = CultureInfo.GetCultureInfo(_languageCode);
return value.ToLocalTime().ToString(L("rss.short_date_format", "MMM d"), culture);
}
private void ApplyLocalization()
{
TitleText.Text = L("component.rss_reader", "RSS Reader");
LatestText.Text = L("rss.latest_articles", "Latest articles");
RefreshButtonText.Text = L("rss.refresh", "Refresh");
MarkAllReadButtonText.Text = L("rss.mark_all_read", "Mark all read");
OpenReaderButtonText.Text = L("rss.open_reader", "Open reader");
EmptyButton.Content = L("rss.empty", "Import OPML or add your first RSS source");
}
private string L(string key, string fallback) => _localization.GetString(_languageCode, key, fallback);
private void DisposeResources()
{
_refreshTimer.Stop();
_service.Changed -= OnServiceChanged;
_service.Dispose();
}
}