Start adding server support

This commit is contained in:
2025-09-24 21:08:59 -04:00
parent 9e2e7aabe8
commit 4e721efa55
47 changed files with 1652 additions and 266 deletions

View File

@@ -0,0 +1,203 @@
using Realms;
using System;
using System.Collections;
using System.ComponentModel;
using System.Linq;
namespace FeedCenter;
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;
}
[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 => Type switch
{
AccountType.Fever => false,
AccountType.GoogleReader => false,
AccountType.Local => true,
_ => throw new NotSupportedException()
};
public bool SupportsFeedDelete => Type switch
{
AccountType.Fever => false,
AccountType.GoogleReader => false,
AccountType.Local => true,
_ => throw new NotSupportedException()
};
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 int GetProgressSteps(FeedCenterEntities entities)
{
var progressSteps = Type switch
{
// Delegate to the right reader based on the account type
AccountType.Fever => new FeverReader().GetProgressSteps(entities),
AccountType.GoogleReader => new GoogleReaderReader().GetProgressSteps(entities),
AccountType.Local => new LocalReader().GetProgressSteps(entities),
_ => throw new NotSupportedException()
};
return progressSteps;
}
public 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 = Type switch
{
// Delegate to the right reader based on the account type
AccountType.Fever => new FeverReader().Read(this, accountReadInput),
AccountType.GoogleReader => new GoogleReaderReader().Read(this, accountReadInput),
AccountType.Local => new LocalReader().Read(this, accountReadInput),
_ => throw new NotSupportedException()
};
return accountReadResult;
}
}

View File

@@ -0,0 +1,11 @@
using System;
namespace FeedCenter;
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;
public enum AccountReadResult
{
Success,
NotDue,
NotEnabled
}

View File

@@ -0,0 +1,8 @@
namespace FeedCenter;
public enum AccountType
{
Local,
Fever,
GoogleReader
}

View File

@@ -22,6 +22,10 @@ public class Category : RealmObject, INotifyDataErrorInfo
[PrimaryKey]
public Guid Id { get; set; } = Guid.NewGuid();
public string RemoteId { get; set; }
public Account Account { get; set; }
public bool IsDefault { get; internal set; }
public string Name
@@ -56,9 +60,9 @@ public class Category : RealmObject, INotifyDataErrorInfo
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(e.PropertyName));
}
public static Category CreateDefault()
public static Category CreateDefault(Account account)
{
return new Category { Name = DefaultName, IsDefault = true };
return new Category { Name = DefaultName, IsDefault = true, Account = account };
}
private void ValidateName()

View File

@@ -12,7 +12,6 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using ChrisKaczor.ApplicationUpdate;
using FeedCenter.Data;
using FeedCenter.FeedParsers;
using FeedCenter.Properties;
using FeedCenter.Xml;
@@ -34,14 +33,16 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
_dataErrorDictionary.ErrorsChanged += DataErrorDictionaryErrorsChanged;
}
[PrimaryKey]
public Guid Id { get; set; }
public string RemoteId { get; set; }
public bool Authenticate { get; set; }
public Guid CategoryId { get; set; }
public int CheckInterval { get; set; } = 60;
public string Description { get; set; }
public bool Enabled { get; set; } = true;
[PrimaryKey]
public Guid Id { get; set; }
public Account Account { get; set; }
[UsedImplicitly]
public IList<FeedItem> Items { get; }
@@ -171,9 +172,9 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
public static Feed Create()
public static Feed Create(FeedCenterEntities entities)
{
return new Feed { Id = Guid.NewGuid(), CategoryId = Database.Entities.DefaultCategory.Id };
return new Feed { Id = Guid.NewGuid(), CategoryId = entities.DefaultCategory.Id, Account = entities.LocalAccount };
}
private void DataErrorDictionaryErrorsChanged(object sender, DataErrorsChangedEventArgs e)
@@ -215,7 +216,7 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
break;
}
// If the feed was successfully read and we have no last update timestamp - set the last update timestamp to now
// If the feed was successfully read, and we have no last update timestamp - set the last update timestamp to now
if (result == FeedReadResult.Success && LastUpdated == default)
LastUpdated = DateTimeOffset.Now;

View File

@@ -1,25 +1,28 @@
using System;
using System.Text.RegularExpressions;
using FeedCenter.Options;
using FeedCenter.Options;
using Realms;
using System;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace FeedCenter;
public partial class FeedItem : RealmObject
{
[PrimaryKey]
public Guid Id { get; set; }
public bool BeenRead { get; set; }
public string Description { get; set; }
public Guid FeedId { get; set; }
public string Guid { get; set; }
[PrimaryKey]
public Guid Id { get; set; }
public DateTimeOffset LastFound { get; set; }
public string Link { get; set; }
public bool New { get; set; }
public int Sequence { get; set; }
public string Title { get; set; }
public string RemoteId { get; set; }
public static FeedItem Create()
{
@@ -42,7 +45,7 @@ public partial class FeedItem : RealmObject
case MultipleLineDisplay.FirstLine:
// Find the first newline
var newlineIndex = title.IndexOf("\n", StringComparison.Ordinal);
var newlineIndex = title.IndexOf('\n', StringComparison.Ordinal);
// If a newline was found return everything before it
if (newlineIndex > -1)
@@ -70,6 +73,34 @@ public partial class FeedItem : RealmObject
return title;
}
public async Task MarkAsRead(FeedCenterEntities entities)
{
var feed = entities.Feeds.FirstOrDefault(f => f.Id == FeedId);
entities.SaveChanges(() =>
{
BeenRead = true;
New = false;
});
if (feed == null || feed.Account.Type == AccountType.Local)
return;
switch (feed.Account.Type)
{
case AccountType.Fever:
// Delegate to the right reader based on the account type
await FeverReader.MarkFeedItemRead(feed.Account, RemoteId);
break;
case AccountType.Local:
break;
default:
Debug.Assert(false);
break;
}
}
[GeneratedRegex("\\n")]
private static partial Regex NewlineRegex();

View File

@@ -0,0 +1,145 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using ChrisKaczor.FeverClient;
namespace FeedCenter;
internal class FeverReader : IAccountReader
{
public int GetProgressSteps(FeedCenterEntities entities)
{
return 7;
}
public AccountReadResult Read(Account account, 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 = 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;
transaction.Commit();
accountReadInput.IncrementProgress();
return AccountReadResult.Success;
}
public static async Task MarkFeedItemRead(Account account, string feedItemId)
{
var apiKey = account.Authenticate ? GetApiKey(account) : string.Empty;
var feverClient = new FeverClient(account.Url, apiKey);
await feverClient.MarkFeedItemAsRead(int.Parse(feedItemId));
}
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,144 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace FeedCenter;
internal class GoogleReaderReader : IAccountReader
{
public int GetProgressSteps(FeedCenterEntities entities)
{
return 7;
}
public AccountReadResult Read(Account account, 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;
transaction.Commit();
accountReadInput.IncrementProgress();
return AccountReadResult.Success;
}
public static async Task MarkFeedItemRead(Account account, string feedItemId)
{
//var apiKey = account.Authenticate ? GetApiKey(account) : string.Empty;
//var feverClient = new FeverClient.FeverClient(account.Url, apiKey);
//await feverClient.MarkFeedItemAsRead(int.Parse(feedItemId));
}
//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,7 @@
namespace FeedCenter;
public interface IAccountReader
{
public int GetProgressSteps(FeedCenterEntities entities);
public AccountReadResult Read(Account account, AccountReadInput accountReadInput);
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace FeedCenter;
public class LocalReader : IAccountReader
{
public int GetProgressSteps(FeedCenterEntities entities)
{
var enabledFeedCount = entities.Feeds.Count(f => f.Account.Type == AccountType.Local && f.Enabled);
return enabledFeedCount;
}
public AccountReadResult Read(Account account, 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 AccountReadResult.Success;
}
}