diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2b3671f..1f7a38a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,6 +2,7 @@ net8.0;net9.0 embedded + true 3.0.0.0 3.0.0.0 3.0.0 diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs index ad14d54..42b2f8b 100644 --- a/src/MyWebLog.Data/Converters.fs +++ b/src/MyWebLog.Data/Converters.fs @@ -1,14 +1,15 @@ -/// Converters for discriminated union types +/// Converters for discriminated union types module MyWebLog.Converters open MyWebLog open System -/// JSON.NET converters for discriminated union types +/// JSON.NET converters for discriminated union types module Json = - + open Newtonsoft.Json - + + /// Converter for the type type CategoryIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: CategoryId, _: JsonSerializer) = @@ -16,6 +17,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: CategoryId, _: bool, _: JsonSerializer) = (string >> CategoryId) reader.Value + /// Converter for the type type CommentIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: CommentId, _: JsonSerializer) = @@ -23,6 +25,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: CommentId, _: bool, _: JsonSerializer) = (string >> CommentId) reader.Value + /// Converter for the type type CommentStatusConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: CommentStatus, _: JsonSerializer) = @@ -30,6 +33,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: CommentStatus, _: bool, _: JsonSerializer) = (string >> CommentStatus.Parse) reader.Value + /// Converter for the type type CustomFeedIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: CustomFeedId, _: JsonSerializer) = @@ -37,27 +41,31 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: CustomFeedId, _: bool, _: JsonSerializer) = (string >> CustomFeedId) reader.Value + /// Converter for the type type CustomFeedSourceConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: CustomFeedSource, _: JsonSerializer) = writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: CustomFeedSource, _: bool, _: JsonSerializer) = (string >> CustomFeedSource.Parse) reader.Value - + + /// Converter for the type type ExplicitRatingConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: ExplicitRating, _: JsonSerializer) = writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: ExplicitRating, _: bool, _: JsonSerializer) = (string >> ExplicitRating.Parse) reader.Value - + + /// Converter for the type type MarkupTextConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: MarkupText, _: JsonSerializer) = writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: MarkupText, _: bool, _: JsonSerializer) = (string >> MarkupText.Parse) reader.Value - + + /// Converter for the type type PermalinkConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: Permalink, _: JsonSerializer) = @@ -65,6 +73,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: Permalink, _: bool, _: JsonSerializer) = (string >> Permalink) reader.Value + /// Converter for the type type PageIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: PageId, _: JsonSerializer) = @@ -72,6 +81,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: PageId, _: bool, _: JsonSerializer) = (string >> PageId) reader.Value + /// Converter for the type type PodcastMediumConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: PodcastMedium, _: JsonSerializer) = @@ -79,6 +89,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: PodcastMedium, _: bool, _: JsonSerializer) = (string >> PodcastMedium.Parse) reader.Value + /// Converter for the type type PostIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: PostId, _: JsonSerializer) = @@ -86,6 +97,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: PostId, _: bool, _: JsonSerializer) = (string >> PostId) reader.Value + /// Converter for the type type TagMapIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: TagMapId, _: JsonSerializer) = @@ -93,6 +105,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: TagMapId, _: bool, _: JsonSerializer) = (string >> TagMapId) reader.Value + /// Converter for the type type ThemeAssetIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: ThemeAssetId, _: JsonSerializer) = @@ -100,20 +113,23 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: ThemeAssetId, _: bool, _: JsonSerializer) = (string >> ThemeAssetId.Parse) reader.Value + /// Converter for the type type ThemeIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: ThemeId, _: JsonSerializer) = writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: ThemeId, _: bool, _: JsonSerializer) = (string >> ThemeId) reader.Value - + + /// Converter for the type type UploadIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: UploadId, _: JsonSerializer) = writer.WriteValue(string value) override _.ReadJson(reader: JsonReader, _: Type, _: UploadId, _: bool, _: JsonSerializer) = (string >> UploadId) reader.Value - + + /// Converter for the type type WebLogIdConverter() = inherit JsonConverter() override _.WriteJson(writer: JsonWriter, value: WebLogId, _: JsonSerializer) = @@ -121,6 +137,7 @@ module Json = override _.ReadJson(reader: JsonReader, _: Type, _: WebLogId, _: bool, _: JsonSerializer) = (string >> WebLogId) reader.Value + /// Converter for the type type WebLogUserIdConverter() = inherit JsonConverter () override _.WriteJson(writer: JsonWriter, value: WebLogUserId, _: JsonSerializer) = @@ -131,9 +148,9 @@ module Json = open Microsoft.FSharpLu.Json open NodaTime open NodaTime.Serialization.JsonNet - - /// Configure a serializer to use these converters - let configure (ser : JsonSerializer) = + + /// Configure a serializer to use these converters (and other settings) + let configure (ser: JsonSerializer) = // Our converters [ CategoryIdConverter() :> JsonConverter CommentIdConverter() @@ -160,12 +177,14 @@ module Json = ser.NullValueHandling <- NullValueHandling.Ignore ser.MissingMemberHandling <- MissingMemberHandling.Ignore ser - + /// Serializer settings extracted from a JsonSerializer (a property sure would be nice...) let mutable private serializerSettings : JsonSerializerSettings option = None - - /// Extract settings from the serializer to be used in JsonConvert calls - let settings (ser : JsonSerializer) = + + /// Extract settings from the serializer to be used in JsonConvert calls + /// The serializer from which settings will be extracted if required + /// The serializer settings to use for JsonConvert calls + let settings (ser: JsonSerializer) = if Option.isNone serializerSettings then serializerSettings <- JsonSerializerSettings ( ConstructorHandling = ser.ConstructorHandling, diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index f68009e..0039a09 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -5,17 +5,17 @@ - - - - - + + + + + - + - + diff --git a/src/MyWebLog.Data/Utils.fs b/src/MyWebLog.Data/Utils.fs index cbed976..b22d563 100644 --- a/src/MyWebLog.Data/Utils.fs +++ b/src/MyWebLog.Data/Utils.fs @@ -1,11 +1,16 @@ -/// Utility functions for manipulating data +/// Utility functions for manipulating data [] module internal MyWebLog.Data.Utils open MyWebLog open MyWebLog.ViewModels -/// Create a category hierarchy from the given list of categories +/// Create a category hierarchy from the given list of categories +/// The categories from which the list should be generated +/// The ID of the parent category for this list +/// The base URL to use in slugs for categories at this level +/// The names of parent categories for this level +/// An array of DisplayCategory instances sorted alphabetically by parent category let rec orderByHierarchy (cats: Category list) parentId slugBase parentNames = seq { for cat in cats |> List.filter (fun c -> c.ParentId = parentId) do let fullSlug = (match slugBase with Some it -> $"{it}/" | None -> "") + cat.Slug @@ -19,48 +24,75 @@ let rec orderByHierarchy (cats: Category list) parentId slugBase parentNames = s yield! orderByHierarchy cats (Some cat.Id) (Some fullSlug) ([ cat.Name ] |> List.append parentNames) } -/// Get lists of items removed from and added to the given lists +/// Get lists of items removed from and added to the given lists +/// The type of items in the list +/// The return type of the comparision function +/// The prior list +/// The current list +/// The function to use when comparing items in the list +/// A tuple with fst being added items and snd being removed items let diffLists<'T, 'U when 'U: equality> oldItems newItems (f: 'T -> 'U) = let diff compList = fun item -> not (compList |> List.exists (fun other -> f item = f other)) List.filter (diff newItems) oldItems, List.filter (diff oldItems) newItems -/// Find the revisions added and removed +/// Find the revisions added and removed +/// The previous revisions +/// The current revisions +/// A tuple with fst being added revisions and snd being removed revisions let diffRevisions (oldRevs: Revision list) newRevs = diffLists oldRevs newRevs (fun rev -> $"{rev.AsOf.ToUnixTimeTicks()}|{rev.Text}") open MyWebLog.Converters open Newtonsoft.Json -/// Serialize an object to JSON +/// Serialize an object to JSON +/// The type of the item being serialized +/// The JSON serializer whose settings should be used +/// The item to be serialized +/// A string with the given object serialized to JSON let serialize<'T> ser (item: 'T) = JsonConvert.SerializeObject(item, Json.settings ser) -/// Deserialize a JSON string +/// Deserialize a JSON string +/// The type of the item being deserialized +/// The JSON serializer whose settings should be used +/// The string with the JSON representation of the item +/// The item deserialized from JSON let deserialize<'T> (ser: JsonSerializer) value = JsonConvert.DeserializeObject<'T>(value, Json.settings ser) + open BitBadger.Documents -/// Create a document serializer using the given JsonSerializer +/// Create a document serializer using the given JsonSerializer +/// The JSON.NET serializer on which the document serializer should be based +/// A document serializer instance let createDocumentSerializer ser = { new IDocumentSerializer with member _.Serialize<'T>(it: 'T) : string = serialize ser it member _.Deserialize<'T>(it: string) : 'T = deserialize ser it } -/// Data migration utilities +/// Data migration utilities module Migration = - + open Microsoft.Extensions.Logging - /// The current database version + /// The current database version let currentDbVersion = "v2.2" - /// Log a migration step + /// Log a migration step + /// The logger to which the message should be logged + /// The migration being run + /// The log message let logStep<'T> (log: ILogger<'T>) migration message = log.LogInformation $"Migrating %s{migration}: %s{message}" - /// Notify the user that a backup/restore + /// Notify the user that a backup/restore is required to migrate + /// The logger to which the message should be logged + /// The old (current) version of the database + /// The new (application) version required + /// All web logs contained in the database let backupAndRestoreRequired log oldVersion newVersion webLogs = logStep log $"%s{oldVersion} to %s{newVersion}" "Requires Using Action" @@ -74,7 +106,6 @@ module Migration = yield! webLogs |> List.map (fun (url, slug) -> $"./myWebLog backup %s{url} {oldVersion}.%s{slug}.json") ] |> String.concat "\n" |> log.LogWarning - + log.LogCritical "myWebLog will now exit" exit 1 |> ignore - \ No newline at end of file diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 65c2325..e1d6463 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -3,29 +3,29 @@ open MyWebLog open NodaTime -/// A category under which a post may be identified +/// A category under which a post may be identified [] type Category = { - /// The ID of the category + /// The ID of the category Id: CategoryId - /// The ID of the web log to which the category belongs + /// The ID of the web log to which the category belongs WebLogId: WebLogId - /// The displayed name + /// The displayed name Name: string - /// The slug (used in category URLs) + /// The slug (used in category URLs) Slug: string - /// A longer description of the category + /// A longer description of the category Description: string option - /// The parent ID of this category (if a subcategory) + /// The parent ID of this category (if a subcategory) ParentId: CategoryId option } with - - /// An empty category + + /// An empty category static member Empty = { Id = CategoryId.Empty WebLogId = WebLogId.Empty @@ -35,38 +35,38 @@ type Category = { ParentId = None } -/// A comment on a post +/// A comment on a post [] type Comment = { - /// The ID of the comment + /// The ID of the comment Id: CommentId - /// The ID of the post to which this comment applies + /// The ID of the post to which this comment applies PostId: PostId - /// The ID of the comment to which this comment is a reply + /// The ID of the comment to which this comment is a reply InReplyToId: CommentId option - /// The name of the commentor + /// The name of the commentor Name: string - /// The e-mail address of the commentor + /// The e-mail address of the commentor Email: string - /// The URL of the commentor's personal website + /// The URL of the commentor's personal website Url: string option - /// The status of the comment + /// The status of the comment Status: CommentStatus - /// When the comment was posted + /// When the comment was posted PostedOn: Instant - /// The text of the comment + /// The text of the comment Text: string } with - - /// An empty comment + + /// An empty comment static member Empty = { Id = CommentId.Empty PostId = PostId.Empty @@ -79,50 +79,50 @@ type Comment = { Text = "" } -/// A page (text not associated with a date/time) +/// A page (text not associated with a date/time) [] type Page = { - /// The ID of this page + /// The ID of this page Id: PageId - /// The ID of the web log to which this page belongs + /// The ID of the web log to which this page belongs WebLogId: WebLogId - /// The ID of the author of this page + /// The ID of the author of this page AuthorId: WebLogUserId - /// The title of the page + /// The title of the page Title: string - /// The link at which this page is displayed + /// The link at which this page is displayed Permalink: Permalink - /// When this page was published + /// When this page was published PublishedOn: Instant - /// When this page was last updated + /// When this page was last updated UpdatedOn: Instant - /// Whether this page shows as part of the web log's navigation + /// Whether this page shows as part of the web log's navigation IsInPageList: bool - /// The template to use when rendering this page + /// The template to use when rendering this page Template: string option - /// The current text of the page + /// The current text of the page Text: string - /// Metadata for this page + /// Metadata for this page Metadata: MetaItem list - - /// Permalinks at which this page may have been previously served (useful for migrated content) + + /// Permalinks at which this page may have been previously served (useful for migrated content) PriorPermalinks: Permalink list - /// Revisions of this page + /// Revisions of this page Revisions: Revision list } with - - /// An empty page + + /// An empty page static member Empty = { Id = PageId.Empty WebLogId = WebLogId.Empty @@ -139,59 +139,59 @@ type Page = { Revisions = [] } -/// A web log post +/// A web log post [] type Post = { - /// The ID of this post + /// The ID of this post Id: PostId - /// The ID of the web log to which this post belongs + /// The ID of the web log to which this post belongs WebLogId: WebLogId - /// The ID of the author of this post + /// The ID of the author of this post AuthorId: WebLogUserId - /// The status + /// The status Status: PostStatus - /// The title + /// The title Title: string - /// The link at which the post resides + /// The link at which the post resides Permalink: Permalink - /// The instant on which the post was originally published + /// The instant on which the post was originally published PublishedOn: Instant option - /// The instant on which the post was last updated + /// The instant on which the post was last updated UpdatedOn: Instant - /// The template to use in displaying the post + /// The template to use in displaying the post Template: string option - - /// The text of the post in HTML (ready to display) format + + /// The text of the post in HTML (ready to display) format Text: string - /// The Ids of the categories to which this is assigned + /// The Ids of the categories to which this is assigned CategoryIds: CategoryId list - /// The tags for the post + /// The tags for the post Tags: string list - /// Podcast episode information for this post + /// Podcast episode information for this post Episode: Episode option - - /// Metadata for the post + + /// Metadata for the post Metadata: MetaItem list - - /// Permalinks at which this post may have been previously served (useful for migrated content) + + /// Permalinks at which this post may have been previously served (useful for migrated content) PriorPermalinks: Permalink list - /// The revisions for this post + /// The revisions for this post Revisions: Revision list } with - - /// An empty post + + /// An empty post static member Empty = { Id = PostId.Empty WebLogId = WebLogId.Empty @@ -211,136 +211,138 @@ type Post = { Revisions = [] } +/// /// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1") +/// [] type TagMap = { - /// The ID of this tag mapping + /// The ID of this tag mapping Id: TagMapId - - /// The ID of the web log to which this tag mapping belongs + + /// The ID of the web log to which this tag mapping belongs WebLogId: WebLogId - - /// The tag which should be mapped to a different value in links + + /// The tag which should be mapped to a different value in links Tag: string - - /// The value by which the tag should be linked + + /// The value by which the tag should be linked UrlValue: string } with - - /// An empty tag mapping + + /// An empty tag mapping static member Empty = { Id = TagMapId.Empty; WebLogId = WebLogId.Empty; Tag = ""; UrlValue = "" } -/// A theme +/// A theme [] type Theme = { - /// The ID / path of the theme + /// The ID / path of the theme Id: ThemeId - - /// A long name of the theme + + /// A long name of the theme Name: string - - /// The version of the theme + + /// The version of the theme Version: string - - /// The templates for this theme + + /// The templates for this theme Templates: ThemeTemplate list } with - - /// An empty theme + + /// An empty theme static member Empty = { Id = ThemeId.Empty; Name = ""; Version = ""; Templates = [] } -/// A theme asset (a file served as part of a theme, at /themes/[theme]/[asset-path]) +/// A theme asset (a file served as part of a theme, at /themes/[theme]/[asset-path]) [] type ThemeAsset = { - /// The ID of the asset (consists of theme and path) + /// The ID of the asset (consists of theme and path) Id: ThemeAssetId - - /// The updated date (set from the file date from the ZIP archive) + + /// The updated date (set from the file date from the ZIP archive) UpdatedOn: Instant - - /// The data for the asset + + /// The data for the asset Data: byte array } with - - /// An empty theme asset + + /// An empty theme asset static member Empty = { Id = ThemeAssetId.Empty; UpdatedOn = Noda.epoch; Data = [||] } -/// An uploaded file +/// An uploaded file [] type Upload = { - /// The ID of the upload + /// The ID of the upload Id: UploadId - - /// The ID of the web log to which this upload belongs + + /// The ID of the web log to which this upload belongs WebLogId: WebLogId - - /// The link at which this upload is served + + /// The link at which this upload is served Path: Permalink - - /// The updated date/time for this upload + + /// The updated date/time for this upload UpdatedOn: Instant - - /// The data for the upload + + /// The data for the upload Data: byte array } with - - /// An empty upload + + /// An empty upload static member Empty = { Id = UploadId.Empty; WebLogId = WebLogId.Empty; Path = Permalink.Empty; UpdatedOn = Noda.epoch; Data = [||] } open Newtonsoft.Json -/// A web log +/// A web log [] type WebLog = { - /// The ID of the web log + /// The ID of the web log Id: WebLogId - /// The name of the web log + /// The name of the web log Name: string - /// The slug of the web log + /// The slug of the web log Slug: string - - /// A subtitle for the web log + + /// A subtitle for the web log Subtitle: string option - /// The default page ("posts" or a page Id) + /// The default page ("posts" or a page Id) DefaultPage: string - /// The number of posts to display on pages of posts + /// The number of posts to display on pages of posts PostsPerPage: int - /// The ID of the theme (also the path within /themes) + /// The ID of the theme (also the path within /themes) ThemeId: ThemeId - /// The URL base + /// The URL base UrlBase: string - /// The time zone in which dates/times should be displayed + /// The time zone in which dates/times should be displayed TimeZone: string - - /// The RSS options for this web log + + /// The RSS options for this web log Rss: RssOptions - - /// Whether to automatically load htmx + + /// Whether to automatically load htmx AutoHtmx: bool - - /// Where uploads are placed + + /// Where uploads are placed Uploads: UploadDestination - /// Redirect rules for this weblog + /// Redirect rules for this weblog RedirectRules: RedirectRule list } with - - /// An empty web log + + /// An empty web log static member Empty = { Id = WebLogId.Empty Name = "" @@ -355,8 +357,10 @@ type WebLog = { AutoHtmx = false Uploads = Database RedirectRules = [] } - + + /// /// Any extra path where this web log is hosted (blank if web log is hosted at the root of the domain) + /// [] member this.ExtraPath = let pathParts = this.UrlBase.Split "://" @@ -365,16 +369,22 @@ type WebLog = { else let path = pathParts[1].Split "/" if path.Length > 1 then $"""/{path |> Array.skip 1 |> String.concat "/"}""" else "" - - /// Generate an absolute URL for the given link + + /// Generate an absolute URL for the given link + /// The permalink for which an absolute URL should be generated + /// An absolute URL for the given link member this.AbsoluteUrl(permalink: Permalink) = $"{this.UrlBase}/{permalink}" - - /// Generate a relative URL for the given link + + /// Generate a relative URL for the given link + /// The permalink for which a relative URL should be generated + /// A relative URL for the given link member this.RelativeUrl(permalink: Permalink) = $"{this.ExtraPath}/{permalink}" - - /// Convert an Instant (UTC reference) to the web log's local date/time + + /// Convert an Instant (UTC reference) to the web log's local date/time + /// The UTC Instant to be converted + /// The local date/time for this web log member this.LocalTime(date: Instant) = DateTimeZoneProviders.Tzdb.GetZoneOrNull this.TimeZone |> Option.ofObj @@ -382,44 +392,44 @@ type WebLog = { |> Option.defaultValue (date.ToDateTimeUtc()) -/// A user of the web log +/// A user of the web log [] type WebLogUser = { - /// The ID of the user + /// The ID of the user Id: WebLogUserId - /// The ID of the web log to which this user belongs + /// The ID of the web log to which this user belongs WebLogId: WebLogId - /// The user name (e-mail address) + /// The user name (e-mail address) Email: string - /// The user's first name + /// The user's first name FirstName: string - /// The user's last name + /// The user's last name LastName: string - /// The user's preferred name + /// The user's preferred name PreferredName: string - /// The hash of the user's password + /// The hash of the user's password PasswordHash: string - /// The URL of the user's personal site + /// The URL of the user's personal site Url: string option - /// The user's access level + /// The user's access level AccessLevel: AccessLevel - - /// When the user was created + + /// When the user was created CreatedOn: Instant - - /// When the user last logged on + + /// When the user last logged on LastSeenOn: Instant option } with - - /// An empty web log user + + /// An empty web log user static member Empty = { Id = WebLogUserId.Empty WebLogId = WebLogId.Empty @@ -432,8 +442,8 @@ type WebLogUser = { AccessLevel = Author CreatedOn = Noda.epoch LastSeenOn = None } - - /// Get the user's displayed name + + /// Get the user's displayed name [] member this.DisplayName = (seq { (match this.PreferredName with "" -> this.FirstName | n -> n); " "; this.LastName } diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj index 489ff15..32f4129 100644 --- a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -7,11 +7,11 @@ - + - - + + diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 2ba8266..e947d65 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -4,56 +4,73 @@ open System open Markdig open NodaTime -/// Support functions for domain definition +/// Support functions for domain definition [] module private Helpers = open Markdown.ColorCode - /// Create a new ID (short GUID) - // https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID + /// Create a new ID (short GUID) + /// A 21-character URL-friendly string representing a GUID + /// https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID let newId () = Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..21] - - /// Pipeline with most extensions enabled + + /// Pipeline with most extensions enabled let markdownPipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build() -/// Functions to support NodaTime manipulation +/// Functions to support NodaTime manipulation module Noda = - - /// The clock to use when getting "now" (will make mutable for testing) + + /// The clock to use when getting "now" (will make mutable for testing) let clock: IClock = SystemClock.Instance - - /// The Unix epoch + + /// The Unix epoch let epoch = Instant.FromUnixTimeSeconds 0L - - /// Truncate an instant to remove fractional seconds + + /// Truncate an instant to remove fractional seconds + /// The value from which fractional seconds should be removed + /// The Instant value with no fractional seconds let toSecondsPrecision (value: Instant) = Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds()) - - /// The current Instant, with fractional seconds truncated + + /// The current Instant, with fractional seconds truncated + /// The current Instant with no fractional seconds let now = clock.GetCurrentInstant >> toSecondsPrecision - - /// Convert a date/time to an Instant with whole seconds + + /// Convert a date/time to an Instant with whole seconds + /// The date/time to convert + /// An Instant with no fractional seconds let fromDateTime (dt: DateTime) = Instant.FromDateTimeUtc(DateTime(dt.Ticks, DateTimeKind.Utc)) |> toSecondsPrecision -/// A user's access level +/// A user's access level [] type AccessLevel = - /// The user may create and publish posts and edit the ones they have created + /// The user may create and publish posts and edit the ones they have created | Author - /// The user may edit posts they did not create, but may not delete them + /// The user may edit posts they did not create, but may not delete them | Editor - /// The user may delete posts and configure web log settings + /// The user may delete posts and configure web log settings | WebLogAdmin - /// The user may manage themes (which affects all web logs for an installation) + /// The user may manage themes (which affects all web logs for an installation) | Administrator - - /// Parse an access level from its string representation + + /// Weights applied to each access level + static member private Weights = + [ Author, 10 + Editor, 20 + WebLogAdmin, 30 + Administrator, 40 ] + |> Map.ofList + + /// Parse an access level from its string representation + /// The string representation to be parsed + /// The AccessLevel instance parsed from the string + /// If the string is not valid static member Parse level = match level with | "Author" -> Author @@ -62,70 +79,71 @@ type AccessLevel = | "Administrator" -> Administrator | _ -> invalidArg (nameof level) $"{level} is not a valid access level" - /// The string representation of this access level + /// override this.ToString() = match this with | Author -> "Author" | Editor -> "Editor" | WebLogAdmin -> "WebLogAdmin" | Administrator -> "Administrator" - - /// Does a given access level allow an action that requires a certain access level? + + /// Does a given access level allow an action that requires a certain access level? + /// The minimum level of access needed + /// True if this level satisfies the given level, false if not member this.HasAccess(needed: AccessLevel) = - let weights = - [ Author, 10 - Editor, 20 - WebLogAdmin, 30 - Administrator, 40 ] - |> Map.ofList - weights[needed] <= weights[this] + AccessLevel.Weights[needed] <= AccessLevel.Weights[this] -/// An identifier for a category +/// An identifier for a category [] type CategoryId = | CategoryId of string - - /// An empty category ID + + /// An empty category ID static member Empty = CategoryId "" - - /// Create a new category ID + + /// Create a new category ID + /// A new category ID static member Create = newId >> CategoryId - /// The string representation of this category ID + /// override this.ToString() = match this with CategoryId it -> it -/// An identifier for a comment +/// An identifier for a comment [] type CommentId = | CommentId of string - - /// An empty comment ID + + /// An empty comment ID static member Empty = CommentId "" - - /// Create a new comment ID + + /// Create a new comment ID + /// A new commend ID static member Create = newId >> CommentId - /// The string representation of this comment ID + /// override this.ToString() = match this with CommentId it -> it -/// Statuses for post comments +/// Statuses for post comments [] type CommentStatus = - /// The comment is approved + /// The comment is approved | Approved - /// The comment has yet to be approved + /// The comment has yet to be approved | Pending - /// The comment was unsolicited and unwelcome + /// The comment was unsolicited and unwelcome | Spam - /// Parse a string into a comment status + /// Parse a string into a comment status + /// The string representation of the status + /// The CommentStatus instance parsed from the string + /// If the string is not valid static member Parse status = match status with | "Approved" -> Approved @@ -133,69 +151,72 @@ type CommentStatus = | "Spam" -> Spam | _ -> invalidArg (nameof status) $"{status} is not a valid comment status" - /// Convert a comment status to a string + /// override this.ToString() = match this with Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam" -/// Valid values for the iTunes explicit rating +/// Valid values for the iTunes explicit rating [] type ExplicitRating = | Yes | No | Clean - - /// Parse a string into an explicit rating + + /// Parse a string into an explicit rating + /// The string representation of the rating + /// The ExplicitRating parsed from the string + /// If the string is not valid static member Parse rating = match rating with | "yes" -> Yes | "no" -> No | "clean" -> Clean | _ -> invalidArg (nameof rating) $"{rating} is not a valid explicit rating" - - /// The string value of this rating + + /// override this.ToString() = match this with Yes -> "yes" | No -> "no" | Clean -> "clean" -/// A location (specified by Podcast Index) +/// A location (specified by Podcast Index) type Location = { - /// The name of the location (free-form text) + /// The name of the location (free-form text) Name: string - /// A geographic coordinate string (RFC 5870) + /// A geographic coordinate string (RFC 5870) Geo: string - /// An OpenStreetMap query + /// An OpenStreetMap query Osm: string option } -/// A chapter in a podcast episode +/// A chapter in a podcast episode type Chapter = { - /// The start time for the chapter + /// The start time for the chapter StartTime: Duration - /// The title for this chapter + /// The title for this chapter Title: string option - /// A URL for an image for this chapter + /// A URL for an image for this chapter ImageUrl: string option - /// A URL with information pertaining to this chapter + /// A URL with information pertaining to this chapter Url: string option - /// Whether this chapter is hidden + /// Whether this chapter is hidden IsHidden: bool option - /// The episode end time for the chapter + /// The episode end time for the chapter EndTime: Duration option - /// A location that applies to a chapter + /// A location that applies to a chapter Location: Location option } with - - /// An empty chapter + + /// An empty chapter static member Empty = { StartTime = Duration.Zero Title = None @@ -208,67 +229,67 @@ type Chapter = { open NodaTime.Text -/// A podcast episode +/// A podcast episode type Episode = { - /// The URL to the media file for the episode (may be permalink) + /// The URL to the media file for the episode (may be permalink) Media: string - - /// The length of the media file, in bytes + + /// The length of the media file, in bytes Length: int64 - - /// The duration of the episode + + /// The duration of the episode Duration: Duration option - - /// The media type of the file (overrides podcast default if present) + + /// The media type of the file (overrides podcast default if present) MediaType: string option - - /// The URL to the image file for this episode (overrides podcast image if present, may be permalink) + + /// The URL to the image file for this episode (overrides podcast image if present, may be permalink) ImageUrl: string option - - /// A subtitle for this episode + + /// A subtitle for this episode Subtitle: string option - - /// This episode's explicit rating (overrides podcast rating if present) + + /// This episode's explicit rating (overrides podcast rating if present) Explicit: ExplicitRating option - - /// Chapters for this episode + + /// Chapters for this episode Chapters: Chapter list option - /// A link to a chapter file + /// A link to a chapter file ChapterFile: string option - - /// The MIME type for the chapter file + + /// The MIME type for the chapter file ChapterType: string option - - /// Whether the chapters have locations that should be displayed as waypoints + + /// Whether the chapters have locations that should be displayed as waypoints ChapterWaypoints: bool option - - /// The URL for the transcript of the episode (may be permalink) + + /// The URL for the transcript of the episode (may be permalink) TranscriptUrl: string option - - /// The MIME type of the transcript + + /// The MIME type of the transcript TranscriptType: string option - - /// The language in which the transcript is written + + /// The language in which the transcript is written TranscriptLang: string option - - /// If true, the transcript will be declared (in the feed) to be a captions file + + /// If true, the transcript will be declared (in the feed) to be a captions file TranscriptCaptions: bool option - - /// The season number (for serialized podcasts) + + /// The season number (for serialized podcasts) SeasonNumber: int option - - /// A description of the season + + /// A description of the season SeasonDescription: string option - - /// The episode number + + /// The episode number EpisodeNumber: double option - - /// A description of the episode + + /// A description of the episode EpisodeDescription: string option } with - - /// An empty episode + + /// An empty episode static member Empty = { Media = "" Length = 0L @@ -280,7 +301,7 @@ type Episode = { Chapters = None ChapterFile = None ChapterType = None - ChapterWaypoints = None + ChapterWaypoints = None TranscriptUrl = None TranscriptType = None TranscriptLang = None @@ -289,104 +310,110 @@ type Episode = { SeasonDescription = None EpisodeNumber = None EpisodeDescription = None } - - /// Format a duration for an episode + + /// Format a duration for an episode + /// A duration formatted in hours, minutes, and seconds member this.FormatDuration() = this.Duration |> Option.map (DurationPattern.CreateWithInvariantCulture("H:mm:ss").Format) -/// Types of markup text +/// Types of markup text type MarkupText = - /// Markdown text + /// Markdown text | Markdown of string - /// HTML text + /// HTML text | Html of string - /// Parse a string into a MarkupText instance + /// Parse a string into a MarkupText instance + /// The string to be parsed + /// The MarkupText parsed from the string + /// If the string is not valid static member Parse(text: string) = match text with | _ when text.StartsWith "Markdown: " -> Markdown text[10..] | _ when text.StartsWith "HTML: " -> Html text[6..] | _ -> invalidArg (nameof text) $"Cannot derive type of text ({text})" - - /// The source type for the markup text + + /// The source type for the markup text member this.SourceType = match this with Markdown _ -> "Markdown" | Html _ -> "HTML" - /// The raw text, regardless of type + /// The raw text, regardless of type member this.Text = match this with Markdown text -> text | Html text -> text - - /// The string representation of the markup text + + /// override this.ToString() = $"{this.SourceType}: {this.Text}" - - /// The HTML representation of the markup text + + /// The HTML representation of the markup text + /// An HTML representation of the markup text member this.AsHtml() = match this with Markdown text -> Markdown.ToHtml(text, markdownPipeline) | Html text -> text -/// An item of metadata +/// An item of metadata [] type MetaItem = { - /// The name of the metadata value + /// The name of the metadata value Name: string - - /// The metadata value + + /// The metadata value Value: string } with - - /// An empty metadata item + + /// An empty metadata item static member Empty = { Name = ""; Value = "" } -/// A revision of a page or post +/// A revision of a page or post [] type Revision = { - /// When this revision was saved + /// When this revision was saved AsOf: Instant - /// The text of the revision + /// The text of the revision Text: MarkupText } with - - /// An empty revision + + /// An empty revision static member Empty = { AsOf = Noda.epoch; Text = Html "" } -/// A permanent link +/// A permanent link [] type Permalink = | Permalink of string - /// An empty permalink + /// An empty permalink static member Empty = Permalink "" - - /// The string value of this permalink + + /// override this.ToString() = match this with Permalink it -> it -/// An identifier for a page +/// An identifier for a page [] type PageId = | PageId of string - /// An empty page ID + /// An empty page ID static member Empty = PageId "" - - /// Create a new page ID + + /// Create a new page ID + /// A new page ID static member Create = newId >> PageId - - /// The string value of this page ID + + /// override this.ToString() = match this with PageId it -> it -/// PodcastIndex.org podcast:medium allowed values +/// PodcastIndex.org podcast:medium allowed values [] type PodcastMedium = | Podcast @@ -397,7 +424,10 @@ type PodcastMedium = | Newsletter | Blog - /// Parse a string into a podcast medium + /// Parse a string into a podcast medium + /// The string to be parsed + /// The PodcastMedium parsed from the string + /// If the string is not valid static member Parse medium = match medium with | "podcast" -> Podcast @@ -408,8 +438,8 @@ type PodcastMedium = | "newsletter" -> Newsletter | "blog" -> Blog | _ -> invalidArg (nameof medium) $"{medium} is not a valid podcast medium" - - /// The string value of this podcast medium + + /// override this.ToString() = match this with | Podcast -> "podcast" @@ -421,151 +451,163 @@ type PodcastMedium = | Blog -> "blog" -/// Statuses for posts +/// Statuses for posts [] type PostStatus = - /// The post should not be publicly available + /// The post should not be publicly available | Draft - /// The post is publicly viewable + /// The post is publicly viewable | Published - /// Parse a string into a post status + /// Parse a string into a post status + /// The string to be parsed + /// The PostStatus parsed from the string + /// If the string is not valid static member Parse status = match status with | "Draft" -> Draft | "Published" -> Published | _ -> invalidArg (nameof status) $"{status} is not a valid post status" - - /// The string representation of this post status + + /// The string representation of this post status override this.ToString() = match this with Draft -> "Draft" | Published -> "Published" -/// An identifier for a post +/// An identifier for a post [] type PostId = | PostId of string - /// An empty post ID + /// An empty post ID static member Empty = PostId "" - - /// Create a new post ID + + /// Create a new post ID + /// A new post ID static member Create = newId >> PostId - - /// Convert a post ID to a string + + /// override this.ToString() = match this with PostId it -> it -/// A redirection for a previously valid URL +/// A redirection for a previously valid URL [] type RedirectRule = { - /// The From string or pattern + /// The From string or pattern From: string - - /// The To string or pattern + + /// The To string or pattern To: string - - /// Whether to use regular expressions on this rule + + /// Whether to use regular expressions on this rule IsRegex: bool } with - - /// An empty redirect rule + + /// An empty redirect rule static member Empty = { From = ""; To = ""; IsRegex = false } -/// An identifier for a custom feed +/// An identifier for a custom feed [] type CustomFeedId = | CustomFeedId of string - /// An empty custom feed ID + /// An empty custom feed ID static member Empty = CustomFeedId "" - - /// Create a new custom feed ID + + /// Create a new custom feed ID + /// A new custom feed ID static member Create = newId >> CustomFeedId - - /// Convert a custom feed ID to a string + + /// override this.ToString() = match this with CustomFeedId it -> it -/// The source for a custom feed +/// The source for a custom feed type CustomFeedSource = - /// A feed based on a particular category + /// A feed based on a particular category | Category of CategoryId - /// A feed based on a particular tag + /// A feed based on a particular tag | Tag of string - - /// Parse a feed source from its string version + + /// Parse a feed source from its string version + /// The string to be parsed + /// The CustomFeedSource parsed from the string + /// If the string is not valid static member Parse(source: string) = let value (it : string) = it.Split(":").[1] match source with | _ when source.StartsWith "category:" -> (value >> CategoryId >> Category) source | _ when source.StartsWith "tag:" -> (value >> Tag) source | _ -> invalidArg (nameof source) $"{source} is not a valid feed source" - - /// Create a string version of a feed source + + /// override this.ToString() = match this with | Category (CategoryId catId) -> $"category:{catId}" | Tag tag -> $"tag:{tag}" -/// Options for a feed that describes a podcast +/// Options for a feed that describes a podcast [] type PodcastOptions = { - /// The title of the podcast + /// The title of the podcast Title: string - - /// A subtitle for the podcast + + /// A subtitle for the podcast Subtitle: string option - - /// The number of items in the podcast feed + + /// The number of items in the podcast feed ItemsInFeed: int - - /// A summary of the podcast (iTunes field) + + /// A summary of the podcast (iTunes field) Summary: string - - /// The display name of the podcast author (iTunes field) + + /// The display name of the podcast author (iTunes field) DisplayedAuthor: string - - /// The e-mail address of the user who registered the podcast at iTunes + + /// The e-mail address of the user who registered the podcast at iTunes Email: string - - /// The link to the image for the podcast + + /// The link to the image for the podcast ImageUrl: Permalink - - /// The category from Apple Podcasts (iTunes) under which this podcast is categorized + + /// The category from Apple Podcasts (iTunes) under which this podcast is categorized AppleCategory: string - + + /// /// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values) + /// AppleSubcategory: string option - - /// The explictness rating (iTunes field) + + /// The explictness rating (iTunes field) Explicit: ExplicitRating - - /// The default media type for files in this podcast + + /// The default media type for files in this podcast DefaultMediaType: string option - + + /// /// The base URL for relative URL media files for this podcast (optional; defaults to web log base) + /// MediaBaseUrl: string option - - /// A GUID for this podcast + + /// A GUID for this podcast PodcastGuid: Guid option - - /// A URL at which information on supporting the podcast may be found (supports permalinks) + + /// A URL at which information on supporting the podcast may be found (supports permalinks) FundingUrl: string option - - /// The text to be displayed in the funding item within the feed + + /// The text to be displayed in the funding item within the feed FundingText: string option - - /// The medium (what the podcast IS, not what it is ABOUT) + + /// The medium (what the podcast IS, not what it is ABOUT) Medium: PodcastMedium option } with - - /// A default set of podcast options + + /// A default set of podcast options static member Empty = { Title = "" Subtitle = None @@ -585,23 +627,23 @@ type PodcastOptions = { Medium = None } -/// A custom feed +/// A custom feed [] type CustomFeed = { - /// The ID of the custom feed + /// The ID of the custom feed Id: CustomFeedId - - /// The source for the custom feed + + /// The source for the custom feed Source: CustomFeedSource - - /// The path for the custom feed + + /// The path for the custom feed Path: Permalink - - /// Podcast options, if the feed defines a podcast + + /// Podcast options, if the feed defines a podcast Podcast: PodcastOptions option } with - - /// An empty custom feed + + /// An empty custom feed static member Empty = { Id = CustomFeedId.Empty Source = Category CategoryId.Empty @@ -609,32 +651,32 @@ type CustomFeed = { Podcast = None } -/// Really Simple Syndication (RSS) options for this web log +/// Really Simple Syndication (RSS) options for this web log [] type RssOptions = { - /// Whether the site feed of posts is enabled + /// Whether the site feed of posts is enabled IsFeedEnabled: bool - - /// The name of the file generated for the site feed + + /// The name of the file generated for the site feed FeedName: string - - /// Override the "posts per page" setting for the site feed + + /// Override the "posts per page" setting for the site feed ItemsInFeed: int option - - /// Whether feeds are enabled for all categories + + /// Whether feeds are enabled for all categories IsCategoryEnabled: bool - - /// Whether feeds are enabled for all tags + + /// Whether feeds are enabled for all tags IsTagEnabled: bool - - /// A copyright string to be placed in all feeds + + /// A copyright string to be placed in all feeds Copyright: string option - - /// Custom feeds for this web log + + /// Custom feeds for this web log CustomFeeds: CustomFeed list } with - - /// An empty set of RSS options + + /// An empty set of RSS options static member Empty = { IsFeedEnabled = true FeedName = "feed.xml" @@ -645,136 +687,146 @@ type RssOptions = { CustomFeeds = [] } -/// An identifier for a tag mapping +/// An identifier for a tag mapping [] type TagMapId = | TagMapId of string - /// An empty tag mapping ID + /// An empty tag mapping ID static member Empty = TagMapId "" - - /// Create a new tag mapping ID + + /// Create a new tag mapping ID + /// A new tag mapping ID static member Create = newId >> TagMapId - - /// Convert a tag mapping ID to a string + + /// override this.ToString() = match this with TagMapId it -> it -/// An identifier for a theme (represents its path) +/// An identifier for a theme (represents its path) [] type ThemeId = | ThemeId of string - - /// An empty theme ID + + /// An empty theme ID static member Empty = ThemeId "" - - /// The string representation of a theme ID + + /// override this.ToString() = match this with ThemeId it -> it -/// An identifier for a theme asset +/// An identifier for a theme asset [] type ThemeAssetId = | ThemeAssetId of ThemeId * string - /// An empty theme asset ID + /// An empty theme asset ID static member Empty = ThemeAssetId(ThemeId.Empty, "") - - /// Convert a string into a theme asset ID + + /// Convert a string into a theme asset ID + /// The string to be parsed + /// The ThemeAssetId parsed from the string + /// If the string is not valid static member Parse(it : string) = let themeIdx = it.IndexOf "/" if themeIdx < 0 then invalidArg "id" $"Invalid format; expected [theme_id]/[asset_id] (received {it})" else ThemeAssetId(ThemeId it[..(themeIdx - 1)], it[(themeIdx + 1)..]) - - /// Convert a theme asset ID into a path string + + /// override this.ToString() = match this with ThemeAssetId (ThemeId theme, asset) -> $"{theme}/{asset}" -/// A template for a theme +/// A template for a theme [] type ThemeTemplate = { - /// The name of the template + /// The name of the template Name: string - - /// The text of the template + + /// The text of the template Text: string } with - - /// An empty theme template + + /// An empty theme template static member Empty = { Name = ""; Text = "" } -/// Where uploads should be placed +/// Where uploads should be placed [] type UploadDestination = | Database | Disk - /// Parse an upload destination from its string representation + /// Parse an upload destination from its string representation + /// The string to be parsed + /// The UploadDestination parsed from the string + /// If the string is not valid static member Parse destination = match destination with | "Database" -> Database | "Disk" -> Disk | _ -> invalidArg (nameof destination) $"{destination} is not a valid upload destination" - - /// The string representation of an upload destination + + /// override this.ToString() = match this with Database -> "Database" | Disk -> "Disk" -/// An identifier for an upload +/// An identifier for an upload [] type UploadId = | UploadId of string - /// An empty upload ID + /// An empty upload ID static member Empty = UploadId "" - - /// Create a new upload ID + + /// Create a new upload ID + /// A new upload ID static member Create = newId >> UploadId - - /// The string representation of an upload ID + + /// override this.ToString() = match this with UploadId it -> it -/// An identifier for a web log +/// An identifier for a web log [] type WebLogId = | WebLogId of string - /// An empty web log ID + /// An empty web log ID static member Empty = WebLogId "" - - /// Create a new web log ID + + /// Create a new web log ID + /// A new web log ID static member Create = newId >> WebLogId - - /// Convert a web log ID to a string + + /// override this.ToString() = match this with WebLogId it -> it -/// An identifier for a web log user +/// An identifier for a web log user [] type WebLogUserId = | WebLogUserId of string - - /// An empty web log user ID + + /// An empty web log user ID static member Empty = WebLogUserId "" - - /// Create a new web log user ID + + /// Create a new web log user ID + /// A new web log user ID static member Create = newId >> WebLogUserId - - /// The string representation of a web log user ID + + /// override this.ToString() = match this with WebLogUserId it -> it diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 65935d5..3972f4e 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -5,21 +5,28 @@ open MyWebLog open NodaTime open NodaTime.Text -/// Helper functions for view models +/// Helper functions for view models [] module private Helpers = - - /// Create a string option if a string is blank + + /// Create a string option if a string is blank + /// The string in question + /// Some with the string if non-blank, None otherwise let noneIfBlank it = match (defaultArg (Option.ofObj it) "").Trim() with "" -> None | trimmed -> Some trimmed -/// Helper functions that are needed outside this file +/// Helper functions that are needed outside this file [] 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 + /// + /// The extra path required for a complete relative URL + /// The text in which URL conversion should occur + /// The text with all relative URLs expanded as required let addBaseToRelativeUrls extra (text: string) = if extra = "" then text else @@ -27,87 +34,90 @@ module PublicHelpers = .Replace("src=\"/", $"src=\"{extra}/").Replace("src=/", $"src={extra}/") -/// The model used to display the admin dashboard +/// The model used to display the admin dashboard [] type DashboardModel = { - /// The number of published posts + /// The number of published posts Posts: int - /// The number of post drafts + /// The number of post drafts Drafts: int - /// The number of pages + /// The number of pages Pages: int - /// The number of pages in the page list + /// The number of pages in the page list ListedPages: int - /// The number of categories + /// The number of categories Categories: int - /// The top-level categories + /// The top-level categories TopLevelCategories: int } -/// Details about a category, used to display category lists +/// Details about a category, used to display category lists [] type DisplayCategory = { - /// The ID of the category + /// The ID of the category Id: string - - /// The slug for the category + + /// The slug for the category Slug: string - - /// The name of the category + + /// The name of the category Name: string - - /// A description of the category + + /// A description of the category Description: string option - - /// The parent category names for this (sub)category + + /// The parent category names for this (sub)category ParentNames: string array - - /// The number of posts in this category + + /// The number of posts in this category PostCount: int } -/// Details about a page used to display page lists +/// Details about a page used to display page lists [] type DisplayPage = { - /// The ID of this page + /// The ID of this page Id: string - /// The ID of the author of this page + /// The ID of the author of this page AuthorId: string - - /// The title of the page + + /// The title of the page Title: string - /// The link at which this page is displayed + /// The link at which this page is displayed Permalink: string - /// When this page was published + /// When this page was published PublishedOn: DateTime - /// When this page was last updated + /// When this page was last updated UpdatedOn: DateTime - /// Whether this page shows as part of the web log's navigation + /// Whether this page shows as part of the web log's navigation IsInPageList: bool - - /// Is this the default page? + + /// Is this the default page? IsDefault: bool - - /// The text of the page + + /// The text of the page Text: string - - /// The metadata for the page + + /// The metadata for the page Metadata: MetaItem list } with - - /// Create a minimal display page (no text or metadata) from a database page + + /// Create a minimal display page (no text or metadata) from a database page + /// The web log to which the page belongs + /// The page to be created + /// A DisplayPage with no text or metadata static member FromPageMinimal (webLog: WebLog) (page: Page) = { Id = string page.Id AuthorId = string page.AuthorId @@ -119,8 +129,11 @@ type DisplayPage = { IsDefault = string page.Id = webLog.DefaultPage Text = "" Metadata = [] } - - /// Create a display page from a database page + + /// Create a display page from a database page + /// The web log to which the page belongs + /// The page to be created + /// A DisplayPage with text and metadata static member FromPage webLog page = { DisplayPage.FromPageMinimal webLog page with Text = addBaseToRelativeUrls webLog.ExtraPath page.Text @@ -130,29 +143,32 @@ type DisplayPage = { open System.IO -/// Information about a theme used for display +/// Information about a theme used for display [] type DisplayTheme = { - /// The ID / path slug of the theme + /// The ID / path slug of the theme Id: string - - /// The name of the theme + + /// The name of the theme Name: string - - /// The version of the theme + + /// The version of the theme Version: string - - /// How many templates are contained in the theme + + /// How many templates are contained in the theme TemplateCount: int - - /// Whether the theme is in use by any web logs + + /// Whether the theme is in use by any web logs IsInUse: bool - - /// Whether the theme .zip file exists on the filesystem + + /// Whether the theme .zip file exists on the filesystem IsOnDisk: bool } with - - /// Create a display theme from a theme + + /// Create a display theme from a theme + /// The function to use to determine if this theme is actively in use + /// The theme from which the display version should be created + /// A populated DisplayTheme instance static member FromTheme inUseFunc (theme: Theme) = let fileName = if string theme.Id = "default" then "default-theme.zip" @@ -165,26 +181,30 @@ type DisplayTheme = { IsOnDisk = File.Exists fileName } -/// Information about an uploaded file used for display +/// Information about an uploaded file used for display [] type DisplayUpload = { - /// The ID of the uploaded file + /// The ID of the uploaded file Id: string - - /// The name of the uploaded file + + /// The name of the uploaded file Name: string - - /// The path at which the file is served + + /// The path at which the file is served Path: string - - /// The date/time the file was updated + + /// The date/time the file was updated UpdatedOn: DateTime option - - /// The source for this file (created from UploadDestination DU) + + /// The source for this file (created from UploadDestination DU) Source: string } with - - /// Create a display uploaded file + + /// Create a display uploaded file + /// The web log to which the upload belongs + /// The destination for the uploaded file + /// The uploaded file + /// A populated DisplayUpload instance static member FromUpload (webLog: WebLog) (source: UploadDestination) (upload: Upload) = let path = string upload.Path let name = Path.GetFileName path @@ -195,79 +215,85 @@ type DisplayUpload = { Source = string source } -/// View model for editing categories +/// View model for editing categories [] type EditCategoryModel = { - /// The ID of the category being edited + /// The ID of the category being edited CategoryId: string - - /// The name of the category + + /// The name of the category Name: string - - /// The category's URL slug + + /// The category's URL slug Slug: string - - /// A description of the category (optional) + + /// A description of the category (optional) Description: string - - /// The ID of the category for which this is a subcategory (optional) + + /// The ID of the category for which this is a subcategory (optional) ParentId: string } with - - /// Create an edit model from an existing category + + /// Create an edit model from an existing category + /// The category + /// A populated DisplayCategory instance 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 "" } - - /// Is this a new category? + + /// Is this a new category? member this.IsNew = this.CategoryId = "new" -/// View model to add/edit an episode chapter +/// View model to add/edit an episode chapter [] type EditChapterModel = { - /// The ID of the post to which the chapter belongs + /// The ID of the post to which the chapter belongs PostId: string - - /// The index in the chapter list (-1 means new) + + /// The index in the chapter list (-1 means new) Index: int - - /// The start time of the chapter (H:mm:ss.FF format) + + /// The start time of the chapter (H:mm:ss.FF format) StartTime: string - - /// The title of the chapter + + /// The title of the chapter Title: string - - /// An image to display for this chapter + + /// An image to display for this chapter ImageUrl: string - - /// A URL with information about this chapter + + /// A URL with information about this chapter Url: string - - /// Whether this chapter should be displayed in podcast players + + /// Whether this chapter should be displayed in podcast players IsHidden: bool - - /// The end time of the chapter (HH:MM:SS.FF format) + + /// The end time of the chapter (HH:MM:SS.FF format) EndTime: string - - /// The name of a location + + /// The name of a location LocationName: string - - /// The geographic coordinates of the location + + /// The geographic coordinates of the location LocationGeo: string - - /// An OpenStreetMap query for this location + + /// An OpenStreetMap query for this location LocationOsm: string - - /// Whether to add another chapter after adding this one + + /// Whether to add another chapter after adding this one AddAnother: bool } with - /// Create a display chapter from a chapter + /// Create a display chapter from a chapter + /// The ID of the post to which this chapter belongs + /// The index of the chapter being referenced + /// The chapter information + /// A populated EditChapterModel instance static member FromChapter (postId: PostId) idx (chapter: Chapter) = let pattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF" { PostId = string postId @@ -282,8 +308,9 @@ type EditChapterModel = { LocationGeo = chapter.Location |> Option.map _.Geo |> Option.defaultValue "" LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue "" AddAnother = false } - - /// Create a chapter from the values in this model + + /// Create a chapter from the values in this model + /// A Chapter with values from this model member this.ToChapter () = let parseDuration name value = let pattern = @@ -309,47 +336,48 @@ type EditChapterModel = { Location = location } -/// View model common to page and post edits +/// View model common to page and post edits type EditCommonModel() = - - /// Find the latest revision within a list of revisions + + /// Find the latest revision within a list of revisions let findLatestRevision (revs: Revision list) = match revs |> List.sortByDescending _.AsOf |> List.tryHead with Some rev -> rev | None -> Revision.Empty - - /// The ID of the page or post + + /// The ID of the page or post member val Id = "" with get, set - - /// The title of the page or post + + /// The title of the page or post member val Title = "" with get, set - - /// The permalink for the page or post + + /// The permalink for the page or post member val Permalink = "" with get, set - - /// The entity to which this model applies ("page" or "post") + + /// The entity to which this model applies ("page" or "post") member val Entity = "" with get, set - - /// Whether to provide a link to manage chapters + + /// Whether to provide a link to manage chapters member val IncludeChapterLink = false with get, set - - /// The template to use to display the page + + /// The template to use to display the page member val Template = "" with get, set - - /// The source type ("HTML" or "Markdown") + + /// The source type ("HTML" or "Markdown") member val Source = "" with get, set - - /// The text of the page or post + + /// The text of the page or post member val Text = "" with get, set - - /// Names of metadata items + + /// Names of metadata items member val MetaNames: string array = [||] with get, set - - /// Values of metadata items + + /// Values of metadata items member val MetaValues: string array = [||] with get, set - - /// Whether this is a new page or post + + /// Whether this is a new page or post member this.IsNew with get () = this.Id = "new" - - /// Fill the properties of this object from a page + + /// Fill the properties of this object from a page + /// The page from which this model should be populated member this.PopulateFromPage (page: Page) = let latest = findLatestRevision page.Revisions let metaItems = if page.Metadata.Length = 0 then [ MetaItem.Empty ] else page.Metadata @@ -362,8 +390,9 @@ type EditCommonModel() = this.Text <- latest.Text.Text this.MetaNames <- metaItems |> List.map _.Name |> Array.ofList this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList - - /// Fill the properties of this object from a post + + /// Fill the properties of this object from a post + /// The post from which this model should be populated member this.PopulateFromPost (post: Post) = let latest = findLatestRevision post.Revisions let metaItems = if post.Metadata.Length = 0 then [ MetaItem.Empty ] else post.Metadata @@ -379,74 +408,74 @@ type EditCommonModel() = this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList -/// View model to edit a custom RSS feed +/// View model to edit a custom RSS feed [] type EditCustomFeedModel = { - /// The ID of the feed being editing + /// The ID of the feed being editing Id: string - - /// The type of source for this feed ("category" or "tag") + + /// The type of source for this feed ("category" or "tag") SourceType: string - - /// The category ID or tag on which this feed is based + + /// The category ID or tag on which this feed is based SourceValue: string - - /// The relative path at which this feed is served + + /// The relative path at which this feed is served Path: string - - /// Whether this feed defines a podcast + + /// Whether this feed defines a podcast IsPodcast: bool - - /// The title of the podcast + + /// The title of the podcast Title: string - - /// A subtitle for the podcast + + /// A subtitle for the podcast Subtitle: string - - /// The number of items in the podcast feed + + /// The number of items in the podcast feed ItemsInFeed: int - - /// A summary of the podcast (iTunes field) + + /// A summary of the podcast (iTunes field) Summary: string - - /// The display name of the podcast author (iTunes field) + + /// The display name of the podcast author (iTunes field) DisplayedAuthor: string - - /// The e-mail address of the user who registered the podcast at iTunes + + /// The e-mail address of the user who registered the podcast at iTunes Email: string - - /// The link to the image for the podcast + + /// The link to the image for the podcast ImageUrl: string - - /// The category from Apple Podcasts (iTunes) under which this podcast is categorized + + /// The category from Apple Podcasts (iTunes) under which this podcast is categorized AppleCategory: string - - /// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values) + + /// A further refinement of the categorization of this podcast (Apple Podcasts/iTunes field / values) AppleSubcategory: string - - /// The explictness rating (iTunes field) + + /// The explictness rating (iTunes field) Explicit: string - - /// The default media type for files in this podcast + + /// The default media type for files in this podcast DefaultMediaType: string - - /// The base URL for relative URL media files for this podcast (optional; defaults to web log base) + + /// 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 + + /// The URL for funding information for the podcast FundingUrl: string - - /// The text for the funding link + + /// The text for the funding link FundingText: string - - /// A unique identifier to follow this podcast + + /// A unique identifier to follow this podcast PodcastGuid: string - - /// The medium for the content of this podcast + + /// The medium for the content of this podcast Medium: string } with - - /// An empty custom feed model + + /// An empty custom feed model static member Empty = { Id = "" SourceType = "category" @@ -469,8 +498,10 @@ type EditCustomFeedModel = { FundingText = "" PodcastGuid = "" Medium = "" } - - /// Create a model from a custom feed + + /// Create a model from a custom feed + /// The feed from which the model should be created + /// A populated EditCustomFeedModel instance static member FromFeed (feed: CustomFeed) = let rss = { EditCustomFeedModel.Empty with @@ -499,8 +530,10 @@ type EditCustomFeedModel = { PodcastGuid = p.PodcastGuid |> Option.map _.ToString().ToLowerInvariant() |> Option.defaultValue "" Medium = p.Medium |> Option.map string |> Option.defaultValue "" } | None -> rss - - /// Update a feed with values from this model + + /// Update a feed with values from this model + /// The feed to be updated + /// The feed, updated with values from this model member this.UpdateFeed (feed: CustomFeed) = { feed with Source = if this.SourceType = "tag" then Tag this.SourceValue else Category (CategoryId this.SourceValue) @@ -528,26 +561,28 @@ type EditCustomFeedModel = { None } -/// View model for a user to edit their own information +/// View model for a user to edit their own information [] type EditMyInfoModel = { - /// The user's first name + /// The user's first name FirstName: string - - /// The user's last name + + /// The user's last name LastName: string - - /// The user's preferred name + + /// The user's preferred name PreferredName: string - - /// A new password for the user + + /// A new password for the user NewPassword: string - - /// A new password for the user, confirmed + + /// A new password for the user, confirmed NewPasswordConfirm: string } with - - /// Create an edit model from a user + + /// Create an edit model from a user + /// The user being edited + /// A populated EditMyInfoModel instance static member FromUser (user: WebLogUser) = { FirstName = user.FirstName LastName = user.LastName @@ -556,21 +591,26 @@ type EditMyInfoModel = { NewPasswordConfirm = "" } -/// View model to edit a page +/// View model to edit a page type EditPageModel() = inherit EditCommonModel() - - /// Whether this page is shown in the page list + + /// Whether this page is shown in the page list member val IsShownInPageList = false with get, set - /// Create an edit model from an existing page + /// Create an edit model from an existing page + /// The page from which the model should be generated + /// A populated EditPageModel instance static member FromPage(page: Page) = let model = EditPageModel() model.PopulateFromPage page model.IsShownInPageList <- page.IsInPageList model - - /// Update a page with values from this model + + /// Update a page with values from this model + /// The page to be updated + /// The Instant to use for this particular update + /// The page, updated with the values from this model 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 @@ -597,92 +637,101 @@ type EditPageModel() = | _ -> revision :: page.Revisions } -/// View model to edit a post +/// View model to edit a post type EditPostModel() = inherit EditCommonModel() - - /// The tags for the post + + /// The tags for the post member val Tags = "" with get, set - - /// The category IDs for the post + + /// The category IDs for the post member val CategoryIds: string array = [||] with get, set - - /// The post status + + /// The post status member val Status = "" with get, set - - /// Whether this post should be published + + /// Whether this post should be published member val DoPublish = false with get, set - - /// Whether to override the published date/time + + /// Whether to override the published date/time member val SetPublished = false with get, set - - /// The published date/time to override + + /// The published date/time to override member val PubOverride = Nullable() with get, set - - /// Whether all revisions should be purged and the override date set as the updated date as well + + /// Whether all revisions should be purged and the override date set as the updated date as well member val SetUpdated = false with get, set - - /// Whether this post has a podcast episode + + /// Whether this post has a podcast episode member val IsEpisode = false with get, set - - /// The URL for the media for this episode (may be permalink) + + /// The URL for the media for this episode (may be permalink) member val Media = "" with get, set - - /// The size (in bytes) of the media for this episode + + /// The size (in bytes) of the media for this episode member val Length = 0L with get, set - - /// The duration of the media for this episode + + /// The duration of the media for this episode member val Duration = "" with get, set - - /// The media type (optional, defaults to podcast-defined media type) + + /// The media type (optional, defaults to podcast-defined media type) member val MediaType = "" with get, set - + + /// /// The URL for the image for this episode (may be permalink; optional, defaults to podcast image) + /// member val ImageUrl = "" with get, set - - /// A subtitle for the episode (optional) + + /// A subtitle for the episode (optional) member val Subtitle = "" with get, set - - /// The explicit rating for this episode (optional, defaults to podcast setting) + + /// The explicit rating for this episode (optional, defaults to podcast setting) member val Explicit = "" with get, set - + + /// /// The chapter source ("internal" for chapters defined here, "external" for a file link, "none" if none defined) + /// member val ChapterSource = "" with get, set - - /// The URL for the chapter file for the episode (may be permalink; optional) + + /// The URL for the chapter file for the episode (may be permalink; optional) member val ChapterFile = "" with get, set - + + /// /// The type of the chapter file (optional; defaults to application/json+chapters if chapterFile is provided) + /// member val ChapterType = "" with get, set - - /// Whether the chapter file (or chapters) contains/contain waypoints + + /// Whether the chapter file (or chapters) contains/contain waypoints member val ContainsWaypoints = false with get, set - - /// The URL for the transcript (may be permalink; optional) + + /// The URL for the transcript (may be permalink; optional) member val TranscriptUrl = "" with get, set - - /// The MIME type for the transcript (optional, recommended if transcriptUrl is provided) + + /// The MIME type for the transcript (optional, recommended if transcriptUrl is provided) member val TranscriptType = "" with get, set - - /// The language of the transcript (optional) + + /// The language of the transcript (optional) member val TranscriptLang = "" with get, set - - /// Whether the provided transcript should be presented as captions + + /// Whether the provided transcript should be presented as captions member val TranscriptCaptions = false with get, set - - /// The season number (optional) + + /// The season number (optional) member val SeasonNumber = 0 with get, set - - /// A description of this season (optional, ignored if season number is not provided) + + /// A description of this season (optional, ignored if season number is not provided) member val SeasonDescription = "" with get, set - - /// The episode number (decimal; optional) + + /// The episode number (decimal; optional) member val EpisodeNumber = "" with get, set - - /// A description of this episode (optional, ignored if episode number is not provided) + + /// A description of this episode (optional, ignored if episode number is not provided) member val EpisodeDescription = "" with get, set - - /// Create an edit model from an existing past + + /// Create an edit model from an existing past + /// The web log to which this post belongs + /// The post from which this model should be created + /// A populated EditPostModel instance static member FromPost (webLog: WebLog) (post: Post) = let model = EditPostModel() model.PopulateFromPost post @@ -711,11 +760,14 @@ type EditPostModel() = 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.EpisodeNumber <- defaultArg (episode.EpisodeNumber |> Option.map string) "" model.EpisodeDescription <- defaultArg episode.EpisodeDescription "" model - - /// Update a post with values from the submitted form + + /// Update a post with values from the submitted form + /// The post which should be updated + /// The Instant to use for this particular update + /// The post, updated with the values from this model 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 @@ -788,63 +840,69 @@ type EditPostModel() = None } -/// View model to add/edit a redirect rule +/// View model to add/edit a redirect rule [] type EditRedirectRuleModel = { - /// The ID (index) of the rule being edited + /// The ID (index) of the rule being edited RuleId: int - /// The "from" side of the rule + /// The "from" side of the rule From: string - /// The "to" side of the rule + /// The "to" side of the rule To: string - /// Whether this rule uses a regular expression + /// 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) + /// Whether a new rule should be inserted at the top or appended to the end (ignored for edits) InsertAtTop: bool } with - /// Create a model from an existing rule + /// Create a model from an existing rule + /// The index (within the list for the web log) for this redirect rule + /// The redirect rule being modified + /// A populated EditRedirectRuleModel instance 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 + + /// Convert the values in this model to a redirect rule + /// A redirect rule from this model member this.ToRule() = { From = this.From To = this.To IsRegex = this.IsRegex } -/// View model to edit RSS settings +/// View model to edit RSS settings [] type EditRssModel = { - /// Whether the site feed of posts is enabled + /// Whether the site feed of posts is enabled IsFeedEnabled: bool - - /// The name of the file generated for the site feed + + /// The name of the file generated for the site feed FeedName: string - - /// Override the "posts per page" setting for the site feed + + /// Override the "posts per page" setting for the site feed ItemsInFeed: int - - /// Whether feeds are enabled for all categories + + /// Whether feeds are enabled for all categories IsCategoryEnabled: bool - - /// Whether feeds are enabled for all tags + + /// Whether feeds are enabled for all tags IsTagEnabled: bool - - /// A copyright string to be placed in all feeds + + /// A copyright string to be placed in all feeds Copyright: string } with - - /// Create an edit model from a set of RSS options + + /// Create an edit model from a set of RSS options + /// The RSS options being edited + /// A populated EditRssModel instance static member FromRssOptions (rss: RssOptions) = { IsFeedEnabled = rss.IsFeedEnabled FeedName = rss.FeedName @@ -852,8 +910,10 @@ type EditRssModel = { IsCategoryEnabled = rss.IsCategoryEnabled IsTagEnabled = rss.IsTagEnabled Copyright = defaultArg rss.Copyright "" } - - /// Update RSS options from values in this model + + /// Update RSS options from values in this model + /// The RSS options to be updated + /// The RSS options, updated from values in this model member this.UpdateOptions (rss: RssOptions) = { rss with IsFeedEnabled = this.IsFeedEnabled @@ -864,62 +924,66 @@ type EditRssModel = { Copyright = noneIfBlank this.Copyright } -/// View model to edit a tag mapping +/// View model to edit a tag mapping [] type EditTagMapModel = { - /// The ID of the tag mapping being edited + /// The ID of the tag mapping being edited Id: string - - /// The tag being mapped to a different link value + + /// The tag being mapped to a different link value Tag: string - - /// The link value for the tag + + /// The link value for the tag UrlValue: string } with - - /// Create an edit model from the tag mapping + + /// Create an edit model from the tag mapping + /// The tag mapping being edited + /// A populated EditTagMapModel instance static member FromMapping (tagMap: TagMap) : EditTagMapModel = { Id = string tagMap.Id Tag = tagMap.Tag UrlValue = tagMap.UrlValue } - - /// Whether this is a new tag mapping + + /// Whether this is a new tag mapping member this.IsNew = this.Id = "new" -/// View model to display a user's information +/// View model to display a user's information [] type EditUserModel = { - /// The ID of the user + /// The ID of the user Id: string - /// The user's access level + /// The user's access level AccessLevel: string - - /// The user name (e-mail address) + + /// The user name (e-mail address) Email: string - /// The URL of the user's personal site + /// The URL of the user's personal site Url: string - /// The user's first name + /// The user's first name FirstName: string - /// The user's last name + /// The user's last name LastName: string - /// The user's preferred name + /// The user's preferred name PreferredName: string - - /// The user's password + + /// The user's password Password: string - - /// Confirmation of the user's password + + /// Confirmation of the user's password PasswordConfirm: string } with - - /// Construct a user edit form from a web log user + + /// Construct a user edit form from a web log user + /// The user being edited + /// A populated EditUserModel instance static member FromUser (user: WebLogUser) = { Id = string user.Id AccessLevel = string user.AccessLevel @@ -930,12 +994,14 @@ type EditUserModel = { PreferredName = user.PreferredName Password = "" PasswordConfirm = "" } - - /// Is this a new user? + + /// Is this a new user? member this.IsNew = this.Id = "new" - - /// Update a user with values from this model (excludes password) + + /// Update a user with values from this model (excludes password) + /// The user to be updated + /// The user, updated with values from this model member this.UpdateUser (user: WebLogUser) = { user with AccessLevel = AccessLevel.Parse this.AccessLevel @@ -946,64 +1012,68 @@ type EditUserModel = { PreferredName = this.PreferredName } -/// The model to use to allow a user to log on +/// The model to use to allow a user to log on [] type LogOnModel = { - /// The user's e-mail address + /// The user's e-mail address EmailAddress : string - /// The user's password + /// The user's password Password : string - - /// Where the user should be redirected once they have logged on + + /// Where the user should be redirected once they have logged on ReturnTo : string option } with - - /// An empty log on model + + /// An empty log on model static member Empty = { EmailAddress = ""; Password = ""; ReturnTo = None } -/// View model to manage chapters +/// View model to manage chapters [] type ManageChaptersModel = { - /// The post ID for the chapters being edited + /// The post ID for the chapters being edited Id: string - - /// The title of the post for which chapters are being edited + + /// The title of the post for which chapters are being edited Title: string - - /// The chapters for the post + + /// The chapters for the post Chapters: Chapter list } with - - /// Create a model from a post and its episode's chapters + + /// Create a model from a post and its episode's chapters + /// The post from which this model should be created + /// A populated ManageChaptersModel instance static member Create (post: Post) = { Id = string post.Id Title = post.Title Chapters = post.Episode.Value.Chapters.Value } - -/// View model to manage permalinks + +/// View model to manage permalinks [] type ManagePermalinksModel = { - /// The ID for the entity being edited + /// The ID for the entity being edited Id: string - - /// The type of entity being edited ("page" or "post") + + /// The type of entity being edited ("page" or "post") Entity: string - - /// The current title of the page or post + + /// The current title of the page or post CurrentTitle: string - - /// The current permalink of the page or post + + /// The current permalink of the page or post CurrentPermalink: string - - /// The prior permalinks for the page or post + + /// The prior permalinks for the page or post Prior: string array } with - - /// Create a permalink model from a page + + /// Create a permalink model from a page + /// The page from which the model should be created + /// A populated ManagePermalinksModel instance for the given Page static member FromPage (page: Page) = { Id = string page.Id Entity = "page" @@ -1011,7 +1081,9 @@ type ManagePermalinksModel = { CurrentPermalink = string page.Permalink Prior = page.PriorPermalinks |> List.map string |> Array.ofList } - /// Create a permalink model from a post + /// Create a permalink model from a post + /// The post from which the model should be created + /// A populated ManagePermalinksModel instance for the given Post static member FromPost (post: Post) = { Id = string post.Id Entity = "post" @@ -1020,30 +1092,34 @@ type ManagePermalinksModel = { Prior = post.PriorPermalinks |> List.map string |> Array.ofList } -/// View model to manage revisions +/// View model to manage revisions [] type ManageRevisionsModel = { - /// The ID for the entity being edited + /// The ID for the entity being edited Id: string - - /// The type of entity being edited ("page" or "post") + + /// The type of entity being edited ("page" or "post") Entity: string - - /// The current title of the page or post + + /// The current title of the page or post CurrentTitle: string - - /// The revisions for the page or post + + /// The revisions for the page or post Revisions: Revision list } with - - /// Create a revision model from a page + + /// Create a revision model from a page + /// The page from which the model should be created + /// A populated ManageRevisionsModel instance for the given Page static member FromPage (page: Page) = { Id = string page.Id Entity = "page" CurrentTitle = page.Title Revisions = page.Revisions } - /// Create a revision model from a post + /// Create a revision model from a post + /// The post from which the model should be created + /// A populated ManageRevisionsModel instance for the given Post static member FromPost (post: Post) = { Id = string post.Id Entity = "post" @@ -1051,47 +1127,50 @@ type ManageRevisionsModel = { Revisions = post.Revisions } -/// View model for posts in a list +/// View model for posts in a list [] type PostListItem = { - /// The ID of the post + /// The ID of the post Id: string - - /// The ID of the user who authored the post + + /// The ID of the user who authored the post AuthorId: string - - /// The status of the post + + /// The status of the post Status: string - - /// The title of the post + + /// The title of the post Title: string - - /// The permalink for the post + + /// The permalink for the post Permalink: string - - /// When this post was published + + /// When this post was published PublishedOn: Nullable - - /// When this post was last updated + + /// When this post was last updated UpdatedOn: DateTime - - /// The text of the post + + /// The text of the post Text: string - - /// The IDs of the categories for this post + + /// The IDs of the categories for this post CategoryIds: string list - - /// Tags for the post + + /// Tags for the post Tags: string list - - /// The podcast episode information for this post + + /// The podcast episode information for this post Episode: Episode option - - /// Metadata for the post + + /// Metadata for the post Metadata: MetaItem list } with - /// Create a post list item from a post + /// Create a post list item from a post + /// The web log to which the post belongs + /// The post from which the model should be created + /// A populated PostListItem instance static member FromPost (webLog: WebLog) (post: Post) = { Id = string post.Id AuthorId = string post.AuthorId @@ -1107,63 +1186,65 @@ type PostListItem = { Metadata = post.Metadata } -/// View model for displaying posts +/// View model for displaying posts type PostDisplay = { - /// The posts to be displayed + /// The posts to be displayed Posts: PostListItem array - - /// Author ID -> name lookup + + /// Author ID -> name lookup Authors: MetaItem list - - /// A subtitle for the page + + /// A subtitle for the page Subtitle: string option - - /// The link to view newer (more recent) posts + + /// The link to view newer (more recent) posts NewerLink: string option - - /// The name of the next newer post (single-post only) + + /// The name of the next newer post (single-post only) NewerName: string option - - /// The link to view older (less recent) posts + + /// The link to view older (less recent) posts OlderLink: string option - - /// The name of the next older post (single-post only) + + /// The name of the next older post (single-post only) OlderName: string option } -/// View model for editing web log settings +/// View model for editing web log settings [] type SettingsModel = { - /// The name of the web log + /// The name of the web log Name: string - /// The slug of the web log + /// The slug of the web log Slug: string - - /// The subtitle of the web log + + /// The subtitle of the web log Subtitle: string - /// The default page + /// The default page DefaultPage: string - /// How many posts should appear on index pages + /// How many posts should appear on index pages PostsPerPage: int - /// The time zone in which dates/times should be displayed + /// The time zone in which dates/times should be displayed TimeZone: string - - /// The theme to use to display the web log + + /// The theme to use to display the web log ThemeId: string - - /// Whether to automatically load htmx + + /// Whether to automatically load htmx AutoHtmx: bool - - /// The default location for uploads + + /// The default location for uploads Uploads: string } with - - /// Create a settings model from a web log + + /// Create a settings model from a web log + /// The web log from which this model should be created + /// A populated SettingsModel instance static member FromWebLog(webLog: WebLog) = { Name = webLog.Name Slug = webLog.Slug @@ -1174,8 +1255,10 @@ type SettingsModel = { ThemeId = string webLog.ThemeId AutoHtmx = webLog.AutoHtmx Uploads = string webLog.Uploads } - - /// Update a web log with settings from the form + + /// Update a web log with settings from the form + /// The web log to be updated + /// The web log, updated with the value from this model member this.Update(webLog: WebLog) = { webLog with Name = this.Name @@ -1189,46 +1272,46 @@ type SettingsModel = { Uploads = UploadDestination.Parse this.Uploads } -/// View model for uploading a file +/// View model for uploading a file [] type UploadFileModel = { - /// The upload destination + /// The upload destination Destination : string } -/// View model for uploading a theme +/// View model for uploading a theme [] type UploadThemeModel = { - /// Whether the uploaded theme should overwrite an existing theme + /// Whether the uploaded theme should overwrite an existing theme DoOverwrite : bool } -/// A message displayed to the user +/// A message displayed to the user [] type UserMessage = { - /// The level of the message + /// The level of the message Level: string - - /// The message + + /// The message Message: string - - /// Further details about the message + + /// Further details about the message Detail: string option } with - - /// An empty user message (use one of the others for pre-filled level) + + /// An empty user message (use one of the others for pre-filled level) static member Empty = { Level = ""; Message = ""; Detail = None } - - /// A blank success message + + /// A blank success message static member Success = { UserMessage.Empty with Level = "success" } - - /// A blank informational message + + /// A blank informational message static member Info = { UserMessage.Empty with Level = "primary" } - - /// A blank warning message + + /// A blank warning message static member Warning = { UserMessage.Empty with Level = "warning" } - - /// A blank error message + + /// A blank error message static member Error = { UserMessage.Empty with Level = "danger" } diff --git a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj b/src/MyWebLog.Tests/MyWebLog.Tests.fsproj index 64b9795..934f55d 100644 --- a/src/MyWebLog.Tests/MyWebLog.Tests.fsproj +++ b/src/MyWebLog.Tests/MyWebLog.Tests.fsproj @@ -28,7 +28,7 @@ - + diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index 4beb388..7e3351e 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -3,7 +3,7 @@ open Microsoft.AspNetCore.Http open MyWebLog.Data -/// Extension properties on HTTP context for web log +/// Extension properties on HTTP context for web log [] module Extensions = @@ -17,16 +17,16 @@ module Extensions = type HttpContext with - /// The anti-CSRF service + /// The anti-CSRF service member this.AntiForgery = this.RequestServices.GetRequiredService() - /// The cross-site request forgery token set for this request + /// The cross-site request forgery token set for this request member this.CsrfTokenSet = this.AntiForgery.GetAndStoreTokens this - /// The data implementation + /// The data implementation member this.Data = this.RequestServices.GetRequiredService() - /// The generator string + /// The generator string member this.Generator = match generatorString with | Some gen -> gen @@ -38,20 +38,22 @@ module Extensions = | None -> Some "generator not configured" generatorString.Value - /// The access level for the current user + /// The access level for the current user member this.UserAccessLevel = this.User.Claims |> Seq.tryFind (fun claim -> claim.Type = ClaimTypes.Role) |> Option.map (fun claim -> AccessLevel.Parse claim.Value) - /// The user ID for the current request + /// The user ID for the current request member this.UserId = WebLogUserId (this.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value - /// The web log for the current request + /// The web log for the current request member this.WebLog = this.Items["webLog"] :?> WebLog - /// Does the current user have the requested level of access? + /// Does the current user have the required level of access? + /// The required level of access + /// True if the user has the required access, false if not member this.HasAccessLevel level = defaultArg (this.UserAccessLevel |> Option.map _.HasAccess(level)) false @@ -67,11 +69,11 @@ module WebLogCache = open System.Text.RegularExpressions - /// A redirect rule that caches compiled regular expression rules + /// A redirect rule that caches compiled regular expression rules type CachedRedirectRule = - /// A straight text match rule + /// A straight text match rule | Text of string * string - /// A regular expression match rule + /// A regular expression match rule | RegEx of Regex * string /// The cache of web log details @@ -80,14 +82,17 @@ module WebLogCache = /// Redirect rules with compiled regular expressions let mutable private _redirectCache = ConcurrentDictionary() - /// Try to get the web log for the current request (longest matching URL base wins) - let tryGet (path : string) = + /// Try to get the web log for the current request (longest matching URL base wins) + /// The path for the current request + /// Some with the web log matching the URL, or None if none is found + let tryGet (path: string) = _cache |> List.filter (fun wl -> path.StartsWith wl.UrlBase) |> List.sortByDescending _.UrlBase.Length |> List.tryHead - /// Cache the web log for a particular host + /// Cache the web log for a particular host + /// The web log to be cached let set webLog = _cache <- webLog :: (_cache |> List.filter (fun wl -> wl.Id <> webLog.Id)) _redirectCache[webLog.Id] <- @@ -101,26 +106,32 @@ module WebLogCache = else Text(relUrl it.From, urlTo)) - /// Get all cached web logs + /// Get all cached web logs + /// All cached web logs let all () = _cache - /// Fill the web log cache from the database + /// Fill the web log cache from the database + /// The data implementation from which web logs will be retrieved let fill (data: IData) = backgroundTask { let! webLogs = data.WebLog.All() webLogs |> List.iter set } - /// Get the cached redirect rules for the given web log + /// Get the cached redirect rules for the given web log + /// The ID of the web log for which rules should be retrieved + /// The redirect rules for the given web log ID let redirectRules webLogId = _redirectCache[webLogId] - /// Is the given theme in use by any web logs? + /// Is the given theme in use by any web logs? + /// The ID of the theme whose use should be checked + /// True if any web logs are using the given theme, false if not let isThemeInUse themeId = _cache |> List.exists (fun wl -> wl.ThemeId = themeId) -/// A cache of page information needed to display the page list in templates +/// A cache of page information needed to display the page list in templates module PageListCache = open MyWebLog.ViewModels @@ -128,32 +139,38 @@ module PageListCache = /// Cache of displayed pages let private _cache = ConcurrentDictionary() + /// Fill the page list for the given web log let private fillPages (webLog: WebLog) pages = _cache[webLog.Id] <- pages |> List.map (fun pg -> DisplayPage.FromPage webLog { pg with Text = "" }) |> Array.ofList - /// Are there pages cached for this web log? + /// Are there pages cached for this web log? + /// The HttpContext for the current request + /// True if the current web log has any pages cached, false if not let exists (ctx: HttpContext) = _cache.ContainsKey ctx.WebLog.Id - /// Get the pages for the web log for this request + /// Get the pages for the web log for this request + /// The HttpContext for the current request + /// The page list for the current web log let get (ctx: HttpContext) = _cache[ctx.WebLog.Id] - /// Update the pages for the current web log - let update (ctx: HttpContext) = backgroundTask { - let! pages = ctx.Data.Page.FindListed ctx.WebLog.Id - fillPages ctx.WebLog pages - } - - /// Refresh the pages for the given web log + /// Refresh the pages for the given web log + /// The web log for which pages should be refreshed + /// The data implementation from which pages should be retrieved let refresh (webLog: WebLog) (data: IData) = backgroundTask { let! pages = data.Page.FindListed webLog.Id fillPages webLog pages } + /// Update the pages for the current web log + /// The HttpContext for the current request + let update (ctx: HttpContext) = + refresh ctx.WebLog ctx.Data -/// Cache of all categories, indexed by web log + +/// Cache of all categories, indexed by web log module CategoryCache = open MyWebLog.ViewModels @@ -161,41 +178,51 @@ module CategoryCache = /// The cache itself let private _cache = ConcurrentDictionary() - /// Are there categories cached for this web log? + /// Are there categories cached for this web log? + /// The HttpContext for the current request + /// True if the current web logs has any categories cached, false if not let exists (ctx: HttpContext) = _cache.ContainsKey ctx.WebLog.Id - /// Get the categories for the web log for this request + /// Get the categories for the web log for this request + /// The HttpContext for the current request + /// The categories for the current web log let get (ctx: HttpContext) = _cache[ctx.WebLog.Id] - /// Update the cache with fresh data - let update (ctx: HttpContext) = backgroundTask { - let! cats = ctx.Data.Category.FindAllForView ctx.WebLog.Id - _cache[ctx.WebLog.Id] <- cats - } - - /// Refresh the category cache for the given web log + /// Refresh the category cache for the given web log + /// The ID of the web log for which the cache should be refreshed + /// The data implementation from which categories should be retrieved let refresh webLogId (data: IData) = backgroundTask { let! cats = data.Category.FindAllForView webLogId _cache[webLogId] <- cats } + /// Update the cache with fresh data for the current web log + /// The HttpContext for the current request + let update (ctx: HttpContext) = + refresh ctx.WebLog.Id ctx.Data -/// A cache of asset names by themes + +/// A cache of asset names by themes module ThemeAssetCache = /// A list of asset names for each theme let private _cache = ConcurrentDictionary() - /// Retrieve the assets for the given theme ID + /// Retrieve the assets for the given theme ID + /// The ID of the theme whose assets should be returned + /// The assets for the given theme let get themeId = _cache[themeId] - /// Refresh the list of assets for the given theme + /// Refresh the list of assets for the given theme + /// The ID of the theme whose assets should be refreshed + /// The data implementation from which assets should be retrieved let refreshTheme themeId (data: IData) = backgroundTask { let! assets = data.ThemeAsset.FindByTheme themeId _cache[themeId] <- assets |> List.map (fun a -> match a.Id with ThemeAssetId (_, path) -> path) } - /// Fill the theme asset cache + /// Fill the theme asset cache + /// The data implementation from which assets should be retrieved let fill (data: IData) = backgroundTask { let! assets = data.ThemeAsset.All() for asset in assets do diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 42064ea..ddefdc9 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -33,14 +33,14 @@ - + - - + + diff --git a/src/MyWebLog/Template.fs b/src/MyWebLog/Template.fs index 568203c..0c98d4c 100644 --- a/src/MyWebLog/Template.fs +++ b/src/MyWebLog/Template.fs @@ -1,3 +1,4 @@ +/// Logic to work with Fluid templates module MyWebLog.Template open System @@ -16,23 +17,29 @@ open MyWebLog.ViewModels type VTask<'T> = System.Threading.Tasks.ValueTask<'T> -/// Extensions on Fluid's TemplateContext object +/// Extensions on Fluid's TemplateContext object type TemplateContext with - - /// Get the model of the context as an AppViewContext instance + + /// Get the model of the context as an AppViewContext instance member this.App = this.Model.ToObjectValue() :?> AppViewContext -/// Helper functions for filters and tags +/// Helper functions for filters and tags [] module private Helpers = - - /// Does an asset exist for the current theme? + + /// Does an asset exist for the current theme? + /// The name of the asset + /// The current web log + /// True if the theme has the requested asset name, false if not let assetExists fileName (webLog: WebLog) = ThemeAssetCache.get webLog.ThemeId |> List.exists (fun it -> it = fileName) - /// Obtain the link from known types + /// Obtain the link from known types + /// The FluidValue for the given parameter + /// The function to extract the value of the link into a string + /// The link as a string, or JavaScript to show an alert if a link cannot be determined let permalink (item: FluidValue) (linkFunc: Permalink -> string) = match item.Type with | FluidValues.String -> Some (item.ToStringValue()) @@ -47,16 +54,20 @@ module private Helpers = | Some link -> linkFunc (Permalink link) | None -> $"alert('unknown item type {item.Type}')" - /// Generate a link for theme asset (image, stylesheet, script, etc.) + /// Generate a link for theme asset (image, stylesheet, script, etc.) + /// The name of the theme asset + /// The template context for the current template rendering + /// A relative URL for the given theme asset let themeAsset (input: FluidValue) (ctx: TemplateContext) = let app = ctx.App app.WebLog.RelativeUrl(Permalink $"themes/{app.WebLog.ThemeId}/{input.ToStringValue()}") -/// Fluid template options customized with myWebLog filters +/// Fluid template options customized with myWebLog filters +/// A TemplateOptions instance with all myWebLog filters and types registered let options () = let sValue = StringValue >> VTask - + let it = TemplateOptions.Default it.MemberAccessStrategy.MemberNameStrategy <- MemberNameStrategies.SnakeCase [ // Domain types @@ -69,10 +80,10 @@ let options () = typeof; typeof; typeof; typeof typeof; typeof; typeof; typeof ] |> List.iter it.MemberAccessStrategy.Register - + // A filter to generate an absolute link it.Filters.AddFilter("absolute_link", fun input _ ctx -> sValue (permalink input ctx.App.WebLog.AbsoluteUrl)) - + // A filter to generate a link with posts categorized under the given category it.Filters.AddFilter("category_link", fun input _ ctx -> @@ -84,7 +95,7 @@ let options () = | Some slug -> ctx.App.WebLog.RelativeUrl(Permalink $"category/{slug}/") | None -> $"alert('unknown category object type {input.Type}')" |> sValue) - + // A filter to generate a link that will edit a page it.Filters.AddFilter("edit_page_link", fun input _ ctx -> @@ -96,7 +107,7 @@ let options () = | Some pageId -> ctx.App.WebLog.RelativeUrl(Permalink $"admin/page/{pageId}/edit") | None -> $"alert('unknown page object type {input.Type}')" |> sValue) - + // A filter to generate a link that will edit a post it.Filters.AddFilter("edit_post_link", fun input _ ctx -> @@ -108,7 +119,7 @@ let options () = | Some postId -> ctx.App.WebLog.RelativeUrl(Permalink $"admin/post/{postId}/edit") | None -> $"alert('unknown post object type {input.Type}')" |> sValue) - + // A filter to generate nav links, highlighting the active link (starts-with match) it.Filters.AddFilter("nav_link", fun input args ctx -> @@ -127,10 +138,10 @@ let options () = } |> String.concat "" |> sValue) - + // A filter to generate a relative link it.Filters.AddFilter("relative_link", fun input _ ctx -> sValue (permalink input ctx.App.WebLog.RelativeUrl)) - + // A filter to generate a link with posts tagged with the given tag it.Filters.AddFilter("tag_link", fun input _ ctx -> @@ -142,10 +153,10 @@ let options () = | None -> tag.Replace(" ", "+") |> function tagUrl -> ctx.App.WebLog.RelativeUrl(Permalink $"tag/{tagUrl}/") |> sValue) - + // A filter to generate a link for theme asset (image, stylesheet, script, etc.) it.Filters.AddFilter("theme_asset", fun input _ ctx -> sValue (themeAsset input ctx)) - + // A filter to retrieve the value of a meta item from a list // (shorter than `{% assign item = list | where: "Name", [name] | first %}{{ item.value }}`) it.Filters.AddFilter("value", @@ -153,34 +164,34 @@ let options () = let name = args.At(0).ToStringValue() let picker (value: FluidValue) = let item = value.ToObjectValue() :?> MetaItem - if item.Name = name then Some item.Value else None + if item.Name = name then Some item.Value else None (input :?> ArrayValue).Values |> Seq.tryPick picker |> Option.defaultValue $"-- {name} not found --" |> sValue) - - it - -/// Fluid parser customized with myWebLog filters and tags + it + + +/// Fluid parser customized with myWebLog filters and tags let parser = // spacer let s = " " // Required return for tag delegates let ok () = VTask Fluid.Ast.Completion.Normal - + let it = FluidParser() - + // Create various items in the page header based on the state of the page being generated it.RegisterEmptyTag("page_head", fun writer encoder context -> let app = context.App // let getBool name = // defaultArg (context.Environments[0].[name] |> Option.ofObj |> Option.map Convert.ToBoolean) false - + writer.WriteLine $"""{s}""" - + // Theme assets if assetExists "style.css" app.WebLog then themeAsset (StringValue "style.css") context @@ -190,37 +201,37 @@ let parser = themeAsset (StringValue "favicon.ico") context |> sprintf "%s" s |> writer.WriteLine - + // RSS feeds and canonical URLs let feedLink title url = let escTitle = System.Web.HttpUtility.HtmlAttributeEncode title let relUrl = app.WebLog.RelativeUrl(Permalink url) $"""{s}""" - + if app.WebLog.Rss.IsFeedEnabled && app.IsHome then writer.WriteLine(feedLink app.WebLog.Name app.WebLog.Rss.FeedName) writer.WriteLine $"""{s}""" - + if app.WebLog.Rss.IsCategoryEnabled && app.IsCategoryHome then let slug = context.AmbientValues["slug"] :?> string writer.WriteLine(feedLink app.WebLog.Name $"category/{slug}/{app.WebLog.Rss.FeedName}") - + if app.WebLog.Rss.IsTagEnabled && app.IsTagHome then let slug = context.AmbientValues["slug"] :?> string writer.WriteLine(feedLink app.WebLog.Name $"tag/{slug}/{app.WebLog.Rss.FeedName}") - + if app.IsPost then let post = (* context.Environments[0].["model"] *) obj() :?> PostDisplay let url = app.WebLog.AbsoluteUrl(Permalink post.Posts[0].Permalink) writer.WriteLine $"""{s}""" - + if app.IsPage then let page = (* context.Environments[0].["page"] *) obj() :?> DisplayPage let url = app.WebLog.AbsoluteUrl(Permalink page.Permalink) writer.WriteLine $"""{s}""" - + ok ()) - + // Create various items in the page footer based on the state of the page being generated it.RegisterEmptyTag("page_foot", fun writer encoder context -> @@ -232,7 +243,7 @@ let parser = |> sprintf "%s" s |> writer.WriteLine ok ()) - + // Create links for a user to log on or off, and a dashboard link if they are logged off it.RegisterEmptyTag("user_links", fun writer encoder ctx -> @@ -250,21 +261,27 @@ let parser = } |> Seq.iter writer.WriteLine ok()) - + it open MyWebLog.Data -/// Cache for parsed templates +/// Cache for parsed templates module Cache = - + open System.Collections.Concurrent - + /// Cache of parsed templates let private _cache = ConcurrentDictionary () - - /// Get a template for the given theme and template name + + /// Get a template for the given theme and template name + /// The ID of the theme for which a template should be retrieved + /// The name of the template to retrieve + /// The data implementation from which the template should be retrieved (if not cached) + /// + /// An Ok result with the template if it is found and valid, an Error result if not + /// let get (themeId: ThemeId) (templateName: string) (data: IData) = backgroundTask { let templatePath = $"{themeId}/{templateName}" match _cache.ContainsKey templatePath with @@ -280,32 +297,34 @@ module Cache = return Error $"Theme ID {themeId} does not have a template named {templateName}" | None -> return Error $"Theme ID {themeId} does not exist" } - - /// Get all theme/template names currently cached + + /// Get all theme/template names currently cached + /// All theme/template names current cached let allNames () = _cache.Keys |> Seq.sort |> Seq.toList - - /// Invalidate all template cache entries for the given theme ID + + /// Invalidate all template cache entries for the given theme ID + /// The ID of the theme whose cache should be invalidated let invalidateTheme (themeId: ThemeId) = let keyPrefix = string themeId _cache.Keys |> Seq.filter _.StartsWith(keyPrefix) |> List.ofSeq |> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ()) - - /// Remove all entries from the template cache + + /// Remove all entries from the template cache let empty () = _cache.Clear() -/// A file provider to retrieve files by theme +/// A file provider to retrieve files by theme type ThemeFileProvider(themeId: ThemeId, data: IData) = - + interface IFileProvider with - + member _.GetDirectoryContents _ = raise <| NotImplementedException "The theme file provider does not support directory listings" - + member _.GetFileInfo path = match data.Theme.FindById themeId |> Async.AwaitTask |> Async.RunSynchronously with | Some theme -> @@ -322,12 +341,16 @@ type ThemeFileProvider(themeId: ThemeId, data: IData) = new MemoryStream(Encoding.UTF8.GetBytes template.Text) } | None -> NotFoundFileInfo path | None -> NotFoundFileInfo path - + member _.Watch _ = raise <| NotImplementedException "The theme file provider does not support watching for changes" -/// Render a template to a string +/// Render a template to a string +/// The template to be rendered +/// The app context for rendering this template +/// The data implementation to use if required +/// The rendered template as a string let render (template: IFluidTemplate) (viewCtx: AppViewContext) data = let opts = options () opts.FileProvider <- ThemeFileProvider(viewCtx.WebLog.ThemeId, data) diff --git a/src/MyWebLog/ViewContext.fs b/src/MyWebLog/ViewContext.fs index 5e80df1..1f2ddbe 100644 --- a/src/MyWebLog/ViewContext.fs +++ b/src/MyWebLog/ViewContext.fs @@ -1,102 +1,104 @@ -/// View rendering context for myWebLog +/// View rendering context for myWebLog [] module MyWebLog.ViewContext open Microsoft.AspNetCore.Antiforgery open MyWebLog.ViewModels -/// The rendering context for this application +/// The rendering context for this application [] type AppViewContext = { - /// The web log for this request + /// The web log for this request WebLog: WebLog - - /// The ID of the current user + + /// The ID of the current user UserId: WebLogUserId option - - /// The title of the page being rendered + + /// The title of the page being rendered PageTitle: string - - /// The subtitle for the page + + /// The subtitle for the page Subtitle: string option - - /// The anti-Cross Site Request Forgery (CSRF) token set to use when rendering a form + + /// The anti-Cross Site Request Forgery (CSRF) token set to use when rendering a form Csrf: AntiforgeryTokenSet option - - /// The page list for the web log + + /// The page list for the web log PageList: DisplayPage array - - /// Categories and post counts for the web log + + /// Categories and post counts for the web log Categories: DisplayCategory array - - /// Tag mappings + + /// Tag mappings TagMappings: TagMap array - - /// The URL of the page being rendered + + /// The URL of the page being rendered CurrentPage: string - - /// User messages + + /// User messages Messages: UserMessage array - - /// The generator string for the rendered page + + /// The generator string for the rendered page Generator: string - - /// The payload for this page (see other properties that wrap this one) + + /// The payload for this page (see other properties that wrap this one) Payload: obj - - /// The content of a page (wrapped when rendering the layout) + + /// The content of a page (wrapped when rendering the layout) Content: string - - /// A string to load the minified htmx script + + /// A string to load the minified htmx script HtmxScript: string - - /// Whether the current user is an author + + /// Whether the current user is an author IsAuthor: bool - - /// Whether the current user is an editor (implies author) + + /// Whether the current user is an editor (implies author) IsEditor: bool - - /// Whether the current user is a web log administrator (implies author and editor) + + /// Whether the current user is a web log administrator (implies author and editor) IsWebLogAdmin: bool - - /// Whether the current user is an installation administrator (implies all web log rights) + + /// Whether the current user is an installation administrator (implies all web log rights) IsAdministrator: bool - - /// Whether the current page is the home page of the web log + + /// Whether the current page is the home page of the web log IsHome: bool - - /// Whether the current page is a category archive page + + /// Whether the current page is a category archive page IsCategory: bool - - /// Whether the current page is a category archive home page + + /// Whether the current page is a category archive home page IsCategoryHome: bool - - /// Whether the current page is a tag archive page + + /// Whether the current page is a tag archive page IsTag: bool - - /// Whether the current page is a tag archive home page + + /// Whether the current page is a tag archive home page IsTagHome: bool - - /// Whether the current page is a single post + + /// Whether the current page is a single post IsPost: bool - - /// Whether the current page is a static page + + /// Whether the current page is a static page IsPage: bool - - /// The slug for a category or tag - Slug: string option } -with - - /// Whether there is a user logged on + + /// The slug for a category or tag + Slug: string option +} with + + /// Whether there is a user logged on member this.IsLoggedOn = Option.isSome this.UserId - + + /// The payload for this page as a DisplayPage member this.Page = this.Payload :?> DisplayPage - + + /// The payload for this page as a PostDisplay member this.Posts = this.Payload :?> PostDisplay - - /// An empty view context + + /// An empty view context static member Empty = { WebLog = WebLog.Empty UserId = None @@ -105,21 +107,21 @@ with Csrf = None PageList = [||] Categories = [||] - TagMappings = [||] + TagMappings = [||] CurrentPage = "" Messages = [||] Generator = "" - Payload = obj () - Content = "" + Payload = obj () + Content = "" HtmxScript = "" IsAuthor = false IsEditor = false IsWebLogAdmin = false IsAdministrator = false IsHome = false - IsCategory = false + IsCategory = false IsCategoryHome = false - IsTag = false + IsTag = false IsTagHome = false IsPost = false IsPage = false diff --git a/src/MyWebLog/Views/Admin.fs b/src/MyWebLog/Views/Admin.fs index 7226f4b..7718cbe 100644 --- a/src/MyWebLog/Views/Admin.fs +++ b/src/MyWebLog/Views/Admin.fs @@ -6,7 +6,10 @@ open Giraffe.ViewEngine.Htmx open MyWebLog open MyWebLog.ViewModels -/// The administrator dashboard +/// The administrator dashboard +/// The themes to display +/// The view context +/// The admin dashboard view let dashboard (themes: Theme list) app = [ let templates = Template.Cache.allNames () let cacheBaseUrl = relUrl app "admin/cache/" @@ -55,7 +58,7 @@ let dashboard (themes: Theme list) app = [ ] div [ _class "row" ] [ div [ _class "col-12 col-lg-6 pb-3" ] [ - div [ _class "card" ] [ + div [ _class "card" ] [ header [ _class "card-header text-white bg-secondary" ] [ raw "Web Logs" ] div [ _class "card-body pb-0" ] [ h6 [ _class "card-subtitle text-muted pb-3" ] [ diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 8740b7e..7f91af2 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -1,3 +1,4 @@ +/// Helpers available for all myWebLog views [] module MyWebLog.Views.Helpers @@ -9,28 +10,35 @@ open MyWebLog.ViewModels open NodaTime open NodaTime.Text -/// Create a relative URL for the current web log +/// Create a relative URL for the current web log +/// The app view context for the current view +/// A function that, given a string, will construct a relative URL let relUrl app = Permalink >> app.WebLog.RelativeUrl -/// Add a hidden input with the anti-Cross Site Request Forgery (CSRF) token +/// Create a hidden input with the anti-Cross Site Request Forgery (CSRF) token +/// The app view context for the current view +/// A hidden input with the CSRF token value let antiCsrf app = input [ _type "hidden"; _name app.Csrf.Value.FormFieldName; _value app.Csrf.Value.RequestToken ] -/// Shorthand for encoded text in a template +/// Shorthand for encoded text in a template let txt = encodedText -/// Shorthand for raw text in a template +/// Shorthand for raw text in a template let raw = rawText -/// Rel attribute to prevent opener information from being provided to the new window +/// rel attribute to prevent opener information from being provided to the new window let _relNoOpener = _rel "noopener" -/// The pattern for a long date +/// The pattern for a long date let longDatePattern = ZonedDateTimePattern.CreateWithInvariantCulture("MMMM d, yyyy", DateTimeZoneProviders.Tzdb) -/// Create a long date +/// Create a long date +/// The app view context for the current view +/// The instant from which a localized long date should be produced +/// A text node with the long date let longDate app (instant: Instant) = DateTimeZoneProviders.Tzdb[app.WebLog.TimeZone] |> Option.ofObj @@ -38,11 +46,14 @@ let longDate app (instant: Instant) = |> Option.defaultValue "--" |> txt -/// The pattern for a short time +/// The pattern for a short time let shortTimePattern = ZonedDateTimePattern.CreateWithInvariantCulture("h:mmtt", DateTimeZoneProviders.Tzdb) -/// Create a short time +/// Create a short time +/// The app view context for the current view +/// The instant from which a localized short date should be produced +/// A text node with the short date let shortTime app (instant: Instant) = DateTimeZoneProviders.Tzdb[app.WebLog.TimeZone] |> Option.ofObj @@ -50,11 +61,19 @@ let shortTime app (instant: Instant) = |> Option.defaultValue "--" |> txt -/// Display "Yes" or "No" based on the state of a boolean value +/// Display "Yes" or "No" based on the state of a boolean value +/// The true/false value +/// A text node with Yes if true, No if false let yesOrNo value = raw (if value then "Yes" else "No") -/// Extract an attribute value from a list of attributes, remove that attribute if it is found +/// Extract an attribute value from a list of attributes, remove that attribute if it is found +/// The name of the attribute to be extracted and removed +/// The list of attributes to be searched +/// +/// A tuple with fst being Some with the attribute if found, None if not; and snd +/// being the list of attributes with the extracted one removed +/// let extractAttrValue name attrs = let valueAttr = attrs |> List.tryFind (fun x -> match x with KeyValue (key, _) when key = name -> true | _ -> false) match valueAttr with @@ -62,8 +81,15 @@ let extractAttrValue name attrs = Some value, attrs |> List.filter (fun x -> match x with KeyValue (key, _) when key = name -> false | _ -> true) | Some _ | None -> None, attrs - -/// Create a text input field + +/// Create a text input field +/// The input field type +/// Attributes for the field +/// The name of the input field +/// The text of the label element associated with this input +/// The value of the input field +/// Any extra elements to include after the input and label +/// A div element with the input field constructed let inputField fieldType attrs name labelText value extra = let fieldId, attrs = extractAttrValue "id" attrs let cssClass, attrs = extractAttrValue "class" attrs @@ -76,23 +102,58 @@ let inputField fieldType attrs name labelText value extra = yield! extra ] -/// Create a text input field +/// Create a text input field +/// Attributes for the field +/// The name of the input field +/// The text of the label element associated with this input +/// The value of the input field +/// Any extra elements to include after the input and label +/// A div element with the <input type=text> field constructed let textField attrs name labelText value extra = inputField "text" attrs name labelText value extra -/// Create a number input field +/// Create a number input field +/// Attributes for the field +/// The name of the input field +/// The text of the label element associated with this input +/// The value of the input field +/// Any extra elements to include after the input and label +/// A div element with the <input type=number> field constructed let numberField attrs name labelText value extra = inputField "number" attrs name labelText value extra -/// Create an e-mail input field +/// Create an e-mail input field +/// Attributes for the field +/// The name of the input field +/// The text of the label element associated with this input +/// The value of the input field +/// Any extra elements to include after the input and label +/// A div element with the <input type=email> field constructed let emailField attrs name labelText value extra = inputField "email" attrs name labelText value extra -/// Create a password input field +/// Create a password input field +/// Attributes for the field +/// The name of the input field +/// The text of the label element associated with this input +/// The value of the input field +/// Any extra elements to include after the input and label +/// A div element with the <input type=password> field constructed let passwordField attrs name labelText value extra = inputField "password" attrs name labelText value extra -/// Create a select (dropdown) field +/// Create a select (dropdown) field +/// The type of value in the backing list +/// The type of the value attribute +/// Attributes for the field +/// The name of the input field +/// The text of the label element associated with this input +/// The value of the input field +/// The backing list for this dropdown +/// The function to extract the ID (value attribute) +/// The function to extract the displayed version of the item +/// Any extra elements to include after the input and label +/// A div element with the <select> field constructed let selectField<'T, 'a> attrs name labelText value (values: 'T seq) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra = let cssClass, attrs = extractAttrValue "class" attrs @@ -106,7 +167,13 @@ let selectField<'T, 'a> yield! extra ] -/// Create a checkbox input styled as a switch +/// Create a checkbox input styled as a switch +/// Attributes for the field +/// The name of the input field +/// The text of the label element associated with this input +/// Whether the checkbox should be checked or not +/// Any extra elements to include after the input and label +/// A div element with the switch-style <input type=checkbox> field constructed let checkboxSwitch attrs name labelText (value: bool) extra = let cssClass, attrs = extractAttrValue "class" attrs div [ _class $"""form-check form-switch {defaultArg cssClass ""}""" ] [ @@ -117,17 +184,17 @@ let checkboxSwitch attrs name labelText (value: bool) extra = yield! extra ] -/// A standard save button +/// A standard save button let saveButton = button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ] -/// A spacer bullet to use between action links +/// A spacer bullet to use between action links let actionSpacer = span [ _class "text-muted" ] [ raw " • " ] -/// Functions for generating content in varying layouts +/// Functions for generating content in varying layouts module Layout = - + /// Generate the title tag for a page let private titleTag (app: AppViewContext) = title [] [ txt app.PageTitle; raw " « Admin « "; txt app.WebLog.Name ] @@ -221,15 +288,21 @@ module Layout = ] ] ] - - /// Render a page with a partial layout (htmx request) + + /// Render a page with a partial layout (htmx request) + /// A function that, when given a view context, will return a view + /// The app view context to use when rendering the view + /// A constructed Giraffe View Engine view let partial content app = html [ _lang "en" ] [ titleTag app yield! pageView content app ] - - /// Render a page with a full layout + + /// Render a page with a full layout + /// A function that, when given a view context, will return a view + /// The app view context to use when rendering the view + /// A constructed Giraffe View Engine view let full content app = html [ _lang "en" ] [ meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ] @@ -248,8 +321,11 @@ module Layout = script [ _src (relUrl app "themes/admin/admin.js") ] [] ] ] - - /// Render a bare layout + + /// Render a bare layout + /// A function that, when given a view context, will return a view + /// The app view context to use when rendering the view + /// A constructed Giraffe View Engine view let bare (content: AppViewContext -> XmlNode list) app = html [ _lang "en" ] [ title [] [] @@ -260,14 +336,17 @@ module Layout = // ~~ SHARED TEMPLATES BETWEEN POSTS AND PAGES open Giraffe.Htmx.Common -/// The round-trip instant pattern +/// The round-trip instant pattern let roundTrip = InstantPattern.CreateWithInvariantCulture "uuuu'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff" /// Capitalize the first letter in the given string let private capitalize (it: string) = $"{(string it[0]).ToUpper()}{it[1..]}" -/// The common edit form shared by pages and posts +/// The common edit form shared by pages and posts +/// The model to use to render this view +/// The app view context to use to render this view +/// A common edit view let commonEdit (model: EditCommonModel) app = [ textField [ _class "mb-3"; _required; _autofocus ] (nameof model.Title) "Title" model.Title [] textField [ _class "mb-3"; _required ] (nameof model.Permalink) "Permalink" model.Permalink [ @@ -301,13 +380,18 @@ let commonEdit (model: EditCommonModel) app = [ ] -/// Display a common template list +/// Display a common template list +/// The edit model +/// A list of available templates for this page or post +/// A <select> element to allow a template to be selected let commonTemplates (model: EditCommonModel) (templates: MetaItem seq) = selectField [ _class "mb-3" ] (nameof model.Template) $"{capitalize model.Entity} Template" model.Template templates - (_.Name) (_.Value) [] + _.Name _.Value [] -/// Display the metadata item edit form +/// Display the metadata item edit form +/// The edit model +/// A form for editing metadata let commonMetaItems (model: EditCommonModel) = let items = Array.zip model.MetaNames model.MetaValues let metaDetail idx (name, value) = @@ -320,7 +404,7 @@ let commonMetaItems (model: EditCommonModel) = div [ _class "col-3" ] [ textField [ _id $"MetaNames_{idx}" ] (nameof model.MetaNames) "Name" name [] ] div [ _class "col-8" ] [ textField [ _id $"MetaValues_{idx}" ] (nameof model.MetaValues) "Value" value [] ] ] - + fieldset [] [ legend [] [ raw "Metadata " @@ -342,7 +426,10 @@ let commonMetaItems (model: EditCommonModel) = ] -/// Revision preview template +/// Revision preview template +/// The revision to preview +/// The app view context to use when rendering the preview +/// A view with a revision preview let commonPreview (rev: Revision) app = div [ _class "mwl-revision-preview mb-3" ] [ rev.Text.AsHtml() |> addBaseToRelativeUrls app.WebLog.ExtraPath |> raw @@ -350,7 +437,10 @@ let commonPreview (rev: Revision) app = |> List.singleton -/// Form to manage permalinks for pages or posts +/// Form to manage permalinks for pages or posts +/// The manage permalinks model to be rendered +/// The app view context to use when rendering this view +/// A view for managing permalinks for a page or post let managePermalinks (model: ManagePermalinksModel) app = [ let baseUrl = relUrl app $"admin/{model.Entity}/" let linkDetail idx link = @@ -414,7 +504,10 @@ let managePermalinks (model: ManagePermalinksModel) app = [ ] ] -/// Form to manage revisions for pages or posts +/// Form to manage revisions for pages or posts +/// The manage revisions model to be rendered +/// The app view context to use when rendering this view +/// A view for managing revisions for a page or post let manageRevisions (model: ManageRevisionsModel) app = [ let revUrlBase = relUrl app $"admin/{model.Entity}/{model.Id}/revision" let revDetail idx (rev: Revision) =