mirror of
https://github.com/ckaczor/FeedCenter.git
synced 2026-01-14 01:25:38 -05:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e2e7aabe8 | |||
| 7ee84f079b | |||
| 8f70003bef | |||
| 38f093dc5c | |||
| e5bdc80d10 | |||
| 260268194a | |||
| 8ecde89be0 | |||
| 845e80577c | |||
| 31a04b13e6 | |||
| 64d893ae0f | |||
| 0ddd38e3f2 | |||
| f5f78c8825 |
@@ -1,22 +1,31 @@
|
||||
using FeedCenter.Data;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using FeedCenter.Data;
|
||||
using FeedCenter.Options;
|
||||
using Realms;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace FeedCenter;
|
||||
|
||||
public class FeedCenterEntities
|
||||
{
|
||||
public Realm RealmInstance { get; }
|
||||
|
||||
public RealmObservableCollection<Category> Categories { get; }
|
||||
public RealmObservableCollection<Feed> Feeds { get; private set; }
|
||||
public RealmObservableCollection<Setting> Settings { get; private set; }
|
||||
|
||||
public FeedCenterEntities()
|
||||
{
|
||||
var realmConfiguration = new RealmConfiguration($"{Database.DatabaseFile}");
|
||||
var realmConfiguration = new RealmConfiguration($"{Database.DatabaseFile}")
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
MigrationCallback = (migration, oldSchemaVersion) =>
|
||||
{
|
||||
var newVersionFeeds = migration.NewRealm.All<Feed>();
|
||||
|
||||
foreach (var newVersionFeed in newVersionFeeds)
|
||||
{
|
||||
if (oldSchemaVersion == 0)
|
||||
{
|
||||
newVersionFeed.UserAgent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RealmInstance = Realm.GetInstance(realmConfiguration);
|
||||
|
||||
@@ -30,6 +39,17 @@ public class FeedCenterEntities
|
||||
}
|
||||
}
|
||||
|
||||
public RealmObservableCollection<Category> Categories { get; }
|
||||
|
||||
public Category DefaultCategory
|
||||
{
|
||||
get { return Categories.First(c => c.IsDefault); }
|
||||
}
|
||||
|
||||
public RealmObservableCollection<Feed> Feeds { get; private set; }
|
||||
private Realm RealmInstance { get; }
|
||||
public RealmObservableCollection<Setting> Settings { get; private set; }
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
RealmInstance.Refresh();
|
||||
@@ -44,9 +64,4 @@ public class FeedCenterEntities
|
||||
{
|
||||
return RealmInstance.BeginWrite();
|
||||
}
|
||||
|
||||
public Category DefaultCategory
|
||||
{
|
||||
get { return Categories.First(c => c.IsDefault); }
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
<PackageReference Include="ChrisKaczor.Wpf.Controls.Toolbar" Version="1.0.3" />
|
||||
<PackageReference Include="ChrisKaczor.Wpf.Validation" Version="1.0.4" />
|
||||
<PackageReference Include="ChrisKaczor.Wpf.Windows.ControlBox" Version="1.0.3" />
|
||||
<PackageReference Include="ChrisKaczor.Wpf.Windows.SnappingWindow" Version="1.0.3" />
|
||||
<PackageReference Include="ChrisKaczor.Wpf.Windows.SnappingWindow" Version="1.0.4" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="DebounceThrottle" Version="2.0.0" />
|
||||
<PackageReference Include="H.NotifyIcon.Wpf" Version="2.0.108" />
|
||||
@@ -44,7 +44,7 @@
|
||||
<PackageReference Include="Microsoft.SqlServer.Compact" Version="4.0.8876.1" GeneratePathProperty="true">
|
||||
<NoWarn>NU1701</NoWarn>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Windows.Compatibility" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.Windows.Compatibility" Version="7.0.6" />
|
||||
<PackageReference Include="NameBasedGrid" Version="0.10.1">
|
||||
<NoWarn>NU1701</NoWarn>
|
||||
</PackageReference>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
using ChrisKaczor.ApplicationUpdate;
|
||||
using FeedCenter.Data;
|
||||
using FeedCenter.FeedParsers;
|
||||
using FeedCenter.Properties;
|
||||
using FeedCenter.Xml;
|
||||
using JetBrains.Annotations;
|
||||
using Realms;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
@@ -19,6 +11,14 @@ using System.Net.Sockets;
|
||||
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;
|
||||
using JetBrains.Annotations;
|
||||
using Realms;
|
||||
using Serilog;
|
||||
|
||||
namespace FeedCenter;
|
||||
|
||||
@@ -98,9 +98,6 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
}
|
||||
}
|
||||
|
||||
[MapTo("Password")]
|
||||
public string RawPassword { get; set; }
|
||||
|
||||
public string Password
|
||||
{
|
||||
get => RawPassword;
|
||||
@@ -122,9 +119,15 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
[MapTo("Name")]
|
||||
private string RawName { get; set; } = string.Empty;
|
||||
|
||||
[MapTo("Password")]
|
||||
public string RawPassword { get; set; }
|
||||
|
||||
[MapTo("Source")]
|
||||
private string RawSource { get; set; } = string.Empty;
|
||||
|
||||
[MapTo("Username")]
|
||||
public string RawUsername { get; set; }
|
||||
|
||||
public string Source
|
||||
{
|
||||
get => RawSource;
|
||||
@@ -139,8 +142,7 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
|
||||
public string Title { get; set; }
|
||||
|
||||
[MapTo("Username")]
|
||||
public string RawUsername { get; set; }
|
||||
public string UserAgent { get; set; }
|
||||
|
||||
public string Username
|
||||
{
|
||||
@@ -200,6 +202,9 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
case FeedReadResult.NotEnabled:
|
||||
case FeedReadResult.NotModified:
|
||||
|
||||
// Reset status to success
|
||||
LastReadResult = FeedReadResult.Success;
|
||||
|
||||
// Ignore
|
||||
break;
|
||||
|
||||
@@ -242,6 +247,17 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
return new Tuple<FeedType, string>(feedType, retrieveResult.Item2);
|
||||
}
|
||||
|
||||
private string GetUserAgent()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(UserAgent))
|
||||
return UserAgent;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Settings.Default.DefaultUserAgent))
|
||||
return Settings.Default.DefaultUserAgent;
|
||||
|
||||
return "FeedCenter/" + UpdateCheck.LocalVersion;
|
||||
}
|
||||
|
||||
private Tuple<FeedReadResult, string> RetrieveFeed()
|
||||
{
|
||||
try
|
||||
@@ -252,23 +268,32 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
var clientHandler = new HttpClientHandler
|
||||
{
|
||||
// Set that we'll accept compressed data
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
|
||||
AllowAutoRedirect = true
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(clientHandler);
|
||||
_httpClient = new HttpClient(clientHandler)
|
||||
{
|
||||
// Set a timeout
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
// 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);
|
||||
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||
_httpClient.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip, deflate, br");
|
||||
_httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
|
||||
_httpClient.DefaultRequestHeaders.CacheControl = CacheControlHeaderValue.Parse("max-age=0");
|
||||
}
|
||||
|
||||
// Set a user agent string
|
||||
var userAgent = GetUserAgent();
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Clear();
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
|
||||
// If we need to authenticate then set the credentials
|
||||
_httpClient.DefaultRequestHeaders.Authorization = Authenticate ? new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))) : null;
|
||||
|
||||
_httpClient.DefaultRequestHeaders.IfModifiedSince = LastChecked;
|
||||
|
||||
// Attempt to get the response
|
||||
var response = _httpClient.GetAsync(Source).Result;
|
||||
|
||||
@@ -299,6 +324,11 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
}
|
||||
catch (HttpRequestException httpRequestException)
|
||||
{
|
||||
if (httpRequestException.StatusCode == HttpStatusCode.NotModified)
|
||||
{
|
||||
return Tuple.Create(FeedReadResult.NotModified, string.Empty);
|
||||
}
|
||||
|
||||
Log.Logger.Error(httpRequestException, "Exception");
|
||||
|
||||
return HandleHttpRequestException(httpRequestException);
|
||||
@@ -326,6 +356,9 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
{
|
||||
switch (httpRequestException.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.TooManyRequests:
|
||||
return Tuple.Create(FeedReadResult.TooManyRequests, string.Empty);
|
||||
|
||||
case HttpStatusCode.ServiceUnavailable:
|
||||
return Tuple.Create(FeedReadResult.TemporarilyUnavailable, string.Empty);
|
||||
|
||||
@@ -377,12 +410,12 @@ public partial class Feed : RealmObject, INotifyDataErrorInfo
|
||||
return FeedReadResult.NotDue;
|
||||
}
|
||||
|
||||
// We're checking it now so update the time
|
||||
LastChecked = DateTimeOffset.Now;
|
||||
|
||||
// Read the feed text
|
||||
var retrieveResult = RetrieveFeed();
|
||||
|
||||
// We're checking it now so update the time
|
||||
LastChecked = DateTimeOffset.Now;
|
||||
|
||||
// Get the information out of the async result
|
||||
var result = retrieveResult.Item1;
|
||||
var feedText = retrieveResult.Item2;
|
||||
|
||||
@@ -15,5 +15,6 @@ public enum FeedReadResult
|
||||
ConnectionFailed,
|
||||
ServerError,
|
||||
Moved,
|
||||
TemporarilyUnavailable
|
||||
TemporarilyUnavailable,
|
||||
TooManyRequests
|
||||
}
|
||||
@@ -24,17 +24,18 @@ public partial class MainWindow
|
||||
{
|
||||
StopTimer();
|
||||
|
||||
_mainTimer.Dispose();
|
||||
_mainTimer?.Dispose();
|
||||
_mainTimer = null;
|
||||
}
|
||||
|
||||
private void StartTimer()
|
||||
{
|
||||
_mainTimer.Start();
|
||||
_mainTimer?.Start();
|
||||
}
|
||||
|
||||
private void StopTimer()
|
||||
{
|
||||
_mainTimer.Stop();
|
||||
_mainTimer?.Stop();
|
||||
}
|
||||
|
||||
private void HandleMainTimerElapsed(object sender, EventArgs e)
|
||||
|
||||
@@ -166,6 +166,12 @@ public partial class MainWindow
|
||||
|
||||
// Delete the feed
|
||||
_database.SaveChanges(() => _database.Feeds.Remove(feedToDelete));
|
||||
|
||||
// Refresh the database to current settings
|
||||
ResetDatabase();
|
||||
|
||||
// Re-initialize the feed display
|
||||
DisplayFeed();
|
||||
}
|
||||
|
||||
private void OpenAllFeedItemsOnSinglePage()
|
||||
|
||||
@@ -49,14 +49,14 @@ public partial class MainWindow
|
||||
{
|
||||
// Set the last window location
|
||||
Settings.Default.WindowLocation = new Point(Left, Top);
|
||||
Settings.Default.Save();
|
||||
|
||||
// Set the last window size
|
||||
Settings.Default.WindowSize = new Size(Width, Height);
|
||||
Settings.Default.Save();
|
||||
|
||||
// Save the dock on the navigation tray
|
||||
Settings.Default.ToolbarLocation = NameBasedGrid.NameBasedGrid.GetRow(NavigationToolbarTray) == "TopToolbarRow" ? Dock.Top : Dock.Bottom;
|
||||
|
||||
// Save settings
|
||||
Settings.Default.Save();
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,13 @@
|
||||
<ComboBoxItem Content="{x:Static properties:Resources.openAllMultipleToolbarButton}"
|
||||
Tag="{x:Static feedCenter:MultipleOpenAction.IndividualPages}" />
|
||||
</ComboBox>
|
||||
<ComboBox Name="UserAgentComboBox"
|
||||
mah:TextBoxHelper.UseFloatingWatermark="True"
|
||||
mah:TextBoxHelper.Watermark="{x:Static properties:Resources.userAgentLabel}"
|
||||
DisplayMemberPath="Caption"
|
||||
ItemsSource="{Binding Source={x:Static options:UserAgentItem.UserAgents}}"
|
||||
SelectedValuePath="UserAgent"
|
||||
SelectedValue="{Binding Path=UserAgent, UpdateSourceTrigger=Explicit, ValidatesOnExceptions=true}" />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
<TabItem Header="{x:Static properties:Resources.authenticationTab}">
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
d:DesignWidth="300">
|
||||
<StackPanel options:Spacing.Vertical="10">
|
||||
<CheckBox Content="{x:Static properties:Resources.startWithWindowsCheckBox}"
|
||||
Name="StartWithWindowsCheckBox"
|
||||
IsChecked="{Binding Source={x:Static properties:Settings.Default}, Path=StartWithWindows}"
|
||||
Click="OnSaveSettings" />
|
||||
<ComboBox Name="BrowserComboBox"
|
||||
mah:TextBoxHelper.UseFloatingWatermark="True"
|
||||
<ComboBox mah:TextBoxHelper.UseFloatingWatermark="True"
|
||||
mah:TextBoxHelper.Watermark="{x:Static properties:Resources.defaultBrowserLabel}"
|
||||
d:DataContext="{d:DesignInstance Type=installedBrowsers:InstalledBrowser}"
|
||||
DisplayMemberPath="Name"
|
||||
@@ -24,12 +22,11 @@
|
||||
SelectedValuePath="Key"
|
||||
SelectedValue="{Binding Source={x:Static properties:Settings.Default}, Path=Browser}"
|
||||
SelectionChanged="OnSaveSettings" />
|
||||
<ComboBox Name="UserAgentComboBox"
|
||||
mah:TextBoxHelper.UseFloatingWatermark="True"
|
||||
<ComboBox mah:TextBoxHelper.UseFloatingWatermark="True"
|
||||
mah:TextBoxHelper.Watermark="{x:Static properties:Resources.defaultUserAgentLabel}"
|
||||
d:DataContext="{d:DesignInstance Type=options:UserAgentItem}"
|
||||
DisplayMemberPath="Caption"
|
||||
ItemsSource="{Binding Source={x:Static options:UserAgentItem.UserAgents}}"
|
||||
ItemsSource="{Binding Source={x:Static options:UserAgentItem.DefaultUserAgents}}"
|
||||
SelectedValuePath="UserAgent"
|
||||
SelectedValue="{Binding Source={x:Static properties:Settings.Default}, Path=DefaultUserAgent}"
|
||||
SelectionChanged="OnSaveSettings" />
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FeedCenter.Properties;
|
||||
|
||||
namespace FeedCenter.Options;
|
||||
|
||||
public class UserAgentItem
|
||||
{
|
||||
public string Caption { get; set; }
|
||||
public string UserAgent { get; set; }
|
||||
|
||||
public static List<UserAgentItem> UserAgents => new()
|
||||
public static List<UserAgentItem> DefaultUserAgents => new()
|
||||
{
|
||||
new UserAgentItem
|
||||
{
|
||||
Caption = Properties.Resources.DefaultUserAgentCaption,
|
||||
Caption = Properties.Resources.ApplicationUserAgentCaption,
|
||||
UserAgent = string.Empty
|
||||
},
|
||||
new UserAgentItem
|
||||
@@ -30,4 +31,29 @@ public class UserAgentItem
|
||||
UserAgent = "curl/7.47.0"
|
||||
}
|
||||
};
|
||||
|
||||
public string UserAgent { get; set; }
|
||||
|
||||
public static List<UserAgentItem> UserAgents
|
||||
{
|
||||
get
|
||||
{
|
||||
var defaultUserAgents = DefaultUserAgents;
|
||||
|
||||
var applicationDefaultUserAgent = defaultUserAgents.First(dua => dua.UserAgent == Settings.Default.DefaultUserAgent);
|
||||
|
||||
var userAgents = new List<UserAgentItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Caption = string.Format(Resources.DefaultUserAgentCaption, applicationDefaultUserAgent.Caption),
|
||||
UserAgent = null
|
||||
}
|
||||
};
|
||||
|
||||
userAgents.AddRange(defaultUserAgents);
|
||||
|
||||
return userAgents;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Application/Properties/Resources.Designer.cs
generated
38
Application/Properties/Resources.Designer.cs
generated
@@ -133,6 +133,15 @@ namespace FeedCenter.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Feed Center.
|
||||
/// </summary>
|
||||
public static string ApplicationUserAgentCaption {
|
||||
get {
|
||||
return ResourceManager.GetString("ApplicationUserAgentCaption", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Password.
|
||||
/// </summary>
|
||||
@@ -517,7 +526,7 @@ namespace FeedCenter.Properties {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Feed Center.
|
||||
/// Looks up a localized string similar to Default ({0}).
|
||||
/// </summary>
|
||||
public static string DefaultUserAgentCaption {
|
||||
get {
|
||||
@@ -831,6 +840,15 @@ namespace FeedCenter.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Temporarily unavailable.
|
||||
/// </summary>
|
||||
public static string FeedReadResult_TemporarilyUnavailable {
|
||||
get {
|
||||
return ResourceManager.GetString("FeedReadResult_TemporarilyUnavailable", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Timeout.
|
||||
/// </summary>
|
||||
@@ -840,6 +858,15 @@ namespace FeedCenter.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Too many requests.
|
||||
/// </summary>
|
||||
public static string FeedReadResult_TooManyRequests {
|
||||
get {
|
||||
return ResourceManager.GetString("FeedReadResult_TooManyRequests", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Not authorized.
|
||||
/// </summary>
|
||||
@@ -1461,6 +1488,15 @@ namespace FeedCenter.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to User agent.
|
||||
/// </summary>
|
||||
public static string userAgentLabel {
|
||||
get {
|
||||
return ResourceManager.GetString("userAgentLabel", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Version {0}.
|
||||
/// </summary>
|
||||
|
||||
@@ -524,6 +524,9 @@
|
||||
<value>Category: {0}</value>
|
||||
</data>
|
||||
<data name="DefaultUserAgentCaption" xml:space="preserve">
|
||||
<value>Default ({0})</value>
|
||||
</data>
|
||||
<data name="ApplicationUserAgentCaption" xml:space="preserve">
|
||||
<value>Feed Center</value>
|
||||
</data>
|
||||
<data name="defaultUserAgentLabel" xml:space="preserve">
|
||||
@@ -549,4 +552,13 @@ All feeds currently in category "{0}" will be moved to the default category.</va
|
||||
<data name="ConfirmDeleteFeeds" xml:space="preserve">
|
||||
<value>Are you sure you want to delete the selected feeds?</value>
|
||||
</data>
|
||||
<data name="userAgentLabel" xml:space="preserve">
|
||||
<value>User agent</value>
|
||||
</data>
|
||||
<data name="FeedReadResult_TemporarilyUnavailable" xml:space="preserve">
|
||||
<value>Temporarily unavailable</value>
|
||||
</data>
|
||||
<data name="FeedReadResult_TooManyRequests" xml:space="preserve">
|
||||
<value>Too many requests</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -36,6 +36,9 @@ public static class SettingsStore
|
||||
// Try to get the setting from the database that matches the name and version
|
||||
var setting = entities.Settings.FirstOrDefault(s => s.Name == name);
|
||||
|
||||
if (setting?.Value == value)
|
||||
return;
|
||||
|
||||
entities.SaveChanges(() =>
|
||||
{
|
||||
// If there was no setting we need to create it
|
||||
|
||||
@@ -23,5 +23,4 @@ deploy:
|
||||
- provider: Environment
|
||||
name: GitHub
|
||||
before_build:
|
||||
- cmd: nuget restore
|
||||
- cmd: nuget restore Application\FeedCenter.csproj
|
||||
- cmd: nuget restore
|
||||
Reference in New Issue
Block a user