From 46d6c4f5f1e3d2759ac0bdc572cc03e489f978d8 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 29 May 2022 14:13:37 -0400 Subject: [PATCH] Save RSS settings - Add route for custom feed deletion - Add ID for custom feed --- src/MyWebLog.Data/Converters.fs | 8 ++ src/MyWebLog.Data/Data.fs | 20 +++- src/MyWebLog.Domain/SupportTypes.fs | 21 +++- src/MyWebLog.Domain/ViewModels.fs | 74 ++++++++++++++ src/MyWebLog/Handlers/Feed.fs | 98 ++++++++++++++----- src/MyWebLog/Handlers/Routes.fs | 6 +- src/MyWebLog/Program.fs | 8 +- src/MyWebLog/themes/admin/rss-settings.liquid | 27 +++-- src/MyWebLog/wwwroot/themes/admin/admin.js | 9 ++ 9 files changed, 235 insertions(+), 36 deletions(-) diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs index 49a08c0..13f8673 100644 --- a/src/MyWebLog.Data/Converters.fs +++ b/src/MyWebLog.Data/Converters.fs @@ -20,6 +20,13 @@ type CommentIdConverter () = override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) = (string >> CommentId) reader.Value +type CustomFeedIdConverter () = + inherit JsonConverter () + override _.WriteJson (writer : JsonWriter, value : CustomFeedId, _ : JsonSerializer) = + writer.WriteValue (CustomFeedId.toString value) + override _.ReadJson (reader : JsonReader, _ : Type, _ : CustomFeedId, _ : bool, _ : JsonSerializer) = + (string >> CustomFeedId) reader.Value + type CustomFeedSourceConverter () = inherit JsonConverter () override _.WriteJson (writer : JsonWriter, value : CustomFeedSource, _ : JsonSerializer) = @@ -91,6 +98,7 @@ let all () : JsonConverter seq = // Our converters CategoryIdConverter () CommentIdConverter () + CustomFeedIdConverter () CustomFeedSourceConverter () ExplicitRatingConverter () MarkupTextConverter () diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index 06b0087..6139d8a 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -731,12 +731,28 @@ module WebLog = resultOption; withRetryOptionDefault } - /// Update web log settings (updates all values) + /// Update RSS options for a web log + let updateRssOptions (webLog : WebLog) = + rethink { + withTable Table.WebLog + get webLog.id + update [ "rss", webLog.rss :> obj ] + write; withRetryDefault; ignoreResult + } + + /// Update web log settings (from settings page) let updateSettings (webLog : WebLog) = rethink { withTable Table.WebLog get webLog.id - replace webLog + update [ + "name", webLog.name :> obj + "subtitle", webLog.subtitle + "defaultPage", webLog.defaultPage + "postsPerPage", webLog.postsPerPage + "timeZone", webLog.timeZone + "themePath", webLog.themePath + ] write; withRetryDefault; ignoreResult } diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 3922b8e..6cdbd9f 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -187,6 +187,22 @@ module PostId = let create () = PostId (newId ()) +/// An identifier for a custom feed +type CustomFeedId = CustomFeedId of string + +/// Functions to support custom feed IDs +module CustomFeedId = + + /// An empty custom feed ID + let empty = CustomFeedId "" + + /// Convert a custom feed ID to a string + let toString = function CustomFeedId pi -> pi + + /// Create a new custom feed ID + let create () = CustomFeedId (newId ()) + + /// The source for a custom feed type CustomFeedSource = /// A feed based on a particular category @@ -274,7 +290,10 @@ type PodcastOptions = /// A custom feed type CustomFeed = - { /// The source for the custom feed + { /// The ID of the custom feed + id : CustomFeedId + + /// The source for the custom feed source : CustomFeedSource /// The path for the custom feed diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 022c46f..3bf3e73 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -26,6 +26,34 @@ type DisplayCategory = } +/// A display version of a custom feed definition +type DisplayCustomFeed = + { /// The ID of the custom feed + id : string + + /// The source of the custom feed + source : string + + /// The relative path at which the custom feed is served + path : string + + /// Whether this custom feed is for a podcast + isPodcast : bool + } + + /// Create a display version from a custom feed + static member fromFeed (cats : DisplayCategory[]) (feed : CustomFeed) : DisplayCustomFeed = + let source = + match feed.source with + | Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.id = catId)).name}" + | Tag tag -> $"Tag: {tag}" + { id = CustomFeedId.toString feed.id + source = source + path = Permalink.toString feed.path + isPodcast = Option.isSome feed.podcast + } + + /// Details about a page used to display page lists [] type DisplayPage = @@ -255,6 +283,50 @@ type EditPostModel = } +/// View model to edit RSS settings +[] +type EditRssModel = + { /// Whether the site feed of posts is enabled + feedEnabled : bool + + /// The name of the file generated for the site feed + feedName : string + + /// Override the "posts per page" setting for the site feed + itemsInFeed : int + + /// Whether feeds are enabled for all categories + categoryEnabled : bool + + /// Whether feeds are enabled for all tags + tagEnabled : bool + + /// A copyright string to be placed in all feeds + copyright : string + } + + /// Create an edit model from a set of RSS options + static member fromRssOptions (rss : RssOptions) = + { feedEnabled = rss.feedEnabled + feedName = rss.feedName + itemsInFeed = defaultArg rss.itemsInFeed 0 + categoryEnabled = rss.categoryEnabled + tagEnabled = rss.tagEnabled + copyright = defaultArg rss.copyright "" + } + + /// Update RSS options from values in this mode + member this.updateOptions (rss : RssOptions) = + { rss with + feedEnabled = this.feedEnabled + feedName = this.feedName + itemsInFeed = if this.itemsInFeed = 0 then None else Some this.itemsInFeed + categoryEnabled = this.categoryEnabled + tagEnabled = this.tagEnabled + copyright = if this.copyright.Trim () = "" then None else Some (this.copyright.Trim ()) + } + + /// View model to edit a tag mapping [] type EditTagMapModel = @@ -305,6 +377,8 @@ type EditUserModel = newPassword = "" newPasswordConfirm = "" } + + /// The model to use to allow a user to log on [] type LogOnModel = diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index c9f6d3b..6334428 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -15,10 +15,10 @@ open MyWebLog.ViewModels /// The type of feed to generate type FeedType = - | StandardFeed - | CategoryFeed of CategoryId - | TagFeed of string - | Custom of CustomFeed + | StandardFeed of string + | CategoryFeed of CategoryId * string + | TagFeed of string * string + | Custom of CustomFeed * string /// Derive the type of RSS feed requested let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = @@ -27,21 +27,21 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = let postCount = defaultArg webLog.rss.itemsInFeed webLog.postsPerPage // Standard feed match webLog.rss.feedEnabled && feedPath = name with - | true -> Some (StandardFeed, postCount) + | true -> Some (StandardFeed feedPath, postCount) | false -> // Category feed match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = feedPath.Replace (name, "")) with - | Some cat -> Some (CategoryFeed (CategoryId cat.id), postCount) + | Some cat -> Some (CategoryFeed (CategoryId cat.id, feedPath), postCount) | None -> // Tag feed match feedPath.StartsWith "/tag/" with - | true -> Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, "")), postCount) + | true -> Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, ""), feedPath), postCount) | false -> // Custom feed match webLog.rss.customFeeds |> List.tryFind (fun it -> (Permalink.toString it.path).EndsWith feedPath) with | Some feed -> - Some (Custom feed, + Some (Custom (feed, feedPath), feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) | None -> // No feed @@ -50,10 +50,10 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = /// Determine the function to retrieve posts for the given feed let private getFeedPosts (webLog : WebLog) feedType = match feedType with - | StandardFeed -> Data.Post.findPageOfPublishedPosts webLog.id 1 - | CategoryFeed catId -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 - | TagFeed tag -> Data.Post.findPageOfTaggedPosts webLog.id tag 1 - | Custom feed -> + | StandardFeed _ -> Data.Post.findPageOfPublishedPosts webLog.id 1 + | CategoryFeed (catId, _) -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 + | TagFeed (tag, _) -> Data.Post.findPageOfTaggedPosts webLog.id tag 1 + | Custom (feed, _) -> match feed.source with | Category catId -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 | Tag tag -> Data.Post.findPageOfTaggedPosts webLog.id tag 1 @@ -213,6 +213,16 @@ let private addPodcast webLog (rssFeed : SyndicationFeed) (feed : CustomFeed) = rssFeed.ElementExtensions.Add ("explicit", "itunes", ExplicitRating.toString podcast.explicit) rssFeed.ElementExtensions.Add ("subscribe", "rawvoice", feedUrl) +/// Get the feed's self reference and non-feed link +let private selfAndLink webLog feedType = + match feedType with + | StandardFeed path -> path + | CategoryFeed (_, path) -> path + | TagFeed (_, path) -> path + | Custom (_, path) -> path + |> function + | path -> Permalink path, Permalink (path.Replace ($"/{webLog.rss.feedName}", "")) + /// Create a feed with a known non-zero-length list of posts let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask { let webLog = ctx.WebLog @@ -220,7 +230,7 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg let! authors = Post.getAuthors webLog posts conn let! tagMaps = Post.getTagMappings webLog posts conn let cats = CategoryCache.get ctx - let podcast = match feedType with Custom feed when Option.isSome feed.podcast -> Some feed | _ -> None + let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None let toItem post = let item = toFeedItem webLog authors cats tagMaps post @@ -229,7 +239,9 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg addEpisode webLog feed post item | _ -> item - let feed = SyndicationFeed () + let feed = SyndicationFeed () + addNamespace feed "content" "http://purl.org/rss/1.0/modules/content/" + feed.Title <- TextSyndicationContent webLog.name feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn @@ -239,10 +251,10 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg feed.Id <- webLog.urlBase webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy) - // TODO: adjust this link for non-root feeds - feed.Links.Add (SyndicationLink (Uri $"{webLog.urlBase}/feed.xml", "self", "", "application/rss+xml", 0L)) - addNamespace feed "content" "http://purl.org/rss/1.0/modules/content/" - feed.ElementExtensions.Add ("link", "", webLog.urlBase) + let self, link = selfAndLink webLog feedType + feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L)) + feed.ElementExtensions.Add ("link", "", WebLog.absoluteUrl webLog link) + podcast |> Option.iter (addPodcast webLog feed) use mem = new MemoryStream () @@ -270,12 +282,54 @@ open DotLiquid // GET: /admin/rss/settings let editSettings : HttpHandler = fun next ctx -> task { - // TODO: stopped here + let webLog = ctx.WebLog + let feeds = + webLog.rss.customFeeds + |> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx)) + |> Array.ofList return! Hash.FromAnonymousObject - {| csrf = csrfToken ctx - model = ctx.WebLog.rss - page_title = "RSS Settings" + {| csrf = csrfToken ctx + page_title = "RSS Settings" + model = EditRssModel.fromRssOptions webLog.rss + custom_feeds = feeds |} |> viewForTheme "admin" "rss-settings" next ctx } + +// POST: /admin/rss/settings +let saveSettings : HttpHandler = fun next ctx -> task { + let conn = ctx.Conn + let! model = ctx.BindFormAsync () + match! Data.WebLog.findById ctx.WebLog.id conn with + | Some webLog -> + let webLog = { webLog with rss = model.updateOptions webLog.rss } + do! Data.WebLog.updateRssOptions webLog conn + WebLogCache.set webLog + do! addMessage ctx { UserMessage.success with message = "RSS settings updated successfully" } + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx + | None -> return! Error.notFound next ctx +} + +// POST /admin/rss/{id}/delete +let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task { + let conn = ctx.Conn + match! Data.WebLog.findById ctx.WebLog.id conn with + | Some webLog -> + let customId = CustomFeedId feedId + if webLog.rss.customFeeds |> List.exists (fun f -> f.id = customId) then + let webLog = { + webLog with + rss = { + webLog.rss with + customFeeds = webLog.rss.customFeeds |> List.filter (fun f -> f.id <> customId) + } + } + do! Data.WebLog.updateRssOptions webLog conn + WebLogCache.set webLog + do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" } + else + do! addMessage ctx { UserMessage.warning with message = "Post not found; nothing deleted" } + return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx + | None -> return! Error.notFound next ctx +} diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 38aafcd..17f6d01 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -119,7 +119,11 @@ let router : HttpHandler = choose [ routef "/%s/delete" Post.delete ]) subRoute "/settings" (choose [ - route "" >=> Admin.saveSettings + route "" >=> Admin.saveSettings + subRoute "/rss" (choose [ + route "" >=> Feed.saveSettings + routef "/%s/delete" Feed.deleteCustomFeed + ]) subRoute "/tag-mapping" (choose [ route "/save" >=> Admin.saveMapping routef "/%s/delete" Admin.deleteMapping diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 2ae829a..2e7da5e 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -220,10 +220,10 @@ let main args = [ // Domain types typeof; typeof; typeof; typeof; typeof; typeof // View models - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof; typeof; typeof - typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof + typeof; typeof; typeof; typeof // Framework types typeof; typeof; typeof; typeof typeof; typeof; typeof diff --git a/src/MyWebLog/themes/admin/rss-settings.liquid b/src/MyWebLog/themes/admin/rss-settings.liquid index 89ebee2..9ba2fad 100644 --- a/src/MyWebLog/themes/admin/rss-settings.liquid +++ b/src/MyWebLog/themes/admin/rss-settings.liquid @@ -45,7 +45,7 @@
+ value="{{ model.copyright }}"> Can be a @@ -69,18 +69,30 @@ Source - Path + Relative Path Podcast? - {%- assign feed_count = model.custom_feeds | size -%} + {%- assign feed_count = custom_feeds | size -%} {% if feed_count > 0 %} - {% for feed in model.custom_feeds %} + {% for feed in custom_feeds %} - {{ feed.source }} - + {{ feed.source }}
+ + View Feed + + {%- capture feed_edit %}admin/rss/{{ feed.id }}/edit{% endcapture -%} + Edit + + {%- capture feed_del %}admin/rss/{{ feed.id }}/delete{% endcapture -%} + {%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%} + + Delete + + {{ feed.path }} {% if feed.is_podcast %}Yes{% else %}No{% endif %} @@ -93,4 +105,7 @@ {% endif %} +
+ +
diff --git a/src/MyWebLog/wwwroot/themes/admin/admin.js b/src/MyWebLog/wwwroot/themes/admin/admin.js index e9dafaf..b6bd865 100644 --- a/src/MyWebLog/wwwroot/themes/admin/admin.js +++ b/src/MyWebLog/wwwroot/themes/admin/admin.js @@ -178,6 +178,15 @@ return this.deleteItem(`category "${name}"`, url) }, + /** + * Confirm and delete a custom RSS feed + * @param source The source for the feed to be deleted + * @param url The URL to which the form should be posted + */ + deleteCustomFeed(source, url) { + return this.deleteItem(`custom RSS feed based on ${source}`, url) + }, + /** * Confirm and delete a page * @param title The title of the page to be deleted