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