myWebLog/src/MyWebLog.Domain/ViewModels.fs

1252 lines
41 KiB
Forth
Raw Normal View History

2022-06-23 00:35:12 +00:00
namespace MyWebLog.ViewModels
open System
open MyWebLog
open NodaTime
2022-06-23 00:35:12 +00:00
/// Helper functions for view models
[<AutoOpen>]
module private Helpers =
/// Create a string option if a string is blank
let noneIfBlank (it : string) =
2023-12-13 20:43:35 +00:00
match (defaultArg (Option.ofObj it) "").Trim() with "" -> None | trimmed -> Some trimmed
2022-06-23 00:35:12 +00:00
/// Helper functions that are needed outside this file
[<AutoOpen>]
module PublicHelpers =
/// If the web log is not being served from the domain root, add the path information to relative URLs in page and
/// post text
let addBaseToRelativeUrls extra (text : string) =
if extra = "" then text
else text.Replace("href=\"/", $"href=\"{extra}/").Replace ("src=\"/", $"src=\"{extra}/")
2022-06-29 02:18:56 +00:00
/// The model used to display the admin dashboard
[<NoComparison; NoEquality>]
2023-12-13 20:43:35 +00:00
type DashboardModel = {
/// The number of published posts
Posts : int
2022-06-29 02:18:56 +00:00
2023-12-13 20:43:35 +00:00
/// The number of post drafts
Drafts : int
2022-06-29 02:18:56 +00:00
2023-12-13 20:43:35 +00:00
/// The number of pages
Pages : int
2022-06-29 02:18:56 +00:00
2023-12-13 20:43:35 +00:00
/// The number of pages in the page list
ListedPages : int
2022-06-29 02:18:56 +00:00
2023-12-13 20:43:35 +00:00
/// The number of categories
Categories : int
2022-06-29 02:18:56 +00:00
2023-12-13 20:43:35 +00:00
/// The top-level categories
TopLevelCategories : int
}
2022-06-29 02:18:56 +00:00
2022-06-23 00:35:12 +00:00
/// Details about a category, used to display category lists
[<NoComparison; NoEquality>]
2023-12-13 20:43:35 +00:00
type DisplayCategory = {
/// The ID of the category
Id : string
/// The slug for the category
Slug : string
/// The name of the category
Name : string
/// A description of the category
Description : string option
/// The parent category names for this (sub)category
ParentNames : string[]
/// The number of posts in this category
PostCount : int
}
2022-06-23 00:35:12 +00:00
/// A display version of a custom feed definition
2023-12-13 20:43:35 +00:00
type DisplayCustomFeed = {
/// The ID of the custom feed
Id: string
2023-12-13 20:43:35 +00:00
/// The source of the custom feed
Source: string
2023-12-13 20:43:35 +00:00
/// The relative path at which the custom feed is served
Path: string
2023-12-13 20:43:35 +00:00
/// Whether this custom feed is for a podcast
IsPodcast: bool
2023-12-13 20:43:35 +00:00
}
/// Support functions for custom feed displays
module DisplayCustomFeed =
2022-06-23 00:35:12 +00:00
/// Create a display version from a custom feed
let fromFeed (cats: DisplayCategory array) (feed: CustomFeed) : DisplayCustomFeed =
2022-06-23 00:35:12 +00:00
let source =
match feed.Source with
| Category (CategoryId catId) -> $"Category: {(cats |> Array.find (fun cat -> cat.Id = catId)).Name}"
2022-06-23 00:35:12 +00:00
| Tag tag -> $"Tag: {tag}"
{ Id = string feed.Id
2022-07-20 02:51:51 +00:00
Source = source
Path = string feed.Path
2022-07-20 02:51:51 +00:00
IsPodcast = Option.isSome feed.Podcast
2022-06-23 00:35:12 +00:00
}
/// Details about a page used to display page lists
[<NoComparison; NoEquality>]
type DisplayPage = {
/// The ID of this page
Id: string
2022-06-23 00:35:12 +00:00
/// The ID of the author of this page
AuthorId: string
/// The title of the page
Title: string
2022-06-23 00:35:12 +00:00
/// The link at which this page is displayed
Permalink: string
2022-06-23 00:35:12 +00:00
/// When this page was published
PublishedOn: DateTime
2022-06-23 00:35:12 +00:00
/// When this page was last updated
UpdatedOn: DateTime
2022-06-23 00:35:12 +00:00
/// Whether this page shows as part of the web log's navigation
IsInPageList: bool
/// Is this the default page?
IsDefault: bool
/// The text of the page
Text: string
/// The metadata for the page
Metadata: MetaItem list
} with
2022-06-23 00:35:12 +00:00
/// Create a minimal display page (no text or metadata) from a database page
static member FromPageMinimal (webLog: WebLog) (page: Page) = {
Id = string page.Id
AuthorId = string page.AuthorId
2023-12-16 03:46:12 +00:00
Title = page.Title
Permalink = string page.Permalink
PublishedOn = webLog.LocalTime page.PublishedOn
UpdatedOn = webLog.LocalTime page.UpdatedOn
2023-12-16 03:46:12 +00:00
IsInPageList = page.IsInPageList
IsDefault = string page.Id = webLog.DefaultPage
2023-12-16 03:46:12 +00:00
Text = ""
Metadata = []
}
2022-06-23 00:35:12 +00:00
/// Create a display page from a database page
static member FromPage webLog page =
{ DisplayPage.FromPageMinimal webLog page with
Text = addBaseToRelativeUrls webLog.ExtraPath page.Text
Metadata = page.Metadata
2022-06-23 00:35:12 +00:00
}
2022-07-15 03:25:29 +00:00
/// Information about a revision used for display
[<NoComparison; NoEquality>]
2023-12-13 20:43:35 +00:00
type DisplayRevision = {
/// The as-of date/time for the revision
AsOf: DateTime
2023-12-13 20:43:35 +00:00
/// The as-of date/time for the revision in the web log's local time zone
AsOfLocal: DateTime
2023-12-13 20:43:35 +00:00
/// The format of the text of the revision
Format: string
2023-12-13 20:43:35 +00:00
}
2022-07-15 03:25:29 +00:00
2023-12-13 20:43:35 +00:00
/// Functions to support displaying revisions
module DisplayRevision =
2022-07-15 03:25:29 +00:00
/// Create a display revision from an actual revision
let fromRevision (webLog: WebLog) (rev : Revision) =
{ AsOf = rev.AsOf.ToDateTimeUtc ()
AsOfLocal = webLog.LocalTime rev.AsOf
2023-12-16 03:46:12 +00:00
Format = rev.Text.SourceType
2022-07-15 03:25:29 +00:00
}
2022-06-29 02:18:56 +00:00
open System.IO
2022-06-23 00:35:12 +00:00
2022-07-23 01:19:19 +00:00
/// Information about a theme used for display
[<NoComparison; NoEquality>]
2023-12-13 20:43:35 +00:00
type DisplayTheme = {
/// The ID / path slug of the theme
Id: string
2023-12-13 20:43:35 +00:00
/// The name of the theme
Name: string
2023-12-13 20:43:35 +00:00
/// The version of the theme
Version: string
2023-12-13 20:43:35 +00:00
/// How many templates are contained in the theme
TemplateCount: int
2023-12-13 20:43:35 +00:00
/// Whether the theme is in use by any web logs
IsInUse: bool
2023-12-13 20:43:35 +00:00
/// Whether the theme .zip file exists on the filesystem
IsOnDisk: bool
2023-12-13 20:43:35 +00:00
}
/// Functions to support displaying themes
module DisplayTheme =
2022-07-23 01:19:19 +00:00
/// Create a display theme from a theme
let fromTheme inUseFunc (theme: Theme) =
{ Id = string theme.Id
2022-07-23 01:19:19 +00:00
Name = theme.Name
Version = theme.Version
TemplateCount = List.length theme.Templates
IsInUse = inUseFunc theme.Id
IsOnDisk = File.Exists $"{theme.Id}-theme.zip"
2022-07-23 01:19:19 +00:00
}
2022-06-29 02:18:56 +00:00
/// Information about an uploaded file used for display
[<NoComparison; NoEquality>]
2023-12-13 20:43:35 +00:00
type DisplayUpload = {
/// The ID of the uploaded file
Id: string
2023-12-13 20:43:35 +00:00
/// The name of the uploaded file
Name: string
2023-12-13 20:43:35 +00:00
/// The path at which the file is served
Path: string
2023-12-13 20:43:35 +00:00
/// The date/time the file was updated
UpdatedOn: DateTime option
2023-12-13 20:43:35 +00:00
/// The source for this file (created from UploadDestination DU)
Source: string
2023-12-13 20:43:35 +00:00
}
/// Functions to support displaying uploads
module DisplayUpload =
2022-06-29 02:18:56 +00:00
/// Create a display uploaded file
let fromUpload (webLog: WebLog) (source: UploadDestination) (upload: Upload) =
let path = string upload.Path
2022-06-29 02:18:56 +00:00
let name = Path.GetFileName path
{ Id = string upload.Id
2022-07-20 02:51:51 +00:00
Name = name
Path = path.Replace(name, "")
UpdatedOn = Some (webLog.LocalTime upload.UpdatedOn)
Source = string source
2022-07-20 02:51:51 +00:00
}
/// View model to display a user's information
[<NoComparison; NoEquality>]
2023-12-13 20:43:35 +00:00
type DisplayUser = {
/// The ID of the user
Id: string
2022-07-20 02:51:51 +00:00
2023-12-13 20:43:35 +00:00
/// The user name (e-mail address)
Email: string
2022-07-20 02:51:51 +00:00
2023-12-13 20:43:35 +00:00
/// The user's first name
FirstName: string
2022-07-20 02:51:51 +00:00
2023-12-13 20:43:35 +00:00
/// The user's last name
LastName: string
2022-07-20 02:51:51 +00:00
2023-12-13 20:43:35 +00:00
/// The user's preferred name
PreferredName: string
2022-07-20 02:51:51 +00:00
2023-12-13 20:43:35 +00:00
/// The URL of the user's personal site
Url: string
2022-07-20 02:51:51 +00:00
2023-12-13 20:43:35 +00:00
/// The user's access level
AccessLevel: string
2023-12-13 20:43:35 +00:00
/// When the user was created
CreatedOn: DateTime
2023-12-13 20:43:35 +00:00
/// When the user last logged on
LastSeenOn: Nullable<DateTime>
2023-12-13 20:43:35 +00:00
}
/// Functions to support displaying a user's information
module DisplayUser =
2022-07-20 02:51:51 +00:00
/// Construct a displayed user from a web log user
let fromUser (webLog: WebLog) (user: WebLogUser) = {
Id = string user.Id
Email = user.Email
FirstName = user.FirstName
LastName = user.LastName
PreferredName = user.PreferredName
Url = defaultArg user.Url ""
AccessLevel = string user.AccessLevel
CreatedOn = webLog.LocalTime user.CreatedOn
LastSeenOn = user.LastSeenOn |> Option.map webLog.LocalTime |> Option.toNullable
}
2022-06-23 00:35:12 +00:00
/// View model for editing categories
[<CLIMutable; NoComparison; NoEquality>]
type EditCategoryModel = {
/// The ID of the category being edited
CategoryId: string
/// The name of the category
Name: string
/// The category's URL slug
Slug: string
/// A description of the category (optional)
Description: string
/// The ID of the category for which this is a subcategory (optional)
ParentId: string
} with
2022-06-23 00:35:12 +00:00
/// Create an edit model from an existing category
static member fromCategory (cat: Category) =
{ CategoryId = string cat.Id
2022-07-20 02:51:51 +00:00
Name = cat.Name
Slug = cat.Slug
Description = defaultArg cat.Description ""
ParentId = cat.ParentId |> Option.map string |> Option.defaultValue ""
2022-06-23 00:35:12 +00:00
}
/// Is this a new category?
member this.IsNew = this.CategoryId = "new"
2022-06-23 00:35:12 +00:00
/// View model to edit a custom RSS feed
[<CLIMutable; NoComparison; NoEquality>]
type EditCustomFeedModel =
{ /// The ID of the feed being editing
Id : string
2022-06-23 00:35:12 +00:00
/// The type of source for this feed ("category" or "tag")
SourceType : string
2022-06-23 00:35:12 +00:00
/// The category ID or tag on which this feed is based
SourceValue : string
2022-06-23 00:35:12 +00:00
/// The relative path at which this feed is served
Path : string
2022-06-23 00:35:12 +00:00
/// Whether this feed defines a podcast
IsPodcast : bool
2022-06-23 00:35:12 +00:00
/// The title of the podcast
Title : string
2022-06-23 00:35:12 +00:00
/// A subtitle for the podcast
Subtitle : string
2022-06-23 00:35:12 +00:00
/// The number of items in the podcast feed
ItemsInFeed : int
2022-06-23 00:35:12 +00:00
/// A summary of the podcast (iTunes field)
Summary : string
2022-06-23 00:35:12 +00:00
/// The display name of the podcast author (iTunes field)
DisplayedAuthor : string
2022-06-23 00:35:12 +00:00
/// The e-mail address of the user who registered the podcast at iTunes
Email : string
2022-06-23 00:35:12 +00:00
/// The link to the image for the podcast
ImageUrl : string
2022-06-23 00:35:12 +00:00
/// The category from Apple Podcasts (iTunes) under which this podcast is categorized
AppleCategory : string
2022-06-23 00:35:12 +00:00
/// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values)
AppleSubcategory : string
2022-06-23 00:35:12 +00:00
/// The explictness rating (iTunes field)
Explicit : string
2022-06-23 00:35:12 +00:00
/// The default media type for files in this podcast
DefaultMediaType : string
2022-06-23 00:35:12 +00:00
/// The base URL for relative URL media files for this podcast (optional; defaults to web log base)
MediaBaseUrl : string
/// The URL for funding information for the podcast
FundingUrl : string
/// The text for the funding link
FundingText : string
/// A unique identifier to follow this podcast
PodcastGuid : string
/// The medium for the content of this podcast
Medium : string
2022-06-23 00:35:12 +00:00
}
/// An empty custom feed model
static member empty =
2022-07-20 02:51:51 +00:00
{ Id = ""
SourceType = "category"
SourceValue = ""
Path = ""
IsPodcast = false
Title = ""
Subtitle = ""
ItemsInFeed = 25
Summary = ""
DisplayedAuthor = ""
Email = ""
ImageUrl = ""
AppleCategory = ""
AppleSubcategory = ""
Explicit = "no"
DefaultMediaType = "audio/mpeg"
MediaBaseUrl = ""
FundingUrl = ""
FundingText = ""
PodcastGuid = ""
Medium = ""
2022-06-23 00:35:12 +00:00
}
/// Create a model from a custom feed
2023-12-16 03:46:12 +00:00
static member fromFeed (feed: CustomFeed) =
2022-06-23 00:35:12 +00:00
let rss =
{ EditCustomFeedModel.empty with
Id = string feed.Id
SourceType = match feed.Source with Category _ -> "category" | Tag _ -> "tag"
SourceValue = match feed.Source with Category (CategoryId catId) -> catId | Tag tag -> tag
Path = string feed.Path
2022-06-23 00:35:12 +00:00
}
match feed.Podcast with
2022-06-23 00:35:12 +00:00
| Some p ->
{ rss with
IsPodcast = true
Title = p.Title
Subtitle = defaultArg p.Subtitle ""
ItemsInFeed = p.ItemsInFeed
Summary = p.Summary
DisplayedAuthor = p.DisplayedAuthor
Email = p.Email
ImageUrl = string p.ImageUrl
AppleCategory = p.AppleCategory
AppleSubcategory = defaultArg p.AppleSubcategory ""
Explicit = string p.Explicit
DefaultMediaType = defaultArg p.DefaultMediaType ""
MediaBaseUrl = defaultArg p.MediaBaseUrl ""
FundingUrl = defaultArg p.FundingUrl ""
FundingText = defaultArg p.FundingText ""
2023-12-16 03:46:12 +00:00
PodcastGuid = p.PodcastGuid |> Option.map _.ToString().ToLowerInvariant() |> Option.defaultValue ""
Medium = p.Medium |> Option.map string |> Option.defaultValue ""
2022-06-23 00:35:12 +00:00
}
| None -> rss
/// Update a feed with values from this model
member this.UpdateFeed (feed : CustomFeed) =
2022-06-23 00:35:12 +00:00
{ feed with
Source = if this.SourceType = "tag" then Tag this.SourceValue else Category (CategoryId this.SourceValue)
Path = Permalink this.Path
Podcast =
if this.IsPodcast then
2022-06-23 00:35:12 +00:00
Some {
Title = this.Title
Subtitle = noneIfBlank this.Subtitle
ItemsInFeed = this.ItemsInFeed
Summary = this.Summary
DisplayedAuthor = this.DisplayedAuthor
Email = this.Email
ImageUrl = Permalink this.ImageUrl
AppleCategory = this.AppleCategory
AppleSubcategory = noneIfBlank this.AppleSubcategory
2023-12-15 04:49:38 +00:00
Explicit = ExplicitRating.Parse this.Explicit
DefaultMediaType = noneIfBlank this.DefaultMediaType
MediaBaseUrl = noneIfBlank this.MediaBaseUrl
PodcastGuid = noneIfBlank this.PodcastGuid |> Option.map Guid.Parse
FundingUrl = noneIfBlank this.FundingUrl
FundingText = noneIfBlank this.FundingText
2023-12-16 03:46:12 +00:00
Medium = noneIfBlank this.Medium |> Option.map PodcastMedium.Parse
2022-06-23 00:35:12 +00:00
}
else
None
}
/// View model for a user to edit their own information
[<CLIMutable; NoComparison; NoEquality>]
type EditMyInfoModel =
{ /// The user's first name
FirstName : string
/// The user's last name
LastName : string
/// The user's preferred name
PreferredName : string
/// A new password for the user
NewPassword : string
/// A new password for the user, confirmed
NewPasswordConfirm : string
}
/// Create an edit model from a user
static member fromUser (user : WebLogUser) =
2022-07-20 02:51:51 +00:00
{ FirstName = user.FirstName
LastName = user.LastName
PreferredName = user.PreferredName
NewPassword = ""
NewPasswordConfirm = ""
}
2022-06-23 00:35:12 +00:00
/// View model to edit a page
[<CLIMutable; NoComparison; NoEquality>]
2023-12-16 03:46:12 +00:00
type EditPageModel = {
/// The ID of the page being edited
PageId: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The title of the page
Title: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The permalink for the page
Permalink: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The template to use to display the page
Template: string
/// Whether this page is shown in the page list
IsShownInPageList: bool
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The source format for the text
Source: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The text of the page
Text: string
/// Names of metadata items
MetaNames: string array
/// Values of metadata items
MetaValues: string array
} with
2022-06-23 00:35:12 +00:00
/// Create an edit model from an existing page
2023-12-16 03:46:12 +00:00
static member fromPage (page: Page) =
2022-06-23 00:35:12 +00:00
let latest =
2023-12-16 03:46:12 +00:00
match page.Revisions |> List.sortByDescending _.AsOf |> List.tryHead with
2022-06-23 00:35:12 +00:00
| Some rev -> rev
2023-12-16 03:46:12 +00:00
| None -> Revision.Empty
let page = if page.Metadata |> List.isEmpty then { page with Metadata = [ MetaItem.Empty ] } else page
{ PageId = string page.Id
2022-07-20 02:51:51 +00:00
Title = page.Title
Permalink = string page.Permalink
2022-07-20 02:51:51 +00:00
Template = defaultArg page.Template ""
IsShownInPageList = page.IsInPageList
2023-12-16 03:46:12 +00:00
Source = latest.Text.SourceType
Text = latest.Text.Text
MetaNames = page.Metadata |> List.map _.Name |> Array.ofList
MetaValues = page.Metadata |> List.map _.Value |> Array.ofList
2022-06-23 00:35:12 +00:00
}
/// Whether this is a new page
member this.IsNew = this.PageId = "new"
/// Update a page with values from this model
2023-12-16 03:46:12 +00:00
member this.UpdatePage (page: Page) now =
let revision = { AsOf = now; Text = MarkupText.Parse $"{this.Source}: {this.Text}" }
// Detect a permalink change, and add the prior one to the prior list
match string page.Permalink with
| "" -> page
| link when link = this.Permalink -> page
| _ -> { page with PriorPermalinks = page.Permalink :: page.PriorPermalinks }
|> function
| page ->
{ page with
Title = this.Title
Permalink = Permalink this.Permalink
UpdatedOn = now
IsInPageList = this.IsShownInPageList
Template = match this.Template with "" -> None | tmpl -> Some tmpl
2023-12-16 03:46:12 +00:00
Text = revision.Text.AsHtml()
Metadata = Seq.zip this.MetaNames this.MetaValues
|> Seq.filter (fun it -> fst it > "")
|> Seq.map (fun it -> { Name = fst it; Value = snd it })
|> Seq.sortBy (fun it -> $"{it.Name.ToLower ()} {it.Value.ToLower ()}")
|> List.ofSeq
Revisions = match page.Revisions |> List.tryHead with
| Some r when r.Text = revision.Text -> page.Revisions
| _ -> revision :: page.Revisions
}
2022-06-23 00:35:12 +00:00
/// View model to edit a post
[<CLIMutable; NoComparison; NoEquality>]
2023-12-16 03:46:12 +00:00
type EditPostModel = {
/// The ID of the post being edited
PostId: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The title of the post
Title: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The permalink for the post
Permalink: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The source format for the text
Source: string
2022-06-23 00:35:12 +00:00
2023-12-16 03:46:12 +00:00
/// The text of the post
Text: string
/// The tags for the post
Tags: string
/// The template used to display the post
Template: string
/// The category IDs for the post
CategoryIds: string array
/// The post status
Status: string
/// Whether this post should be published
DoPublish: bool
/// Names of metadata items
MetaNames: string array
/// Values of metadata items
MetaValues: string array
/// Whether to override the published date/time
SetPublished: bool
/// The published date/time to override
PubOverride: Nullable<DateTime>
/// Whether all revisions should be purged and the override date set as the updated date as well
SetUpdated: bool
/// Whether this post has a podcast episode
IsEpisode: bool
/// The URL for the media for this episode (may be permalink)
Media: string
/// The size (in bytes) of the media for this episode
Length: int64
/// The duration of the media for this episode
Duration: string
/// The media type (optional, defaults to podcast-defined media type)
MediaType: string
/// The URL for the image for this episode (may be permalink; optional, defaults to podcast image)
ImageUrl: string
/// A subtitle for the episode (optional)
Subtitle: string
/// The explicit rating for this episode (optional, defaults to podcast setting)
Explicit: string
/// The URL for the chapter file for the episode (may be permalink; optional)
ChapterFile: string
/// The type of the chapter file (optional; defaults to application/json+chapters if chapterFile is provided)
ChapterType: string
/// The URL for the transcript (may be permalink; optional)
TranscriptUrl: string
/// The MIME type for the transcript (optional, recommended if transcriptUrl is provided)
TranscriptType: string
/// The language of the transcript (optional)
TranscriptLang: string
/// Whether the provided transcript should be presented as captions
TranscriptCaptions: bool
/// The season number (optional)
SeasonNumber: int
/// A description of this season (optional, ignored if season number is not provided)
SeasonDescription: string
/// The episode number (decimal; optional)
EpisodeNumber: string
/// A description of this episode (optional, ignored if episode number is not provided)
EpisodeDescription: string
} with
2022-06-23 00:35:12 +00:00
/// Create an edit model from an existing past
static member fromPost (webLog: WebLog) (post: Post) =
2022-06-23 00:35:12 +00:00
let latest =
2023-12-16 03:46:12 +00:00
match post.Revisions |> List.sortByDescending _.AsOf |> List.tryHead with
2022-06-23 00:35:12 +00:00
| Some rev -> rev
2023-12-16 03:46:12 +00:00
| None -> Revision.Empty
let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.Empty ] } else post
2023-12-15 04:49:38 +00:00
let episode = defaultArg post.Episode Episode.Empty
{ PostId = string post.Id
2022-07-20 02:51:51 +00:00
Title = post.Title
Permalink = string post.Permalink
2023-12-16 03:46:12 +00:00
Source = latest.Text.SourceType
Text = latest.Text.Text
2022-07-20 02:51:51 +00:00
Tags = String.Join (", ", post.Tags)
Template = defaultArg post.Template ""
CategoryIds = post.CategoryIds |> List.map string |> Array.ofList
Status = string post.Status
2022-07-20 02:51:51 +00:00
DoPublish = false
2023-12-16 03:46:12 +00:00
MetaNames = post.Metadata |> List.map _.Name |> Array.ofList
MetaValues = post.Metadata |> List.map _.Value |> Array.ofList
2022-07-20 02:51:51 +00:00
SetPublished = false
PubOverride = post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable
2022-07-20 02:51:51 +00:00
SetUpdated = false
IsEpisode = Option.isSome post.Episode
Media = episode.Media
Length = episode.Length
2023-12-15 04:49:38 +00:00
Duration = defaultArg (episode.FormatDuration()) ""
2022-07-20 02:51:51 +00:00
MediaType = defaultArg episode.MediaType ""
ImageUrl = defaultArg episode.ImageUrl ""
Subtitle = defaultArg episode.Subtitle ""
Explicit = defaultArg (episode.Explicit |> Option.map string) ""
2022-07-20 02:51:51 +00:00
ChapterFile = defaultArg episode.ChapterFile ""
ChapterType = defaultArg episode.ChapterType ""
TranscriptUrl = defaultArg episode.TranscriptUrl ""
TranscriptType = defaultArg episode.TranscriptType ""
TranscriptLang = defaultArg episode.TranscriptLang ""
TranscriptCaptions = defaultArg episode.TranscriptCaptions false
SeasonNumber = defaultArg episode.SeasonNumber 0
SeasonDescription = defaultArg episode.SeasonDescription ""
EpisodeNumber = defaultArg (episode.EpisodeNumber |> Option.map string) ""
EpisodeDescription = defaultArg episode.EpisodeDescription ""
}
/// Whether this is a new post
member this.IsNew = this.PostId = "new"
/// Update a post with values from the submitted form
2023-12-16 03:46:12 +00:00
member this.UpdatePost (post: Post) now =
let revision = { AsOf = now; Text = MarkupText.Parse $"{this.Source}: {this.Text}" }
// Detect a permalink change, and add the prior one to the prior list
match string post.Permalink with
| "" -> post
| link when link = this.Permalink -> post
| _ -> { post with PriorPermalinks = post.Permalink :: post.PriorPermalinks }
|> function
| post ->
{ post with
Title = this.Title
Permalink = Permalink this.Permalink
PublishedOn = if this.DoPublish then Some now else post.PublishedOn
UpdatedOn = now
2023-12-16 03:46:12 +00:00
Text = revision.Text.AsHtml()
Tags = this.Tags.Split ","
|> Seq.ofArray
|> Seq.map (fun it -> it.Trim().ToLower ())
|> Seq.filter (fun it -> it <> "")
|> Seq.sort
|> List.ofSeq
Template = match this.Template.Trim () with "" -> None | tmpl -> Some tmpl
CategoryIds = this.CategoryIds |> Array.map CategoryId |> List.ofArray
Status = if this.DoPublish then Published else post.Status
Metadata = Seq.zip this.MetaNames this.MetaValues
|> Seq.filter (fun it -> fst it > "")
|> Seq.map (fun it -> { Name = fst it; Value = snd it })
|> Seq.sortBy (fun it -> $"{it.Name.ToLower ()} {it.Value.ToLower ()}")
|> List.ofSeq
Revisions = match post.Revisions |> List.tryHead with
| Some r when r.Text = revision.Text -> post.Revisions
| _ -> revision :: post.Revisions
Episode =
if this.IsEpisode then
Some {
Media = this.Media
Length = this.Length
Duration = noneIfBlank this.Duration
|> Option.map (TimeSpan.Parse >> Duration.FromTimeSpan)
MediaType = noneIfBlank this.MediaType
ImageUrl = noneIfBlank this.ImageUrl
Subtitle = noneIfBlank this.Subtitle
2023-12-15 04:49:38 +00:00
Explicit = noneIfBlank this.Explicit |> Option.map ExplicitRating.Parse
2023-08-01 02:17:14 +00:00
Chapters = match post.Episode with Some e -> e.Chapters | None -> None
ChapterFile = noneIfBlank this.ChapterFile
ChapterType = noneIfBlank this.ChapterType
TranscriptUrl = noneIfBlank this.TranscriptUrl
TranscriptType = noneIfBlank this.TranscriptType
TranscriptLang = noneIfBlank this.TranscriptLang
TranscriptCaptions = if this.TranscriptCaptions then Some true else None
SeasonNumber = if this.SeasonNumber = 0 then None else Some this.SeasonNumber
SeasonDescription = noneIfBlank this.SeasonDescription
EpisodeNumber = match noneIfBlank this.EpisodeNumber |> Option.map Double.Parse with
| Some it when it = 0.0 -> None
| Some it -> Some (double it)
| None -> None
EpisodeDescription = noneIfBlank this.EpisodeDescription
}
else
None
}
2022-06-23 00:35:12 +00:00
/// View model to add/edit a redirect rule
[<CLIMutable; NoComparison; NoEquality>]
type EditRedirectRuleModel =
{ /// The ID (index) of the rule being edited
RuleId : int
/// The "from" side of the rule
From : string
/// The "to" side of the rule
To : string
/// Whether this rule uses a regular expression
IsRegex : bool
/// Whether a new rule should be inserted at the top or appended to the end (ignored for edits)
InsertAtTop : bool
}
/// Create a model from an existing rule
static member fromRule idx (rule : RedirectRule) =
{ RuleId = idx
From = rule.From
To = rule.To
IsRegex = rule.IsRegex
InsertAtTop = false
}
/// Update a rule with the values from this model
member this.UpdateRule (rule : RedirectRule) =
{ rule with
From = this.From
To = this.To
IsRegex = this.IsRegex
}
2022-06-23 00:35:12 +00:00
/// View model to edit RSS settings
[<CLIMutable; NoComparison; NoEquality>]
type EditRssModel =
{ /// Whether the site feed of posts is enabled
IsFeedEnabled : bool
2022-06-23 00:35:12 +00:00
/// The name of the file generated for the site feed
FeedName : string
2022-06-23 00:35:12 +00:00
/// Override the "posts per page" setting for the site feed
ItemsInFeed : int
2022-06-23 00:35:12 +00:00
/// Whether feeds are enabled for all categories
IsCategoryEnabled : bool
2022-06-23 00:35:12 +00:00
/// Whether feeds are enabled for all tags
IsTagEnabled : bool
2022-06-23 00:35:12 +00:00
/// A copyright string to be placed in all feeds
Copyright : string
2022-06-23 00:35:12 +00:00
}
/// Create an edit model from a set of RSS options
static member fromRssOptions (rss : RssOptions) =
2022-07-20 02:51:51 +00:00
{ IsFeedEnabled = rss.IsFeedEnabled
FeedName = rss.FeedName
ItemsInFeed = defaultArg rss.ItemsInFeed 0
IsCategoryEnabled = rss.IsCategoryEnabled
IsTagEnabled = rss.IsTagEnabled
Copyright = defaultArg rss.Copyright ""
2022-06-23 00:35:12 +00:00
}
/// Update RSS options from values in this model
member this.UpdateOptions (rss : RssOptions) =
2022-06-23 00:35:12 +00:00
{ rss with
IsFeedEnabled = this.IsFeedEnabled
FeedName = this.FeedName
ItemsInFeed = if this.ItemsInFeed = 0 then None else Some this.ItemsInFeed
IsCategoryEnabled = this.IsCategoryEnabled
IsTagEnabled = this.IsTagEnabled
Copyright = noneIfBlank this.Copyright
2022-06-23 00:35:12 +00:00
}
/// View model to edit a tag mapping
[<CLIMutable; NoComparison; NoEquality>]
type EditTagMapModel =
{ /// The ID of the tag mapping being edited
Id : string
2022-06-23 00:35:12 +00:00
/// The tag being mapped to a different link value
Tag : string
2022-06-23 00:35:12 +00:00
/// The link value for the tag
UrlValue : string
2022-06-23 00:35:12 +00:00
}
/// Whether this is a new tag mapping
member this.IsNew = this.Id = "new"
2022-06-23 00:35:12 +00:00
/// Create an edit model from the tag mapping
static member fromMapping (tagMap : TagMap) : EditTagMapModel =
{ Id = string tagMap.Id
2022-07-20 02:51:51 +00:00
Tag = tagMap.Tag
UrlValue = tagMap.UrlValue
2022-06-23 00:35:12 +00:00
}
/// View model to display a user's information
[<CLIMutable; NoComparison; NoEquality>]
type EditUserModel = {
/// The ID of the user
Id: string
/// The user's access level
AccessLevel: string
/// The user name (e-mail address)
Email: string
/// The URL of the user's personal site
Url: string
/// The user's first name
FirstName: string
/// The user's last name
LastName: string
/// The user's preferred name
PreferredName: string
/// The user's password
Password: string
/// Confirmation of the user's password
PasswordConfirm: string
} with
/// Construct a displayed user from a web log user
static member fromUser (user: WebLogUser) =
{ Id = string user.Id
AccessLevel = string user.AccessLevel
Url = defaultArg user.Url ""
Email = user.Email
FirstName = user.FirstName
LastName = user.LastName
PreferredName = user.PreferredName
Password = ""
PasswordConfirm = ""
}
/// Is this a new user?
member this.IsNew = this.Id = "new"
/// Update a user with values from this model (excludes password)
2023-12-15 04:49:38 +00:00
member this.UpdateUser (user: WebLogUser) =
{ user with
2023-12-15 04:49:38 +00:00
AccessLevel = AccessLevel.Parse this.AccessLevel
Email = this.Email
Url = noneIfBlank this.Url
FirstName = this.FirstName
LastName = this.LastName
PreferredName = this.PreferredName
}
2022-06-23 00:35:12 +00:00
/// The model to use to allow a user to log on
[<CLIMutable; NoComparison; NoEquality>]
type LogOnModel =
{ /// The user's e-mail address
EmailAddress : string
2022-06-23 00:35:12 +00:00
/// The user's password
Password : string
2022-06-23 00:35:12 +00:00
/// Where the user should be redirected once they have logged on
ReturnTo : string option
2022-06-23 00:35:12 +00:00
}
/// An empty log on model
static member empty =
{ EmailAddress = ""; Password = ""; ReturnTo = None }
2022-06-23 00:35:12 +00:00
/// View model to manage permalinks
[<CLIMutable; NoComparison; NoEquality>]
2023-12-16 03:46:12 +00:00
type ManagePermalinksModel = {
/// The ID for the entity being edited
Id: string
/// The type of entity being edited ("page" or "post")
Entity: string
/// The current title of the page or post
CurrentTitle: string
/// The current permalink of the page or post
CurrentPermalink: string
/// The prior permalinks for the page or post
Prior: string array
} with
2022-06-23 00:35:12 +00:00
/// Create a permalink model from a page
2023-12-16 03:46:12 +00:00
static member fromPage (pg: Page) =
{ Id = string pg.Id
2022-07-20 02:51:51 +00:00
Entity = "page"
CurrentTitle = pg.Title
CurrentPermalink = string pg.Permalink
Prior = pg.PriorPermalinks |> List.map string |> Array.ofList
2022-06-23 00:35:12 +00:00
}
/// Create a permalink model from a post
2023-12-16 03:46:12 +00:00
static member fromPost (post: Post) =
{ Id = string post.Id
2022-07-20 02:51:51 +00:00
Entity = "post"
CurrentTitle = post.Title
CurrentPermalink = string post.Permalink
Prior = post.PriorPermalinks |> List.map string |> Array.ofList
2022-06-23 00:35:12 +00:00
}
2022-07-15 03:25:29 +00:00
/// View model to manage revisions
[<NoComparison; NoEquality>]
2022-07-15 03:25:29 +00:00
type ManageRevisionsModel =
{ /// The ID for the entity being edited
Id : string
2022-07-15 03:25:29 +00:00
/// The type of entity being edited ("page" or "post")
Entity : string
2022-07-15 03:25:29 +00:00
/// The current title of the page or post
CurrentTitle : string
2022-07-15 03:25:29 +00:00
/// The revisions for the page or post
2023-12-16 03:46:12 +00:00
Revisions : DisplayRevision array
2022-07-15 03:25:29 +00:00
}
/// Create a revision model from a page
2023-12-16 03:46:12 +00:00
static member fromPage webLog (pg: Page) =
{ Id = string pg.Id
2022-07-20 02:51:51 +00:00
Entity = "page"
CurrentTitle = pg.Title
Revisions = pg.Revisions |> List.map (DisplayRevision.fromRevision webLog) |> Array.ofList
2022-07-15 03:25:29 +00:00
}
/// Create a revision model from a post
2023-12-16 03:46:12 +00:00
static member fromPost webLog (post: Post) =
{ Id = string post.Id
2022-07-20 02:51:51 +00:00
Entity = "post"
CurrentTitle = post.Title
Revisions = post.Revisions |> List.map (DisplayRevision.fromRevision webLog) |> Array.ofList
2022-07-15 03:25:29 +00:00
}
2022-06-23 00:35:12 +00:00
/// View model for posts in a list
[<NoComparison; NoEquality>]
2023-12-16 03:46:12 +00:00
type PostListItem = {
/// The ID of the post
Id: string
/// The ID of the user who authored the post
AuthorId: string
/// The status of the post
Status: string
/// The title of the post
Title: string
/// The permalink for the post
Permalink: string
/// When this post was published
PublishedOn: Nullable<DateTime>
/// When this post was last updated
UpdatedOn: DateTime
/// The text of the post
Text: string
/// The IDs of the categories for this post
CategoryIds: string list
/// Tags for the post
Tags: string list
/// The podcast episode information for this post
Episode: Episode option
/// Metadata for the post
Metadata: MetaItem list
} with
2022-06-23 00:35:12 +00:00
/// Create a post list item from a post
static member fromPost (webLog: WebLog) (post: Post) = {
Id = string post.Id
AuthorId = string post.AuthorId
Status = string post.Status
Title = post.Title
Permalink = string post.Permalink
PublishedOn = post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable
UpdatedOn = webLog.LocalTime post.UpdatedOn
Text = addBaseToRelativeUrls webLog.ExtraPath post.Text
CategoryIds = post.CategoryIds |> List.map string
Tags = post.Tags
Episode = post.Episode
Metadata = post.Metadata
}
2022-06-23 00:35:12 +00:00
/// View model for displaying posts
type PostDisplay =
{ /// The posts to be displayed
Posts : PostListItem[]
2022-06-23 00:35:12 +00:00
/// Author ID -> name lookup
Authors : MetaItem list
2022-06-23 00:35:12 +00:00
/// A subtitle for the page
Subtitle : string option
2022-06-23 00:35:12 +00:00
/// The link to view newer (more recent) posts
NewerLink : string option
2022-06-23 00:35:12 +00:00
/// The name of the next newer post (single-post only)
NewerName : string option
2022-06-23 00:35:12 +00:00
/// The link to view older (less recent) posts
OlderLink : string option
2022-06-23 00:35:12 +00:00
/// The name of the next older post (single-post only)
OlderName : string option
2022-06-23 00:35:12 +00:00
}
/// View model for editing web log settings
[<CLIMutable; NoComparison; NoEquality>]
type SettingsModel = {
/// The name of the web log
Name: string
2022-06-23 00:35:12 +00:00
/// The slug of the web log
Slug: string
/// The subtitle of the web log
Subtitle: string
2022-06-23 00:35:12 +00:00
/// The default page
DefaultPage: string
2022-06-23 00:35:12 +00:00
/// How many posts should appear on index pages
PostsPerPage: int
2022-06-23 00:35:12 +00:00
/// The time zone in which dates/times should be displayed
TimeZone: string
/// The theme to use to display the web log
ThemeId: string
/// Whether to automatically load htmx
AutoHtmx: bool
/// The default location for uploads
Uploads: string
} with
2022-06-23 00:35:12 +00:00
/// Create a settings model from a web log
static member fromWebLog (webLog: WebLog) =
2022-07-20 02:51:51 +00:00
{ Name = webLog.Name
Slug = webLog.Slug
Subtitle = defaultArg webLog.Subtitle ""
DefaultPage = webLog.DefaultPage
PostsPerPage = webLog.PostsPerPage
TimeZone = webLog.TimeZone
ThemeId = string webLog.ThemeId
2022-07-20 02:51:51 +00:00
AutoHtmx = webLog.AutoHtmx
Uploads = string webLog.Uploads
2022-06-23 00:35:12 +00:00
}
/// Update a web log with settings from the form
member this.update (webLog : WebLog) =
{ webLog with
Name = this.Name
Slug = this.Slug
Subtitle = if this.Subtitle = "" then None else Some this.Subtitle
DefaultPage = this.DefaultPage
PostsPerPage = this.PostsPerPage
TimeZone = this.TimeZone
ThemeId = ThemeId this.ThemeId
AutoHtmx = this.AutoHtmx
Uploads = UploadDestination.Parse this.Uploads
2022-06-23 00:35:12 +00:00
}
2022-07-02 00:59:21 +00:00
/// View model for uploading a file
[<CLIMutable; NoComparison; NoEquality>]
type UploadFileModel =
{ /// The upload destination
Destination : string
2022-07-02 00:59:21 +00:00
}
2022-07-24 20:32:37 +00:00
/// View model for uploading a theme
[<CLIMutable; NoComparison; NoEquality>]
type UploadThemeModel =
{ /// Whether the uploaded theme should overwrite an existing theme
DoOverwrite : bool
}
2022-07-02 00:59:21 +00:00
/// A message displayed to the user
2022-06-23 00:35:12 +00:00
[<CLIMutable; NoComparison; NoEquality>]
type UserMessage =
{ /// The level of the message
Level : string
2022-06-23 00:35:12 +00:00
/// The message
Message : string
2022-06-23 00:35:12 +00:00
/// Further details about the message
Detail : string option
2022-06-23 00:35:12 +00:00
}
/// Functions to support user messages
module UserMessage =
/// An empty user message (use one of the others for pre-filled level)
let empty = { Level = ""; Message = ""; Detail = None }
2022-06-23 00:35:12 +00:00
/// A blank success message
let success = { empty with Level = "success" }
2022-06-23 00:35:12 +00:00
/// A blank informational message
let info = { empty with Level = "primary" }
2022-06-23 00:35:12 +00:00
/// A blank warning message
let warning = { empty with Level = "warning" }
2022-06-23 00:35:12 +00:00
/// A blank error message
let error = { empty with Level = "danger" }