Start cleaning up account types

This commit is contained in:
2025-11-15 15:30:23 -05:00
parent 6bae35a255
commit 66ea567eaa
23 changed files with 82 additions and 88 deletions

View File

@@ -0,0 +1,188 @@
using FeedCenter.Feeds;
using Realms;
using System;
using System.Collections;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace FeedCenter.Accounts;
public class Account : RealmObject, INotifyDataErrorInfo
{
public const string DefaultName = "< Local >";
private readonly DataErrorDictionary _dataErrorDictionary;
public Account() : this(AccountType.Local)
{
}
public Account(AccountType type)
{
Type = type;
_dataErrorDictionary = new DataErrorDictionary();
_dataErrorDictionary.ErrorsChanged += DataErrorDictionaryErrorsChanged;
}
private IAccountReader _accountReader;
private IAccountReader GetAccountReader()
{
_accountReader ??= AccountReaderFactory.CreateAccountReader(this);
return _accountReader;
}
[PrimaryKey]
public Guid Id { get; set; } = Guid.NewGuid();
public AccountType Type
{
get => Enum.TryParse(TypeRaw, out AccountType result) ? result : AccountType.Local;
set => TypeRaw = value.ToString();
}
public bool SupportsFeedEdit => GetAccountReader().SupportsFeedEdit;
public bool SupportsFeedDelete => GetAccountReader().SupportsFeedDelete;
private string TypeRaw { get; set; }
public string Name
{
get => RawName;
set
{
RawName = value;
ValidateString(nameof(Name), RawName);
RaisePropertyChanged();
}
}
[MapTo("Name")]
private string RawName { get; set; } = string.Empty;
public string Url
{
get => RawUrl;
set
{
RawUrl = value;
ValidateString(nameof(Url), RawUrl);
RaisePropertyChanged();
}
}
[MapTo("Url")]
public string RawUrl { get; set; }
public string Username
{
get => RawUsername;
set
{
RawUsername = value;
if (!Authenticate)
{
_dataErrorDictionary.ClearErrors(nameof(Username));
return;
}
ValidateString(nameof(Username), RawUsername);
RaisePropertyChanged();
}
}
[MapTo("Username")]
public string RawUsername { get; set; }
public string Password
{
get => RawPassword;
set
{
RawPassword = value;
if (!Authenticate)
{
_dataErrorDictionary.ClearErrors(nameof(Password));
return;
}
ValidateString(nameof(Password), RawPassword);
RaisePropertyChanged();
}
}
[MapTo("Password")]
public string RawPassword { get; set; }
public bool Authenticate { get; set; }
public bool Enabled { get; set; } = true;
public int CheckInterval { get; set; } = 60;
public DateTimeOffset LastChecked { get; set; }
public bool HasErrors => _dataErrorDictionary.Any();
public IEnumerable GetErrors(string propertyName)
{
return _dataErrorDictionary.GetErrors(propertyName);
}
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
private void DataErrorDictionaryErrorsChanged(object sender, DataErrorsChangedEventArgs e)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(e.PropertyName));
}
private void ValidateString(string propertyName, string value)
{
_dataErrorDictionary.ClearErrors(propertyName);
if (string.IsNullOrWhiteSpace(value))
_dataErrorDictionary.AddError(propertyName, $"{propertyName} cannot be empty");
}
public static Account CreateDefault()
{
return new Account { Name = DefaultName, Type = AccountType.Local };
}
public async Task<int> GetProgressSteps(AccountReadInput accountReadInput)
{
var progressSteps = await GetAccountReader().GetProgressSteps(accountReadInput);
return progressSteps;
}
public async Task<AccountReadResult> Read(AccountReadInput accountReadInput)
{
// If not enabled then do nothing
if (!Enabled)
return AccountReadResult.NotEnabled;
// Check if we're forcing a read
if (!accountReadInput.ForceRead)
{
// Figure out how long since we last checked
var timeSpan = DateTimeOffset.Now - LastChecked;
// Check if we are due to read the feed
if (timeSpan.TotalMinutes < CheckInterval)
return AccountReadResult.NotDue;
}
var accountReadResult = await GetAccountReader().Read(accountReadInput);
return accountReadResult;
}
}

View File

@@ -0,0 +1,11 @@
using System;
namespace FeedCenter.Accounts;
public class AccountReadInput(FeedCenterEntities entities, Guid? feedId, bool forceRead, Action incrementProgress)
{
public FeedCenterEntities Entities { get; set; } = entities;
public Guid? FeedId { get; set; } = feedId;
public bool ForceRead { get; set; } = forceRead;
public Action IncrementProgress { get; private set; } = incrementProgress ?? throw new ArgumentNullException(nameof(incrementProgress));
}

View File

@@ -0,0 +1,8 @@
namespace FeedCenter.Accounts;
public enum AccountReadResult
{
Success,
NotDue,
NotEnabled
}

View File

@@ -0,0 +1,16 @@
using System;
namespace FeedCenter.Accounts;
internal static class AccountReaderFactory
{
internal static IAccountReader CreateAccountReader(Account account) =>
account.Type switch
{
AccountType.Miniflux => new MinifluxReader(account),
AccountType.Local => new LocalReader(account),
AccountType.Fever => new FeverReader(account),
AccountType.GoogleReader => new GoogleReaderReader(account),
_ => throw new NotSupportedException($"Account type '{account.Type}' is not supported."),
};
}

View File

@@ -0,0 +1,9 @@
namespace FeedCenter.Accounts;
public enum AccountType
{
Local,
Fever,
GoogleReader,
Miniflux
}

View File

@@ -0,0 +1,156 @@
using ChrisKaczor.FeverClient;
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using FeedCenter.Feeds;
namespace FeedCenter.Accounts;
internal class FeverReader(Account account) : IAccountReader
{
public async Task<int> GetProgressSteps(AccountReadInput accountReadInput)
{
var apiKey = account.Authenticate ? GetApiKey(account) : string.Empty;
var feverClient = new FeverClient(account.Url, apiKey);
var feeds = await feverClient.GetFeeds();
return feeds.Count() * 2 + 5;
}
public async Task<AccountReadResult> Read(AccountReadInput accountReadInput)
{
var checkTime = DateTimeOffset.UtcNow;
var apiKey = account.Authenticate ? GetApiKey(account) : string.Empty;
var feverClient = new FeverClient(account.Url, apiKey);
accountReadInput.IncrementProgress();
var feverFeeds = (await feverClient.GetFeeds()).ToList();
accountReadInput.IncrementProgress();
var allFeverFeedItems = (await feverClient.GetAllFeedItems()).ToList();
accountReadInput.IncrementProgress();
var transaction = accountReadInput.Entities.BeginTransaction();
foreach (var feverFeed in feverFeeds)
{
var feed = accountReadInput.Entities.Feeds.FirstOrDefault(f => f.RemoteId == feverFeed.Id.ToString() && f.Account.Id == account.Id);
if (feed == null)
{
feed = new Feed
{
Id = Guid.NewGuid(),
RemoteId = feverFeed.Id.ToString(),
Title = feverFeed.Title,
Source = feverFeed.Url,
Link = feverFeed.SiteUrl,
Account = account,
Name = feverFeed.Title,
CategoryId = accountReadInput.Entities.DefaultCategory.Id,
Enabled = true,
CheckInterval = 0,
};
accountReadInput.Entities.Feeds.Add(feed);
}
feed.Name = feverFeed.Title;
feed.Title = feverFeed.Title;
feed.Link = feverFeed.SiteUrl;
feed.Source = feverFeed.Url;
feed.LastReadResult = FeedReadResult.Success;
feed.LastChecked = checkTime;
accountReadInput.IncrementProgress();
var feverFeedItems = allFeverFeedItems
.Where(f => f.FeedId == feverFeed.Id)
.OrderByDescending(fi => fi.CreatedOnTime).ToList();
var sequence = 1;
foreach (var feverFeedItem in feverFeedItems)
{
var feedItem = feed.Items.FirstOrDefault(f => f.RemoteId == feverFeedItem.Id.ToString());
if (feedItem == null)
{
feedItem = new FeedItem
{
Id = Guid.NewGuid(),
RemoteId = feverFeedItem.Id.ToString(),
Title = feverFeedItem.Title,
Link = feverFeedItem.Url,
Description = feverFeedItem.Html,
BeenRead = feverFeedItem.IsRead,
FeedId = feed.Id,
Guid = Guid.NewGuid().ToString(),
Sequence = sequence++,
};
feed.Items.Add(feedItem);
}
feedItem.LastFound = checkTime;
feedItem.BeenRead = feverFeedItem.IsRead;
feedItem.Sequence = sequence++;
}
accountReadInput.IncrementProgress();
var feedItemsNotSeen = feed.Items.Where(fi => fi.LastFound != checkTime).ToList();
foreach (var feedItemNotSeen in feedItemsNotSeen)
{
feed.Items.Remove(feedItemNotSeen);
}
}
accountReadInput.IncrementProgress();
var feedsNotSeen = accountReadInput.Entities.Feeds.Where(f => f.Account.Id == account.Id && f.LastChecked != checkTime).ToList();
foreach (var feedNotSeen in feedsNotSeen)
{
accountReadInput.Entities.Feeds.Remove(feedNotSeen);
}
account.LastChecked = checkTime;
await transaction.CommitAsync();
accountReadInput.IncrementProgress();
return AccountReadResult.Success;
}
public async Task MarkFeedItemRead(string feedItemId)
{
var apiKey = account.Authenticate ? GetApiKey(account) : string.Empty;
var feverClient = new FeverClient(account.Url, apiKey);
await feverClient.MarkFeedItemAsRead(int.Parse(feedItemId));
}
public bool SupportsFeedDelete => false;
public bool SupportsFeedEdit => false;
private static string GetApiKey(Account account)
{
var input = $"{account.Username}:{account.Password}";
var hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}

View File

@@ -0,0 +1,147 @@
using System;
using System.Threading.Tasks;
namespace FeedCenter.Accounts;
internal class GoogleReaderReader(Account account) : IAccountReader
{
public Task<int> GetProgressSteps(AccountReadInput accountReadInput)
{
return Task.FromResult(7);
}
public async Task<AccountReadResult> Read(AccountReadInput accountReadInput)
{
var checkTime = DateTimeOffset.UtcNow;
//var apiKey = account.Authenticate ? GetApiKey(account) : string.Empty;
//var feverClient = new FeverClient.FeverClient(account.Url, apiKey);
//accountReadInput.IncrementProgress();
//var feverFeeds = feverClient.GetFeeds().Result.ToList();
//accountReadInput.IncrementProgress();
//var allFeverFeedItems = feverClient.GetAllFeedItems().Result.ToList();
//accountReadInput.IncrementProgress();
var transaction = accountReadInput.Entities.BeginTransaction();
//foreach (var feverFeed in feverFeeds)
//{
// var feed = accountReadInput.Entities.Feeds.FirstOrDefault(f => f.RemoteId == feverFeed.Id.ToString() && f.Account.Id == account.Id);
// if (feed == null)
// {
// feed = new Feed
// {
// Id = Guid.NewGuid(),
// RemoteId = feverFeed.Id.ToString(),
// Title = feverFeed.Title,
// Source = feverFeed.Url,
// Link = feverFeed.SiteUrl,
// Account = account,
// Name = feverFeed.Title,
// CategoryId = accountReadInput.Entities.DefaultCategory.Id,
// Enabled = true,
// CheckInterval = 0,
// };
// accountReadInput.Entities.Feeds.Add(feed);
// }
// feed.Name = feverFeed.Title;
// feed.Title = feverFeed.Title;
// feed.Link = feverFeed.SiteUrl;
// feed.Source = feverFeed.Url;
// feed.LastReadResult = FeedReadResult.Success;
// feed.LastChecked = checkTime;
// accountReadInput.IncrementProgress();
// var feverFeedItems = allFeverFeedItems
// .Where(f => f.FeedId == feverFeed.Id)
// .OrderByDescending(fi => fi.CreatedOnTime).ToList();
// var sequence = 1;
// foreach (var feverFeedItem in feverFeedItems)
// {
// var feedItem = feed.Items.FirstOrDefault(f => f.RemoteId == feverFeedItem.Id.ToString());
// if (feedItem == null)
// {
// feedItem = new FeedItem
// {
// Id = Guid.NewGuid(),
// RemoteId = feverFeedItem.Id.ToString(),
// Title = feverFeedItem.Title,
// Link = feverFeedItem.Url,
// Description = feverFeedItem.Html,
// BeenRead = feverFeedItem.IsRead,
// FeedId = feed.Id,
// Guid = Guid.NewGuid().ToString(),
// Sequence = sequence++,
// };
// feed.Items.Add(feedItem);
// }
// feedItem.LastFound = checkTime;
// feedItem.BeenRead = feverFeedItem.IsRead;
// feedItem.Sequence = sequence++;
// }
// accountReadInput.IncrementProgress();
// var feedItemsNotSeen = feed.Items.Where(fi => fi.LastFound != checkTime).ToList();
// foreach (var feedItemNotSeen in feedItemsNotSeen)
// {
// feed.Items.Remove(feedItemNotSeen);
// }
//}
accountReadInput.IncrementProgress();
//var feedsNotSeen = accountReadInput.Entities.Feeds.Where(f => f.Account.Id == account.Id && f.LastChecked != checkTime).ToList();
//foreach (var feedNotSeen in feedsNotSeen)
//{
// accountReadInput.Entities.Feeds.Remove(feedNotSeen);
//}
account.LastChecked = checkTime;
await transaction.CommitAsync();
accountReadInput.IncrementProgress();
return AccountReadResult.Success;
}
public Task MarkFeedItemRead(string feedItemId)
{
//var apiKey = account.Authenticate ? GetApiKey(account) : string.Empty;
//var feverClient = new FeverClient.FeverClient(account.Url, apiKey);
//await feverClient.MarkFeedItemAsRead(int.Parse(feedItemId));
return Task.CompletedTask;
}
public bool SupportsFeedDelete => false;
public bool SupportsFeedEdit => false;
//private static string GetApiKey(Account account)
//{
// var input = $"{account.Username}:{account.Password}";
// var hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
// return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
//}
}

View File

@@ -0,0 +1,12 @@
using System.Threading.Tasks;
namespace FeedCenter.Accounts;
public interface IAccountReader
{
public Task<int> GetProgressSteps(AccountReadInput accountReadInput);
public Task<AccountReadResult> Read(AccountReadInput accountReadInput);
public Task MarkFeedItemRead(string feedItemId);
public bool SupportsFeedDelete { get; }
public bool SupportsFeedEdit { get; }
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FeedCenter.Feeds;
namespace FeedCenter.Accounts;
public class LocalReader(Account account) : IAccountReader
{
public Task<int> GetProgressSteps(AccountReadInput accountReadInput)
{
var enabledFeedCount = accountReadInput.Entities.Feeds.Count(f => f.Account.Type == AccountType.Local && f.Enabled);
return Task.FromResult(enabledFeedCount);
}
public Task<AccountReadResult> Read(AccountReadInput accountReadInput)
{
var checkTime = DateTimeOffset.UtcNow;
// Create the list of feeds to read
var feedsToRead = new List<Feed>();
// If we have a single feed then add it to the list - otherwise add them all
if (accountReadInput.FeedId != null)
feedsToRead.Add(accountReadInput.Entities.Feeds.First(feed => feed.Id == accountReadInput.FeedId));
else
feedsToRead.AddRange(accountReadInput.Entities.Feeds.Where(f => f.Account.Type == AccountType.Local));
// Loop over each feed and read it
foreach (var feed in feedsToRead)
{
// Read the feed
accountReadInput.Entities.SaveChanges(() => feed.Read(accountReadInput.ForceRead));
accountReadInput.IncrementProgress();
}
accountReadInput.Entities.SaveChanges(() => account.LastChecked = checkTime);
return Task.FromResult(AccountReadResult.Success);
}
public Task MarkFeedItemRead(string feedItemId)
{
throw new NotImplementedException();
}
public bool SupportsFeedDelete => true;
public bool SupportsFeedEdit => true;
}

View File

@@ -0,0 +1,151 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using ChrisKaczor.MinifluxClient;
using FeedCenter.Feeds;
namespace FeedCenter.Accounts;
internal class MinifluxReader(Account account) : IAccountReader
{
public async Task<int> GetProgressSteps(AccountReadInput accountReadInput)
{
var minifluxClient = new MinifluxClient(account.Url, account.Password);
int feedCount;
if (accountReadInput.FeedId.HasValue)
{
feedCount = 1;
}
else
{
var feeds = await minifluxClient.GetFeeds();
feedCount = feeds.Count();
}
return feedCount * 2 + 4;
}
public async Task<AccountReadResult> Read(AccountReadInput accountReadInput)
{
var checkTime = DateTimeOffset.UtcNow;
var minifluxClient = new MinifluxClient(account.Url, account.Password);
accountReadInput.IncrementProgress();
var localFeeds = accountReadInput.Entities.Feeds.ToList();
var remoteFeeds = (await minifluxClient.GetFeeds()).ToList();
if (accountReadInput.FeedId.HasValue)
{
localFeeds = localFeeds.Where(f => f.Id == accountReadInput.FeedId.Value).ToList();
remoteFeeds = remoteFeeds.Where(rf => rf.Id.ToString() == localFeeds.First().RemoteId).ToList();
}
accountReadInput.IncrementProgress();
var transaction = accountReadInput.Entities.BeginTransaction();
foreach (var remoteFeed in remoteFeeds)
{
var feed = accountReadInput.Entities.Feeds.FirstOrDefault(f => f.RemoteId == remoteFeed.Id.ToString() && f.Account.Id == account.Id);
if (feed == null)
{
feed = new Feed
{
Id = Guid.NewGuid(),
RemoteId = remoteFeed.Id.ToString(),
Title = remoteFeed.Title,
Source = remoteFeed.FeedUrl,
Link = remoteFeed.SiteUrl,
Account = account,
Name = remoteFeed.Title,
CategoryId = accountReadInput.Entities.DefaultCategory.Id,
Enabled = true,
CheckInterval = 0,
};
accountReadInput.Entities.Feeds.Add(feed);
}
feed.Name = remoteFeed.Title;
feed.Title = remoteFeed.Title;
feed.Link = remoteFeed.SiteUrl;
feed.Source = remoteFeed.FeedUrl;
feed.LastReadResult = FeedReadResult.Success;
feed.LastChecked = checkTime;
accountReadInput.IncrementProgress();
var sequence = 1;
var remoteFeedItems = (await minifluxClient.GetFeedEntries(remoteFeed.Id, SortField.PublishedAt, SortDirection.Descending)).ToList();
foreach (var remoteFeedItem in remoteFeedItems)
{
var feedItem = feed.Items.FirstOrDefault(f => f.RemoteId == remoteFeedItem.Id.ToString());
if (feedItem == null)
{
feedItem = new FeedItem
{
Id = Guid.NewGuid(),
RemoteId = remoteFeedItem.Id.ToString(),
Title = remoteFeedItem.Title,
Link = remoteFeedItem.Url,
Description = remoteFeedItem.Content,
BeenRead = remoteFeedItem.Status == "read",
FeedId = feed.Id,
Guid = Guid.NewGuid().ToString(),
Sequence = sequence++,
};
feed.Items.Add(feedItem);
}
feedItem.LastFound = checkTime;
feedItem.BeenRead = remoteFeedItem.Status == "read";
feedItem.Sequence = sequence++;
}
accountReadInput.IncrementProgress();
var feedItemsNotSeen = feed.Items.Where(fi => fi.LastFound != checkTime).ToList();
foreach (var feedItemNotSeen in feedItemsNotSeen)
{
feed.Items.Remove(feedItemNotSeen);
}
}
accountReadInput.IncrementProgress();
var feedsNotSeen = localFeeds.Where(f => f.Account.Id == account.Id && f.LastChecked != checkTime).ToList();
foreach (var feedNotSeen in feedsNotSeen)
{
accountReadInput.Entities.Feeds.Remove(feedNotSeen);
}
account.LastChecked = checkTime;
await transaction.CommitAsync();
accountReadInput.IncrementProgress();
return AccountReadResult.Success;
}
public async Task MarkFeedItemRead(string feedItemId)
{
var minifluxClient = new MinifluxClient(account.Url, account.Password);
await minifluxClient.MarkFeedEntries([long.Parse(feedItemId)], FeedEntryStatus.Read);
}
public bool SupportsFeedDelete => true;
public bool SupportsFeedEdit => true;
}