mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-08-20 13:51:28 +08:00
feat.RSS
This commit is contained in:
64
LanMountainDesktop.AirAppHost/RssReaderAirAppView.axaml
Normal file
64
LanMountainDesktop.AirAppHost/RssReaderAirAppView.axaml
Normal 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>
|
||||
318
LanMountainDesktop.AirAppHost/RssReaderAirAppView.axaml.cs
Normal file
318
LanMountainDesktop.AirAppHost/RssReaderAirAppView.axaml.cs
Normal 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; }
|
||||
}
|
||||
87
LanMountainDesktop.Tests/RssReaderServiceTests.cs
Normal file
87
LanMountainDesktop.Tests/RssReaderServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
665
LanMountainDesktop/Services/RssReader/RssReaderService.cs
Normal file
665
LanMountainDesktop/Services/RssReader/RssReaderService.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
76
LanMountainDesktop/Views/Components/RssReaderWidget.axaml
Normal file
76
LanMountainDesktop/Views/Components/RssReaderWidget.axaml
Normal 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>
|
||||
170
LanMountainDesktop/Views/Components/RssReaderWidget.axaml.cs
Normal file
170
LanMountainDesktop/Views/Components/RssReaderWidget.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user