diff --git a/.gitignore b/.gitignore
index 3d671a7e..ed4aec45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ TestResults/
*.mdf
*.psess
*.vsp
+.idea/
/**/project.lock.json
@@ -18,4 +19,4 @@ StyleCop.Cache
/.vs
/packages
/RedditSharpTests/secrets.json
-/RedditSharpTests/private.config
+/RedditSharpTests/private.json
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c0c5796a..9c0f9931 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Development
-Use a private.config file to pull in config for tests. The required format is as follows
+Use a private.json file to pull in config for tests. The required format is as follows
```json
{
diff --git a/RedditSharp/Data/Collections/AddOrRemovePostsFromCollectionParams.cs b/RedditSharp/Data/Collections/AddOrRemovePostsFromCollectionParams.cs
new file mode 100644
index 00000000..df38eed2
--- /dev/null
+++ b/RedditSharp/Data/Collections/AddOrRemovePostsFromCollectionParams.cs
@@ -0,0 +1,17 @@
+namespace RedditSharp.Data.Collections
+{
+ internal class AddOrRemovePostsFromCollectionParams
+ {
+ ///
+ /// UUID of a collection
+ ///
+ [RedditAPIName("collection_id")]
+ internal string CollectionId { get; set; }
+
+ ///
+ /// Full name of link, e.g. t3_xyz
+ ///
+ [RedditAPIName("link_fullname")]
+ internal string LinkFullName { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Data/Collections/CollectionCreationParams.cs b/RedditSharp/Data/Collections/CollectionCreationParams.cs
new file mode 100644
index 00000000..13d97ac3
--- /dev/null
+++ b/RedditSharp/Data/Collections/CollectionCreationParams.cs
@@ -0,0 +1,23 @@
+namespace RedditSharp.Data.Collections
+{
+ internal class CollectionCreationParams
+ {
+ ///
+ /// Title of the submission. Maximum 300 characters.
+ ///
+ [RedditAPIName("title")]
+ internal string Title { get; set; }
+
+ ///
+ /// Description of the collection. Maximum of 500 characters.
+ ///
+ [RedditAPIName("description")]
+ internal string Description { get; set; }
+
+ ///
+ /// Name of the subreddit to which you are submitting.
+ ///
+ [RedditAPIName("sr_fullname")]
+ internal string Subreddit { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Data/Collections/GetCollectionParams.cs b/RedditSharp/Data/Collections/GetCollectionParams.cs
new file mode 100644
index 00000000..9a2616db
--- /dev/null
+++ b/RedditSharp/Data/Collections/GetCollectionParams.cs
@@ -0,0 +1,20 @@
+namespace RedditSharp.Data.Collections
+{
+ internal class CollectionBaseParams
+ {
+ ///
+ /// UUID of a collection
+ ///
+ [RedditAPIName("collection_id")]
+ internal string CollectionId { get; set; }
+ }
+
+ internal class GetCollectionParams : CollectionBaseParams
+ {
+ ///
+ /// Should include all the links
+ ///
+ [RedditAPIName("include_links")]
+ internal bool IncludeLinks { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Data/Urls.cs b/RedditSharp/Data/Urls.cs
new file mode 100644
index 00000000..1bdb7e88
--- /dev/null
+++ b/RedditSharp/Data/Urls.cs
@@ -0,0 +1,15 @@
+namespace RedditSharp.Data
+{
+ internal static class Urls
+ {
+ internal static class Collections
+ {
+ internal const string AddPost = "/api/v1/collections/add_post_to_collection";
+ internal static string Get(string collectionId, bool includeLinks) => $"/api/v1/collections/collection.json?collection_id={collectionId}&include_links={includeLinks}";
+ internal const string CreateCollectionUrl = "/api/v1/collections/create_collection";
+ internal const string Delete = "/api/v1/collections/delete_collection";
+ internal const string RemovePost = "/api/v1/collections/remove_post_in_collection";
+ internal static string SubredditCollectionsUrl(string fullName) => $"/api/v1/collections/subreddit_collections.json?sr_fullname={fullName}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Exceptions/RedditException.cs b/RedditSharp/Exceptions/RedditException.cs
index e6ff600e..9a1118fd 100644
--- a/RedditSharp/Exceptions/RedditException.cs
+++ b/RedditSharp/Exceptions/RedditException.cs
@@ -1,4 +1,5 @@
using System;
+using Newtonsoft.Json.Linq;
namespace RedditSharp
{
@@ -8,6 +9,8 @@ namespace RedditSharp
[Serializable]
public class RedditException : Exception
{
+ public JArray Errors { get; }
+
///
/// Initializes a new instance of the RedditException class.
///
@@ -26,6 +29,17 @@ public RedditException(string message)
}
+ ///
+ /// Initializes a new instance of the RedditException class with a specified error message and a JArray of errors
+ ///
+ /// The message that describes the error.
+ /// List of errors.
+ public RedditException(string message, JArray errors)
+ : base(message)
+ {
+ Errors = errors;
+ }
+
///
/// Initializes a new instance of the RedditException class with a specified error message and
/// a referenced inner exception that is the cause of this exception.
diff --git a/RedditSharp/Extensions/JTokenExtensions/JTokenExtensions.cs b/RedditSharp/Extensions/JTokenExtensions/JTokenExtensions.cs
new file mode 100644
index 00000000..a029590d
--- /dev/null
+++ b/RedditSharp/Extensions/JTokenExtensions/JTokenExtensions.cs
@@ -0,0 +1,33 @@
+using Newtonsoft.Json.Linq;
+
+namespace RedditSharp.Extensions.JTokenExtensions
+{
+ public static class JTokenExtensions
+ {
+ public static void ThrowIfHasErrors(this JToken json, string message)
+ {
+ if (json["errors"].IsNonEmptyArray(out var errors))
+ {
+ throw new RedditException($"{message} {errors}", errors);
+ }
+ }
+
+ public static bool IsNonEmptyArray(this JToken json, out JArray array)
+ {
+ var isArray = _IsArray(json, out array);
+ return isArray && array.Count > 0;
+ }
+
+ private static bool _IsArray(JToken json, out JArray array)
+ {
+ if (json != null && json.Type == JTokenType.Array)
+ {
+ array = (JArray)json;
+ return true;
+ }
+ array = default;
+ return false;
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Helpers/Helpers.PopulateObjects.cs b/RedditSharp/Helpers/Helpers.PopulateObjects.cs
new file mode 100644
index 00000000..df7dfb83
--- /dev/null
+++ b/RedditSharp/Helpers/Helpers.PopulateObjects.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json.Linq;
+using RedditSharp.Interfaces;
+
+namespace RedditSharp
+{
+ public partial class Helpers
+ {
+ internal static List PopulateObjects(JToken json, IWebAgent webAgent)
+ where T : ISettableWebAgent, new()
+ {
+ if (json.Type != JTokenType.Array)
+ throw new ArgumentException("must be of type array", nameof(json));
+
+ var objects = new List();
+
+ for (var i = 0; i < json.Count(); i++)
+ {
+ var item = new T();
+ PopulateObject(json[i], item);
+ item.WebAgent = webAgent;
+ objects.Add(item);
+ }
+
+ return objects;
+ }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Interfaces/ISettableWebAgent.cs b/RedditSharp/Interfaces/ISettableWebAgent.cs
new file mode 100644
index 00000000..463b06c5
--- /dev/null
+++ b/RedditSharp/Interfaces/ISettableWebAgent.cs
@@ -0,0 +1,10 @@
+using Newtonsoft.Json;
+
+namespace RedditSharp.Interfaces
+{
+ internal interface ISettableWebAgent
+ {
+ [JsonIgnore]
+ IWebAgent WebAgent { set; }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Reddit.cs b/RedditSharp/Reddit.cs
index e5cedfaa..dca55986 100644
--- a/RedditSharp/Reddit.cs
+++ b/RedditSharp/Reddit.cs
@@ -5,6 +5,9 @@
using System.Linq.Expressions;
using System.Security.Authentication;
using System.Threading.Tasks;
+using RedditSharp.Data;
+using RedditSharp.Data.Collections;
+using RedditSharp.Extensions.JTokenExtensions;
using DefaultWebAgent = RedditSharp.WebAgent;
namespace RedditSharp
@@ -477,5 +480,23 @@ public Listing GetListing(string url, int maxLimit = -1, int limitPerReque
{
return new Listing(this.WebAgent, url, maxLimit, limitPerRequest);
}
+
+ public async Task GetCollectionAsync(string collectionId, bool includePostsContent = true)
+ {
+ var json = await WebAgent.Get(Urls.Collections.Get(collectionId, includePostsContent));
+ json.ThrowIfHasErrors("Could not retrieve the collection.");
+ return new Collection(json, WebAgent);
+ }
+
+ ///
+ /// Deletes the specified collection. Must be a mod of the subreddit to delete.
+ ///
+ ///
+ ///
+ public async Task DeleteCollectionAsync(string collectionId)
+ {
+ var json = await WebAgent.Post(Urls.Collections.Delete, new CollectionBaseParams { CollectionId = collectionId });
+ json.ThrowIfHasErrors("Could not delete the collection.");
+ }
}
}
diff --git a/RedditSharp/RedditSharp.csproj b/RedditSharp/RedditSharp.csproj
index 419f3cf4..fbbed94c 100755
--- a/RedditSharp/RedditSharp.csproj
+++ b/RedditSharp/RedditSharp.csproj
@@ -12,12 +12,12 @@
-
+
-
+
diff --git a/RedditSharp/Things/Collection.cs b/RedditSharp/Things/Collection.cs
new file mode 100644
index 00000000..74a9d105
--- /dev/null
+++ b/RedditSharp/Things/Collection.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using RedditSharp.Data;
+using RedditSharp.Data.Collections;
+using RedditSharp.Extensions.JTokenExtensions;
+using RedditSharp.Interfaces;
+
+namespace RedditSharp.Things
+{
+ public class Collection : ISettableWebAgent
+ {
+ [JsonProperty("subreddit_id")]
+ public string SubredditId { get; internal set; }
+
+ [JsonProperty("description")]
+ public string Description { get; internal set; }
+
+ [JsonProperty("author_name")]
+ public string AuthorName { get; internal set; }
+
+ [JsonProperty("collection_id")]
+ public string CollectionId { get; internal set; }
+
+ [JsonProperty("display_layout")]
+ public string DisplayLayout { get; internal set; }
+
+ [JsonProperty("permalink")]
+ public string Permalink { get; internal set; }
+
+ [JsonProperty("link_ids")]
+ public string[] LinkIds { get; internal set; }
+
+ [JsonProperty("title")]
+ public string Title { get; internal set; }
+
+ [JsonProperty("created_at_utc"), JsonConverter(typeof(UnixTimestampConverter))]
+ public DateTime CreatedAtUtc { get; internal set; }
+
+ [JsonProperty("author_id")]
+ public string AuthorId { get; internal set; }
+
+ [JsonProperty("last_update_utc"), JsonConverter(typeof(UnixTimestampConverter))]
+ public DateTime LastUpdateUtc { get; internal set; }
+
+ public Post[] Posts { get; }
+
+ public IWebAgent WebAgent { private get; set; }
+
+ public Collection()
+ {
+ }
+
+ public Collection(JToken json, IWebAgent agent)
+ {
+ WebAgent = agent;
+
+ Helpers.PopulateObject(json, this);
+
+ var posts = new List();
+ var children = json.SelectToken("sorted_links.data.children");
+ if (children != null && children.Type == JTokenType.Array)
+ {
+ posts.AddRange(children.Select(item => new Post(WebAgent, item)));
+ }
+
+ Posts = posts.ToArray();
+ }
+
+ ///
+ /// Adds a post to the collection
+ ///
+ /// Full name of link, e.g. t3_xyz
+ public async Task AddPostAsync(string linkFullName)
+ {
+ var data = new AddOrRemovePostsFromCollectionParams
+ {
+ CollectionId = CollectionId,
+ LinkFullName = linkFullName,
+ };
+ var json = await WebAgent.Post(Urls.Collections.AddPost, data);
+ json.ThrowIfHasErrors("Could not add post to collection.");
+ }
+
+ ///
+ /// Removes a post from the collection
+ ///
+ /// Full name of link, e.g. t3_xyz
+ public async Task RemovePostAsync(string linkFullName)
+ {
+ var data = new AddOrRemovePostsFromCollectionParams
+ {
+ CollectionId = CollectionId,
+ LinkFullName = linkFullName,
+ };
+ var json = await WebAgent.Post(Urls.Collections.RemovePost, data);
+ json.ThrowIfHasErrors("Could not remove post from collection.");
+ }
+
+ public async Task DeleteAsync()
+ {
+ var json = await WebAgent.Post(Urls.Collections.Delete, null);
+ json.ThrowIfHasErrors("Could not remove collection.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharp/Things/Subreddit.cs b/RedditSharp/Things/Subreddit.cs
index c1e08afc..b8fc4f5d 100644
--- a/RedditSharp/Things/Subreddit.cs
+++ b/RedditSharp/Things/Subreddit.cs
@@ -6,6 +6,9 @@
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using RedditSharp.Data;
+using RedditSharp.Data.Collections;
+using RedditSharp.Extensions.JTokenExtensions;
namespace RedditSharp.Things
{
@@ -936,6 +939,28 @@ public Listing GetModerationLog(ModActionType action, IEnumerable.Create(WebAgent, url, max, 500);
}
+ public async Task CreateCollectionAsync(string title, string description)
+ {
+ var data = new CollectionCreationParams
+ {
+ Subreddit = FullName,
+ Title = title,
+ Description = description,
+ };
+ var json = await WebAgent.Post(Urls.Collections.CreateCollectionUrl, data).ConfigureAwait(false);
+ json.ThrowIfHasErrors("Could not create collection.");
+ var result = new Collection(json, WebAgent);
+ return result;
+ }
+
+ public async Task> GetCollectionsAsync()
+ {
+ var json = await WebAgent.Get(Urls.Collections.SubredditCollectionsUrl(FullName)).ConfigureAwait(false);
+ json.ThrowIfHasErrors("Could not retrieve collections.");
+ var result = Helpers.PopulateObjects(json, WebAgent);
+ return result;
+ }
+
#region Static Operations
public static async Task> GetModeratorsAsync(IWebAgent agent, string subreddit ) {
diff --git a/RedditSharpTests/AuthenticatedTestsFixture.cs b/RedditSharpTests/AuthenticatedTestsFixture.cs
index f51b7a36..3ea01d5d 100644
--- a/RedditSharpTests/AuthenticatedTestsFixture.cs
+++ b/RedditSharpTests/AuthenticatedTestsFixture.cs
@@ -12,7 +12,7 @@ public class AuthenticatedTestsFixture
public AuthenticatedTestsFixture()
{
ConfigurationBuilder builder = new ConfigurationBuilder();
- builder.AddJsonFile("private.config",true)
+ builder.AddJsonFile("private.json",true)
.AddEnvironmentVariables();
Config = builder.Build();
WebAgent = new RedditSharp.BotWebAgent(Config["TestUserName"], Config["TestUserPassword"], Config["RedditClientID"], Config["RedditClientSecret"], Config["RedditRedirectURI"]);
diff --git a/RedditSharpTests/Collections/CollectionTests.cs b/RedditSharpTests/Collections/CollectionTests.cs
new file mode 100644
index 00000000..33fbf1da
--- /dev/null
+++ b/RedditSharpTests/Collections/CollectionTests.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using RedditSharp;
+using RedditSharp.Things;
+using Retry;
+using Xunit;
+
+namespace RedditSharpTests.Collections
+{
+ [Collection("AuthenticatedTests")]
+ public class CollectionTests
+ {
+ private readonly Reddit _reddit;
+ private readonly string _subredditName;
+
+ public CollectionTests(AuthenticatedTestsFixture authenticatedFixture)
+ {
+ var authFixture = authenticatedFixture;
+ var agent = new WebAgent(authFixture.AccessToken);
+ _reddit = new Reddit(agent, true);
+ _subredditName = authFixture.Config["TestSubreddit"];
+ }
+
+ [SkippableFact]
+ public async Task CreatingACollection()
+ {
+ var guid = GenerateGuid();
+ var currentDate = DateTime.UtcNow.AddMinutes(-2);
+
+ var sub = await _reddit.GetSubredditAsync(_subredditName);
+ SkipIfNotModerator(sub);
+
+ var title = $"A collection with no posts {guid}";
+ var description = $"Collection description {GenerateGuid()}";
+
+ var result = await sub.CreateCollectionAsync(title, description);
+
+ Assert.Equal(description, result.Description);
+ Assert.Equal(title, result.Title);
+ Assert.Equal(sub.FullName, result.SubredditId);
+ Assert.True(result.CreatedAtUtc >= currentDate);
+ Assert.True(result.LastUpdateUtc >= currentDate);
+
+ var collections = await sub.GetCollectionsAsync();
+ Assert.True(collections.Count >= 1, "there should be at least one collection");
+ var collection = collections.FirstOrDefault(x => x.CollectionId == result.CollectionId);
+ Assert.NotNull(collection);
+
+ await _reddit.DeleteCollectionAsync(collection.CollectionId);
+ }
+
+ [SkippableFact]
+ public async Task CreatingACollectionAndAddingPosts()
+ {
+ var post1Guid = GenerateGuid();
+ var post2Guid = GenerateGuid();
+ var title = $"Collection of {post1Guid} and {post2Guid}";
+ var description = $"Awesome new collection {GenerateGuid()}";
+
+ var sub = await _reddit.GetSubredditAsync(_subredditName);
+ SkipIfNotModerator(sub);
+
+ var post1Task = sub.SubmitPostAsync($"Post {post1Guid}", "https://github.com/CrustyJew/RedditSharp", resubmit: true);
+ var post2Task = sub.SubmitTextPostAsync($"Post {post2Guid}", $"Post {post2Guid}");
+
+ var createCollectionTask = sub.CreateCollectionAsync(title, description);
+
+ var post1 = await post1Task;
+ var post2 = await post2Task;
+ var collectionResult = await createCollectionTask;
+
+ Assert.NotNull(post1);
+ Assert.NotNull(post2);
+
+ var addPost1Task = collectionResult.AddPostAsync(post1.FullName);
+ var addPost2Task = collectionResult.AddPostAsync(post2.FullName);
+
+ await addPost1Task;
+ await addPost2Task;
+
+ var collection = await RetryHelper.Instance
+ .Try(() => _reddit.GetCollectionAsync(collectionResult.CollectionId))
+ .WithTryInterval(TimeSpan.FromSeconds(0.5))
+ .WithMaxTryCount(10)
+ .Until(c => c.LinkIds.Length > 1);
+
+ Assert.Equal(2, collection.LinkIds.Length);
+ Assert.Contains(post1.FullName, collection.LinkIds);
+ Assert.Contains(post2.FullName, collection.LinkIds);
+
+ Assert.Equal(2, collection.Posts.Length);
+
+ var collectionWithLinkContent = await _reddit.GetCollectionAsync(collectionResult.CollectionId, includePostsContent: false);
+
+ Assert.Empty(collectionWithLinkContent.Posts);
+
+ await _reddit.DeleteCollectionAsync(collection.CollectionId);
+ }
+
+ [Fact]
+ public async Task DeletingANonExistentCollection()
+ {
+ var exception = await Assert.ThrowsAsync(() => _reddit.DeleteCollectionAsync("00000000-0000-0000-1111-111111111111"));
+ Assert.Contains(exception.Errors, error => error[0].ToString().Equals("INVALID_COLLECTION_ID"));
+ }
+
+ private void SkipIfNotModerator(Subreddit sub)
+ {
+ Skip.If(sub.UserIsModerator != true, $"User isn't a moderator of ${_subredditName} so a collection cannot be made.");
+ }
+
+ private static string GenerateGuid()
+ {
+ return Guid.NewGuid().ToString("N").Substring(0, 5);
+ }
+ }
+}
\ No newline at end of file
diff --git a/RedditSharpTests/RedditSharpTests.csproj b/RedditSharpTests/RedditSharpTests.csproj
index 329244ef..0ff26325 100644
--- a/RedditSharpTests/RedditSharpTests.csproj
+++ b/RedditSharpTests/RedditSharpTests.csproj
@@ -1,13 +1,12 @@
- netcoreapp1.1
+ netcoreapp2.2
RedditSharpTests
RedditSharpTests
true
aspnet-RedditSharpTests
- $(PackageTargetFallback);dotnet5.6;portable-net45+win8
- 1.1.1
+ true
@@ -15,28 +14,22 @@
-
-
+
+
-
+
-
-
+
+
+
-
+
+
-
-
-
-
-
-
-
-
-
+
PreserveNewest