mirror of
https://github.com/ckaczor/FeedCenter.git
synced 2026-01-14 01:25:38 -05:00
More UI updates
This commit is contained in:
@@ -6,71 +6,70 @@ using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
using Realms;
|
||||
|
||||
namespace FeedCenter
|
||||
namespace FeedCenter;
|
||||
|
||||
public class Category : RealmObject, INotifyDataErrorInfo
|
||||
{
|
||||
public class Category : RealmObject, INotifyDataErrorInfo
|
||||
public const string DefaultName = "< default >";
|
||||
|
||||
private readonly DataErrorDictionary _dataErrorDictionary;
|
||||
|
||||
public Category()
|
||||
{
|
||||
public const string DefaultName = "< default >";
|
||||
_dataErrorDictionary = new DataErrorDictionary();
|
||||
_dataErrorDictionary.ErrorsChanged += DataErrorDictionaryErrorsChanged;
|
||||
}
|
||||
|
||||
private readonly DataErrorDictionary _dataErrorDictionary;
|
||||
[Ignored]
|
||||
public ICollection<Feed> Feeds { get; set; }
|
||||
|
||||
public Category()
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public bool IsDefault { get; internal set; }
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => RawName;
|
||||
set
|
||||
{
|
||||
_dataErrorDictionary = new DataErrorDictionary();
|
||||
_dataErrorDictionary.ErrorsChanged += DataErrorDictionaryErrorsChanged;
|
||||
}
|
||||
RawName = value;
|
||||
|
||||
[Ignored]
|
||||
public ICollection<Feed> Feeds { get; set; }
|
||||
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public bool IsDefault { get; internal set; }
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => RawName;
|
||||
set
|
||||
{
|
||||
RawName = value;
|
||||
|
||||
ValidateName();
|
||||
RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[MapTo("Name")]
|
||||
private string RawName { get; set; } = string.Empty;
|
||||
|
||||
[UsedImplicitly]
|
||||
public int SortKey => IsDefault ? 0 : 1;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public static Category CreateDefault()
|
||||
{
|
||||
return new Category { Name = DefaultName, IsDefault = true };
|
||||
}
|
||||
|
||||
private void ValidateName()
|
||||
{
|
||||
_dataErrorDictionary.ClearErrors(nameof(Name));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
_dataErrorDictionary.AddError(nameof(Name), "Name cannot be empty");
|
||||
ValidateName();
|
||||
RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[MapTo("Name")]
|
||||
private string RawName { get; set; } = string.Empty;
|
||||
|
||||
[UsedImplicitly]
|
||||
public int SortKey => IsDefault ? 0 : 1;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public static Category CreateDefault()
|
||||
{
|
||||
return new Category { Name = DefaultName, IsDefault = true };
|
||||
}
|
||||
|
||||
private void ValidateName()
|
||||
{
|
||||
_dataErrorDictionary.ClearErrors(nameof(Name));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
_dataErrorDictionary.AddError(nameof(Name), "Name cannot be empty");
|
||||
}
|
||||
}
|
||||
@@ -3,41 +3,40 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace FeedCenter
|
||||
namespace FeedCenter;
|
||||
|
||||
internal class DataErrorDictionary : Dictionary<string, List<string>>
|
||||
{
|
||||
internal class DataErrorDictionary : Dictionary<string, List<string>>
|
||||
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
|
||||
|
||||
private void OnErrorsChanged(string propertyName)
|
||||
{
|
||||
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
private void OnErrorsChanged(string propertyName)
|
||||
{
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
|
||||
}
|
||||
public IEnumerable GetErrors(string propertyName)
|
||||
{
|
||||
return TryGetValue(propertyName, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public IEnumerable GetErrors(string propertyName)
|
||||
{
|
||||
return TryGetValue(propertyName, out var value) ? value : null;
|
||||
}
|
||||
public void AddError(string propertyName, string error)
|
||||
{
|
||||
if (!ContainsKey(propertyName))
|
||||
this[propertyName] = new List<string>();
|
||||
|
||||
public void AddError(string propertyName, string error)
|
||||
{
|
||||
if (!ContainsKey(propertyName))
|
||||
this[propertyName] = new List<string>();
|
||||
if (this[propertyName].Contains(error))
|
||||
return;
|
||||
|
||||
if (this[propertyName].Contains(error))
|
||||
return;
|
||||
this[propertyName].Add(error);
|
||||
OnErrorsChanged(propertyName);
|
||||
}
|
||||
|
||||
this[propertyName].Add(error);
|
||||
OnErrorsChanged(propertyName);
|
||||
}
|
||||
public void ClearErrors(string propertyName)
|
||||
{
|
||||
if (!ContainsKey(propertyName))
|
||||
return;
|
||||
|
||||
public void ClearErrors(string propertyName)
|
||||
{
|
||||
if (!ContainsKey(propertyName))
|
||||
return;
|
||||
|
||||
Remove(propertyName);
|
||||
OnErrorsChanged(propertyName);
|
||||
}
|
||||
Remove(propertyName);
|
||||
OnErrorsChanged(propertyName);
|
||||
}
|
||||
}
|
||||
@@ -20,431 +20,480 @@ using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FeedCenter
|
||||
namespace FeedCenter;
|
||||
|
||||
#region Enumerations
|
||||
|
||||
public enum MultipleOpenAction
|
||||
{
|
||||
#region Enumerations
|
||||
IndividualPages,
|
||||
SinglePage
|
||||
}
|
||||
|
||||
public enum MultipleOpenAction
|
||||
public enum FeedType
|
||||
{
|
||||
Unknown,
|
||||
Rss,
|
||||
Rdf,
|
||||
Atom
|
||||
}
|
||||
|
||||
public enum FeedReadResult
|
||||
{
|
||||
Success,
|
||||
NotModified,
|
||||
NotDue,
|
||||
UnknownError,
|
||||
InvalidXml,
|
||||
NotEnabled,
|
||||
Unauthorized,
|
||||
NoResponse,
|
||||
NotFound,
|
||||
Timeout,
|
||||
ConnectionFailed,
|
||||
ServerError,
|
||||
Moved,
|
||||
TemporarilyUnavailable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
{
|
||||
private static HttpClient _httpClient;
|
||||
|
||||
private readonly DataErrorDictionary _dataErrorDictionary;
|
||||
|
||||
public Feed()
|
||||
{
|
||||
IndividualPages,
|
||||
SinglePage
|
||||
_dataErrorDictionary = new DataErrorDictionary();
|
||||
_dataErrorDictionary.ErrorsChanged += DataErrorDictionaryErrorsChanged;
|
||||
}
|
||||
|
||||
public enum FeedType
|
||||
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; }
|
||||
|
||||
[UsedImplicitly]
|
||||
public IList<FeedItem> Items { get; }
|
||||
|
||||
public DateTimeOffset LastChecked { get; set; }
|
||||
|
||||
public FeedReadResult LastReadResult
|
||||
{
|
||||
Unknown,
|
||||
Rss,
|
||||
Rdf,
|
||||
Atom
|
||||
get => Enum.TryParse(LastReadResultRaw, out FeedReadResult result) ? result : FeedReadResult.Success;
|
||||
set => LastReadResultRaw = value.ToString();
|
||||
}
|
||||
|
||||
public enum FeedReadResult
|
||||
// ReSharper disable once UnusedMember.Global
|
||||
public string LastReadResultDescription
|
||||
{
|
||||
Success,
|
||||
NotModified,
|
||||
NotDue,
|
||||
UnknownError,
|
||||
InvalidXml,
|
||||
NotEnabled,
|
||||
Unauthorized,
|
||||
NoResponse,
|
||||
NotFound,
|
||||
Timeout,
|
||||
ConnectionFailed,
|
||||
ServerError,
|
||||
Moved
|
||||
get
|
||||
{
|
||||
// Cast the last read result to the proper enum
|
||||
var lastReadResult = LastReadResult;
|
||||
|
||||
// Build the name of the resource using the enum name and the value
|
||||
var resourceName = $"{nameof(FeedReadResult)}_{lastReadResult}";
|
||||
|
||||
// Try to get the value from the resources
|
||||
var resourceValue = Resources.ResourceManager.GetString(resourceName);
|
||||
|
||||
// Return the value or just the enum value if not found
|
||||
return resourceValue ?? lastReadResult.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
private string LastReadResultRaw { get; set; }
|
||||
|
||||
public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
public DateTimeOffset LastUpdated { get; set; }
|
||||
public string Link { get; set; }
|
||||
|
||||
public MultipleOpenAction MultipleOpenAction
|
||||
{
|
||||
private static HttpClient _httpClient;
|
||||
get => Enum.TryParse(MultipleOpenActionRaw, out MultipleOpenAction result) ? result : MultipleOpenAction.IndividualPages;
|
||||
set => MultipleOpenActionRaw = value.ToString();
|
||||
}
|
||||
|
||||
private readonly DataErrorDictionary _dataErrorDictionary;
|
||||
private string MultipleOpenActionRaw { get; set; }
|
||||
|
||||
public Feed()
|
||||
public string Name
|
||||
{
|
||||
get => RawName;
|
||||
set
|
||||
{
|
||||
_dataErrorDictionary = new DataErrorDictionary();
|
||||
_dataErrorDictionary.ErrorsChanged += DataErrorDictionaryErrorsChanged;
|
||||
RawName = value;
|
||||
|
||||
ValidateString(nameof(Name), RawName);
|
||||
RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Authenticate { get; set; }
|
||||
[MapTo("Password")]
|
||||
public string RawPassword { 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; }
|
||||
|
||||
[UsedImplicitly]
|
||||
public IList<FeedItem> Items { get; }
|
||||
|
||||
public DateTimeOffset LastChecked { get; set; }
|
||||
|
||||
public FeedReadResult LastReadResult
|
||||
public string Password
|
||||
{
|
||||
get => RawPassword;
|
||||
set
|
||||
{
|
||||
get => Enum.TryParse(LastReadResultRaw, out FeedReadResult result) ? result : FeedReadResult.Success;
|
||||
set => LastReadResultRaw = value.ToString();
|
||||
}
|
||||
RawPassword = value;
|
||||
|
||||
// ReSharper disable once UnusedMember.Global
|
||||
public string LastReadResultDescription
|
||||
{
|
||||
get
|
||||
if (!Authenticate)
|
||||
{
|
||||
// Cast the last read result to the proper enum
|
||||
var lastReadResult = LastReadResult;
|
||||
|
||||
// Build the name of the resource using the enum name and the value
|
||||
var resourceName = $"{nameof(FeedReadResult)}_{lastReadResult}";
|
||||
|
||||
// Try to get the value from the resources
|
||||
var resourceValue = Resources.ResourceManager.GetString(resourceName);
|
||||
|
||||
// Return the value or just the enum value if not found
|
||||
return resourceValue ?? lastReadResult.ToString();
|
||||
_dataErrorDictionary.ClearErrors(nameof(Password));
|
||||
return;
|
||||
}
|
||||
|
||||
ValidateString(nameof(Password), RawPassword);
|
||||
RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private string LastReadResultRaw { get; set; }
|
||||
[MapTo("Name")]
|
||||
private string RawName { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset LastUpdated { get; set; }
|
||||
public string Link { get; set; }
|
||||
[MapTo("Source")]
|
||||
private string RawSource { get; set; } = string.Empty;
|
||||
|
||||
public MultipleOpenAction MultipleOpenAction
|
||||
public string Source
|
||||
{
|
||||
get => RawSource;
|
||||
set
|
||||
{
|
||||
get => Enum.TryParse(MultipleOpenActionRaw, out MultipleOpenAction result) ? result : MultipleOpenAction.IndividualPages;
|
||||
set => MultipleOpenActionRaw = value.ToString();
|
||||
RawSource = value;
|
||||
|
||||
ValidateString(nameof(Source), RawSource);
|
||||
RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private string MultipleOpenActionRaw { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
public string Name
|
||||
[MapTo("Username")]
|
||||
public string RawUsername { get; set; }
|
||||
|
||||
public string Username
|
||||
{
|
||||
get => RawUsername;
|
||||
set
|
||||
{
|
||||
get => RawName;
|
||||
set
|
||||
RawUsername = value;
|
||||
|
||||
if (!Authenticate)
|
||||
{
|
||||
RawName = value;
|
||||
|
||||
ValidateString(nameof(Name), RawName);
|
||||
RaisePropertyChanged();
|
||||
_dataErrorDictionary.ClearErrors(nameof(Username));
|
||||
return;
|
||||
}
|
||||
|
||||
ValidateString(nameof(Username), RawUsername);
|
||||
RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasErrors => _dataErrorDictionary.Any();
|
||||
|
||||
public IEnumerable GetErrors(string propertyName)
|
||||
{
|
||||
return _dataErrorDictionary.GetErrors(propertyName);
|
||||
}
|
||||
|
||||
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
|
||||
|
||||
public static Feed Create()
|
||||
{
|
||||
return new Feed { Id = Guid.NewGuid(), CategoryId = Database.Entities.DefaultCategory.Id };
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#region Reading
|
||||
|
||||
public FeedReadResult Read(bool forceRead = false)
|
||||
{
|
||||
Log.Logger.Information("Reading feed: {0}", Source);
|
||||
|
||||
var result = ReadFeed(forceRead);
|
||||
|
||||
// Handle the result
|
||||
switch (result)
|
||||
{
|
||||
case FeedReadResult.NotDue:
|
||||
case FeedReadResult.NotEnabled:
|
||||
case FeedReadResult.NotModified:
|
||||
|
||||
// Ignore
|
||||
break;
|
||||
|
||||
default:
|
||||
// Save as last result
|
||||
LastReadResult = result;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
public string Password { get; set; }
|
||||
// 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;
|
||||
|
||||
[MapTo("Name")]
|
||||
private string RawName { get; set; } = string.Empty;
|
||||
Log.Logger.Information("Done reading feed: {0}", result);
|
||||
|
||||
[MapTo("Source")]
|
||||
private string RawSource { get; set; } = string.Empty;
|
||||
return result;
|
||||
}
|
||||
|
||||
public string Source
|
||||
public Tuple<FeedType, string> DetectFeedType()
|
||||
{
|
||||
var retrieveResult = RetrieveFeed();
|
||||
|
||||
if (retrieveResult.Item1 != FeedReadResult.Success)
|
||||
{
|
||||
get => RawSource;
|
||||
set
|
||||
{
|
||||
RawSource = value;
|
||||
return new Tuple<FeedType, string>(FeedType.Unknown, string.Empty);
|
||||
}
|
||||
|
||||
ValidateString(nameof(Source), RawSource);
|
||||
RaisePropertyChanged();
|
||||
var feedType = FeedType.Unknown;
|
||||
|
||||
try
|
||||
{
|
||||
feedType = FeedParserBase.DetectFeedType(retrieveResult.Item2);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return new Tuple<FeedType, string>(feedType, retrieveResult.Item2);
|
||||
}
|
||||
|
||||
private Tuple<FeedReadResult, string> RetrieveFeed()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create and configure the HTTP client if needed
|
||||
if (_httpClient == null)
|
||||
{
|
||||
var clientHandler = new HttpClientHandler
|
||||
{
|
||||
// Set that we'll accept compressed data
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
AllowAutoRedirect = true
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(clientHandler);
|
||||
|
||||
// Set a user agent string
|
||||
var userAgent = string.IsNullOrWhiteSpace(Settings.Default.DefaultUserAgent) ? "FeedCenter/" + UpdateCheck.LocalVersion : Settings.Default.DefaultUserAgent;
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
|
||||
// Set a timeout
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
// If we need to authenticate then set the credentials
|
||||
_httpClient.DefaultRequestHeaders.Authorization = Authenticate ? new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))) : null;
|
||||
|
||||
// Attempt to get the response
|
||||
var feedStream = _httpClient.GetStreamAsync(Source).Result;
|
||||
|
||||
// Create the text reader
|
||||
using StreamReader textReader = new XmlSanitizingStream(feedStream, Encoding.UTF8);
|
||||
|
||||
// Get the feed text
|
||||
var feedText = textReader.ReadToEnd();
|
||||
|
||||
if (string.IsNullOrEmpty(feedText))
|
||||
return Tuple.Create(FeedReadResult.NoResponse, string.Empty);
|
||||
|
||||
// Get rid of any leading and trailing whitespace
|
||||
feedText = feedText.Trim();
|
||||
|
||||
// Clean up common invalid XML characters
|
||||
feedText = feedText.Replace(" ", " ");
|
||||
|
||||
// Find ampersands that aren't properly escaped and replace them with escaped versions
|
||||
var r = UnescapedAmpersandRegex();
|
||||
feedText = r.Replace(feedText, "&");
|
||||
|
||||
return Tuple.Create(FeedReadResult.Success, feedText);
|
||||
}
|
||||
|
||||
public string Title { get; set; }
|
||||
public string Username { get; set; }
|
||||
|
||||
public bool HasErrors => _dataErrorDictionary.Any();
|
||||
|
||||
public IEnumerable GetErrors(string propertyName)
|
||||
catch (IOException ioException)
|
||||
{
|
||||
return _dataErrorDictionary.GetErrors(propertyName);
|
||||
Log.Logger.Error(ioException, "Exception");
|
||||
|
||||
return Tuple.Create(FeedReadResult.ConnectionFailed, string.Empty);
|
||||
}
|
||||
|
||||
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
|
||||
|
||||
public static Feed Create()
|
||||
catch (AggregateException aggregateException)
|
||||
{
|
||||
return new Feed { Id = Guid.NewGuid(), CategoryId = Database.Entities.DefaultCategory.Id };
|
||||
}
|
||||
Log.Logger.Error(aggregateException, "Exception");
|
||||
|
||||
private void DataErrorDictionaryErrorsChanged(object sender, DataErrorsChangedEventArgs e)
|
||||
{
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(e.PropertyName));
|
||||
}
|
||||
var innerException = aggregateException.InnerException;
|
||||
|
||||
private void ValidateString(string propertyName, string value)
|
||||
{
|
||||
_dataErrorDictionary.ClearErrors(propertyName);
|
||||
if (innerException is not HttpRequestException httpRequestException)
|
||||
return Tuple.Create(FeedReadResult.UnknownError, string.Empty);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
_dataErrorDictionary.AddError(propertyName, $"{propertyName} cannot be empty");
|
||||
}
|
||||
|
||||
#region Reading
|
||||
|
||||
public FeedReadResult Read(bool forceRead = false)
|
||||
{
|
||||
Log.Logger.Information("Reading feed: {0}", Source);
|
||||
|
||||
var result = ReadFeed(forceRead);
|
||||
|
||||
// Handle the result
|
||||
switch (result)
|
||||
switch (httpRequestException.StatusCode)
|
||||
{
|
||||
case FeedReadResult.NotDue:
|
||||
case FeedReadResult.NotEnabled:
|
||||
case FeedReadResult.NotModified:
|
||||
case HttpStatusCode.ServiceUnavailable:
|
||||
return Tuple.Create(FeedReadResult.TemporarilyUnavailable, string.Empty);
|
||||
|
||||
case HttpStatusCode.InternalServerError:
|
||||
return Tuple.Create(FeedReadResult.ServerError, string.Empty);
|
||||
|
||||
case HttpStatusCode.NotModified:
|
||||
return Tuple.Create(FeedReadResult.NotModified, string.Empty);
|
||||
|
||||
case HttpStatusCode.NotFound:
|
||||
return Tuple.Create(FeedReadResult.NotFound, string.Empty);
|
||||
|
||||
case HttpStatusCode.Unauthorized:
|
||||
case HttpStatusCode.Forbidden:
|
||||
return Tuple.Create(FeedReadResult.Unauthorized, string.Empty);
|
||||
|
||||
case HttpStatusCode.Moved:
|
||||
return Tuple.Create(FeedReadResult.Moved, string.Empty);
|
||||
}
|
||||
|
||||
if (httpRequestException.InnerException is not SocketException socketException)
|
||||
return Tuple.Create(FeedReadResult.UnknownError, string.Empty);
|
||||
|
||||
return socketException.SocketErrorCode switch
|
||||
{
|
||||
SocketError.NoData => Tuple.Create(FeedReadResult.NoResponse, string.Empty),
|
||||
SocketError.HostNotFound => Tuple.Create(FeedReadResult.NotFound, string.Empty),
|
||||
_ => Tuple.Create(FeedReadResult.UnknownError, string.Empty)
|
||||
};
|
||||
}
|
||||
catch (WebException webException)
|
||||
{
|
||||
var result = FeedReadResult.UnknownError;
|
||||
|
||||
switch (webException.Status)
|
||||
{
|
||||
case WebExceptionStatus.ConnectFailure:
|
||||
case WebExceptionStatus.NameResolutionFailure:
|
||||
result = FeedReadResult.ConnectionFailed;
|
||||
|
||||
// Ignore
|
||||
break;
|
||||
|
||||
default:
|
||||
// Save as last result
|
||||
LastReadResult = result;
|
||||
case WebExceptionStatus.Timeout:
|
||||
result = FeedReadResult.Timeout;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// 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;
|
||||
Log.Logger.Error(webException, "Exception");
|
||||
|
||||
Log.Logger.Information("Done reading feed: {0}", result);
|
||||
if (result == FeedReadResult.UnknownError)
|
||||
Debug.Print("Unknown error");
|
||||
|
||||
return result;
|
||||
return Tuple.Create(result, string.Empty);
|
||||
}
|
||||
|
||||
public Tuple<FeedType, string> DetectFeedType()
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Logger.Error(exception, "Exception");
|
||||
|
||||
return Tuple.Create(FeedReadResult.UnknownError, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private FeedReadResult ReadFeed(bool forceRead)
|
||||
{
|
||||
try
|
||||
{
|
||||
// If not enabled then do nothing
|
||||
if (!Enabled)
|
||||
return FeedReadResult.NotEnabled;
|
||||
|
||||
// Check if we're forcing a read
|
||||
if (!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 FeedReadResult.NotDue;
|
||||
}
|
||||
|
||||
// We're checking it now so update the time
|
||||
LastChecked = DateTimeOffset.Now;
|
||||
|
||||
// Read the feed text
|
||||
var retrieveResult = RetrieveFeed();
|
||||
|
||||
if (retrieveResult.Item1 != FeedReadResult.Success)
|
||||
// Get the information out of the async result
|
||||
var result = retrieveResult.Item1;
|
||||
var feedText = retrieveResult.Item2;
|
||||
|
||||
// If we didn't successfully retrieve the feed then stop
|
||||
if (result != FeedReadResult.Success)
|
||||
return result;
|
||||
|
||||
// Create a new RSS parser
|
||||
var feedParser = FeedParserBase.CreateFeedParser(this, feedText);
|
||||
|
||||
// Parse the feed
|
||||
result = feedParser.ParseFeed(feedText);
|
||||
|
||||
// If we didn't successfully parse the feed then stop
|
||||
if (result != FeedReadResult.Success)
|
||||
return result;
|
||||
|
||||
// Create the removed items list - if an item wasn't seen during this check then remove it
|
||||
var removedItems = Items.Where(testItem => testItem.LastFound != LastChecked).ToList();
|
||||
|
||||
// If items were removed the feed was updated
|
||||
if (removedItems.Count > 0)
|
||||
LastUpdated = DateTime.Now;
|
||||
|
||||
// Loop over the items to be removed
|
||||
foreach (var itemToRemove in removedItems)
|
||||
{
|
||||
return new Tuple<FeedType, string>(FeedType.Unknown, string.Empty);
|
||||
// Remove the item from the list
|
||||
Items.Remove(itemToRemove);
|
||||
}
|
||||
|
||||
return new Tuple<FeedType, string>(FeedParserBase.DetectFeedType(retrieveResult.Item2), retrieveResult.Item2);
|
||||
return FeedReadResult.Success;
|
||||
}
|
||||
|
||||
private Tuple<FeedReadResult, string> RetrieveFeed()
|
||||
catch (FeedParseException feedParseException)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create and configure the HTTP client if needed
|
||||
if (_httpClient == null)
|
||||
{
|
||||
var clientHandler = new HttpClientHandler
|
||||
{
|
||||
// Set that we'll accept compressed data
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
AllowAutoRedirect = true
|
||||
};
|
||||
Log.Logger.Error(feedParseException, "Exception");
|
||||
|
||||
_httpClient = new HttpClient(clientHandler);
|
||||
|
||||
// Set a user agent string
|
||||
var userAgent = string.IsNullOrWhiteSpace(Settings.Default.DefaultUserAgent) ? "FeedCenter/" + UpdateCheck.LocalVersion : Settings.Default.DefaultUserAgent;
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
|
||||
// Set a timeout
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
// If we need to authenticate then set the credentials
|
||||
_httpClient.DefaultRequestHeaders.Authorization = Authenticate ? new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))) : null;
|
||||
|
||||
// Attempt to get the response
|
||||
var feedStream = _httpClient.GetStreamAsync(Source).Result;
|
||||
|
||||
// Create the text reader
|
||||
using StreamReader textReader = new XmlSanitizingStream(feedStream, Encoding.UTF8);
|
||||
|
||||
// Get the feed text
|
||||
var feedText = textReader.ReadToEnd();
|
||||
|
||||
if (string.IsNullOrEmpty(feedText))
|
||||
return Tuple.Create(FeedReadResult.NoResponse, string.Empty);
|
||||
|
||||
// Get rid of any leading and trailing whitespace
|
||||
feedText = feedText.Trim();
|
||||
|
||||
// Clean up common invalid XML characters
|
||||
feedText = feedText.Replace(" ", " ");
|
||||
|
||||
// Find ampersands that aren't properly escaped and replace them with escaped versions
|
||||
var r = UnescapedAmpersandRegex();
|
||||
feedText = r.Replace(feedText, "&");
|
||||
|
||||
return Tuple.Create(FeedReadResult.Success, feedText);
|
||||
}
|
||||
catch (IOException ioException)
|
||||
{
|
||||
Log.Logger.Error(ioException, "Exception");
|
||||
|
||||
return Tuple.Create(FeedReadResult.ConnectionFailed, string.Empty);
|
||||
}
|
||||
catch (AggregateException aggregateException)
|
||||
{
|
||||
Log.Logger.Error(aggregateException, "Exception");
|
||||
|
||||
var innerException = aggregateException.InnerException;
|
||||
|
||||
if (innerException is not HttpRequestException httpRequestException)
|
||||
return Tuple.Create(FeedReadResult.UnknownError, string.Empty);
|
||||
|
||||
switch (httpRequestException.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.InternalServerError:
|
||||
return Tuple.Create(FeedReadResult.ServerError, string.Empty);
|
||||
|
||||
case HttpStatusCode.NotModified:
|
||||
return Tuple.Create(FeedReadResult.NotModified, string.Empty);
|
||||
|
||||
case HttpStatusCode.NotFound:
|
||||
return Tuple.Create(FeedReadResult.NotFound, string.Empty);
|
||||
|
||||
case HttpStatusCode.Unauthorized:
|
||||
case HttpStatusCode.Forbidden:
|
||||
return Tuple.Create(FeedReadResult.Unauthorized, string.Empty);
|
||||
|
||||
case HttpStatusCode.Moved:
|
||||
return Tuple.Create(FeedReadResult.Moved, string.Empty);
|
||||
}
|
||||
|
||||
if (httpRequestException.InnerException is not SocketException socketException)
|
||||
return Tuple.Create(FeedReadResult.UnknownError, string.Empty);
|
||||
|
||||
switch (socketException.SocketErrorCode)
|
||||
{
|
||||
case SocketError.NoData:
|
||||
return Tuple.Create(FeedReadResult.NoResponse, string.Empty);
|
||||
|
||||
case SocketError.HostNotFound:
|
||||
return Tuple.Create(FeedReadResult.NotFound, string.Empty);
|
||||
}
|
||||
|
||||
return Tuple.Create(FeedReadResult.UnknownError, string.Empty);
|
||||
}
|
||||
catch (WebException webException)
|
||||
{
|
||||
var result = FeedReadResult.UnknownError;
|
||||
|
||||
switch (webException.Status)
|
||||
{
|
||||
case WebExceptionStatus.ConnectFailure:
|
||||
case WebExceptionStatus.NameResolutionFailure:
|
||||
result = FeedReadResult.ConnectionFailed;
|
||||
|
||||
break;
|
||||
|
||||
case WebExceptionStatus.Timeout:
|
||||
result = FeedReadResult.Timeout;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Log.Logger.Error(webException, "Exception");
|
||||
|
||||
if (result == FeedReadResult.UnknownError)
|
||||
Debug.Print("Unknown error");
|
||||
|
||||
return Tuple.Create(result, string.Empty);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Logger.Error(exception, "Exception");
|
||||
|
||||
return Tuple.Create(FeedReadResult.UnknownError, string.Empty);
|
||||
}
|
||||
return FeedReadResult.InvalidXml;
|
||||
}
|
||||
|
||||
private FeedReadResult ReadFeed(bool forceRead)
|
||||
catch (InvalidFeedFormatException exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
// If not enabled then do nothing
|
||||
if (!Enabled)
|
||||
return FeedReadResult.NotEnabled;
|
||||
Log.Logger.Error(exception, "Exception");
|
||||
|
||||
// Check if we're forcing a read
|
||||
if (!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 FeedReadResult.NotDue;
|
||||
}
|
||||
|
||||
// We're checking it now so update the time
|
||||
LastChecked = DateTimeOffset.Now;
|
||||
|
||||
// Read the feed text
|
||||
var retrieveResult = RetrieveFeed();
|
||||
|
||||
// Get the information out of the async result
|
||||
var result = retrieveResult.Item1;
|
||||
var feedText = retrieveResult.Item2;
|
||||
|
||||
// If we didn't successfully retrieve the feed then stop
|
||||
if (result != FeedReadResult.Success)
|
||||
return result;
|
||||
|
||||
// Create a new RSS parser
|
||||
var feedParser = FeedParserBase.CreateFeedParser(this, feedText);
|
||||
|
||||
// Parse the feed
|
||||
result = feedParser.ParseFeed(feedText);
|
||||
|
||||
// If we didn't successfully parse the feed then stop
|
||||
if (result != FeedReadResult.Success)
|
||||
return result;
|
||||
|
||||
// Create the removed items list - if an item wasn't seen during this check then remove it
|
||||
var removedItems = Items.Where(testItem => testItem.LastFound != LastChecked).ToList();
|
||||
|
||||
// If items were removed the feed was updated
|
||||
if (removedItems.Count > 0)
|
||||
LastUpdated = DateTime.Now;
|
||||
|
||||
// Loop over the items to be removed
|
||||
foreach (var itemToRemove in removedItems)
|
||||
{
|
||||
// Remove the item from the list
|
||||
Items.Remove(itemToRemove);
|
||||
}
|
||||
|
||||
return FeedReadResult.Success;
|
||||
}
|
||||
catch (FeedParseException feedParseException)
|
||||
{
|
||||
Log.Logger.Error(feedParseException, "Exception");
|
||||
|
||||
return FeedReadResult.InvalidXml;
|
||||
}
|
||||
catch (InvalidFeedFormatException exception)
|
||||
{
|
||||
Log.Logger.Error(exception, "Exception");
|
||||
|
||||
return FeedReadResult.InvalidXml;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Logger.Error(exception, "Exception");
|
||||
|
||||
return FeedReadResult.UnknownError;
|
||||
}
|
||||
return FeedReadResult.InvalidXml;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Logger.Error(exception, "Exception");
|
||||
|
||||
[GeneratedRegex("&(?!(?:[a-z]+|#[0-9]+|#x[0-9a-f]+);)")]
|
||||
private static partial Regex UnescapedAmpersandRegex();
|
||||
|
||||
#endregion
|
||||
return FeedReadResult.UnknownError;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex("&(?!(?:[a-z]+|#[0-9]+|#x[0-9a-f]+);)")]
|
||||
private static partial Regex UnescapedAmpersandRegex();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -3,84 +3,83 @@ using System.Text.RegularExpressions;
|
||||
using FeedCenter.Options;
|
||||
using Realms;
|
||||
|
||||
namespace FeedCenter
|
||||
namespace FeedCenter;
|
||||
|
||||
public partial class FeedItem : RealmObject
|
||||
{
|
||||
public partial class FeedItem : RealmObject
|
||||
public bool BeenRead { get; set; }
|
||||
public string Description { get; set; }
|
||||
|
||||
public Feed Feed { 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 static FeedItem Create()
|
||||
{
|
||||
public bool BeenRead { get; set; }
|
||||
public string Description { get; set; }
|
||||
|
||||
public Feed Feed { 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 static FeedItem Create()
|
||||
{
|
||||
return new FeedItem { Id = System.Guid.NewGuid() };
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var title = Title;
|
||||
|
||||
switch (Properties.Settings.Default.MultipleLineDisplay)
|
||||
{
|
||||
case MultipleLineDisplay.SingleLine:
|
||||
|
||||
// Strip any newlines from the title
|
||||
title = NewlineRegex().Replace(title, " ");
|
||||
|
||||
break;
|
||||
|
||||
case MultipleLineDisplay.FirstLine:
|
||||
|
||||
// Find the first newline
|
||||
var newlineIndex = title.IndexOf("\n", StringComparison.Ordinal);
|
||||
|
||||
// If a newline was found return everything before it
|
||||
if (newlineIndex > -1)
|
||||
title = title[..newlineIndex];
|
||||
|
||||
break;
|
||||
case MultipleLineDisplay.Normal:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
title ??= string.Empty;
|
||||
|
||||
// Condense multiple spaces to one space
|
||||
title = MultipleSpaceRegex().Replace(title, " ");
|
||||
|
||||
// Condense tabs to one space
|
||||
title = TabRegex().Replace(title, " ");
|
||||
|
||||
// If the title is blank then put in the "no title" title
|
||||
if (title.Length == 0)
|
||||
title = Properties.Resources.NoTitleText;
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
[GeneratedRegex("\\n")]
|
||||
private static partial Regex NewlineRegex();
|
||||
|
||||
[GeneratedRegex("[ ]{2,}")]
|
||||
private static partial Regex MultipleSpaceRegex();
|
||||
|
||||
[GeneratedRegex("\\t")]
|
||||
private static partial Regex TabRegex();
|
||||
return new FeedItem { Id = System.Guid.NewGuid() };
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var title = Title;
|
||||
|
||||
switch (Properties.Settings.Default.MultipleLineDisplay)
|
||||
{
|
||||
case MultipleLineDisplay.SingleLine:
|
||||
|
||||
// Strip any newlines from the title
|
||||
title = NewlineRegex().Replace(title, " ");
|
||||
|
||||
break;
|
||||
|
||||
case MultipleLineDisplay.FirstLine:
|
||||
|
||||
// Find the first newline
|
||||
var newlineIndex = title.IndexOf("\n", StringComparison.Ordinal);
|
||||
|
||||
// If a newline was found return everything before it
|
||||
if (newlineIndex > -1)
|
||||
title = title[..newlineIndex];
|
||||
|
||||
break;
|
||||
case MultipleLineDisplay.Normal:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
title ??= string.Empty;
|
||||
|
||||
// Condense multiple spaces to one space
|
||||
title = MultipleSpaceRegex().Replace(title, " ");
|
||||
|
||||
// Condense tabs to one space
|
||||
title = TabRegex().Replace(title, " ");
|
||||
|
||||
// If the title is blank then put in the "no title" title
|
||||
if (title.Length == 0)
|
||||
title = Properties.Resources.NoTitleText;
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
[GeneratedRegex("\\n")]
|
||||
private static partial Regex NewlineRegex();
|
||||
|
||||
[GeneratedRegex("[ ]{2,}")]
|
||||
private static partial Regex MultipleSpaceRegex();
|
||||
|
||||
[GeneratedRegex("\\t")]
|
||||
private static partial Regex TabRegex();
|
||||
}
|
||||
Reference in New Issue
Block a user