1466 lines
60 KiB
FSharp
1466 lines
60 KiB
FSharp
namespace MyWebLog.ViewModels
|
|
|
|
open System
|
|
open MyWebLog
|
|
open NodaTime
|
|
open NodaTime.Text
|
|
|
|
/// <summary>Helper functions for view models</summary>
|
|
[<AutoOpen>]
|
|
module private Helpers =
|
|
|
|
/// <summary>Create a string option if a string is blank</summary>
|
|
/// <param name="it">The string in question</param>
|
|
/// <returns><c>Some</c> with the string if non-blank, <c>None</c> otherwise</returns>
|
|
let noneIfBlank it =
|
|
match (defaultArg (Option.ofObj it) "").Trim() with "" -> None | trimmed -> Some trimmed
|
|
|
|
|
|
/// <summary>Helper functions that are needed outside this file</summary>
|
|
[<AutoOpen>]
|
|
module PublicHelpers =
|
|
|
|
/// <summary>
|
|
/// If the web log is not being served from the domain root, add the path information to relative URLs in page and
|
|
/// post text
|
|
/// </summary>
|
|
/// <param name="extra">The extra path required for a complete relative URL</param>
|
|
/// <param name="text">The text in which URL conversion should occur</param>
|
|
/// <returns>The text with all relative URLs expanded as required</returns>
|
|
let addBaseToRelativeUrls extra (text: string) =
|
|
if extra = "" then text
|
|
else
|
|
text.Replace("href=\"/", $"href=\"{extra}/").Replace("href=/", $"href={extra}/")
|
|
.Replace("src=\"/", $"src=\"{extra}/").Replace("src=/", $"src={extra}/")
|
|
|
|
|
|
/// <summary>The model used to display the admin dashboard</summary>
|
|
[<NoComparison; NoEquality>]
|
|
type DashboardModel = {
|
|
/// <summary>The number of published posts</summary>
|
|
Posts: int
|
|
|
|
/// <summary>The number of post drafts</summary>
|
|
Drafts: int
|
|
|
|
/// <summary>The number of pages</summary>
|
|
Pages: int
|
|
|
|
/// <summary>The number of pages in the page list</summary>
|
|
ListedPages: int
|
|
|
|
/// <summary>The number of categories</summary>
|
|
Categories: int
|
|
|
|
/// <summary>The top-level categories</summary>
|
|
TopLevelCategories: int
|
|
}
|
|
|
|
|
|
/// <summary>Details about a category, used to display category lists</summary>
|
|
[<NoComparison; NoEquality>]
|
|
type DisplayCategory = {
|
|
/// <summary>The ID of the category</summary>
|
|
Id: string
|
|
|
|
/// <summary>The slug for the category</summary>
|
|
Slug: string
|
|
|
|
/// <summary>The name of the category</summary>
|
|
Name: string
|
|
|
|
/// <summary>A description of the category</summary>
|
|
Description: string option
|
|
|
|
/// <summary>The parent category names for this (sub)category</summary>
|
|
ParentNames: string array
|
|
|
|
/// <summary>The number of posts in this category</summary>
|
|
PostCount: int
|
|
}
|
|
|
|
|
|
/// <summary>Details about a page used to display page lists</summary>
|
|
[<NoComparison; NoEquality>]
|
|
type DisplayPage = {
|
|
/// <summary>The ID of this page</summary>
|
|
Id: string
|
|
|
|
/// <summary>The ID of the author of this page</summary>
|
|
AuthorId: string
|
|
|
|
/// <summary>The title of the page</summary>
|
|
Title: string
|
|
|
|
/// <summary>The link at which this page is displayed</summary>
|
|
Permalink: string
|
|
|
|
/// <summary>When this page was published</summary>
|
|
PublishedOn: DateTime
|
|
|
|
/// <summary>When this page was last updated</summary>
|
|
UpdatedOn: DateTime
|
|
|
|
/// <summary>Whether this page shows as part of the web log's navigation</summary>
|
|
IsInPageList: bool
|
|
|
|
/// <summary>Is this the default page?</summary>
|
|
IsDefault: bool
|
|
|
|
/// <summary>The text of the page</summary>
|
|
Text: string
|
|
|
|
/// <summary>The metadata for the page</summary>
|
|
Metadata: MetaItem list
|
|
|
|
/// <summary>The OpenGraph properties for the page</summary>
|
|
OpenGraph: OpenGraphProperties option
|
|
} with
|
|
|
|
/// <summary>Create a minimal display page (no text or metadata) from a database page</summary>
|
|
/// <param name="webLog">The web log to which the page belongs</param>
|
|
/// <param name="page">The page to be created</param>
|
|
/// <returns>A <c>DisplayPage</c> with no text or metadata</returns>
|
|
static member FromPageMinimal (webLog: WebLog) (page: Page) =
|
|
{ Id = string page.Id
|
|
AuthorId = string page.AuthorId
|
|
Title = page.Title
|
|
Permalink = string page.Permalink
|
|
PublishedOn = webLog.LocalTime page.PublishedOn
|
|
UpdatedOn = webLog.LocalTime page.UpdatedOn
|
|
IsInPageList = page.IsInPageList
|
|
IsDefault = string page.Id = webLog.DefaultPage
|
|
Text = ""
|
|
Metadata = []
|
|
OpenGraph = None }
|
|
|
|
/// <summary>Create a display page from a database page</summary>
|
|
/// <param name="webLog">The web log to which the page belongs</param>
|
|
/// <param name="page">The page to be created</param>
|
|
/// <returns>A <c>DisplayPage</c> with text and metadata</returns>
|
|
static member FromPage webLog page =
|
|
{ DisplayPage.FromPageMinimal webLog page with
|
|
Text = addBaseToRelativeUrls webLog.ExtraPath page.Text
|
|
Metadata = page.Metadata
|
|
OpenGraph = page.OpenGraph
|
|
}
|
|
|
|
|
|
open System.IO
|
|
|
|
/// <summary>Information about a theme used for display</summary>
|
|
[<NoComparison; NoEquality>]
|
|
type DisplayTheme = {
|
|
/// <summary>The ID / path slug of the theme</summary>
|
|
Id: string
|
|
|
|
/// <summary>The name of the theme</summary>
|
|
Name: string
|
|
|
|
/// <summary>The version of the theme</summary>
|
|
Version: string
|
|
|
|
/// <summary>How many templates are contained in the theme</summary>
|
|
TemplateCount: int
|
|
|
|
/// <summary>Whether the theme is in use by any web logs</summary>
|
|
IsInUse: bool
|
|
|
|
/// <summary>Whether the theme .zip file exists on the filesystem</summary>
|
|
IsOnDisk: bool
|
|
} with
|
|
|
|
/// <summary>Create a display theme from a theme</summary>
|
|
/// <param name="inUseFunc">The function to use to determine if this theme is actively in use</param>
|
|
/// <param name="theme">The theme from which the display version should be created</param>
|
|
/// <returns>A populated <c>DisplayTheme</c> instance</returns>
|
|
static member FromTheme inUseFunc (theme: Theme) =
|
|
let fileName =
|
|
if string theme.Id = "default" then "default-theme.zip"
|
|
else Path.Combine(".", "themes", $"{theme.Id}-theme.zip")
|
|
{ Id = string theme.Id
|
|
Name = theme.Name
|
|
Version = theme.Version
|
|
TemplateCount = List.length theme.Templates
|
|
IsInUse = inUseFunc theme.Id
|
|
IsOnDisk = File.Exists fileName }
|
|
|
|
|
|
/// <summary>Information about an uploaded file used for display</summary>
|
|
[<NoComparison; NoEquality>]
|
|
type DisplayUpload = {
|
|
/// <summary>The ID of the uploaded file</summary>
|
|
Id: string
|
|
|
|
/// <summary>The name of the uploaded file</summary>
|
|
Name: string
|
|
|
|
/// <summary>The path at which the file is served</summary>
|
|
Path: string
|
|
|
|
/// <summary>The date/time the file was updated</summary>
|
|
UpdatedOn: DateTime option
|
|
|
|
/// <summary>The source for this file (created from UploadDestination DU)</summary>
|
|
Source: string
|
|
} with
|
|
|
|
/// <summary>Create a display uploaded file</summary>
|
|
/// <param name="webLog">The web log to which the upload belongs</param>
|
|
/// <param name="source">The destination for the uploaded file</param>
|
|
/// <param name="upload">The uploaded file</param>
|
|
/// <returns>A populated <c>DisplayUpload</c> instance</returns>
|
|
static member FromUpload (webLog: WebLog) (source: UploadDestination) (upload: Upload) =
|
|
let path = string upload.Path
|
|
let name = Path.GetFileName path
|
|
{ Id = string upload.Id
|
|
Name = name
|
|
Path = path.Replace(name, "")
|
|
UpdatedOn = Some (webLog.LocalTime upload.UpdatedOn)
|
|
Source = string source }
|
|
|
|
|
|
/// <summary>View model for editing categories</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditCategoryModel = {
|
|
/// <summary>The ID of the category being edited</summary>
|
|
CategoryId: string
|
|
|
|
/// <summary>The name of the category</summary>
|
|
Name: string
|
|
|
|
/// <summary>The category's URL slug</summary>
|
|
Slug: string
|
|
|
|
/// <summary>A description of the category (optional)</summary>
|
|
Description: string
|
|
|
|
/// <summary>The ID of the category for which this is a subcategory (optional)</summary>
|
|
ParentId: string
|
|
} with
|
|
|
|
/// <summary>Create an edit model from an existing category</summary>
|
|
/// <param name="cat">The category</param>
|
|
/// <returns>A populated <c>DisplayCategory</c> instance</returns>
|
|
static member FromCategory (cat: Category) =
|
|
{ CategoryId = string cat.Id
|
|
Name = cat.Name
|
|
Slug = cat.Slug
|
|
Description = defaultArg cat.Description ""
|
|
ParentId = cat.ParentId |> Option.map string |> Option.defaultValue "" }
|
|
|
|
/// <summary>Is this a new category?</summary>
|
|
member this.IsNew =
|
|
this.CategoryId = "new"
|
|
|
|
|
|
/// <summary>View model to add/edit an episode chapter</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditChapterModel = {
|
|
/// <summary>The ID of the post to which the chapter belongs</summary>
|
|
PostId: string
|
|
|
|
/// <summary>The index in the chapter list (-1 means new)</summary>
|
|
Index: int
|
|
|
|
/// <summary>The start time of the chapter (H:mm:ss.FF format)</summary>
|
|
StartTime: string
|
|
|
|
/// <summary>The title of the chapter</summary>
|
|
Title: string
|
|
|
|
/// <summary>An image to display for this chapter</summary>
|
|
ImageUrl: string
|
|
|
|
/// <summary>A URL with information about this chapter</summary>
|
|
Url: string
|
|
|
|
/// <summary>Whether this chapter should be displayed in podcast players</summary>
|
|
IsHidden: bool
|
|
|
|
/// <summary>The end time of the chapter (HH:MM:SS.FF format)</summary>
|
|
EndTime: string
|
|
|
|
/// <summary>The name of a location</summary>
|
|
LocationName: string
|
|
|
|
/// <summary>The geographic coordinates of the location</summary>
|
|
LocationGeo: string
|
|
|
|
/// <summary>An OpenStreetMap query for this location</summary>
|
|
LocationOsm: string
|
|
|
|
/// <summary>Whether to add another chapter after adding this one</summary>
|
|
AddAnother: bool
|
|
} with
|
|
|
|
/// <summary>Create a display chapter from a chapter</summary>
|
|
/// <param name="postId">The ID of the post to which this chapter belongs</param>
|
|
/// <param name="idx">The index of the chapter being referenced</param>
|
|
/// <param name="chapter">The chapter information</param>
|
|
/// <returns>A populated <c>EditChapterModel</c> instance</returns>
|
|
static member FromChapter (postId: PostId) idx (chapter: Chapter) =
|
|
let pattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF"
|
|
{ PostId = string postId
|
|
Index = idx
|
|
StartTime = pattern.Format chapter.StartTime
|
|
Title = defaultArg chapter.Title ""
|
|
ImageUrl = defaultArg chapter.ImageUrl ""
|
|
Url = defaultArg chapter.Url ""
|
|
IsHidden = defaultArg chapter.IsHidden false
|
|
EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue ""
|
|
LocationName = chapter.Location |> Option.map _.Name |> Option.defaultValue ""
|
|
LocationGeo = chapter.Location |> Option.map _.Geo |> Option.defaultValue ""
|
|
LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue ""
|
|
AddAnother = false }
|
|
|
|
/// <summary>Create a chapter from the values in this model</summary>
|
|
/// <returns>A <c>Chapter</c> with values from this model</returns>
|
|
member this.ToChapter () =
|
|
let parseDuration name value =
|
|
let pattern =
|
|
match value |> Seq.fold (fun count chr -> if chr = ':' then count + 1 else count) 0 with
|
|
| 0 -> "S"
|
|
| 1 -> "M:ss"
|
|
| 2 -> "H:mm:ss"
|
|
| _ -> invalidArg name "Max time format is H:mm:ss"
|
|
|> function
|
|
| it -> DurationPattern.CreateWithInvariantCulture $"{it}.FFFFFFFFF"
|
|
let result = pattern.Parse value
|
|
if result.Success then result.Value else raise result.Exception
|
|
let location =
|
|
match noneIfBlank this.LocationName with
|
|
| None -> None
|
|
| Some name -> Some { Name = name; Geo = this.LocationGeo; Osm = noneIfBlank this.LocationOsm }
|
|
{ StartTime = parseDuration (nameof this.StartTime) this.StartTime
|
|
Title = noneIfBlank this.Title
|
|
ImageUrl = noneIfBlank this.ImageUrl
|
|
Url = noneIfBlank this.Url
|
|
IsHidden = if this.IsHidden then Some true else None
|
|
EndTime = noneIfBlank this.EndTime |> Option.map (parseDuration (nameof this.EndTime))
|
|
Location = location }
|
|
|
|
|
|
/// <summary>View model common to page and post edits</summary>
|
|
type EditCommonModel() =
|
|
|
|
/// <summary>Find the latest revision within a list of revisions</summary>
|
|
let findLatestRevision (revs: Revision list) =
|
|
match revs |> List.sortByDescending _.AsOf |> List.tryHead with Some rev -> rev | None -> Revision.Empty
|
|
|
|
/// <summary>The ID of the page or post</summary>
|
|
member val Id = "" with get, set
|
|
|
|
/// <summary>The title of the page or post</summary>
|
|
member val Title = "" with get, set
|
|
|
|
/// <summary>The permalink for the page or post</summary>
|
|
member val Permalink = "" with get, set
|
|
|
|
/// <summary>The entity to which this model applies ("page" or "post")</summary>
|
|
member val Entity = "" with get, set
|
|
|
|
/// <summary>Whether to provide a link to manage chapters</summary>
|
|
member val IncludeChapterLink = false with get, set
|
|
|
|
/// <summary>The template to use to display the page</summary>
|
|
member val Template = "" with get, set
|
|
|
|
/// <summary>The source type ("HTML" or "Markdown")</summary>
|
|
member val Source = "" with get, set
|
|
|
|
/// <summary>The text of the page or post</summary>
|
|
member val Text = "" with get, set
|
|
|
|
/// <summary>Whether to assign OpenGraph properties to this page or post</summary>
|
|
member val AssignOpenGraph = false with get, set
|
|
|
|
/// <summary>The type of object represented by this page or post</summary>
|
|
member val OpenGraphType = "" with get, set
|
|
|
|
/// <summary>The URL for the image associated with this page or post</summary>
|
|
member val OpenGraphImageUrl = "" with get, set
|
|
|
|
/// <summary>The MIME type of the image associated with this page or post</summary>
|
|
member val OpenGraphImageType = "" with get, set
|
|
|
|
/// <summary>The width of the image associated with this page or post</summary>
|
|
member val OpenGraphImageWidth = "" with get, set
|
|
|
|
/// <summary>The height of the image associated with this page or post</summary>
|
|
member val OpenGraphImageHeight = "" with get, set
|
|
|
|
/// <summary>The alternate text for the image associated with this page or post</summary>
|
|
member val OpenGraphImageAlt = "" with get, set
|
|
|
|
/// <summary>The URL of an audio file associated with this page or post</summary>
|
|
member val OpenGraphAudioUrl = "" with get, set
|
|
|
|
/// <summary>The MIME type of the audio file associated with this page or post</summary>
|
|
member val OpenGraphAudioType = "" with get, set
|
|
|
|
/// <summary>A short description of this page or post</summary>
|
|
member val OpenGraphDescription = "" with get, set
|
|
|
|
/// <summary>A word excluded from the title when alphabetizing</summary>
|
|
member val OpenGraphDeterminer = "" with get, set
|
|
|
|
/// <summary>The primary locale for this page or post</summary>
|
|
member val OpenGraphLocale = "" with get, set
|
|
|
|
/// <summary>Alternate locales in which this page or post is available</summary>
|
|
member val OpenGraphAlternateLocales = "" with get, set
|
|
|
|
/// <summary>The URL of a video file associated with this page or post</summary>
|
|
member val OpenGraphVideoUrl = "" with get, set
|
|
|
|
/// <summary>The MIME type of a video file associated with this page or post</summary>
|
|
member val OpenGraphVideoType = "" with get, set
|
|
|
|
/// <summary>The width of the video file associated with this page or post</summary>
|
|
member val OpenGraphVideoWidth = "" with get, set
|
|
|
|
/// <summary>The height of the video file associated with this page or post</summary>
|
|
member val OpenGraphVideoHeight = "" with get, set
|
|
|
|
/// <summary>The names of extra OpenGraph properties for this page or post</summary>
|
|
member val OpenGraphExtraNames: string array = [||] with get, set
|
|
|
|
/// <summary>The values of extra OpenGraph properties for this page or post</summary>
|
|
member val OpenGraphExtraValues: string array = [||] with get, set
|
|
|
|
/// <summary>Names of metadata items</summary>
|
|
member val MetaNames: string array = [||] with get, set
|
|
|
|
/// <summary>Values of metadata items</summary>
|
|
member val MetaValues: string array = [||] with get, set
|
|
|
|
/// <summary>Whether this is a new page or post</summary>
|
|
member this.IsNew with get () = this.Id = "new"
|
|
|
|
/// <summary>Populate the OpenGraph properties</summary>
|
|
/// <param name="og">The existing OpenGraph property set</param>
|
|
member private this.PopulateOpenGraph(og: OpenGraphProperties) =
|
|
this.AssignOpenGraph <- true
|
|
this.OpenGraphType <- string og.Type
|
|
this.OpenGraphImageUrl <- og.Image.Url
|
|
this.OpenGraphImageType <- defaultArg og.Image.Type ""
|
|
this.OpenGraphImageWidth <- defaultArg (og.Image.Width |> Option.map string) ""
|
|
this.OpenGraphImageHeight <- defaultArg (og.Image.Height |> Option.map string) ""
|
|
this.OpenGraphImageAlt <- defaultArg og.Image.Alt ""
|
|
this.OpenGraphDescription <- defaultArg og.Description ""
|
|
this.OpenGraphDeterminer <- defaultArg og.Determiner ""
|
|
this.OpenGraphLocale <- defaultArg og.Locale ""
|
|
this.OpenGraphAlternateLocales <- defaultArg (og.LocaleAlternate |> Option.map (String.concat ", ")) ""
|
|
match og.Audio with
|
|
| Some audio ->
|
|
this.OpenGraphAudioUrl <- audio.Url
|
|
this.OpenGraphAudioType <- defaultArg audio.Type ""
|
|
| None -> ()
|
|
match og.Video with
|
|
| Some video ->
|
|
this.OpenGraphVideoUrl <- video.Url
|
|
this.OpenGraphVideoType <- defaultArg video.Type ""
|
|
this.OpenGraphVideoWidth <- defaultArg (video.Width |> Option.map string) ""
|
|
this.OpenGraphVideoHeight <- defaultArg (video.Height |> Option.map string) ""
|
|
| None -> ()
|
|
match og.Other with
|
|
| Some other ->
|
|
this.OpenGraphExtraNames <- other |> List.map _.Name |> Array.ofList
|
|
this.OpenGraphExtraValues <- other |> List.map _.Value |> Array.ofList
|
|
| None -> ()
|
|
|
|
/// <summary>Fill the properties of this object from a page</summary>
|
|
/// <param name="page">The page from which this model should be populated</param>
|
|
member this.PopulateFromPage(page: Page) =
|
|
let latest = findLatestRevision page.Revisions
|
|
let metaItems = if page.Metadata.Length = 0 then [ MetaItem.Empty ] else page.Metadata
|
|
this.Id <- string page.Id
|
|
this.Title <- page.Title
|
|
this.Permalink <- string page.Permalink
|
|
this.Entity <- "page"
|
|
this.Template <- defaultArg page.Template ""
|
|
this.Source <- latest.Text.SourceType
|
|
this.Text <- latest.Text.Text
|
|
this.MetaNames <- metaItems |> List.map _.Name |> Array.ofList
|
|
this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList
|
|
page.OpenGraph |> Option.iter this.PopulateOpenGraph
|
|
|
|
/// <summary>Fill the properties of this object from a post</summary>
|
|
/// <param name="post">The post from which this model should be populated</param>
|
|
member this.PopulateFromPost(post: Post) =
|
|
let latest = findLatestRevision post.Revisions
|
|
let metaItems = if post.Metadata.Length = 0 then [ MetaItem.Empty ] else post.Metadata
|
|
this.Id <- string post.Id
|
|
this.Title <- post.Title
|
|
this.Permalink <- string post.Permalink
|
|
this.Entity <- "post"
|
|
this.IncludeChapterLink <- Option.isSome post.Episode && Option.isSome post.Episode.Value.Chapters
|
|
this.Template <- defaultArg post.Template ""
|
|
this.Source <- latest.Text.SourceType
|
|
this.Text <- latest.Text.Text
|
|
this.MetaNames <- metaItems |> List.map _.Name |> Array.ofList
|
|
this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList
|
|
post.OpenGraph |> Option.iter this.PopulateOpenGraph
|
|
|
|
/// <summary>Convert the properties of the model into a set of OpenGraph properties</summary>
|
|
member this.ToOpenGraph() =
|
|
if this.AssignOpenGraph then
|
|
let audio =
|
|
match this.OpenGraphAudioUrl.Trim() with
|
|
| "" -> None
|
|
| url -> Some { OpenGraphAudio.Url = url; Type = noneIfBlank this.OpenGraphAudioType }
|
|
let video =
|
|
match this.OpenGraphVideoUrl.Trim() with
|
|
| "" -> None
|
|
| url ->
|
|
Some {
|
|
OpenGraphVideo.Url = url
|
|
Type = noneIfBlank this.OpenGraphVideoType
|
|
Width = noneIfBlank this.OpenGraphVideoWidth |> Option.map int
|
|
Height = noneIfBlank this.OpenGraphVideoHeight |> Option.map int
|
|
}
|
|
Some {
|
|
Type = if this.OpenGraphType = "" then Article else OpenGraphType.Parse this.OpenGraphType
|
|
Image = {
|
|
Url = this.OpenGraphImageUrl
|
|
Type = noneIfBlank this.OpenGraphImageType
|
|
Width = noneIfBlank this.OpenGraphImageWidth |> Option.map int
|
|
Height = noneIfBlank this.OpenGraphImageHeight |> Option.map int
|
|
Alt = noneIfBlank this.OpenGraphImageAlt
|
|
}
|
|
Description = noneIfBlank this.OpenGraphDescription
|
|
Determiner = noneIfBlank this.OpenGraphDeterminer
|
|
Locale = noneIfBlank this.OpenGraphLocale
|
|
LocaleAlternate = noneIfBlank this.OpenGraphAlternateLocales
|
|
|> Option.map (fun alts -> alts.Split ',' |> Array.map _.Trim() |> List.ofArray)
|
|
Audio = audio
|
|
Video = video
|
|
Other = Seq.zip this.OpenGraphExtraNames this.OpenGraphExtraValues
|
|
|> Seq.filter (fun it -> fst it > "")
|
|
|> Seq.map (fun it -> { Name = fst it; Value = snd it })
|
|
|> List.ofSeq
|
|
|> function it -> match it with [] -> None | _ -> Some it
|
|
}
|
|
else
|
|
None
|
|
|
|
/// <summary>View model to edit a custom RSS feed</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditCustomFeedModel = {
|
|
/// <summary>The ID of the feed being editing</summary>
|
|
Id: string
|
|
|
|
/// <summary>The type of source for this feed ("category" or "tag")</summary>
|
|
SourceType: string
|
|
|
|
/// <summary>The category ID or tag on which this feed is based</summary>
|
|
SourceValue: string
|
|
|
|
/// <summary>The relative path at which this feed is served</summary>
|
|
Path: string
|
|
|
|
/// <summary>Whether this feed defines a podcast</summary>
|
|
IsPodcast: bool
|
|
|
|
/// <summary>The title of the podcast</summary>
|
|
Title: string
|
|
|
|
/// <summary>A subtitle for the podcast</summary>
|
|
Subtitle: string
|
|
|
|
/// <summary>The number of items in the podcast feed</summary>
|
|
ItemsInFeed: int
|
|
|
|
/// <summary>A summary of the podcast (iTunes field)</summary>
|
|
Summary: string
|
|
|
|
/// <summary>The display name of the podcast author (iTunes field)</summary>
|
|
DisplayedAuthor: string
|
|
|
|
/// <summary>The e-mail address of the user who registered the podcast at iTunes</summary>
|
|
Email: string
|
|
|
|
/// <summary>The link to the image for the podcast</summary>
|
|
ImageUrl: string
|
|
|
|
/// <summary>The category from Apple Podcasts (iTunes) under which this podcast is categorized</summary>
|
|
AppleCategory: string
|
|
|
|
/// <summary>A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values)</summary>
|
|
AppleSubcategory: string
|
|
|
|
/// <summary>The explictness rating (iTunes field)</summary>
|
|
Explicit: string
|
|
|
|
/// <summary>The default media type for files in this podcast</summary>
|
|
DefaultMediaType: string
|
|
|
|
/// <summary>The base URL for relative URL media files for this podcast (optional; defaults to web log base)</summary>
|
|
MediaBaseUrl: string
|
|
|
|
/// <summary>The URL for funding information for the podcast</summary>
|
|
FundingUrl: string
|
|
|
|
/// <summary>The text for the funding link</summary>
|
|
FundingText: string
|
|
|
|
/// <summary>A unique identifier to follow this podcast</summary>
|
|
PodcastGuid: string
|
|
|
|
/// <summary>The medium for the content of this podcast</summary>
|
|
Medium: string
|
|
} with
|
|
|
|
/// <summary>An empty custom feed model</summary>
|
|
static member Empty =
|
|
{ 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 = "" }
|
|
|
|
/// <summary>Create a model from a custom feed</summary>
|
|
/// <param name="feed">The feed from which the model should be created</param>
|
|
/// <returns>A populated <c>EditCustomFeedModel</c> instance</returns>
|
|
static member FromFeed (feed: CustomFeed) =
|
|
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 }
|
|
match feed.Podcast with
|
|
| 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 ""
|
|
PodcastGuid = p.PodcastGuid |> Option.map _.ToString().ToLowerInvariant() |> Option.defaultValue ""
|
|
Medium = p.Medium |> Option.map string |> Option.defaultValue "" }
|
|
| None -> rss
|
|
|
|
/// <summary>Update a feed with values from this model</summary>
|
|
/// <param name="feed">The feed to be updated</param>
|
|
/// <returns>The feed, updated with values from this model</returns>
|
|
member this.UpdateFeed (feed: CustomFeed) =
|
|
{ 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
|
|
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
|
|
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
|
|
Medium = noneIfBlank this.Medium |> Option.map PodcastMedium.Parse }
|
|
else
|
|
None }
|
|
|
|
|
|
/// <summary>View model for a user to edit their own information</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditMyInfoModel = {
|
|
/// <summary>The user's first name</summary>
|
|
FirstName: string
|
|
|
|
/// <summary>The user's last name</summary>
|
|
LastName: string
|
|
|
|
/// <summary>The user's preferred name</summary>
|
|
PreferredName: string
|
|
|
|
/// <summary>A new password for the user</summary>
|
|
NewPassword: string
|
|
|
|
/// <summary>A new password for the user, confirmed</summary>
|
|
NewPasswordConfirm: string
|
|
} with
|
|
|
|
/// <summary>Create an edit model from a user</summary>
|
|
/// <param name="user">The user being edited</param>
|
|
/// <returns>A populated <c>EditMyInfoModel</c> instance</returns>
|
|
static member FromUser (user: WebLogUser) =
|
|
{ FirstName = user.FirstName
|
|
LastName = user.LastName
|
|
PreferredName = user.PreferredName
|
|
NewPassword = ""
|
|
NewPasswordConfirm = "" }
|
|
|
|
|
|
/// <summary>View model to edit a page</summary>
|
|
type EditPageModel() =
|
|
inherit EditCommonModel()
|
|
|
|
/// <summary>Whether this page is shown in the page list</summary>
|
|
member val IsShownInPageList = false with get, set
|
|
|
|
/// <summary>Create an edit model from an existing page</summary>
|
|
/// <param name="page">The page from which the model should be generated</param>
|
|
/// <returns>A populated <c>EditPageModel</c> instance</returns>
|
|
static member FromPage(page: Page) =
|
|
let model = EditPageModel()
|
|
model.PopulateFromPage page
|
|
model.IsShownInPageList <- page.IsInPageList
|
|
model
|
|
|
|
/// <summary>Update a page with values from this model</summary>
|
|
/// <param name="page">The page to be updated</param>
|
|
/// <param name="now">The <c>Instant</c> to use for this particular update</param>
|
|
/// <returns>The page, updated with the values from this model</returns>
|
|
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
|
|
Text = revision.Text.AsHtml()
|
|
OpenGraph = this.ToOpenGraph()
|
|
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 }
|
|
|
|
|
|
/// <summary>View model to edit a post</summary>
|
|
type EditPostModel() =
|
|
inherit EditCommonModel()
|
|
|
|
/// <summary>The tags for the post</summary>
|
|
member val Tags = "" with get, set
|
|
|
|
/// <summary>The category IDs for the post</summary>
|
|
member val CategoryIds: string array = [||] with get, set
|
|
|
|
/// <summary>The post status</summary>
|
|
member val Status = "" with get, set
|
|
|
|
/// <summary>Whether this post should be published</summary>
|
|
member val DoPublish = false with get, set
|
|
|
|
/// <summary>Whether to override the published date/time</summary>
|
|
member val SetPublished = false with get, set
|
|
|
|
/// <summary>The published date/time to override</summary>
|
|
member val PubOverride = Nullable<DateTime>() with get, set
|
|
|
|
/// <summary>Whether all revisions should be purged and the override date set as the updated date as well</summary>
|
|
member val SetUpdated = false with get, set
|
|
|
|
/// <summary>Whether this post has a podcast episode</summary>
|
|
member val IsEpisode = false with get, set
|
|
|
|
/// <summary>The URL for the media for this episode (may be permalink)</summary>
|
|
member val Media = "" with get, set
|
|
|
|
/// <summary>The size (in bytes) of the media for this episode</summary>
|
|
member val Length = 0L with get, set
|
|
|
|
/// <summary>The duration of the media for this episode</summary>
|
|
member val Duration = "" with get, set
|
|
|
|
/// <summary>The media type (optional, defaults to podcast-defined media type)</summary>
|
|
member val MediaType = "" with get, set
|
|
|
|
/// <summary>
|
|
/// The URL for the image for this episode (may be permalink; optional, defaults to podcast image)
|
|
/// </summary>
|
|
member val ImageUrl = "" with get, set
|
|
|
|
/// <summary>A subtitle for the episode (optional)</summary>
|
|
member val Subtitle = "" with get, set
|
|
|
|
/// <summary>The explicit rating for this episode (optional, defaults to podcast setting)</summary>
|
|
member val Explicit = "" with get, set
|
|
|
|
/// <summary>
|
|
/// The chapter source ("internal" for chapters defined here, "external" for a file link, "none" if none defined)
|
|
/// </summary>
|
|
member val ChapterSource = "" with get, set
|
|
|
|
/// <summary>The URL for the chapter file for the episode (may be permalink; optional)</summary>
|
|
member val ChapterFile = "" with get, set
|
|
|
|
/// <summary>
|
|
/// The type of the chapter file (optional; defaults to application/json+chapters if chapterFile is provided)
|
|
/// </summary>
|
|
member val ChapterType = "" with get, set
|
|
|
|
/// <summary>Whether the chapter file (or chapters) contains/contain waypoints</summary>
|
|
member val ContainsWaypoints = false with get, set
|
|
|
|
/// <summary>The URL for the transcript (may be permalink; optional)</summary>
|
|
member val TranscriptUrl = "" with get, set
|
|
|
|
/// <summary>The MIME type for the transcript (optional, recommended if transcriptUrl is provided)</summary>
|
|
member val TranscriptType = "" with get, set
|
|
|
|
/// <summary>The language of the transcript (optional)</summary>
|
|
member val TranscriptLang = "" with get, set
|
|
|
|
/// <summary>Whether the provided transcript should be presented as captions</summary>
|
|
member val TranscriptCaptions = false with get, set
|
|
|
|
/// <summary>The season number (optional)</summary>
|
|
member val SeasonNumber = 0 with get, set
|
|
|
|
/// <summary>A description of this season (optional, ignored if season number is not provided)</summary>
|
|
member val SeasonDescription = "" with get, set
|
|
|
|
/// <summary>The episode number (decimal; optional)</summary>
|
|
member val EpisodeNumber = "" with get, set
|
|
|
|
/// <summary>A description of this episode (optional, ignored if episode number is not provided)</summary>
|
|
member val EpisodeDescription = "" with get, set
|
|
|
|
/// <summary>Create an edit model from an existing past</summary>
|
|
/// <param name="webLog">The web log to which this post belongs</param>
|
|
/// <param name="post">The post from which this model should be created</param>
|
|
/// <returns>A populated <c>EditPostModel</c> instance</returns>
|
|
static member FromPost (webLog: WebLog) (post: Post) =
|
|
let model = EditPostModel()
|
|
model.PopulateFromPost post
|
|
let episode = defaultArg post.Episode Episode.Empty
|
|
model.Tags <- post.Tags |> String.concat ", "
|
|
model.CategoryIds <- post.CategoryIds |> List.map string |> Array.ofList
|
|
model.Status <- string post.Status
|
|
model.PubOverride <- post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable
|
|
model.IsEpisode <- Option.isSome post.Episode
|
|
model.Media <- episode.Media
|
|
model.Length <- episode.Length
|
|
model.Duration <- defaultArg (episode.FormatDuration()) ""
|
|
model.MediaType <- defaultArg episode.MediaType ""
|
|
model.ImageUrl <- defaultArg episode.ImageUrl ""
|
|
model.Subtitle <- defaultArg episode.Subtitle ""
|
|
model.Explicit <- defaultArg (episode.Explicit |> Option.map string) ""
|
|
model.ChapterSource <- if Option.isSome episode.Chapters then "internal"
|
|
elif Option.isSome episode.ChapterFile then "external"
|
|
else "none"
|
|
model.ChapterFile <- defaultArg episode.ChapterFile ""
|
|
model.ChapterType <- defaultArg episode.ChapterType ""
|
|
model.ContainsWaypoints <- defaultArg episode.ChapterWaypoints false
|
|
model.TranscriptUrl <- defaultArg episode.TranscriptUrl ""
|
|
model.TranscriptType <- defaultArg episode.TranscriptType ""
|
|
model.TranscriptLang <- defaultArg episode.TranscriptLang ""
|
|
model.TranscriptCaptions <- defaultArg episode.TranscriptCaptions false
|
|
model.SeasonNumber <- defaultArg episode.SeasonNumber 0
|
|
model.SeasonDescription <- defaultArg episode.SeasonDescription ""
|
|
model.EpisodeNumber <- defaultArg (episode.EpisodeNumber |> Option.map string) ""
|
|
model.EpisodeDescription <- defaultArg episode.EpisodeDescription ""
|
|
model
|
|
|
|
/// <summary>Update a post with values from the submitted form</summary>
|
|
/// <param name="post">The post which should be updated</param>
|
|
/// <param name="now">The <c>Instant</c> to use for this particular update</param>
|
|
/// <returns>The post, updated with the values from this model</returns>
|
|
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
|
|
Text = revision.Text.AsHtml()
|
|
Tags = this.Tags.Split ","
|
|
|> Seq.ofArray
|
|
|> Seq.map _.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
|
|
OpenGraph = this.ToOpenGraph()
|
|
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
|
|
Explicit = noneIfBlank this.Explicit |> Option.map ExplicitRating.Parse
|
|
Chapters = if this.ChapterSource = "internal" then
|
|
match post.Episode with
|
|
| Some e when Option.isSome e.Chapters -> e.Chapters
|
|
| Some _
|
|
| None -> Some []
|
|
else None
|
|
ChapterFile = if this.ChapterSource = "external" then noneIfBlank this.ChapterFile
|
|
else None
|
|
ChapterType = if this.ChapterSource = "external" then noneIfBlank this.ChapterType
|
|
else None
|
|
ChapterWaypoints = if this.ChapterSource = "none" then None
|
|
elif this.ContainsWaypoints then Some true
|
|
else None
|
|
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 }
|
|
|
|
|
|
/// <summary>View model to add/edit a redirect rule</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditRedirectRuleModel = {
|
|
/// <summary>The ID (index) of the rule being edited</summary>
|
|
RuleId: int
|
|
|
|
/// <summary>The "from" side of the rule</summary>
|
|
From: string
|
|
|
|
/// <summary>The "to" side of the rule</summary>
|
|
To: string
|
|
|
|
/// <summary>Whether this rule uses a regular expression</summary>
|
|
IsRegex: bool
|
|
|
|
/// <summary>Whether a new rule should be inserted at the top or appended to the end (ignored for edits)</summary>
|
|
InsertAtTop: bool
|
|
} with
|
|
|
|
/// <summary>Create a model from an existing rule</summary>
|
|
/// <param name="idx">The index (within the list for the web log) for this redirect rule</param>
|
|
/// <param name="rule">The redirect rule being modified</param>
|
|
/// <returns>A populated <c>EditRedirectRuleModel</c> instance</returns>
|
|
static member FromRule idx (rule: RedirectRule) =
|
|
{ RuleId = idx
|
|
From = rule.From
|
|
To = rule.To
|
|
IsRegex = rule.IsRegex
|
|
InsertAtTop = false }
|
|
|
|
/// <summary>Convert the values in this model to a redirect rule</summary>
|
|
/// <returns>A redirect rule from this model</returns>
|
|
member this.ToRule() =
|
|
{ From = this.From
|
|
To = this.To
|
|
IsRegex = this.IsRegex }
|
|
|
|
|
|
/// <summary>View model to edit RSS settings</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditRssModel = {
|
|
/// <summary>Whether the site feed of posts is enabled</summary>
|
|
IsFeedEnabled: bool
|
|
|
|
/// <summary>The name of the file generated for the site feed</summary>
|
|
FeedName: string
|
|
|
|
/// <summary>Override the "posts per page" setting for the site feed</summary>
|
|
ItemsInFeed: int
|
|
|
|
/// <summary>Whether feeds are enabled for all categories</summary>
|
|
IsCategoryEnabled: bool
|
|
|
|
/// <summary>Whether feeds are enabled for all tags</summary>
|
|
IsTagEnabled: bool
|
|
|
|
/// <summary>A copyright string to be placed in all feeds</summary>
|
|
Copyright: string
|
|
} with
|
|
|
|
/// <summary>Create an edit model from a set of RSS options</summary>
|
|
/// <param name="rss">The RSS options being edited</param>
|
|
/// <returns>A populated <c>EditRssModel</c> instance</returns>
|
|
static member FromRssOptions (rss: RssOptions) =
|
|
{ IsFeedEnabled = rss.IsFeedEnabled
|
|
FeedName = rss.FeedName
|
|
ItemsInFeed = defaultArg rss.ItemsInFeed 0
|
|
IsCategoryEnabled = rss.IsCategoryEnabled
|
|
IsTagEnabled = rss.IsTagEnabled
|
|
Copyright = defaultArg rss.Copyright "" }
|
|
|
|
/// <summary>Update RSS options from values in this model</summary>
|
|
/// <param name="rss">The RSS options to be updated</param>
|
|
/// <returns>The RSS options, updated from values in this model</returns>
|
|
member this.UpdateOptions (rss: RssOptions) =
|
|
{ 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 }
|
|
|
|
|
|
/// <summary>View model to edit a tag mapping</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditTagMapModel = {
|
|
/// <summary>The ID of the tag mapping being edited</summary>
|
|
Id: string
|
|
|
|
/// <summary>The tag being mapped to a different link value</summary>
|
|
Tag: string
|
|
|
|
/// <summary>The link value for the tag</summary>
|
|
UrlValue: string
|
|
} with
|
|
|
|
/// <summary>Create an edit model from the tag mapping</summary>
|
|
/// <param name="tagMap">The tag mapping being edited</param>
|
|
/// <returns>A populated <c>EditTagMapModel</c> instance</returns>
|
|
static member FromMapping (tagMap: TagMap) : EditTagMapModel =
|
|
{ Id = string tagMap.Id
|
|
Tag = tagMap.Tag
|
|
UrlValue = tagMap.UrlValue }
|
|
|
|
/// <summary>Whether this is a new tag mapping</summary>
|
|
member this.IsNew =
|
|
this.Id = "new"
|
|
|
|
|
|
/// <summary>View model to display a user's information</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type EditUserModel = {
|
|
/// <summary>The ID of the user</summary>
|
|
Id: string
|
|
|
|
/// <summary>The user's access level</summary>
|
|
AccessLevel: string
|
|
|
|
/// <summary>The user name (e-mail address)</summary>
|
|
Email: string
|
|
|
|
/// <summary>The URL of the user's personal site</summary>
|
|
Url: string
|
|
|
|
/// <summary>The user's first name</summary>
|
|
FirstName: string
|
|
|
|
/// <summary>The user's last name</summary>
|
|
LastName: string
|
|
|
|
/// <summary>The user's preferred name</summary>
|
|
PreferredName: string
|
|
|
|
/// <summary>The user's password</summary>
|
|
Password: string
|
|
|
|
/// <summary>Confirmation of the user's password</summary>
|
|
PasswordConfirm: string
|
|
} with
|
|
|
|
/// <summary>Construct a user edit form from a web log user</summary>
|
|
/// <param name="user">The user being edited</param>
|
|
/// <returns>A populated <c>EditUserModel</c> instance</returns>
|
|
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 = "" }
|
|
|
|
/// <summary>Is this a new user?</summary>
|
|
member this.IsNew =
|
|
this.Id = "new"
|
|
|
|
/// <summary>Update a user with values from this model (excludes password)</summary>
|
|
/// <param name="user">The user to be updated</param>
|
|
/// <returns>The user, updated with values from this model</returns>
|
|
member this.UpdateUser (user: WebLogUser) =
|
|
{ user with
|
|
AccessLevel = AccessLevel.Parse this.AccessLevel
|
|
Email = this.Email.ToLowerInvariant()
|
|
Url = noneIfBlank this.Url
|
|
FirstName = this.FirstName
|
|
LastName = this.LastName
|
|
PreferredName = this.PreferredName }
|
|
|
|
|
|
/// <summary>The model to use to allow a user to log on</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type LogOnModel = {
|
|
/// <summary>The user's e-mail address</summary>
|
|
EmailAddress : string
|
|
|
|
/// <summary>The user's password</summary>
|
|
Password : string
|
|
|
|
/// <summary>Where the user should be redirected once they have logged on</summary>
|
|
ReturnTo : string option
|
|
} with
|
|
|
|
/// <summary>An empty log on model</summary>
|
|
static member Empty =
|
|
{ EmailAddress = ""; Password = ""; ReturnTo = None }
|
|
|
|
|
|
/// <summary>View model to manage chapters</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type ManageChaptersModel = {
|
|
/// <summary>The post ID for the chapters being edited</summary>
|
|
Id: string
|
|
|
|
/// <summary>The title of the post for which chapters are being edited</summary>
|
|
Title: string
|
|
|
|
/// <summary>The chapters for the post</summary>
|
|
Chapters: Chapter list
|
|
} with
|
|
|
|
/// <summary>Create a model from a post and its episode's chapters</summary>
|
|
/// <param name="post">The post from which this model should be created</param>
|
|
/// <returns>A populated <c>ManageChaptersModel</c> instance</returns>
|
|
static member Create (post: Post) =
|
|
{ Id = string post.Id
|
|
Title = post.Title
|
|
Chapters = post.Episode.Value.Chapters.Value }
|
|
|
|
|
|
/// <summary>View model to manage permalinks</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type ManagePermalinksModel = {
|
|
/// <summary>The ID for the entity being edited</summary>
|
|
Id: string
|
|
|
|
/// <summary>The type of entity being edited ("page" or "post")</summary>
|
|
Entity: string
|
|
|
|
/// <summary>The current title of the page or post</summary>
|
|
CurrentTitle: string
|
|
|
|
/// <summary>The current permalink of the page or post</summary>
|
|
CurrentPermalink: string
|
|
|
|
/// <summary>The prior permalinks for the page or post</summary>
|
|
Prior: string array
|
|
} with
|
|
|
|
/// <summary>Create a permalink model from a page</summary>
|
|
/// <param name="page">The page from which the model should be created</param>
|
|
/// <returns>A populated <c>ManagePermalinksModel</c> instance for the given <c>Page</c></returns>
|
|
static member FromPage (page: Page) =
|
|
{ Id = string page.Id
|
|
Entity = "page"
|
|
CurrentTitle = page.Title
|
|
CurrentPermalink = string page.Permalink
|
|
Prior = page.PriorPermalinks |> List.map string |> Array.ofList }
|
|
|
|
/// <summary>Create a permalink model from a post</summary>
|
|
/// <param name="post">The post from which the model should be created</param>
|
|
/// <returns>A populated <c>ManagePermalinksModel</c> instance for the given <c>Post</c></returns>
|
|
static member FromPost (post: Post) =
|
|
{ Id = string post.Id
|
|
Entity = "post"
|
|
CurrentTitle = post.Title
|
|
CurrentPermalink = string post.Permalink
|
|
Prior = post.PriorPermalinks |> List.map string |> Array.ofList }
|
|
|
|
|
|
/// <summary>View model to manage revisions</summary>
|
|
[<NoComparison; NoEquality>]
|
|
type ManageRevisionsModel = {
|
|
/// <summary>The ID for the entity being edited</summary>
|
|
Id: string
|
|
|
|
/// <summary>The type of entity being edited ("page" or "post")</summary>
|
|
Entity: string
|
|
|
|
/// <summary>The current title of the page or post</summary>
|
|
CurrentTitle: string
|
|
|
|
/// <summary>The revisions for the page or post</summary>
|
|
Revisions: Revision list
|
|
} with
|
|
|
|
/// <summary>Create a revision model from a page</summary>
|
|
/// <param name="page">The page from which the model should be created</param>
|
|
/// <returns>A populated <c>ManageRevisionsModel</c> instance for the given <c>Page</c></returns>
|
|
static member FromPage (page: Page) =
|
|
{ Id = string page.Id
|
|
Entity = "page"
|
|
CurrentTitle = page.Title
|
|
Revisions = page.Revisions }
|
|
|
|
/// <summary>Create a revision model from a post</summary>
|
|
/// <param name="post">The post from which the model should be created</param>
|
|
/// <returns>A populated <c>ManageRevisionsModel</c> instance for the given <c>Post</c></returns>
|
|
static member FromPost (post: Post) =
|
|
{ Id = string post.Id
|
|
Entity = "post"
|
|
CurrentTitle = post.Title
|
|
Revisions = post.Revisions }
|
|
|
|
|
|
/// <summary>View model for posts in a list</summary>
|
|
[<NoComparison; NoEquality>]
|
|
type PostListItem = {
|
|
/// <summary>The ID of the post</summary>
|
|
Id: string
|
|
|
|
/// <summary>The ID of the user who authored the post</summary>
|
|
AuthorId: string
|
|
|
|
/// <summary>The status of the post</summary>
|
|
Status: string
|
|
|
|
/// <summary>The title of the post</summary>
|
|
Title: string
|
|
|
|
/// <summary>The permalink for the post</summary>
|
|
Permalink: string
|
|
|
|
/// <summary>When this post was published</summary>
|
|
PublishedOn: Nullable<DateTime>
|
|
|
|
/// <summary>When this post was last updated</summary>
|
|
UpdatedOn: DateTime
|
|
|
|
/// <summary>The text of the post</summary>
|
|
Text: string
|
|
|
|
/// <summary>The IDs of the categories for this post</summary>
|
|
CategoryIds: string list
|
|
|
|
/// <summary>Tags for the post</summary>
|
|
Tags: string list
|
|
|
|
/// <summary>The podcast episode information for this post</summary>
|
|
Episode: Episode option
|
|
|
|
/// <summary>Metadata for the post</summary>
|
|
Metadata: MetaItem list
|
|
|
|
/// <summary>OpenGraph properties for the post</summary>
|
|
OpenGraph: OpenGraphProperties option
|
|
} with
|
|
|
|
/// <summary>Create a post list item from a post</summary>
|
|
/// <param name="webLog">The web log to which the post belongs</param>
|
|
/// <param name="post">The post from which the model should be created</param>
|
|
/// <returns>A populated <c>PostListItem</c> instance</returns>
|
|
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
|
|
OpenGraph = post.OpenGraph }
|
|
|
|
|
|
/// <summary>View model for displaying posts</summary>
|
|
type PostDisplay = {
|
|
/// <summary>The posts to be displayed</summary>
|
|
Posts: PostListItem array
|
|
|
|
/// <summary>Author ID -> name lookup</summary>
|
|
Authors: MetaItem list
|
|
|
|
/// <summary>A subtitle for the page</summary>
|
|
Subtitle: string option
|
|
|
|
/// <summary>The link to view newer (more recent) posts</summary>
|
|
NewerLink: string option
|
|
|
|
/// <summary>The name of the next newer post (single-post only)</summary>
|
|
NewerName: string option
|
|
|
|
/// <summary>The link to view older (less recent) posts</summary>
|
|
OlderLink: string option
|
|
|
|
/// <summary>The name of the next older post (single-post only)</summary>
|
|
OlderName: string option
|
|
}
|
|
|
|
|
|
/// <summary>View model for editing web log settings</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type SettingsModel = {
|
|
/// <summary>The name of the web log</summary>
|
|
Name: string
|
|
|
|
/// <summary>The slug of the web log</summary>
|
|
Slug: string
|
|
|
|
/// <summary>The subtitle of the web log</summary>
|
|
Subtitle: string
|
|
|
|
/// <summary>The default page</summary>
|
|
DefaultPage: string
|
|
|
|
/// <summary>How many posts should appear on index pages</summary>
|
|
PostsPerPage: int
|
|
|
|
/// <summary>The time zone in which dates/times should be displayed</summary>
|
|
TimeZone: string
|
|
|
|
/// <summary>The theme to use to display the web log</summary>
|
|
ThemeId: string
|
|
|
|
/// <summary>Whether to automatically load htmx</summary>
|
|
AutoHtmx: string
|
|
|
|
/// <summary>The default location for uploads</summary>
|
|
Uploads: string
|
|
|
|
/// <summary>Whether to automatically apply OpenGraph properties to all pages and posts</summary>
|
|
AutoOpenGraph: bool
|
|
} with
|
|
|
|
/// <summary>Create a settings model from a web log</summary>
|
|
/// <param name="webLog">The web log from which this model should be created</param>
|
|
/// <returns>A populated <c>SettingsModel</c> instance</returns>
|
|
static member FromWebLog(webLog: WebLog) =
|
|
{ Name = webLog.Name
|
|
Slug = webLog.Slug
|
|
Subtitle = defaultArg webLog.Subtitle ""
|
|
DefaultPage = webLog.DefaultPage
|
|
PostsPerPage = webLog.PostsPerPage
|
|
TimeZone = webLog.TimeZone
|
|
ThemeId = string webLog.ThemeId
|
|
AutoHtmx = string webLog.AutoHtmx
|
|
Uploads = string webLog.Uploads
|
|
AutoOpenGraph = webLog.AutoOpenGraph }
|
|
|
|
/// <summary>Update a web log with settings from the form</summary>
|
|
/// <param name="webLog">The web log to be updated</param>
|
|
/// <returns>The web log, updated with the value from this model</returns>
|
|
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 = AutoHtmxType.Parse this.AutoHtmx
|
|
Uploads = UploadDestination.Parse this.Uploads
|
|
AutoOpenGraph = this.AutoOpenGraph }
|
|
|
|
|
|
/// <summary>View model for uploading a file</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type UploadFileModel = {
|
|
/// <summary>The upload destination</summary>
|
|
Destination : string
|
|
}
|
|
|
|
|
|
/// <summary>View model for uploading a theme</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type UploadThemeModel = {
|
|
/// <summary>Whether the uploaded theme should overwrite an existing theme</summary>
|
|
DoOverwrite : bool
|
|
}
|
|
|
|
|
|
/// <summary>A message displayed to the user</summary>
|
|
[<CLIMutable; NoComparison; NoEquality>]
|
|
type UserMessage = {
|
|
/// <summary>The level of the message</summary>
|
|
Level: string
|
|
|
|
/// <summary>The message</summary>
|
|
Message: string
|
|
|
|
/// <summary>Further details about the message</summary>
|
|
Detail: string option
|
|
} with
|
|
|
|
/// <summary>An empty user message (use one of the others for pre-filled level)</summary>
|
|
static member Empty = { Level = ""; Message = ""; Detail = None }
|
|
|
|
/// <summary>A blank success message</summary>
|
|
static member Success = { UserMessage.Empty with Level = "success" }
|
|
|
|
/// <summary>A blank informational message</summary>
|
|
static member Info = { UserMessage.Empty with Level = "primary" }
|
|
|
|
/// <summary>A blank warning message</summary>
|
|
static member Warning = { UserMessage.Empty with Level = "warning" }
|
|
|
|
/// <summary>A blank error message</summary>
|
|
static member Error = { UserMessage.Empty with Level = "danger" }
|