V2 #1

Merged
danieljsummers merged 102 commits from v2 into main 2022-06-23 00:35:12 +00:00
9 changed files with 235 additions and 36 deletions
Showing only changes of commit 46d6c4f5f1 - Show all commits

View File

@ -20,6 +20,13 @@ type CommentIdConverter () =
override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) = override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) =
(string >> CommentId) reader.Value (string >> CommentId) reader.Value
type CustomFeedIdConverter () =
inherit JsonConverter<CustomFeedId> ()
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 () = type CustomFeedSourceConverter () =
inherit JsonConverter<CustomFeedSource> () inherit JsonConverter<CustomFeedSource> ()
override _.WriteJson (writer : JsonWriter, value : CustomFeedSource, _ : JsonSerializer) = override _.WriteJson (writer : JsonWriter, value : CustomFeedSource, _ : JsonSerializer) =
@ -91,6 +98,7 @@ let all () : JsonConverter seq =
// Our converters // Our converters
CategoryIdConverter () CategoryIdConverter ()
CommentIdConverter () CommentIdConverter ()
CustomFeedIdConverter ()
CustomFeedSourceConverter () CustomFeedSourceConverter ()
ExplicitRatingConverter () ExplicitRatingConverter ()
MarkupTextConverter () MarkupTextConverter ()

View File

@ -731,12 +731,28 @@ module WebLog =
resultOption; withRetryOptionDefault 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) = let updateSettings (webLog : WebLog) =
rethink { rethink {
withTable Table.WebLog withTable Table.WebLog
get webLog.id 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 write; withRetryDefault; ignoreResult
} }

View File

@ -187,6 +187,22 @@ module PostId =
let create () = PostId (newId ()) 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 /// The source for a custom feed
type CustomFeedSource = type CustomFeedSource =
/// A feed based on a particular category /// A feed based on a particular category
@ -274,7 +290,10 @@ type PodcastOptions =
/// A custom feed /// A custom feed
type CustomFeed = type CustomFeed =
{ /// The source for the custom feed { /// The ID of the custom feed
id : CustomFeedId
/// The source for the custom feed
source : CustomFeedSource source : CustomFeedSource
/// The path for the custom feed /// The path for the custom feed

View File

@ -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 /// Details about a page used to display page lists
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DisplayPage = type DisplayPage =
@ -255,6 +283,50 @@ type EditPostModel =
} }
/// View model to edit RSS settings
[<CLIMutable; NoComparison; NoEquality>]
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 /// View model to edit a tag mapping
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type EditTagMapModel = type EditTagMapModel =
@ -305,6 +377,8 @@ type EditUserModel =
newPassword = "" newPassword = ""
newPasswordConfirm = "" newPasswordConfirm = ""
} }
/// The model to use to allow a user to log on /// The model to use to allow a user to log on
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type LogOnModel = type LogOnModel =

View File

@ -15,10 +15,10 @@ open MyWebLog.ViewModels
/// The type of feed to generate /// The type of feed to generate
type FeedType = type FeedType =
| StandardFeed | StandardFeed of string
| CategoryFeed of CategoryId | CategoryFeed of CategoryId * string
| TagFeed of string | TagFeed of string * string
| Custom of CustomFeed | Custom of CustomFeed * string
/// Derive the type of RSS feed requested /// Derive the type of RSS feed requested
let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = 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 let postCount = defaultArg webLog.rss.itemsInFeed webLog.postsPerPage
// Standard feed // Standard feed
match webLog.rss.feedEnabled && feedPath = name with match webLog.rss.feedEnabled && feedPath = name with
| true -> Some (StandardFeed, postCount) | true -> Some (StandardFeed feedPath, postCount)
| false -> | false ->
// Category feed // Category feed
match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = feedPath.Replace (name, "")) with 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 -> | None ->
// Tag feed // Tag feed
match feedPath.StartsWith "/tag/" with 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 -> | false ->
// Custom feed // Custom feed
match webLog.rss.customFeeds match webLog.rss.customFeeds
|> List.tryFind (fun it -> (Permalink.toString it.path).EndsWith feedPath) with |> List.tryFind (fun it -> (Permalink.toString it.path).EndsWith feedPath) with
| Some feed -> | Some feed ->
Some (Custom feed, Some (Custom (feed, feedPath),
feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount)
| None -> | None ->
// No feed // No feed
@ -50,10 +50,10 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option =
/// Determine the function to retrieve posts for the given feed /// Determine the function to retrieve posts for the given feed
let private getFeedPosts (webLog : WebLog) feedType = let private getFeedPosts (webLog : WebLog) feedType =
match feedType with match feedType with
| StandardFeed -> Data.Post.findPageOfPublishedPosts webLog.id 1 | StandardFeed _ -> Data.Post.findPageOfPublishedPosts webLog.id 1
| CategoryFeed catId -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 | CategoryFeed (catId, _) -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1
| TagFeed tag -> Data.Post.findPageOfTaggedPosts webLog.id tag 1 | TagFeed (tag, _) -> Data.Post.findPageOfTaggedPosts webLog.id tag 1
| Custom feed -> | Custom (feed, _) ->
match feed.source with match feed.source with
| Category catId -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1 | Category catId -> Data.Post.findPageOfCategorizedPosts webLog.id [ catId ] 1
| Tag tag -> Data.Post.findPageOfTaggedPosts webLog.id tag 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 ("explicit", "itunes", ExplicitRating.toString podcast.explicit)
rssFeed.ElementExtensions.Add ("subscribe", "rawvoice", feedUrl) 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 /// Create a feed with a known non-zero-length list of posts
let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask { let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask {
let webLog = ctx.WebLog 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! authors = Post.getAuthors webLog posts conn
let! tagMaps = Post.getTagMappings webLog posts conn let! tagMaps = Post.getTagMappings webLog posts conn
let cats = CategoryCache.get ctx 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 toItem post =
let item = toFeedItem webLog authors cats tagMaps 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 addEpisode webLog feed post item
| _ -> 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.Title <- TextSyndicationContent webLog.name
feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name
feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn 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 feed.Id <- webLog.urlBase
webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy) webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy)
// TODO: adjust this link for non-root feeds let self, link = selfAndLink webLog feedType
feed.Links.Add (SyndicationLink (Uri $"{webLog.urlBase}/feed.xml", "self", "", "application/rss+xml", 0L)) feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L))
addNamespace feed "content" "http://purl.org/rss/1.0/modules/content/" feed.ElementExtensions.Add ("link", "", WebLog.absoluteUrl webLog link)
feed.ElementExtensions.Add ("link", "", webLog.urlBase)
podcast |> Option.iter (addPodcast webLog feed) podcast |> Option.iter (addPodcast webLog feed)
use mem = new MemoryStream () use mem = new MemoryStream ()
@ -270,12 +282,54 @@ open DotLiquid
// GET: /admin/rss/settings // GET: /admin/rss/settings
let editSettings : HttpHandler = fun next ctx -> task { 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! return!
Hash.FromAnonymousObject Hash.FromAnonymousObject
{| csrf = csrfToken ctx {| csrf = csrfToken ctx
model = ctx.WebLog.rss page_title = "RSS Settings"
page_title = "RSS Settings" model = EditRssModel.fromRssOptions webLog.rss
custom_feeds = feeds
|} |}
|> viewForTheme "admin" "rss-settings" next ctx |> 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<EditRssModel> ()
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
}

View File

@ -119,7 +119,11 @@ let router : HttpHandler = choose [
routef "/%s/delete" Post.delete routef "/%s/delete" Post.delete
]) ])
subRoute "/settings" (choose [ subRoute "/settings" (choose [
route "" >=> Admin.saveSettings route "" >=> Admin.saveSettings
subRoute "/rss" (choose [
route "" >=> Feed.saveSettings
routef "/%s/delete" Feed.deleteCustomFeed
])
subRoute "/tag-mapping" (choose [ subRoute "/tag-mapping" (choose [
route "/save" >=> Admin.saveMapping route "/save" >=> Admin.saveMapping
routef "/%s/delete" Admin.deleteMapping routef "/%s/delete" Admin.deleteMapping

View File

@ -220,10 +220,10 @@ let main args =
[ // Domain types [ // Domain types
typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog> typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog>
// View models // View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayPage>; typeof<EditCategoryModel> typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
typeof<EditPageModel>; typeof<EditPostModel>; typeof<EditTagMapModel>; typeof<EditUserModel> typeof<EditCategoryModel>; typeof<EditPageModel>; typeof<EditPostModel>; typeof<EditRssModel>
typeof<LogOnModel>; typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem> typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>; typeof<ManagePermalinksModel>
typeof<SettingsModel>; typeof<UserMessage> typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
// Framework types // Framework types
typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list> typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list>
typeof<string list>; typeof<string option>; typeof<TagMap list> typeof<string list>; typeof<string option>; typeof<TagMap list>

View File

@ -45,7 +45,7 @@
<div class="col-12 col-md-5 col-xl-4 pb-3"> <div class="col-12 col-md-5 col-xl-4 pb-3">
<div class="form-floating"> <div class="form-floating">
<input type="text" name="copyright" id="copyright" class="form-control" placeholder="Copyright String" <input type="text" name="copyright" id="copyright" class="form-control" placeholder="Copyright String"
{% if model.copyright %}value="{{ model.copyright.value }}"{% endif %}> value="{{ model.copyright }}">
<label for="copyright">Copyright String</label> <label for="copyright">Copyright String</label>
<span class="form-text"> <span class="form-text">
Can be a Can be a
@ -69,18 +69,30 @@
<thead> <thead>
<tr> <tr>
<th scope="col">Source</th> <th scope="col">Source</th>
<th scope="col">Path</th> <th scope="col">Relative Path</th>
<th scope="col">Podcast?</th> <th scope="col">Podcast?</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{%- assign feed_count = model.custom_feeds | size -%} {%- assign feed_count = custom_feeds | size -%}
{% if feed_count > 0 %} {% if feed_count > 0 %}
{% for feed in model.custom_feeds %} {% for feed in custom_feeds %}
<tr> <tr>
<td> <td>
{{ feed.source }} {{ feed.source }}<br>
<!-- TODO: view / edit / delete --> <small>
<a href="{{ feed.path | relative_link }}" target="_blank">View Feed</a>
<span class="text-muted"> &bull; </span>
{%- capture feed_edit %}admin/rss/{{ feed.id }}/edit{% endcapture -%}
<a href="{{ feed_edit | relative_link }}">Edit</a>
<span class="text-muted"> &bull; </span>
{%- capture feed_del %}admin/rss/{{ feed.id }}/delete{% endcapture -%}
{%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%}
<a href="{{ feed_del_link }}" class="text-danger"
onclick="return Admin.deleteCustomFeed('{{ feed.source }}', '{{ feed_del_link }}')">
Delete
</a>
</small>
</td> </td>
<td>{{ feed.path }}</td> <td>{{ feed.path }}</td>
<td>{% if feed.is_podcast %}Yes{% else %}No{% endif %}</td> <td>{% if feed.is_podcast %}Yes{% else %}No{% endif %}</td>
@ -93,4 +105,7 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
<form method="post" id="deleteForm">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
</form>
</article> </article>

View File

@ -178,6 +178,15 @@
return this.deleteItem(`category "${name}"`, url) 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 * Confirm and delete a page
* @param title The title of the page to be deleted * @param title The title of the page to be deleted